修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断 #320
Reference in New Issue
Block a user
Delete Branch "fix/issue-318-direct-write-lock-wait"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
草稿状态保留,等人工评审后再转正式;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 一节已经规定这类争用结果要「统一投影为争用并进入既有有界等待」,所以几十毫秒的失败时长本身就是判据。direct_tool_bridge.rs)——短暂重叠排队等成功,同一轮并行写多个文件按同一把锁串行,只有预算耗尽才报错。create_new失败分三类(filesystem.rs)——目标已存在(含 Windows delete-pending)是争用;锁文件并不存在却仍创建失败是权限 / ACL 拒绝,失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。Windows 上两者同为ACCESS_DENIED(5),只能靠「目标是否存在」区分。commandId / pid / createdAt / ownerIsSelf,锁文件不可读时显式表达成「身份不可读」而不是默认「没有持锁方」;等待预算耗尽记project.write_lock.wait_exhausted(含等待毫秒数),权限拒绝记project.write_lock.permission_denied。ProjectWriteLockSnapshot+ 字节 CAS 删除,「活持有者始终不回收」的判据不变;本次只给快照补commandId和describe_holder()。本 PR 基于
origin/master(a1b9b2489),已包含 #313 的项目写锁回收实现,不存在回退它的问题。CI 结果(commit 4ecab1942,全部通过)
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_file3/3、parent_wake16/16、waits_across3/3。Timeout失败(provider_transient_retry_backoff_*、provider_transient_retry_transport_failure_*、background_agent_runtime_can_create_checkpoint_before_file_write),已在校验用的 pristineorigin/mastera1b9b2489上逐一复跑确认同样失败,属既有环境问题;它们在 CI 上全部通过,与本 PR 无关。不做项
不放宽项目级串行化语义、不引入可重入锁、不改「同一调用链禁止二次获取项目锁」的约定、不改 AGC 多进程拓扑、不改
pendingOperations语义、不改「活持有者始终不回收」判据。其余仍用零等待取锁的入口(command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令)不在本次范围,已作为后续事项记入技术方案。总评
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()做控制流。必须改
path.exists()在acquire_project_write_lock里把 WindowsACCESS_DENIED判成不可重试的权限拒绝。等待契约必须按「可重试 / 终态」拆,而不是按一次 metadata 观察拆。Display。结构问题
filesystem.rs1184 → 1347 行,锁策略已经是第三次往这个文件里堆(回收、分类、诊断)。direct_tool_bridge.rs2927 → 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等于没抽完。残余
0o500端到端用例在 root CI 里会静默跳过,你们已经补了纯函数用例,这点诚实。但那条纯函数用例并没有锁住真正危险的分支:WindowsPermissionDenied + 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() {[结构] 行为对(同轮并行写必须排队成功),但不要把这 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) =>[必须改] 这里仍然用字符串前缀当等待契约。这次改动把问题放大了,而不是收掉。
等待层只重试
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());[必须改] Windows
ACCESS_DENIED用path.exists()做一次分类,会把有界等待打穿。create_new失败后再读exists()不是稳定 oracle:create_new失败和exists()之间 Drop,文件已经消失。这时本该再抢一次就能成功,却被投影成权限拒绝;project_gates对非争用前缀是直接 return,等待窗口为零。这就是 Issue #318 的几十毫秒失败,只是文案换成了 ACL。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 用例不会跑。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仍必须可重试」都没覆盖。按评审意见改了 3 个 commit,CI 已全绿。
1. 重试判据不再看
path.exists()(必须改项)—8c639e5d1create_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. 控制流改按类型(第二个必须改项)—
8c639e5d1acquire_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 抓到的一条平台判据缺陷 —
71e9ad31306f738dc9的 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 反例。验证
71e9ad313):Repository checks 6m17s ✅ / Frontend tests 8m23s ✅ / Backend tests 12m7s ✅ / Native shell tests 25m6s ✅project_write_lock18 条、bridge_write_file3 条、parent_wake16 条、waits_across3 条全过;check:rustfmt、check:encoding(4332 文件)、git diff --check干净。评审意见(PR #320 / Issue #318)
整体方案与 2026-08-13 已定口径一致:Direct 写通道纳入统一有界等待、
create_new失败按错误码分三类、终态改判放在预算耗尽之后、持锁方身份进入错误文案与日志,判据参数化平台使 Linux CI 能覆盖 Windows 分支。以下两处建议处理,另有几点确认项。1. [中]
agc_write_file的 10 秒有界等待在 async Axum handler 里同步阻塞 tokio workerhandle_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 次 × 5msstd::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 名。确认项(不阻塞)
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,不是对产出文案的精确断言,不会被后缀打破。