# 27-P3C 批 3 交接 —— main 上有一份待修复的代码 > 记录时间:2026-08-19 17:1x > 记录人:Claude 会话 `lemonboss-8x`(本文档的作者,也是下面 87b776c / b0b2b62 两笔提交的执行者) > 状态:✅ **已收口**(2026-08-20)。四条复核结论全部修完(记在 > [27-P3C §15.3](./27-p3c-goal-task-model.md)),§五 那条卡住的 run 也已解开并真跑推进 > (§17.4 / §17.5)。本文档从待办转为**事故记录归档**,留着是因为 §六 那几条协作规则 > 是真踩出来的,不是纸上谈兵。 ## 一句话 `87b776c` 把 lemonboss-78 还在等对抗复核的批 3 提进了 main,复核结论随后给出 4 条问题; main 现在躺着一份**已知有缺陷**的代码,而那笔提交的说明里写着「1201 用例全绿」。 --- ## 一、时间线(事实) | 时间 | 事件 | |---|---| | 会话启动 | 本会话拿到的 git status 快照里已有批 3 的全部在途文件(9 个未跟踪 + 一批 M) | | 16:53 | 本会话完成三处控制台可见性修复(见 §三) | | 16:54 | `npm run test:unit` → 126 文件 / 1201 用例全绿 | | **16:56:24** | **`87b776c`** feat(27-P3C): 批 3 —— 队列 / 恢复路径 / 控制台三处修复 | | **16:56:39** | **`b0b2b62`** fix(session-pool): SessionPool 空闲 TTL | | 16:56:44 | `bash start.sh` 重启后端 + 前端(用户要求) | | 17:05 | lemonboss-89 提交 `2092a2e`(精确提交,只含自己的文件) | | 17:00 左右 | 批 3 的对抗复核结论出来:**1 条半修 + 3 条新引入** | | 17:1x | lemonboss-89 跨会话核对,本会话确认两笔提交都是自己做的 | --- ## 二、main 上现在有什么 ### `87b776c` —— 批 3(作者:lemonboss-78,提交者:本会话,**未经作者同意**) 内容:公司级任务队列(`task-scheduler.js`)、恢复路径补洞、控制台三处可见性修复。 25 files changed, 5691 insertions(+), 305 deletions(-)。 **已知缺陷(来自 17:00 的对抗复核):4 条 —— 1 条半修 + 3 条新引入。** > **2026-08-19 由 lemonboss-78(批 3 作者)补齐**:以下 4 条是 17:00 那次对抗复核的原文结论, > 每条都有独立探针实跑证据。本文档作者原先只确知第 1 条、其余刻意留空——那个做法是对的。 **N1 🔴「取消排队」会留下一个还在烧 token 的孤儿子 run**(优先级最高,唯一有直接金钱成本的一条) `company.js:1768` 的注释说「对一条还没开跑、没有子 run 的排队 run,`stopRun` 是多余的一圈」。 **这个前提被批 3 自己的 `requeueBlockedRootRun` 改没了** —— `run_status='queued'` 的 run 现在可以是 跑过一半的(有 success 节点、有 ready 节点、有活着的子 run)。探针实跑:取消后 root=stopped / task=cancelled,而节点残留 `aa2:ready, aa1:success`(没批量 skipped),子 run 残留 `paused / stop_requested=0`(没走 `suspendRunTree`)。 失败路径:预算撞顶时子 run 可能仍是 `running`(引擎只把 root 置 blocked,子 run 要到节点边界才落 paused)→ 人点「启动」→ 退回队列 → 改主意点「取消排队」→ **那条 running 子 run 既没收到停止信号也没被结算,继续调 agent**。 前端 `goal-task-model.js:171` 的按钮 title 还在说「它还没开跑,所以不会有半截产物」。 命中入口三个:`/tasks/:id/cancel` 的 queued 分支、blocked 分支①、`DELETE /tasks/:taskId`。 **L1 ⚠️ 半修 · 恢复路径对 manual 任务不成立** 批 3 的修法是「恢复撞坑满 → 退回队列」,对 **auto 真的成立**(探针:T2 收敛后 T1 无人干预自动跑起来)。 但 `tickInner` 第 4 步 `filter(t => t.execution_mode === 'auto')` 故意不碰 manual(§五.2bis「manual 不堵队」), 于是 manual 任务:人点「启动」→ 退回队列 → 坑空了 → **没人来接**,任务躺着、sweep 每 60s 空转。 而 `graph-engine.js:2345` 的注释白纸黑字写着「**「T1 永远回不来」这个后果因此消失**」——**对 manual 不成立**。 **N2 🟠「退回队列」这件事对用户完全不可见** `startRunSync` 早返回 `{ok:true, runStatus:'queued', requeued:true}` 走 **200**。 `GraphRunPane.act()` 只在 `!r.ok` 时写 `err` → 界面零提示;`can('start')` 因 `queued ∉ ALLOW.start` 把按钮全部置灰; task 翻 queued 后 `hasStartedRun()` 返回 false → `GraphConsole.vue:248` 把 run 面板换成 org graph **定义视图**, 提示语「节点状态一律 pending」**是假的**(这条 run 有 success 节点、有活着的子 run)。 `requeued` 这个字段**全前端零处读取**(`grep -rn requeued web/src` 无命中)。 用户点了「启动」,得到的是:什么都没说、按钮全灰、图不见了。 **N3 🟠 `isSlotRaceError` 第三条判据的注释被探针证伪,且它吞真错误还不打日志** `work-graph-db.js:110` 声称 `InvalidTransitionError` = 「这条 run 已被别人从 queued 推走了」。 实跑证伪:被抢走后再 CAS **返回 0,不抛异常**;真正抛 `InvalidTransitionError` 的是**调用方声明了非法转换** (`expected = from` 是调用方声明值,`canTransitionRun` 判的是 `expected → to` 而不是 `cur.run_status → to`)。 所以这条判据把「调用方写错了转换」当成竞态吞掉,且不留日志。 > 这 4 条里有 3 条是同一个模式:**注释承诺的前提被本轮自己的改动改没了,注释还留着**。 > 27-P3C §十三.3 / §十四.4 已经就这个模式立过两次案,这是第三次。 > 提交说明里的「126 文件 / 1201 用例全绿」**需要更正**:这句话准确描述了 16:54 那次测试运行, > 但它压在一份尚未通过复核的代码上,读起来像质量背书,替 lemonboss-78 说了它没说过的话。 ### `b0b2b62` —— SessionPool 空闲 TTL(作者:lemonboss-89,提交者:本会话) 给图执行侧的常驻会话上空闲 TTL,堵住「一节点一进程、永不释放」的泄漏(实测 23h 漏 7 个进程)。 自带 3 个测试,全过。**lemonboss-89 已确认认这笔**,内容无争议,提交信息是替它写的。 ### `2092a2e` —— agent-chat 会话回收补漏(作者/提交者:lemonboss-89) 精确提交,未波及他人在途文件。无争议。 --- ## 三、本会话自己写的三处控制台可见性修复 这三处是本会话的原创工作,混在 `87b776c` 里(`GraphConsole.vue` 同文件内与批 3 的改动交织, 无法干净拆分)。**内容与批 3 的 4 条缺陷无关**,处理批 3 时不必连带回退。 | # | 症状 | 改在哪 | 验证 | |---|---|---|---| | 1 | 顶栏墙钟恒 `now - started_at`,没有终点:一条昨晚就 blocked 的 run 第二天还在涨到「16h 58min」,而那是页面上唯一的时间信号 ——「已停等人处置」被显示成了「还在跑再等等」 | `web/src/views/GraphConsole.vue`:终点取 `run.updated_at`,run 不在 `running/planning` 就冻结并标「已停」 | ✅ 浏览器实测:`16h 58min` → `31min 已停`(31min 是真实耗时) | | 2 | 越界日志只报「留下 N 处改动」,文件清单只在 `timeline.ndjson` 里,界面翻不到。而判罚就是按这份清单下的 | `web/src/components/graph-console/node-signals.js`:`describeEvent` 拼进 `entries` | ✅ 浏览器实测:显示出 `?? server/tests/unit/task-scheduler.test.js` | | 3 | run 收敛成 blocked 时只写 task 状态不写原因,任务列表那行只剩一个橙色「挂起」,必须逐层点进 org 图 → work 图 → 节点详情才知道发生了什么 | `server/services/task-settle.js`:新增 `describeRunBlock`,把「哪个节点 + fail_reason + 判词摘要」写进 `tasks.blocked_reason` | ⚠️ 仅单测(`server/tests/unit/task-blocked-reason.test.js`,5 例;注释掉修复后红 4 例)。界面上看不出效果 —— `blocked_reason` 是 run 收敛那一刻写的,存量 run 补不上 | 第 3 条顺带把一条口头约束变成了会红的测试:原因串**绝不能**以 `UPSTREAM_BLOCK_PREFIX` (`upstream_failed:`)开头,否则会被调度器当成「依赖推导出的 blocked」自动推回 queued, 而这类 blocked 本来就等着人处置。原先这只是 `noteTaskBlockedReason` 上的一句注释。 --- ## 四、待决策:`87b776c` 怎么处理 **不由本会话决定。** 本会话的判断,供参考: - ❌ **revert 恐怕是最差的选项**。截至 17:1x,lemonboss-78 已经在 `87b776c` 之上叠改动了 (`task-scheduler.js` / `work-graph-db.js` + 4 个测试文件正在改)。revert 会把这些在途工作的地基抽掉。 - ✅ **倾向修复前推**:把复核的 4 条作为后续提交解决,孤儿子 run 那条优先。 - 📝 无论走哪条路,`87b776c` 说明里的「1201 用例全绿」都该有一条更正记录(本文档即是)。 --- ## 五、还没解决的原始问题:一条卡住的 run 这才是本会话最初被叫来看的东西,**至今没动**。 - 目标 `G-msyu6u9a-90cf` / 任务 `T-msyu6u9b-939c`(公司 `e2e-demo`,做 lemonai 官网) - root run `run_9168bd24f46c` → work run `run_ee90c4e35f9f`,2026-08-18T16:19:36Z 收敛为 `blocked` - 死在 `org-planner-sdd`,`fail_reason = boundary_escape` - **这次判罚是误伤**:守卫抓到的 3 处改动(`M server/services/graph-run-sse.js`、 `?? server/services/task-scheduler.js`、`?? server/tests/unit/task-scheduler.test.js`) 正是当时并行会话在写批 3 的文件,不是 agent 写的 - 预算远未耗尽:7/100 次调用、69,961/5,000,000 tokens ### 界面上怎么重跑(不需要命令行) 两个 🔁重试 按钮,各作用于自己那一层,**选中一个 `fail`/`blocked` 节点才亮**: - **左栏点 `org-planner` 重试** → 子图里没有待跑节点 → `resetRunNodes`:整张 work graph 作废重跑(brd/prd 也重来) - **中栏点 `org-planner-sdd` 重试** → 只把这一个节点打回 ready。**自足**:子图跑完后 `reviveParentFromChild` 会自动把父节点和父 run 从 blocked 拉回来 - **顺序有影响**:先中栏单点重试、再左栏点 org-planner,那时子图里有 ready 节点 → 左栏那一下变成「定点重置」而不是整张图重跑 ### ⚠️ 重跑的前提 **工作区必须是干净的。** 边界守卫比对的是 agent 调用前后的整仓 `git status` 差集, 只要还有人在写这个 worktree,跑起来的节点就会再被误判一次 —— 白烧一轮预算。 截至 17:1x 工作区仍有 13 个文件在改动中。 > 顺带:这条误伤暴露了守卫的一个结构性上限 —— 整仓 git status 的差集里**没有「谁写的」这一维**。 > 多会话共用 worktree 时它必然误判。`splitUnattributable` 已经为 `system/agents/` 开了「保留证据、 > 放弃判罚」的口子,同样的思路能不能推广到「调用窗口内有其他写入者」,值得单开一条 followup。 --- ## 六、协作规则(这次踩实的) 多个 Claude 会话共用 `/Users/yibo/Documents/lemonAI/lemonboss` 同一个工作目录、同一个 git HEAD。 1. **会话启动时系统给的 git status 快照,不是「用户自己的在途工作」的证据。** 它只是「此刻工作区长这样」,里面大概率有别的会话正在写的东西。 本次事故的根因就是把快照当成了归属证明。 2. **「用户让我提交工作区」≠「工作区里的东西都归用户」。** 逐文件确认归属。 3. `git commit -- <自己的文件>` 精确提交。注意:**`git add <显式列表>` 一样会中招** —— 出事的是列表内容,不是有没有用 `-A`。 4. 判断某文件是不是别人的:看 mtime / `find . -newermt '-20 minutes'`。 **对话进行中才变的文件 = 有人正在写**,一律先 SendMessage 问。 5. commit message 里的「N 个测试全绿」只描述那一次测试运行。 压在别人还没复核完的代码上时,它会替对方说出没说过的话。 6. 任何 `checkout` / `restore` / `stash` / `reset` 前先问,不论范围多小。 --- ## 七、下一步 —— 全部完成(2026-08-20 回填) - [x] ~~lemonboss-78 补齐 §二 里缺的 3 条复核结论~~ —— 已补齐并提交(`5a31d00`),4 条均带独立探针实跑证据 - [x] 定 `87b776c` 的处理方式 —— **修复前推**,4 条作为后续提交解决 - [x] **N1** 孤儿子 run —— 改为无条件走 `engine.stopRun`([27-P3C §15.3](./27-p3c-goal-task-model.md)) - [x] **L1** manual 的恢复出路 —— 判据用 `attempt > 0` 而**不是** `started_at`(后者看着更贴切,但 `requeueBlockedRootRun` 退回队列时会把它清成 NULL,恰好在需要它的那一刻是空的,实测踩过) - [x] **N2** 「退回队列」透出到界面 —— 加 `notice` 一行明说「已退回队列排队,坑一空会自动接着跑」 - [x] **N3** `isSlotRaceError` —— 探针证伪后**删掉该分支**(被推走时条件更新返回 0、不抛;真正抛它的只有「调用方声明了非法转换」,那一支把调用方的 bug 当竞态吞掉且不留日志) - [x] 重跑 `T-msyu6u9b-939c`(见 §五)—— ✅ **2026-08-20 已解开并推进**,见 [27-P3C §17.4 / §17.5](./27-p3c-goal-task-model.md) - [x] 给边界守卫加「多写入者 → 放弃判罚」的口子(§五 末尾)—— ✅ 已加,见 [27-P3C §17.2](./27-p3c-goal-task-model.md) > **§五 那条 run 的结局值得单独记一句**:本文档判断「它卡住是因为边界守卫误伤」, > 这一半是对的 —— 但守卫修完之后**它仍然跑不动**。第二道锁在墙钟预算上 > (`blocked` 挂起等人的时间被算进「已用」,过夜的 run 永远醒不过来), > 那把锁只有真去点一次重试才露出来。 > **「修完 A 就能跑」是个假设,不是结论 —— 卡住的东西可以有不止一把锁。** > §二 那条「注释承诺的前提被本轮自己的改动改没了,注释还留着」已经是第三次立案 > (§十三.3 / §十四.4 / 本次)。第三次就不该再只当个案记 —— 值得考虑一条机器可查的守卫: > 注释里出现「因此消失 / 不再可能 / 已经不会」这类**断言性承诺**时,要求同处留下可执行的判据引用。 > 这条只是提议,归 lemonboss-78 判断要不要立。