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,235 @@
|
|
|
1
|
+
# 部分失败的合法出口:`supersedes` 与 `parent_decision`(v1,冻结)
|
|
2
|
+
|
|
3
|
+
> 语义由 #309 冻结。实现期发现矛盾 → 停下报告,不得重新解释本契约。
|
|
4
|
+
> **本文件是 #309 唯一的 D 编号命名空间**:实现代码与测试注释里的 `#309 Dn` 只解析到这里的
|
|
5
|
+
> D1–D4。`#309` 之外的 `Dn`(例如 `docs/legion-v2-contract.md` 的 D11 = 可视化三层、
|
|
6
|
+
> D12 = SQLite 账本)与本契约无关;本契约没有 D5 及以后的条款。
|
|
7
|
+
|
|
8
|
+
## 背景:一个没有任何合法出口的状态
|
|
9
|
+
|
|
10
|
+
默认机器(`src/legion/phase-machine.ts` 的 `DEFAULT_PHASE_MACHINE`)在 `implementing`
|
|
11
|
+
留了三扇门,而「1 个 child failed + N 个 child settled」这种**最常见**的收队形态
|
|
12
|
+
一扇都推不开:
|
|
13
|
+
|
|
14
|
+
| 出口 | 关卡 | 为什么过不去 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `implementing → reviewing` | `children_present` + `children_none_failed` | `children_none_failed` 只容忍出现在 `handledIds` 里的失败节点 |
|
|
17
|
+
| `implementing → failed` | `children_all_failed` | 部分失败不是「全部失败」 |
|
|
18
|
+
| `implementing → aborted` | — | 工具面对 centurion 硬拦(`aborted is controlled by the parent or supervisor`),只有父级 retire 走得到;且 `aborted` 是「放弃」不是「判负」,语义错位 |
|
|
19
|
+
|
|
20
|
+
`handledIds` 的**唯一**生产点在 `requestLegionPhase` 的 `failedHandledIds`,硬前提是
|
|
21
|
+
`self.reworkCount > 0`;而 `reworkCount` 只在两条 `rework: true` 的边上自增
|
|
22
|
+
(`reviewing → implementing`、`verifying → implementing`)。`implementing` 没有任何
|
|
23
|
+
rework 边 → **自锁**。与此同时 `to: "failed"` 在 `requestLegionPhase` 里被一句
|
|
24
|
+
`${to} is controlled by the parent or supervisor` 硬拦,状态机根本跑不到。
|
|
25
|
+
|
|
26
|
+
而状态机一侧其实**早就留好了口子**:`terminalReason: "parent_decision"` 在
|
|
27
|
+
`evaluateDeclaredFailure` 里无条件放行。`src/` 里零生产者——口子造好了,没人走过。
|
|
28
|
+
|
|
29
|
+
## 冻结决定
|
|
30
|
+
|
|
31
|
+
### D1 — 让 `parent_decision` 有真实生产者:centurion 可显式判负
|
|
32
|
+
|
|
33
|
+
`legion_phase` 新增可选入参 `reason?: string`。终态守卫从「三个都硬拦」改成:
|
|
34
|
+
|
|
35
|
+
| `to` | 条件 | 结果 |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `done` | — | 仍然硬拦(supervisor / 父级产物,centurion 不得自行宣告成功) |
|
|
38
|
+
| `aborted` | — | 仍然硬拦(`aborted` 是父级 retire 的紧急停止,不是 centurion 的按钮) |
|
|
39
|
+
| `failed` | `reason` trim 后非空 | 放行,`terminalReason: "parent_decision"` 传给状态机 |
|
|
40
|
+
| `failed` | `reason` 缺失/空白 | 拒绝,文案点名缺 `reason` |
|
|
41
|
+
|
|
42
|
+
落库走**既有**接受路径(`updateNode` + `appendEvent`),没有新通道。`phase_change`
|
|
43
|
+
事件的 `data` 带上 `terminalReason: "parent_decision"` 与 `reason`,于是「谁、为什么判负」
|
|
44
|
+
是账本里的一条可查事实,而不是模型的一句话。`reason` 与 `note`/`evidence` 一样按 2 000 字符
|
|
45
|
+
截断**落库**(返回给模型的消息保留全文,因为它不落库)。成功回执逐字为
|
|
46
|
+
`Team failed by centurion decision: <reason>`。
|
|
47
|
+
|
|
48
|
+
判负是**终态裁决**,所以它和另外两条终态路径(budget 强制失败、`failTeamIfAllChildrenFailed`)
|
|
49
|
+
一样**给父级发一封邮件**(`kind: notice`、`urgency: action-needed`,正文与回执同源)。父级不应
|
|
50
|
+
靠轮询账本才能发现一个已经结束的团队。
|
|
51
|
+
|
|
52
|
+
**判负不是聚变**:它只提交 `implementing → failed` 这一条机器本来就声明的边。非法边
|
|
53
|
+
(例如从终态再判负)照旧由状态机拒绝——工具面不为它开特例。
|
|
54
|
+
|
|
55
|
+
具体到实现:`terminalReason: "parent_decision"` **仅在**本团队**有效(归一化后)机器的**当前
|
|
56
|
+
相位中存在一条 `{ to: "failed" }` 边、且该边的 `actorRole` 为空或正对本节点时才传入。没有
|
|
57
|
+
可用边、或边被保留给别的角色时,工具面传的是「没有 terminalReason」,状态机便按它自己的
|
|
58
|
+
规则拒绝(`INVALID_PARAMS` + `failed requires parent decision, all children failed, or
|
|
59
|
+
exhausted rework`)。于是「这张卡不许从这个相位失败」不会被 centurion 的一句话覆盖。
|
|
60
|
+
|
|
61
|
+
**「归一化后」是有意的,两个方向都要看清**:`normalizePhaseMachine` 在卡**根本没声明**
|
|
62
|
+
`failed`/`done`/`aborted` 这些相位时,会把它们补上,并给每个非终止相位追加一条
|
|
63
|
+
`{ to: <该终态> }` 边。因此:
|
|
64
|
+
|
|
65
|
+
| 卡的写法 | `failed` 边从本相位看 | 可否判负 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| 没声明 `failed` 相位 | 注入的,存在 | **可以**(与 `evaluatePhaseTransition` 看到的是同一张机器) |
|
|
68
|
+
| 声明了 `failed` 相位,但本相位的 `next` 里没有它 | 不存在 | 不可以 |
|
|
69
|
+
| 声明了,但 `actorRole` 指向别的角色 | 这条不可由本节点取 | 不可以——**除非**同一相位另有一条未保留的 `failed` 边,那就按那条放行(与书写顺序无关) |
|
|
70
|
+
|
|
71
|
+
第一行不是漏洞:`normalizePhaseMachine` 的注入从来只**增加可达性、不增加能力**
|
|
72
|
+
(它的文档注释就是这么写的),真正把门的是 `terminalReason` 要求与 `actorRole` 校验。
|
|
73
|
+
所以「卡声明了 `failed` 相位」本身就是一道 opt-out:声明了就按声明的边来,没声明就享受
|
|
74
|
+
注入兜底。判负仍是显式决定,不是默认路径。
|
|
75
|
+
|
|
76
|
+
拒绝文案本身**不**含「legal next phases」列表:`evaluateDeclaredFailure` 跑在边查找**之前**
|
|
77
|
+
(`src/legion/state-machine.ts`,本契约不动它),所以 `to:"failed"` 这条路根本到不了那句
|
|
78
|
+
带出口列表的提示。要让这句话变成 `invalid_transition` + 出口列表,得重排
|
|
79
|
+
`evaluateDeclaredFailure` 与边查找的顺序——`state-machine.ts` 的语义变更,另开 follow-up。
|
|
80
|
+
本契约选择让状态机自己说话,不在工具面里再造一个「这条边合法吗」的判定点。
|
|
81
|
+
|
|
82
|
+
**已知边界(有意,不改)**:`terminalReason: "parent_decision"` 在
|
|
83
|
+
`evaluateDeclaredFailure` 里是无条件放行的(`src/legion/state-machine.ts`,本契约不动它),
|
|
84
|
+
并且那次判定发生在边查找**之前**。因此 `failed` 边自身携带的 `require` 条件**不**参与
|
|
85
|
+
判定——这正是「判负」的含义:条件恰恰是失败掉的东西。`actorRole` 不同,它管的是
|
|
86
|
+
「谁有权裁决」,所以仍然生效。
|
|
87
|
+
|
|
88
|
+
**已知边界(有意,保留):声明判负不收割子节点,且它必须永远可用**。`planning` / `reviewing` /
|
|
89
|
+
`verifying` 这些相位**没有** phase 级 `require`,所以即使还有一个 `status = "running"` 的
|
|
90
|
+
直接子节点,`to:"failed"` + `reason` 依然会被接受;那个子节点**保持 `running`**——判负只改本
|
|
91
|
+
团队自己的相位,不去 `stop` / `retire` 任何人(子节点的进程归父级持有,只有父级能收)。
|
|
92
|
+
|
|
93
|
+
这不是遗漏,而是这条口子必须**永远可用**的理由本身:若给判负加一条「子节点必须 settled」之类
|
|
94
|
+
的守卫,一个永不结束的子节点(卡死的工具调用、丢掉的 backend、被宿主挂起的 session)就会
|
|
95
|
+
让团队连「认输」都做不到——#309 要消灭的死锁会原样搬到下一个相位,且更坏:那里连
|
|
96
|
+
`to:"failed"` 都没有替代方案。所以收尾的责任被**显式**留在父级:父级调
|
|
97
|
+
`subagent({ action: "stop" })` 收掉仍在 `running` 的子节点;判负本身只负责让团队不再假装
|
|
98
|
+
还在推进。`test/unit/legion-phase-exit.test.ts` 有一条用例钉住现行为(`reviewing` + 一个
|
|
99
|
+
`running` 的 reviewer → 判负被接受、相位变 `failed`、该 child 仍 `running`)。
|
|
100
|
+
|
|
101
|
+
### D2 — 让 `handled` 有第二个诚实生产者:显式 `supersedes`
|
|
102
|
+
|
|
103
|
+
`legion_phase` 新增可选入参 `supersedes?: string[]`,每项是本团队的**直接子节点**
|
|
104
|
+
`name` 或 `id`。校验三条,任一不满足即拒绝(文案点名是哪一项):
|
|
105
|
+
|
|
106
|
+
1. 每项都能解析成本团队的直接子节点;
|
|
107
|
+
2. 该子节点 `status === "failed"`;
|
|
108
|
+
3. 团队里**至少有一个 `status === "settled"` 的直接子节点**。
|
|
109
|
+
|
|
110
|
+
通过后把这些 id **并集**进 `failedHandledIds`,并在接受路径上写一条
|
|
111
|
+
`node_handled` 持久事件(`data = { handledId, byNodeId, reason? }`,`reason` 取自 `note`,不取
|
|
112
|
+
`reason`——后者是团队判负的理由,不是单个失败的审计注解)。空数组等价于
|
|
113
|
+
没传:不校验、不写事件、不改并集。节点表不加列,`reworkCount` 语义与
|
|
114
|
+
`updatedAt < phaseSince` 的时间比较规则一个字都不改。
|
|
115
|
+
|
|
116
|
+
#### 为什么 `supersedes` 必须要求一个 settled 兄弟
|
|
117
|
+
|
|
118
|
+
`handled` 这个状态的**语义**是「这个失败已经被处理掉了」,不是「这个失败被一笔勾销」。
|
|
119
|
+
`settled` 兄弟是这句话唯一可验证的证据:有人接着把活干了。少了这一条,`supersedes`
|
|
120
|
+
就退化成一句「我说它没事」,`children_none_failed` 变成可以随口清空的关卡——那正是
|
|
121
|
+
`children_all_failed` 那条边试图防的事,只是换了个方向。
|
|
122
|
+
|
|
123
|
+
同一理由决定了 `children_all_failed` 不能被 `supersedes` 绕开:全部失败时没有
|
|
124
|
+
settled 兄弟,第 3 条校验直接不成立,机器照旧强制判负(`all_children_failed`)。
|
|
125
|
+
两条路互不侵占。
|
|
126
|
+
|
|
127
|
+
**已知边界(有意,不改)**:第 3 条是**团队级**的——一个 settled 兄弟可以给任意多个
|
|
128
|
+
失败节点作证,且不要求它比失败节点更新。这是冻结规则原样落地的结果,不是实现偏差;
|
|
129
|
+
要收紧成「每个被 supersede 的失败都要有自己的替代者」需要先改这条规则(`settled` 是否
|
|
130
|
+
等价于「成功」本身就是另一个契约问题),不在本次范围。
|
|
131
|
+
|
|
132
|
+
#### 为什么 `handled` 不持久化
|
|
133
|
+
|
|
134
|
+
现有 `handledIds` 是**每次请求时现算**的(`reworkCount > 0` + `updatedAt < phaseSince`),
|
|
135
|
+
不是账本里的一个布尔位。保持这个形状有三个具体好处:
|
|
136
|
+
|
|
137
|
+
- **没有第二个真相**:入列/出列不需要在两处同步,也就不存在「表里说 handled、条件里
|
|
138
|
+
说没 handled」的漂移;
|
|
139
|
+
- **rework 后自动失效**:`updatedAt < phaseSince` 让上一轮的「已处理」在新一轮里自动
|
|
140
|
+
作废——失败的 child 一旦被 retry 成功,它就不再是失败,也就无需谁去撤销标记;
|
|
141
|
+
- **schema 冻结**:`nodes` 表 19 列由 `test/unit/legion-schema-sentinel.test.ts` 守着
|
|
142
|
+
(`docs/legion-v2-contract.md` D12)。加一列 = 动账本合约,代价远大于收益。
|
|
143
|
+
|
|
144
|
+
`node_handled` 事件是**审计**(谁在什么时候把哪个失败认定成已处理),不是**状态**。
|
|
145
|
+
读 `handledIds` 的代码一行都不读这个事件——它的存在不改变任何判定。
|
|
146
|
+
|
|
147
|
+
**「审计可查」是有保留期限的**:`node_handled` 与 `phase_change` **同级**,不享受
|
|
148
|
+
`src/runs/hygiene.ts` 的 `RETAINED_EVENT_TYPES` 豁免(该名单只有 `orphan_detected` /
|
|
149
|
+
`budget_refused` / `node_abandoned`)。所以团队进入终态(`settled` / `failed`)后,这些行会在
|
|
150
|
+
`eventRetentionDays`(默认 30 天)之后被删,或在 `eventMaxRows`(默认 200 000 行)压力下
|
|
151
|
+
按最旧优先被删。它与 `phase_change` 同寿:够用来回答「这次迁移当时凭什么被放行」,但**不是**
|
|
152
|
+
永久凭据——需要长期留存的证据应落在 MR / 契约文件 / 任务摘要里,而不是指望账本。
|
|
153
|
+
|
|
154
|
+
#### 与 `updatedAt < phaseSince` 的关系
|
|
155
|
+
|
|
156
|
+
两个来源是**并集**,各自独立:
|
|
157
|
+
|
|
158
|
+
| 来源 | 归谁 | 何时进 `handledIds` |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| rework 期间已修复的失败 | 机器自动 | `reworkCount > 0` 且 `child.updatedAt < phaseSince` |
|
|
161
|
+
| 显式 `supersedes` | centurion 主动 | 本次请求带 `supersedes`,且三条校验通过 |
|
|
162
|
+
|
|
163
|
+
- `supersedes` **不**放宽时间规则:它不把「`updatedAt` 晚于 `phaseSince` 的失败」变成
|
|
164
|
+
已处理,只是**额外**给出一个由 centurion 署名、带 settled 兄弟作证的来源。
|
|
165
|
+
- 反过来,`updatedAt < phaseSince` **不**因为它存在就自动关闭校验:只要本次请求带了
|
|
166
|
+
`supersedes`,第 1/2/3 条校验照跑(写进账本的每一条 `node_handled` 都必须成立)。
|
|
167
|
+
- 并集去重:两个来源合起来之后不再重复。注意这一层**不可观测**(消费方
|
|
168
|
+
`children_none_failed` 自己先 `new Set` 再 `has`),所以它只是防御;**真正可验收、也有测试
|
|
169
|
+
守住的是 `supersededIds` 自身的去重**(重复 entry 只产生一条 `node_handled`)。不要把那个
|
|
170
|
+
`new Set` 当作可测试条款。
|
|
171
|
+
|
|
172
|
+
### D3 — 拒绝文案必须列出真实可达的动作
|
|
173
|
+
|
|
174
|
+
`children_none_failed` 失败时的文案(`describeFailedCondition`)改成同时给出两条
|
|
175
|
+
**今天真的可调用**的路:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
a child failed and must be handled: pass supersedes:[<failed child>] once a replacement has settled, or declare to:"failed" with a reason to fail the team
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
旧文案(`a child failed and must be reworked or the team must fail`)建议的两个动作在
|
|
182
|
+
今天的代码里**都不可达**:`implementing` 没有 rework 边,`to:"failed"` 被工具面硬拦。
|
|
183
|
+
一句「按它做就一定会再被拒」的提示,比没有提示更贵——模型会照着它重试到预算烧完。
|
|
184
|
+
|
|
185
|
+
### D4 — 错误码卫生
|
|
186
|
+
|
|
187
|
+
- 新增 `ErrorCodes.CHILD_TERMINAL`;`orchestrator.ts` 里两处
|
|
188
|
+
`legion child is terminal and cannot be resumed` 不再冒充 `BUDGET_EXCEEDED`
|
|
189
|
+
(它们与预算无关,冒用会让上层把「终态」误判成「预算耗尽」)。
|
|
190
|
+
- `index.ts` 的 `reviveChild` catch 不再把所有错误一律改写成 `START_FAILED`:
|
|
191
|
+
`SubagentError` 且已有明确 `code` 时**保留该 code**,其余维持 `START_FAILED`。
|
|
192
|
+
- 错误码**只走 `details.error`**,不拼进用户可见正文:`test/unit/teams.test.ts` 与
|
|
193
|
+
`test/integration/launch-queue.test.ts` 都钉了「正文不得出现 `BUDGET_EXCEEDED`」。
|
|
194
|
+
|
|
195
|
+
## 为什么不用自环 `implementing → implementing`
|
|
196
|
+
|
|
197
|
+
自环是这个问题最直觉的解法,也是**唯一被机器明确禁止**的解法:
|
|
198
|
+
|
|
199
|
+
- validate 期 `validatePhaseMachine` 拒自环;
|
|
200
|
+
- 运行期 `evaluateDeclarative` 以 `same_phase` 拒绝(`from === to`)。
|
|
201
|
+
|
|
202
|
+
更重要的是语义:自环会把「重新派工一轮」编码成一次相位迁移,而 `reworkCount` 的自增
|
|
203
|
+
是挂在 `rework: true` 的**边**上的。让自环兼作 rework 边,等于把「同一相位重来」和
|
|
204
|
+
「预算计数」绑在一起——`updatedAt < phaseSince` 这条时间规则随即失真(`phaseSince`
|
|
205
|
+
被自环刷新,上一轮记下的失败会因为时间戳比较翻转而凭空变成「未处理」)。要凑出的
|
|
206
|
+
效果是「已处理的失败不再挡路」,那就直接、显式地说「已处理」,不要绕道去动迁移图。
|
|
207
|
+
|
|
208
|
+
因此本契约**不动** `src/legion/phase-machine.ts` 的边表:没有新边,没有新相位,
|
|
209
|
+
`test/unit/legion-state-machine.test.ts` 的 §7.1 矩阵与 19 条原因表原样绿。
|
|
210
|
+
|
|
211
|
+
## `node_handled` 与事件闭集
|
|
212
|
+
|
|
213
|
+
`appendEvent` 按闭集 `LEGION_EVENT_TYPES` 校验,`mapEvent` 遇到不认识的存量类型会抛错
|
|
214
|
+
——这正是 #233 的游标冻结事故。所以 `node_handled` 必须**加入闭集**,并同步修订两处
|
|
215
|
+
把该闭集内容钉死的清单断言(`test/unit/legion-events.test.ts` 的条数、
|
|
216
|
+
`test/unit/teams.test.ts` 的 `#266 A13` stored closed set)。**这是清单条数的机械更新,
|
|
217
|
+
不是为了让红灯变绿**:写入方与读取方仍在构造上一致,事故模式没有被重新引入。
|
|
218
|
+
`LEGION_PROCESS_EVENT_TYPES`(仅进程内、不落库)保持 11 条不变。
|
|
219
|
+
|
|
220
|
+
## 验收清单
|
|
221
|
+
|
|
222
|
+
| # | 事实 |
|
|
223
|
+
|---|---|
|
|
224
|
+
| 1 | 1 failed + 1 settled + `reworkCount === 0`,`legion_phase{to:"reviewing"}` 被拒,文案仍是 `children_none_failed` 那句(`a child failed and must be handled…`),相位不动 |
|
|
225
|
+
| 2 | 同上 + `supersedes:[<failed name>]` → 放行到 `reviewing`,且账本里多一条 `node_handled` |
|
|
226
|
+
| 3 | `supersedes` 指向 settled 兄弟 / 不存在的名字 → 拒绝并点名该 entry,相位不动 |
|
|
227
|
+
| 4 | `legion_phase{to:"failed", reason:"…"}` → `phase === "failed"`,事件带 `terminalReason: "parent_decision"` |
|
|
228
|
+
| 5 | 缺 `reason` 的 `to:"failed"` 被拒;`to:"done"` / `to:"aborted"` 仍被拒 |
|
|
229
|
+
| 6 | `children_none_failed` 的拒绝文案同时含 `supersedes` 与 `to:"failed"` |
|
|
230
|
+
| 7 | 终态 resume 的错误码是 `CHILD_TERMINAL`,且工具正文不含 `BUDGET_EXCEEDED` |
|
|
231
|
+
| 8 | `src/legion/phase-machine.ts`、`nodes` 表 19 列、§7.1 矩阵、19 条原因表**均未改动** |
|
|
232
|
+
| 9 | 卡声明的机器在当前相位没有 `failed` 边(或该边 `actorRole` 保留给其他角色)时,`to:"failed"` 带 `reason` 仍被拒——工具面不替它开特例 |
|
|
233
|
+
| 10 | `reworkCount > 0` + `updatedAt < phaseSince` 这半个来源单独可证:无 `supersedes` 也能放行,且不写 `node_handled`(自动推导不是署名决定) |
|
|
234
|
+
| 11 | 声明判负不收割子节点:`reviewing` + 一个 `running` 的 reviewer,`to:"failed"` + `reason` 仍被接受,相位变 `failed`,该 child 仍 `running`(收尾归父级的 `stop`) |
|
|
235
|
+
| 12 | 声明判负会**给父级发一封邮件**(`from = 本团队`,正文与工具回执同源 `Team failed by centurion decision: <reason>`) |
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 进程树清扫 `sweepProcessTree`(v1,冻结)
|
|
2
|
+
|
|
3
|
+
> 语义由 #310 冻结(`Closes #310`,PR #334)。实现期发现矛盾 → 停下报告,不得重新解释本契约。
|
|
4
|
+
> **本文件是 #310 的 D 编号命名空间**:代码与测试注释里的 `#310 review Fn` 指的是本 PR
|
|
5
|
+
> 第一轮对抗式 review 的 finding 编号(F1–F8),与本文件的 D1–D7 无关。
|
|
6
|
+
|
|
7
|
+
## 背景
|
|
8
|
+
|
|
9
|
+
两条命令执行点(`src/runs/acceptance.ts` 的 `runVerifyCommand`、`src/extension/legion-tools.ts`
|
|
10
|
+
的 `runLegionCommandNow`)都 spawn 一个 shell:`bash -lc ...`,或 `timeout ... bash -lc ...`。
|
|
11
|
+
杀掉这个直接子进程只回收 shell 本身——它 fork 出去的东西(`cmd &`、`node --test`、dev server)
|
|
12
|
+
会被 reparent 到 init 继续跑:一次真实的 `node --test` 在它的 run 结束后 44 分钟仍然活着
|
|
13
|
+
(`ppid=1`)。
|
|
14
|
+
|
|
15
|
+
`src/shared/proc-sweep.ts` 是唯一的清扫原语:两个执行点都 `detached: true`(自己的进程组),
|
|
16
|
+
在**我们自己结束命令**的路径上清扫整棵树,然后再 settle 结果。
|
|
17
|
+
|
|
18
|
+
## 冻结决定
|
|
19
|
+
|
|
20
|
+
### D1 — 监视集包含 root 自己
|
|
21
|
+
|
|
22
|
+
`sweepProcessTree(rootPid)` 的监视集是 `{rootPid} ∪ 从 procfs 枚举到的后代`。
|
|
23
|
+
|
|
24
|
+
root 必须在内:它是调用方**真正 spawn 的那个进程**,而且在 shell `exec` 进一个应用之后,
|
|
25
|
+
它**就是唯一的进程**。只监视后代会退化成一个具体的假阳性——root 装了 `SIGTERM` handler
|
|
26
|
+
(每个长驻服务的形状)、忽略第一次信号、此刻又没有活着的子进程时:不升级、`survivors` 返回
|
|
27
|
+
`[]`、调用方 `.catch(() => null)` 连结果都不看,于是「超时 → 等 sweep 完成 → resolve」这条契约
|
|
28
|
+
在最要紧的场景里 **resolve 了而进程还活着**(PR #334 review F1)。
|
|
29
|
+
|
|
30
|
+
### D2 — 升级条件:组内仍有活口就发 `SIGKILL`
|
|
31
|
+
|
|
32
|
+
grace 到期后,`SIGKILL` 的判据是「**组内仍有活口**」,不是「有枚举到的 pid 存活」:
|
|
33
|
+
|
|
34
|
+
- 组信号 `kill(-pgid, SIGKILL)` 只要组内还有一个**活着的**成员就发;
|
|
35
|
+
- 判活走 procfs,**僵尸算已死**(和 `isAlive` 同一套定义)。刚被 `SIGTERM` 杀掉、还没被 reap
|
|
36
|
+
的 leader 会让 `kill(-pgid, 0)` 成功,把它当活口会让每次干净的 sweep 都虚报一次 `SIGKILL`;
|
|
37
|
+
- 被 `SIGKILL` 的目标(含只按 pgid 可达、不在枚举里的组员——grace 期间被 reparent 到 init 的
|
|
38
|
+
孙进程)全部纳入**等待死亡**的集合,最多等 `killAfterMs`。否则 promise 会在某个刚挨了
|
|
39
|
+
`SIGKILL`、还在退场途中的进程身上提前 settle,`survivors: []` 就成了猜测。
|
|
40
|
+
|
|
41
|
+
`killed` 的计数口径不变:进程组算一次,之后每个 pid 各算一次。
|
|
42
|
+
|
|
43
|
+
### D3 — 什么算「正常退出」:不杀,只 warn
|
|
44
|
+
|
|
45
|
+
- 直接子进程**自己退出**了(含 `code === 0`)→ 正常退出,`survivingDescendants()` 收集残留,
|
|
46
|
+
只 warn 一行(`[legion] verify command exited leaving N process(es) running: <命令摘要> (pids …)`),
|
|
47
|
+
一个都不杀。`cmd &` 在验证命令里是合法用法。
|
|
48
|
+
- `close` 要等 stdio 全关,所以**未重定向**的 `bash -lc 'sleep 600 &'` 会把 `close` 挂住到
|
|
49
|
+
后台进程结束。此时若直接子进程已经退出(`child.exitCode !== null`),命令就是结束了:按正常
|
|
50
|
+
退出处理(报告残留、用自己的退出码),**不许**因为「管道被占」判超时并杀掉合法后台进程
|
|
51
|
+
(review F3)。warning **只发一次**,后续 `close` 不再重复。
|
|
52
|
+
- `legion-verify` 的真实 deadline 是它**内层**的 `timeout(1)`:它到点会杀掉够得着的组员并以
|
|
53
|
+
GNU timeout 的超时码 **124**(或被转发信号打死)退出。`close` 看到 `code === 124` 或
|
|
54
|
+
`child.signalCode` 非空时,按**超时**处理(清扫整棵树),不是按正常退出只 warn
|
|
55
|
+
(review F2)。内层 `timeout` 保留为二重保险。
|
|
56
|
+
- 清扫之后如果仍有 `survivors`(`EPERM`、容器边界之类),**warn 一行**报出 pid 数与命令摘要,
|
|
57
|
+
不许静默丢弃 sweep 结果(review F5)。
|
|
58
|
+
|
|
59
|
+
### D4 — 预算:超时结果最多延后 `graceMs + killAfterMs` = 2.5s
|
|
60
|
+
|
|
61
|
+
sweep 是异步的,并且 runner 的 promise 会 park 到它完成(D1/D2 的前提)。默认
|
|
62
|
+
`graceMs = 2000`、`killAfterMs = 500`(`DEFAULT_GRACE_MS` / `DEFAULT_KILL_AFTER_MS`),所以
|
|
63
|
+
**超时/中断结果最多比超时点晚 2.5s 才 resolve**,这段时间记在命令**之外**(verify queue 的等待
|
|
64
|
+
不计入 `timeoutMs`,这 2.5s 也不计入——但它确实占用 wall clock)。上层若有总预算门禁,需要把这
|
|
65
|
+
2.5s 算进去。grace 是早退的:第一次信号就把全树打死时不会付满窗口。
|
|
66
|
+
|
|
67
|
+
**补充(二审 minor,不是 2.5s 那个量级)**:D3 里「命令已退出、只是未重定向的 `cmd &` 占着管道」
|
|
68
|
+
那种形状,**卡到 deadline 才 settle**:`close` 要等 stdio 全关,而那个后台进程不到 deadline 不会
|
|
69
|
+
松手(D3 的 `child.exitCode` 判定就是在 deadline 到点那一刻做的)。所以对这条形状而言,最坏延迟
|
|
70
|
+
是 **`timeoutMs` 本身**(fallback timer 是 `limit + 1s`),不是 `graceMs + killAfterMs`。两者不要
|
|
71
|
+
混淆:2.5s 是「我们已经判超时之后,清扫还要多久」,`timeoutMs` 是「命令明明早就结束了,结果还
|
|
72
|
+
要等多久」。上限:`timeoutMs`(未重定向 `cmd &` 且永不退出)与 `graceMs + killAfterMs`(超时后
|
|
73
|
+
的清扫)取其一,不叠加。
|
|
74
|
+
|
|
75
|
+
### D5 — 平台降级
|
|
76
|
+
|
|
77
|
+
- **非 Linux**:没有 procfs,`enumerateDescendants` 与 `groupMembers` 都返回 `[]`,sweep 退化成
|
|
78
|
+
「给进程组发信号」(组仍然是对的,因为调用方 `detached: true`),`ESRCH`/`EPERM` 都是正常
|
|
79
|
+
结局,永不抛错。
|
|
80
|
+
**正常退出路径上的残留 warn 在非 Linux 上恒为空**(`survivingDescendants` 里两条来源都是
|
|
81
|
+
procfs),也就是说 macOS 上「`cmd &` 残留」既不会被杀也不会被报告——这是已知降级,不是 bug。
|
|
82
|
+
- **Windows**:没有进程组可发,直接对 root 发信号(与 #310 之前的行为一致);判活用
|
|
83
|
+
`kill(pid, 0)`(僵尸的语义由平台决定)。
|
|
84
|
+
|
|
85
|
+
### D6 — `signal?: AbortSignal` 目前**没有生产调用者**
|
|
86
|
+
|
|
87
|
+
两个 runner 都接受尾参 `signal`,超时/中断/输出超限三条路径都会先 sweep 再 settle;但
|
|
88
|
+
`src/` 里**没有任何调用点传入它**(`defaultVerifyRunner` 不转发,`legion-verify` 只传三个参数;
|
|
89
|
+
Pi 的 Esc/TUI 中断不经过这两个函数)。所以「**中断**先 sweep」这半条目前是库级能力,端到端没有
|
|
90
|
+
触发者——**中断接线是待办**,不要把它当成已生效的行为写进任何面向用户的地方。传入的
|
|
91
|
+
`AbortSignal` 已经是 aborted 状态时仍然立即按中断处理。
|
|
92
|
+
|
|
93
|
+
### D7 — `rpc-supervisor.ts` 的 retire sweep 不动
|
|
94
|
+
|
|
95
|
+
`src/supervisor/rpc-supervisor.ts` 的 retire 清扫**零改动**,也不应被本原语替换:它只对每个
|
|
96
|
+
后代的 pgid 发 `kill(-<descendant.pgid>)`、从不发 `-rootPid`(RPC 子进程**没有** `detached`,
|
|
97
|
+
和宿主共享进程组,`-rootPid` 会把宿主自己带走),节奏由它自己的 `signalTimeoutMs` 决定,
|
|
98
|
+
并且窗口之后**无条件** `SIGKILL`。本原语是「我们 spawn 的、detached 的命令树」专用。
|
|
99
|
+
|
|
100
|
+
## 验收清单
|
|
101
|
+
|
|
102
|
+
- `npm run typecheck` / `npm test` / `npm run test:integration` 全绿;
|
|
103
|
+
- `test/unit/proc-sweep.test.ts` 覆盖:组内孙进程、`setsid` 逃逸、忽略 `SIGTERM` 的**后代**、
|
|
104
|
+
忽略 `SIGTERM` 且**无子进程的 root**(D1,修前必红)、`ESRCH` 吞掉、正常退出不杀只 warn、
|
|
105
|
+
内层 timeout 的 124 路径(D3)、未重定向 `cmd &` 不判超时(D3)、两个 runner 的超时/中断清扫。
|
|
106
|
+
- 残余风险(已知、本契约不覆盖):`DescendantInfo` 不带 starttime,grace 窗口内按快照 pid
|
|
107
|
+
`SIGKILL` 有 PID 复用风险(由 `fix/pid-reuse-race` 分支处理)。
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Provider 空生成单次重试契约 v1(FROZEN)
|
|
2
|
+
|
|
3
|
+
> 状态:**FROZEN**(#316 设计裁决)。
|
|
4
|
+
> 冲突时**停下报告**,不得重新解释本契约。
|
|
5
|
+
> 关联:#316(provider `finish_reason: error` 空生成)、#309(失败节点成为永久 phase 门)、
|
|
6
|
+
> #232(`waitOutTransientConnection` 的轮询退避)、`docs/contracts/subagent-renewal-v1.md`(renewal 预算)。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## §1 问题陈述
|
|
11
|
+
|
|
12
|
+
provider 可以在 **HTTP 200** 之后在流内终止生成:pi 的 `mapStopReason` 把未知
|
|
13
|
+
`finish_reason` 落到 `{ stopReason: "error", errorMessage: "Provider finish_reason: " + reason }`。
|
|
14
|
+
该 message 同时撞不上上游 `isRetryableAssistantError` 的 `NON_RETRYABLE` 与 `RETRYABLE`
|
|
15
|
+
两套正则,provider 层的 `maxRetries` 也只包住抛出的 HTTP 错误,于是**全链路一次都不重试**。
|
|
16
|
+
|
|
17
|
+
实测签名(#316 报告,两例,分属两个 team 子树、两种角色):
|
|
18
|
+
|
|
19
|
+
| 字段 | 值 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `stopReason` / `rawStopReason` | `error` / `error` |
|
|
22
|
+
| `content` | 空(无 `text`、无 `toolCall`) |
|
|
23
|
+
| `usage.input` | **0** |
|
|
24
|
+
| `usage.output` | 2 / 42(极小) |
|
|
25
|
+
| `durationMs` | 92–175 s(正常轮次 6–41 s) |
|
|
26
|
+
|
|
27
|
+
本仓原有唯一的瞬时兜底是 `isTransientConnectionFailure`(只匹配 `/connection error/i`)+
|
|
28
|
+
`waitOutTransientConnection`,而后者**只轮询 session 文件、从不重发 prompt**:对空生成这一类,
|
|
29
|
+
等待永远不会产生新消息——child 活着且空闲,只有**新的一轮 prompt** 才能把它救回来。
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## §2 D1 — 判据:按 transcript 形状,不猜 provider 字符串
|
|
34
|
+
|
|
35
|
+
`src/shared/session.ts` 导出:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export function isEmptyGeneration(parsed: Pick<ParsedSession, "turns">): boolean;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**四条件全部满足**才算空生成:
|
|
42
|
+
|
|
43
|
+
1. 最后一轮存在 assistant 记录(硬杀死的轮次没有);
|
|
44
|
+
2. 该记录的 `stopReason === "error"`;
|
|
45
|
+
3. `text.trim() === ""`;
|
|
46
|
+
4. `tools.length === 0`。
|
|
47
|
+
|
|
48
|
+
**刻意不看的东西**(写死在这里,避免下次有人"顺手加一条"):
|
|
49
|
+
|
|
50
|
+
- **不看 `usage`**。`usage.input === 0` 只是显示层素材(`src/extension/summary.ts`),
|
|
51
|
+
用它做判定等于把展示层语义升格成控制面契约;报告里它也只是签名的一部分,不是判据。
|
|
52
|
+
- **不匹配 `errorMessage` 文本**。`isTransientConnectionFailure` 那种"撞字符串"的做法正是
|
|
53
|
+
#316 漏网的原因:上游换一句文案就失效。形状判据不受文案影响。
|
|
54
|
+
|
|
55
|
+
`deriveOutcome` 在该形状落到 `failed` 时置 `Execution.errorKind = "provider-empty-generation"`
|
|
56
|
+
(`outcomeFromFinalMessage` 的 `case "error"` 非 abort 分支)。这是它**唯一**的置位点:
|
|
57
|
+
|
|
58
|
+
- `aborted` 永不置位——父子 abort 不是 provider 故障,重发 prompt 会被当成新任务;
|
|
59
|
+
- `success` / `truncated` 永不置位——有产出的失败是"任务失败",不是"这一轮丢了"。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## §3 D2 — collect 内有界重试一次
|
|
64
|
+
|
|
65
|
+
位置:`src/runs/orchestrator.ts` 的 `Orchestrator.collect()`,`deriveOutcome` 之后、
|
|
66
|
+
`finishCollect` 之前。
|
|
67
|
+
|
|
68
|
+
前置条件(**全部**满足才重试):
|
|
69
|
+
|
|
70
|
+
| 条件 | 理由 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `execution.status === "failed"` 且 `errorKind === "provider-empty-generation"` | 只治这一种故障 |
|
|
73
|
+
| `supervisor.isAlive(name)` | 死掉的 child 收不了 prompt |
|
|
74
|
+
| `!signal?.aborted` | abort 不得变成新一轮 |
|
|
75
|
+
| `(child.providerRetriesUsed ?? 0) < 1` | 上限 1 次 |
|
|
76
|
+
|
|
77
|
+
动作:
|
|
78
|
+
|
|
79
|
+
1. `await supervisor.steer(name, CONTINUE_PROMPT)`(review round 1:`prompt` 返回 `void`,
|
|
80
|
+
丢掉了 `dispatch()` 算好的 `SteerDisposition`,无法区分「已送达」与「被 input handler 吞掉」。
|
|
81
|
+
`steer` 对 idle child 与 `prompt` 等价——两个 supervisor 在 `!turnActive` 时都走 `session.prompt`
|
|
82
|
+
并返回 `"accepted"`——但会把 disposition 交回来),`CONTINUE_PROMPT` 逐字为
|
|
83
|
+
`Your previous turn ended with no output. Continue from where you left off; do not restart the task.`
|
|
84
|
+
(child 仍在同一会话里持有原任务上下文,所以是 continue 不是 restart);
|
|
85
|
+
2. 预算在 `await` 之前**乐观占位**:`providerRetriesUsed += 1` 与 gate 读取相邻且同步执行
|
|
86
|
+
(否则并发 collect 可各读一次 0、各发一轮 prompt、计到 2,见 review round 1 minor 5);
|
|
87
|
+
发送抛错、或 `disposition === "intercepted"`(文本被 input handler 吞掉、child 根本没看到,
|
|
88
|
+
`abandonTurn` 已回滚)时**退回**该次占位:原判不变、不写 note,并按
|
|
89
|
+
`src/extension/legion-tools.ts` 处理被吞 mail 的方式留一条 `console.warn`;
|
|
90
|
+
3. 复用 `waitSettled`(经 `awaitSupervisor`,因此仍会尊重 abort 与 UI 阻塞)等这一轮 settle;
|
|
91
|
+
4. 重 `parseSessionFile`,然后走**与首轮完全同一个** outcome 阶梯(`classifyOutcome`:
|
|
92
|
+
(a) connection blip 持有、(b) `gone` + 空 transcript 的 abort、(c) `parentShutdownRetire` 归因、
|
|
93
|
+
(d) 活 child 的 `timeout` → `running` 快照),不得只抄其中几条
|
|
94
|
+
(review round 1 major 1:重试轮以 `Connection error.` 结束——空文本 + `stopReason: error`,
|
|
95
|
+
同时满足 `isEmptyGeneration`——曾被判 `failed` 并让 `shouldRecycleAfterCollect("failed")`
|
|
96
|
+
把活 child 回收,而同一消息出现在首轮时是 `running` 持有)。
|
|
97
|
+
|
|
98
|
+
不变量:
|
|
99
|
+
|
|
100
|
+
- **只重试一次**:计数器在 `ChildRecord` 上,**跨 collect(同进程内)**保持;
|
|
101
|
+
`ChildRecord` 不落盘(无 store 形状可写),进程重启后从 0 开始——重启即新进程、新预算,
|
|
102
|
+
本契约不声称、也不实现跨进程持久化(review round 1 minor 6);
|
|
103
|
+
- 不得 `recordSettled` 两次、不得写两份 `.output.md`:重试发生在 `finishCollect` 之前,
|
|
104
|
+
终局只经过那一个出口;
|
|
105
|
+
- 重试轮的 settle 若是 `timeout` 且 child 仍活着,则沿用既有的
|
|
106
|
+
`running` + `COLLECT_DEADLINE_REASON` 快照(不把"还在生成"说成 abort 或 failed)。
|
|
107
|
+
「活 child + `timeout`」本身就是"这一轮没结束"的充要信号,**不再**额外要求
|
|
108
|
+
"最后一轮尚无 assistant 记录"——prompt 被接受后 transcript 可能仍停在上一轮
|
|
109
|
+
(review round 1 minor 3);
|
|
110
|
+
- D4 的 `(provider returned an empty generation; retried once)` 在**终局处**(`finishCollect`
|
|
111
|
+
入口)按 `providerRetriesUsed > 0 && failed && errorKind === "provider-empty-generation"`
|
|
112
|
+
补齐且幂等,而不是只在重试自己那次 collect 里拼:重试等待返回 `running`、之后才以空生成
|
|
113
|
+
结束时,下一次 collect 因预算已用跳过重试,父级仍能分清"重试过仍失败"与"首次失败"
|
|
114
|
+
(review round 1 minor 4);
|
|
115
|
+
- `renewalsUsed` 一行都不动(见 §5)。
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## §4 D3 — 可观测性
|
|
120
|
+
|
|
121
|
+
- `TurnRecord.assistants[]` 新增可选 `responseId?: string`:上游 assistant 消息自带该字段,
|
|
122
|
+
此前被丢弃。**纯透传**,任何判定都不读它。
|
|
123
|
+
- `recordSettled` 写 `node_failed` 事件时,data 在既有 `{ execution, reason }` 不变的前提下
|
|
124
|
+
追加 `errorKind`(无值时不写该键)。父级与 ledger 查询因此能区分
|
|
125
|
+
"provider 空生成" 与其它 `failed`,不必再读 transcript 猜字符串。
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## §5 为什么上限是 1 次(以及为什么不复用 renewal 预算)
|
|
130
|
+
|
|
131
|
+
**上限 1 次**:故障形态是"一次上游流终止"。同一次 collect 内第二次仍然得到空生成,
|
|
132
|
+
说明 provider 此刻是持续故障而不是偶发——再 prompt 一次要付真实的 token 与墙钟,
|
|
133
|
+
换不回新信息。第二次之后的处置属于父级决策(`continue` / 改名重派 / 人工介入),
|
|
134
|
+
不该由运行时做成自动循环。有界是硬要求:无限重试会让一个坏 provider 变成烧钱循环,
|
|
135
|
+
并且与 #309 的"失败要能被看见"直接冲突——把失败重试到看不见,比一次失败更糟。
|
|
136
|
+
|
|
137
|
+
**不复用 `renewalsUsed`**:
|
|
138
|
+
|
|
139
|
+
- renewal 是**面向用户的预算**(`subagent-renewal-v1.md` §6),授予机制是
|
|
140
|
+
"没有可测进展 → 提议给父级/人",且普通 `failed` 的 collect **刻意不扣费**
|
|
141
|
+
(`test/unit/renewal-wiring.test.ts`)。把自动重试记进 renewal,
|
|
142
|
+
等于让一次 provider 抖动吃掉人给的续命额度。
|
|
143
|
+
- renewal 的语义是**重新开跑**(resume/relaunch,可能换进程、换 worktree 基线),
|
|
144
|
+
而这里要的是同一个活着的 child **继续下一轮**。两者不是同一个动作,共用一个计数器只会
|
|
145
|
+
让两边的账都算不清。
|
|
146
|
+
|
|
147
|
+
因此:独立计数器 `ChildRecord.providerRetriesUsed`,缺省读作 0(#266 时代的老记录同样适用)。
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## §6 刻意不做的事
|
|
152
|
+
|
|
153
|
+
- **不改 `isTransientConnectionFailure` 的语义**:`/connection error/i` 的既有行为与
|
|
154
|
+
`waitOutTransientConnection` 的轮询路径原样保留;它现在在 docstring 里写明
|
|
155
|
+
"只覆盖 connection error,不覆盖 provider 空生成"。
|
|
156
|
+
- **不改 ledger 状态闭集**、不改 `shouldRecycleAfterCollect`
|
|
157
|
+
(`failed` 仍然 `true` → 自动 retire,重试只是把这一判定推迟到重试之后)。
|
|
158
|
+
- **不做无限重试**,不做指数退避重试队列。
|
|
159
|
+
- **不解析 `finishReason`**:本仓不读该字段,判据全部落在 `stopReason` + 内容形状上。
|
|
160
|
+
- **不修上游**(`pi-ai` / `pi-coding-agent` 的 retry 分类):那是另一个仓;本契约为
|
|
161
|
+
legion 侧兜底,契约范围仅限本仓。
|