修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断 #320

Merged
lhk229 merged 8 commits from fix/issue-318-direct-write-lock-wait into master 2026-09-10 21:43:38 +08:00
Member

草稿状态保留,等人工评审后再转正式;CI 已全绿。

关联 Issue #318。

这次修了什么

Issue #318 的场景是:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 agc_write_file 每次都返回「项目正在被其他写操作占用」,单次 durationMs 只有 24-42ms。

根因不是锁没释放,而是这条通道根本没等.agent/project.lockcreate_new 存在性锁,Direct 通道调的是零等待的 acquire_project_write_lock,而 file.write / file.patch / file.delete 等入口用的是约 10 秒有界等待。本仓技术方案 2026-08-13 一节已经规定这类争用结果要「统一投影为争用并进入既有有界等待」,所以几十毫秒的失败时长本身就是判据。

  1. Direct 写路径纳入统一有界等待direct_tool_bridge.rs)——短暂重叠排队等成功,同一轮并行写多个文件按同一把锁串行,只有预算耗尽才报错。
  2. create_new 失败分三类filesystem.rs)——目标已存在(含 Windows delete-pending)是争用;锁文件并不存在却仍创建失败是权限 / ACL 拒绝,失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。Windows 上两者同为 ACCESS_DENIED(5),只能靠「目标是否存在」区分。
  3. 可诊断性——争用错误与等待日志带 commandId / pid / createdAt / ownerIsSelf,锁文件不可读时显式表达成「身份不可读」而不是默认「没有持锁方」;等待预算耗尽记 project.write_lock.wait_exhausted(含等待毫秒数),权限拒绝记 project.write_lock.permission_denied
  4. 复用既有回收机制——沿用 #313ProjectWriteLockSnapshot + 字节 CAS 删除,「活持有者始终不回收」的判据不变;本次只给快照补 commandIddescribe_holder()

本 PR 基于 origin/master(a1b9b2489),已包含 #313 的项目写锁回收实现,不存在回退它的问题。

CI 结果(commit 4ecab1942,全部通过)

检查 结果
Repository checks 3m5s
Frontend tests 4m2s
Backend tests 6m51s
Native shell tests 19m16s

Native shell tests 首轮曾失败一次,原因在测试夹具而不是产品代码:project_write_lock_does_not_project_permission_denial_as_contentionexpect_err 断言「0o500 只读目录必须挡住取锁」,而 CI 容器以 root 运行、权限位不生效,取锁会正常成功。已在第二个 commit 修正:该端到端用例在取锁意外成功时跳过并注明原因,同时新增平台无关project_write_lock_classifies_by_whether_the_target_exists,直接锁住分类判据本身,不再依赖会被 root 绕过的 ACL 环境。

本机验证

  • cargo check --tests 无错误;npm run check:encodinggit diff --check 干净。
  • project_write_lock 家族 19/19、bridge_write_file 3/3、parent_wake 16/16、waits_across 3/3。
  • 本机另有 3 条 Timeout 失败(provider_transient_retry_backoff_*provider_transient_retry_transport_failure_*background_agent_runtime_can_create_checkpoint_before_file_write),已在校验用的 pristine origin/master a1b9b2489 上逐一复跑确认同样失败,属既有环境问题;它们在 CI 上全部通过,与本 PR 无关。

不做项

不放宽项目级串行化语义、不引入可重入锁、不改「同一调用链禁止二次获取项目锁」的约定、不改 AGC 多进程拓扑、不改 pendingOperations 语义、不改「活持有者始终不回收」判据。其余仍用零等待取锁的入口(command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令)不在本次范围,已作为后续事项记入技术方案。

