From 89d40c08365aedea2dabd98dd4cbae8f9c31d47c Mon Sep 17 00:00:00 2001 From: lhk Date: Fri, 2 Oct 2026 10:55:29 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=B8=8E=E8=B5=84=E6=BA=90=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E7=9B=AE=E9=94=81=E4=BA=89=E7=94=A8=20(#581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 #551:本地素材导入、账户素材提交和资源编辑提交在 conversation.write 短暂持有项目锁时会立即失败。现在复用既有约 10 秒有界等待,并将同步取锁与提交放入阻塞线程池,等待期间异步 runtime 继续响应。 - 项目锁先于账号身份租约,等待后重新验证账号身份、导入权限及原 WritePermit;保留跨进程互斥、权限分类和持锁方诊断。 - 资源资产/版本提交和已完成结果清理保留 operation/project guard,取消异步等待不会提前释放仍在执行的提交互斥。超时保留原 operation、幂等键与 staging,恢复不重复远端生成。 - Runtime 素材导入与 UI workflow 切图登记也接入阻塞池,避免共享导入函数新增等待后阻塞这些调用方。同步更新 AGC 行为规范及共享记忆。 验证:163 项 Rust 定向测试通过,覆盖导入与资源编辑、Direct bridge、Runtime 导入、UI workflow、项目锁恢复和失败分类。包含同进程争用、单线程 runtime 心跳、并行 manifest/revision、账号切换、许可取消、完整超时后原 operation 恢复及取消后的 guard 生命周期。 ```sh cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- resource_editor:: commands::agent_asset_import_tests agent::direct_tool_bridge::tests tests::project_lock_recovery project::write_lock agent::runtime_tools::context::asset_import_input_tests ui_editor::agent_tools --test-threads=1 npm run check:rustfmt npm run check:encoding npm run check:doc-index git diff --check ``` 全部上述检查通过。Linux 本地未执行 Windows 专属删除挂起行为测试;未调用真实付费 Provider,远端链路使用既有 mock 测试和已下载账本恢复测试验证。没有 API/DTO/schema 变更或数据迁移。 Closes #551 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/581 Co-authored-by: lhk Co-committed-by: lhk --- .../src-tauri/src/agent/direct_tool_bridge.rs | 48 ++- .../src/agent/runtime_tools/context.rs | 32 +- .../src-tauri/src/commands.rs | 190 ++++++++++- .../src-tauri/src/project/resource_editor.rs | 299 +++++++++++++++--- .../src-tauri/src/tests/mod.rs | 38 +++ .../src/ui_editor/agent_tools/run_workflow.rs | 2 +- .../agent_tools/steps/separate/cut_images.rs | 37 ++- .../shared-memory/decision-log.md | 7 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 16 + 9 files changed, 605 insertions(+), 64 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 4940fbfec..34842f918 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1622,11 +1622,13 @@ async fn bridge_import_account_assets( } } if !local_paths.is_empty() { - match crate::commands::import_local_project_assets_for_agent_with_write_permit( + match crate::commands::import_local_project_assets_for_agent_in_blocking_pool( &state.root, &local_paths, write_permit.as_ref(), - ) { + ) + .await + { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { json!({ "id": asset.id, @@ -4748,21 +4750,39 @@ mod tests { let revision_before = read_game_creator_agent_runtime_project_revision(root) .unwrap() .revision; - session - .interrupt("fixture cancellation before asset commit".into()) - .unwrap(); - assert!( - crate::commands::import_local_project_assets_for_agent_with_write_permit( + assert!(crate::tests::with_conversation_write_contention( + root, + crate::commands::import_local_project_assets_for_agent_in_blocking_pool( root, &["game/import-source.png".to_string()], Some(&permit), - ) - .is_err() - ); - assert!( - crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit) - .is_err() - ); + ), + || session + .interrupt("fixture cancellation during asset lock wait".into()) + .unwrap(), + ) + .await + .is_err()); + let commit_root = root.to_path_buf(); + let commit_bytes = bytes.clone(); + let commit_permit = permit.clone(); + assert!(crate::tests::with_conversation_write_contention( + root, + async move { + tokio::task::spawn_blocking(move || { + crate::commands::commit_account_asset_for_write_permit_test( + &commit_root, + &commit_bytes, + &commit_permit, + ) + }) + .await + .unwrap() + }, + || {} + ) + .await + .is_err()); assert_eq!( fs::read(root.join(".agent/manifest.json")).unwrap(), manifest_before diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index f888c3f61..9ad7bbdd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import( } } if !local_paths.is_empty() { - match import_local_project_assets_for_agent(root, &local_paths) { + match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await + { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { serde_json::json!({ "id": asset.id, @@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search( mod asset_import_input_tests { use super::*; + #[tokio::test] + async fn runtime_local_import_waits_without_blocking_worker() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap(); + fs::write(root.join("game/import.md"), b"# Imported").unwrap(); + let observation = crate::tests::with_conversation_write_contention( + root, + async { + let observation = observe_agent_runtime_asset_import( + root, + &serde_json::json!({"localPaths": ["game/import.md"]}), + ) + .await; + if observation.status != "ok" { + return Err(format!("{observation:?}")); + } + Ok(observation) + }, + || {}, + ) + .await + .unwrap(); + let detail: serde_json::Value = + serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap(); + assert_eq!(detail["imported"].as_array().unwrap().len(), 1); + assert_eq!(detail["revisionAdvanceCount"], 1); + } + #[test] fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() { let temporary = tempfile::tempdir().expect("create asset context project root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index b5c910a84..9be6df9bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1981,6 +1981,126 @@ mod agent_asset_import_tests { ); } + fn account_import_contention_downloads( + ) -> Vec<(AgentEditorAssetRecord, String, String, Vec)> { + vec![( + AgentEditorAssetRecord { + asset_id: "account-contention".into(), + origin: AgentEditorAssetOrigin::AccountLibrary, + canvas_project_id: None, + folder_id: None, + folder_label: None, + label: "账户争用测试".into(), + object_key: None, + image_src: None, + asset_object_id: None, + asset_kind: Some("image".into()), + source_type: None, + width: None, + height: None, + size_bytes: None, + }, + "image/png".into(), + "assets/uploads/account-contention.png".into(), + tiny_png(), + )] + } + + #[tokio::test] + async fn asset_imports_wait_for_conversation_without_blocking_or_losing_updates() { + let project = crate::tests::canonical_test_tempdir("import-contention-"); + let root = project.path(); + init_local_game_project_at(root, "import-contention", "导入争用").unwrap(); + fs::write(root.join("game/local.png"), tiny_png()).unwrap(); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let local_paths = vec!["game/local.png".to_string()]; + let (local, account) = crate::tests::with_conversation_write_contention( + root, + async { + tokio::try_join!( + import_local_project_assets_for_agent_in_blocking_pool( + root, + &local_paths, + None, + ), + commit_account_editor_asset_downloads_in_blocking_pool( + root, + account_import_contention_downloads(), + None, + None, + ), + ) + }, + || {}, + ) + .await + .unwrap(); + let manifest = read_existing_manifest_for_project(root).unwrap(); + for result in [&local, &account] { + assert_eq!(result.assets.len(), 1); + let asset = &result.assets[0]; + assert!(manifest.assets.iter().any(|entry| entry.id == asset.id)); + assert_eq!(fs::read(root.join(&asset.local_path)).unwrap(), tiny_png()); + } + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + 2 + ); + } + + #[tokio::test] + async fn account_asset_commit_rechecks_session_after_conversation_wait() { + let _session_guard = install_test_platform_session( + "import-owner-a", + "import-token-a", + "https://import.example.test", + ); + let session = current_platform_session().unwrap(); + let project = crate::tests::canonical_test_tempdir("import-session-contention-"); + let root = project.path(); + init_local_game_project_at(root, "import-session-contention", "导入身份争用").unwrap(); + let before = fs::read(root.join(".agent/manifest.json")).unwrap(); + let before_revision = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let generation = session.identity_generation + 1; + let error = crate::tests::with_conversation_write_contention( + root, + commit_account_editor_asset_downloads_in_blocking_pool( + root, + account_import_contention_downloads(), + None, + Some(session), + ), + || { + install_platform_session( + "import-owner-b", + "import-token-b", + "https://import.example.test", + generation, + generation, + ) + .unwrap(); + }, + ) + .await + .err() + .expect("旧账号不能在等待后提交"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!(fs::read(root.join(".agent/manifest.json")).unwrap(), before); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before_revision + ); + assert!(!root.join("assets/uploads/account-contention.png").exists()); + } + #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { let project = crate::tests::canonical_test_tempdir("agent-local-import-"); @@ -3237,6 +3357,25 @@ pub(crate) fn import_local_project_assets_for_agent( import_local_project_assets_for_agent_with_write_permit(root, relative_paths, None) } +pub(crate) async fn import_local_project_assets_for_agent_in_blocking_pool( + root: &Path, + relative_paths: &[String], + write_permit: Option<&crate::agent::WritePermit>, +) -> Result { + let root = root.to_path_buf(); + let relative_paths = relative_paths.to_vec(); + let write_permit = write_permit.cloned(); + tokio::task::spawn_blocking(move || { + import_local_project_assets_for_agent_with_write_permit( + &root, + &relative_paths, + write_permit.as_ref(), + ) + }) + .await + .map_err(|error| format!("本地素材导入任务未返回:{error}"))? +} + pub(crate) fn import_local_project_assets_for_agent_with_write_permit( root: &Path, relative_paths: &[String], @@ -3301,7 +3440,11 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( )); } - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_import", + )?; + enforce_project_permission_policy(root, "canvas.asset_import")?; let commit = || { let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(inputs.len()); @@ -3497,20 +3640,50 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit( } access.validate_frozen_session()?; - let _platform_session_lease = frozen_session - .as_ref() - .map(|session| acquire_platform_session_identity_lease(&session.identity())) - .transpose()?; - // 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。 - commit_account_editor_asset_downloads(root, downloads, write_permit) + commit_account_editor_asset_downloads_in_blocking_pool( + root, + downloads, + write_permit, + frozen_session, + ) + .await +} + +async fn commit_account_editor_asset_downloads_in_blocking_pool( + root: &Path, + downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>, + write_permit: Option<&crate::agent::WritePermit>, + frozen_session: Option, +) -> Result { + let root = root.to_path_buf(); + let write_permit = write_permit.cloned(); + tokio::task::spawn_blocking(move || { + commit_account_editor_asset_downloads( + &root, + downloads, + write_permit.as_ref(), + frozen_session.as_ref(), + ) + }) + .await + .map_err(|error| format!("账户素材提交任务未返回:{error}"))? } fn commit_account_editor_asset_downloads( root: &Path, downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>, write_permit: Option<&crate::agent::WritePermit>, + frozen_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_import", + )?; + enforce_project_permission_policy(root, "canvas.asset_import")?; + // 等待后才取得身份租约;等待期间允许换号,提交时拒绝旧账号结果。 + let _platform_session_lease = frozen_session + .map(|session| acquire_platform_session_identity_lease(&session.identity())) + .transpose()?; let commit = || { let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(downloads.len()); @@ -3639,6 +3812,7 @@ pub(crate) fn commit_account_asset_for_write_permit_test( bytes.to_vec(), )], Some(write_permit), + None, ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 472dad7a1..407dbe42f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -4302,7 +4302,8 @@ fn commit_resource_edit_asset_internal( reference_resource_ids: vec![source.canonical_resource_id.clone()], }, }; - let _project_lock = acquire_project_write_lock(root, "resource.edit")?; + let _project_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -4615,6 +4616,10 @@ fn commit_resource_edit_asset_with_frozen_platform_session( ledger: &mut ResourceEditLedger, platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { + // 项目锁先于平台身份租约,避免等待时挡住换号或与其它提交形成锁顺序反转。 + // 内层提交在同一线程复用项目锁,整个事务不能跨 await 或线程迁移。 + let _project_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?; with_frozen_resource_edit_platform_session(platform_session, || { commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger) }) @@ -4696,7 +4701,10 @@ fn commit_resource_edit_version( ledger: &mut ResourceEditLedger, ) -> Result { let version_id = format!("edit-{}", input.operation_id); - let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?; + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "resource.edit.version", + )?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -5518,7 +5526,7 @@ pub(crate) async fn derive_local_project_resource_at( let root = Path::new(input.project_path.trim()); validate_project_root(root)?; let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; - let _operation_guard = operation_lock.lock().await; + let operation_guard = operation_lock.lock_owned().await; let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -5652,23 +5660,36 @@ pub(crate) async fn derive_local_project_resource_at( ledger } }; - if ledger.phase == ResourceEditLedgerPhase::Committed { + if ledger.phase == ResourceEditLedgerPhase::Committed + || input.edit_kind == LocalProjectResourceEditKind::Version + { let project_lock = resource_edit_project_mutation_lock(root)?; - let _project_guard = project_lock.lock().await; - let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?; - cleanup_committed_resource_edit_staging(root, &mut ledger)?; - return committed_resource_edit_result( - root, - &input, - &source.canonical_resource_id, - ledger.result_asset_id.as_deref(), - ledger.result_version_id.as_deref(), - ); - } - if input.edit_kind == LocalProjectResourceEditKind::Version { - let project_lock = resource_edit_project_mutation_lock(root)?; - let _project_guard = project_lock.lock().await; - return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); + let project_guard = project_lock.lock_owned().await; + let root = root.to_path_buf(); + return tokio::task::spawn_blocking(move || { + // 调用方取消等待时,互斥仍由正在运行的提交持有。 + let _operation_guard = operation_guard; + let _project_guard = project_guard; + if ledger.phase == ResourceEditLedgerPhase::Committed { + let _project_write_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "resource.edit.cleanup", + )?; + cleanup_committed_resource_edit_staging(&root, &mut ledger)?; + committed_resource_edit_result( + &root, + &input, + &source.canonical_resource_id, + ledger.result_asset_id.as_deref(), + ledger.result_version_id.as_deref(), + ) + } else { + commit_resource_edit_version(&root, &input, &source, &prompt, &mut ledger) + } + }) + .await + .map_err(|error| format!("资源编辑提交任务未返回:{error}"))?; } let remote_credentials = if input.edit_kind.is_remote_media() && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded @@ -5775,16 +5796,28 @@ pub(crate) async fn derive_local_project_resource_at( } } let project_lock = resource_edit_project_mutation_lock(root)?; - let _project_guard = project_lock.lock().await; - let result = commit_resource_edit_asset_with_frozen_platform_session( - root, - &input, - &source, - &prompt, - &asset_name, - &mut ledger, - frozen_platform_session.as_ref(), - )?; + let project_guard = project_lock.lock_owned().await; + let commit_root = root.to_path_buf(); + let commit_input = input.clone(); + let commit_session = frozen_platform_session.clone(); + let (result, ledger, _operation_guard, _project_guard) = + tokio::task::spawn_blocking(move || { + let operation_guard = operation_guard; + let project_guard = project_guard; + let result = commit_resource_edit_asset_with_frozen_platform_session( + &commit_root, + &commit_input, + &source, + &prompt, + &asset_name, + &mut ledger, + commit_session.as_ref(), + )?; + // 正常返回仍覆盖后续 binding 写入;取消等待则在阻塞任务结束后释放。 + Ok::<_, String>((result, ledger, operation_guard, project_guard)) + }) + .await + .map_err(|error| format!("资源编辑提交任务未返回:{error}"))??; if let (Some((api_base_url, api_key, _)), Some(principal)) = (remote_credentials.as_ref(), binding_principal.as_ref()) { @@ -6462,7 +6495,7 @@ mod tests { drop(other_project_guard); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[tokio::test] async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() { let directory = tempfile::tempdir().expect("create concurrent commit fixture"); let root = directory.path(); @@ -6513,14 +6546,31 @@ mod tests { requests.push(request); } - let (first, second) = tokio::join!( - derive_local_project_resource_at(requests[0].clone()), - derive_local_project_resource_at(requests[1].clone()) - ); - first.expect("commit first operation"); - second.expect("commit second operation"); + fs::write(root.join("game/parallel.md"), b"# Imported").unwrap(); + let local_paths = vec!["game/parallel.md".to_string()]; + let (_, _, imported) = crate::tests::with_conversation_write_contention( + root, + async { + tokio::try_join!( + derive_local_project_resource_at(requests[0].clone()), + derive_local_project_resource_at(requests[1].clone()), + crate::commands::import_local_project_assets_for_agent_in_blocking_pool( + root, + &local_paths, + None, + ), + ) + }, + || {}, + ) + .await + .expect("并行导入和资源提交必须等待后成功"); let committed = read_existing_manifest_for_project(root).expect("read committed manifest"); + assert!(committed + .assets + .iter() + .any(|asset| asset.id == imported.assets[0].id)); for request in &requests { assert_eq!( committed @@ -6535,10 +6585,185 @@ mod tests { read_game_creator_agent_runtime_project_revision(root) .expect("read committed revision") .revision, - 2 + 3 ); } + fn staged_background_removal_for_contention(root: &Path) -> DeriveLocalProjectResourceInput { + init_local_game_project_at(root, PROJECT_ID, "资源提交争用").unwrap(); + let uploaded = + upload_local_asset_at(root, "source.png", "image/png", &resource_editor_test_png()) + .unwrap(); + let manifest = read_existing_manifest_for_project(root).unwrap(); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::BackgroundRemoval, + format!("asset:{}", uploaded.id), + ); + request.source_asset_id = Some(uploaded.id); + request.source_path = Some(uploaded.local_path); + request.source_media_type = Some("image/png".into()); + let source = resolve_resource_edit_source(root, &manifest, &request).unwrap(); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.request_fingerprint = resource_edit_request_fingerprint( + &request, + &source, + &request.prompt, + &request.asset_name, + ) + .unwrap(); + ledger.remote_operation_id = Some("completed-background-removal".into()); + ledger.remote_object_key = Some("generated/completed.png".into()); + ledger.staged_media_type = Some("image/png".into()); + ledger.staged_extension = Some("png".into()); + write_resource_edit_ledger(root, &ledger).unwrap(); + write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png()) + .unwrap(); + request + } + + #[tokio::test] + async fn downloaded_resource_commit_and_replay_wait_without_new_remote_work() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + // 不提供远端凭据:下载完成后的提交与重放必须只使用原账本和 staging。 + let result = crate::tests::with_conversation_write_contention( + root, + derive_local_project_resource_at(request.clone()), + || {}, + ) + .await + .unwrap(); + assert_eq!(result.operation_id, request.operation_id); + assert_eq!(result.committed_project_revision, before + 1); + let ledger = read_resource_edit_ledger(root, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert_eq!(ledger.idempotency_key, request.idempotency_key); + assert_eq!( + ledger.remote_operation_id.as_deref(), + Some("completed-background-removal") + ); + let replay = crate::tests::with_conversation_write_contention( + root, + derive_local_project_resource_at(request.clone()), + || {}, + ) + .await + .unwrap(); + assert_eq!(replay.operation_id, result.operation_id); + assert_eq!( + replay.committed_project_revision, + result.committed_project_revision + ); + assert_eq!(replay.asset, result.asset); + assert_eq!( + replay + .manifest + .assets + .iter() + .filter(|asset| asset.id == format!("edit-{}", request.operation_id)) + .count(), + 1 + ); + } + + #[tokio::test] + async fn cancelled_resource_commit_wait_keeps_operation_and_project_guards() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let operation_lock = resource_edit_operation_lock(root, &request.operation_id).unwrap(); + let project_lock = resource_edit_project_mutation_lock(root).unwrap(); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + crate::tests::with_conversation_write_contention( + root, + async { + let mut commit = Box::pin(derive_local_project_resource_at(request.clone())); + assert!(futures::poll!(commit.as_mut()).is_pending()); + drop(commit); + // JoinHandle 被丢弃后阻塞任务仍会继续,互斥必须随任务保留。 + assert!(operation_lock.try_lock().is_err()); + assert!(project_lock.try_lock().is_err()); + let _finished = + tokio::time::timeout(Duration::from_secs(10), operation_lock.lock()) + .await + .expect("阻塞提交应在项目锁释放后完成"); + Ok(()) + }, + || {}, + ) + .await + .unwrap(); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .unwrap() + .unwrap() + .phase, + ResourceEditLedgerPhase::Committed + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + 1 + ); + assert!(project_lock.try_lock().is_ok()); + } + + #[tokio::test] + async fn resource_commit_timeout_preserves_staging_and_resumes_original_operation() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let ledger_before = read_resource_edit_ledger(root, &request.operation_id).unwrap(); + let lock = acquire_project_write_lock(root, "conversation.write").unwrap(); + let started = std::time::Instant::now(); + let error = derive_local_project_resource_at(request.clone()) + .await + .unwrap_err(); + assert!( + started.elapsed() >= Duration::from_secs(9), + "未使用完整有界等待:{error}" + ); + assert!(error.contains("commandId=conversation.write"), "{error}"); + assert!(error.contains("ownerIsSelf=true"), "{error}"); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id).unwrap(), + ledger_before + ); + assert_eq!( + read_resource_edit_staging(root, &request.operation_id).unwrap(), + resource_editor_test_png() + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + ); + drop(lock); + let recovered = derive_local_project_resource_at(request.clone()) + .await + .unwrap(); + assert_eq!(recovered.operation_id, request.operation_id); + assert_eq!(recovered.committed_project_revision, before + 1); + } + #[test] fn edit_kind_provenance_uses_stable_kebab_case_values() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 37aa5d9eb..7eec7b3da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -16,6 +16,44 @@ const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; +/// 在另一线程持有真实项目锁;单线程 runtime 必须能在释放锁前推进心跳。 +pub(crate) async fn with_conversation_write_contention( + root: &Path, + operation: impl std::future::Future>, + while_waiting: impl FnOnce(), +) -> Result { + let root = root.to_path_buf(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _lock = acquire_project_write_lock(&root, "conversation.write").unwrap(); + ready_tx.send(()).unwrap(); + // 若回归为 runtime 同步阻塞,限时释放以让断言失败而不是挂死测试。 + let _ = release_rx.recv_timeout(Duration::from_secs(5)); + }); + ready_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let finished = AtomicBool::new(false); + let started = Instant::now(); + let (result, ()) = tokio::join!( + async { + let result = operation.await; + finished.store(true, Ordering::SeqCst); + result + }, + async { + tokio::time::sleep(Duration::from_millis(100)).await; + let elapsed = started.elapsed(); + let completed_while_locked = finished.load(Ordering::SeqCst); + while_waiting(); + let _ = release_tx.send(()); + assert!(elapsed < Duration::from_secs(3), "项目锁等待阻塞了 runtime"); + assert!(!completed_while_locked, "项目锁释放前操作已返回"); + } + ); + holder.join().unwrap(); + result +} + // 委派测试显式持有与正式 action 执行链相同的项目写锁,随后调用现役核心。 fn observe_agent_runtime_agent_delegate( root: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs index a92e00b21..5c937bc74 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs @@ -122,7 +122,7 @@ pub(crate) async fn run_ui_design_doc_workflow( ) .await?; // 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。 - let cut_images = register_cut_image_sprites(root, &dto)?; + let cut_images = register_cut_image_sprites(root, &dto).await?; let mut backfill_errors = cut_images.errors; progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?; backfill_errors.extend(apply_separation( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs index 28997580e..795e8bd49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs @@ -6,7 +6,7 @@ //! 说明,不回滚已经登记成功的资源。 use super::normalize_cut_image_path; -use crate::import_local_project_assets_for_agent; +use crate::import_local_project_assets_for_agent_in_blocking_pool; use crate::ui_editor::commands::SeparationDTO; use crate::ui_editor::resource::sprite::SpriteAsset; use crate::ui_editor::utils::SpriteAssetId; @@ -29,7 +29,7 @@ pub(crate) struct CutImageSprites { pub(crate) errors: Vec, } -pub(crate) fn register_cut_image_sprites( +pub(crate) async fn register_cut_image_sprites( root: &Path, dto: &SeparationDTO, ) -> Result { @@ -43,7 +43,8 @@ pub(crate) fn register_cut_image_sprites( } let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new(); for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) { - let imported = import_local_project_assets_for_agent(root, batch)?; + let imported = + import_local_project_assets_for_agent_in_blocking_pool(root, batch, None).await?; if imported.assets.len() != batch.len() { return Err(format!( "本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个", @@ -140,6 +141,36 @@ mod tests { } } + #[tokio::test] + async fn cut_image_registration_waits_without_blocking_worker() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + crate::init_local_game_project_at(root, "cut-import-wait", "切图导入等待").unwrap(); + image::DynamicImage::new_rgba8(2, 2) + .save(root.join("game/cut.png")) + .unwrap(); + let dto = SeparationDTO { + bound_nodes: vec![bound("game/cut.png"), bound("game/cut.png")], + problematic_nodes: Vec::new(), + }; + let result = crate::tests::with_conversation_write_contention( + root, + register_cut_image_sprites(root, &dto), + || {}, + ) + .await + .unwrap(); + assert_eq!(result.sprites.len(), 1); + assert!(result.by_path.contains_key("game/cut.png")); + assert!(result.errors.is_empty()); + assert_eq!( + crate::read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + 1 + ); + } + #[test] fn unique_paths_normalize_separators_and_drop_duplicates() { let dto = SeparationDTO { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd675dbb0..e1c5c9503 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8726,6 +8726,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。 - 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。 +## 2026-10-01 素材提交复用有界项目锁等待 + +- 本地素材导入、账户素材下载后的提交、资源编辑资产/版本提交及已完成结果清理复用既有类型化有界等待,跨进程排他、同线程重入和权限分类保持不变。 +- 同步等待与事务整体放入阻塞线程池;必须先取得项目锁,再取得平台身份租约,并在等待后验证原 WritePermit。不能在账号互斥锁内等待项目锁,也不能把项目锁 guard 跨线程搬到实际提交线程。 +- 资源编辑的 operation/project 异步互斥 guard 随阻塞任务持有,调用方取消等待不能提前释放。原账本、operation、幂等键及 staging 继续用于本地恢复,不因锁争用重复远端生成。 +- 关联:Issue #551;行为与证据见 AGC 实施计划“素材导入与资源提交的项目锁等待”。 + ## 2026-09-14 AGC 图片类生成后台化:提交即返回 + 项目内任务账本 + 本地排队 - 背景:栏目画布图片类入口原先是一条同步 IPC `generate_local_project_asset`,一次调用最长等 35 分钟;提交期间两块生成浮层把 × / 遮罩 / Esc /「取消」全部锁死,用户既关不掉面板也看不到进度。远端图片类生成当时共用 single-flight 输出槽(`standalone_platform_art_generation_runtime_context` 按 `outputPath` 派生 `run_id`)——**同一批次内该槽身份已改为精确动作指纹,见本文件 2026-09-14「放开 AGC 手工图片生成的本地并发」条目。** diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d2ab76784..8f0361afc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1618,6 +1618,22 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - **素材重命名 UI**:新增 `ResourceRenameDialog`,入口在资源卡选中工具条的 `extraActions`(与「分类与标签」并列),只输入新文件名。前端严格按 `deny_unknown_fields` 只传 `{ projectPath, assetId, newFileName }`;成功后复用 `reloadManifestAfterAssetCommand` 这条既有 manifest 重载路径刷新资源卡与 `@` 面板显示名。资料性边界沿用 Rust 侧口径:不改游戏源码里对旧 `assets/` 的引用。 - **过渡 allowlist 收敛**:`check-config.mjs` 的 `allowedUncalledTauriCommands` 删除 `normalize_local_project_raster_resource`——该命令在 C3 之后的选中工具条里已有真实前端调用方,不再是「无人调用」的过渡项。 - **验证**:AGC 前端全量 1080 passed / 4 skipped / 5 failed(5 条失败全部是 C3 交互改造后仍待更新的既有用例,与本包改动无关:`renders immutable manifest versions…`、`keeps the resource toolbar and canvas interactive…`、`loads registered documents, art media, video, and audio…`、`moves historical resource positions…`,以及 `projectResourceLiveIntegration` 的一条选中按钮选择器);本包新增的 25 条测试(标签库 5、筛选条 3、参考图弹窗 6、参考图模型 7、重命名 2、版本判定 5、版本切换 3,其中 5 条落在共享契约与共享组件目录)全部通过;共享美术画布组件 1385 passed;`cargo check --all-targets` 通过;`asset_rename` 9 passed;`npm run check:encoding` 与 `git diff --check` 干净。 +## 素材导入与资源提交的项目锁等待 + +- 本地素材导入、账户素材下载后的本地提交、资源编辑资产提交及其已完成结果恢复必须吸收短暂项目写锁争用;等待沿用现有有界预算与类型化失败分类。耗尽时报告可读取的持锁方身份,身份不可读时明确说明,不推断锁无人持有。Windows 删除挂起与 ACL 的现有终态判据保持不变。 +- 同步取锁、等待、提交和释放在同一个阻塞任务内完成,不占用异步 runtime worker。等待不持有平台登录态互斥锁;取得项目锁后再验证账号身份并持有身份租约覆盖本地提交。导入的原始 WritePermit 在等待后重新校验,取消或过期的许可不能提交。 +- 项目锁内重新读取 manifest 与 revision,复核项目和资源身份,保持并行提交无更新丢失。跨进程排他、同线程重入和现有残留回收判据不变,不新增同 PID 豁免。 +- 资源编辑继续使用原 operation、幂等键、账本和 staging;本地提交争用不得重发远端生成。阻塞提交开始后,operation 与项目提交互斥 guard 归阻塞任务所有,即使等待它的异步调用被取消也不能提前释放它们。网络请求不在项目写锁内执行。 +- 不改变 API、DTO、账本 schema 或已有项目数据,不要求迁移;其它零等待入口按各自专题处理。本节覆盖下文旧事项中本地/账户素材导入与资源提交的部分。 +- 验收证据:三条入口在 conversation.write 持锁时等待后成功;等待期间单线程 runtime 心跳继续推进;取消导入零写入;并行提交 manifest/revision 完整;耗尽、权限拒绝、活跨进程锁和残留回收沿用锁测试;远端已完成的资源编辑恢复保持同 operation 且零新增远端请求。真实 Windows 删除挂起行为仍需 Windows 环境验证。 + +| 行为 | 自动化证据入口 | +| --- | --- | +| 导入等待、账号切换与许可取消 | `commands::agent_asset_import_tests`、`direct_tool_bridge::tests::cancelled_import_write_permit_keeps_files_manifest_and_revision_unchanged` | +| Runtime 与切图登记不阻塞 worker | `runtime_local_import_waits_without_blocking_worker`、`cut_image_registration_waits_without_blocking_worker` | +| 并行提交、超时恢复、取消时互斥生命周期 | `project_mutation_lock_serializes_independent_asset_commits_without_lost_updates`、`resource_commit_timeout_preserves_staging_and_resumes_original_operation`、`cancelled_resource_commit_wait_keeps_operation_and_project_guards` | +| 已下载结果和已提交回包复用、权限与跨进程边界 | `downloaded_resource_commit_and_replay_wait_without_new_remote_work`、`resource_editor::background_removal_tests`、`project_lock_recovery`、`project::write_lock` | + ## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类 - `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。 From f2127a64891c36657b646c8148b99bc98376b11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Fri, 2 Oct 2026 10:55:47 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=88=97=E4=B8=BE=E8=A2=AB=E6=97=A0=E5=85=B3=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E9=94=81=E5=88=A0=E9=99=A4=E6=89=93=E6=96=AD=EF=BC=88?= =?UTF-8?q?#552=EF=BC=89=20(#580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列举 `assets` 时,原实现先递归扫描整个项目,再过滤范围;`.agent/project.lock` 在枚举与读取元数据之间正常删除会使整次查询失败。 现在在读取条目元数据前裁剪目标范围与受保护路径,只遍历目标子树及必要祖先。枚举后消失的文件/目录仅跳过 `NotFound`,其余权限与 IO 错误仍返回;进入排队目录前复核符号链接与 Windows 重解析点。通用文件树保留原有可见范围,Agent 结果继续过滤、排序后分页。 通过通道协调真实项目写锁释放,确定性覆盖全量扫描的 TOCTOU 及定向 `assets` 扫描;另覆盖目录消失/类型变化、权限错误、私有路径、链接、引擎目录和分页。同步更新 AGC 契约与共享排障记录。 验证: - 15 条定向 Rust 测试通过:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --test-threads=1 project_file_listing generic_file_tools local_project_file_commands external_listing` - 生产 Rust 检查通过:`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell` - `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 - 本地验证在 Linux 执行,未运行 Windows 专属运行时验证;分页仍是实时观察,不承诺跨请求快照一致性。 Fixes #552 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/580 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 85 +++++- .../src-tauri/src/project/filesystem.rs | 83 ++++- .../src/project/filesystem/listing_tests.rs | 288 ++++++++++++++++++ .../src-tauri/src/tests/project.rs | 17 ++ docs/project-memory/shared-memory/pitfalls.md | 6 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 + 6 files changed, 459 insertions(+), 28 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 34842f918..10ec599ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { let scope_relative = scope .and_then(|scope| scope.project_relative) .filter(|path| !path.is_empty()); - let listed = list_local_project_files_at(root)?; - let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); + let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| { + bridge_project_file_is_hidden_control_path(path) + || should_skip_project_snapshot_path(path) + || reject_sensitive_project_file_read(path).is_err() + })?; let mut files = listed .files .into_iter() .filter(|file| file.kind == "file") - .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path)) - .filter(|file| !should_skip_project_snapshot_path(&file.path)) - .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) - .filter(|file| { - scope_relative.as_ref().is_none_or(|scope| { - file.path == *scope - || scope_prefix - .as_ref() - .is_some_and(|prefix| file.path.starts_with(prefix)) - }) - }) .filter(|file| { let (category, _) = bridge_project_file_class(&file.path); requested_kind == "all" || requested_kind == category @@ -4195,6 +4187,73 @@ mod tests { ); } + #[test] + fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap(); + for path in [ + "assets/z.png", + "assets/nested/b.png", + "assets/a.png", + "assets/notes.txt", + "assets-other/wrong.png", + "unrelated/wrong.png", + ".agent/hidden.png", + "assets/.env", + "assets/secret.key", + "assets/.codex/hidden.png", + "assets/node_modules/hidden.png", + "assets/.git/hidden.png", + ] { + let target = root.join(path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, b"test-only").unwrap(); + } + let payload = |arguments: Value| { + let result = bridge_list_project_files(root, &arguments); + assert_eq!(result["isError"], false, "{result}"); + serde_json::from_str::(result["content"][0]["text"].as_str().unwrap()).unwrap() + }; + let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 })); + assert_eq!(first["total"], 3); + assert_eq!(first["offset"], 0); + assert_eq!(first["limit"], 2); + assert_eq!(first["nextOffset"], 2); + assert_eq!(first["files"][0]["path"], "assets/a.png"); + assert_eq!(first["files"][1]["path"], "assets/nested/b.png"); + let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 })); + assert_eq!(last["total"], 3); + assert_eq!(last["files"].as_array().unwrap().len(), 1); + assert_eq!(last["files"][0]["path"], "assets/z.png"); + assert!(last["nextOffset"].is_null()); + for path in ["assets/nested", "assets/nested/b.png"] { + let page = payload(json!({ "path": path })); + assert_eq!(page["total"], 1); + assert_eq!(page["files"][0]["path"], "assets/nested/b.png"); + } + assert_eq!( + payload(json!({ "path": "assets", "query": "NESTED" }))["total"], + 1 + ); + assert_eq!(payload(json!({ "path": "assets" }))["total"], 4); + assert_eq!(payload(json!({ "path": "missing" }))["total"], 0); + let beyond = payload(json!({ "path": "assets", "offset": 20 })); + assert!(beyond["files"].as_array().unwrap().is_empty()); + assert!(beyond["nextOffset"].is_null()); + for path in [ + ".agent", + "assets/.env", + "assets/.codex", + "assets/node_modules", + ] { + assert_eq!( + bridge_list_project_files(root, &json!({ "path": path }))["isError"], + true + ); + } + } + #[tokio::test] async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() { use crate::analytics::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index dd1c045bb..d5089ff54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(test)] +mod listing_tests; + /** * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 * @@ -18,6 +21,24 @@ fn is_engine_generated_root_directory(relative_path: &str) -> bool { pub(crate) fn list_local_project_files_at( root: &Path, +) -> Result { + list_local_project_files_scoped_at(root, None, |_| false) +} + +/// 在读取元数据前裁剪范围;调用方的可见性规则不改变通用文件树。 +pub(crate) fn list_local_project_files_scoped_at( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, +) -> Result { + list_local_project_files_with_metadata(root, scope, exclude, |path| fs::symlink_metadata(path)) +} + +fn list_local_project_files_with_metadata( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, + mut read_metadata: impl FnMut(&Path) -> std::io::Result, ) -> Result { validate_project_root(root)?; if !root.exists() { @@ -37,28 +58,58 @@ pub(crate) fn list_local_project_files_at( */ let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let is_unity_project = discover_local_unity_project_root(root)?.is_some(); + let scope_prefix = scope.map(|path| format!("{path}/")); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { - for entry in fs::read_dir(&dir) - .map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))? - { + // 目录排队后可能消失或被换成链接,进入前重新核验。 + let metadata = match read_metadata(&dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取文件元数据失败:{}: {error}", dir.display())), + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取项目目录失败:{}: {error}", dir.display())), + }; + for entry in entries { let entry = entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?; let path = entry.path(); - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + let relative_path = relative_project_path(root, &path)?; + let in_scope = scope.is_none_or(|scope| { + relative_path == scope + || scope_prefix + .as_ref() + .is_some_and(|prefix| relative_path.starts_with(prefix)) + }); + let scope_ancestor = + scope.is_some_and(|scope| scope.starts_with(&format!("{relative_path}/"))); + if (!in_scope && !scope_ancestor) || exclude(&relative_path) { continue; } - let file_type = metadata.file_type(); - let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) { continue; } + let metadata = match read_metadata(&path) { + Ok(metadata) => metadata, + // read_dir 只观察名字,不保证随后还能读取该条目。 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("读取文件元数据失败:{}: {error}", path.display())) + } + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let file_type = metadata.file_type(); let modified_at = metadata .modified() .ok() @@ -80,14 +131,16 @@ pub(crate) fn list_local_project_files_at( { continue; } - files.push(LocalProjectFileEntry { - path: relative_path, - kind: "directory".to_string(), - size: 0, - modified_at, - }); + if in_scope { + files.push(LocalProjectFileEntry { + path: relative_path, + kind: "directory".to_string(), + size: 0, + modified_at, + }); + } dirs.push(path); - } else if file_type.is_file() { + } else if file_type.is_file() && in_scope { let size = metadata.len(); files.push(LocalProjectFileEntry { path: relative_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs new file mode 100644 index 000000000..2bf6134f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use std::io::{Error, ErrorKind}; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn project_file_listing_survives_lock_release_between_enumeration_and_metadata() { + for scope in [None, Some("assets")] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); + let timeout = Duration::from_secs(10); + + std::thread::scope(|threads| { + let owner_root = &root; + let owner = threads.spawn(move || { + let lock = acquire_project_write_lock(owner_root, "file-listing-race").unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(timeout).unwrap(); + drop(lock); + released_tx.send(()).unwrap(); + }); + ready_rx.recv_timeout(timeout).unwrap(); + let mut observed_gap = false; + let listed = list_local_project_files_with_metadata( + &root, + scope, + |_| false, + |path| { + if scope.is_some() { + assert!( + !path.starts_with(root.join(".agent")), + "定向列举不得检查锁目录" + ); + } + let trigger = if scope.is_some() { + root.join("assets") + } else { + root.join(PROJECT_WRITE_LOCK_PATH) + }; + if path == trigger && !observed_gap { + // 全量列举复现锁的 TOCTOU;定向列举在访问 assets 时同时释放锁。 + release_tx.send(()).unwrap(); + released_rx.recv_timeout(timeout).unwrap(); + let result = fs::symlink_metadata(path); + if scope.is_none() { + assert_eq!(result.as_ref().unwrap_err().kind(), ErrorKind::NotFound); + } + observed_gap = true; + return result; + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + owner.join().unwrap(); + assert!(observed_gap, "夹具必须在列举期间释放真实锁"); + assert!(listed + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + assert!(!listed + .files + .iter() + .any(|file| file.path == PROJECT_WRITE_LOCK_PATH)); + }); + } +} + +#[test] +fn project_file_listing_prunes_scope_and_private_paths_before_metadata() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + for path in [ + "assets/nested", + "assets-other", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + fs::create_dir_all(root.join(path)).unwrap(); + } + fs::write(root.join("assets/nested/hero.png"), b"image").unwrap(); + fs::write(root.join("assets/other.png"), b"other").unwrap(); + fs::write(root.join(".env"), b"test-only").unwrap(); + for scope in ["assets/nested", "assets/nested/hero.png"] { + let listed = list_local_project_files_with_metadata( + root, + Some(scope), + |_| false, + |path| { + if path != root + && path != root.join("assets") + && !path.starts_with(root.join("assets/nested")) + { + panic!("不应读取范围外元数据:{}", path.display()); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!( + listed + .files + .iter() + .filter(|file| file.kind == "file") + .map(|file| file.path.as_str()) + .collect::>(), + ["assets/nested/hero.png"] + ); + } + let listed = list_local_project_files_with_metadata( + root, + None, + |path| path == ".env", + |path| { + for excluded in [ + ".env", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + assert!( + !path.starts_with(root.join(excluded)), + "过滤必须早于元数据读取" + ); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!( + listed.files.iter().any(|file| file.path == ".agent"), + "通用文件树仍可见普通控制面目录" + ); +} + +#[test] +fn project_file_listing_skips_disappearing_entries_but_preserves_io_errors() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::create_dir(root.join("assets")).unwrap(); + let target = root.join("assets/hero.png"); + fs::write(&target, b"image").unwrap(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::Other] { + let error = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + return Err(Error::new(kind, "listing-error-sentinel")); + } + fs::symlink_metadata(path) + }, + ) + .unwrap_err(); + assert!( + error.contains("hero.png") && error.contains("listing-error-sentinel"), + "{error}" + ); + } + let listed = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + fs::remove_file(path).unwrap(); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!(!listed.files.iter().any(|file| file.kind == "file")); +} + +#[test] +fn project_file_listing_handles_queued_directory_disappearance_and_type_change() { + for change in ["before-metadata", "before-read-dir", "replace-with-file"] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let target = root.join("assets"); + fs::create_dir(&target).unwrap(); + let mut visits = 0; + let result = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + visits += 1; + if visits == 2 { + let previous = fs::symlink_metadata(path); + fs::remove_dir(path).unwrap(); + if change == "before-read-dir" { + return previous; + } + if change == "replace-with-file" { + fs::write(path, b"not a directory").unwrap(); + } + } + } + fs::symlink_metadata(path) + }, + ); + assert_eq!(visits, 2); + if change == "replace-with-file" { + assert!(result.unwrap_err().contains("读取项目目录失败")); + } else { + assert!(!result.unwrap().files.iter().any(|file| file.kind == "file")); + } + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_reports_target_permission_errors_but_ignores_unrelated_ones() { + use std::os::unix::fs::PermissionsExt; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let blocked = root.join("blocked"); + fs::create_dir(&blocked).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap(); + // 先恢复权限再断言,避免失败时影响临时目录回收。 + let scoped = list_local_project_files_scoped_at(root, Some("assets"), |_| false); + let blocked_result = list_local_project_files_scoped_at(root, Some("blocked"), |_| false); + let permission_denied = fs::read_dir(&blocked).is_err(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(scoped + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + // root / CAP_DAC_OVERRIDE 环境不能制造真实 EACCES;注入用例仍覆盖错误分支。 + if permission_denied { + let error = blocked_result.unwrap_err(); + assert!( + error.contains("读取项目目录失败") && error.contains("blocked"), + "{error}" + ); + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_never_enters_linked_or_replaced_directories() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::write(outside.path().join("private.txt"), b"outside").unwrap(); + symlink(outside.path(), root.join("linked")).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + let mut visits = 0; + let listed = list_local_project_files_with_metadata( + root, + None, + |_| false, + |path| { + if path == root.join("assets") { + visits += 1; + if visits == 2 { + fs::remove_dir(path).unwrap(); + symlink(outside.path(), path).unwrap(); + } + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!(visits, 2); + assert!(!listed.files.iter().any(|file| file.kind == "file")); + for scope in ["linked", "linked/private.txt", "assets/private.txt"] { + assert!( + list_local_project_files_scoped_at(root, Some(scope), |_| false) + .unwrap() + .files + .is_empty() + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index f1ed549ff..0ec6629fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3860,6 +3860,23 @@ fn local_project_file_listing_skips_engine_generated_directories_only_for_engine .expect("write cocos package.json"); let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + for scope in ["library", "library/imported/hero.json", "temp/programming"] { + assert!( + list_local_project_files_scoped_at(cocos, Some(scope), |_| false) + .expect("list generated scope") + .files + .is_empty() + ); + } + fs::create_dir_all(cocos.join("assets/library")).unwrap(); + fs::write(cocos.join("assets/library/kept.png"), b"image").unwrap(); + assert!( + list_local_project_files_scoped_at(cocos, Some("assets/library"), |_| false) + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/library/kept.png") + ); let paths = listed .files .iter() diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cb554f6da..ef8834e21 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -47,6 +47,12 @@ - **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 +## 2026-10-01 文件列举被无关临时锁删除打断 + +- `read_dir` 返回名字后,文件可能在 `symlink_metadata` 前正常消失;`.agent/project.lock` 的正常释放就能触发这类竞态。不要先全项目扫描再按 Agent 的 `path` 和可见性过滤。 +- 项目内列举在元数据读取前裁剪范围和受保护路径,只进入目标子树及必要祖先;共享文件树保留自己的可见性策略。枚举后消失的文件/目录仅跳过 `NotFound`,其它 IO 错误仍可诊断,进入排队目录前复核链接/重解析点。 +- 并发回归用通道协调真实写锁的释放与元数据读取,不靠高频循环碰撞;分页按本次观察到的可见文件排序,不保证跨请求快照。完整合同见 AGC 实施计划“项目文件列举的范围与并发边界”。 + ## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 - 项目外读取不能只依赖末段 `O_NOFOLLOW`:父目录符号链接可隐藏 `.ssh` 等受保护名字。文件读取和目录列表在访问前逐段检查原始路径,拒绝符号链接与 Windows 重解析点;目录扫描对子目录再次检查。系统临时目录若含平台别名(例如 macOS `/var`),普通读取测试使用临时目录的 canonical 路径,不能通过 canonicalize 待读路径来抹掉待检测链接。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8f0361afc..02d6de4ba 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,13 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-10-01 项目文件列举的范围与并发边界 + +`agc_list_project_files` 的项目内 `path` 在遍历时限制到目标文件、目标目录及其必要祖先;不递归无关子树,也不读取无关条目的元数据。Agent 的控制面、敏感文件和快照排除规则在读取条目元数据之前生效;通用 UI 文件树保持原有可见范围。引擎生成目录仍按项目根身份排除,不能因定向列举改变判定基准。 + +文件列举是实时观察,不是文件系统快照。枚举后消失的文件或待进入目录(`NotFound`)跳过;权限、非目录和其它 IO 错误保留路径及原因,不统一吞错或重试。目标不存在时继续返回空列表;符号链接和 Windows 重解析点不得作为遍历入口。过滤后按项目相对路径排序,再计算 `total`、`offset`、`limit` 和 `nextOffset`;多次分页之间的并发变更不承诺快照一致性。 + +本修复不调整项目写锁等待、写入许可、项目外读取或工具 DTO。验收通过受控的枚举/元数据间隙释放真实项目锁,并覆盖定向范围、消失目录、真实 IO 错误、受保护路径、链接、引擎目录及分页;不依赖概率性循环碰撞。 + ## 2026-09-29 Codex 私有运行目录路径解析 新建的私有运行目录先确认是普通目录并收紧权限,再解析真实路径,后续 `codex-home`、`workspace` 和隔离用户目录均在真实路径下创建,避免 macOS 系统临时目录的符号链接阻断启动。Windows 扩展 UNC 路径 `\\?\UNC\server\share\...` 必须转换为 `\\server\share\...`,盘符路径才直接去掉 `\\?\` 前缀;转换后保留绝对路径语义。私有子目录仍执行原有祖先符号链接与 reparse point 检查。 From fbd30d7c6658deba671825dd906664c4f6aa1af1 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:17:28 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=BE=AA=E7=8E=AF=E6=B8=B8=E6=A0=87=E4=B8=8E?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=80=81=E8=A2=AB=E5=BB=B6=E8=BF=9F=20effect?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 界面树循环游标改为渲染期换算有效游标,不再用被动 effect 事后重置 复制路径按钮把复制态绑定到具体路径,去掉延迟重置的 effect 复制态断言改为等待异步落盘,避免同步断言抢跑 新增 useUiTreeNodeCycle 循环顺序与界面树替换重置用例 --- .../components/UiEditorCopyPathButton.tsx | 27 +++--- .../src/view/ui-editor/useUiTreeNodeCycle.ts | 24 ++++- .../tests/uiEditorPage.test.ts | 3 +- .../tests/uiTreeNodeCycle.test.ts | 96 +++++++++++++++++++ 4 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx index ea19e48bf..b33d9e62a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx @@ -1,31 +1,36 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { Copy } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; + +type CopyState = 'idle' | 'copied' | 'failed'; export function UiEditorCopyPathButton({ relativePath, }: { relativePath: string; }) { - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', - ); - - useEffect(() => setCopyState('idle'), [relativePath]); + // 复制态跟着具体路径走:路径变了就等于回到未复制。这里不用 effect 事后重置, + // 因为延迟执行的 effect 会晚于复制结果落盘,把「已复制」覆盖回未复制。 + const [copyState, setCopyState] = useState<{ + relativePath: string; + value: CopyState; + }>({ relativePath, value: 'idle' }); + const value = + copyState.relativePath === relativePath ? copyState.value : 'idle'; async function copyPath() { - setCopyState('idle'); + setCopyState({ relativePath, value: 'idle' }); try { await writeText(relativePath); - setCopyState('copied'); + setCopyState({ relativePath, value: 'copied' }); } catch { - setCopyState('failed'); + setCopyState({ relativePath, value: 'failed' }); } } return ( <> - {copyState === 'failed' ? ( + {value === 'failed' ? (

复制失败,请手动复制路径。

@@ -36,7 +41,7 @@ export function UiEditorCopyPathButton({ onClick={() => void copyPath()} >