From 81c23891326270cf86b099e2cdad89bdc1a31b86 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:30:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Direct=20=E5=86=99?= =?UTF-8?q?=E9=80=9A=E9=81=93=E9=A1=B9=E7=9B=AE=E9=94=81=E9=9B=B6=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E4=B8=8E=E6=8C=81=E9=94=81=E6=96=B9=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Direct 写路径(agc_write_file)改用统一的有界等待,与 file.write / file.patch / file.delete 同语义,同一轮并行写多个文件按同一把锁串行,不再在 24-42ms 内把重叠判成"项目正在被其他写操作占用" - create_new 失败拆成争用 / 权限拒绝 / 其它三类:锁文件不存在却仍创建失败不再投影成争用;sharing violation(32) 与 lock violation(33) 恒定归争用 - 争用错误与等待日志带上持锁方身份(commandId / pid / createdAt / ownerIsSelf),锁文件处于删除挂起或未写完时显式表达成"身份不可读" - ProjectWriteLockSnapshot 补 commandId 与 describe_holder(),沿用既有快照 + 字节 CAS 回收机制,不新增第二套回收判据 - 有界等待预算耗尽记 project.write_lock.wait_exhausted(含等待毫秒数),权限拒绝记 project.write_lock.permission_denied,争用不在零等待入口里逐次记账 - 新增同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝分类、ACL 拒绝不投影成争用五条回归用例 - 同步 decision-log、pitfalls 与技术方案文档 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 130 +++++++++++++- .../agent/runtime_actions/project_gates.rs | 20 ++- .../src-tauri/src/project/filesystem.rs | 166 ++++++++++++++++-- .../src/tests/project_lock_recovery.rs | 81 +++++++++ .../shared-memory/decision-log.md | 9 + docs/project-memory/shared-memory/pitfalls.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 10 ++ 7 files changed, 405 insertions(+), 20 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 bdbeb222c..677710649 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 @@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { return Err("工具参数 content 不能包含 NUL".to_string()); } reject_command_output_wrapper(content)?; - let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?; + // Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写", + // 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。 + // 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义: + // 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.file.write", + )?; let written = write_local_project_file_at(root, &path, content)?; let revision = advance_agent_runtime_project_revision_locked(root)?; Ok::<_, String>(json!({ @@ -2712,6 +2719,127 @@ mod tests { ); } + /// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功, + /// 而不是互相报"项目正在被其他写操作占用"。 + #[test] + fn bridge_write_file_serializes_parallel_writes_in_one_round() { + let temporary = tempfile::tempdir().expect("create parallel direct write root"); + init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试") + .expect("initialize parallel direct write root"); + let root = temporary.path().to_path_buf(); + let paths = (0..4) + .map(|index| format!("game/parallel-{index}.js")) + .collect::>(); + + let results = std::thread::scope(|scope| { + let handles = paths + .iter() + .map(|path| { + let root = root.clone(); + let path = path.clone(); + scope.spawn(move || { + let result = bridge_write_file( + &root, + &json!({ "path": path, "content": format!("// {path}\n") }), + ); + (path, result) + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().expect("parallel direct write must not panic")) + .collect::>() + }); + + for (path, result) in &results { + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "parallel direct write of {path} must succeed: {result}" + ); + assert_eq!( + fs::read_to_string(root.join(path)).expect("read parallel direct write"), + format!("// {path}\n") + ); + } + } + + /// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时, + /// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。 + #[test] + fn bridge_write_file_waits_for_a_short_same_process_project_writer() { + let temporary = tempfile::tempdir().expect("create contended direct write root"); + init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试") + .expect("initialize contended direct write root"); + let root = temporary.path().to_path_buf(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let holder_barrier = std::sync::Arc::clone(&barrier); + let holder_root = root.clone(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire a short-lived project writer"); + holder_barrier.wait(); + std::thread::sleep(std::time::Duration::from_millis(300)); + drop(lock); + }); + barrier.wait(); + + let result = bridge_write_file( + &root, + &json!({ "path": "game/waited.js", "content": "// waited\n" }), + ); + + holder.join().expect("join the short-lived project writer"); + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "the direct write must wait out a short same-process writer: {result}" + ); + assert_eq!( + fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"), + "// waited\n" + ); + } + + /// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。 + #[cfg(unix)] + #[test] + fn bridge_write_file_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().expect("create acl direct write root"); + init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试") + .expect("initialize acl direct write root"); + let agent_directory = temporary.path().join(".agent"); + let original = fs::metadata(&agent_directory) + .expect("read control directory metadata") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("drop write permission on the control directory"); + + let result = bridge_write_file( + temporary.path(), + &json!({ "path": "game/acl-denied.js", "content": "// denied\n" }), + ); + fs::set_permissions(&agent_directory, original) + .expect("restore control directory permission"); + + if result.get("isError").and_then(Value::as_bool) != Some(true) { + // 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。 + return; + } + let text = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + !text.contains("项目正在被其他写操作占用"), + "a permission denial must not be projected as lock contention: {text}" + ); + assert!(!temporary.path().join("game/acl-denied.js").exists()); + } + #[test] fn resource_request_uuid_is_stable_v4_and_domain_separated() { let operation = direct_resource_request_uuid("turn-1", "operation", "abc"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 71d9f51e6..14e802659 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1833,16 +1833,28 @@ fn acquire_game_creator_agent_runtime_project_write_lock_within( max_attempts: usize, ) -> Result { let max_attempts = max_attempts.max(1); + let started_at = std::time::Instant::now(); for attempt in 0..max_attempts { - match acquire_project_write_lock(root, command_id) { + let contention = match acquire_project_write_lock(root, command_id) { Err(error) - if error.starts_with("项目正在被其他写操作占用:") - && attempt + 1 < max_attempts => + if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { - std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); + error } result => return result, + }; + if attempt + 1 == max_attempts { + // 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。 + // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人"。 + app_log!( + "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} holder={}", + attempt, + started_at.elapsed().as_millis(), + crate::project::project_write_lock_contention_diagnostic(root) + ); + return Err(contention); } + std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); } unreachable!("project write lock retry loop always returns") } 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 02f3c83a0..3b3837176 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 @@ -208,6 +208,7 @@ pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> #[derive(Debug, Clone)] pub(crate) struct ProjectWriteLockSnapshot { content: Vec, + command_id: Option, pid: Option, created_at: Option, process_started_at: Option, @@ -224,12 +225,38 @@ impl ProjectWriteLockSnapshot { .and_then(serde_json::Value::as_u64) }; Some(Self { + command_id: payload + .as_ref() + .and_then(|payload| payload.get("commandId")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), pid: number("pid"), created_at: number("createdAt"), process_started_at: number("processStartedAt"), content, }) } + + /// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、 + /// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。 + /// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。 + pub(crate) fn describe_holder(&self) -> String { + format!( + "commandId={} pid={} createdAt={} ownerIsSelf={}", + self.command_id.as_deref().unwrap_or("unknown"), + self.pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + self.created_at + .map(|created_at| created_at.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + match self.pid { + Some(pid) if pid == u64::from(std::process::id()) => "true", + Some(_) => "false", + None => "unknown", + }, + ) + } } fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { @@ -344,32 +371,120 @@ pub(crate) fn project_write_lock_reclaim( } } -fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { +/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、 +/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 +/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 +pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; + +/// `create_new` 失败到底意味着什么。三类的处置完全不同:争用可以等待,权限拒绝必须 +/// 失败关闭,其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectWriteLockOpenFailure { + Contention, + Permission, + Other, +} + +fn project_write_lock_classify_open_error( + error: &std::io::Error, + lock_path_exists: bool, +) -> ProjectWriteLockOpenFailure { if error.kind() == std::io::ErrorKind::AlreadyExists { - return true; + return ProjectWriteLockOpenFailure::Contention; } #[cfg(windows)] { - // Windows can report an existing or delete-pending create_new target as - // ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it. - return error.kind() == std::io::ErrorKind::PermissionDenied - || matches!(error.raw_os_error(), Some(5 | 32 | 33)); + // Windows 会把已存在或处于 delete-pending 的 create_new 目标报成 ACCESS_DENIED + // 而不是 ALREADY_EXISTS。32 / 33 是 sharing violation 与 lock violation,只可能 + // 在目标被占用时出现,恒定归争用。 + if matches!(error.raw_os_error(), Some(32 | 33)) { + return ProjectWriteLockOpenFailure::Contention; + } + // ACCESS_DENIED(5) 有两种含义,只能靠"目标是否存在"区分:delete-pending 或存在 + // 的目标是争用;目标并不存在却仍创建失败,是真正的权限 / ACL 拒绝。 + if error.kind() == std::io::ErrorKind::PermissionDenied || error.raw_os_error() == Some(5) { + return if lock_path_exists { + ProjectWriteLockOpenFailure::Contention + } else { + ProjectWriteLockOpenFailure::Permission + }; + } } #[cfg(not(windows))] - false + { + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } + } + ProjectWriteLockOpenFailure::Other +} + +/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, +/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +fn project_write_lock_contention_error( + path: &Path, + snapshot: Option<&ProjectWriteLockSnapshot>, +) -> String { + match snapshot { + Some(snapshot) => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", + path.display(), + snapshot.describe_holder() + ), + None => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)", + path.display() + ), + } +} + +fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String { + // 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把 + // 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。 + format!( + "项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}", + path.display() + ) +} + +/// 等待预算耗尽时写进 App 日志的持锁方快照。 +pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String { + let Ok(path) = resolve_project_write_lock_path(root) else { + return "持锁方身份不可解析".to_string(); + }; + match ProjectWriteLockSnapshot::read(&path) { + Some(snapshot) => snapshot.describe_holder(), + None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(), + } } #[cfg(all(test, windows))] #[test] fn project_write_lock_treats_windows_target_races_as_contention() { for code in [5, 32, 33] { - assert!( - project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)), - "Windows project lock error {code} must enter the bounded contention wait" + assert_eq!( + project_write_lock_classify_open_error( + &std::io::Error::from_raw_os_error(code), + true + ), + ProjectWriteLockOpenFailure::Contention, + "Windows project lock error {code} with an existing target must enter the bounded contention wait" ); } } +#[cfg(windows)] +#[test] +fn project_write_lock_reports_acl_denial_without_an_existing_target() { + // delete-pending 与真实 ACL 拒绝在 Windows 上同为 ACCESS_DENIED(5);目标不存在时 + // 必须落到权限类,否则 ACL 问题会被投影成"别人在写"。 + assert_eq!( + project_write_lock_classify_open_error(&std::io::Error::from_raw_os_error(5), false), + ProjectWriteLockOpenFailure::Permission + ); +} + #[cfg(all(test, windows))] #[test] fn project_write_lock_hardens_space_containing_path_in_process() { @@ -473,10 +588,29 @@ pub(crate) fn acquire_project_write_lock( bypassed_same_process: false, }); } - Err(error) if project_write_lock_open_error_is_contention(&error) => { + Err(error) => { + let failure = project_write_lock_classify_open_error(&error, path.exists()); + if failure == ProjectWriteLockOpenFailure::Permission { + // 权限类错误不会重试,所以在这里记录:它必须能在 App 日志里 + // 和"别人正在写"区分开。 + app_log!( + "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", + path.display(), + error.raw_os_error() + ); + return Err(project_write_lock_permission_error(&path, &error)); + } + if failure == ProjectWriteLockOpenFailure::Other { + return Err(format!("创建项目写锁失败:{}: {error}", path.display())); + } if !retried_after_reclaim { if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { if project_write_lock_reclaim(&path, &snapshot)? { + app_log!( + "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", + path.display(), + snapshot.describe_holder() + ); retried_after_reclaim = true; continue; } @@ -496,10 +630,12 @@ pub(crate) fn acquire_project_write_lock( bypassed_same_process: true, }); } - return Err(format!("项目正在被其他写操作占用:{}", path.display())); - } - Err(error) => { - return Err(format!("创建项目写锁失败:{}: {error}", path.display())); + // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, + // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 + return Err(project_write_lock_contention_error( + &path, + ProjectWriteLockSnapshot::read(&path).as_ref(), + )); } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs index 9f7dc6541..ef40e1a95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs @@ -356,3 +356,84 @@ fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() { ); fs::remove_dir_all(root).ok(); } + +/// Issue #318 取证缺口:争用错误必须点名持锁方,现场才能回答"谁在持锁、是不是自己人"。 +/// 另一个活进程持锁时必须报成争用、带出身份,并且不能把它的锁当成残留回收掉。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_contention_names_a_live_external_holder() { + let root = unique_project_path(); + fs::create_dir_all(root.join(".agent")).expect("创建 .agent 目录"); + let holder = spawn_unrelated_live_process(); + let holder_pid = holder.id(); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "external-editor-writer", + "pid": holder_pid, + "createdAt": unix_timestamp(), + "nonce": 7, + })) + .expect("序列化外部持有者锁 fixture"), + ); + let held_content = fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("读回持有者锁"); + + let error = + acquire_project_write_lock(&root, "file.write").expect_err("活的外部进程持锁时必须报争用"); + assert!( + error.starts_with("项目正在被其他写操作占用:"), + "争用必须保持共享前缀:{error}" + ); + assert!( + error.contains("commandId=external-editor-writer"), + "错误必须点名持锁命令:{error}" + ); + assert!( + error.contains(&format!("pid={holder_pid}")), + "错误必须点名持锁进程:{error}" + ); + assert!( + error.contains("ownerIsSelf=false"), + "错误必须说明持锁方不是本进程:{error}" + ); + assert_eq!( + fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("复查持有者锁"), + held_content, + "活外部持有者的锁文件不得被回收或改写" + ); + assert!( + project_write_lock_contention_diagnostic(&root).contains(&format!("pid={holder_pid}")), + "等待日志必须带同一份持锁方身份" + ); + + stop_unrelated_live_process(holder); + fs::remove_dir_all(root).ok(); +} + +/// Issue #318 第 3 条:权限拒绝不能再投影成"被其他写操作占用"。 +/// 文案刻意不含争用前缀,有界等待和前端才不会把它当成"等一下就好"的瞬时状态。 +#[cfg(unix)] +#[test] +fn project_write_lock_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let root = unique_project_path(); + let agent_directory = root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("创建 .agent 目录"); + let original = fs::metadata(&agent_directory) + .expect("读取控制目录元数据") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("去掉控制目录写权限"); + + let error = + acquire_project_write_lock(&root, "file.write").expect_err("只读控制目录必须挡住取锁"); + fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限"); + + assert!( + !error.starts_with("项目正在被其他写操作占用:"), + "权限拒绝不得投影成写锁争用:{error}" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index acd0c02a6..04774e523 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8197,3 +8197,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 - 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 - 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 + +## 2026-09-10 Direct 写通道纳入统一项目锁等待窗口并补齐持锁方可诊断 + +- 背景:Issue #318。`agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,却用零等待取锁,任何重叠都在 24-42ms 内被投影成“项目正在被其他写操作占用”;同一形状已在 2026-07-22 由 `file.write / file.patch / file.delete` 用有界等待修过,本项目技术方案的 2026-08-13 一节也已规定这类争用结果“统一投影为争用并进入既有有界等待”。现场取证还缺 `commandId / pid / createdAt / ownerIsSelf`,无法回答“谁在持锁”,加上 `create_new` 把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,排障被引向“残留锁”。 +- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成争用(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类;Windows 上 delete-pending 与 ACL 拒绝同为 `ACCESS_DENIED(5)`,按“目标是否存在”区分,`sharing violation(32)` 与 `lock violation(33)` 恒定归争用。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照与等待时长,权限拒绝按 `project.write_lock.permission_denied` 记录。 +- 复用既有实现:回收判据沿用 2026-09-09 的 `ProjectWriteLockSnapshot` + `project_write_lock_reclaim_decision` + 字节 CAS 删除,不新增第二套回收机制;本次只给快照补 `commandId` 与 `describe_holder()`,供错误文案和日志使用。 +- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。 +- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、ACL 拒绝不投影成争用三条。 +- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e90226dc7..dcccf53d6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5059,3 +5059,12 @@ - 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。 - 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。 + +## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断 + +- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed` 且 `durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files`、`agc_list_registered_assets`、`client.session.info`)全部正常,整轮无法写入任何项目文件。 +- **原因**:`.agent/project.lock` 是 `create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。 +- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按争用 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。 +- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 是同进程另一条写通道,`false` 才是真外部进程。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。 +- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、以及权限拒绝不投影成争用。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9313fba40..eb544aa90 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1337,3 +1337,13 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 - 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 - 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。 + +## 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 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。 +- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态。 +- `create_new` 的失败必须分三类处置,不能再共用一句文案:目标已存在(含 Windows delete-pending)是争用,进入有界等待;锁文件并不存在却仍创建失败是权限 / ACL 拒绝,失败关闭且文案不得含争用前缀;其它 I/O 错误原样上报。Windows 上 delete-pending 与真实 ACL 拒绝同为 `ACCESS_DENIED(5)`,只能靠“目标是否存在”区分;`sharing violation(32)` 与 `lock violation(33)` 只可能在目标被占用时出现,恒定归争用。归类判据以纯函数形式保留,便于不依赖 ACL 环境直接断言。 +- 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。 +- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数与持锁方身份,权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。 +- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、真实存活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用。对应 Rust `project_lock_recovery` 与 `direct_tool_bridge` 定向测试。 +- 仍待收口(后续事项):其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用时仍会立刻失败。