草稿状态保留,等人工评审后再转正式;CI 已全绿。 关联 Issue #318。 ## 这次修了什么 Issue #318 的场景是:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回「项目正在被其他写操作占用」,单次 `durationMs` 只有 24-42ms。 根因不是锁没释放,而是**这条通道根本没等**:`.agent/project.lock` 是 `create_new` 存在性锁,Direct 通道调的是零等待的 `acquire_project_write_lock`,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。本仓技术方案 2026-08-13 一节已经规定这类争用结果要「统一投影为争用并进入既有有界等待」,所以几十毫秒的失败时长本身就是判据。 1. **Direct 写路径纳入统一有界等待**(`direct_tool_bridge.rs`)——短暂重叠排队等成功,同一轮并行写多个文件按同一把锁串行,只有预算耗尽才报错。 2. **`create_new` 失败分三类**(`filesystem.rs`)——目标已存在(含 Windows delete-pending)是争用;锁文件并不存在却仍创建失败是权限 / ACL 拒绝,失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。Windows 上两者同为 `ACCESS_DENIED(5)`,只能靠「目标是否存在」区分。 3. **可诊断性**——争用错误与等待日志带 `commandId / pid / createdAt / ownerIsSelf`,锁文件不可读时显式表达成「身份不可读」而不是默认「没有持锁方」;等待预算耗尽记 `project.write_lock.wait_exhausted`(含等待毫秒数),权限拒绝记 `project.write_lock.permission_denied`。 4. **复用既有回收机制**——沿用 #313 的 `ProjectWriteLockSnapshot` + 字节 CAS 删除,「活持有者始终不回收」的判据不变;本次只给快照补 `commandId` 和 `describe_holder()`。 本 PR 基于 `origin/master`(a1b9b2489),已包含 #313 的项目写锁回收实现,不存在回退它的问题。 ## CI 结果(commit 4ecab1942,全部通过) | 检查 | 结果 | | --- | --- | | Repository checks | ✅ 3m5s | | Frontend tests | ✅ 4m2s | | Backend tests | ✅ 6m51s | | Native shell tests | ✅ 19m16s | `Native shell tests` 首轮曾失败一次,原因在**测试夹具**而不是产品代码:`project_write_lock_does_not_project_permission_denial_as_contention` 用 `expect_err` 断言「0o500 只读目录必须挡住取锁」,而 CI 容器以 root 运行、权限位不生效,取锁会正常成功。已在第二个 commit 修正:该端到端用例在取锁意外成功时跳过并注明原因,同时新增**平台无关**的 `project_write_lock_classifies_by_whether_the_target_exists`,直接锁住分类判据本身,不再依赖会被 root 绕过的 ACL 环境。 ## 本机验证 - `cargo check --tests` 无错误;`npm run check:encoding`、`git diff --check` 干净。 - `project_write_lock` 家族 19/19、`bridge_write_file` 3/3、`parent_wake` 16/16、`waits_across` 3/3。 - 本机另有 3 条 `Timeout` 失败(`provider_transient_retry_backoff_*`、`provider_transient_retry_transport_failure_*`、`background_agent_runtime_can_create_checkpoint_before_file_write`),已在校验用的 **pristine `origin/master` a1b9b2489** 上逐一复跑确认同样失败,属既有环境问题;它们在 CI 上全部通过,与本 PR 无关。 ## 不做项 不放宽项目级串行化语义、不引入可重入锁、不改「同一调用链禁止二次获取项目锁」的约定、不改 AGC 多进程拓扑、不改 `pendingOperations` 语义、不改「活持有者始终不回收」判据。其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围,已作为后续事项记入技术方案。
suzmii added 1 commit 2026-09-10 16:31:00 +08:00
修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断
Project CI / Repository checks (pull_request) Successful in 3m9s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / Backend tests (pull_request) Successful in 6m54s
Project CI / Native shell tests (pull_request) Failing after 14m11s
81c2389132
- 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 与技术方案文档
suzmii added the Kind/Bug
Priority
High
2
labels 2026-09-10 16:32:11 +08:00
suzmii added 1 commit 2026-09-10 16:52:32 +08:00
修正 CI 权限用例在 root 容器下的前提并补平台无关的分类判据
Project CI / Repository checks (pull_request) Successful in 3m5s
Project CI / Frontend tests (pull_request) Successful in 4m2s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 19m16s
4ecab19429
- 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 绕过的端到端用例
suzmii marked the pull request as ready for review 2026-09-10 17:15:08 +08:00
lhk229 requested changes 2026-09-10 17:33:10 +08:00
lhk229 left a comment
Owner

总评

Direct 写通道改走既有 acquire_game_creator_agent_runtime_project_write_lock_with_wait,方向是对的:这是 2026-07-22 / 2026-08-13 已经定过的形状,不该在 direct_tool_bridge 里再写一套等待循环。持锁方身份、wait_exhausted 只在预算耗尽时记账、回收机制沿用 #313,文档也同步了。这些成立。

不能按现在这版合并。核心问题不是「再擦一点文案」,而是把「要不要重试」建在了错误的分类模型上:Windows 的 ACCESS_DENIED(5)path.exists() 在零等待层一次性判死。这个判据既有 TOCTOU,也不稳定覆盖 delete-pending。一旦判成权限拒绝,有界等待立刻退出——Issue #318 要修的「几十毫秒直接失败」会在 Windows 上换一句文案回来。CI 是 Linux,挡不住。

