pi-legion 5.0.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/CONTRIBUTING.md +33 -2
  3. package/README.md +10 -5
  4. package/agents/centurion.md +32 -7
  5. package/agents/scout.md +11 -0
  6. package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
  7. package/docs/contracts/centurion-declared-phases-v1.md +104 -0
  8. package/docs/contracts/declarative-phase-machine-v5.md +43 -0
  9. package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
  10. package/docs/contracts/finished-cache-eviction-v1.md +169 -0
  11. package/docs/contracts/galaxy-scratch-v1.md +78 -0
  12. package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
  13. package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
  14. package/docs/contracts/legion-page-floor-v1.md +129 -0
  15. package/docs/contracts/legion-session-continuity-v1.md +118 -0
  16. package/docs/contracts/mailbox-delivery-v1.md +162 -0
  17. package/docs/contracts/notice-claim-v1.md +206 -0
  18. package/docs/contracts/partial-failure-exit-v1.md +235 -0
  19. package/docs/contracts/proc-sweep-v1.md +107 -0
  20. package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
  21. package/docs/contracts/stall-visibility-v1.md +218 -0
  22. package/docs/contracts/subagent-renewal-v1.md +5 -3
  23. package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
  24. package/docs/contracts/viz-frame-alloc-v1.md +123 -0
  25. package/docs/contracts/wait-mail-preempt-v1.md +117 -0
  26. package/docs/contracts/wait-not-stop.md +1 -1
  27. package/docs/design.md +4 -1
  28. package/docs/inprocess-supervisor.md +9 -3
  29. package/docs/legion-v2-contract.md +122 -20
  30. package/docs/vexillatio-contract.md +5 -1
  31. package/index.ts +1903 -130
  32. package/package.json +5 -5
  33. package/skills/pi-legion/SKILL.md +2 -1
  34. package/src/agents/agents.ts +26 -1
  35. package/src/agents/settings.ts +67 -2
  36. package/src/api.ts +17 -0
  37. package/src/extension/child-guard.ts +2 -1
  38. package/src/extension/join.ts +54 -0
  39. package/src/extension/legion-commands.ts +15 -4
  40. package/src/extension/legion-doctor.ts +48 -6
  41. package/src/extension/legion-status.ts +44 -2
  42. package/src/extension/legion-tools.ts +1044 -109
  43. package/src/extension/notify.ts +31 -2
  44. package/src/extension/playbook.ts +6 -3
  45. package/src/extension/runtime.ts +882 -81
  46. package/src/extension/spawn-hints.ts +52 -12
  47. package/src/extension/summary.ts +7 -15
  48. package/src/extension/timing.ts +4 -15
  49. package/src/legion/db.ts +23 -1
  50. package/src/legion/events.ts +7 -0
  51. package/src/legion/interrupt.ts +98 -0
  52. package/src/legion/mail-release.ts +266 -0
  53. package/src/legion/mail.ts +456 -29
  54. package/src/legion/nodes.ts +31 -0
  55. package/src/legion/phase-conditions.ts +7 -1
  56. package/src/legion/phase-machine.ts +160 -8
  57. package/src/legion/state-machine.ts +34 -2
  58. package/src/runs/acceptance.ts +105 -6
  59. package/src/runs/args.ts +16 -0
  60. package/src/runs/child-scope.ts +323 -0
  61. package/src/runs/extension-inheritance.ts +145 -19
  62. package/src/runs/orchestrator.ts +613 -55
  63. package/src/runs/store.ts +149 -5
  64. package/src/shared/proc-sweep.ts +317 -0
  65. package/src/shared/progress.ts +11 -2
  66. package/src/shared/session-map.ts +29 -0
  67. package/src/shared/session.ts +308 -6
  68. package/src/shared/types.ts +34 -0
  69. package/src/supervisor/inprocess-supervisor.ts +158 -12
  70. package/src/supervisor/race-timeout.ts +55 -0
  71. package/src/supervisor/routing-supervisor.ts +55 -4
  72. package/src/supervisor/rpc-supervisor.ts +215 -29
  73. package/src/supervisor/types.ts +439 -2
  74. package/src/tui/status.ts +2 -1
  75. package/src/tui/viz/canvas.ts +358 -36
  76. package/src/tui/viz/palette.ts +18 -2
  77. package/src/tui/viz/panels.ts +507 -104
  78. package/src/tui/viz/rate.ts +120 -15
  79. package/src/tui/viz/view.ts +72 -4
