From ac736ca72c3de19335b47c02795b8b44192f3f89 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 07:47:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E9=9B=86=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=AB=9E=E6=80=81=E4=B8=8E=E6=A8=A1=E5=9D=97=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冻结九路径恢复前态并让可信事务句柄贯穿提交与回滚 将无日志的旧图集残留收口为只读识别和人工对账 按符号大小写保留导入别名并为投影根绑定分配无冲突名称 补齐普通文件竞态、目录替换和模块命名冲突回归 同步图集事务与 ESM 投影技术约束 --- .../src/agent/generation/canvas_generation.rs | 339 +++++++++++------- .../runtime_protocol/autonomous_completion.rs | 138 ++++--- .../autonomous_completion_contract_tests.rs | 51 +++ .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- 5 files changed, 360 insertions(+), 174 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 038df501a..3b5fd94a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1542,12 +1542,20 @@ fn recover_interrupted_platform_art_replacement_at( output_path: &str, ) -> Result<(), String> { let target = resolve_local_project_path(root, output_path)?; - if target.exists() { + let Some(parent) = TrustedPlatformArtRecoveryParent::open_optional(root, &target, false)? + else { + return Ok(()); + }; + if !matches!( + parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "待恢复平台素材" + )?, + PlatformArtRecoveryFileState::Missing + ) { return Ok(()); } - let parent = target - .parent() - .ok_or_else(|| "待恢复平台素材缺少父目录".to_string())?; let file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1556,19 +1564,7 @@ fn recover_interrupted_platform_art_replacement_at( let replacement_prefix = format!(".{file_name}.replacement."); let mut previous = Vec::new(); let mut replacements = Vec::new(); - let entries = match fs::read_dir(parent) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "扫描中断的平台素材替换文件失败:{}: {error}", - parent.display() - )); - } - }; - for entry in entries { - let entry = entry.map_err(|error| format!("读取平台素材替换目录项失败:{error}"))?; - let name = entry.file_name(); + for name in parent.list_names()? { let Some(name) = name.to_str() else { continue; }; @@ -1584,16 +1580,22 @@ fn recover_interrupted_platform_art_replacement_at( if suffix.is_empty() { continue; } - let file_type = entry - .file_type() - .map_err(|error| format!("读取平台素材替换文件类型失败:{error}"))?; - if !file_type.is_file() { + let name = std::ffi::OsString::from(name); + let file = parent + .open_file(&name) + .map_err(|error| format!("锚定打开中断的平台素材替换文件失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted( + &file + .metadata() + .map_err(|error| format!("读取平台素材替换文件类型失败:{error}"))?, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + ) { return Err("中断的平台素材替换路径不是普通文件,已拒绝恢复".to_string()); } if is_previous { - previous.push((suffix.to_string(), entry.path())); + previous.push((suffix.to_string(), name, file)); } else { - replacements.push((suffix.to_string(), entry.path())); + replacements.push((suffix.to_string(), name)); } } if previous.is_empty() { @@ -1602,24 +1604,18 @@ fn recover_interrupted_platform_art_replacement_at( if previous.len() != 1 || replacements.len() > 1 { return Err("发现多组中断的平台素材替换文件,无法安全自动恢复".to_string()); } - let (previous_suffix, previous_path) = previous.pop().expect("one previous path exists"); + let (previous_suffix, _previous_name, _previous_file) = + previous.pop().expect("one previous path exists"); if replacements .first() .is_some_and(|(replacement_suffix, _)| replacement_suffix != &previous_suffix) { return Err("中断的平台素材 replacement/previous 标识不一致,已拒绝恢复".to_string()); } - move_platform_art_asset_without_replacing(&previous_path, &target) - .map_err(|error| format!("恢复中断替换的旧平台素材失败:{error}"))?; - if let Some((_, replacement_path)) = replacements.pop() { - fs::remove_file(&replacement_path).map_err(|error| { - format!( - "恢复旧平台素材后回收中断的新素材失败:{}: {error}", - replacement_path.display() - ) - })?; - } - Ok(()) + Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 检测到无 durable journal 的 legacy previous/replacement 残留;为避免 pathname 竞态未自动恢复 canonical 或删除残留:{}", + target.display() + )) } fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), String> { @@ -3876,13 +3872,30 @@ fn include_platform_art_recovery_current_item_after_install_error( fn restore_strict_platform_art_transaction_at_with_hook( root: &Path, transaction_directory: &Path, - mut before_apply: F, + before_apply: F, ) -> Result where F: FnMut(usize, &Path) -> Result<(), String>, { let trusted_transaction_directory = TrustedPlatformArtTransactionDirectory::open_anchored(root, transaction_directory)?; + restore_strict_platform_art_transaction_with_trusted_directory( + root, + transaction_directory, + trusted_transaction_directory, + before_apply, + ) +} + +fn restore_strict_platform_art_transaction_with_trusted_directory( + root: &Path, + transaction_directory: &Path, + trusted_transaction_directory: TrustedPlatformArtTransactionDirectory, + mut before_apply: F, +) -> Result +where + F: FnMut(usize, &Path) -> Result<(), String>, +{ let prepared_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); let committed_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_COMMITTED); if strict_platform_art_transaction_marker_exists( @@ -3997,9 +4010,35 @@ where for (canonical, _) in &recovery_plan { preflight_platform_art_recovery_target(root, canonical)?; } - let mut applied = Vec::with_capacity(recovery_plan.len()); + // Freeze the rollback expectation for the entire contract before the first + // canonical mutation. A later entry changed while an earlier entry is being + // restored must therefore fail its CAS instead of becoming the new rollback + // baseline and being silently overwritten. let mut total_rollback_bytes = 0_u64; - for (index, (canonical, desired)) in recovery_plan.into_iter().enumerate() { + let mut frozen_recovery_plan = Vec::with_capacity(recovery_plan.len()); + for (canonical, desired) in recovery_plan { + let remaining = + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES.saturating_sub(total_rollback_bytes); + let previous = read_platform_art_recovery_file_state_anchored( + root, + &canonical, + remaining, + "平台图集事务恢复前合同", + )?; + if let PlatformArtRecoveryFileState::Present(previous_bytes) = &previous { + total_rollback_bytes = match total_rollback_bytes + .checked_add(u64::try_from(previous_bytes.len()).unwrap_or(u64::MAX)) + { + Some(total) if total <= STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES => total, + _ => { + return Err("平台图集事务恢复前合同累计超过 64 MiB,已拒绝恢复".to_string()); + } + }; + } + frozen_recovery_plan.push((canonical, previous, desired)); + } + let mut applied = Vec::with_capacity(frozen_recovery_plan.len()); + for (index, (canonical, previous, desired)) in frozen_recovery_plan.into_iter().enumerate() { if let Err(error) = before_apply(index, &canonical) { return Err(platform_art_recovery_error_after_rollback( root, @@ -4016,39 +4055,6 @@ where error, )); } - let remaining = - STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES.saturating_sub(total_rollback_bytes); - let previous = match read_platform_art_recovery_file_state_anchored( - root, - &canonical, - remaining, - "平台图集事务恢复前合同", - ) { - Ok(previous) => previous, - Err(error) => { - return Err(platform_art_recovery_error_after_rollback( - root, - &applied, - &recovery_suffix, - error, - )); - } - }; - if let PlatformArtRecoveryFileState::Present(previous_bytes) = &previous { - total_rollback_bytes = match total_rollback_bytes - .checked_add(u64::try_from(previous_bytes.len()).unwrap_or(u64::MAX)) - { - Some(total) if total <= STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES => total, - _ => { - return Err(platform_art_recovery_error_after_rollback( - root, - &applied, - &recovery_suffix, - "平台图集事务恢复前合同累计超过 64 MiB,已拒绝恢复".to_string(), - )); - } - }; - } if let Err(error) = install_platform_art_recovery_state_cas( root, &canonical, @@ -4178,6 +4184,7 @@ impl PlatformArtAssetGenerationOptions { struct PlatformArtSliceContractRollback { root: PathBuf, transaction_directory: PathBuf, + trusted_transaction_directory: Option, armed: bool, } @@ -4512,24 +4519,22 @@ impl PlatformArtSliceContractRollback { .map(|cleanup_error| format!("{error};{cleanup_error}")) .unwrap_or(error)); } - drop(trusted_transaction_directory); Ok(Self { root: root.to_path_buf(), transaction_directory, + trusted_transaction_directory: Some(trusted_transaction_directory), armed: true, }) } fn commit(&mut self) -> Result, String> { sync_strict_platform_art_contract_state_at(&self.root, true)?; - let trusted_transaction_directory = - TrustedPlatformArtTransactionDirectory::open_anchored( - &self.root, - &self.transaction_directory, - ) - .map_err(|error| { + let trusted_transaction_directory = self + .trusted_transaction_directory + .as_ref() + .ok_or_else(|| { format!( - "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同待提交,但锚定事务目录失败:{error}" + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同待提交,但缺少 prepared 阶段锚定的事务目录句柄" ) })?; trusted_transaction_directory.publish_marker( @@ -4561,13 +4566,11 @@ impl PlatformArtSliceContractRollback { )); } } - sync_platform_art_directory(&self.transaction_directory, "平台图集已提交事务").map_err( - |error| { + trusted_transaction_directory.handle.sync_all().map_err(|error| { format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}" ) - }, - )?; + })?; cleanup_interrupted_platform_art_contract_files_at(&self.root).map_err(|error| { format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但清理原子替换残留失败:{error}" @@ -4578,6 +4581,10 @@ impl PlatformArtSliceContractRollback { "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步残留清理结果失败:{error}" ) })?; + let trusted_transaction_directory = self + .trusted_transaction_directory + .take() + .expect("prepared transaction handle remains available through commit"); Ok( remove_trusted_platform_art_transaction_directory(trusted_transaction_directory) .err() @@ -4589,8 +4596,21 @@ impl PlatformArtSliceContractRollback { if !self.armed { return Ok(()); } - restore_strict_platform_art_transaction_at(&self.root, &self.transaction_directory)?; + let trusted_transaction_directory = self + .trusted_transaction_directory + .take() + .ok_or_else(|| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同待回滚,但缺少 prepared 阶段锚定的事务目录句柄" + ) + })?; self.armed = false; + restore_strict_platform_art_transaction_with_trusted_directory( + &self.root, + &self.transaction_directory, + trusted_transaction_directory, + |_, _| Ok(()), + )?; Ok(()) } } @@ -7148,7 +7168,7 @@ mod canvas_generation_tests { fs::write(path, format!("new-partial-contract-{index}")) .expect("write partially committed contract"); } - std::mem::forget(transaction); + drop(transaction); drop(project_lock); let forged_same_process_lock = root.join(".agent/project.lock"); @@ -7240,7 +7260,7 @@ mod canvas_generation_tests { )); fs::write(last_snapshot, b"corrupted-last-snapshot") .expect("corrupt the final durable snapshot"); - std::mem::forget(transaction); + drop(transaction); let error = recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock) @@ -7294,7 +7314,7 @@ mod canvas_generation_tests { fs::write(&path, &bytes).expect("write partial canonical contract"); partial_contract.push((path, bytes)); } - std::mem::forget(transaction); + drop(transaction); let error = recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock) @@ -7555,7 +7575,7 @@ mod canvas_generation_tests { .expect("write partial contract"); } let transaction_directory = transaction.transaction_directory.clone(); - std::mem::forget(transaction); + drop(transaction); let first = root.join(STRICT_PLATFORM_ART_CONTRACT_PATHS[0]); let error = restore_strict_platform_art_transaction_at_with_hook( @@ -7806,7 +7826,7 @@ mod canvas_generation_tests { fs::write(&path, &bytes).expect("write partial canonical contract"); partial_contract.push((path, bytes)); } - std::mem::forget(transaction); + drop(transaction); let late_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; let late_path = partial_contract[late_index].0.clone(); @@ -7844,6 +7864,99 @@ mod canvas_generation_tests { assert!(transaction_directory.exists()); } + #[test] + fn durable_strict_contract_transaction_rejects_late_regular_file_change_from_frozen_state() { + let temporary = tempfile::tempdir().expect("create late regular-file race project"); + let root = temporary.path(); + init_local_game_project_at(root, "late-file-race", "图集恢复普通文件晚序竞态测试") + .expect("init project"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(path, format!("old-contract-{index}")).expect("write original contract"); + } + + let transaction = PlatformArtSliceContractRollback::capture(root, "late-file-race") + .expect("persist complete contract snapshot"); + let transaction_directory = transaction.transaction_directory.clone(); + let mut partial_contract = Vec::new(); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + let bytes = format!("partial-new-contract-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write partial canonical contract"); + partial_contract.push((path, bytes)); + } + drop(transaction); + + let late_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; + let late_path = partial_contract[late_index].0.clone(); + let concurrent_bytes = b"concurrent-ordinary-file".to_vec(); + let error = restore_strict_platform_art_transaction_at_with_hook( + root, + &transaction_directory, + |index, canonical| { + if index == late_index { + fs::write(canonical, &concurrent_bytes) + .map_err(|error| format!("mutate late ordinary target: {error}"))?; + } + Ok(()) + }, + ) + .expect_err("late ordinary-file change must fail the frozen CAS"); + + assert!( + error.contains("CAS 目标已被并发修改"), + "unexpected error: {error}" + ); + for (path, expected) in partial_contract.iter().take(late_index) { + assert_eq!( + fs::read(path).expect("read rolled-back partial contract"), + *expected, + "late ordinary-file conflict must roll back earlier recovery at {}", + path.display() + ); + } + assert_eq!( + fs::read(&late_path).expect("read concurrent late target"), + concurrent_bytes, + "rollback must not overwrite the late ordinary-file change" + ); + assert!(transaction_directory.exists()); + } + + #[cfg(unix)] + #[test] + fn prepared_transaction_keeps_original_directory_handle_through_live_restore() { + let temporary = tempfile::tempdir().expect("create live transaction handle project"); + let root = temporary.path(); + init_local_game_project_at(root, "live-handle", "prepared 事务句柄测试") + .expect("init project"); + let mut transaction = PlatformArtSliceContractRollback::capture(root, "live-handle") + .expect("capture prepared transaction"); + let transaction_directory = transaction.transaction_directory.clone(); + let displaced = transaction_directory.with_file_name("transaction-displaced"); + fs::rename(&transaction_directory, &displaced) + .expect("displace prepared transaction directory"); + fs::create_dir(&transaction_directory).expect("create pathname substitute"); + fs::write( + transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED), + b"prepared\n", + ) + .expect("write substitute prepared marker"); + + let error = transaction + .restore() + .expect_err("live rollback must remain bound to the prepared directory identity"); + assert!( + error.contains("目录身份发生变化"), + "unexpected error: {error}" + ); + assert!(transaction_directory + .join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED) + .exists()); + } + #[test] fn durable_strict_contract_transaction_rollback_preserves_concurrently_changed_installed_target( ) { @@ -7871,7 +7984,7 @@ mod canvas_generation_tests { (path, bytes) }) .collect::>(); - std::mem::forget(transaction); + drop(transaction); let late_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; let first_path = partial_contract[0].0.clone(); @@ -8015,7 +8128,7 @@ mod canvas_generation_tests { } sync_platform_art_directory(&transaction.transaction_directory, "测试事务") .expect("sync committed transaction"); - std::mem::forget(transaction); + drop(transaction); drop(project_lock); let project_lock = acquire_project_write_lock(root, "canvas.asset_generate.recover-test") @@ -8061,7 +8174,7 @@ mod canvas_generation_tests { "测试不完整 committed marker", ) .expect("persist empty marker fixture"); - std::mem::forget(transaction); + drop(transaction); let error = recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock) @@ -8135,7 +8248,7 @@ mod canvas_generation_tests { } #[test] - fn strict_slice_recovery_restores_missing_main_then_finishes_the_same_remote_result() { + fn strict_slice_legacy_missing_main_recovery_requires_reconciliation() { let temporary = tempfile::tempdir().expect("create missing-main recovery project"); let root = temporary.path(); init_local_game_project_at(root, "strict-missing-main", "严格主图缺失恢复测试") @@ -8147,38 +8260,16 @@ mod canvas_generation_tests { fs::rename(&target, &previous).expect("simulate crash after backing up main image"); fs::write(&replacement, b"new-image").expect("write staged remote result"); - recover_interrupted_platform_art_replacement_at(root, "assets/art-spritesheet.png") - .expect("restore the canonical old image before retrying the local commit"); + let recovery_error = + recover_interrupted_platform_art_replacement_at(root, "assets/art-spritesheet.png") + .expect_err( + "safe legacy recovery must surface retained residue for reconciliation", + ); + assert!(recovery_error.contains(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)); - assert_eq!( - fs::read(&target).expect("read restored main"), - b"temporary-old-image" - ); - assert!(!previous.exists()); - assert!(!replacement.exists()); - let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); - prepared.recover_existing_outputs = true; - let mut authorization_count = 0usize; - let generated = commit_prepared_platform_art_asset_strict_slices_at( - root, - prepared, - &replacement_options(), - |_| { - authorization_count += 1; - Ok(()) - }, - ) - .expect("finish the accepted remote result after restoring the interrupted backup"); - - assert_eq!(authorization_count, 1); - assert_eq!( - fs::read(&target).expect("read committed main"), - b"new-image" - ); - assert_eq!(generated.slices.len(), 4); - assert!(root - .join(".agent/runtime/art-spritesheet-contract.json") - .is_file()); + assert!(!target.exists()); + assert!(previous.exists()); + assert!(replacement.exists()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 39a99fb88..dd7f95cc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -2763,7 +2763,7 @@ fn javascript_source_type(is_module: bool) -> JavascriptSourceType { } } -fn javascript_is_syntactically_valid(content: &str, is_module: bool) -> bool { +pub(in crate::agent) fn javascript_is_syntactically_valid(content: &str, is_module: bool) -> bool { let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); @@ -3495,6 +3495,7 @@ struct JavascriptModuleAnalysis { static_sources: Vec, import_declaration_ranges: Vec>, imports: Vec<(String, Vec<(String, String)>)>, + root_bindings: BTreeSet, used_import_locals: BTreeSet, import_reference_spans: BTreeMap>, namespace_import_members: BTreeMap>>>, @@ -3571,7 +3572,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { for specifier in declaration.specifiers.iter().flatten() { match specifier { JavascriptImportDeclarationSpecifier::ImportSpecifier(specifier) => { - let local = specifier.local.name.to_string().to_ascii_lowercase(); + let local = specifier.local.name.to_string(); bindings.push(( javascript_module_export_name(&specifier.imported), local.clone(), @@ -3588,7 +3589,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { } } JavascriptImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { - let local = specifier.local.name.to_string().to_ascii_lowercase(); + let local = specifier.local.name.to_string(); bindings.push(("default".to_string(), local.clone())); if let Some(symbol_id) = specifier.local.symbol_id.get() { self.import_symbols.push((local, symbol_id)); @@ -3597,7 +3598,7 @@ impl<'a> VisitJavascript<'a> for JavascriptModuleAnalysisCollector { } } JavascriptImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - let local = specifier.local.name.to_string().to_ascii_lowercase(); + let local = specifier.local.name.to_string(); bindings.push(("*".to_string(), local.clone())); if let Some(symbol_id) = specifier.local.symbol_id.get() { self.import_symbols.push((local.clone(), symbol_id)); @@ -3878,6 +3879,13 @@ fn javascript_module_analysis(content: &str, is_module: bool) -> Option std::collections::BTreeMa .map(|offset| cursor + offset + 1) .or_else(|| content[cursor..].find('\n').map(|offset| cursor + offset)) .unwrap_or(content.len()); - declarations - .entry(name) - .or_insert_with(|| content[start..end].to_string()); + declarations.insert(name, content[start..end].to_string()); } } declarations @@ -4114,6 +4120,24 @@ pub(in crate::agent) fn apply_javascript_span_replacements( true } +fn unique_javascript_projection_binding_name( + preferred: &str, + assigned: &BTreeSet, + remaining_projection: &BTreeSet, +) -> String { + let preferred = preferred.to_ascii_lowercase(); + if !assigned.contains(&preferred) && !remaining_projection.contains(&preferred) { + return preferred; + } + for index in 1_u64.. { + let candidate = format!("{preferred}__agc_import_{index}"); + if !assigned.contains(&candidate) && !remaining_projection.contains(&candidate) { + return candidate; + } + } + unreachable!("a monotonically suffixed JavaScript binding name must become unique") +} + pub(in crate::agent) fn rename_javascript_root_binding( content: &mut String, from: &str, @@ -4533,6 +4557,12 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( } } } + let mut unit_root_bindings = importer_analysis.root_bindings.clone(); + for (_, bindings) in &importer_analysis.imports { + for (_, local) in bindings { + unit_root_bindings.remove(&local.to_ascii_lowercase()); + } + } for (origin, bindings) in origins { let Some(origin_content) = module_contents.get(&origin) else { continue; @@ -4542,29 +4572,55 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .map(|(exported, _, _)| exported.to_ascii_lowercase()) .collect::>(); let mut projection = javascript_module_binding_projection(origin_content, &used_names); - let mut synthetic_aliases = BTreeMap::::new(); - for (exported, local, namespace) in &bindings { - let origin_local = module_analyses - .get(&origin) - .and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase())) - .and_then(|target| match target { - JavascriptExportTarget::Local(name) => Some(name.as_str()), - JavascriptExportTarget::Reexport { .. } => None, - }) - .unwrap_or(exported.as_str()); - if namespace.is_none() && origin_local == "__agc_default_export__" { - synthetic_aliases - .entry(origin_local.to_string()) - .or_insert_with(|| local.clone()); - } + if projection.is_empty() { + continue; } - for (synthetic, canonical) in &synthetic_aliases { - if !rename_javascript_root_binding(&mut projection, synthetic, canonical) { + let projection_analysis = javascript_module_analysis(&projection, true) + .ok_or_else(|| format!("自主构建模块投影不是有效 JavaScript:{origin}"))?; + let preferred_synthetic_alias = + bindings.iter().find_map(|(exported, local, namespace)| { + let origin_local = module_analyses + .get(&origin) + .and_then(|analysis| analysis.exports.get(&exported.to_ascii_lowercase())) + .and_then(|target| match target { + JavascriptExportTarget::Local(name) => Some(name.as_str()), + JavascriptExportTarget::Reexport { .. } => None, + }) + .unwrap_or(exported.as_str()); + (namespace.is_none() && origin_local == "__agc_default_export__") + .then(|| local.to_ascii_lowercase()) + }); + let mut remaining_projection = projection_analysis.root_bindings.clone(); + let mut assigned_bindings = unit_root_bindings.clone(); + let mut projected_binding_names = BTreeMap::::new(); + for original in &projection_analysis.root_bindings { + remaining_projection.remove(original); + let preferred = if original == "__agc_default_export__" { + preferred_synthetic_alias.as_deref().unwrap_or(original) + } else { + original + }; + let canonical = unique_javascript_projection_binding_name( + preferred, + &assigned_bindings, + &remaining_projection, + ); + assigned_bindings.insert(canonical.clone()); + projected_binding_names.insert(original.clone(), canonical); + } + for (original, canonical) in &projected_binding_names { + if original != canonical + && !rename_javascript_root_binding(&mut projection, original, canonical) + { return Err(format!( - "自主构建模块匿名 default 投影绑定无法按符号重命名:{origin}::{synthetic} -> {canonical}" + "自主构建模块投影绑定无法按符号重命名:{origin}::{original} -> {canonical}" )); } } + if javascript_module_analysis(&projection, true).is_none() { + return Err(format!("自主构建模块投影重命名后语义无效:{origin}")); + } + unit_root_bindings = assigned_bindings; for (exported, local, namespace) in bindings { let origin_local = module_analyses .get(&origin) @@ -4574,10 +4630,11 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( JavascriptExportTarget::Reexport { .. } => None, }) .unwrap_or(exported.as_str()); - let projected_local = synthetic_aliases - .get(origin_local) + let projected_local = projected_binding_names + .get(&origin_local.to_ascii_lowercase()) .map(String::as_str) .unwrap_or(origin_local); + let normalized_local = local.to_ascii_lowercase(); if let Some(namespace) = namespace { if let Some(ranges) = importer_analysis .namespace_import_members @@ -4591,37 +4648,22 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .map(|range| (range, projected_local.to_string())), ); } - } else if origin_local == "__agc_default_export__" { - if projected_local != local { - if let Some(spans) = importer_analysis.import_reference_spans.get(&local) { - unit_replacements.extend(spans.iter().map(|span| { - let replacement = if span.shorthand { - format!("{local}: {projected_local}") - } else { - projected_local.to_string() - }; - (span.range.clone(), replacement) - })); - } - } - } else if origin_local != local { + } else if projected_local != normalized_local { if let Some(spans) = importer_analysis.import_reference_spans.get(&local) { unit_replacements.extend(spans.iter().map(|span| { let replacement = if span.shorthand { - format!("{local}: {origin_local}") + format!("{normalized_local}: {projected_local}") } else { - origin_local.to_string() + projected_local.to_string() }; (span.range.clone(), replacement) })); } } } - if !projection.is_empty() { - unit.push('\n'); - unit.push_str(&projection); - added_projection = true; - } + unit.push('\n'); + unit.push_str(&projection); + added_projection = true; } if added_projection { if !apply_javascript_span_replacements(&mut unit, &mut unit_replacements) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index ccb8fbad7..cf1b4b58e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -2793,6 +2793,10 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or "two declarations from one dependency", "import { rotatePiece as turnLeft } from './origin.mjs'; import { rotatePiece as turnRight } from './origin.mjs'; turnLeft(); turnRight();", ), + ( + "case-distinct local aliases", + "import { rotatePiece as turn, rotatePiece as Turn } from './origin.mjs'; turn(); Turn();", + ), ] { fs::write(root.join("game/main.mjs"), source).expect("write duplicate alias importer"); let modules = read_external_gameplay_javascript_at(root, html) @@ -2883,6 +2887,32 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or ); } + fs::write( + root.join("game/origin.mjs"), + "const turn = () => 'helper'; export default function() { return turn(); }", + ) + .expect("write anonymous default with a colliding private binding"); + fs::write( + root.join("game/main.mjs"), + "import turn from './origin.mjs'; turn();", + ) + .expect("write anonymous default alias that collides in its origin"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read collision-safe anonymous default projection"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("const turn__agc_import_1 = function()")) + .unwrap_or_else(|| { + panic!( + "anonymous default must receive a collision-free canonical alias: {:#?}", + modules.module_units() + ) + }); + assert!(projected.contains("const turn = () => 'helper';")); + assert!(projected.contains("turn__agc_import_1();")); + assert!(javascript_is_syntactically_valid(projected, true)); + fs::write( root.join("game/bridge-a.mjs"), "export { default } from './origin.mjs';", @@ -2917,6 +2947,27 @@ fn javascript_module_projection_preserves_duplicate_aliases_without_duplicate_or 1, "an anonymous default reached through two bridges must be projected once", ); + + fs::write( + root.join("game/origin.mjs"), + "export function rotatePiece() { return 'rotated'; }", + ) + .expect("restore named export origin"); + fs::write( + root.join("game/main.mjs"), + "import { rotatePiece as turnPiece } from './origin.mjs'; const rotatePiece = 'metadata'; turnPiece();", + ) + .expect("write named import whose origin binding collides in the importer"); + let modules = read_external_gameplay_javascript_at(root, html) + .expect("read collision-safe named projection"); + let projected = modules + .module_units() + .iter() + .find(|unit| unit.contains("function rotatepiece__agc_import_1()")) + .expect("named origin binding must avoid importer root bindings"); + assert!(projected.contains("const rotatepiece = 'metadata';")); + assert!(projected.contains("rotatepiece__agc_import_1();")); + assert!(javascript_is_syntactically_valid(projected, true)); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 80240d960..4080b180d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5977,6 +5977,7 @@ ## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口 - 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 操作,FIFO 等非普通文件必须在读取前失败关闭,恢复前态也必须在同一叶子句柄上稳定双读并复核前后元数据与当前 inode。Windows 祖先 pin 只请求读访问并拒绝 delete sharing,可重复持有;只有事务叶子句柄请求删除访问。事务捕获与恢复 CAS 从 canonical 项目根句柄逐组件打开或创建父目录,staging、no-replace link/move 与 unlink 均相对固定父目录句柄执行;清理事务证据前再次复核整组安装结果。安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。 -- JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importer。同一 export 的多个本地 alias 保序保留,同一 dependency 的多条 import declaration 合并绑定;投影在 importer 内按最终 origin 聚合,因此同一 origin 经不同 dependency 或 bridge 到达时也只生成一次根声明。object shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 +- 图集事务 live identity:`prepared` 后继续由同一 trusted transaction directory handle 贯穿 canonical 提交、`committed` 清理和 live rollback,不再按路径重开并接受替换目录。恢复在任何写入前冻结九项 canonical 全部前态,晚序普通文件变化必须 CAS 失败且不得被旧快照覆盖。没有 durable journal 的 legacy `.previous / .replacement` 只做锚定识别并进入 reconciliation,不自动恢复 canonical 或删除残留。 +- JavaScript / ESM:Tetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、import 后再 export 的 bridge、re-export 与缺失导出链接不再由字符串扫描猜测;跨模块 alias 保留 origin 根绑定名称,只按解析到 import symbol 的 reference span 改写 importer。同一 export 的多个本地 alias 按大小写敏感的 symbol identity 保序保留,同一 dependency 的多条 import declaration 合并绑定;投影在 importer 内按最终 origin 聚合,因此同一 origin 经不同 dependency 或 bridge 到达时也只生成一次根声明。组合单元为 importer 和所有 origin 根绑定分配无冲突的确定性名称,匿名 default、同源私有根名、importer 局部根名及不同 origin 都不得合并;重命名后必须重新通过 parser 与 semantic,只有最终启发式扫描文本统一小写。object shorthand 展开后保留原键;namespace 只改写绑定到 import symbol 的完整 member span,同文本属性和局部遮蔽均不得连带改写。HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。 - 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finish,RAF / timer 继续不计入动作结果。 - 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 766d31974..8148af1bb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -828,8 +828,9 @@ game-project/ - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 - game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW + O_NONBLOCK` 读取和清理,FIFO 等非普通文件不得阻塞恢复;恢复前态在同一叶子句柄稳定双读并复核元数据、内容和当前 inode。Windows 祖先目录 pin 只请求读访问并拒绝 delete sharing,允许九文件捕获重复持有同一 root;只有事务叶子句柄请求删除访问。事务目录创建、合同源捕获与恢复 CAS 必须从 canonical 项目根句柄逐组件打开或创建父目录,staging、hard-link、no-replace move 与 unlink 全部相对固定父目录句柄执行,不能退回 pathname 预检后操作。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖切换安装;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,逐项安装完成后、清理事务证据前还必须再次复核整组 installed 状态,再按 transaction id 整组回滚或幂等清理。回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 +- `prepared` 发布后,创建阶段锚定的事务目录句柄和 identity 必须由 live rollback 对象一直持有到 `committed` 清理或 rollback 结束,提交和回滚不得按 `PathBuf` 重新接受替换目录。恢复在首个 canonical 写入前一次性冻结九路径全部前态,晚序普通文件内容变化也必须触发 CAS 冲突并逆序回滚早序安装。历史 `.previous / .replacement` 若没有 durable journal,只允许通过锚定父目录识别后进入 reconciliation;不得凭 pathname 自动 hard-link、move、恢复 canonical 或删除残留。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 -- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / import 后再 export 的 bridge / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。跨模块 alias 投影保留 origin 根绑定,只改写解析到 import symbol 的 importer reference span;同一 export 的多个本地 alias 必须保序保留,同一 dependency 的多条 import declaration 合并绑定,同一 origin 即使经不同 dependency 或 bridge 到达也只投影一次。object shorthand 展开为显式键值以保留原键,namespace 只改写完整 member span。同文本对象属性和局部遮蔽不得连带改写,投影依赖也只由 semantic 未解析根引用递归纳入。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并在 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / import 后再 export 的 bridge / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。跨模块 alias 投影保留 origin 根绑定,只改写解析到 import symbol 的 importer reference span;同一 export 的多个本地 alias 必须按大小写敏感的 symbol identity 保序保留,同一 dependency 的多条 import declaration 合并绑定,同一 origin 即使经不同 dependency 或 bridge 到达也只投影一次。组合 importer 与 origin 前,全部投影根绑定都必须分配跨 importer、同源私有绑定和其它 origin 无冲突的确定性名称,重命名后重新通过 parser 与 semantic;只有最终交给静态启发式扫描的文本允许统一小写。object shorthand 展开为显式键值以保留原键,namespace 只改写完整 member span。同文本对象属性和局部遮蔽不得连带改写,投影依赖也只由 semantic 未解析根引用递归纳入。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据;filter / splice 消行仍必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 与传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。