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`。