Files
Genarrative/apps
lhk229 1337ce03e1 策划审批的两个 GUI 入口等过项目锁瞬时争用,不再把成功的审批画成失败
线上症状:点「批准」后卡里弹出 `项目正在被其他写操作占用`,但 agent.db 显示
`plan.gdd_decided action=approve` 已落盘、approvals/v1.json 已写、index 里
approvedVersion=1、run 随后正常收束,且没有任何 gdd_projection_gap ——
审批本身是成功的,报错来自它之后那一次刷新。

成因是决定放行 run 续跑,而 GUI 在 decide 返回后紧接着 hydrate:
`decide → await hydratePlanGddState()` 与 runner 的 `agent.schedule_ready`、
会话写入、planning 投影同时伸手拿 `.agent/project.lock`。hydrate 那一支是
一次性取锁、不重试,撞上就返回 PLAN_STORAGE_IO,前端原样贴进审批卡。

仓库里本来就有这个约定:project_gates 的 `..._with_wait` 只认
`项目正在被其他写操作占用:` 这一个前缀并退避重试,运行时侧 18 个点都在用。
策划的两个 GUI 入口是漏网的。按「丢了这一次要付什么代价」分档接上:

- planning.gdd-decision 是一次性用户意图,丢了用户得重新找到卡片 → 等满窗口。
- planning.hydrate 是轮询刷新,下一拍还会来 → 只等 1 秒(新增 short wait 档),
  免得为一次刷新把面板卡住整个窗口。

前端再兜一层:hydrate 撞上这条错误时保留上一份状态、不写错误位。后端等过短
窗口仍拿不到,只说明此刻运行时正在写盘;这条 effect 每次监工状态变化都会重跑。
用 includes 而不是 startsWith,因为 hydrate 的错误带 `PLAN_STORAGE_IO: ` 前缀。

两条新测试都做过 A/B(换回一次性取锁即失败):
- decision_rides_out_a_briefly_held_project_lock
- hydrate_rides_out_a_briefly_held_project_lock
锁由后台线程持有 120ms 再释放;pid 与 createdAt 都写真值,否则会被失效锁回收
顺手删掉、根本占不住。

hydrate 原有的争用用例(考脱敏与错误码形状)保持红,只更新了那条已失效的
「这一支不重试」注释。

做游戏链路未触及:改的两个函数都是策划专属入口,共用的
`acquire_project_write_lock` 语义一行未动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:13:11 +00:00
..
2026-07-17 16:56:46 +08:00