有一条更简单的路:Windows 上 ACCESS_DENIED / 32 / 33 对等待层一律可重试;只在预算耗尽且目标仍不存在时,才投影成权限拒绝。这样第 3 条验收的终态文案还在,也不必在 create_new 失败的瞬间用 racy 的 exists() 做控制流。

必须改

  1. 不要用 path.exists()acquire_project_write_lock 里把 Windows ACCESS_DENIED 判成不可重试的权限拒绝。等待契约必须按「可重试 / 终态」拆,而不是按一次 metadata 观察拆。
  2. 有界等待仍靠字符串前缀识别争用。这次刚好又把「文案里能不能出现这个前缀」变成了会不会等 10 秒的开关。控制流应该是类型,前缀只该是 Display

结构问题

  • filesystem.rs 1184 → 1347 行,锁策略已经是第三次往这个文件里堆(回收、分类、诊断)。direct_tool_bridge.rs 2927 → 3055 行,只为塞 120 行测试。锁应拆到 project/write_lock.rs,Direct 相关测试放到 project_lock_recovery.rs 或独立测试文件,不要继续膨胀已经过 1k / 3k 的文件。
  • PROJECT_WRITE_LOCK_CONTENTION_PREFIX 抽出来了,但 provider_recovery.rsplanning_session_v2.rs 仍手写同一句。常量的意义就是把这些站点收口;只改 project_gates.rs 等于没抽完。

残余

  • Unix 上 0o500 端到端用例在 root CI 里会静默跳过,你们已经补了纯函数用例,这点诚实。但那条纯函数用例并没有锁住真正危险的分支:Windows PermissionDenied + exists=true(争用)以及 ACCESS_DENIED + exists=false 在等待层必须可重试。
  • ownerIsSelf 只比 PID,回收路径用的 processStartedAt 没有进入诊断。PID 复用时会把外人报成自己人。这不是本 PR 的主伤,但不要把它写成可靠判据。
## 总评 Direct 写通道改走既有 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,方向是对的:这是 2026-07-22 / 2026-08-13 已经定过的形状,不该在 `direct_tool_bridge` 里再写一套等待循环。持锁方身份、`wait_exhausted` 只在预算耗尽时记账、回收机制沿用 #313,文档也同步了。这些成立。 但 **不能按现在这版合并**。核心问题不是「再擦一点文案」,而是把「要不要重试」建在了错误的分类模型上:Windows 的 `ACCESS_DENIED(5)` 用 `path.exists()` 在零等待层一次性判死。这个判据既有 TOCTOU,也不稳定覆盖 delete-pending。一旦判成权限拒绝,有界等待立刻退出——Issue #318 要修的「几十毫秒直接失败」会在 Windows 上换一句文案回来。CI 是 Linux,挡不住。 有一条更简单的路:Windows 上 `ACCESS_DENIED / 32 / 33` 对等待层一律可重试;**只在预算耗尽且目标仍不存在时**,才投影成权限拒绝。这样第 3 条验收的终态文案还在,也不必在 `create_new` 失败的瞬间用 racy 的 `exists()` 做控制流。 ## 必须改 1. 不要用 `path.exists()` 在 `acquire_project_write_lock` 里把 Windows `ACCESS_DENIED` 判成不可重试的权限拒绝。等待契约必须按「可重试 / 终态」拆,而不是按一次 metadata 观察拆。 2. 有界等待仍靠字符串前缀识别争用。这次刚好又把「文案里能不能出现这个前缀」变成了会不会等 10 秒的开关。控制流应该是类型,前缀只该是 `Display`。 ## 结构问题 - `filesystem.rs` 1184 → 1347 行,锁策略已经是第三次往这个文件里堆(回收、分类、诊断)。`direct_tool_bridge.rs` 2927 → 3055 行,只为塞 120 行测试。锁应拆到 `project/write_lock.rs`,Direct 相关测试放到 `project_lock_recovery.rs` 或独立测试文件,不要继续膨胀已经过 1k / 3k 的文件。 - `PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 抽出来了,但 `provider_recovery.rs`、`planning_session_v2.rs` 仍手写同一句。常量的意义就是把这些站点收口;只改 `project_gates.rs` 等于没抽完。 ## 残余 - Unix 上 `0o500` 端到端用例在 root CI 里会静默跳过,你们已经补了纯函数用例,这点诚实。但那条纯函数用例并没有锁住真正危险的分支:Windows `PermissionDenied + exists=true`(争用)以及 `ACCESS_DENIED + exists=false` 在等待层必须可重试。 - `ownerIsSelf` 只比 PID,回收路径用的 `processStartedAt` 没有进入诊断。PID 复用时会把外人报成自己人。这不是本 PR 的主伤,但不要把它写成可靠判据。
@@ -2715,0 +2722,4 @@
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
/// 而不是互相报"项目正在被其他写操作占用"。
#[test]
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
Owner

