From 81c23891326270cf86b099e2cdad89bdc1a31b86 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:30:22 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Direct=20?= =?UTF-8?q?=E5=86=99=E9=80=9A=E9=81=93=E9=A1=B9=E7=9B=AE=E9=94=81=E9=9B=B6?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E4=B8=8E=E6=8C=81=E9=94=81=E6=96=B9=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=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 命令`)本批不改,遇到同类争用时仍会立刻失败。 -- 2.52.0 From 4ecab19429a10060ef0bf0ceecf88ece0e53645b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:52:30 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20CI=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=94=A8=E4=BE=8B=E5=9C=A8=20root=20=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E4=B8=8B=E7=9A=84=E5=89=8D=E6=8F=90=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=97=A0=E5=85=B3=E7=9A=84=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_write_lock_does_not_project_permission_denial_as_contention 不再用 expect_err 断言“只读目录必须挡住取锁”:CI 容器以 root 运行,0o500 不生效,取锁会正常成功;此时跳过端到端前提 - 新增平台无关用例 project_write_lock_classifies_by_whether_the_target_exists:目标存在才是争用、目标不存在却创建失败是权限拒绝、NotFound 归其它 - 让权限分类判据在不依赖 ACL 环境的条件下也有回归护栏,避免只靠会被 root 绕过的端到端用例 --- .../src-tauri/src/project/filesystem.rs | 27 +++++++++++++++++++ .../src/tests/project_lock_recovery.rs | 9 +++++-- 2 files changed, 34 insertions(+), 2 deletions(-) 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 3b3837176..80014ff44 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 @@ -485,6 +485,33 @@ fn project_write_lock_reports_acl_denial_without_an_existing_target() { ); } +/// 分类判据本身与平台无关,两个平台都要盯住:目标存在才是争用,目标不存在却创建失败 +/// 是权限拒绝。这条用例不依赖 ACL 环境,因此在 CI 容器以 root 运行时仍然有效。 +#[test] +fn project_write_lock_classifies_by_whether_the_target_exists() { + assert_eq!( + project_write_lock_classify_open_error( + &std::io::Error::from(std::io::ErrorKind::AlreadyExists), + true + ), + ProjectWriteLockOpenFailure::Contention + ); + assert_eq!( + project_write_lock_classify_open_error( + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + false + ), + ProjectWriteLockOpenFailure::Permission + ); + assert_eq!( + project_write_lock_classify_open_error( + &std::io::Error::from(std::io::ErrorKind::NotFound), + false + ), + ProjectWriteLockOpenFailure::Other + ); +} + #[cfg(all(test, windows))] #[test] fn project_write_lock_hardens_space_containing_path_in_process() { 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 ef40e1a95..f7de9d54e 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 @@ -426,10 +426,15 @@ fn project_write_lock_does_not_project_permission_denial_as_contention() { fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) .expect("去掉控制目录写权限"); - let error = - acquire_project_write_lock(&root, "file.write").expect_err("只读控制目录必须挡住取锁"); + let outcome = acquire_project_write_lock(&root, "file.write"); fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限"); + // CI 容器以 root 运行,0o500 目录照样可以创建文件;此时本用例的前提不成立, + // 直接跳过。分类判据本身另有不依赖 ACL 环境的纯函数用例覆盖。 + let Err(error) = outcome else { + return; + }; + assert!( !error.starts_with("项目正在被其他写操作占用:"), "权限拒绝不得投影成写锁争用:{error}" -- 2.52.0 From 8c639e5d1333e4bf84a36b04283d9d128678acf0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 20:07:28 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=86=99=E9=94=81=E9=87=8D=E8=AF=95=E5=88=A4=E6=8D=AE=E7=94=A8?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=85=83=E6=95=B0=E6=8D=AE=E8=A7=82=E5=AF=9F?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E7=9E=AC=E6=97=B6=E4=BA=89=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目写锁分类改为只按错误码判定重试性,不再用 path.exists() 决定“要不要等”:真机 6 万次建锁/删锁竞争实测 396-538 例命中“ACCESS_DENIED(5) + 目标不可见”,旧判据会让等待层立刻失败关闭,把毫秒级竞争换成更误导的 ACL 文案 - 新增 ProjectWriteLockFailure(Retryable / Terminal)与 acquire_project_write_lock_failure,有界等待改按类型分流,acquire_project_write_lock 退化为它的文案包装 - Windows 的 ACCESS_DENIED(5) 终态改判移到等待预算耗尽之后:只有真的等过预算且目标此刻仍不存在时才投影成权限拒绝,单次试探保持争用语义 - 锁分类判据改为平台参数传入(project_write_lock_open_failure_for),Linux CI 可覆盖 Windows 分支;替换原先只在 Windows 本地执行的分类用例 - 零等待入口在错误码不可区分时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,争用前缀逐字不变,调用方既有重试语义不受影响 - PROJECT_WRITE_LOCK_CONTENTION_PREFIX 收口 provider_recovery.rs、planning_session_v2.rs、direct_runtime.rs 三处手写文案 - project.write_lock.wait_exhausted 日志补 projection= 分类,attempts 记为实际尝试次数 - 同步更新技术方案、decision-log、pitfalls,并修正 ownerIsSelf 只比 PID 的表述 --- .../src-tauri/src/agent/direct_runtime.rs | 2 +- .../agent/runtime_actions/project_gates.rs | 33 +- .../agent/runtime_driver/provider_recovery.rs | 2 +- .../runtime_protocol/planning_session_v2.rs | 2 +- .../src-tauri/src/project/filesystem.rs | 359 +++++++++++++----- .../shared-memory/decision-log.md | 5 +- docs/project-memory/shared-memory/pitfalls.md | 7 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 12 +- 8 files changed, 307 insertions(+), 115 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index d40b85804..2d94613ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st if normalized.contains("permission-denied") || normalized.contains("http 403") { return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; } - if error.contains("项目正在被其他写操作占用") { + if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { return "当前项目仍有写入正在结束,请稍后再次发送该需求"; } if error.contains("身份不唯一") 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 14e802659..f1fde1023 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 @@ -1822,11 +1822,12 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200; /// Take the project write lock, riding out transient contention for at most /// `max_attempts` polls. /// -/// `项目正在被其他写操作占用:` is the one lock error that means -/// "nothing is broken, the current holder is mid-write" — every other variant -/// (a torn lock file, a denied path) is returned immediately. Callers pick the -/// budget from what a lost race costs them: a one-shot user intent waits out the -/// full window, a poll that will run again shortly waits far less. +/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的 +/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是 +/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上—— +/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race +/// costs them: a one-shot user intent waits out the full window, a poll that will run +/// again shortly waits far less. fn acquire_game_creator_agent_runtime_project_write_lock_within( root: &Path, command_id: &str, @@ -1835,24 +1836,24 @@ fn acquire_game_creator_agent_runtime_project_write_lock_within( let max_attempts = max_attempts.max(1); let started_at = std::time::Instant::now(); for attempt in 0..max_attempts { - let contention = match acquire_project_write_lock(root, command_id) { - Err(error) - if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => - { - error - } - result => return result, + let failure = match acquire_project_write_lock_failure(root, command_id) { + Ok(lock) => return Ok(lock), + Err(failure) if failure.is_retryable() => failure, + Err(failure) => return Err(failure.message()), }; if attempt + 1 == max_attempts { // 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。 - // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人"。 + // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人", + // 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。 + // 单次试探(max_attempts == 1)没有等待证据,不做终态改判。 + let (projection, message) = failure.exhausted_projection(max_attempts > 1); app_log!( - "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} holder={}", - attempt, + "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}", + attempt + 1, started_at.elapsed().as_millis(), crate::project::project_write_lock_contention_diagnostic(root) ); - return Err(contention); + return Err(message); } std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index efd6d42fd..518dabcd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome { pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); - error.starts_with("项目正在被其他写操作占用:") + error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) || error.contains("另一个程序正在使用此文件") || normalized.contains("sharing violation") || normalized.contains("lock violation") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs index 43dbe909a..765a14a82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2( "planning.v2.hydrate", ) { Ok(lock) => lock, - Err(error) if error.starts_with("项目正在被其他写操作占用:") => { + Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { return Ok(None) } Err(error) => return Err(error), 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 80014ff44..876720523 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 @@ -376,55 +376,141 @@ pub(crate) fn project_write_lock_reclaim( /// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; -/// `create_new` 失败到底意味着什么。三类的处置完全不同:争用可以等待,权限拒绝必须 -/// 失败关闭,其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: +/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 +/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectWriteLockPlatform { + Windows, + Unix, +} + +pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { + ProjectWriteLockPlatform::Windows +} else { + ProjectWriteLockPlatform::Unix +}; + +/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 +/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL /// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +/// +/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: +/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 +/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 +/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 +/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 +/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProjectWriteLockOpenFailure { - Contention, + /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 + Retryable, + /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 Permission, + /// 其它 I/O 错误,原样上报。 Other, } -fn project_write_lock_classify_open_error( +fn project_write_lock_open_failure_for( + platform: ProjectWriteLockPlatform, error: &std::io::Error, - lock_path_exists: bool, ) -> ProjectWriteLockOpenFailure { if error.kind() == std::io::ErrorKind::AlreadyExists { - return ProjectWriteLockOpenFailure::Contention; + return ProjectWriteLockOpenFailure::Retryable; } - #[cfg(windows)] - { - // 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; + match platform { + ProjectWriteLockPlatform::Windows => { + // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 + // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL + // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 + if matches!(error.raw_os_error(), Some(5 | 32 | 33)) + || error.kind() == std::io::ErrorKind::PermissionDenied + { + return ProjectWriteLockOpenFailure::Retryable; + } } - // 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))] - { - if error.kind() == std::io::ErrorKind::PermissionDenied { - return ProjectWriteLockOpenFailure::Permission; + ProjectWriteLockPlatform::Unix => { + // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, + // 可以立刻判定,不必让调用方白等一个等待窗口。 + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } } } ProjectWriteLockOpenFailure::Other } +/// 一次取锁失败的完整形状。 +/// +/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 +/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 +#[derive(Debug)] +pub(crate) enum ProjectWriteLockFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 + /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + Retryable { + path: PathBuf, + source: std::io::Error, + }, + /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 + Terminal(String), +} + +/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, +/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 +fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { + source.kind() == std::io::ErrorKind::PermissionDenied +} + +impl ProjectWriteLockFailure { + /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable { .. }) + } + + /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 + /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 + pub(crate) fn message(&self) -> String { + match self { + Self::Terminal(message) => message.clone(), + Self::Retryable { path, source } => project_write_lock_contention_error( + path, + ProjectWriteLockSnapshot::read(path).as_ref(), + project_write_lock_permission_is_ambiguous(source) && !path.exists(), + ), + } + } + + /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 + /// + /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, + /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) + /// 没有等待证据,保持争用语义,不做终态改判。 + pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { + let Self::Retryable { path, source } = self else { + return ("terminal", self.message()); + }; + if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { + return ( + "permission_denied", + project_write_lock_permission_error(path, source), + ); + } + ("contention", self.message()) + } +} + /// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, /// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +/// +/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 +/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 +/// 出来,而不是替调用方猜一个。 fn project_write_lock_contention_error( path: &Path, snapshot: Option<&ProjectWriteLockSnapshot>, + permission_ambiguous: bool, ) -> String { match snapshot { Some(snapshot) => format!( @@ -432,6 +518,10 @@ fn project_write_lock_contention_error( path.display(), snapshot.describe_holder() ), + None if permission_ambiguous => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", + path.display() + ), None => format!( "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)", path.display() @@ -459,56 +549,115 @@ pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String { } } -#[cfg(all(test, windows))] +/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, +/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 +/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 #[test] -fn project_write_lock_treats_windows_target_races_as_contention() { - for code in [5, 32, 33] { +fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { + for error in [ + std::io::Error::from_raw_os_error(5), + std::io::Error::from_raw_os_error(32), + std::io::Error::from_raw_os_error(33), + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::AlreadyExists), + ] { assert_eq!( - project_write_lock_classify_open_error( - &std::io::Error::from_raw_os_error(code), - true + project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), + ProjectWriteLockOpenFailure::Retryable, + "Windows 上 {error:?} 必须进入有界等待" + ); + } + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::AlreadyExists) + ), + ProjectWriteLockOpenFailure::Retryable, + "Unix 上目标存在必须是争用" + ); + // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied) + ), + ProjectWriteLockOpenFailure::Permission + ); + for platform in [ + ProjectWriteLockPlatform::Windows, + ProjectWriteLockPlatform::Unix, + ] { + assert_eq!( + project_write_lock_open_failure_for( + platform, + &std::io::Error::from(std::io::ErrorKind::NotFound) ), - ProjectWriteLockOpenFailure::Contention, - "Windows project lock error {code} with an existing target must enter the bounded contention wait" + ProjectWriteLockOpenFailure::Other, + "其它 I/O 错误必须原样上报,不进入等待" ); } } -#[cfg(windows)] +/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 +/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 #[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 +fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { + let temporary = tempfile::tempdir().expect("create projection root"); + let path = temporary.path().join("project.lock"); + let ambiguous = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert!( + ambiguous.is_retryable(), + "ACCESS_DENIED 必须允许进入有界等待" ); -} -/// 分类判据本身与平台无关,两个平台都要盯住:目标存在才是争用,目标不存在却创建失败 -/// 是权限拒绝。这条用例不依赖 ACL 环境,因此在 CI 容器以 root 运行时仍然有效。 -#[test] -fn project_write_lock_classifies_by_whether_the_target_exists() { - assert_eq!( - project_write_lock_classify_open_error( - &std::io::Error::from(std::io::ErrorKind::AlreadyExists), - true - ), - ProjectWriteLockOpenFailure::Contention + // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "permission_denied"); + assert!( + !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "预算耗尽且目标缺失时不得再投影成争用:{message}" ); - assert_eq!( - project_write_lock_classify_open_error( - &std::io::Error::from(std::io::ErrorKind::PermissionDenied), - false - ), - ProjectWriteLockOpenFailure::Permission + + // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 + let (projection, message) = ambiguous.exhausted_projection(false); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" ); - assert_eq!( - project_write_lock_classify_open_error( - &std::io::Error::from(std::io::ErrorKind::NotFound), - false - ), - ProjectWriteLockOpenFailure::Other + + // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 + fs::write(&path, b"{}").expect("write a visible lock fixture"); + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + fs::remove_file(&path).expect("remove lock fixture"); + + // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 + let occupied = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(32), + }; + assert_eq!(occupied.exhausted_projection(true).0, "contention"); + + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 + let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( + &path, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!(!denied.is_retryable()); + assert!( + !denied + .message() + .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{}", + denied.message() ); } @@ -550,15 +699,29 @@ pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, ) -> Result { - validate_project_root(root)?; - let mut path = resolve_project_write_lock_path(root)?; + acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) +} + +/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 +/// +/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 +/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 +pub(crate) fn acquire_project_write_lock_failure( + root: &Path, + command_id: &str, +) -> Result { + validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; + let mut path = + resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "项目锁目录")?; - prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?; + ensure_game_creator_private_directory_tree(parent, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; } // Re-check the parent after creation so skipping metadata only for the final // create_new target cannot weaken the normal ancestor link/reparse checks. - path = resolve_project_write_lock_path(root)?; + path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; let payload = serde_json::json!({ "commandId": command_id, "pid": std::process::id(), @@ -570,7 +733,8 @@ pub(crate) fn acquire_project_write_lock( "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), }); let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成项目写锁失败:{error}"))?; + .map_err(|error| format!("生成项目写锁失败:{error}")) + .map_err(ProjectWriteLockFailure::Terminal)?; let mut retried_after_reclaim = false; loop { let mut options = fs::OpenOptions::new(); @@ -585,29 +749,41 @@ pub(crate) fn acquire_project_write_lock( if let Err(error) = file.write_all(content.as_bytes()) { drop(file); let _ = fs::remove_file(&path); - return Err(format!("写入项目写锁失败:{}: {error}", path.display())); + return Err(ProjectWriteLockFailure::Terminal(format!( + "写入项目写锁失败:{}: {error}", + path.display() + ))); } if let Err(error) = file.sync_all() { drop(file); let _ = fs::remove_file(&path); - return Err(format!("落盘项目写锁失败:{}: {error}", path.display())); + return Err(ProjectWriteLockFailure::Terminal(format!( + "落盘项目写锁失败:{}: {error}", + path.display() + ))); } drop(file); if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") { let _ = fs::remove_file(&path); - return Err(error); + return Err(ProjectWriteLockFailure::Terminal(error)); } let actual = match fs::read_to_string(&path) { Ok(actual) => actual, Err(error) => { let _ = fs::remove_file(&path); - return Err(format!("读取项目写锁失败:{}: {error}", path.display())); + return Err(ProjectWriteLockFailure::Terminal(format!( + "读取项目写锁失败:{}: {error}", + path.display() + ))); } }; if actual != content { let _ = fs::remove_file(&path); - return Err(format!("项目写锁内容校验失败:{}", path.display())); + return Err(ProjectWriteLockFailure::Terminal(format!( + "项目写锁内容校验失败:{}", + path.display() + ))); } return Ok(ProjectWriteLock { path, @@ -616,23 +792,34 @@ pub(crate) fn acquire_project_write_lock( }); } Err(error) => { - let failure = project_write_lock_classify_open_error(&error, path.exists()); + // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): + // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 + let failure = + project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); if failure == ProjectWriteLockOpenFailure::Permission { - // 权限类错误不会重试,所以在这里记录:它必须能在 App 日志里 - // 和"别人正在写"区分开。 + // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED + // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 + // 这里记录:它必须能在 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)); + return Err(ProjectWriteLockFailure::Terminal( + project_write_lock_permission_error(&path, &error), + )); } if failure == ProjectWriteLockOpenFailure::Other { - return Err(format!("创建项目写锁失败:{}: {error}", path.display())); + return Err(ProjectWriteLockFailure::Terminal(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)? { + if project_write_lock_reclaim(&path, &snapshot) + .map_err(ProjectWriteLockFailure::Terminal)? + { app_log!( "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", path.display(), @@ -659,10 +846,10 @@ pub(crate) fn acquire_project_write_lock( } // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 - return Err(project_write_lock_contention_error( - &path, - ProjectWriteLockSnapshot::read(&path).as_ref(), - )); + return Err(ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: error, + }); } } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 04774e523..4095ab9cd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8201,8 +8201,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 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` 记录。 +- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。 +- 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。 - 复用既有实现:回收判据沿用 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 拒绝不投影成争用三条。 +- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、ACL 拒绝不投影成争用三条;`project/filesystem` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 3 条、`parent_wake` 16 条、`waits_across` 3 条全过。 - 关联文档:`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 9e9f14a08..d5978358b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5072,7 +5072,8 @@ - **现象**: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 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、以及权限拒绝不投影成争用。 +- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按可重试 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。 +- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。 +- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 +- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 - **关联**:`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 eb544aa90..cc9458b19 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1341,9 +1341,11 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 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 环境直接断言。 +- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。 +- `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。 +- Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。 +- 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / 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 命令`)本批不改,遇到同类争用时仍会立刻失败。 +- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。 +- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/filesystem` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 +- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② `project/filesystem.rs` 已经堆了回收、分类、诊断三套锁策略(1533 行),`direct_tool_bridge.rs` 也超过 3000 行,后续把锁策略拆到 `project/write_lock.rs`、Direct 锁用例移到 `tests/project_lock_recovery.rs`。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 -- 2.52.0 From 06f738dc996fba48d675e3325fd0de5492db5b78 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 20:12:28 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=B0=86=E9=A1=B9=E7=9B=AE=E5=86=99?= =?UTF-8?q?=E9=94=81=E4=BB=8E=20project/filesystem.rs=20=E7=BA=AF=E6=90=AC?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=20project/write=5Flock.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 project/write_lock.rs:取锁、等待分类、持锁方诊断、残留回收与 4 条锁用例整体搬移,逻辑不变 - project/filesystem.rs 只保留项目文件 IO(1533 → 680 行),锁相关常量、结构、进程判据与用例全部移出 - project.rs 注册 mod write_lock 并 pub(crate) use write_lock::*,crate::project:: 与 crate:: 既有路径不变 - windows_metadata_is_reparse_point 提为 pub(crate),供 write_lock 复用同一条 reparse point 判据 - agent_db.rs 与 checkpoint.rs 的 PROJECT_FILE_FLAG_OPEN_REPARSE_POINT 导入路径改为 super::write_lock - 同步修正技术方案、Fast GDD 技术方案、decision-log、pitfalls 中指向锁实现的文件路径,并把“拆锁”从后续事项改为已完成 --- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/agent_db.rs | 2 +- .../src-tauri/src/project/checkpoint.rs | 2 +- .../src-tauri/src/project/filesystem.rs | 859 +----------------- .../src-tauri/src/project/write_lock.rs | 856 +++++++++++++++++ .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 2 +- 9 files changed, 869 insertions(+), 864 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4ffe94674..003d4464b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -16,6 +16,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_canvas::*; @@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index ca30e8bf4..08691c487 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2,7 +2,7 @@ use super::*; use std::collections::BTreeSet; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 9c99aaede..380cc2836 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -3,7 +3,7 @@ use super::*; use super::filesystem::validate_portable_project_path_component; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; #[cfg(windows)] fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> { 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 876720523..6f067628b 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,860 +1,5 @@ use super::*; -#[cfg(windows)] -pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - -static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(1); -const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; -/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 -/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 -/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 -const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; -/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 -const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; -const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; - -#[derive(Debug)] -pub(crate) struct ProjectWriteLock { - path: PathBuf, - content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. - bypassed_same_process: bool, -} - -impl ProjectWriteLock { - pub(crate) fn guards_project_root(&self, root: &Path) -> Result { - let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; - if self.bypassed_same_process { - // The relaxed guard deliberately has no ownership of the durable - // `.agent/project.lock` file. It still binds the observation to - // the validated project root so callers cannot use a guard from a - // different project. - return Ok(self.path == expected_path); - } - Ok(self.path == expected_path - && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) - } -} - -impl Drop for ProjectWriteLock { - fn drop(&mut self) { - if self.bypassed_same_process { - return; - } - if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(unix)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 - // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - let result = unsafe { libc::kill(process_id, 0) }; - if result == 0 { - return Some(true); - } - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => Some(false), - Some(libc::EPERM) => Some(true), - _ => None, - } -} - -#[cfg(windows)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - use std::ffi::c_void; - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 - // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - const STILL_ACTIVE: u32 = 259; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is - // closed below. We only request the query permission needed here. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - // ERROR_INVALID_PARAMETER means the process no longer exists. For - // access-denied/other failures we cannot prove liveness, so keep the - // conservative unknown result and let the normal bounded wait decide. - return match std::io::Error::last_os_error().raw_os_error() { - Some(87) => Some(false), - _ => None, - }; - } - let mut exit_code = 0_u32; - // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. - let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - Some(exit_code == STILL_ACTIVE) -} - -#[cfg(not(any(unix, windows)))] -fn project_write_lock_process_is_alive(_process_id: u64) -> Option { - None -} - -/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 -/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 -/// 保持原有的保守回收策略。 -#[cfg(windows)] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - use std::ffi::c_void; - - #[repr(C)] - struct FileTime { - low_date_time: u32, - high_date_time: u32, - } - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetProcessTimes( - process: *mut c_void, - creation_time: *mut FileTime, - exit_time: *mut FileTime, - kernel_time: *mut FileTime, - user_time: *mut FileTime, - ) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 - const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - return None; - } - // SAFETY: every FileTime is plain data filled by GetProcessTimes. - let mut creation = unsafe { std::mem::zeroed::() }; - let mut exit = unsafe { std::mem::zeroed::() }; - let mut kernel = unsafe { std::mem::zeroed::() }; - let mut user = unsafe { std::mem::zeroed::() }; - // SAFETY: `process` is a live handle and all four pointers are writable scalars. - let result = - unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); - file_time - .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) - .map(|unix_100ns| unix_100ns / 10_000_000) -} - -#[cfg(target_os = "linux")] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. - let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; - if clock_ticks <= 0 { - return None; - } - let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; - let start_ticks = stat - .rsplit_once(") ")? - .1 - .split_whitespace() - .nth(19)? - .parse::() - .ok()?; - let boot_time = fs::read_to_string("/proc/stat") - .ok()? - .lines() - .find_map(|line| line.strip_prefix("btime "))? - .trim() - .parse::() - .ok()?; - Some(boot_time + start_ticks / clock_ticks as u64) -} - -#[cfg(not(any(windows, target_os = "linux")))] -pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { - None -} - -/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: -/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 -/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 -#[derive(Debug, Clone)] -pub(crate) struct ProjectWriteLockSnapshot { - content: Vec, - command_id: Option, - pid: Option, - created_at: Option, - process_started_at: Option, -} - -impl ProjectWriteLockSnapshot { - pub(crate) fn read(path: &Path) -> Option { - let content = fs::read(path).ok()?; - let payload = serde_json::from_slice::(&content).ok(); - let number = |key: &str| { - payload - .as_ref() - .and_then(|payload| payload.get(key)) - .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 { - ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) - == Some(u64::from(std::process::id())) -} - -/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” -/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 -/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 -fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { - metadata - .modified() - .ok() - .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs()) -} - -/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 -/// 按“不回收”处理,不能退化成 0 或极大值。 -fn project_write_lock_age_seconds( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, -) -> Option { - if let Some(created_at) = snapshot.created_at { - return Some(now.saturating_sub(created_at)); - } - modified_at.map(|modified_at| now.saturating_sub(modified_at)) -} - -/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 -/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 -pub(crate) fn project_write_lock_reclaim_decision( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, - process_is_alive: impl Fn(u64) -> Option, - process_started_at: impl Fn(u64) -> Option, -) -> bool { - let Some(owner_pid) = snapshot.pid else { - // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 - return project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); - }; - match process_is_alive(owner_pid) { - Some(false) => true, - Some(true) => { - // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 - match (snapshot.process_started_at, process_started_at(owner_pid)) { - // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 - (Some(stored), Some(actual)) => stored != actual, - // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; - // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 - (None, Some(actual)) => { - let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { - return false; - }; - actual - > lock_created_at - .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) - } - _ => false, - } - } - // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 - None => project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), - } -} - -/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 -fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { - let metadata = fs::symlink_metadata(path).ok()?; - if metadata.file_type().is_symlink() - || windows_metadata_is_reparse_point(&metadata) - || !metadata.is_file() - || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES - { - return None; - } - let snapshot = ProjectWriteLockSnapshot::read(path)?; - project_write_lock_reclaim_decision( - &snapshot, - project_write_lock_file_modified_seconds(&metadata), - unix_timestamp(), - project_write_lock_process_is_alive, - project_write_lock_process_start_time_seconds, - ) - .then_some(snapshot) -} - -/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 -/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 -/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 -pub(crate) fn project_write_lock_reclaim( - path: &Path, - snapshot: &ProjectWriteLockSnapshot, -) -> Result { - match fs::read(path) { - Ok(content) if content == snapshot.content => {} - Ok(_) => return Ok(false), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); - } - } - match fs::remove_file(path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), - } -} - -/// `.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 = "项目正在被其他写操作占用:"; - -/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: -/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 -/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ProjectWriteLockPlatform { - Windows, - Unix, -} - -pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { - ProjectWriteLockPlatform::Windows -} else { - ProjectWriteLockPlatform::Unix -}; - -/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 -/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL -/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 -/// -/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: -/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 -/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 -/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 -/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 -/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProjectWriteLockOpenFailure { - /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 - Retryable, - /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 - Permission, - /// 其它 I/O 错误,原样上报。 - Other, -} - -fn project_write_lock_open_failure_for( - platform: ProjectWriteLockPlatform, - error: &std::io::Error, -) -> ProjectWriteLockOpenFailure { - if error.kind() == std::io::ErrorKind::AlreadyExists { - return ProjectWriteLockOpenFailure::Retryable; - } - match platform { - ProjectWriteLockPlatform::Windows => { - // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 - // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL - // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 - if matches!(error.raw_os_error(), Some(5 | 32 | 33)) - || error.kind() == std::io::ErrorKind::PermissionDenied - { - return ProjectWriteLockOpenFailure::Retryable; - } - } - ProjectWriteLockPlatform::Unix => { - // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, - // 可以立刻判定,不必让调用方白等一个等待窗口。 - if error.kind() == std::io::ErrorKind::PermissionDenied { - return ProjectWriteLockOpenFailure::Permission; - } - } - } - ProjectWriteLockOpenFailure::Other -} - -/// 一次取锁失败的完整形状。 -/// -/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 -/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 -#[derive(Debug)] -pub(crate) enum ProjectWriteLockFailure { - /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 - /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 - Retryable { - path: PathBuf, - source: std::io::Error, - }, - /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 - Terminal(String), -} - -/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, -/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 -fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { - source.kind() == std::io::ErrorKind::PermissionDenied -} - -impl ProjectWriteLockFailure { - /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 - pub(crate) fn is_retryable(&self) -> bool { - matches!(self, Self::Retryable { .. }) - } - - /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 - /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 - /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 - pub(crate) fn message(&self) -> String { - match self { - Self::Terminal(message) => message.clone(), - Self::Retryable { path, source } => project_write_lock_contention_error( - path, - ProjectWriteLockSnapshot::read(path).as_ref(), - project_write_lock_permission_is_ambiguous(source) && !path.exists(), - ), - } - } - - /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 - /// - /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, - /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) - /// 没有等待证据,保持争用语义,不做终态改判。 - pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { - let Self::Retryable { path, source } = self else { - return ("terminal", self.message()); - }; - if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { - return ( - "permission_denied", - project_write_lock_permission_error(path, source), - ); - } - ("contention", self.message()) - } -} - -/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, -/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 -/// -/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 -/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 -/// 出来,而不是替调用方猜一个。 -fn project_write_lock_contention_error( - path: &Path, - snapshot: Option<&ProjectWriteLockSnapshot>, - permission_ambiguous: bool, -) -> String { - match snapshot { - Some(snapshot) => format!( - "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", - path.display(), - snapshot.describe_holder() - ), - None if permission_ambiguous => format!( - "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", - path.display() - ), - 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(), - } -} - -/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, -/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 -/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 -#[test] -fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { - for error in [ - std::io::Error::from_raw_os_error(5), - std::io::Error::from_raw_os_error(32), - std::io::Error::from_raw_os_error(33), - std::io::Error::from(std::io::ErrorKind::PermissionDenied), - std::io::Error::from(std::io::ErrorKind::AlreadyExists), - ] { - assert_eq!( - project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), - ProjectWriteLockOpenFailure::Retryable, - "Windows 上 {error:?} 必须进入有界等待" - ); - } - assert_eq!( - project_write_lock_open_failure_for( - ProjectWriteLockPlatform::Unix, - &std::io::Error::from(std::io::ErrorKind::AlreadyExists) - ), - ProjectWriteLockOpenFailure::Retryable, - "Unix 上目标存在必须是争用" - ); - // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 - assert_eq!( - project_write_lock_open_failure_for( - ProjectWriteLockPlatform::Unix, - &std::io::Error::from(std::io::ErrorKind::PermissionDenied) - ), - ProjectWriteLockOpenFailure::Permission - ); - for platform in [ - ProjectWriteLockPlatform::Windows, - ProjectWriteLockPlatform::Unix, - ] { - assert_eq!( - project_write_lock_open_failure_for( - platform, - &std::io::Error::from(std::io::ErrorKind::NotFound) - ), - ProjectWriteLockOpenFailure::Other, - "其它 I/O 错误必须原样上报,不进入等待" - ); - } -} - -/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 -/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 -#[test] -fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { - let temporary = tempfile::tempdir().expect("create projection root"); - let path = temporary.path().join("project.lock"); - let ambiguous = ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: std::io::Error::from_raw_os_error(5), - }; - assert!( - ambiguous.is_retryable(), - "ACCESS_DENIED 必须允许进入有界等待" - ); - - // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 - let (projection, message) = ambiguous.exhausted_projection(true); - assert_eq!(projection, "permission_denied"); - assert!( - !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "预算耗尽且目标缺失时不得再投影成争用:{message}" - ); - - // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 - let (projection, message) = ambiguous.exhausted_projection(false); - assert_eq!(projection, "contention"); - assert!( - message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{message}" - ); - - // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 - fs::write(&path, b"{}").expect("write a visible lock fixture"); - let (projection, message) = ambiguous.exhausted_projection(true); - assert_eq!(projection, "contention"); - assert!( - message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{message}" - ); - fs::remove_file(&path).expect("remove lock fixture"); - - // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 - let occupied = ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: std::io::Error::from_raw_os_error(32), - }; - assert_eq!(occupied.exhausted_projection(true).0, "contention"); - - // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 - let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( - &path, - &std::io::Error::from(std::io::ErrorKind::PermissionDenied), - )); - assert!(!denied.is_retryable()); - assert!( - !denied - .message() - .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), - "{}", - denied.message() - ); -} - -#[cfg(all(test, windows))] -#[test] -fn project_write_lock_hardens_space_containing_path_in_process() { - let parent = tempfile::tempdir().expect("create spaced lock parent"); - let root = parent - .path() - .join("Genarrative GameAgent") - .join("gameagent-space"); - fs::create_dir_all(&root).expect("create spaced project root"); - let lock = acquire_project_write_lock(&root, "planning.v2.approval") - .expect("acquire project lock under a space-containing path"); - let lock_path = root.join(".agent").join("project.lock"); - assert!(lock_path.is_file(), "project lock must exist while held"); - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) - .expect("new project lock must already satisfy the private DACL contract"); - drop(lock); - assert!( - !lock_path.exists(), - "project lock must be removed when the guard is dropped" - ); -} - -fn resolve_project_write_lock_path(root: &Path) -> Result { - let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; - let (parent_relative, file_name) = normalized - .rsplit_once('/') - .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; - let parent = resolve_local_project_path(root, parent_relative)?; - // create_new is the authority for the final lock component. On Windows a - // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED - // before the existing bounded contention wait has a chance to run. - Ok(parent.join(file_name)) -} - -pub(crate) fn acquire_project_write_lock( - root: &Path, - command_id: &str, -) -> Result { - acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) -} - -/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 -/// -/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 -/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 -pub(crate) fn acquire_project_write_lock_failure( - root: &Path, - command_id: &str, -) -> Result { - validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; - let mut path = - resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "项目锁目录") - .map_err(ProjectWriteLockFailure::Terminal)?; - prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") - .map_err(ProjectWriteLockFailure::Terminal)?; - } - // Re-check the parent after creation so skipping metadata only for the final - // create_new target cannot weaken the normal ancestor link/reparse checks. - path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; - let payload = serde_json::json!({ - "commandId": command_id, - "pid": std::process::id(), - // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 - "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( - std::process::id() - )), - "createdAt": unix_timestamp(), - "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }); - let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成项目写锁失败:{error}")) - .map_err(ProjectWriteLockFailure::Terminal)?; - let mut retried_after_reclaim = false; - loop { - let mut options = fs::OpenOptions::new(); - options.create_new(true).write(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - match options.open(&path) { - Ok(mut file) => { - if let Err(error) = file.write_all(content.as_bytes()) { - drop(file); - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "写入项目写锁失败:{}: {error}", - path.display() - ))); - } - if let Err(error) = file.sync_all() { - drop(file); - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "落盘项目写锁失败:{}: {error}", - path.display() - ))); - } - drop(file); - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(error)); - } - let actual = match fs::read_to_string(&path) { - Ok(actual) => actual, - Err(error) => { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "读取项目写锁失败:{}: {error}", - path.display() - ))); - } - }; - if actual != content { - let _ = fs::remove_file(&path); - return Err(ProjectWriteLockFailure::Terminal(format!( - "项目写锁内容校验失败:{}", - path.display() - ))); - } - return Ok(ProjectWriteLock { - path, - content: content.clone(), - bypassed_same_process: false, - }); - } - Err(error) => { - // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): - // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 - let failure = - project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); - if failure == ProjectWriteLockOpenFailure::Permission { - // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED - // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 - // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 - app_log!( - "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", - path.display(), - error.raw_os_error() - ); - return Err(ProjectWriteLockFailure::Terminal( - project_write_lock_permission_error(&path, &error), - )); - } - if failure == ProjectWriteLockOpenFailure::Other { - return Err(ProjectWriteLockFailure::Terminal(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) - .map_err(ProjectWriteLockFailure::Terminal)? - { - app_log!( - "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", - path.display(), - snapshot.describe_holder() - ); - retried_after_reclaim = true; - continue; - } - } - } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } - // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, - // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 - return Err(ProjectWriteLockFailure::Retryable { - path: path.clone(), - source: error, - }); - } - } - } -} - pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -1426,7 +571,9 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } -fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { +/// 路径是否是指向别处的 reparse point(符号链接、junction 等)。项目锁文件与项目文件 +/// 遍历都要靠它拒绝“名字在项目里、内容在项目外”的对象,因此对 `write_lock` 可见。 +pub(crate) fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { #[cfg(windows)] { use std::os::windows::fs::MetadataExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs new file mode 100644 index 000000000..bb4f9d323 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -0,0 +1,856 @@ +use super::*; + +#[cfg(windows)] +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + +static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); +const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 +/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 +/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 +const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; +/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 +const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; +const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; + +#[derive(Debug)] +pub(crate) struct ProjectWriteLock { + path: PathBuf, + content: String, + /// In the free-form autonomous lane a single Runtime process may have + /// several specialist actions in flight at once. A file lock is still + /// useful across processes, but making same-process contenders fail turns + /// ordinary parallel work into a dead run (and can deadlock nested tool + /// calls). Such a contender receives an in-process/advisory guard instead + /// of deleting the real holder's lock on drop. + bypassed_same_process: bool, +} + +impl ProjectWriteLock { + pub(crate) fn guards_project_root(&self, root: &Path) -> Result { + let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + if self.bypassed_same_process { + // The relaxed guard deliberately has no ownership of the durable + // `.agent/project.lock` file. It still binds the observation to + // the validated project root so callers cannot use a guard from a + // different project. + return Ok(self.path == expected_path); + } + Ok(self.path == expected_path + && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) + } +} + +impl Drop for ProjectWriteLock { + fn drop(&mut self) { + if self.bypassed_same_process { + return; + } + if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + let result = unsafe { libc::kill(process_id, 0) }; + if result == 0 { + return Some(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Some(false), + Some(libc::EPERM) => Some(true), + _ => None, + } +} + +#[cfg(windows)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is + // closed below. We only request the query permission needed here. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + // ERROR_INVALID_PARAMETER means the process no longer exists. For + // access-denied/other failures we cannot prove liveness, so keep the + // conservative unknown result and let the normal bounded wait decide. + return match std::io::Error::last_os_error().raw_os_error() { + Some(87) => Some(false), + _ => None, + }; + } + let mut exit_code = 0_u32; + // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. + let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + Some(exit_code == STILL_ACTIVE) +} + +#[cfg(not(any(unix, windows)))] +fn project_write_lock_process_is_alive(_process_id: u64) -> Option { + None +} + +/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 +/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 +/// 保持原有的保守回收策略。 +#[cfg(windows)] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetProcessTimes( + process: *mut c_void, + creation_time: *mut FileTime, + exit_time: *mut FileTime, + kernel_time: *mut FileTime, + user_time: *mut FileTime, + ) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 + const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + // SAFETY: every FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: `process` is a live handle and all four pointers are writable scalars. + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); + file_time + .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) + .map(|unix_100ns| unix_100ns / 10_000_000) +} + +#[cfg(target_os = "linux")] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. + let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clock_ticks <= 0 { + return None; + } + let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; + let start_ticks = stat + .rsplit_once(") ")? + .1 + .split_whitespace() + .nth(19)? + .parse::() + .ok()?; + let boot_time = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + Some(boot_time + start_ticks / clock_ticks as u64) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { + None +} + +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + command_id: Option, + pid: Option, + created_at: Option, + process_started_at: Option, +} + +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { + payload + .as_ref() + .and_then(|payload| payload.get(key)) + .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 { + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) +} + +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); + } + modified_at.map(|modified_at| now.saturating_sub(modified_at)) +} + +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); + }; + match process_is_alive(owner_pid) { + Some(false) => true, + Some(true) => { + // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 + match (snapshot.process_started_at, process_started_at(owner_pid)) { + // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 + (Some(stored), Some(actual)) => stored != actual, + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 + (None, Some(actual)) => { + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; + actual + > lock_created_at + .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) + } + _ => false, + } + } + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return None; + } + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); + } + } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), + } +} + +/// `.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 = "项目正在被其他写操作占用:"; + +/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: +/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 +/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectWriteLockPlatform { + Windows, + Unix, +} + +pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { + ProjectWriteLockPlatform::Windows +} else { + ProjectWriteLockPlatform::Unix +}; + +/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 +/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +/// +/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: +/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 +/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 +/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 +/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 +/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectWriteLockOpenFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 + Retryable, + /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 + Permission, + /// 其它 I/O 错误,原样上报。 + Other, +} + +fn project_write_lock_open_failure_for( + platform: ProjectWriteLockPlatform, + error: &std::io::Error, +) -> ProjectWriteLockOpenFailure { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return ProjectWriteLockOpenFailure::Retryable; + } + match platform { + ProjectWriteLockPlatform::Windows => { + // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 + // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL + // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 + if matches!(error.raw_os_error(), Some(5 | 32 | 33)) + || error.kind() == std::io::ErrorKind::PermissionDenied + { + return ProjectWriteLockOpenFailure::Retryable; + } + } + ProjectWriteLockPlatform::Unix => { + // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, + // 可以立刻判定,不必让调用方白等一个等待窗口。 + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } + } + } + ProjectWriteLockOpenFailure::Other +} + +/// 一次取锁失败的完整形状。 +/// +/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 +/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 +#[derive(Debug)] +pub(crate) enum ProjectWriteLockFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 + /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + Retryable { + path: PathBuf, + source: std::io::Error, + }, + /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 + Terminal(String), +} + +/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, +/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 +fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { + source.kind() == std::io::ErrorKind::PermissionDenied +} + +impl ProjectWriteLockFailure { + /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable { .. }) + } + + /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 + /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 + pub(crate) fn message(&self) -> String { + match self { + Self::Terminal(message) => message.clone(), + Self::Retryable { path, source } => project_write_lock_contention_error( + path, + ProjectWriteLockSnapshot::read(path).as_ref(), + project_write_lock_permission_is_ambiguous(source) && !path.exists(), + ), + } + } + + /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 + /// + /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, + /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) + /// 没有等待证据,保持争用语义,不做终态改判。 + pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { + let Self::Retryable { path, source } = self else { + return ("terminal", self.message()); + }; + if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { + return ( + "permission_denied", + project_write_lock_permission_error(path, source), + ); + } + ("contention", self.message()) + } +} + +/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, +/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +/// +/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 +/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 +/// 出来,而不是替调用方猜一个。 +fn project_write_lock_contention_error( + path: &Path, + snapshot: Option<&ProjectWriteLockSnapshot>, + permission_ambiguous: bool, +) -> String { + match snapshot { + Some(snapshot) => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", + path.display(), + snapshot.describe_holder() + ), + None if permission_ambiguous => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", + path.display() + ), + 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(), + } +} + +/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, +/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 +/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 +#[test] +fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { + for error in [ + std::io::Error::from_raw_os_error(5), + std::io::Error::from_raw_os_error(32), + std::io::Error::from_raw_os_error(33), + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::AlreadyExists), + ] { + assert_eq!( + project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), + ProjectWriteLockOpenFailure::Retryable, + "Windows 上 {error:?} 必须进入有界等待" + ); + } + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::AlreadyExists) + ), + ProjectWriteLockOpenFailure::Retryable, + "Unix 上目标存在必须是争用" + ); + // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied) + ), + ProjectWriteLockOpenFailure::Permission + ); + for platform in [ + ProjectWriteLockPlatform::Windows, + ProjectWriteLockPlatform::Unix, + ] { + assert_eq!( + project_write_lock_open_failure_for( + platform, + &std::io::Error::from(std::io::ErrorKind::NotFound) + ), + ProjectWriteLockOpenFailure::Other, + "其它 I/O 错误必须原样上报,不进入等待" + ); + } +} + +/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 +/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 +#[test] +fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { + let temporary = tempfile::tempdir().expect("create projection root"); + let path = temporary.path().join("project.lock"); + let ambiguous = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert!( + ambiguous.is_retryable(), + "ACCESS_DENIED 必须允许进入有界等待" + ); + + // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "permission_denied"); + assert!( + !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "预算耗尽且目标缺失时不得再投影成争用:{message}" + ); + + // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 + let (projection, message) = ambiguous.exhausted_projection(false); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + + // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 + fs::write(&path, b"{}").expect("write a visible lock fixture"); + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + fs::remove_file(&path).expect("remove lock fixture"); + + // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 + let occupied = ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: std::io::Error::from_raw_os_error(32), + }; + assert_eq!(occupied.exhausted_projection(true).0, "contention"); + + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 + let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( + &path, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!(!denied.is_retryable()); + assert!( + !denied + .message() + .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{}", + denied.message() + ); +} + +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + +pub(crate) fn acquire_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) +} + +/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 +/// +/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 +/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 +pub(crate) fn acquire_project_write_lock_failure( + root: &Path, + command_id: &str, +) -> Result { + validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; + let mut path = + resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + let payload = serde_json::json!({ + "commandId": command_id, + "pid": std::process::id(), + // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 + "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( + std::process::id() + )), + "createdAt": unix_timestamp(), + "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成项目写锁失败:{error}")) + .map_err(ProjectWriteLockFailure::Terminal)?; + let mut retried_after_reclaim = false; + loop { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "写入项目写锁失败:{}: {error}", + path.display() + ))); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "落盘项目写锁失败:{}: {error}", + path.display() + ))); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(error)); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "读取项目写锁失败:{}: {error}", + path.display() + ))); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "项目写锁内容校验失败:{}", + path.display() + ))); + } + return Ok(ProjectWriteLock { + path, + content: content.clone(), + bypassed_same_process: false, + }); + } + Err(error) => { + // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): + // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 + let failure = + project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); + if failure == ProjectWriteLockOpenFailure::Permission { + // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED + // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 + // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 + app_log!( + "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", + path.display(), + error.raw_os_error() + ); + return Err(ProjectWriteLockFailure::Terminal( + project_write_lock_permission_error(&path, &error), + )); + } + if failure == ProjectWriteLockOpenFailure::Other { + return Err(ProjectWriteLockFailure::Terminal(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) + .map_err(ProjectWriteLockFailure::Terminal)? + { + app_log!( + "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", + path.display(), + snapshot.describe_holder() + ); + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } + // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, + // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 + return Err(ProjectWriteLockFailure::Retryable { + path: path.clone(), + source: error, + }); + } + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4095ab9cd..d29d54561 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -27,7 +27,7 @@ - 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 - 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 -- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 +- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 - 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d5978358b..05cf7b501 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5058,7 +5058,7 @@ - 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。 - 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。 - 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 ## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09) @@ -5066,7 +5066,7 @@ - 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。 - 处理: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`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`。 ## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断 @@ -5076,4 +5076,4 @@ - **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。 - **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 - **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 -- **关联**:`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`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.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 cc9458b19..cfb01e78f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1348,4 +1348,4 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。 - 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。 - 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/filesystem` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 -- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② `project/filesystem.rs` 已经堆了回收、分类、诊断三套锁策略(1533 行),`direct_tool_bridge.rs` 也超过 3000 行,后续把锁策略拆到 `project/write_lock.rs`、Direct 锁用例移到 `tests/project_lock_recovery.rs`。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 +- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(856 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3054 行,锁用例与 120 行桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 48a74dd94..5f231d263 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1896,7 +1896,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | 现役 pending wire | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520` | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire | | submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 | | JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper | -| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | +| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | | completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 | | plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`) | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 | | plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`) | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`) | -- 2.52.0 From 71e9ad31334b2aefcee2b5c8c597c2aec081fb46 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 20:46:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=81=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=88=E6=80=81=E5=88=A4=E6=8D=AE=E6=BC=8F=E4=BC=A0?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AF=BC=E8=87=B4=20CI=20=E6=8A=8A=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=94=B9=E5=88=A4=E6=88=90=E4=BA=89=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_write_lock_permission_is_ambiguous 改为按平台加原始错误码判定(Windows 的 ACCESS_DENIED(5));只看 ErrorKind 在 Linux 上会得出相反结论:errno 5 在 Windows 是 ACCESS_DENIED、在 Linux 是 EIO - ProjectWriteLockFailure::Retryable 携带 platform,终态改判与争用文案都用同一次分类的平台,不再依赖宿主 errno 语义 - 终态投影用例显式用 Windows 平台构造失败并补 Unix 反例,两个平台上结论一致;CI 首次推送正是在此失败(2358 passed / 1 failed,left contention / right permission_denied) - pitfalls 补充“判据的每一环都要带平台”的排障经验 --- .../src-tauri/src/project/write_lock.rs | 49 +++++++++++++++---- docs/project-memory/shared-memory/pitfalls.md | 2 +- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index bb4f9d323..f615b2ddb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -447,8 +447,9 @@ fn project_write_lock_open_failure_for( #[derive(Debug)] pub(crate) enum ProjectWriteLockFailure { /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 - /// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + /// 保留平台与 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 Retryable { + platform: ProjectWriteLockPlatform, path: PathBuf, source: std::io::Error, }, @@ -456,10 +457,16 @@ pub(crate) enum ProjectWriteLockFailure { Terminal(String), } -/// Windows 上 `ACCESS_DENIED(5)` 既可能是删除拆链窗口,也可能是真实权限拒绝, -/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。 -fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool { - source.kind() == std::io::ErrorKind::PermissionDenied +/// 只有 Windows 的 `ACCESS_DENIED(5)` 才可能在等待之后被改判:它在分类阶段与删除拆链 +/// 窗口不可区分。平台必须一起传进来——Linux 上 errno 5 是 `EIO` 而不是 `EACCES`, +/// 只看 `kind()` 会让同一条判据在两个平台上得出不同结论,而这条判据正是要在 CI 上跑。 +fn project_write_lock_permission_is_ambiguous( + platform: ProjectWriteLockPlatform, + source: &std::io::Error, +) -> bool { + platform == ProjectWriteLockPlatform::Windows + && (source.kind() == std::io::ErrorKind::PermissionDenied + || source.raw_os_error() == Some(5)) } impl ProjectWriteLockFailure { @@ -474,10 +481,14 @@ impl ProjectWriteLockFailure { pub(crate) fn message(&self) -> String { match self { Self::Terminal(message) => message.clone(), - Self::Retryable { path, source } => project_write_lock_contention_error( + Self::Retryable { + platform, + path, + source, + } => project_write_lock_contention_error( path, ProjectWriteLockSnapshot::read(path).as_ref(), - project_write_lock_permission_is_ambiguous(source) && !path.exists(), + project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists(), ), } } @@ -488,10 +499,16 @@ impl ProjectWriteLockFailure { /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) /// 没有等待证据,保持争用语义,不做终态改判。 pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { - let Self::Retryable { path, source } = self else { + let Self::Retryable { + platform, + path, + source, + } = self + else { return ("terminal", self.message()); }; - if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() { + if waited && project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists() + { return ( "permission_denied", project_write_lock_permission_error(path, source), @@ -600,11 +617,15 @@ fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_prob /// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 /// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 +/// +/// 平台连同错误一起构造,用例因此不依赖宿主的 errno 语义:Linux 上 errno 5 是 `EIO` +/// 而不是 `EACCES`,只看 `kind()` 会让同一条判据在 CI 与 Windows 上得出不同结论。 #[test] fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { let temporary = tempfile::tempdir().expect("create projection root"); let path = temporary.path().join("project.lock"); let ambiguous = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, path: path.clone(), source: std::io::Error::from_raw_os_error(5), }; @@ -641,11 +662,20 @@ fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_t // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 let occupied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, path: path.clone(), source: std::io::Error::from_raw_os_error(32), }; assert_eq!(occupied.exhausted_projection(true).0, "contention"); + // Unix 侧没有这种不可区分的错误码:EACCES 在分类阶段就是终态,永远不会被改判。 + let unix_denied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Unix, + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert_eq!(unix_denied.exhausted_projection(true).0, "contention"); + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( &path, @@ -847,6 +877,7 @@ pub(crate) fn acquire_project_write_lock_failure( // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 return Err(ProjectWriteLockFailure::Retryable { + platform: PROJECT_WRITE_LOCK_PLATFORM, path: path.clone(), source: error, }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 05cf7b501..9a0bf6edd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5073,7 +5073,7 @@ - **现象**: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` 区分“自己人”和“别人”。 -- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。 +- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。 - **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 - **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。 -- 2.52.0 From ff42fff61cbaa0ce8bb13ebe6df95e762780a0f0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 21:38:48 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E6=8C=89=E8=AF=84=E5=AE=A1=E6=84=8F?= =?UTF-8?q?=E8=A7=81=E6=8A=8A=E5=86=99=E9=94=81=E7=AD=89=E5=BE=85=E6=8C=AA?= =?UTF-8?q?=E5=87=BA=20runtime=20worker=20=E5=B9=B6=E6=94=B6=E7=B4=A7=20wa?= =?UTF-8?q?it=5Fexhausted=20=E8=AE=B0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agc_write_file 写路径改经 bridge_write_file_in_blocking_pool 走 tokio::task::spawn_blocking:有界等待是同步轮询(最多约 10 秒),直接在 async handler 里跑会占住 tokio worker,争用窗口内同一轮并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令 - 新增用例用默认 current_thread runtime 加心跳任务锁住该性质;把 handler 临时改回同步直调时该用例按预期失败,确认有区分度 - project.write_lock.wait_exhausted 与终态改判一起改为只在真的等过(max_attempts > 1)时发生:hydrate 的单次试探不再写 waitedMs 近似 0 的“耗尽”日志 - 技术方案、decision-log、pitfalls 同步这两条,并补“同步有界等待不能直接跑在 async handler 里”的排障经验 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 83 ++++++++++++++++++- .../agent/runtime_actions/project_gates.rs | 21 +++-- .../shared-memory/decision-log.md | 4 +- docs/project-memory/shared-memory/pitfalls.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 +- 5 files changed, 103 insertions(+), 15 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 677710649..d1dead65a 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 @@ -1500,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { } } +/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async, +/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个 +/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime, +/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。 +/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。 +async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value { + let task_root = root.clone(); + match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await { + Ok(result) => result, + Err(error) => bridge_tool_result( + redact_agent_runtime_error( + &root, + &format!("agc_write_file 阻塞任务未返回:{error}"), + 480, + ), + Vec::new(), + true, + ), + } +} + fn bridge_safe_account_asset_projection(asset: &Value) -> Option { let asset_id = asset.get("assetId").and_then(Value::as_str)?; if asset_id.trim().is_empty() { @@ -2314,7 +2335,9 @@ async fn handle_direct_tool_bridge( bridge_list_registered_assets(&state.root, &request.arguments) } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), - "agc_write_file" => bridge_write_file(&state.root, &request.arguments), + "agc_write_file" => { + bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await + } "agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await, "agc_import_account_assets" => { bridge_import_account_assets(&state, &request.arguments).await @@ -2802,6 +2825,64 @@ mod tests { ); } + /// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池, + /// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步 + /// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。 + #[tokio::test] + async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + let temporary = tempfile::tempdir().expect("create blocking pool write root"); + init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试") + .expect("initialize blocking pool write root"); + let state = direct_tool_bridge_state(temporary.path().to_path_buf()); + + // 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。 + let holder_root = temporary.path().to_path_buf(); + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire the concurrent project writer"); + let holder = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(250)); + drop(lock); + }); + + // 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。 + let heartbeat = Arc::new(AtomicBool::new(false)); + let heartbeat_writer = Arc::clone(&heartbeat); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + heartbeat_writer.store(true, Ordering::SeqCst); + }); + + let response = handle_direct_tool_bridge( + axum::extract::State(state), + axum::Json(DirectToolBridgeRequest { + tool: "agc_write_file".to_string(), + arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }), + }), + ) + .await + .0; + + holder.join().expect("join the concurrent project writer"); + assert!( + heartbeat.load(Ordering::SeqCst), + "写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker" + ); + assert_eq!( + response.get("isError").and_then(Value::as_bool), + Some(false), + "the pooled direct write must still wait out the holder: {response}" + ); + assert_eq!( + fs::read_to_string(temporary.path().join("game/blocking-pool.js")) + .expect("read pooled direct write"), + "// pooled\n" + ); + } + /// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。 #[cfg(unix)] #[test] 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 f1fde1023..95b17611d 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 @@ -1845,14 +1845,19 @@ fn acquire_game_creator_agent_runtime_project_write_lock_within( // 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。 // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人", // 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。 - // 单次试探(max_attempts == 1)没有等待证据,不做终态改判。 - let (projection, message) = failure.exhausted_projection(max_attempts > 1); - app_log!( - "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}", - attempt + 1, - started_at.elapsed().as_millis(), - crate::project::project_write_lock_contention_diagnostic(root) - ); + // 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待: + // 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate + // 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。 + let waited = max_attempts > 1; + let (projection, message) = failure.exhausted_projection(waited); + if waited { + app_log!( + "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}", + attempt + 1, + started_at.elapsed().as_millis(), + crate::project::project_write_lock_contention_diagnostic(root) + ); + } return Err(message); } std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d29d54561..e874353b4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8201,9 +8201,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 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 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。 +- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。①′ 这条等待是同步轮询(最多约 10 秒),handler 是 async,因此写路径经 `spawn_blocking` 走阻塞线程池:直接在 handler 里同步等待会占住 tokio worker,争用窗口内并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令,破坏 Issue #318 现场“只读工具全部正常”的诊断特征。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生,hydrate 的单次试探既不写日志也不改判。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。⑤ 平台判据的每一环都要带平台:终态改判也曾只比 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,CI 直接把它判成 `contention`;现由 `Retryable { platform, path, source }` 携带平台。 - 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。 - 复用既有实现:回收判据沿用 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 拒绝不投影成争用三条;`project/filesystem` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 3 条、`parent_wake` 16 条、`waits_across` 3 条全过。 +- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。 - 关联文档:`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 9a0bf6edd..451a00738 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5074,6 +5074,7 @@ - **原因**:`.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` 区分“自己人”和“别人”。 - **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。 +- **补充:同步有界等待不能直接跑在 async handler 里**。这条等待是 2 000 × 5ms 的同步轮询(最多约 10 秒),而 `handle_direct_tool_bridge` 是 async:直接在 handler 里等待会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而 bridge 与只读端点、UI 命令共享同一个 runtime——于是“只读工具全部正常”这条现场诊断特征会在争用窗口内失效,把排障引向错误方向(本次现场正是靠它判断“写锁没释放”的)。做法是把整条写路径挪进 `tokio::task::spawn_blocking`(仓库既有模式),等待语义与错误文案都不变;用例用默认 `current_thread` runtime 加心跳任务锁住这一点:handler 一旦同步阻塞,同一 runtime 上的心跳就完全停摆。**这类“零等待改成有界等待”的改动都要同时问一句:调用方是不是 async,等待窗口会不会占住执行器。** - **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 -- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 +- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、有界等待不占 runtime worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.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 cfb01e78f..5842afc88 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1341,11 +1341,12 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 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 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。 +- 这条等待是**同步轮询**(2_000 × 5ms,最多约 10 秒),而 `handle_direct_tool_bridge` 是 async handler:直接在 handler 里跑完整条写路径会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime——Issue #318 现场“只读工具全部正常”这条诊断特征会在争用窗口内失效。因此写路径经 `bridge_write_file_in_blocking_pool` 走 `tokio::task::spawn_blocking`(仓库既有模式,如 `codex_app_server.rs` 的 DirectProject 历史落盘),等待语义与错误文案不变;定向用例用默认 `current_thread` runtime 加心跳任务锁住“等待期间 runtime 仍在推进”。 - 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。 - `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。 - Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。 - 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,把两种处置都交给调用方,而不是替它猜一个。 - 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。 -- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。 -- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/filesystem` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 -- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(856 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3054 行,锁用例与 120 行桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 +- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。 +- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 +- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 -- 2.52.0