@@ -0,0 +1,129 @@
1
+ # /legion 页面底色契约 v1(README for the page floor)
2
+
3
+ > 状态:**生效**,随 #308 落地。这是一条**记录性**契约:把一个已经可验证的渲染不变量写死,
4
+ > 避免以后有人把 `/legion` 行尾的底色改回去。
5
+ > 冲突时**停下报告**,不得自行重新解释本契约。
6
+ > 关联:#308(浮层右侧黑竖带)、#193(浮层黑边:浮层本身不画遮罩)、#157(浮层 75% 尺寸)。
7
+
8
+ ---
9
+
10
+ ## §1 问题陈述
11
+
12
+ `/legion` 轨道图里选中节点按 `o`/`⏎` 打开详情浮层后,**浮层右缘到终端右缘**是一条纯黑竖带,
13
+ 左侧正常。根因不是浮层越界(`paintModalFrame` 每行宽度恒等于 rect 宽,见
14
+ `src/tui/live-overlay.ts`),而是 **base 的行尾空格不带任何 SGR**:
15
+
16
+ 1. `Canvas` 构造即预填 `W×H` 空格(`src/tui/viz/canvas.ts`),`renderRow` 只在
17
+ `start.color && text.trim().length > 0` 时才套 `theme.fg` —— 于是行尾一大段空格是裸空格。
18
+ 2. pi-tui 的合成器(`@earendil-works/pi-tui@1.1.0`,`dist/utils.js` 的 `extractSegments`;
19
+ 本 check-out 里快照发生在 `:1242` `after += pooledStyleTracker.getActiveCodes()`)对矩形
20
+ **右侧段**的样式继承是「after 段的**第一个字素**出现时,打一次当时激活 SGR 的快照」。
21
+ 3. 行尾是空格且此前无激活 bg ⇒ 快照为空 ⇒ 右侧留白段落到终端默认背景 ⇒ 黑竖带。
22
+
23
+ **注意**:`test/helpers/modal-composite.ts` 里 #193 的 `wipedRows` 判据对此**不敏感**
24
+ (它 strip 掉所有 escape,只比文本,而文本一直是 base 的)。所以 #308 的验收用例必须保留 ANSI,
25
+ 比较的是「**样式**」而不是「文本」。
26
+
27
+ ---
28
+
29
+ ## §2 约定
30
+
31
+ **R1 — `/legion` 每一行外层必须带底色。**
32
+ 唯一出口:`src/tui/viz/view.ts` 的 `createLegionViewFactory().render()` →
33
+ `paintPageFloor(theme, canvas.toLines(theme))`。所有 row 都要包,包括整行空白的 row
34
+ (正是这些行构成了浮层右缘到终端右缘的留白段)。
35
+ 状态栏小部件的轨道图(`src/tui/viz/railway.ts` → 宿主 chat 内的 widget)**不走**这个出口:
36
+ 它躺在宿主聊天区里,必须跟聊天底色一致。
37
+
38
+ **R2 — 底色 key 只能取 `ThemeBg` 的 7 个之一,当前 = `toolPendingBg`。**
39
+ `ThemeBg = selectedBg | searchMatchBg | userMessageBg | customMessageBg | toolPendingBg |
40
+ toolSuccessBg | toolErrorBg`(`@earendil-works/pi-coding-agent` 的 `theme.d.ts`)。
41
+
42
+ 选 `toolPendingBg` 的理由:7 个里只有它是**中性面**而不是带色号的面板 ——
43
+ dark 主题 `okhsl(229 5% 24%)`、light 主题 `okhsl(248 3% 91%)`、`system` 主题里属于
44
+ `neutral` family,没有色相身份,这正是「页面底」该有的样子。其余 6 个都带明确的颜色叙事,
45
+ 铺满整页会串色:`selectedBg` / `userMessageBg` 是两个内置主题里的 `blueBg`(蓝),
46
+ `customMessageBg` 紫,`searchMatchBg` 黄(高亮语义),`toolSuccessBg` / `toolErrorBg` 绿 / 红。
47
+ 取舍是**亮度**而不是色相:`toolPendingBg` 比主题里仅用于导出(`export`,非可渲染色)的
48
+ `pageBg`(dark `okhsl(262 14% 16%)`)亮约 8 个点。上游没有把 `pageBg` 暴露成可渲染 token,
49
+ 所以只能在 `ThemeBg` 里选一个最接近的中性面。
50
+
51
+ **R3 — `theme.bg` 只能通过可选调用使用。**
52
+ `VizTheme.bg?`(`src/tui/viz/palette.ts`)是可选方法,测试里的假主题大多只有 `fg`。
53
+ 写法固定为 `theme.bg?.(LEGION_PAGE_BG, line) ?? line`。
54
+ 推论:**没有 bg 的主题,输出必须逐字节不变**(既有断言依赖这一点)。
55
+
56
+ **R4 — 三条硬红线(issue 已实测,违反会打死既有用例)。**
57
+
58
+ | 禁止 | 会打死的用例 |
59
+ |---|---|
60
+ | 用 `inverse` 冒充底色 | `test/unit/legion-viz.test.ts` 里「portrait tiny 不得出现 `\x1b[7m`」 |
61
+ | 无 bg 时无条件追加行尾 escape | `test/unit/legion-viz.test.ts` 里 `plainTheme` 的整行形状断言(`/^[─┬\s\-−0-9a-z·./]+$/`) |
62
+ | 改变行宽 | 一批 `visibleWidth(line) === width` 断言 |
63
+
64
+ 底色用零宽 escape 包**已有**内容,因此 `visibleWidth` 不变;不要往里塞填充字符。
65
+
66
+ **R5 — `system` 主题下仍然是终端默认底色(本仓不可解,不是回归)。**
67
+ `system` 主题在「终端没上报配色」档(`generateSystemThemeColors` 走 `!background` 分支 →
68
+ `indexedColors()`)里,把 `PANELS` 全部 7 个 bg key 一律赋 `""`;`Theme.bg(key, text)`
69
+ 于是渲染成 `\x1b[49m<text>\x1b[49m`,也就是显式要求终端默认背景。
70
+ 实测(`new Theme(colors, colors, "ansi16")`):
71
+
72
+ ```
73
+ selectedBg "\u001b[49mX\u001b[49m"
74
+ searchMatchBg "\u001b[49mX\u001b[49m"
75
+ userMessageBg "\u001b[49mX\u001b[49m"
76
+ customMessageBg "\u001b[49mX\u001b[49m"
77
+ toolPendingBg "\u001b[49mX\u001b[49m"
78
+ toolSuccessBg "\u001b[49mX\u001b[49m"
79
+ toolErrorBg "\u001b[49mX\u001b[49m"
80
+ ```
81
+
82
+ ⇒ 该档位下本修法正确但**无效**(黑竖带消失不了),因为上游缺一个「页面底色」token。
83
+ 要根治得等上游把 `export` 里的 `pageBg`(dark `okhsl(262 14% 16%)`、light
84
+ `okhsl(17 3% 94%)`)暴露成可渲染 token;在那之前本仓只能做到「有真实 bg token 的主题不黑」。
85
+
86
+ **R6 — `theme.bg(key)` 只认那 7 个 key。**
87
+ 传别的(例如 `theme.bg("pageBg", …)`)在运行期抛 `Unknown theme color: pageBg`
88
+ (`Theme.tokenAnsi()`)。**不要**碰 `ThemeBg` 之外的名字。
89
+
90
+ ---
91
+
92
+ ## §3 验收
93
+
94
+ 保留 ANSI 的合成用例(新增于 `test/unit/legion-live-overlay.test.ts`,harness 在
95
+ `test/helpers/modal-composite.ts` 的 `assertOverlayGutterInheritsBase`):
96
+
97
+ > `/legion` 视图作 base + 75% 居中浮层,逐帧行断言
98
+ > 「**浮层左右留白段的 strip 结果等于 base 同列文本,且第一个格子的背景 SGR 等于 base 同列背景 SGR**」。
99
+
100
+ **REV-1(#347)—— 挂载方式变了,断言不变。** §3 落地时 `/legion` 的生产几何是**全屏
101
+ overlay**;#347 之后生产几何改为**主屏挂载**(`ui.custom(..., { overlay: false })`,见
102
+ `docs/contracts/legion-main-screen-mount-v1.md`)。本条断言的**内容与强度都不改**,两种挂载
103
+ 都必须绿:
104
+
105
+ - `assertOverlayGutterInheritsBase({ ..., mount: "overlay" })` —— 历史几何,保留为记录(原 #308 用例);
106
+ - `assertOverlayGutterInheritsBase({ ..., mount: "main" })` —— **当前生产几何**(#347 新增用例)。
107
+
108
+ 理由:#308 的机制是「合成器对 rect 右侧段取 base 行尾的一次性 SGR 快照」,它只取决于
109
+ **详情浮层是 overlay**,与页面自己怎么挂载无关。实测(主屏挂载、去掉 floor)
110
+ `rowBg` / `baseRightBg` / `frameRightBg` 同样退化 `""` ⇒ 黑带重现,所以 `paintPageFloor`
111
+ 在主屏挂载后**仍然必需**,不是「保险」。
112
+
113
+ 该用例必须**在去掉修复后变红**:去掉 `paintPageFloor` 后所有段的 bg 快照都是 `""`
114
+ (`rowBg` / `baseRightBg` / `frameRightBg` 全空),而文本仍逐字符相同 ——
115
+ 后者正是「不能只用 `wipedRows`」的证据。
116
+
117
+ 断言里必须显式要求 **非空**(`baseRightBg === PAGE_FLOOR_ANSI`),否则
118
+ 「`"" === ""`」会让修复前的代码通过;只断言两边相等是无效断言。
119
+
120
+ ---
121
+
122
+ ## §4 不做(边界)
123
+
124
+ - **不改宿主合成器**(`node_modules/@earendil-works/pi-tui`),不改浮层几何。
125
+ - **不给浮层自己画遮罩/底色**:浮层 rect 内的每个 cell 都被合成器替换,遮罩必然不透明
126
+ (#193 已裁决)。
127
+ - **不改状态栏 widget 的轨道图**:见 R1。
128
+ - **不为 `system` 主题档位造假底色**(例如硬编码灰):那会把「跟随 `/theme`」这条既有
129
+ 不变量换成一条猜出来的颜色,且浅色终端下会瞎。
@@ -0,0 +1,118 @@
1
+ # 会话中断与恢复契约 v1(同 session 才提示、恢复必须显式)
2
+
3
+ - 状态:生效,随实现 PR 落地。
4
+ - 上游:`docs/legion-v2-contract.md`(FROZEN-1,按 §15 修订)、
5
+ `docs/contracts/declarative-phase-machine-v5.md`、`docs/contracts/centurion-declared-phases-v1.md`。
6
+ - 冲突时停下报告,不得自行解释。
7
+
8
+ ## §0 一句话
9
+
10
+ root 退出时,子树**停手但保留可续的状态**;下次启动**同一个 session** 才把这份状态接回来、并告诉 agent
11
+ 「上次有 N 个 subagent 被打断」;**恢复必须是显式动作**(用户说 或 命令);**换 session 一律零动作**。
12
+
13
+ ## §1 会话身份
14
+
15
+ - 判据只有一种:**session 文件的绝对路径相等**(`path.resolve`)。仓内没有 session id 概念
16
+ (`ChildRecord.sessionId` 是死字段),不要新造。
17
+ - `RunRecord` 增 `sessionFile?: string`(走 `src/runs/store.ts:32-43` 的 `declare module` 增强,与 `nodeId` 同模式)。
18
+ `writeRun`/`readRun` 原样保留未知字段(已核实),所以只改 `createRootLegion` 的构造即可。
19
+ - 取不到 session 文件(`SessionManager.inMemory()`)⇒ 视为**没有会话身份**:不记录、不查找、不提示。
20
+
21
+ ## §2 同 session 重开(reopen)
22
+
23
+ `createRootLegion` 之前新增一步查找:`store.listRuns()` 里 `sessionFile` 与当前 session 绝对路径相等的
24
+ **最近一条** run(`createdAt` 最大)。
25
+
26
+ | 情况 | 行为 |
27
+ |---|---|
28
+ | 找到 | **复用它**:用它的 `runId` / run 目录 / `legion.db`;root 行已存在则**update**(status→running、updatedAt),**不 insert**(`insertNode` 会按名字冲突抛 `NodeNameConflictError`);**不**新建 run、**不**新写 `run.json`(只更新 `updatedAt`)。 |
29
+ | 没找到 | 与今天完全一致:`createRun` 新 run、新账本。 |
30
+ | 找到但账本损坏/root 行缺失 | 退回「没找到」,不抛。 |
31
+
32
+ 理由:账本按 run 开、`legion.db` 在 run 目录里。要"接回上次的树",最省的是复用同一份 run,
33
+ 而不是在新账本里重建一棵影子树(那会让 `legion_tree`、相位、邮件全部断链)。
34
+
35
+ ## §3 退出 = 暂停,不是判负(关键改动)
36
+
37
+ 今天 `session_shutdown` → `retire({cause:"session-shutdown"})` → `abortLegionPhase` 会把相位推到
38
+ `aborted`(终态),而 `childFromNode` 对终态相位直接抛 `CHILD_TERMINAL` ⇒ **被打断的队永远不可恢复**。
39
+ 这与本契约冲突,必须改:
40
+
41
+ - `cause === "session-shutdown"` 时**跳过相位迁移**,只做:杀进程、`status → retired`、
42
+ 写 `node_retired` 事件并带 `{ reason: "session-shutdown" }`(**不新增事件类型**:闭集仍 16 条)。
43
+ - 其它 cause(`stop`、`abort`、超预算、显式 retire)**行为不变**:仍走 `abortLegionPhase`。
44
+ - 相位、`phase_since`、`rework_count` **一律不动**(暂停是暂停,不是新 stint,不写 `phase_change{reason:"revived"}`)。
45
+ - 结果:`isTerminalNode` 仍为 false(`retired` 且相位非终态)⇒ 可被 resume;`hasEnded` 为 true ⇒ 暂停期间
46
+ 邮件不会被投给它(保持 §8.3 现状,不新增投递语义)。
47
+ - **实现顺序(实测修正,2026-11-02)**:标记必须在退出流程**最前面**完成(`markSessionInterrupted`),
48
+ 早于 `--print`/`--json` 的 `drainBeforeShutdown`。原因:drain 期间被杀的子节点,其截断 session 仍会被
49
+ 解析成 `success`,retire 路径会把它记成 `settled` —— 容器实测:worker 在 SIGTERM 时仍 `running`,
50
+ 退出后却变成 `settled/success`,暂停标记被吞掉。因此:① 先只写账本(不杀进程、不动相位);
51
+ ② 「是否被打断」以**账本最后一条 `node_retired` 的 reason** 为准,而不是调用点的 `cause`;
52
+ ③ 标记过的节点只有在最后一轮**真的完整结束**时才允许升级成 `settled`(`isLastTurnComplete`)。
53
+
54
+ ## §4 恢复必须显式
55
+
56
+ - **不自动 resume**。root 启动时**只统计和提示**,绝不 spawn。
57
+ - 恢复动作是 `subagent({ action: "resume", name })`(既有工具):`reviveChild` 从 run.json 定位、
58
+ `orchestrator.launch` 复用 `previous.sessionFile`。
59
+ - 前提:新会话必须已经 reopen 同一账本(§2),否则 `input.legion.nodeId` 在账本里不存在,`launch` 会抛
60
+ `legion parent node not found`(拒绝而非静默降级,保持现状)。
61
+ - **声明过的 `phases` 必须跨进程存活**:`ChildRecord` 增 `phases?: PhaseMachine`(launch 时快照),
62
+ `reviveChild` / `resumeLegionChild` 传回 `Orchestrator.launch`,由既有 `LEGION_PHASES_ENV` 通道下发给子进程。
63
+ 没有它,复活的内联声明队会退化到角色卡机器(自定义相位名 → `invalid_phase`)。
64
+
65
+ ## §5 提示(agent 看得到、用户也看得到)
66
+
67
+ - 触发条件:本次启动 **reopen 成功** 且账本里存在被打断的**直接子节点**。判据两条(实现期修订,
68
+ 见下):① 状态仍是 `starting/running/blocked`(进程随 session 死了,不会有人再推动它);
69
+ ② 或状态是 `retired` **且**它最后一条 `node_retired` 带 `reason: "session-shutdown"`
70
+ (用户主动 `stop` 的节点不带这个 reason,不能报成"被打断")。
71
+ - `phase` **不参与**判据:自 #371 起只有声明 `phases:` 的队才有相位,把 `phase` 当必要条件会漏掉
72
+ 最常见的场景——**被打断的 worker**。节点还必须带 `sessionFile`(没有恢复凭据就不该被宣传)。
73
+ - 实现期修订记录(2026-11-02,本 PR):初版 §5 要求 `phase` 非空且非终态,实际上线时会把所有 worker
74
+ 排除在外;改为上述两条判据,测试同时钉住「主动 stop 不出现在清单里」。
75
+ - agent 侧:注入**system prompt 一段**(复用 `before_agent_start` 的 playbook 注入位),内容包含
76
+ 节点名、相位、以及一句硬规则:
77
+
78
+ ```
79
+ These subagents were interrupted when the previous session exited.
80
+ Do NOT resume them on your own; ask the user first, then call
81
+ subagent({action:"resume", name}) for each one they approve.
82
+ ```
83
+
84
+ 该段**只在存在被打断节点时出现**;节点全部被恢复或被丢弃后自动消失(幂等,不累积)。
85
+ - 用户侧:一行 `ctx.ui.notify`(同一次启动只一次)。
86
+ - **跨 session 不提示、不扫描、不写任何东西**(§1 判据不成立即返回)。
87
+
88
+ ## §6 不变量
89
+
90
+ | 编号 | 不变量 | 守卫 |
91
+ |---|---|---|
92
+ | I1 | 跨 session 零副作用:不读旧 run、不提示、不改状态 | 新增用例(断言旧 run.json 的 `updatedAt` 不变) |
93
+ | I2 | 事件闭集仍 16 条;`node_retired` 只多一个 `reason` 字段 | `test/unit/legion-schema-sentinel.test.ts`、`legion-events.test.ts` **不改** |
94
+ | I3 | `NodeStatus` 仍 6 个值,不加 `interrupted` | `test/unit/legion-nodes.test.ts:168-201` **不改** |
95
+ | I4 | 非 `session-shutdown` 的 retire 行为逐字不变(相位仍被 abort) | 现有 retire/abort 用例 **不改** |
96
+ | I5 | 工作树语义不变(centurion 独占、强制 true、绑 rpc) | `worktree-centurion-only.test.ts` **不改** |
97
+ | I6 | 暂停期间不自动恢复、不自动开 turn(不烧 token) | 新增用例(断言零 spawn、零模型调用) |
98
+ | I7 | reopen 后 `legion_tree` 能看到上次的节点与相位 | 新增用例 |
99
+
100
+ ## §7 验收
101
+
102
+ 1. **同 session**:会话 A 派 2 个 worker 后退出 → 同 session 重开 → 账本仍是同一份、2 个节点可见且相位不变、
103
+ prompt 里出现那 2 个名字、UI 一行提示、**零 spawn**。
104
+ 2. **显式恢复**:`subagent({action:"resume", name})` 后子节点重新跑起来,`nodes.status` 回 `running`,
105
+ 相位不变(不写 `revived` 事件以外的迁移),session 文件复用同一个。
106
+ 3. **声明机恢复**:内联 `phases` 的 centurion 被打断后恢复,`legion_phase` 仍认它的自定义相位名
107
+ (不给 `invalid_phase`)。
108
+ 4. **跨 session**:不同 session 启动 → 不提示、不扫描、旧 run 文件 byte-identical(I1)。
109
+ 5. **`kill -9`**:子进程在 `stdin` 断开后自行退出(沿用 pi 的 stdin-close-shutdown 契约;实测记录写进报告)。
110
+ 6. `npm run typecheck` / `npm test` / `npm run test:integration` 全绿。
111
+
112
+ ## §8 非目标
113
+
114
+ - 不做自动恢复(C 方案)。
115
+ - 不改 `NodeStatus` 闭集、不加事件类型。
116
+ - 不为暂停新增邮件/通知语义(`hasEnded` 的邮件语义保持不变)。
117
+ - 不动 worktree、MR、acceptance。
118
+ - 不处理「不同机器/不同 store root 的 session 迁移」。
@@ -0,0 +1,162 @@
1
+ # 信箱投递五态(v1,冻结)
2
+
3
+ > 语义由 #322 / #333 冻结。实现期发现矛盾 → 停下报告,不得重新解释本契约。
4
+ > **本文件是 mailbox-delivery 的 D 编号命名空间**:实现代码与测试注释里的
5
+ > `#322 Dn` 只解析到这里的 D1–D5。`docs/legion-v2-contract.md` 的 D 编号(D11 可视化、
6
+ > D12 账本…#322 无关)不在本契约内。
7
+
8
+ ## 背景
9
+
10
+ `messages.delivered` 原本只有三值(`PENDING(0)` / `INJECTED(1)` / `BOUNCED(2)`),而
11
+ `LegionPostman.deliverToChild` 在 send 调用**返回**后就写 `INJECTED`。#313 之后我们知道那个返回是三态的:
12
+
13
+ | disposition | 真实含义 | 旧落库值 |
14
+ |---|---|---|
15
+ | `queued` | 已入队,等子节点下个 turn 边界(工具批结束之后) | `INJECTED` |
16
+ | `accepted` | 为空闲子节点起了一轮 | `INJECTED` |
17
+ | `intercepted` | 被 input handler 拦下,子节点**从未看到**,且不会被重投 | `INJECTED` |
18
+
19
+ 于是同一个 `1` 同时表示「排队中」「已起轮」「根本没送到」,父节点没有任何可查询的信号能区分。
20
+ #333 是它的一个具体后果:一条 `Phase timeout:` 的 `action-needed` 通知在团队已经收敛(推进相位 /
21
+ 失败 / 被 retire)之后才被投递,读起来像一条待办,却没有撤销路径。
22
+
23
+ ## 冻结决定
24
+
25
+ ### D1 — 五值状态域,schema v1 → v2
26
+
27
+ ```ts
28
+ export const MAIL_DELIVERY = {
29
+ PENDING: 0,
30
+ ACCEPTED: 1, // send 返回了:queued(等 turn 边界)或 accepted(起了新轮)
31
+ BOUNCED: 2, // 收件人终态 / 被 input handler 拦下(永不送达)
32
+ DELIVERED: 3, // 子节点真的消费了(判据见 D3)
33
+ OBSOLETE: 4, // 曾经有效,但送达前已失去意义(#333 的 phase_timeout)
34
+ } as const;
35
+ ```
36
+
37
+ - `LEGION_DB_USER_VERSION` 升到 **2**,`migrate()` 增加 v1→v2 分支:**只写版本升级与注释,不改列**。
38
+ `messages.delivered` 一直是 `INTEGER NOT NULL DEFAULT 0`,取值域是写入方与读取方之间的语义约定,
39
+ SQLite 无法约束。旧行的 `1` 直接按 `ACCEPTED` 解释,**无需回填**。
40
+ - `MAIL_DELIVERY.INJECTED` 保留为**弃用别名**(指向 `ACCEPTED`,同值 1)并标 `@deprecated`,
41
+ 避免仓库外 import 断裂;仓库内调用点改用新名字,**只保留一处以钉住别名**
42
+ (`test/unit/legion-mail.test.ts` 断言 `INJECTED === ACCEPTED`,别处不得再引用:#338 minor 3)。
43
+ - 既有 v1 账本不会因为版本升级得到 `idx_messages_rate`(见 legion-v2-contract `REV-19`),本契约不改这一点。
44
+
45
+ 合法迁移(其余一律 `InvalidDeliveryTransitionError`;同值重复是幂等空操作):
46
+
47
+ | 从 | 到 | 谁写 |
48
+ |---|---|---|
49
+ | `0 PENDING` | `1 ACCEPTED` / `2 BOUNCED` / `4 OBSOLETE` | 邮差 |
50
+ | `1 ACCEPTED` | `3 DELIVERED` / `4 OBSOLETE` | 邮差 tick 确认 |
51
+ | `2` / `3` / `4` | —(终值) | — |
52
+
53
+ 写 `1` 时若收件人已是终态,按 `0→2` 落 `BOUNCED`(既有语义的保留)。
54
+ `1→2` **刻意不合法**:一条已经交给收件人的信再退回来,就是把历史改写成没发生过的样子。
55
+
56
+ ### D2 — 发送侧按 disposition 落值
57
+
58
+ - `queued` / `accepted` → `ACCEPTED`,事件 `mail_sent`(保持既有语义:send 已完成)。
59
+ `ACCEPTED` 本身**不写新事件行**——`sendMail` 落库时写的 `mail_sent` 已经就是这个事实,
60
+ 再写一条只是重复;邮差把那条既有事件重新 emit 到总线供 UI 用。
61
+ - `intercepted` → `BOUNCED`,`reason: "intercepted_by_input_handler"`,发送方收到 `mail_bounced` 事件
62
+ (并沿用既有退信邮件路径),并**删除** #313 引入的那条 `console.warn`:状态本身现在会说真话。
63
+ - 被拦下的那次 send **不算一次 steer**:不写 `node_steered`(#338 minor 1)。那个事件的意思是
64
+ 「文本真的进了收件人队列」,被 input handler 吃掉的不算;进程内事件集也只有 11 个成员且被 #266 A13
65
+ 的整表 + 长度断言钉死,为它加第 12 个要改写另一个 issue 的冻结断言。**等价观测信号是 `mail_bounced`
66
+ (带 `reason`)**:它照常走总线,UI 消费方据此显示退信,不需要第二条假事件。
67
+ - 收件人终态(`isTerminalNode`)→ 保持 `BOUNCED`(既有行为,事件不带 `reason`)。
68
+ - `supervisor.prompt` 分支(`void`,无 disposition)→ `ACCEPTED`,但这是**后端能力上限**:
69
+ in-process 的 `prompt` 无法区分 `queued`/`accepted`,所以这个值不声称任何观测结论。
70
+ 同理 `resumeChild` 的首条消息若被包装成任务卡文本,D3 的精确匹配会落空而停在 `ACCEPTED`。
71
+
72
+ ### D3 — `DELIVERED`:精确文本匹配,不做启发式
73
+
74
+ **判据(冻结)**:解析收件人 session 文件(`parseSessionFile`),其**最后 20 个 turn** 的 user 文本中
75
+ 存在一条与**邮差实际注入的文本**(`formatMail(message)`,即带 `[legion mail #id] / From / Subject /
76
+ Urgency` 信封的那段)**完全相等**的字符串 → `DELIVERED`,写 `mail_delivered` 事件(保留既有 event type)。
77
+
78
+ > 冻结原文写的是「完全等于邮件 **body**」。实现只认**注入信封**这一种形式(#338 M2 收窄):
79
+ > 邮差真正塞进 session 的是 `formatMail(...)`,只认 body 会让生产路径永远不可能确认投递。
80
+ > 而 body 单独不足以当证据——它不带 message id,任务卡、另一封信、被引用的原文都能与它整串相等,
81
+ > 于是「别处出现过同一句话」会被读成「这封已送达」(反例:task 文本与 mail body 同名)。
82
+ > 信封形式仍是整串相等,D3 的「不做更弱的启发式」没有被放松,且自带 id,误判面最小。
83
+
84
+ **为什么不用 contains / 前缀 / 去空白 / 模糊匹配**:子节点在报告、review note、verdict 里会大量
85
+ 引用邮件原文——「把信里那句话抄回给我」是常态。任何包含式判定都会把「提到过」误读成「看到过」,
86
+ 而这个状态的唯一职责是把「子节点到底看到没有」答成事实(legion-v2-contract §10.4:viz reads facts)。
87
+ 宁可停在 `ACCEPTED`,也不要给一个错的 `DELIVERED`。
88
+
89
+ 扫描有界:每 tick 最多扫描 **N = 20** 封 `ACCEPTED` 邮件,**per-seam 游标轮转**——每个收件人记一个
90
+ `cursor`,下一批取 `id > cursor` 的 N 封,扫到尾部回绕到队头,因此每封 `ACCEPTED` 都会被周期性重扫。
91
+ 已知代价(#338 M3 订正):某收件箱有 M 封 `ACCEPTED` 时,一封的确认最坏等 `ceil(M / N)` 个 tick
92
+ (默认 2s/tick)——是**延迟**,不是停摆。早先「每 tick 固定取最老 20 封」的写法会让 20 封无法判定的
93
+ 邮件永久占住窗口,确认与作废两条通道一起停摆,那是死锁而不是延迟。
94
+ 确认在**投递之前**跑,因此本 tick 刚发出的信至少保持一 tick 的 `ACCEPTED`,不会在同一次 tick 里
95
+ 既入队又"被消费"。
96
+
97
+ ### D3 附注 — root 收件人的证据来源(#338 M1)
98
+
99
+ `DELIVERED` 的证据是**收件人自己**的 session 文件,而 `nodes.session_file` 历来只有子节点路径
100
+ (`orchestrator.ts` 的 `reserveNode`)会写。root 节点创建时不写这一列,于是发给 root 的邮件恒停在
101
+ `ACCEPTED`——包括 #333 的整条 `Phase timeout:` 路径(子团队 `to: "parent"`,收件人就是 root)。
102
+
103
+ 现在 `createRootLegion` 在建 root 行的同一次 `insertNode` 里写入本进程的
104
+ `ctx.sessionManager.getSessionFile()`;两个建树入口(`subagent` 的首次 launch 与 `vexillatio`)都传。
105
+
106
+ **能力上限**:`run.json` 不记录 root 的会话文件,因此 `rebuildLegionDb` 从 run 记录重建账本时,
107
+ root 行的 `session_file` 仍是 NULL —— 这些账本里发给 root 的邮件确认不了,会停在 `ACCEPTED`
108
+ (状态不撒谎:没有证据就不升级)。
109
+
110
+ ### D4 — `OBSOLETE`:只对 `Phase timeout:` notice 生效(#333)
111
+
112
+ **判据(冻结)**:`kind === "notice"` 且 `subject` 以 `Phase timeout:` 开头,而发件节点(= 团队根,
113
+ `checkOwnPhaseTimeout` 报的是自己的相位)已经**离开**该 phase(`phase` 变了)或已是终态 → `OBSOLETE`,
114
+ 写 `mail_bounced` 事件 + `reason: "obsolete_after_phase_change"`(**不新增 event type**)。
115
+
116
+ - "该 phase" 取自产生这条通知的 `phase_timeout` 事件(`data.phase` / `data.phaseSince`),**不解析正文**:
117
+ `phase` 不同、或 `phase_since` 不同(之后重新进入同一相位)都算离开。读不到那条事件 → 保持 `ACCEPTED`,绝不猜。
118
+ - **复活 = 新 stint(#357)**:`continue` 唤醒一个已经结束的团队时,`Orchestrator.reserveNode` 的 resume 分支把
119
+ `phase_since` 前移到复活时刻,并写一条 `phase_change`(`from === to`、`data.reason: "revived"`,**不新增 event
120
+ type**、不动 `Phase timeout:` 前缀)。相位名不变(工作从原地继续),变的只有 clock:上一 stint 的通知继续判
121
+ `OBSOLETE`,而复活后的 stint 拥有自己的 `phase_since`,因此 `hasPhaseTimeoutForSince` 不会把它当成旧那一轮
122
+ ——同一团队第二次超时仍会产生 `phase_timeout` 并再次通知。未复活时仍然一个 stint 只告警一次。
123
+ - **「已结束」= `hasEnded`(#354)**:终态(`isTerminalNode`)**或** `status ∈ {settled, retired}`。只做调查、
124
+ 从不迁移相位的团队,结局时 `phase`/`phase_since` 仍是出生值,旧判据因而永远判不出「过期」——通知在团队
125
+ 收队 30 分钟后才投递到 root。`reason` 文案不变(仍 `obsolete_after_phase_change`,事件闭集仍是 16),
126
+ 覆盖面明确包含「在超时相位里就地结束」;`isTerminalNode` 语义**不**改(投递退信与孤儿扫描共用它)。
127
+ - 只有 `kind === "notice"` 可被自动作废:mail kind 闭集是 `question|report|handoff|notice`,
128
+ **没有** `instruction`(#338 M4 订正了三处把它写成规则的文档/注释)。其它 kind **永不**自动作废——
129
+ 一个问题、一份交接过不过期由人判断。
130
+ - 求值在两处:
131
+ 1. **投递前**(`PENDING → OBSOLETE`):不再注入。这是 #333 的根因路径——一条排队中的通知在团队收敛后
132
+ 才被 root 的邮差取走;
133
+ 2. **tick 确认**(`ACCEPTED → OBSOLETE`):只改标签,不回滚已经注入 recipient 队列的文本。
134
+ - `DELIVERED` 优先于 `OBSOLETE`:确实消费过的事实不会被改写成"失效"。
135
+
136
+ ### D5 — 查询面
137
+
138
+ - `pendingInbox` 只返回 `PENDING`(既有,不改)。
139
+ - 新增只读导出 `mailboxStats(db, nodeId): { pending, accepted, delivered, bounced, obsolete }`。
140
+ **目前没有任何消费者**(#338 minor 4):D5 要求的是导出 + 单测,不是接线;把「供 `/legion` 状态与
141
+ doctor 用」写成既成事实是不成立的承诺——它是未来这类读者可用的查询面,不是已经存在的界面。
142
+
143
+ ## 不做(本轮刻意出清)
144
+
145
+ - **不新增 ledger event type**:闭集仍 16 个(`DELIVERED` 复用 `mail_delivered`,`OBSOLETE` 复用
146
+ `mail_bounced` + `reason`,`ACCEPTED` 复用 `sendMail` 的 `mail_sent`)。见 legion-v2-contract `REV-22`。
147
+ - **不改 `deliverBatch` / claim 语义**,也不动 `node_handled` 等既有事件的形状。
148
+ - **不做**相位推进时的主动撤回(#333 建议 2):需要邮件的 revoke 能力,与 #319/#320 的 claim 语义相邻,
149
+ 另立决策。本条只做「投递前校验 + 事后标注」。
150
+ - **不把** `DELIVERED` 接到 UI 渲染,也不改 `delivered_at` 以外的列。
151
+
152
+ ## 验收锚点
153
+
154
+ `test/unit/legion-tools.test.ts`(三态落值、确认升级、子串不升级、#333 两处求值、幂等;
155
+ #338 增:root 收件人升级到 DELIVERED、游标轮转、`report` 不作废、无 `phase_timeout` 事件不猜、
156
+ 同相位重进作废、每 seam 只 parse 一次、按 message id 重发事件)、
157
+ `test/unit/legion-phase-timeout-ended.test.ts`(#354「已结束 = `hasEnded`」那句的**唯一**钉住者:
158
+ 停在出生相位、在超时相位里就地 `settled`/`retired` 的团队必须作废;`isTerminalNode` 不放宽)、
159
+ `test/unit/legion-mail.test.ts`(五值迁移边、`mailboxStats`、精确匹配窗口、`inbox` 游标分页)、
160
+ `test/unit/legion-root-session.test.ts`(#338 M1:`createRootLegion` 把本进程会话文件写进 root 行,
161
+ 并据此确认 root 收件人)、
162
+ `test/unit/legion-db.test.ts`(v1 旧账本打开即 v2 且数据不变)。
@@ -0,0 +1,206 @@
1
+ # 完成通知去重:claim-once(v1,冻结)
2
+
3
+ ## 背景
4
+
5
+ 同一个 child 的终态结果有两条路到达父 agent:
6
+
7
+ 1. **工具结果**:`wait` / `collect` 返回 `renderCollect` 文本。
8
+ 2. **主动通知**:watch 终态 → `join.onTerminal` → `deliverCompletion`(`subagent-notify`)。
9
+
10
+ 现有去重只有两个内存位 `TrackedJob.consumedByTool` / `TrackedJob.notified`,并且**只在 watch 跑到终态的那一刻判断一次**(`runtime.ts` 的 `!job.consumedByTool && !job.notified` 闸门)。判完之后,通知还要穿过两层缓冲才真正落地:
11
+
12
+ - `JoinCoordinator.group.buffered`(10s 窗口 / 同组 pending 清空才 flush)
13
+ - `runtime.outbox`(父 run 活跃时入队,`agent_settled` 或 80s 上限才 flush,且**没有按 name 撤销的 API**)
14
+
15
+ 于是产生四个重复窗口:
16
+
17
+ | # | 场景 | 结果 |
18
+ |---|---|---|
19
+ | W1 | 兄弟未完成 → 条目已在 `join.buffered` → 父 `wait` 拿到结果 → 窗口到期 flush | 同一份 output 再提一次 |
20
+ | W2 | 父 run 活跃 → 通知进 `outbox` → 本 turn 内 `wait` 拿到结果 → `agent_settled` flush | 再提一次 |
21
+ | W3 | `waitOne` 终态 `release(name)` → `join.remove` 只删 `pending`,**不删 `buffered`** | 孤儿缓冲条目照发 |
22
+ | W4 | 两个标记都挂在 `job` 上,`release` 删 job 即蒸发 | flush 时无从判断 |
23
+
24
+ 根因:**去重状态挂在会被删除的 job 上,而待发文本在另外两层缓冲里,不知道标记存在。**
25
+
26
+ ## 冻结决定
27
+
28
+ ### D1 — 单一标记,key 为 `(runId, name)`,寿命长于 job
29
+
30
+ `createSessionRuntime` 闭包内维护 `reported: Set<string>`,key = `${runId}\u0000${name}`。不放在 `TrackedJob` 上(job 会被 `release` 删除)。不进 run.json / legion.db(不改 schema,不改跨进程语义)。
31
+
32
+ **修订(D10)**:key 追加轮次 → `${runId}\u0000${name}\u0000${round}`。原因:`runId` 是父 run id,同名 child 的多轮共用同一个 runId,两段键分不清旧轮与新轮(旧轮未读副本会抢在新轮前并把它过滤掉)。D10 取代本节的两段键形状,其余约束不变。
33
+
34
+ ### D2 — claim 语义:谁先把结果交到父 agent,谁置位
35
+
36
+ - `claim(runId, name): boolean` —— 首次返回 `true` 并置位;已存在返回 `false`。(修订:实现期一度改为 `void`,因为没有调用方要这个返回值;**#320 修订撤销该改动** —— `claimReported` 重新返回 `boolean`,`collect` 缝要用它区分「本次调用取得的 claim」与「别人已经取得的 claim」,见下方 #320 修订。)
37
+ - 置位点(**只有"真实交付"三处**):
38
+ 1. `waitOne` 拿到终态 snapshot、确定要返回它时(在 `retire` 的 await **之前**,避免 retire 期间的 flush 抢跑)。
39
+ 2. `collectChild` 拿到终态 snapshot 并确定返回 `renderCollect` 时。
40
+ 3. 通知**发送成功**之后(`deliverCompletion` 返回 `true`)。
41
+ - **投递侧没有 unclaim 路径**:超时 / interrupted / stillRunning 都不需要撤销,因为它们从未 claim。
42
+
43
+ **修订(复审 minor 4,2026-xx)**:上面「三处都是真实交付」在 `collect` 那条上并不严格成立 —— claim 发生在运行时缝里(`consumeCollectInterruptible` 把 snapshot 交出去的那一刻),而**调用方仍可能反悔**:`collectChild` 在 `await pending` 之后还会检查 `signal?.aborted`(父 turn 被 Esc 掉),`persistChild` 抛错则走 `fail("collect failed: …")`。两条路径都没有把文本交给父 agent。裁决:保留缝内的 claim(D2(a) 的抢跑防护要靠它),在 bail 分支调用 `runtime.unclaimUndelivered(name, snapshot, claimedNow)` 回滚(第三个参数见下面的 #320 修订)。
44
+
45
+ 该回滚**故意比 `unclaimReported`(D7) 更窄**:只删 claim,不清任何副本。理由相反于 D7 —— D7 处理的是「父已读过所以撤销」,此时还没有任何副本落到父手里,待发的通知是唯一剩下的送达路径。
46
+
47
+ **修订(#352,2026-xx):bail 门是「两类、三个丢弃点」,`wait` 的丢弃点此前漏列。**
48
+
49
+ 上面那段只数了 `collect` 一条路。`wait` 也会 claim,也会丢结果,而且丢得比 `collect` 更彻底:
50
+
51
+ 1. **claim 点不是 `waitOne`**:目标 child 已 settle 时它不在 `resolveWaitTargets` 的 live 集合里(`waitAction` 侧),于是 `waitAction` 的 finished-cache 探针(`runtime.consumeCollect`)先取走 claim,`claimedNow === true`。同一条调用链下游 `waitOne` 的 `claimReported` 在这条路径上是**幂等 no-op,返回 `false`** —— 把它的返回值当 `claimedNow` 透传,回滚一无所获。
52
+ 2. **丢弃点是工具层的外层 abort 网**:`await job.retire?.()` 期间父 turn 结束,`waitOne` 仍把渲染好的结果交回上层,而工具层在 `return ok("interrupted (parent turn ended; children still running)")` 之前把整条结果丢弃。这一行文案由 `esc-no-retire-v3` 冻结,**不改**:该丢的还是要丢,欠的是回滚。
53
+ 3. **顺出的形状**:探针的 `(snapshot, claimedNow)` 对随 `waitAction` 的返回值透出(`details.waitClaims`,只收 `claimedNow === true`),丢弃点在 `return ok("interrupted …")` 之前逐条 `runtime.unclaimUndelivered(name, snapshot, true)`。
54
+ - **探针是异步的(复审 major 1)**:claim 可能晚于 `details` 构建 —— `waitOne` 的 `signal?.aborted` / `WAIT_INTERRUPTED` / `WaitTimeoutError` 都能在探针的 `ensureCollect` 仍在飞行时先返回,随后探针 resolve 到终态才占下 `(runId,name,round)`。因此:`waitClaims` **无条件挂载**(一个探针至多一条,空数组就是「本次调用什么都没取」),并另有 `details.waitClaimsPending`(该探针自己的 promise 结果,非枚举字段)——bail 门必须**先 `await` 它**再逐条回滚,否则回滚发生在 claim 之前,等于什么都没回滚,通知照旧被 `deliverBatch` 过滤掉(Db 的残留窗口就是这里)。
55
+
56
+ 于是 bail 门枚举完整为:
57
+
58
+ | # | 丢弃点 | 位置 | 取得的 claim 来自 |
59
+ |---|---|---|---|
60
+ | 1 | `collect` 父 turn 已 abort | `collectChild` 的 `signal?.aborted` 分支 | `consumeCollect` / `consumeCollectInterruptible` 的 `claimedNow` |
61
+ | 2 | `collect` 持久化抛错 | `collectChild` 的 `persistChild` catch | 同上 |
62
+ | 3 | `wait` 结果被外层 abort 网替换 | 工具层 `interrupted (parent turn ended; children still running)` 分支 | `waitAction` finished-cache 探针的 `claimedNow`(**不是** `waitOne` 的) |
63
+
64
+ 三条都用 `unclaimUndelivered`:用 `unclaimReported`(D7) 会把 outbox 里唯一剩下的副本一起丢掉(实测:通知从 1 变 0)。
65
+
66
+ **修订(#320,2026-xx):回滚收窄到「本次调用取的 claim」,并同时把通知路径交还。**
67
+
68
+ 上面那句「待发的通知是唯一剩下的送达路径」在实现上并不成立,回滚本身有两个缺陷:
69
+
70
+ 1. **它删的可能不是自己取的 claim。** `unclaimUndelivered` 原先无条件 `reported.delete(...)`。同一 `(runId,name,round)` 若已被**真实投递**(wait / collect / 通知)claim 过,一次 bail 就把「父已拿到结果」的证据抹掉,已读通知会在下一次 flush 复活。
71
+ - 裁决:claim 的「是否本次取得」**随缝的返回值交出**,即 `consumeCollect*` 返回 `{ snapshot, claimedNow }`(`ClaimedCollect`),`unclaimUndelivered(name, snapshot, claimedNow)` 只在 `claimedNow === true` 时回滚。
72
+ - **不放在按快照索引的表里**:watch 与工具调用共享同一个 `job.collectPromise`,两个 collect 会拿到同一个快照对象;按快照记账时第二次调用会把第一次的 `true` 覆盖成 `false`,之后第一次 bail 直接 return,claim 留下而两份都没交付。按**调用**记账天然没有这个覆盖问题。
73
+ - `claimedNow === false` 时回滚是完整 no-op(连 `consumedByTool` 都不复位):claim 存在即证明结果已交付,通知本就该被过滤。
74
+ 2. **它没有把通知路径交还。** 缝在 claim 之前就把 `consumedByTool` 置了位,而 watch 的闸门 `!consumedByTool && !notified` 在终态时读它。**默认 async 路径下每个 child 都被 watch**(`followChild` 的 `watch: true`);`async: false` 的显式内联 collect **不被 watch**(它在同一个 turn 里直接 `collectChild`),那种形状下没有任何 watch 在闸门处抢跑。快照 resolve 后**watch 的 continuation 先跑**、闸门已看到 `true` 而跳过 `onTerminal`;等 `collectChild` bail 时 watch 正停在 `retire` 的 await 里,`job.watching` 仍为 `true`。因此:
75
+ - **job 分支**的回滚必须复位 `job.consumedByTool = false` **并无条件** `runtime.watch(name)` 重挂 watch(不设 `!job.watching` 守卫 —— 该守卫会让整个修复变成死代码)。换句话说:旧 `!job.watching` 守卫在**默认路径**上恒为死代码(那里永远有人在 watch),只在 `async: false` 的内联形状下才有意义。
76
+ - 安全性:`watch` 的 generation 自增让旧 watch 在 `finally` 处跳过 `release`(job 不被提前摘掉);`notified` 保证同一轮不会二次 `onTerminal`;`deliverBatch` 的 claim 过滤是最后一张网。
77
+ - 复位只作用于**本次 collect 所属的那一轮**(`job.runId`/`job.round` 与快照 origin 相等),不碰 collect 期间被 re-track 出来的新一轮。
78
+ - **时序前提与 `waitOne` 的 `WAIT_INTERRUPTED` 分支不同**:wait 的 interrupt 赢在 collect promise resolve **之前**(watch 随后看到的是「标志已清 + job 还活着」),而本路径只可能发生在 resolve **之后**(闸门已过),所以只清标志不够,必须换一个 watch。
79
+
80
+ **公共面(#327)**:`consumeCollect*` 是 `src/api.ts` 的导出面的一部分(该文件自称 "safe to depend on"),返回类型已从 `Promise<CollectSnapshot>` 变成 `Promise<ClaimedCollect>`。**裸 snapshot 这个形状已经发布过**——v4.0.0 与 v5.0.1 都导出 `consumeCollect(name): Promise<CollectSnapshot> | undefined`——所以它对 `pi-legion/api` 的消费者是**破坏性变更**,带着本改动的那次发布**必须是 major**。迁移就是一行:`const { snapshot } = await rt.consumeCollect(n)`;只有需要回滚的那条路径才用第二个字段。**仍然不做兼容层**(不给旧形状留重载 / 不提供 `snapshotOnly` 开关):多留一个形状会把 claim 与副本的歧义继续摊给所有调用方。
81
+
82
+ ### D3 — 通知路径在投递前最后一刻查 claim
83
+
84
+ 通知被过滤 = 该 `(runId,name)` 已 claim。过滤必须发生在 **join flush 与 outbox flush 两条路径都经过的唯一出口**,不能停留在 `onTerminal` 时刻。
85
+
86
+ ### D4 — outbox 存结构,不存格式化文本
87
+
88
+ `QueuedNotice` 改为持有 `entries`(含 `runId/name/status/input`)、`triggerTurn`、`partial`、`enqueuedAt`。flush 时重新走"过滤 → 格式化 → 投递"。这样 grouped notice 里被 `wait` 领走的成员会从文本中消失,header 的 `N of M` 与 `Still running` 自动正确。
89
+
90
+ ### D5 — 单一投递出口 `deliverBatch`
91
+
92
+ `deliverBatch(entries, triggerTurn, partial)` 是唯一实现:
93
+
94
+ 1. `entries.filter(e => !isClaimed(e.runId, e.name))`
95
+ 2. 空 → 直接返回(**这就是"读过就不再提"**)
96
+ 3. 单条 → `formatCompletionNotice`;多条 → `formatGroupedNotice`(`stillRunning` 在投递时实时重算)
97
+ 4. 立即发送或入 outbox;`deliverCompletion` 返回 `false`(宿主拒绝)→ **不 claim**,允许后续补救
98
+
99
+ `join` 的 `deliver` sink 与 `flushOutbox` 都只调它。单 child、未被 claim 的通知文本必须与现状**逐字节一致**(保留 `Background task …` 旧形状;`partial` 由入队时刻的 `join.allSettled()` 决定并随条目保存,避免 flush 时语义漂移)。
100
+
101
+ ### D6 — 工具结果永不被抑制
102
+
103
+ `wait` / `collect` 是父 agent 的显式请求,任何情况下都返回完整 `renderWait` / `renderCollect`。"先通知后 collect" 允许(父仍拿到完整结果),只是**不再产生第二条通知**。
104
+
105
+ ### D7 — claim 的复活规则
106
+
107
+ **凡是把 `notified` 复位为 `false` 的地方,必须同时撤销该 `(runId,name)` 的 claim;撤销必须是「解 claim + 清掉所有尚未落地的副本」的原子操作**:
108
+
109
+ ```ts
110
+ unclaimReported(runId, name):
111
+ reported.delete(key)
112
+ dropQueued(runId, name) // outbox 条目内过滤掉该 key;条目变空则连同队伍一起删,并重臂/取消 timer
113
+ join.dropBuffered(runId, name)
114
+ ```
115
+
116
+ 只解 claim 不清副本 = 已读结果的旧通知被「复活」后重发(实测复现)。
117
+
118
+ 复位点(**以「是否复位 `notified`」为准,不以函数命名**。下面是代码里真的复位它的地方):
119
+
120
+ - `rewatch()`(父显式要求再报一次)—— **有用例钉住**(三处:outbox 副本、join 窗口副本、同轮再来一条)。
121
+ - `watch()` 的 `rearmLiveWatch`(终态前拿到 `running` 快照重臂)。
122
+ - `watch()` 的 blocked-resume 分支(审批后继续跑)。
123
+ - `watch()` 的 transient connection rearm(进程还活着的 `Connection error`)。
124
+ - `waitOne` 遇到非终态 / blocked 快照的分支(它也复位 `notified`,初版 D7 漏列)。
125
+ - `dispose()` —— 整体清空,`reported.clear()` + `roundOfName.clear()`,不走上面那个三步。
126
+
127
+ **修订(复审判决,收窄)**:初版 D7 把 `track()` 的**两个**分支都算成复位点,两条都不成立:
128
+
129
+ - **existing** 分支基线从不复位 `notified`,在那里 unclaim 是空操作且会抹掉「已交付」证据。
130
+ - **新 job 分支**的 `round` 刚刚由 `nextRoundFor` 取出,该 key 在 `reported` 里**必然不存在**,所以 `unclaimReported` 的 `had` 恒为 false,其 `dropQueued` / `join.dropBuffered` / `finished.delete` 从该站点**不可达**。已删除该调用(改留注释),不让它冒充第 5 个复位点。
131
+
132
+ 是否解除 claim 以「`notified` 是否被复位」为准。除 `rewatch` 外,其余站点在多数序列里该轮从未 claim,因此实际是空操作;保留它们是为了让「复位 `notified` ⇒ 撤销 claim」这条不变量在代码里可逐点核对,而不是靠路径分析。
133
+
134
+ ### D7b — 缓存(`finished`)的生命周期 = 一轮
135
+
136
+ `finished` 按 name 缓存终态快照,而下级 runId 是**父 run id**(同一父 run 下同名 child 的每一轮共用同一个 runId)。若 `track()` 不失效缓存:
137
+
138
+ 1. 新一轮 collect 抛错时缓存里仍是上一轮的 snapshot,`wait`/`collect` 会把**过期成功**交给父 agent(既有缺陷);
139
+ 2. 这一命中还会 claim `${runId}\u0000${name}` —— 正是**本轮失败通知**的键,于是失败通知被永久吞掉(claim-once 引入的回退)。
140
+
141
+ 所以:**`track()` 的两个分支都必须 `finished.delete(name)`**。W2 链路(完成 → release → 父 `wait` 命中缓存)中间没有 `track()`,不受影响。缓存快照写入时必须带上**发起时刻**的 `job.runId`(不是 resolve 时刻),否则同名换 runId 的重跟会写下错配。
142
+
143
+
144
+ ### D8 — `join` 增加 `dropBuffered(runId, name)`
145
+
146
+ 现状 `remove(name)` 只清 `pending`(`join.ts` 的 `remove`),buffered 条目成为孤儿(W3)。新增 `dropBuffered` 清除该 name 的 buffered 条目,`remove()` 的其它语义不变。
147
+
148
+ **修订(实现期裁决,2026-xx)**:`release()` 中调用 `dropBuffered` 必须**以 claim 为前置条件**(`if (isReported(runId, name, round))`)。理由:正常 watch 收尾也是「终态 child 先 release、批次后 flush」,buffered 条目是那条通知的唯一载体;无条件丢弃会把每条 grouped 通知静默吞掉(实测 4 条既有用例变红)。W3 的本质是「claim 之后的 release 留下死重」,所以 gate 在 claim 上既消除孤儿、又不碰未上报路径。
149
+
150
+ **修订(复审 major 1,2026-xx):`release()` 用的是 `dropBufferedRound(runId, name, round)`,不是 `dropBuffered`。**
151
+
152
+ `dropBuffered` 只按 name 过滤,而 `release()` 作用在**一个 job** 上,一个 job 恰好是一轮。于是「本轮被 claim」会把**更早轮次的未读副本**一起删掉,正是 D10 说的「未读即丢」。可达序列(有单测复现):
153
+
154
+ `track x`(round 1) → x 终态入 buffer 未读(兄弟仍 pending)→ re-track(round 2)→ round 2 也入 buffer → `wait(["x"])` claim round 2 → `release` → round 1 的未读通知被静默吞掉。
155
+
156
+ 因此 join 提供两个方法,语义分开、不要合并:
157
+
158
+ - `dropBuffered(runId, name)` —— **name 全轮次**。D7 撤销专用(父要求重看 ⇒ 该名字的每份副本都过期)。
159
+ - `dropBufferedRound(runId, name, round)` —— **单轮**。`release()` 专用。
160
+
161
+ `release()` 的调用点因此是 `if (isReported(job.runId, name, job.round)) join.dropBufferedRound(job.runId, name, job.round)`。
162
+
163
+ ### D10 — claim 键必须含「轮次」,不能只是 `(runId, name)`
164
+
165
+ `runId` 是**父 run id**,同名 child 的多轮(steer / continue / resume)共用一个 `runId`。若键只有 `(runId,name)`:
166
+
167
+ - 两轮各有未读通知排队时,FIFO 先投递旧轮 → claim 该键 → **新轮的通知被过滤**,父 agent 拿到旧结果、永远看不到新结果(观测到的反转);
168
+ - 反之若在 `track()` 无条件丢弃旧轮未读副本,则「未读即丢」。
169
+
170
+ 两种都是损失。正确模型:**一份轮次对应一份可交付结果**。
171
+
172
+ - `TrackedJob` 增加 `round: number`,由 `track()` 从一个 **per-name 单调计数器** 取值(不回绕到 1,避免新旧轮键碰撞)。
173
+ - claim 键 = `${runId}\u0000${name}\u0000${round}`。
174
+ - `finished` 缓存连同 `{runId, round, snapshot}` 一起存,缓存命中 claim 快照自己的轮次。
175
+ - 通知条目(`DeliverableEntry`)携带产生它的 `round`,join/outbox 全程透传。
176
+ - `unclaim` 仍按 `(runId, name)` 范围清理(重启该名字的所有副本),但只在该名字**当前轮**确实有 claim 时执行。
177
+
178
+ 效果:同一轮重复交付仍只得一条;不同轮各自可交付(不丢新结果、不被旧结果顶掉)。
179
+
180
+ ## 验收清单
181
+
182
+ 新增单测(`test/unit/runtime.test.ts`,必要处补 `test/unit/join.test.ts`):
183
+
184
+ 1. **W1**:两个 child;a 完成(b pending,条目进 buffered)→ `wait(["a"])` → 窗口到期 → 只发 b 的通知,a 不出现。
185
+ 2. **W2**:`notifyParentRunStart()` → child 完成(进 outbox)→ `wait` 拿到结果 → `notifyParentSettled()` → 该 child **零**消息;同批未读的兄弟仍须送达(硬要求 7,所以用例的最终断言是「恰好一条,且不含被读的那份」)。
186
+ 3. **反向顺序**:通知先发 → 再 `collect` → 仍返回完整结果,且总消息数仍为 1。
187
+ 4. **timeout 不误伤**:`wait` 超时(`stillRunning`)→ child 后续 settle 仍正常通知。
188
+ 5. **复活**:通知发出后 `rewatch` → 第二轮完成 → 允许第二条通知。
189
+ 6. **grouped flush 重算**:a、b 同组进 outbox → `wait(a)` → flush → 文本只含 b,header 不含 a,`N of 1` 形态正确。
190
+
191
+ 补充硬要求(复审追加):
192
+
193
+ 7. 每条「断言零消息」的用例必须有**正向控制**(同批一个未 claim 的兄弟必须送达,用 `waitFor` 等它),否则成了空转通过。
194
+ - 落点:W2、D10 的同名 re-track、D-B 的 cap flush、F1 的 interruptible collect。四条都补了兄弟控制;D-B 的 flush 一条的「零」是 `forcedFlushes` 的属性本身,所以控制放在同一用例的第二段(同一条 arm → flush → deliverBatch 路径必须送出一条未 claim 的通知)。
195
+ 8. 生产 collect 缝是 `consumeCollectInterruptible`(tool execute 必带 signal),它的 claim 点必须有对应用例。
196
+ 9. 一轮内出现第二轮(同名同 runId)时:第二轮失败通知不得被上一轮的缓存命中吞掉。
197
+ 10. 同名同 runId 的两轮各有未读通知 → 两条都送达(FIFO,不丢新轮);第一轮已读 → 只发第二轮一条。
198
+ 11. **release 只丢当轮**(复审 major 1):两轮各有一份未读副本 → 读掉后一轮(claim round 2)→ `release` → 前一轮的通知仍然送达。
199
+ 12. **D7 撤销的两条副本路径各有用例,且删行即红**(复审 major 2):outbox 副本(删 `dropQueued` 变红)、join 窗口副本(删 revoke 侧 `join.dropBuffered` 变红)。
200
+ 13. **claim 回滚**(复审 minor 4):`collectChild` 在交付前 bail(`persistChild` 抛错 / 父 turn 已 abort)→ claim 回滚 → 待发通知仍然送达。
201
+ 14. **`wait` 的 claim 回滚**(#352):
202
+ - `wait` 命中 finished-cache 且父 turn 在被 wait 的 retire await 期间中断 → 回执仍是冻结的 `interrupted …`,**且**该轮通知随后恰好送达一次;探针的 `claimedNow` 已回滚。
203
+ - 对照:`wait` 正常返回(无中断)→ 通知仍然只有一条(不双发)。
204
+ - 对照:探针 `claimedNow === false`(别的路径已 claim)→ 中断时不得回滚别人的 claim。
205
+
206
+ 全部既有 unit 测试必须保持绿色(`npm test`),`npm run typecheck` 干净。