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.
- package/CHANGELOG.md +858 -0
- package/CONTRIBUTING.md +33 -2
- package/README.md +10 -5
- package/agents/centurion.md +32 -7
- package/agents/scout.md +11 -0
- package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
- package/docs/contracts/centurion-declared-phases-v1.md +104 -0
- package/docs/contracts/declarative-phase-machine-v5.md +43 -0
- package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
- package/docs/contracts/finished-cache-eviction-v1.md +169 -0
- package/docs/contracts/galaxy-scratch-v1.md +78 -0
- package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
- package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
- package/docs/contracts/legion-page-floor-v1.md +129 -0
- package/docs/contracts/legion-session-continuity-v1.md +118 -0
- package/docs/contracts/mailbox-delivery-v1.md +162 -0
- package/docs/contracts/notice-claim-v1.md +206 -0
- package/docs/contracts/partial-failure-exit-v1.md +235 -0
- package/docs/contracts/proc-sweep-v1.md +107 -0
- package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
- package/docs/contracts/stall-visibility-v1.md +218 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/viz-frame-alloc-v1.md +123 -0
- package/docs/contracts/wait-mail-preempt-v1.md +117 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/legion-v2-contract.md +122 -20
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +1903 -130
- package/package.json +5 -5
- package/skills/pi-legion/SKILL.md +2 -1
- package/src/agents/agents.ts +26 -1
- package/src/agents/settings.ts +67 -2
- package/src/api.ts +17 -0
- package/src/extension/child-guard.ts +2 -1
- package/src/extension/join.ts +54 -0
- package/src/extension/legion-commands.ts +15 -4
- package/src/extension/legion-doctor.ts +48 -6
- package/src/extension/legion-status.ts +44 -2
- package/src/extension/legion-tools.ts +1044 -109
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +6 -3
- package/src/extension/runtime.ts +882 -81
- package/src/extension/spawn-hints.ts +52 -12
- package/src/extension/summary.ts +7 -15
- package/src/extension/timing.ts +4 -15
- package/src/legion/db.ts +23 -1
- package/src/legion/events.ts +7 -0
- package/src/legion/interrupt.ts +98 -0
- package/src/legion/mail-release.ts +266 -0
- package/src/legion/mail.ts +456 -29
- package/src/legion/nodes.ts +31 -0
- package/src/legion/phase-conditions.ts +7 -1
- package/src/legion/phase-machine.ts +160 -8
- package/src/legion/state-machine.ts +34 -2
- package/src/runs/acceptance.ts +105 -6
- package/src/runs/args.ts +16 -0
- package/src/runs/child-scope.ts +323 -0
- package/src/runs/extension-inheritance.ts +145 -19
- package/src/runs/orchestrator.ts +613 -55
- package/src/runs/store.ts +149 -5
- package/src/shared/proc-sweep.ts +317 -0
- package/src/shared/progress.ts +11 -2
- package/src/shared/session-map.ts +29 -0
- package/src/shared/session.ts +308 -6
- package/src/shared/types.ts +34 -0
- package/src/supervisor/inprocess-supervisor.ts +158 -12
- package/src/supervisor/race-timeout.ts +55 -0
- package/src/supervisor/routing-supervisor.ts +55 -4
- package/src/supervisor/rpc-supervisor.ts +215 -29
- package/src/supervisor/types.ts +439 -2
- package/src/tui/status.ts +2 -1
- package/src/tui/viz/canvas.ts +358 -36
- package/src/tui/viz/palette.ts +18 -2
- package/src/tui/viz/panels.ts +507 -104
- package/src/tui/viz/rate.ts +120 -15
- package/src/tui/viz/view.ts +72 -4
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# 「活着但不再推进」的可见性(stall visibility,v1,冻结)
|
|
2
|
+
|
|
3
|
+
> 语义由 #315 冻结。实现期发现矛盾 → 停下报告,不得重新解释本契约。
|
|
4
|
+
> **本文件是 #315 唯一的 D 编号命名空间**:实现代码与测试注释里的 `#315 Dn` 只解析到这里
|
|
5
|
+
> 的 D1–D7。`#315` 之外的 `Dn` 与本契约无关。
|
|
6
|
+
|
|
7
|
+
## 背景:为什么这件事在系统里完全隐形
|
|
8
|
+
|
|
9
|
+
`docs/contracts/display-liveness-v1.md` 的八条规则把「谁活着」判准了,但它们的**最强规则**
|
|
10
|
+
恰好把这个问题盖住了:
|
|
11
|
+
|
|
12
|
+
| 层 | 事实 | 后果 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `src/extension/liveness.ts` 规则 1 | `inMemory` 持有一个**非终态** handle → `live` | 只要本进程还攥着 handle,child 恒判 `live` |
|
|
15
|
+
| `src/extension/legion-tools.ts` `refreshHeartbeats` | 心跳只给 `supervisor.isAlive()` 为真的子节点刷 | 卡住的 child 心跳照刷,`updatedAt` 一直是新的 |
|
|
16
|
+
| 同文件 `scanOrphans` | 首个 root child 仍 `isAlive` 时整棵子树豁免 | 卡住的 child 永远不会被判成 orphan |
|
|
17
|
+
| `collect` deadline | 默认 900s | 父要等满这个上限才知道「没动静」 |
|
|
18
|
+
|
|
19
|
+
于是「进程活着但一个工具卡在那里十分钟」与「正在高效工作」在**所有展示面上完全一样**。
|
|
20
|
+
这不是 liveness 判错了——判「活/死」它是对的;缺的是第三个事实:**它在干什么、干了多久**。
|
|
21
|
+
|
|
22
|
+
## 冻结决定
|
|
23
|
+
|
|
24
|
+
### D1 — 只加事实,不改判定
|
|
25
|
+
|
|
26
|
+
两个后端(`InProcessSupervisor` / `RpcSupervisor`)各自在自己的 `ChildRecord` 上记录
|
|
27
|
+
**一个字段**(#331 起它的形状是栈,不再是单槽):
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
interface ChildActivity {
|
|
31
|
+
readonly tool: string; // 子会话事件流原样报来的工具名
|
|
32
|
+
readonly startedAt: number; // 事件到达时的 **host** clock
|
|
33
|
+
readonly toolCallId?: string; // 仅用于把 end 事件匹配回它的 start
|
|
34
|
+
readonly parentToolCallId?: string; // 嵌套归属(codemode → bash),事件流原样带的字段
|
|
35
|
+
}
|
|
36
|
+
// ChildRecord.inFlightTools: ChildActivity[] —— LIFO 栈,栈顶(最后一项)= 最内层
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**D1 修订(#331):字段从「单槽」改成「栈」。** 原 D1 把记录冻结成**一个** `ChildActivity`,
|
|
40
|
+
由此产生两处**少报**(内层 end 擦掉外层、并行兄弟先结束就整行消失,见 D2 的修订记录)。
|
|
41
|
+
现在记录的是 `ChildActivity[]`,先进先出序、**栈顶 = 最后启动 = 最内层**;`parentToolCallId`
|
|
42
|
+
是事件流自己带的字段(`ToolExecutionStartEvent.parentToolCallId`,"Set when another tool (for
|
|
43
|
+
example a codemode script) made this call"),只用于把嵌套调用归属到它的外层调用。
|
|
44
|
+
|
|
45
|
+
数据来源是**子会话已经存在的事件流**(`tool_execution_start` / `tool_execution_end`,
|
|
46
|
+
两个后端的订阅点早已存在)。**不新增 IPC、不新增轮询、不新增 state machine**。
|
|
47
|
+
|
|
48
|
+
- `liveness()` 的 live/dead 判定**一个字都不改**。`live` 的 child 仍然 `live`。
|
|
49
|
+
- **不自动 kill / abort / retire**。本契约只产出字符串。
|
|
50
|
+
- 心跳与孤儿扫描的判据**一个字都不改**(约束见 D7)。
|
|
51
|
+
|
|
52
|
+
### D2 — `startedAt` 是 host clock,只做「年龄」
|
|
53
|
+
|
|
54
|
+
`startedAt` 由 host 在事件到达时读一次 `Date.now()`;耗时永远是 `now - startedAt`,中间只用
|
|
55
|
+
**同一个时钟族**测量一段年龄。绝不把子会话 transcript 里的时间戳拿来和 host 时间戳比大小
|
|
56
|
+
(这正是 display-liveness D3 禁止的「跨 writer 时间戳仲裁」)。
|
|
57
|
+
|
|
58
|
+
`inFlightTool(name)` 在 child **不是 live** 时一律返回 `undefined`:没有活 handle 的进程不可能
|
|
59
|
+
正卡在一个工具里,记录里残留的字段不能当事实用(display-liveness §6 的同类推论)。
|
|
60
|
+
|
|
61
|
+
**D2 修订(#331):折叠与展示都按栈来。**
|
|
62
|
+
|
|
63
|
+
事件折叠(`nextInFlightTool`,纯函数,两个后端在各自已有的事件订阅点上调用):
|
|
64
|
+
|
|
65
|
+
- `tool_execution_start` → **push** 一帧(记 `tool` / `startedAt` / 可选的 `toolCallId`、
|
|
66
|
+
`parentToolCallId`)。没有工具名的 start 不 push,也不动已有帧(不造事实)。
|
|
67
|
+
- `tool_execution_end` → **只移除它点名的那一帧**(按 `toolCallId` 匹配),**绝不清空整栈**。
|
|
68
|
+
内层 `bash` 结束时外层 `codemode` 仍然在跑;并行批里先结束的兄弟不能把还在跑的那个擦掉。
|
|
69
|
+
点名不到任何帧时,只允许落到「没有 id 的那一帧」(两边 id 都可缺),全都有 id 就原样返回——
|
|
70
|
+
陌生 id 的 end 不能擦掉一个确实还在跑的调用。end 自己不带 id 时按栈语义弹出栈顶。
|
|
71
|
+
- `agent_settled` → **清空整栈**:一轮结束就不会再有工具在飞(abort 打断工具时 `end` 事件
|
|
72
|
+
可能永远不来)。
|
|
73
|
+
|
|
74
|
+
展示取值(`formatInFlightToolLine` / `withInFlightToolLine`):
|
|
75
|
+
|
|
76
|
+
- 显示的是**栈顶**(最内层)那一帧:`working · <栈顶 tool> hh:mm:ss`;耗时仍按**栈顶的**
|
|
77
|
+
`startedAt` 计。
|
|
78
|
+
- **嵌套**(栈顶帧的 `parentToolCallId` 链能一路走到栈里的帧,如 `codemode` → `bash`):追加
|
|
79
|
+
` (inside <最外层 tool> ×N)`,`N` = 这条嵌套链的层数(`codemode → bash` 读 `×2`)。
|
|
80
|
+
- **并行兄弟**(栈里不在该嵌套链上的帧):追加 ` (+N more)`,`N` = 这些帧的数量。显示的是
|
|
81
|
+
最近启动的那个(栈顶),兄弟只是被计数。
|
|
82
|
+
- 两个提示可同时出现:`working · bash 00:05:00 (inside codemode ×2) (+1 more)`。5 分钟标记永远
|
|
83
|
+
在最后。
|
|
84
|
+
- **没有 `parentToolCallId` 时,深度 > 1 不能区分「嵌套」与「并行」**,此时按**并行**报
|
|
85
|
+
` (+N more)`:这是更弱、但永远为真的说法。把并行硬说成 `inside` 会是假报——本契约要防的
|
|
86
|
+
恰好是相反的错误(把卡住说成在工作);两个方向都只允许**少报**。
|
|
87
|
+
- **事件里带了 `parentToolCallId`、但那个父帧已经不在栈里**(父调用先结束、或 `agent_settled`
|
|
88
|
+
清过栈)时按同一规则降级为 ` (+N more)`:归属只承认**栈里**能一路走通的链,不拿一个已经
|
|
89
|
+
失效的 id 去猜 `inside`。
|
|
90
|
+
- 栈空(`[]`)→ **不出这一行**,与「没有事实」逐字节相同。
|
|
91
|
+
|
|
92
|
+
`inFlightTool(name)` 返回栈顶(对只实现它的旧后端 / 测试替身保持一帧读取的兼容),
|
|
93
|
+
`inFlightToolStack(name)` 返回整个栈(展示层读它);两者都在 child **不是 live** 时一律 `undefined`:没有活 handle 的
|
|
94
|
+
进程不可能正卡在一个工具里,记录里残留的字段不能当事实用(display-liveness §6 的同类推论)。
|
|
95
|
+
|
|
96
|
+
**为什么这是展示精度修正,不是行为变更**:事件折叠只会 push/移除,不会置位;`inFlightTool*`
|
|
97
|
+
以 `isAlive` 为闸,所以不会把没在跑的工具报成在跑,也不会把已结束的调用继续说成在飞。
|
|
98
|
+
`liveness()`、心跳、孤儿扫描、任何 kill/abort/retire 都不受影响(D5、D7 不变)。
|
|
99
|
+
|
|
100
|
+
### D3 — `STALL_NOTICE_MS = 300_000`
|
|
101
|
+
|
|
102
|
+
单条工具持续到或超过 5 分钟,追加**展示用**标记。常量在 `src/supervisor/types.ts`,
|
|
103
|
+
展示层(`formatInFlightToolLine`)与测试共用同一个值,不各写一份字面量。
|
|
104
|
+
(两个后端不读这个常量,只记录事实;边界判定全部发生在展示层。)
|
|
105
|
+
|
|
106
|
+
选 5 分钟的理由是对照**可观测的替代方案**:现在父等到 900s 的 `collect` deadline 才知道
|
|
107
|
+
没动静。5 分钟落在这个 deadline 之前很久,且它只花一个字符串。
|
|
108
|
+
|
|
109
|
+
**计时基准(#331)**:这里的「单条工具持续」指**栈顶那一帧**的 `startedAt`,不是最外层调用的
|
|
110
|
+
起跑时间。栈顶换人(内层工具 start)就等于把标记的时钟重置,所以 `codemode` 里套一串 `bash`
|
|
111
|
+
的 child 可能很久看不到 5 分钟标记——这是已知的**少报**(宁可不报,也不把最外层的年龄算到
|
|
112
|
+
一个刚启动的内层调用头上)。反方向不会发生:它绝不会把新启动的调用报成卡住。
|
|
113
|
+
|
|
114
|
+
### D4 — `status` 的行格式
|
|
115
|
+
|
|
116
|
+
child 有在飞工具时,状态块**顶部**多一行(lead line,与 `alive · quiet Ns` 同级):
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
working · bash 00:07:31
|
|
120
|
+
worker-0 — state=working agent=worker kind=pi
|
|
121
|
+
alive · quiet 12s
|
|
122
|
+
supervisor=inprocess
|
|
123
|
+
session=/…/worker-0.jsonl
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
栈里有多帧时(#331),行内带上下文提示(语义见 D2):
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
working · bash 00:07:31 (inside codemode ×2) # 嵌套:外层仍在跑
|
|
130
|
+
working · bash 00:07:31 (+1 more) # 并行兄弟:栈顶是最近启动的那个
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- 前缀 **`working`** 来自在飞工具这个事实本身,不是持久化的 `state`:start 事件没有配对的
|
|
134
|
+
end,就是「这个 child 正在一个工具里」,而记录里的 `state` 只在 turn 边界被重写,可能滞后。
|
|
135
|
+
- 耗时为 `hh:mm:ss`,小时字段**不封顶**(30 小时读 `30:00:00`,绝不回绕成 `06:00:00`——
|
|
136
|
+
回绕会把卡死的 child 报成刚开工)。
|
|
137
|
+
- 到/过 `STALL_NOTICE_MS` 追加:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
working · bash 00:12:00 (long-running tool — no progress for 12m; `steer` cannot interrupt it, `abort` can)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`N` 是越过边界的整分钟数。文案点名 `abort` 而不是 `steer`:`steer` 只是排队(#313),
|
|
144
|
+
一个卡在工具里的 child **永远收不到**;`abort`(#314)才是能打断当前 turn 的杠杆,而且它
|
|
145
|
+
同样不杀进程。
|
|
146
|
+
- 同一个渲染器服务 `subagent({ action: "status" })` 的单 child 分支、
|
|
147
|
+
`subagent({ action: "status", all: true })` 的每个块、以及 `/legion status <name>`:
|
|
148
|
+
三处走 `withInFlightToolLine`,不会漂移。
|
|
149
|
+
- **没有在飞工具就不出这一行**:无事实即无声明,`#315` 之前的输出逐字节不变。
|
|
150
|
+
|
|
151
|
+
### D5 — 为什么不做 fleet watchdog
|
|
152
|
+
|
|
153
|
+
`docs/contracts/esc-abort-v1.md` 的非目标写明「不做主机级 watchdog」。`#315` 不推翻它,理由是
|
|
154
|
+
这一条现在**更**成立而不是更松:
|
|
155
|
+
|
|
156
|
+
1. **watchdog 要判「该杀」,而这里没有可信的「该杀」判据。** 5 分钟一个工具可以是 `npm test`
|
|
157
|
+
跑在慢机器上,也可以是 `rg` 扫在 NFS 上,也可以是模型在长思考。判据缺位时,watchdog
|
|
158
|
+
唯一能做的「动作」就是把误杀变成默认行为。
|
|
159
|
+
2. **杀掉最贵的东西换最小的收益。** `retire` 会带走进程与上下文(`stop` 的语义),而卡住的
|
|
160
|
+
child 恰恰是「上下文最贵」的那一类。
|
|
161
|
+
3. **父本来就有一个明确的杠杆。** 看到这一行之后,父可以选择 `abort`(软中断,保留上下文)
|
|
162
|
+
或 `stop`(硬停)。把决定权留在父手里,比在 host 里加一个猜的启发式更诚实。
|
|
163
|
+
4. **主机级 watchdog 需要有权限的 supervisor,而展示层没有。** `status` 是一个纯读的投影;
|
|
164
|
+
给它杀人的手,等于把 display-liveness 「渲染是投影,不是事件回放」的边界拆掉。
|
|
165
|
+
|
|
166
|
+
一句话:**`#315` 把「看不见」修成「看得见」,不把「看得见」升级成「自动处置」。**
|
|
167
|
+
|
|
168
|
+
### D6 — 唯一的行为改动:`scout` 的默认单次 bash 超时
|
|
169
|
+
|
|
170
|
+
`agents/scout.md` 新增 `toolTimeoutMs: 600000`。这是本契约里**唯一**改变运行行为的一项,理由
|
|
171
|
+
是 scout 的失败模式和别的角色不同:
|
|
172
|
+
|
|
173
|
+
- `toolTimeoutMs` 原本**没有默认值**,SDK 的 bash 工具也没有默认超时:一条卡住的探针命令
|
|
174
|
+
(`wait-output`、socket、网络挂载上的全仓 `rg`)能把一个只读侦察 child 钉满它 600s 的
|
|
175
|
+
turn 预算,并且**什么都不产出**。只读角色没有正当的长命令。
|
|
176
|
+
- **只给 `scout`**。`worker` / `centurion` 要跑完整构建、测试套件与集成门禁,
|
|
177
|
+
它们的长命令是**正当**的:误杀一次 `npm run test:integration` 的代价高于它防住的那次挂起。
|
|
178
|
+
给它们加默认超时等于把「慢」判成「坏」。
|
|
179
|
+
- **收益的边界(不要读多)**:600000 与本角色的父侧 `timeoutMs` 完全相等,**没有余量**。所以
|
|
180
|
+
这条预算买到的是「一条卡死的 bash 不再永久钉住 child」(到点被 OS 杀掉,child 还能带着已
|
|
181
|
+
读到的东西收场),**不是**「当次一定能看到侦察结果」—— 同长的命令照样跑不完,只是失败变
|
|
182
|
+
成有界的。是否给 scout 留余量(提高 `toolTimeoutMs` 或缩短 `timeoutMs`)属于父会话裁决,
|
|
183
|
+
不在本契约里改数值。
|
|
184
|
+
- 既有行为不变,并由测试钉住:以 `timeout` 开头的命令不被二次包裹(嵌套 `timeout` 会把外层
|
|
185
|
+
上限压成内层的,并让失败原因报错在错误的那一层);`toolTimeoutMs` 缺失时命令原样不动。
|
|
186
|
+
|
|
187
|
+
### D7 — 边界(实现期不得越界)
|
|
188
|
+
|
|
189
|
+
| 允许 | 禁止 |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `src/supervisor/types.ts` 加 `ChildActivity` / `InFlightToolStack` / `STALL_NOTICE_MS` / `nextInFlightTool` / `formatInFlightToolLine` / `inFlightToolStackOf` / `LegionSupervisor.inFlightTool?` / `LegionSupervisor.inFlightToolStack?`(#331) | 改 `src/extension/liveness.ts` 的任何判定 |
|
|
192
|
+
| 两个后端在各自 `ChildRecord` 上记录/清空该字段 | 改 `src/extension/legion-tools.ts` 的心跳与孤儿扫描判据 |
|
|
193
|
+
| `RoutingSupervisor.inFlightTool` / `inFlightToolStack` 纯转发(不加则工具面为 0:工具面注入的就是 `RoutingSupervisor`) | —— |
|
|
194
|
+
| `index.ts` / `src/extension/legion-status.ts` 的展示拼接 | 改 `runtime.ts` 的 claim / notice 语义 |
|
|
195
|
+
| `agents/scout.md` 的 `toolTimeoutMs` | 给 `worker` / `centurion` 加默认超时 |
|
|
196
|
+
| 展示层字符串 | 任何自动 `abort` / `stop` / `retire` / kill |
|
|
197
|
+
|
|
198
|
+
`inFlightTool` 在 `LegionSupervisor` 上是**可选**成员:它是在接口冻结之后新增的**只读展示**,
|
|
199
|
+
一个答不上来的 supervisor(测试替身、早于 #315 的后端)必须仍然满足契约。答不上来 = 没有
|
|
200
|
+
事实 = 不出那一行。
|
|
201
|
+
|
|
202
|
+
表里的 `——` 是「这一行没带对应的禁令」:`routing-supervisor.ts` 只多一个纯转发方法
|
|
203
|
+
(按 `owners` 路由到具体后端,与 `isAlive` 同形状),它不记录、不判定、不清空任何事实,
|
|
204
|
+
所以没有需要单独禁掉的东西。
|
|
205
|
+
|
|
206
|
+
## 验收
|
|
207
|
+
|
|
208
|
+
- `test/unit/stall-visibility.test.ts`:`hh:mm:ss` 格式(含不封顶的小时)、5 分钟边界与标记文案、
|
|
209
|
+
事件折叠(start / 无关事件 / 匹配的 end / 无名 start / `agent_settled` 清空 / 陌生 id 不清栈 /
|
|
210
|
+
有名与无名混排时陌生 end 只折叠无名帧)、
|
|
211
|
+
嵌套 `codemode → bash`(内层结束外层仍显示)、并行兄弟(显示的那次先结束 → 显示仍在跑的)、
|
|
212
|
+
栈空不出行、两个上下文提示、scout 与 worker 的 `toolTimeoutMs` 取舍。
|
|
213
|
+
- `test/unit/supervisor.test.ts`、`test/unit/inprocess-supervisor.test.ts`:两个后端各自从
|
|
214
|
+
**已有事件流**记录该事实,`agent_settled` 与 dead child 都不留残留;routing 后端按 owner 转发。
|
|
215
|
+
- `test/unit/status-action.test.ts`、`test/unit/legion-status.test.ts`:三个展示出口的 lead line、
|
|
216
|
+
5 分钟标记、以及「没有事实就一行都不多」。
|
|
217
|
+
- `test/unit/child-guard.test.ts`:scout 的 600s 预算真的把 bash 包成
|
|
218
|
+
`timeout --kill-after=2s 600s bash -lc …`;无预算时命令不动。
|
|
@@ -41,10 +41,12 @@
|
|
|
41
41
|
- `wait { timeoutMs }` / `collect { timeoutMs }`(`index.ts:1768`, `:2616`)
|
|
42
42
|
→ **父侧放弃等待,不杀 child**。`inprocess-supervisor.ts:1084` 只 `resolve({settled:false, reason:"timeout"})`。
|
|
43
43
|
- 角色 `timeoutMs`(`agents/worker.md:9` 等)
|
|
44
|
-
→
|
|
45
|
-
|
|
44
|
+
→ **父侧等待预算,不终止 child**(`orchestrator.ts` 的 collect deadline;到点返回 `running`,watch 重新武装)。
|
|
45
|
+
宿主没有 turn 级超时:pi 1.4 的 CLI / settings 都没有该开关,子进程 argv 也不带 timeout(2026-11 容器实测:
|
|
46
|
+
`worker.timeoutMs=10s` 的 child 存活 56s 并正常 `success`)。真正终止 child 的只有 `stop`、session shutdown、
|
|
47
|
+
工具预算切断。
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
**结论:宿主不会因 `timeoutMs` 终止 child。真正的终止来自 stop / shutdown / 工具预算,pi-legion 无法阻止,但能在终止后识别、评估、续约。** 契约按此边界设计。
|
|
48
50
|
|
|
49
51
|
---
|
|
50
52
|
|
|
@@ -159,3 +159,4 @@ npm run test:integration
|
|
|
159
159
|
| 2026-10-19 | v1 | 首次冻结 | #227 三缺陷拆分:D1/D2/D3(A)、D4/D3b/D5(B);D6/D7 显式排除 |
|
|
160
160
|
| 2026-10-19 | v1.1 | 勘误 §1 D1 轮询常量位置:`src/shared/types.ts` → 实为 `src/runs/orchestrator.ts:143` 私有常量,改由 `src/supervisor/spawn-mode.ts` 导出同值常量;§2 Worker A 文件表同步 | Worker A 指出契约原文事实错误;`grep` 确认 `POLL_INTERVAL_MS` 从未在 `src/shared/types.ts` 出现过。属笔误型而非设计型矛盾,advisor 裁决按勘误继续(不回退重派),父会话在此落库 |
|
|
161
161
|
| 2026-10-19 | v1.2 | 新增 **D1a**:首次 prompt 在「受理之前」reject 时 `spawnChild` 仍须抛出(保 `fallbackModels`);§3 验收清单同步为 8 条 | 父会话审查 PR #228 实测发现回归:非阻塞改造把受理前 reject 吞成「已 spawn」,`canFallbackStart` 失效。HEAD 抛错 / 首版实现返回 handle,属 #227 之外的回归,必须以先红后绿用例守住 |
|
|
162
|
+
| 2026-11-04 | v1.3 | **D6 复议**:`COLLECT_DEADLINE_REASON` 的 `cumulative since launch` 措辞改为 `per collect attempt`,同步更新 5 个测试文件的字面断言 | 该字面量本来就是错的(deadline 从 collect 调用起算),当初只因「测试钉住了」而冻结;容器实测(role `timeoutMs`=10s 的 child 存活 56s 并 success)证明它不是 kill 预算,留错字面会继续误导排障 |
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# viz 帧分配契约 v1(#364)
|
|
2
|
+
|
|
3
|
+
- 状态:**生效**,随 #364 落地。
|
|
4
|
+
- 上游:`docs/contracts/legion-chart-frame-buckets-v1.md`(R1–R6 仍然有效)、
|
|
5
|
+
`docs/contracts/legion-page-floor-v1.md`、`docs/contracts/legion-main-screen-mount-v1.md`。
|
|
6
|
+
- 冲突时停下报告,不得自行重新解释。
|
|
7
|
+
|
|
8
|
+
## §0 现状与目标
|
|
9
|
+
|
|
10
|
+
`#362` 修掉了 `Canvas.set()` 的每写分配(52.4/128.0 B → 0.03/0.002 B per write),
|
|
11
|
+
整帧分配降到 120×40/live **2551.8 KB**、200×60/live **4018.2 KB**(chart 契约 §3.4)。
|
|
12
|
+
耗时目标已达成(3.87 / 5.73 ms median,5.47 / 7.44 ms p95)。
|
|
13
|
+
|
|
14
|
+
本契约只做**分配**,目标:
|
|
15
|
+
|
|
16
|
+
| 形状(200 节点,真 `Theme(dark)`) | 现状 | 目标 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 120×40 / live 整帧分配 | 2551.8 KB | ≤ **2048 KB** |
|
|
19
|
+
| 200×60 / live 整帧分配 | 4018.2 KB | ≤ **2048 KB** |
|
|
20
|
+
| 120×40 / live median / p95 | 3.87 / 5.47 ms | 不回归(≤4.0 / ≤7.0 ms) |
|
|
21
|
+
| 200×60 / live median / p95 | 5.73 / 7.44 ms | 不回归(≤7.0 / ≤10.0 ms) |
|
|
22
|
+
|
|
23
|
+
若某形状实测无法达标,**如实记录差额与残余分量**(照 #346 的做法),不得改测量方法凑数。
|
|
24
|
+
|
|
25
|
+
## §1 不变量(必须全绿,逐条有守卫)
|
|
26
|
+
|
|
27
|
+
| 编号 | 不变量 | 守卫 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **I1** | `/legion` 可见输出**逐字节不变**:对 120×40 / 200×60 / 80×24 / 46×60 四个形状,`toPlain()` 与 `toLines(ansiTheme)` 的 sha256 与本契约落地前一致 | 新增内联 golden 用例(见 §4.2) |
|
|
30
|
+
| **I2** | 图表外观 / 布局 / `plotW` / `plotH` 一律不动 | chart 契约 R6 |
|
|
31
|
+
| **I3** | `paintPageFloor` 函数体不动(`view.ts:96`) | page-floor v1 R1/R3/R4;main-screen-mount R5 |
|
|
32
|
+
| **I4** | `drawGalaxy` 的**呈现**不变:同一 `data`/`selectedId`/几何下,画布逐格 `(ch, color, bold, inverse)` 相同 | I1 + 新增逐格比对用例 |
|
|
33
|
+
| **I5** | `Canvas` 活视图语义不变:`get()`/`cells` 返回的对象仍是下一次 `set` 就地改写的同一对象;构造后恰好 `W×H` 个 cell | `test/unit/legion-viz.test.ts:2462-2536` **不改** |
|
|
34
|
+
| **I6** | 滚动 `chartWindow`(尾部 60s)**不在本契约范围内**(需单独授权) | chart 契约 §4 |
|
|
35
|
+
| **I7** | 任何跨帧可变状态必须:**(a)** 有可判定的失效键;**(b)** 有一条用例证明「输入变了,结果跟着变」。无失效证明的缓存一律不许进 | 见 §4.3 |
|
|
36
|
+
|
|
37
|
+
## §2 允许的手段
|
|
38
|
+
|
|
39
|
+
- **A1 每帧 reset 代替每帧 allocate**:`px` 网格、`Canvas` cells、`Map`/`Set` 复用(`clear()` 而非 `new`)。
|
|
40
|
+
- **A2 模块级 memo,键必须是稳定标量**:role 名、node status、`roleColor`/`nodeToken` 的拼好字符串、
|
|
41
|
+
braille 码位 `0..255`、`for (const dy of [0,-1,1,-2,2])` 这类字面量数组。
|
|
42
|
+
- **A3 已解析的 role 色传进 `renderRow`**,不再为每个 role 色格 `{...cell, color}` 复制
|
|
43
|
+
(`canvas.ts:79`;实测 1123/1865 个 role 格 ×72/70 B)。
|
|
44
|
+
- **A4 帧内共享中间结果**:`childrenMap` 每帧只建一次并传给 `dfsOrder`(现在是 `panels.ts:102` 与
|
|
45
|
+
`:250`→`:56` 两份,每帧 2 份 Map + 数组)。
|
|
46
|
+
- **A5 `Canvas` 复用**:可新增 `reset()`,但只允许 `view.ts` 的生产 render 路径使用;
|
|
47
|
+
`new Canvas(W,H)` 的语义与成本不变。
|
|
48
|
+
|
|
49
|
+
## §3 禁止与陷阱(逐条来自侦察,违反即视为方案错误)
|
|
50
|
+
|
|
51
|
+
| 编号 | 陷阱 | 证据 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| **T1** | `px` 网格复用时**必须全格清零 `m` 和 `z`**。`m` 用 `\|=` 只加位;`z` 是写入门槛(`if (z >= cell.z)`),不清零时上一帧的高 `z` 会拒掉本帧低优先级写入,而 `m != 0` 时 `col` 必被读 ⇒ **颜色串帧** | `panels.ts:81`(唯一初始化点)、`:87`、`:88-91`、`:204` |
|
|
54
|
+
| **T2** | **禁止跨帧缓存 `childrenMap` / `pos` / `rel` / `radii` / `leafMemo`**。`data.nodes` 与每个 `VizNode` 对象**每帧都是新的**(`status.ts:925` → `model.ts:93`),缓存会保留上一帧对象;而 `node === center` / `node === virtualRoot` 的身份比较遍布绘制路径,新旧世代混用会让中心球退化成普通节点 | `panels.ts:107`、`:156`、`:171`、`:211`、`:216`、`:230`、`:239`;`status.ts:923-925`;`model.ts:93` |
|
|
55
|
+
| **T3** | `leafMemo` 的 key 是节点 **id 字符串**(不是对象),而结构会随 subagent 派生变化 ⇒ 只能**帧内**用,不能跨帧 | `panels.ts:110`、`:112`、`:117` |
|
|
56
|
+
| **T4** | `rnd()` 种子每帧重置为 `99`:任何「跳过某些 `dot()`」的优化都会让后续随机序列整体错位、星尘/闪烁图案变化 ⇒ 不许用跳过绘制省分配 | `panels.ts:93-95`、`:177-188` |
|
|
57
|
+
| **T5** | `roles()` + `resolveRolePalette` 的缓存键必须含**(theme, 该帧 role 集合)**;`Canvas` 复用时 role 集合必须随 `reset()` 清空(少了 role 会直接改输出) | `canvas.ts:68-74`、`:76`;`palette.ts:153-171` |
|
|
58
|
+
| **T6** | `renderRow` 只在 `color && text.trim().length > 0` 时套 `theme.fg` ⇒ 行尾裸空格行为必须保留(page floor 全靠它) | `canvas.ts:161`;page-floor v1 §16/§33 |
|
|
59
|
+
| **T7** | `Canvas.set()` 不得再引入分配 | #362 + `test/unit/legion-viz.test.ts:2485-2500` |
|
|
60
|
+
|
|
61
|
+
## §4 验收
|
|
62
|
+
|
|
63
|
+
### 4.1 可复现基准(进仓)
|
|
64
|
+
|
|
65
|
+
新增 `bench/perf-core/10-legion-frame-alloc.ts`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
node --expose-gc --experimental-strip-types bench/perf-core/10-legion-frame-alloc.ts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- 单进程、`--expose-gc`、GC 落定后取「单帧 `heapUsed` 差值」**20 次中位**;
|
|
72
|
+
时间取 5 轮 × 50 帧 median / p95(nearest-rank)。
|
|
73
|
+
- 页面主题 = 真 `Theme(dark)`(按绝对路径深导入
|
|
74
|
+
`node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js` 的 `getThemeByName`;
|
|
75
|
+
包入口不导出它,这是既有 `/tmp/perf-tui/lib.ts:113-117` 的做法)。
|
|
76
|
+
- 数据 = 200 节点真 `VizNode[]` + 预热 `RateSampler`(61 采样 @2s),`live` 与 `settled` 两种形状。
|
|
77
|
+
- 输出每形状 `frameAllocKB` / `median` / `p95` / `plainDigest` / `ansiDigest` + 分量归因
|
|
78
|
+
(`drawGalaxy` / `toLines` / `drawChart` / `new Canvas` / 其余)。
|
|
79
|
+
- 门禁:`120x40 <= 2048 KB`、`200x60 <= 2048 KB`、median/p95 ≤ §0 上限、两个 digest 命中 golden 文件;
|
|
80
|
+
miss → `process.exitCode = 1`(照 `bench/perf-core/09-legion-mount.ts:358-406` 的 `gates` 形态)。
|
|
81
|
+
|
|
82
|
+
### 4.2 可见输出 golden(进仓,补上 recon 发现的空档)
|
|
83
|
+
|
|
84
|
+
当前仓里**没有**任何 golden 快照——`test/unit/legion-viz.test.ts:627` / `:2398` 都是
|
|
85
|
+
「同一份代码渲染两次再比」,两侧同时漂移抓不住。本契约要求补一条内联用例:
|
|
86
|
+
|
|
87
|
+
- 对 120×40 / 200×60 / 80×24 / 46×60 四个形状,断言 `canvas.toPlain()` 与
|
|
88
|
+
`canvas.toLines(ansiTheme)` 的 sha256 等于内联 golden(fake `ansiTheme`,不依赖宿主包,稳定);
|
|
89
|
+
- golden 值 = **本契约落地前**(父提交)的渲染结果,逐字节;
|
|
90
|
+
- 这条用例同时是 §1-I1 的守卫与 bench digest 的锚。
|
|
91
|
+
|
|
92
|
+
### 4.3 缓存失效用例
|
|
93
|
+
|
|
94
|
+
对每一个新增的跨帧缓存(A5 `Canvas` 复用、A2 的 module memo、A3 的 palette 缓存)各写一条用例,
|
|
95
|
+
证明「输入变了结果跟着变」:几何变化后 `px` 网格重建、`Canvas` 复用后上一帧残留的
|
|
96
|
+
`ch/color/bold/inverse` 不泄漏、role 集合变化后调色板更新。参照 #346 的反例形态
|
|
97
|
+
(`test/unit/legion-viz.test.ts:2296-2298`:「no cache, no accumulation」)。
|
|
98
|
+
|
|
99
|
+
### 4.4 不回归
|
|
100
|
+
|
|
101
|
+
`npm run typecheck` / `npm test` / `npm run test:integration` 全绿;
|
|
102
|
+
`node --experimental-strip-types bench/perf-core/09-legion-mount.ts` 在**空闲独占**机器上仍 `ok: true`。
|
|
103
|
+
机器不空闲时(有并发任务、`loadavg`/foreign CPU/探针超阈值)该基准输出 `ok: "inconclusive"`、
|
|
104
|
+
时间类门标 `not judged`、退出码 0 —— **不算回归**,也不是通过;须换空闲机器复跑
|
|
105
|
+
(见 `bench/perf-core/README.md` 的「`09` 必须空闲独占运行」与 #368)。
|
|
106
|
+
|
|
107
|
+
## §5 交付顺序(按契约写死的优先级,实测可微调但必须写进报告)
|
|
108
|
+
|
|
109
|
+
1. **S1 `drawGalaxy` 内部零分配化**(最大项,1.16–1.23 MB / 2.2 ms):
|
|
110
|
+
`px` reset 复用(守 T1)、`leaves()` 迭代化(现在每个调用点一个 `guard` Set:`panels.ts:111`,
|
|
111
|
+
一帧约 2N−1 个)、`childrenMap` 同帧共享(A4)、`roleColor`/`nodeToken` memo(A2)、
|
|
112
|
+
braille 256 项查表(替代每格 `String.fromCharCode`,`panels.ts:204`)、字面量数组提升到模块级。
|
|
113
|
+
2. **S2 `toLines` / `renderRow`**:palette 传入 + 去掉 `{...cell, color}` + `roles()` 不再全网格扫描 +
|
|
114
|
+
palette 缓存(守 T5、T6)。
|
|
115
|
+
3. **S3 `Canvas` 复用**(`reset()` + `view.ts:104` 生产路径;守 I5、T7)。
|
|
116
|
+
4. **S4 `drawChart` 临时对象**:`bands`(`panels.ts:373-395`)、`computeStats` 的
|
|
117
|
+
`byRole` 投影(`:335`)、`"─".repeat(plotW)`(`:422`)。
|
|
118
|
+
|
|
119
|
+
每步后跑同一 bench:任一步让 §4.2 的 digest 变化 → 回退该步,不得为了数字改输出。
|
|
120
|
+
|
|
121
|
+
> **#369 之后**:本节各步用到的帧内容器(`px` 网格及其尺寸键、`drawGalaxy` 的 19 个、`drawChart` 的
|
|
122
|
+
> 4 个,共 26)已收进 `panels.ts` 的 `GalaxyScratch` 单例,统一由 `begin()` 清零、跨帧身份不变;
|
|
123
|
+
> 本节的步骤划分、T1–T7 的含义与 §1 的不变量都**不变**,只是上述 `panels.ts:NNN` 行号随重构移动。
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# wait / collect 邮件抢占契约 v1
|
|
2
|
+
|
|
3
|
+
- 状态:生效,随实现 PR 落地。
|
|
4
|
+
- 上游:`docs/legion-v2-contract.md`(冻结契约)、`docs/contracts/notice-claim-v1.md`(#352 的 claim 语义)。
|
|
5
|
+
- 本契约只改**阻塞工具的返回时机**,不改邮件本身的内容、路由与记账规则。
|
|
6
|
+
|
|
7
|
+
## §0 问题(实测)
|
|
8
|
+
|
|
9
|
+
`legion_mail → root` 走 `notice()` → `pi.sendMessage({triggerTurn:true, deliverAs:"followUp"})`。
|
|
10
|
+
宿主的 followUp 只在**当前轮"本来要停"时**被取走(`agent-loop.js:197`),且一封一轮。
|
|
11
|
+
所以根会话只要在一个长工具调用里,邮件就进队列干等:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
10:21:27 根会话进入 subagent({action:"wait", timeoutMs:900000})
|
|
15
|
+
10:24:00 cent-1 写入 mail #1(契约冻结)
|
|
16
|
+
10:40:59 这一轮才结束 → mail #1 才被投递(延迟 17.3 分钟)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## §1 触发条件
|
|
20
|
+
|
|
21
|
+
- 调用者是 legion 节点(工具入口的 `input.legion` 存在),且该节点的 inbox 里存在
|
|
22
|
+
`delivery = PENDING` 的邮件。
|
|
23
|
+
- 进入 `wait` / `collect` 时**先查一次**;之后每 **250ms** 查一次,直到这次调用结束。
|
|
24
|
+
- 与 `signal`(调用方中断)互不覆盖:谁先到谁赢。
|
|
25
|
+
|
|
26
|
+
### §1.1 阻塞期间该节点的 postman 必须停手
|
|
27
|
+
|
|
28
|
+
`PENDING` 是触发条件的全部前提,而**生产里这一前提曾经不成立**:实测问题会话的账本
|
|
29
|
+
(`r-f4ef2868/legion.db`)显示,root 的 postman 在写入后 **~200ms** 就把邮件
|
|
30
|
+
`PENDING → ACCEPTED` 并推进 host 的 followUp 队列 —— 比任何一次 250ms 轮询都早。
|
|
31
|
+
于是 `pendingMailCount(root)` 在整个 15 分钟的 `wait` 里恒为 0,功能形同虚设。
|
|
32
|
+
|
|
33
|
+
因此:
|
|
34
|
+
|
|
35
|
+
- **阻塞调用持有节点期间,该节点自己的 postman 不得投递**:`deliverRootInbox` 在看到 hold
|
|
36
|
+
时直接返回,邮件留在 `PENDING` 供抢占取走(`beginMailHold` / `endMailHold` /
|
|
37
|
+
`isMailHeld`,`src/legion/mail-release.ts`,进程级、按 nodeId 计数)。
|
|
38
|
+
- hold 覆盖**所有**退出路径:超时、调用方 abort、抛错、正常返回,都在 `finally` 里释放 ——
|
|
39
|
+
一次超时的 `wait` 不允许把 inbox 永久占住。
|
|
40
|
+
- hold 只覆盖**同一进程内的投递者**:root 的 postman、以及**同进程(in-process)子节点**的投递都受它约束
|
|
41
|
+
(`deliverChildren` 同样检查子节点的 hold)。RPC 子节点的投递发生在父进程,那里的 hold 在另一个进程里,
|
|
42
|
+
不受此约束 —— 那种情况下由 §3.1 的租约保证「不重复投递」,本条的「抢占优先」只对同进程投递者成立。
|
|
43
|
+
|
|
44
|
+
## §2 行为
|
|
45
|
+
|
|
46
|
+
命中即**立即返回**(不再等 timeout、不再等 settle):
|
|
47
|
+
|
|
48
|
+
- 正文 = 被抢占的邮件(`formatMail` 原文,按 id 升序,最多 100 封)+ 一行状态说明:
|
|
49
|
+
- `wait`:`Still running: <name>, …`(真机验证后的措辞;早先草案写的是 `Children are still running: <name (state)>, …`,已废弃)
|
|
50
|
+
- `collect`:`The collect was interrupted by this mail; call collect again after handling it.`
|
|
51
|
+
- `details.state = "mail"`、`details.mailIds = number[]`、`details.targets = string[]`。
|
|
52
|
+
- 返回是**正常工具结果**(不是错误、不是 `interrupted`)——模型可以读完邮件继续工作。
|
|
53
|
+
|
|
54
|
+
## §3 投递记账(恰好一次)
|
|
55
|
+
|
|
56
|
+
- 被抢占的邮件必须 `transitionDelivery(…, ACCEPTED)`(与 postman 的 `deliverRootInbox` 同一语义)。
|
|
57
|
+
- 不新增事件:`mail_sent` 已在 `sendMail` 落账(`src/legion/mail.ts:311`),这里只动 delivery 列。
|
|
58
|
+
- open 防重复有两条(都必要):**发送前先在账本上认领**(§3.1 租约,postman 与抢占方用同一个
|
|
59
|
+
`claimForDelivery`,输的一方跳过自己的发送),以及**阻塞调用期间该节点的 postman 停手**(§1.1,邮件留在
|
|
60
|
+
PENDING 供抢占)。测试必须钉住「抢占之后 postman tick 一次,不再产生 followUp」。
|
|
61
|
+
- **重投(`Redelivered: …`)不消耗发送方配额**:它是平台把被丢弃的工具结果救回来,不是发送方发的新邮件,
|
|
62
|
+
因此限流查询排除带该前缀的行;前缀只加一次。
|
|
63
|
+
|
|
64
|
+
### §3.1 在途认领(租约)
|
|
65
|
+
|
|
66
|
+
同一条消息可能被两条路同时拿:postman 的 `followUp/steer/prompt`(`deliverToChild`)与收件人自己
|
|
67
|
+
被阻塞的 `wait`/`collect`(本契约 §1)。谁先投递必须是**数据库层互斥**,而不是两个 async 调用点的先后。
|
|
68
|
+
|
|
69
|
+
实现用**租约**,不新增状态、不新增合法边:
|
|
70
|
+
|
|
71
|
+
- `messages.delivered_at`(本就存在且可空)在 `delivered = PENDING` 期间复用为**认领时间**;
|
|
72
|
+
- 认领 = 条件更新
|
|
73
|
+
`UPDATE messages SET delivered_at = ? WHERE id = ? AND delivered = 0 AND (delivered_at IS NULL OR delivered_at < ?)`
|
|
74
|
+
(`?` 为 `now - leaseMs`),`changes === 0` ⇒ 别人持有,**跳过发送**;
|
|
75
|
+
- 租约 **60s**(`MAIL_DELIVERY_CLAIM_LEASE_MS`,`src/legion/mail-release.ts`):只用于**崩溃兜底**,
|
|
76
|
+
正常路径由状态机收尾——发送返回 ⇒ `transitionDelivery(…, ACCEPTED)`(顺带把 `delivered_at` 写成真正的接受时间);
|
|
77
|
+
发送抛错 ⇒ `UPDATE … SET delivered_at = NULL WHERE delivered = 0`(放回 PENDING,下一 tick 重试,**不判 BOUNCED**);
|
|
78
|
+
- 认领者异常退出(进程消失、没机会释放):租约过期后(`now > delivered_at + leaseMs`)即可被重新认领;
|
|
79
|
+
- `delivered = PENDING` 的含义因此**不变**(仍是"还没交付"),`ACCEPTED` 之后 `delivered_at` 回归"接受时间",
|
|
80
|
+
§3 的"恰好一次"依旧由 `PENDING → ACCEPTED` 单独保证。
|
|
81
|
+
|
|
82
|
+
由此 `ACCEPTED` 不承担"在途"语义,#322 的五值域与四类合法边一条不改。
|
|
83
|
+
|
|
84
|
+
## §4 取消
|
|
85
|
+
|
|
86
|
+
- `wait`:内部 `AbortController` 与调用方 `signal` 合并(`AbortSignal.any`),abort 原因可区分
|
|
87
|
+
「mail 抢占」与「调用方中断」;后者仍返回原来的 `interrupted (parent turn ended; children still running)`。
|
|
88
|
+
- `collect`:走既有的 interruptible 路径,**claim 回滚照旧**(`unclaimUndelivered`);
|
|
89
|
+
抢占分支在回滚之后返回邮件正文,不允许留下「被抑制但没有交付」的通知(#352 的不变量)。
|
|
90
|
+
|
|
91
|
+
## §5 不变量
|
|
92
|
+
|
|
93
|
+
| 编号 | 不变量 | 守卫 |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| I1 | 无邮件时行为逐字节不变(结果文本、`details`、timeout 语义) | 现有 wait/collect 用例**一行不改** |
|
|
96
|
+
| I2 | 邮件恰好交付一次:PENDING→ACCEPTED + 不再被 postman 投递 | 新用例 #2 |
|
|
97
|
+
| I3 | 不新增事件类型、不改 SQLite schema | `legion-schema-sentinel` / `legion-events` 不改 |
|
|
98
|
+
| I4 | 子节点状态与进程不被触碰(只返回,不 retire/abort) | 新用例断言 `status` 不变 |
|
|
99
|
+
| I5 | 调用方中断(Esc/父轮结束)语义不变 | 现有 interrupted 用例不改 |
|
|
100
|
+
| I6 | 抢占延迟 ≤ 500ms(250ms 轮询) | 新用例用假时钟或实测墙钟上界 |
|
|
101
|
+
|
|
102
|
+
## §6 验收
|
|
103
|
+
|
|
104
|
+
1. `wait`:活子节点 + 100ms 后写入一封 mail → 1s 内返回,正文含 mail 标题与正文;
|
|
105
|
+
`details.state === "mail"`;账本里该邮件 `delivery = ACCEPTED`;随后 postman tick → 不投递(I2)。
|
|
106
|
+
2. `collect`:同上,且 `details.state === "mail"`、子节点 `status` 不变(I4)。
|
|
107
|
+
3. 无邮件:`wait` 照旧等超时、`collect` 照旧拿结果(I1,现有用例不改)。
|
|
108
|
+
4. 调用方 `signal` 先触发:仍返回 `interrupted …`(I5)。
|
|
109
|
+
5. 变异:去掉轮询 → 用例 1/2 失败(超时或拿不到 mail);把 `transitionDelivery` 去掉 → 用例 2 的
|
|
110
|
+
「不再重复投递」变红。
|
|
111
|
+
6. `npm run typecheck` / `npm test` / `npm run test:integration` 全绿。
|
|
112
|
+
|
|
113
|
+
## §7 非目标
|
|
114
|
+
|
|
115
|
+
- 不改 postman 的投递节奏、不改 `notice()` 的 `followUp` 语义。
|
|
116
|
+
- 不做跨进程/跨节点的邮件合并。
|
|
117
|
+
- 不把邮件内容塞进 `wait` 的 `timeoutDetails`(那条详情属于 #352 的 claim 语义)。
|
|
@@ -14,7 +14,7 @@ result includes structured fields for the **parent** to decide:
|
|
|
14
14
|
- `status`: `wait_timeout` | `still_running` | `budget_exceeded` (budget optional)
|
|
15
15
|
- `done` / `stillRunning[{ name, elapsedMs, budgetMs? }]`
|
|
16
16
|
- `decisions`: `extend` | `steer_again` | `stop` | `force_stop` (model hint enum, not a new RPC)
|
|
17
|
-
- `hint`: wait
|
|
17
|
+
- `hint`: wait only stopped waiting — the children are still running and keep their context; prefer `status`/`extend`/`steer_again`, and use `stop`/`force_stop` only to abandon the work
|
|
18
18
|
|
|
19
19
|
The parent then calls existing tools (`wait` again, `steer`, or `stop` with
|
|
20
20
|
optional `force: true`). Tooling must not auto-SIGKILL on timeout.
|
package/docs/design.md
CHANGED
|
@@ -928,7 +928,10 @@ subagent({
|
|
|
928
928
|
|
|
929
929
|
// launch
|
|
930
930
|
agent?: string, task?: string,
|
|
931
|
-
tasks?: Array<{ agent: string; task: string }>, // 并行
|
|
931
|
+
tasks?: Array<{ agent: string; task: string; name?: string }>, // 并行
|
|
932
|
+
// name 是该条目自己的 handle(批量内必须互不相同,
|
|
933
|
+
// 省略则由 launch 自动编号 role-N);单条目时也可
|
|
934
|
+
// 用顶层 name=,两处给不同值会被拒
|
|
932
935
|
async?: boolean, // 默认 true
|
|
933
936
|
model?: string, cwd?: string, placement?: string,
|
|
934
937
|
worktree?: boolean, // 隔离 git worktree(默认随角色)
|
|
@@ -80,9 +80,15 @@ Never fall back across columns: in-process limits are read from
|
|
|
80
80
|
|
|
81
81
|
### Renewing a budget casualty (#270)
|
|
82
82
|
|
|
83
|
-
An agent's `timeoutMs` is
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
An agent's `timeoutMs` is the parent's **wait** budget, not a kill switch: when
|
|
84
|
+
it elapses, `collect`/`wait` stop waiting and report `running`, and the child
|
|
85
|
+
keeps running (the watch re-arms). Nothing in pi or pi-legion terminates a child
|
|
86
|
+
at that deadline — verified against pi 1.4: no CLI flag, no settings key, and no
|
|
87
|
+
timeout is passed in a child's argv.
|
|
88
|
+
|
|
89
|
+
A child is terminated by an explicit `stop`, by session shutdown, or by a
|
|
90
|
+
tool-call budget cut. When that happens **and** it measurably progressed, the
|
|
91
|
+
completion notice ends with a ready-made command:
|
|
86
92
|
|
|
87
93
|
```
|
|
88
94
|
✗ W2 (worker) failed (terminated during tool call)
|