[结构] 行为对(同轮并行写必须排队成功),但不要把这 120 行测试继续堆进已经 2927 行的 direct_tool_bridge.rs(本 PR 之后 3055 行)。

bridge_write_file 改用 with_wait 是对的,测试也应跟着锁模块走:放到现有的 project_lock_recovery.rs,或单独的 Direct 写锁测试文件。filesystem.rs 同样从 1184 涨到 1347,分类 / 前缀 / describe_holder / 诊断又塞进目录遍历文件中间。锁策略已经第三次膨胀同一份文件,现在就该拆到 project/write_lock.rs,而不是再补一套纯函数用例进去。

**[结构]** 行为对(同轮并行写必须排队成功),但不要把这 120 行测试继续堆进已经 2927 行的 `direct_tool_bridge.rs`(本 PR 之后 3055 行)。 `bridge_write_file` 改用 `with_wait` 是对的,测试也应跟着锁模块走:放到现有的 `project_lock_recovery.rs`,或单独的 Direct 写锁测试文件。`filesystem.rs` 同样从 1184 涨到 1347,分类 / 前缀 / `describe_holder` / 诊断又塞进目录遍历文件中间。锁策略已经第三次膨胀同一份文件,现在就该拆到 `project/write_lock.rs`,而不是再补一套纯函数用例进去。
@@ -1838,3 +1839,2 @@
Err(error)
if error.starts_with("项目正在被其他写操作占用:")
&& attempt + 1 < max_attempts =>
if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) =>
Owner

[必须改] 这里仍然用字符串前缀当等待契约。这次改动把问题放大了,而不是收掉。

等待层只重试 starts_with(CONTENTION_PREFIX),其它全部立即返回。于是:

  • 权限文案「千万不能带这个前缀」从展示约束变成了控制流约束;
  • filesystem.rs 里那次 racy 的 exists() 分类一旦判错,这层没有第二条防线;
  • provider_recovery.rsplanning_session_v2.rs、前端 App.tsx 继续手写同一句。常量抽了,调用方没收口。

code judo:让 acquire_project_write_lock 返回类型化错误(争用 / 权限 / 其它),等待循环 match 争用变体。前缀只留在 Display 里给旧前端用。这样「ACL 不能被投影成争用」是类型不变量,不必靠文案纪律维持,也不必在 create_new 失败的瞬间用 exists() 猜。

