Commit 5a31d006f6b7a56671ee86a2106bfc29508563e9

Authored by yibo
1 parent 06fbc9ff

docs(27-P3C): 补齐交接文档里留空的 3 条复核结论

lemonboss-68 写交接文档时只确知 1 条,其余 3 条刻意留「待补」而不是
凭二手转述硬写 —— 那个判断是对的,记错了反倒会变成新的事实来源。
这里由批 3 作者按 17:00 那次对抗复核的原文补齐,每条都有探针实跑证据。

四条:
- N1 🔴 取消排队留下还在烧 token 的孤儿子 run(唯一有直接金钱成本的)
- L1 ⚠️ 恢复路径对 manual 不成立(调度器故意不碰 manual,于是坑空着没人接)
- N2 🟠「退回队列」对用户完全不可见(200 + 按钮全灰 + 图消失 + 一句假提示)
- N3 🟠 isSlotRaceError 的判据注释被探针证伪,且吞真错误不打日志

其中 3 条是同一个模式:注释承诺的前提被本轮自己的改动改没了,注释还留着。
27-P3C §十三.3 / §十四.4 已就这个模式立过两次案,这是第三次。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-batch3-handover.md
... ... @@ -36,11 +36,42 @@ main 现在躺着一份**已知有缺陷**的代码,而那笔提交的说明
36 36  
37 37 **已知缺陷(来自 17:00 的对抗复核):4 条 —— 1 条半修 + 3 条新引入。**
38 38  
39   -本文档作者**只确知其中 1 条**,其余 3 条的准确描述以 lemonboss-78 / lemonboss-89 手上的复核结论为准,
40   -不要以本文档为准:
41   -
42   -- ⚠️ **「取消排队」会留下一个还在烧 token 的孤儿子 run**。这条有实际成本,优先级最高。
43   -- 其余 3 条:**待补**(本文档作者没拿到原文,刻意不复述二手转述,避免记错了反倒变成新的事实来源)。
  39 +> **2026-08-19 由 lemonboss-78(批 3 作者)补齐**:以下 4 条是 17:00 那次对抗复核的原文结论,
  40 +> 每条都有独立探针实跑证据。本文档作者原先只确知第 1 条、其余刻意留空——那个做法是对的。
  41 +
  42 +**N1 🔴「取消排队」会留下一个还在烧 token 的孤儿子 run**(优先级最高,唯一有直接金钱成本的一条)
  43 +`company.js:1768` 的注释说「对一条还没开跑、没有子 run 的排队 run,`stopRun` 是多余的一圈」。
  44 +**这个前提被批 3 自己的 `requeueBlockedRootRun` 改没了** —— `run_status='queued'` 的 run 现在可以是
  45 +跑过一半的(有 success 节点、有 ready 节点、有活着的子 run)。探针实跑:取消后 root=stopped /
  46 +task=cancelled,而节点残留 `aa2:ready, aa1:success`(没批量 skipped),子 run 残留
  47 +`paused / stop_requested=0`(没走 `suspendRunTree`)。
  48 +失败路径:预算撞顶时子 run 可能仍是 `running`(引擎只把 root 置 blocked,子 run 要到节点边界才落 paused)→
  49 +人点「启动」→ 退回队列 → 改主意点「取消排队」→ **那条 running 子 run 既没收到停止信号也没被结算,继续调 agent**。
  50 +前端 `goal-task-model.js:171` 的按钮 title 还在说「它还没开跑,所以不会有半截产物」。
  51 +命中入口三个:`/tasks/:id/cancel` 的 queued 分支、blocked 分支①、`DELETE /tasks/:taskId`。
  52 +
  53 +**L1 ⚠️ 半修 · 恢复路径对 manual 任务不成立**
  54 +批 3 的修法是「恢复撞坑满 → 退回队列」,对 **auto 真的成立**(探针:T2 收敛后 T1 无人干预自动跑起来)。
  55 +但 `tickInner` 第 4 步 `filter(t => t.execution_mode === 'auto')` 故意不碰 manual(§五.2bis「manual 不堵队」),
  56 +于是 manual 任务:人点「启动」→ 退回队列 → 坑空了 → **没人来接**,任务躺着、sweep 每 60s 空转。
  57 +而 `graph-engine.js:2345` 的注释白纸黑字写着「**「T1 永远回不来」这个后果因此消失**」——**对 manual 不成立**。
  58 +
  59 +**N2 🟠「退回队列」这件事对用户完全不可见**
  60 +`startRunSync` 早返回 `{ok:true, runStatus:'queued', requeued:true}` 走 **200**。
  61 +`GraphRunPane.act()` 只在 `!r.ok` 时写 `err` → 界面零提示;`can('start')` 因 `queued ∉ ALLOW.start` 把按钮全部置灰;
  62 +task 翻 queued 后 `hasStartedRun()` 返回 false → `GraphConsole.vue:248` 把 run 面板换成 org graph **定义视图**,
  63 +提示语「节点状态一律 pending」**是假的**(这条 run 有 success 节点、有活着的子 run)。
  64 +`requeued` 这个字段**全前端零处读取**(`grep -rn requeued web/src` 无命中)。
  65 +用户点了「启动」,得到的是:什么都没说、按钮全灰、图不见了。
  66 +
  67 +**N3 🟠 `isSlotRaceError` 第三条判据的注释被探针证伪,且它吞真错误还不打日志**
  68 +`work-graph-db.js:110` 声称 `InvalidTransitionError` = 「这条 run 已被别人从 queued 推走了」。
  69 +实跑证伪:被抢走后再 CAS **返回 0,不抛异常**;真正抛 `InvalidTransitionError` 的是**调用方声明了非法转换**
  70 +(`expected = from` 是调用方声明值,`canTransitionRun` 判的是 `expected → to` 而不是 `cur.run_status → to`)。
  71 +所以这条判据把「调用方写错了转换」当成竞态吞掉,且不留日志。
  72 +
  73 +> 这 4 条里有 3 条是同一个模式:**注释承诺的前提被本轮自己的改动改没了,注释还留着**。
  74 +> 27-P3C §十三.3 / §十四.4 已经就这个模式立过两次案,这是第三次。
44 75  
45 76 > 提交说明里的「126 文件 / 1201 用例全绿」**需要更正**:这句话准确描述了 16:54 那次测试运行,
46 77 > 但它压在一份尚未通过复核的代码上,读起来像质量背书,替 lemonboss-78 说了它没说过的话。
... ...