**[必须改]** 这里仍然用字符串前缀当等待契约。这次改动把问题放大了,而不是收掉。 等待层只重试 `starts_with(CONTENTION_PREFIX)`,其它全部立即返回。于是: - 权限文案「千万不能带这个前缀」从展示约束变成了控制流约束; - `filesystem.rs` 里那次 racy 的 `exists()` 分类一旦判错,这层没有第二条防线; - `provider_recovery.rs`、`planning_session_v2.rs`、前端 `App.tsx` 继续手写同一句。常量抽了,调用方没收口。 code judo:让 `acquire_project_write_lock` 返回类型化错误(争用 / 权限 / 其它),等待循环 match 争用变体。前缀只留在 `Display` 里给旧前端用。这样「ACL 不能被投影成争用」是类型不变量,不必靠文案纪律维持,也不必在 `create_new` 失败的瞬间用 `exists()` 猜。
@@ -475,2 +617,3 @@
}
Err(error) if project_write_lock_open_error_is_contention(&error) => {
Err(error) => {
let failure = project_write_lock_classify_open_error(&error, path.exists());
Owner

[必须改] Windows ACCESS_DENIEDpath.exists() 做一次分类,会把有界等待打穿。

create_new 失败后再读 exists() 不是稳定 oracle:

  1. TOCTOU:持锁方在 create_new 失败和 exists() 之间 Drop,文件已经消失。这时本该再抢一次就能成功,却被投影成权限拒绝;project_gates 对非争用前缀是直接 return,等待窗口为零。这就是 Issue #318 的几十毫秒失败,只是文案换成了 ACL。
  2. delete-pending:仓库已有 runtime_project_write_lock_waits_for_delete_pending_target,专门锁「Windows 上 delete-pending 目标报 ACCESS_DENIED,必须走有界等待」。Rust 的 Path::exists() 在 metadata 失败(包括 ACCESS_DENIED)时返回 false。一旦 delete-pending 走到这条,分类变成 Permission,等待立刻退出。CI 是 Linux,这条 Windows 用例不会跑。
  3. 同一条锁路径其它地方用 symlink_metadata / OPEN_REPARSE_POINT,这里却用会跟随 reparse、并把 IO 错误吞成 falseexists(),边界也不一致。

更简单的模型:Windows 上 ACCESS_DENIED(5)3233 对等待层一律可重试。权限拒绝只在等待预算耗尽且目标仍不存在时投影。零等待入口如果也要区分 ACL,不要用锁文件自身的 exists(),那正好是争用对象。

project_write_lock_classifies_by_whether_the_target_exists 也锁不住这条。它断言的是「目标在不在决定争用还是权限」,但 Unix 路径根本不看 lock_path_exists;真正危险的 PermissionDenied + exists=true(Windows 争用)和「exists=false 仍必须可重试」都没覆盖。

**[必须改]** Windows `ACCESS_DENIED` 用 `path.exists()` 做一次分类,会把有界等待打穿。 `create_new` 失败后再读 `exists()` 不是稳定 oracle: 1. **TOCTOU**:持锁方在 `create_new` 失败和 `exists()` 之间 Drop,文件已经消失。这时本该再抢一次就能成功,却被投影成权限拒绝;`project_gates` 对非争用前缀是直接 return,等待窗口为零。这就是 Issue #318 的几十毫秒失败,只是文案换成了 ACL。 2. **delete-pending**:仓库已有 `runtime_project_write_lock_waits_for_delete_pending_target`,专门锁「Windows 上 delete-pending 目标报 ACCESS_DENIED,必须走有界等待」。Rust 的 `Path::exists()` 在 metadata 失败(包括 ACCESS_DENIED)时返回 `false`。一旦 delete-pending 走到这条,分类变成 `Permission`,等待立刻退出。CI 是 Linux,这条 Windows 用例不会跑。 3. 同一条锁路径其它地方用 `symlink_metadata` / `OPEN_REPARSE_POINT`,这里却用会跟随 reparse、并把 IO 错误吞成 `false` 的 `exists()`,边界也不一致。 **更简单的模型**:Windows 上 `ACCESS_DENIED(5)`、`32`、`33` 对等待层一律可重试。权限拒绝只在**等待预算耗尽且目标仍不存在**时投影。零等待入口如果也要区分 ACL,不要用锁文件自身的 `exists()`,那正好是争用对象。 `project_write_lock_classifies_by_whether_the_target_exists` 也锁不住这条。它断言的是「目标在不在决定争用还是权限」,但 Unix 路径根本不看 `lock_path_exists`;真正危险的 `PermissionDenied + exists=true`(Windows 争用)和「`exists=false` 仍必须可重试」都没覆盖。
suzmii added 3 commits 2026-09-10 20:12:37 +08:00
- 项目写锁分类改为只按错误码判定重试性,不再用 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 的表述
将项目写锁从 project/filesystem.rs 纯搬移到 project/write_lock.rs
Project CI / Repository checks (pull_request) Successful in 2m37s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 6m36s
Project CI / Native shell tests (pull_request) Failing after 13m57s
06f738dc99
- 新建 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 中指向锁实现的文件路径,并把“拆锁”从后续事项改为已完成
suzmii added 1 commit 2026-09-10 20:47:11 +08:00
修复锁失败终态判据漏传平台导致 CI 把权限改判成争用
Project CI / Repository checks (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 8m23s
Project CI / Backend tests (pull_request) Successful in 12m7s
Project CI / Native shell tests (pull_request) Successful in 25m6s
71e9ad3133
- 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 补充“判据的每一环都要带平台”的排障经验
suzmii requested review from lhk229 2026-09-10 21:12:39 +08:00
lhk229 added 1 commit 2026-09-10 21:13:25 +08:00
Merge branch 'master' into fix/issue-318-direct-write-lock-wait
Project CI / Repository checks (pull_request) Successful in 2m58s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m13s
e8c5d2e247
Author
Member

按评审意见改了 3 个 commit,CI 已全绿。

1. 重试判据不再看 path.exists()(必须改项)— 8c639e5d1

create_new 失败的可重试性只看错误码:Windows 的 ACCESS_DENIED(5) / sharing violation(32) / lock violation(33) / 已存在一律进入有界等待;Unix 的 EACCES 明确判权限拒绝(Unix 没有删除挂起,可以立即判定、不白等一个窗口)。

评审说的“exists() 既有 TOCTOU、也不稳定覆盖 delete-pending”,实测复核后机制要修正一处:带句柄的 delete-pending 是覆盖住的——tests/project_tools.rsruntime_project_write_lock_waits_for_delete_pending_target 就是那个形状(句柄不带 FILE_SHARE_DELETE 置删除位),它一直通过,说明该形状下 exists()==true、仍然走等待。真正的洞是无句柄的删除拆链窗口DeleteFileW 先摘掉目录项、删除挂起随后才结束,create_new 在这个窗口里报 ACCESS_DENIED(5),而等分类器去问 exists() 时目录项已经消失。本机 6 万次建锁/删锁竞争实测 396-538 例命中该组合(占争用失败 1.3%–1.6%),旧判据会让等待层立刻失败关闭——正是 #318 要消灭的“毫秒级直接失败”,只是换成了更误导的 ACL 文案。

改法:终态改判移到等待预算耗尽之后——真的等过、且目标此刻仍不存在,才投影成权限拒绝;单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)没有等待证据,保持争用语义。

2. 控制流改按类型(第二个必须改项)— 8c639e5d1

acquire_project_write_lock_failure 返回 ProjectWriteLockFailure::{Retryable, Terminal},有界等待按 is_retryable() 分流,acquire_project_write_lock 退化为它的文案包装。零等待入口保持前缀逐字不变(provider_recovery.rs / planning_session_v2.rs / direct_runtime.rs / 前端 App.tsx 的重试语义不受影响),仅在错误码不可区分时补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。PROJECT_WRITE_LOCK_CONTENTION_PREFIX 同时收口了那三处手写文案;wait_exhausted 日志补 projection=attempts 记为实际尝试次数。

3. 结构收口 — 06f738dc9(纯搬移)

锁策略拆到 project/write_lock.rs(856 行:取锁、等待分类、持锁方诊断、残留回收),project/filesystem.rs 回到 680 行;project.rs 注册 mod write_lock + pub(crate) use write_lock::*crate::project::*crate::* 的既有路径不变。Direct 锁用例仍在 direct_tool_bridge.rs,列为后续事项。

4. CI 抓到的一条平台判据缺陷 — 71e9ad313

06f738dc9 的 Native shell tests 失败 1 条(2358 passed / 1 failed,left: contention / right: permission_denied):终态改判那一步只比了 ErrorKind,而 errno 5 在 Windows 是 ACCESS_DENIED、在 Linux 是 EIO,同一条判据在 CI 上给出相反结论。已改为平台连同错误一起传(Retryable { platform, path, source }),用例显式用 Windows 平台构造并补 Unix 反例。

验证

  • CI(71e9ad313):Repository checks 6m17s / Frontend tests 8m23s / Backend tests 12m7s / Native shell tests 25m6s
  • 本机(Windows):project_write_lock 18 条、bridge_write_file 3 条、parent_wake 16 条、waits_across 3 条全过;check:rustfmtcheck:encoding(4332 文件)、git diff --check 干净。
按评审意见改了 3 个 commit,CI 已全绿。 ## 1. 重试判据不再看 `path.exists()`(必须改项)— `8c639e5d1` `create_new` 失败的可重试性只看错误码:Windows 的 `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在一律进入有界等待;Unix 的 `EACCES` 明确判权限拒绝(Unix 没有删除挂起,可以立即判定、不白等一个窗口)。 评审说的“`exists()` 既有 TOCTOU、也不稳定覆盖 delete-pending”,实测复核后机制要修正一处:**带句柄的 delete-pending 是覆盖住的**——`tests/project_tools.rs` 的 `runtime_project_write_lock_waits_for_delete_pending_target` 就是那个形状(句柄不带 `FILE_SHARE_DELETE` 置删除位),它一直通过,说明该形状下 `exists()==true`、仍然走等待。真正的洞是**无句柄的删除拆链窗口**:`DeleteFileW` 先摘掉目录项、删除挂起随后才结束,`create_new` 在这个窗口里报 `ACCESS_DENIED(5)`,而等分类器去问 `exists()` 时目录项已经消失。本机 6 万次建锁/删锁竞争实测 **396-538 例**命中该组合(占争用失败 1.3%–1.6%),旧判据会让等待层立刻失败关闭——正是 #318 要消灭的“毫秒级直接失败”,只是换成了更误导的 ACL 文案。 改法:终态改判移到**等待预算耗尽之后**——真的等过、且目标此刻仍不存在,才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_*`)没有等待证据,保持争用语义。 ## 2. 控制流改按类型(第二个必须改项)— `8c639e5d1` `acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 退化为它的文案包装。零等待入口保持前缀逐字不变(`provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` / 前端 `App.tsx` 的重试语义不受影响),仅在错误码不可区分时补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口了那三处手写文案;`wait_exhausted` 日志补 `projection=`,`attempts` 记为实际尝试次数。 ## 3. 结构收口 — `06f738dc9`(纯搬移) 锁策略拆到 `project/write_lock.rs`(856 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到 680 行;`project.rs` 注册 `mod write_lock` + `pub(crate) use write_lock::*`,`crate::project::*` 与 `crate::*` 的既有路径不变。Direct 锁用例仍在 `direct_tool_bridge.rs`,列为后续事项。 ## 4. CI 抓到的一条平台判据缺陷 — `71e9ad313` `06f738dc9` 的 Native shell tests 失败 1 条(2358 passed / 1 failed,`left: contention / right: permission_denied`):终态改判那一步只比了 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,同一条判据在 CI 上给出相反结论。已改为平台连同错误一起传(`Retryable { platform, path, source }`),用例显式用 Windows 平台构造并补 Unix 反例。 ## 验证 - CI(`71e9ad313`):Repository checks 6m17s ✅ / Frontend tests 8m23s ✅ / Backend tests 12m7s ✅ / Native shell tests 25m6s ✅ - 本机(Windows):`project_write_lock` 18 条、`bridge_write_file` 3 条、`parent_wake` 16 条、`waits_across` 3 条全过;`check:rustfmt`、`check:encoding`(4332 文件)、`git diff --check` 干净。
Owner

评审意见(PR #320 / Issue #318)

整体方案与 2026-08-13 已定口径一致:Direct 写通道纳入统一有界等待、create_new 失败按错误码分三类、终态改判放在预算耗尽之后、持锁方身份进入错误文案与日志,判据参数化平台使 Linux CI 能覆盖 Windows 分支。以下两处建议处理,另有几点确认项。

1. [中] agc_write_file 的 10 秒有界等待在 async Axum handler 里同步阻塞 tokio worker

handle_direct_tool_bridgedirect_tool_bridge.rs:2305)是 async handler,"agc_write_file" => bridge_write_file(...) 是同步直调。改动前 acquire_project_write_lock 零等待,阻塞只有毫秒级;改动后 acquire_game_creator_agent_runtime_project_write_lock_with_wait 是 2000 次 × 5ms std::thread::sleep 的轮询,争用持续期间会占住一个 tokio worker 最多约 10 秒(每次轮询还附带 validate_project_root / 目录 prepare / 回收探测等 syscall)。触发场景正是本 PR 的目标场景:另一条写通道持锁几秒 + 同一轮并行多个 agc_write_file,多个 worker 同时被占;该 bridge 与 App 其它 async 任务共享同一 runtime,核心数较少时会波及 agc_list_project_files 等只读端点和 UI 命令——即 PR 描述里"只读工具全部正常"这条诊断特征在争用窗口内可能不再成立。建议把写路径包进 tokio::task::spawn_blocking(仓库已有此模式,如 codex_app_server.rs:2791),等待语义不变。

2. [低] 单次试探(max_attempts == 1)现在也会写 project.write_lock.wait_exhausted 日志

acquire_game_creator_agent_runtime_project_write_lock_within 里日志与终态返回共用一个分支,只在投影时区分了 max_attempts > 1。于是 try_acquire_game_creator_agent_runtime_project_write_lock(唯一调用方是 hydrate_planning_session_v2,项目打开 / planning 策略都会触发)每次撞锁都会写一条 wait_exhausted waitedMs≈0 到持久化 App 日志:语义上"没有等待却记 wait_exhausted",也与 write_lock.rs 注释"争用不在零等待入口里记日志"的表述不一致。持锁方诊断本身有价值,建议要么日志同样用 max_attempts > 1 门控,要么为零等待入口换一个不带 exhausted 语义的 event 名。

确认项(不阻塞)

  • Windows 真实 ACL 拒绝现在要先等满约 10 秒才报权限错误(exhausted_projection 的设计代价),文档已写明,确认产品侧可接受。
  • bridge_write_file_does_not_project_permission_denial_as_contention 在 root / 权限位被忽略的环境下静默跳过属合理兜底,纯函数判据用例已覆盖该分支。
  • 争用错误新增"(持锁方 …)"后缀后保持前缀逐字不变,provider_recovery.rs / planning_session_v2.rs / direct_runtime.rs / App.tsxstarts_with / contains 判据不受影响;e2e 脚本与 recovery.rs 里的同名字符串是注入 fixture,不是对产出文案的精确断言,不会被后缀打破。
## 评审意见(PR #320 / Issue #318) 整体方案与 2026-08-13 已定口径一致:Direct 写通道纳入统一有界等待、`create_new` 失败按错误码分三类、终态改判放在预算耗尽之后、持锁方身份进入错误文案与日志,判据参数化平台使 Linux CI 能覆盖 Windows 分支。以下两处建议处理,另有几点确认项。 ### 1. [中] `agc_write_file` 的 10 秒有界等待在 async Axum handler 里同步阻塞 tokio worker `handle_direct_tool_bridge`(`direct_tool_bridge.rs:2305`)是 async handler,`"agc_write_file" => bridge_write_file(...)` 是同步直调。改动前 `acquire_project_write_lock` 零等待,阻塞只有毫秒级;改动后 `acquire_game_creator_agent_runtime_project_write_lock_with_wait` 是 2000 次 × 5ms `std::thread::sleep` 的轮询,争用持续期间会占住一个 tokio worker 最多约 10 秒(每次轮询还附带 `validate_project_root` / 目录 prepare / 回收探测等 syscall)。触发场景正是本 PR 的目标场景:另一条写通道持锁几秒 + 同一轮并行多个 `agc_write_file`,多个 worker 同时被占;该 bridge 与 App 其它 async 任务共享同一 runtime,核心数较少时会波及 `agc_list_project_files` 等只读端点和 UI 命令——即 PR 描述里"只读工具全部正常"这条诊断特征在争用窗口内可能不再成立。建议把写路径包进 `tokio::task::spawn_blocking`(仓库已有此模式,如 `codex_app_server.rs:2791`),等待语义不变。 ### 2. [低] 单次试探(`max_attempts == 1`)现在也会写 `project.write_lock.wait_exhausted` 日志 `acquire_game_creator_agent_runtime_project_write_lock_within` 里日志与终态返回共用一个分支,只在投影时区分了 `max_attempts > 1`。于是 `try_acquire_game_creator_agent_runtime_project_write_lock`(唯一调用方是 `hydrate_planning_session_v2`,项目打开 / planning 策略都会触发)每次撞锁都会写一条 `wait_exhausted waitedMs≈0` 到持久化 App 日志:语义上"没有等待却记 wait_exhausted",也与 `write_lock.rs` 注释"争用不在零等待入口里记日志"的表述不一致。持锁方诊断本身有价值,建议要么日志同样用 `max_attempts > 1` 门控,要么为零等待入口换一个不带 exhausted 语义的 event 名。 ### 确认项(不阻塞) - Windows 真实 ACL 拒绝现在要先等满约 10 秒才报权限错误(`exhausted_projection` 的设计代价),文档已写明,确认产品侧可接受。 - `bridge_write_file_does_not_project_permission_denial_as_contention` 在 root / 权限位被忽略的环境下静默跳过属合理兜底,纯函数判据用例已覆盖该分支。 - 争用错误新增"(持锁方 …)"后缀后保持前缀逐字不变,`provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` / `App.tsx` 的 `starts_with` / `contains` 判据不受影响;e2e 脚本与 `recovery.rs` 里的同名字符串是注入 fixture,不是对产出文案的精确断言,不会被后缀打破。
suzmii added 1 commit 2026-09-10 21:38:55 +08:00
按评审意见把写锁等待挪出 runtime worker 并收紧 wait_exhausted 记账
Project CI / Repository checks (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Successful in 4m3s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Native shell tests (pull_request) Successful in 19m35s
ff42fff61c
- 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 里”的排障经验
lhk229 merged commit 11c8cbdf67 into master 2026-09-10 21:43:38 +08:00
lhk229 deleted branch fix/issue-318-direct-write-lock-wait 2026-09-10 21:43:38 +08:00
Sign in to join this conversation.