pi-legion 4.0.0 → 5.0.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 +168 -1
- package/README.md +101 -44
- package/agents/centurion.md +20 -16
- package/agents/scout.md +3 -1
- package/agents/worker.md +5 -7
- package/docs/contracts/declarative-phase-machine-v5.md +128 -0
- package/docs/contracts/subagent-renewal-v1.md +260 -0
- package/docs/headless-child-contract.md +6 -3
- package/docs/inprocess-supervisor.md +70 -24
- package/docs/legion-v2-contract.md +9 -0
- package/docs/spawn-mode-visibility-v1.md +4 -1
- package/index.ts +598 -44
- package/package.json +2 -2
- package/src/agents/agents.ts +293 -36
- package/src/agents/overrides.ts +3 -1
- package/src/agents/settings.ts +6 -18
- package/src/extension/child-guard.ts +25 -1
- package/src/extension/legion-tools.ts +66 -21
- package/src/extension/notify.ts +136 -4
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +56 -1
- package/src/extension/summary.ts +13 -4
- package/src/extension/timing.ts +8 -4
- package/src/legion/phase-conditions.ts +164 -0
- package/src/legion/phase-machine.ts +403 -0
- package/src/legion/state-machine.ts +219 -115
- package/src/runs/args.ts +32 -18
- package/src/runs/extension-inheritance.ts +220 -0
- package/src/runs/orchestrator.ts +137 -44
- package/src/runs/progress-signal.ts +137 -0
- package/src/runs/store-lookup.ts +270 -0
- package/src/shared/types.ts +88 -3
- package/src/supervisor/child-context.ts +19 -0
- package/src/supervisor/inprocess-supervisor.ts +29 -5
- package/src/supervisor/routing-supervisor.ts +2 -1
- package/src/supervisor/rpc-supervisor.ts +2 -2
- package/src/tui/tree-view.ts +56 -0
- package/src/tui/viz/palette.ts +0 -3
- package/agents/advisor.md +0 -110
- package/agents/designer.md +0 -87
- package/agents/planner.md +0 -53
- package/agents/prototype.md +0 -72
- package/agents/reviewer.md +0 -113
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Declarative phase machine (v5)
|
|
2
|
+
|
|
3
|
+
Status: draft, layer 3 (#298).
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
The legion phase machine is hard-coded in three places that must agree:
|
|
8
|
+
|
|
9
|
+
| Place | Hard-codes |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `src/legion/state-machine.ts:1` | The seven phase names and every transition rule |
|
|
12
|
+
| `src/agents/settings.ts:117` | A duplicate copy of the phase names, for `phaseTimeouts` validation |
|
|
13
|
+
| `src/extension/legion-tools.ts:47` | The `legion_phase` tool's `to` enum |
|
|
14
|
+
|
|
15
|
+
Worse, the *rules inside a phase* are keyed on **role names**:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
implementing → reviewing requires every `role === "worker"` to be settled
|
|
19
|
+
reviewing → verifying requires a settled `role === "reviewer"` with verdict ok:true
|
|
20
|
+
planning → implementing requires no `role === "planner"` still running
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
So a team cannot re-shape its workflow without editing source, and a role that
|
|
24
|
+
merely *exists* acquires workflow meaning. "worker" is not "a worker" — it is a
|
|
25
|
+
specific slot in a fixed pipeline.
|
|
26
|
+
|
|
27
|
+
## Goal
|
|
28
|
+
|
|
29
|
+
A centurion declares its team's phases and the conditions for leaving them.
|
|
30
|
+
Defaults preserve today's behaviour exactly, so an undeclared machine is the
|
|
31
|
+
four-phase pipeline above.
|
|
32
|
+
|
|
33
|
+
## Non-goals
|
|
34
|
+
|
|
35
|
+
- Arbitrary scripting. Conditions are a closed vocabulary of predicates, not code.
|
|
36
|
+
- Per-node machines. One machine per team root; children inherit it read-only.
|
|
37
|
+
- Changing how children are spawned. Phases gate *progress*, not dispatch.
|
|
38
|
+
|
|
39
|
+
## Where the declaration lives
|
|
40
|
+
|
|
41
|
+
Role frontmatter, on the team root card:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
---
|
|
45
|
+
name: centurion
|
|
46
|
+
phases:
|
|
47
|
+
- id: planning
|
|
48
|
+
require: [contract_file]
|
|
49
|
+
next: [implementing]
|
|
50
|
+
timeoutMs: 900000
|
|
51
|
+
- id: implementing
|
|
52
|
+
require: [children_settled]
|
|
53
|
+
next: [reviewing, failed]
|
|
54
|
+
- id: reviewing
|
|
55
|
+
require: [verdict_ok]
|
|
56
|
+
next: [verifying, implementing]
|
|
57
|
+
- id: verifying
|
|
58
|
+
require: [verification_passed]
|
|
59
|
+
next: [done]
|
|
60
|
+
---
|
|
61
|
+
```text
|
|
62
|
+
|
|
63
|
+
Kept on the card rather than in `settings` because the machine is a property of
|
|
64
|
+
*this team*, like its roster — two centurions in one settings file may run
|
|
65
|
+
different workflows.
|
|
66
|
+
|
|
67
|
+
Resolution order: card `phases` → `settings.legion.phases` → built-in default.
|
|
68
|
+
|
|
69
|
+
## Condition vocabulary
|
|
70
|
+
|
|
71
|
+
A condition answers "may this node leave phase X for phase Y?" over a snapshot of
|
|
72
|
+
the node and its children. Closed set, each with one meaning:
|
|
73
|
+
|
|
74
|
+
| Condition | True when |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `contract_file` | `contract_path` is set and the file exists and is non-empty |
|
|
77
|
+
| `children_settled` | every child of this node is terminal (settled/failed/retired) |
|
|
78
|
+
| `children_none_failed` | no child is `failed`, or every failed child is listed in `handled` |
|
|
79
|
+
| `verdict_ok` | some settled child's last turn parsed to `{ok: true}` |
|
|
80
|
+
| `verdict_not_ok` | some settled child's last turn parsed to `{ok: false}` |
|
|
81
|
+
| `verification_passed` | the node's stored verification result is `pass` |
|
|
82
|
+
| `verification_failed` | the node's stored verification result is `fail` |
|
|
83
|
+
| `rework_available` | `rework_count < maxRework` |
|
|
84
|
+
| `children_all_failed` | there is ≥1 child and all are `failed` |
|
|
85
|
+
| `always` | unconditional |
|
|
86
|
+
|
|
87
|
+
`require` is a conjunction: all listed conditions must hold.
|
|
88
|
+
`next` is the allowed successor set; a transition to anything outside it is
|
|
89
|
+
refused with `invalid_transition`.
|
|
90
|
+
|
|
91
|
+
**Role names do not appear anywhere.** `verdict_ok` asks "did any settled child
|
|
92
|
+
return ok:true", not "did the reviewer". That is what lets `worker` become a
|
|
93
|
+
general worker.
|
|
94
|
+
|
|
95
|
+
## Terminal phases
|
|
96
|
+
|
|
97
|
+
Exactly three are terminal and are always available regardless of `next`:
|
|
98
|
+
|
|
99
|
+
- `done` — only when the phase's `require` holds
|
|
100
|
+
- `failed` — parent decision, all children failed, or rework exhausted
|
|
101
|
+
- `aborted` — parent retire only
|
|
102
|
+
|
|
103
|
+
## Safety rails (unchanged)
|
|
104
|
+
|
|
105
|
+
These are invariants, not configuration:
|
|
106
|
+
|
|
107
|
+
- A new team starts in the machine's **first** phase.
|
|
108
|
+
- `rework_count` still caps at `maxRework` (default 2).
|
|
109
|
+
- `centurion`-only gates stay: a child cannot advance the team.
|
|
110
|
+
- Phase timeouts still only *report*; they never kill work.
|
|
111
|
+
- A declared machine is validated at load time. Unknown condition, empty `next`,
|
|
112
|
+
duplicate id, unreachable phase, or a terminal phase used as a start → the card
|
|
113
|
+
is rejected (same posture as unknown frontmatter).
|
|
114
|
+
|
|
115
|
+
## Migration
|
|
116
|
+
|
|
117
|
+
The default machine reproduces today's table, so no behaviour change without a
|
|
118
|
+
declaration. The role-name predicates become condition predicates:
|
|
119
|
+
|
|
120
|
+
| Old rule | New condition |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| all `worker` settled before reviewing | `children_settled` |
|
|
123
|
+
| settled `reviewer` with ok:true | `verdict_ok` |
|
|
124
|
+
| no `planner` still running | covered by `children_settled` |
|
|
125
|
+
| verification pass before done | `verification_passed` |
|
|
126
|
+
|
|
127
|
+
Once this lands, `planner` and `reviewer` stop being load-bearing and their
|
|
128
|
+
bundled cards can be deleted.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# 子 agent 续约契约 v1(FROZEN)
|
|
2
|
+
|
|
3
|
+
> 状态:**FROZEN**(2026-10-24 用户提出"能不能加个续约的能力")。
|
|
4
|
+
> 冲突时**停下报告**,不得重新解释本契约。
|
|
5
|
+
> 关联:#270(本契约跟踪号)。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §1 问题陈述
|
|
10
|
+
|
|
11
|
+
用户原话:
|
|
12
|
+
|
|
13
|
+
> 能不能加个续约的能力,比如上层 agent 看到超时了,可以看看他实际有没有推进,
|
|
14
|
+
> 如果实际推进了,他可以选择续约,让任务继续,而不是丢弃,
|
|
15
|
+
> 不然 worker 的工作浪费了
|
|
16
|
+
|
|
17
|
+
### 实际发生的事(已确证)
|
|
18
|
+
|
|
19
|
+
一天之内两次:
|
|
20
|
+
|
|
21
|
+
| 事件 | 结果 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `W2`(`runtime.ts` 的 D3 投影改造) | 30 分钟耗尽,**零产出落盘** |
|
|
24
|
+
| `W4`(TUI 五文件) | 代码写完,卡在验证/报告;**已找到的编译错误被它自己 revert 掉了**(为了守文件归属纪律) |
|
|
25
|
+
|
|
26
|
+
两者都不是"做错了",而是**在正确的路径上被切断**。
|
|
27
|
+
|
|
28
|
+
### 关键事实(侦察确证,带证据)
|
|
29
|
+
|
|
30
|
+
1. **session 文件完整保留。** `.pi-subagents/runs/r-491ceace/worker-3.jsonl`
|
|
31
|
+
有 271 行 / 1.4MB,最后一条是 `thinking` —— 说明它是**在思考或工具执行中途**被切断的。
|
|
32
|
+
2. **续接机制已经存在。** `index.ts:1844-1861` 的 `reviveChild`:
|
|
33
|
+
```ts
|
|
34
|
+
if (supervisor.isAlive(name)) {
|
|
35
|
+
await orchestrator.steer(name, params.message); // 活着:追加消息
|
|
36
|
+
}
|
|
37
|
+
const handle = await orchestrator.launch({ agent, task, name, ... }); // 死了:复用 session
|
|
38
|
+
// → "Context preserved from <sessionFile>"
|
|
39
|
+
```
|
|
40
|
+
3. **两种超时必须区分**(这是 §3 D1 的基础):
|
|
41
|
+
- `wait { timeoutMs }` / `collect { timeoutMs }`(`index.ts:1768`, `:2616`)
|
|
42
|
+
→ **父侧放弃等待,不杀 child**。`inprocess-supervisor.ts:1084` 只 `resolve({settled:false, reason:"timeout"})`。
|
|
43
|
+
- 角色 `timeoutMs`(`agents/worker.md:9` 等)
|
|
44
|
+
→ 由 **pi 宿主**执行,child 被终止。pi-legion 源码里**没有** `terminated` 这个字面量
|
|
45
|
+
(全仓 grep 只命中 `src/shared/session.ts:912` 的 "terminated during tool call" 文案)。
|
|
46
|
+
|
|
47
|
+
**结论:pi-legion 无法阻止宿主终止 child,但能在终止后识别、评估、续约。** 契约按此边界设计。
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## §2 根因(为什么现在做不到)
|
|
52
|
+
|
|
53
|
+
### R1 — 超时终止被当成"失败",没有分类
|
|
54
|
+
|
|
55
|
+
`execution: failed (terminated)` 在展示层与其他失败混在一起。
|
|
56
|
+
父会话看到的是一条"失败通知",没有"这个 child 被超时切断、其 session 可续"的语义。
|
|
57
|
+
|
|
58
|
+
### R2 — 没有"推进度"概念
|
|
59
|
+
|
|
60
|
+
要判断"该不该续约",必须回答"这 30 分钟他是在干活还是卡死"。现有信号分散:
|
|
61
|
+
|
|
62
|
+
| 信号 | 位置 | 能回答 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `lastJsonlEventAgeMs` | `runtime.ts` | 最近一次活动距今多久 |
|
|
65
|
+
| `progressFromSessionFile` | `src/shared/progress.ts` | 解析出的进度摘要 |
|
|
66
|
+
| `NodeActivity{turn,tools,turnStartedAt}` | `runtime.ts` | 第几轮、用过什么工具 |
|
|
67
|
+
| `COLLECT_DEADLINE_REASON` | `orchestrator.ts:280` | 父侧放弃等待(≠ child 死) |
|
|
68
|
+
|
|
69
|
+
但**没有任何一个**被用来回答"该不该续约"。
|
|
70
|
+
|
|
71
|
+
### R3 — `wait`/`collect` 超时与 child 终止被混为一谈
|
|
72
|
+
|
|
73
|
+
`inprocess-supervisor.ts:1084` 的超时只表示"我没等到",child 可能还在跑。
|
|
74
|
+
把它当成 child 失败会导致**错误地重派**(浪费)或**错误地放弃**(丢失)。
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## §3 决策
|
|
79
|
+
|
|
80
|
+
### D1 — 只对"真终止 + 有推进"提议续约
|
|
81
|
+
|
|
82
|
+
续约提议的**必要且充分条件**:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
1. child 确实被终止(不是父侧 wait/collect 超时)
|
|
86
|
+
2. 自上次续约点以来有可观测推进
|
|
87
|
+
3. 该角色允许续约(见 D5)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
任一条不满足 → **不提议**,退回现有行为。
|
|
91
|
+
|
|
92
|
+
**理由**:无推进的续约只是把卡死延长一个预算周期,还占着并发槽。
|
|
93
|
+
这正是角色 `timeoutMs` 存在的意义。
|
|
94
|
+
|
|
95
|
+
### D2 — 推进度用"产出信号",不用"存活时间"
|
|
96
|
+
|
|
97
|
+
**判据(按可靠度)**:
|
|
98
|
+
|
|
99
|
+
| 优先级 | 信号 | 判读 |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| 1 | session JSONL 中**新增**的 `toolResult` 行数 | 真做事的最强证据(工具有返回) |
|
|
102
|
+
| 2 | session JSONL 中**新增**的 `assistant` 消息数 | 有产出(写了代码/报告) |
|
|
103
|
+
| 3 | 文件系统副作用(工作树内改动数) | 直接证据,但读盘贵 |
|
|
104
|
+
| 4 | `lastJsonlEventAgeMs` 变小 | **不采用**:只说明有 token 流动 |
|
|
105
|
+
|
|
106
|
+
**明确排除**:进程存活、CPU 时间、token 消耗 —— 这些在"卡在一个失败循环里"时同样增长。
|
|
107
|
+
|
|
108
|
+
**阈值**(D2-a,2026-10-24 父会话裁决,修订本节初稿):
|
|
109
|
+
- 自上次续约点起,新增 **`toolResult` 行数 ≥ 3** → 视为有推进
|
|
110
|
+
- 或工作树内新增/修改文件 ≥ **1** → 视为有推进
|
|
111
|
+
- `assistant` 消息**只统计展示,不参与判定**
|
|
112
|
+
|
|
113
|
+
**为什么只有 toolResult 计数、assistant 不计数**:初稿写的是「`toolResult` + `assistant`
|
|
114
|
+
合计 ≥ 3」,但 `assistant` 在死循环里会无限增长 —— 模型反复说同一句话也算 "推进",
|
|
115
|
+
而那恰恰是最需要**不**续约的情形。`toolResult` 意味着**一个工具真的返回了**,是
|
|
116
|
+
"世界被操作过"的证据。因此 assistant 计数保留(给用户看"他产出了多少"),但
|
|
117
|
+
`progressed` 只由 toolResult 与文件改动决定。
|
|
118
|
+
|
|
119
|
+
**为什么是 3**:一次工具调用往返(call + result)+ 一次重试,才够说明"在推进而不是
|
|
120
|
+
卡在第一次失败上"。1 或 2 会把"刚开工就超时"与"稳步推进"混为一谈。
|
|
121
|
+
|
|
122
|
+
### D3 — 续约是**显式选择**,不是自动行为
|
|
123
|
+
|
|
124
|
+
**禁止**自动续约。理由:
|
|
125
|
+
- 自动续约会让"卡死"变成"无限占槽",失去预算的约束力
|
|
126
|
+
- 用户/父会话必须能看见"这个 child 卡了 30 分钟,但确实在推进"这个事实再决定
|
|
127
|
+
|
|
128
|
+
**形式**:超时通知里附带**结构化续约提议**:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
✗ W2 (worker) failed: terminated — turn budget exhausted (30m)
|
|
132
|
+
progressed: 47 tool results, 12 assistant messages, 6 files changed since spawn
|
|
133
|
+
resume: subagent({ action: "continue", name: "W2", message: "<what to continue>" })
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
即:**给出判断依据 + 现成的命令**,让父会话/用户一条命令就能续。
|
|
137
|
+
|
|
138
|
+
### D4 — 续约就是 `continue`,不新造机制
|
|
139
|
+
|
|
140
|
+
**禁止**新增 `renew`/`extend` action。续约 = `subagent({action:"continue", name, message})`。
|
|
141
|
+
|
|
142
|
+
理由:
|
|
143
|
+
- `reviveChild`(`index.ts:1844`)已经实现"复用 session 继续",语义完全匹配
|
|
144
|
+
- 新增 action 会分裂两条等价路径,违反单一事实来源
|
|
145
|
+
|
|
146
|
+
**契约要求的是**:让父会话知道**什么时候值得**发这条命令,并把命令**准备好**。
|
|
147
|
+
|
|
148
|
+
### D5 — 角色可声明是否允许续约
|
|
149
|
+
|
|
150
|
+
新增 frontmatter 字段 `renewable`(可选,默认 `true`):
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
# agents/worker.md
|
|
154
|
+
renewable: true # 默认;超时且有推进时提议续约
|
|
155
|
+
renewable: false # 例如 scout:侦察任务超时说明范围没切好,续约是浪费
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**理由**:侦察类角色超时通常意味着任务本身定义有误(范围太大),
|
|
159
|
+
续约只是把错误延后;而实现类角色超时往往是工程量估计不足,续约是对的。
|
|
160
|
+
|
|
161
|
+
### D5-a — 如何区分「超时终止」与「正常结束后的 continue」(2026-10-24 父会话裁决)
|
|
162
|
+
|
|
163
|
+
`continue`/`resume` 对所有已终态的 child 都可用,但**只有被预算切断的**才该消耗续约额度。
|
|
164
|
+
否则「正常失败了、我重试一次」也会吃掉额度,额度就失去意义。
|
|
165
|
+
|
|
166
|
+
**判据(依可靠度排序,取第一个可用者)**:
|
|
167
|
+
|
|
168
|
+
| 优先级 | 判据 | 位置 | 说明 |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| 1 | `execution.stopReason === "toolUse"` | `src/shared/session.ts:912` | **在工具调用中被切断** — 预算耗尽的典型形状 |
|
|
171
|
+
| 2 | 缺 assistant message("missing reply") | `session.ts:12-13`(F29) | hard kill 不写任何 assistant 行 |
|
|
172
|
+
| 3 | `execution.reason` 含 `terminated` | `session.ts:912` | 宿主标注 |
|
|
173
|
+
|
|
174
|
+
**依据**:`src/shared/session.ts:1-14` 的文件头已经把这套语义测过并写死:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
F29 — "aborted" is never actually written. A graceful interrupt (esc) writes
|
|
178
|
+
error + an errorMessage containing "aborted". A hard kill writes NO
|
|
179
|
+
assistant message at all, so "missing reply" IS the abort signal.
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
即:**被切断的会话在 JSONL 里是可辨识的**,不需要额外记账。
|
|
183
|
+
`shouldRecycleAfterCollect`(`runtime.ts:226`)只区分「终态/非终态」,**不足以**做这个判断。
|
|
184
|
+
|
|
185
|
+
**`stopReason === "stop"` 或 `"length"`** 的正常结束 → 计额度**不**消耗(用户主动重试)。
|
|
186
|
+
|
|
187
|
+
### D6 — 续约预算上限
|
|
188
|
+
|
|
189
|
+
单个 child 的累计续约次数上限 `maxRenewals`(可选,默认 **2**,`0` 表示禁用续约)。
|
|
190
|
+
|
|
191
|
+
理由:给两次重来机会足以覆盖"估计不足",超过则说明任务切分有问题。
|
|
192
|
+
每次续约获得一个**完整**的角色 `timeoutMs`(不累计、不递减)。
|
|
193
|
+
|
|
194
|
+
**满额后**:通知里不再提议,改为明确说明"已达续约上限,请重新切分任务"。
|
|
195
|
+
|
|
196
|
+
### D7 — `wait`/`collect` 超时**绝不**提议续约
|
|
197
|
+
|
|
198
|
+
见 R3。父侧等待超时 ≠ child 失败。此时正确做法是继续 `wait` 或 `collect`
|
|
199
|
+
(child 还活着),不是续约。
|
|
200
|
+
|
|
201
|
+
**验收**:断言这两个超时路径不产生续约提议。
|
|
202
|
+
|
|
203
|
+
### D8 — 推进度基线在续约后重置
|
|
204
|
+
|
|
205
|
+
每次续约成功后,记录新的基线(时间戳 + 当时的行数)。
|
|
206
|
+
下次超时只统计**自基线以来**的新增。
|
|
207
|
+
|
|
208
|
+
**理由**:防止"总共做过很多事"被误判为"刚刚在推进"。
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## §4 实施切分
|
|
213
|
+
|
|
214
|
+
| 单元 | 文件 | 内容 |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| **W1** | `src/runs/progress-signal.ts`(新) | D2 推进度测量:纯函数 + 可注入的文件读取器 |
|
|
217
|
+
| **W2** | `src/shared/types.ts`, `src/agents/{agents,overrides}.ts` | D5 `renewable` + D6 `maxRenewals` 字段解析与默认值 |
|
|
218
|
+
| **W3** | `src/extension/notify.ts`, `src/extension/runtime.ts` | D3 结构化续约提议渲染(仅超时终止路径)+ D7 排除 wait/collect 超时 |
|
|
219
|
+
| **W4** | `index.ts` | D8 基线记录 + 续约计数 + 满额后的文案 |
|
|
220
|
+
| **W5** | `docs/`, `CHANGELOG.md`, `agents/*.md` | 文档与角色默认值 |
|
|
221
|
+
|
|
222
|
+
**文件归属(禁止越界)**:每个 W 只能改自己那一行的文件。
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## §5 验收
|
|
227
|
+
|
|
228
|
+
| 编号 | 断言 | 归属 |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| **A1** | 超时终止 + 有推进 + `renewable` → 通知含续约提议与 `continue` 命令 | W3 |
|
|
231
|
+
| **A2** | 超时终止 + **无**推进 → 不提议(续约只是延长卡死) | W1+W3 |
|
|
232
|
+
| **A3** | `wait`/`collect` 超时(child 仍活)→ **不**提议续约(D7) | W3 |
|
|
233
|
+
| **A4** | `renewable: false` 的角色超时 → 不提议 | W2+W3 |
|
|
234
|
+
| **A5** | 续约计数达 `maxRenewals` → 不再提议,文案改为"请重新切分" | W4 |
|
|
235
|
+
| **A6** | 续约成功后基线重置:仅统计新增,不把历史算进推进度(D8) | W4 |
|
|
236
|
+
| **A10** | 正常 `stopReason: "stop"` 后的 `continue` **不**消耗续约额度;`toolUse` 切断才消耗(D5-a) | W4 |
|
|
237
|
+
| **A7** | 推进度阈值:新增 **3 个 toolResult** 或 1 个文件改动为界;assistant 单独不越线(D2-a) | W1 |
|
|
238
|
+
| **A8** | `renew`/`extend` action **不存在**;续约只走 `continue`(D4) | W3 |
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## §6 迁移影响
|
|
243
|
+
|
|
244
|
+
| 影响 | 处置 |
|
|
245
|
+
|---|---|
|
|
246
|
+
| 现有角色文件缺 `renewable` | 默认 `true`,无需改动 |
|
|
247
|
+
| 超时通知文案变化 | `notify.ts` 现有测试需复核;**只增不改**(保留原有行) |
|
|
248
|
+
| `maxRenewals` 计数存放位置 | 写入 `ChildRecord`(父 run.json),不写账本(与 #266 D3 一致) |
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## §7 禁令
|
|
253
|
+
|
|
254
|
+
1. **禁止**自动续约(D3)。
|
|
255
|
+
2. **禁止**新增 `renew`/`extend` action(D4)。
|
|
256
|
+
3. **禁止**用"进程存活/CPU 时间/token 消耗"判断推进(D2)。
|
|
257
|
+
4. **禁止**在 `wait`/`collect` 超时路径提议续约(D7)。
|
|
258
|
+
5. **禁止**让续约累计超过 `maxRenewals`(D6)。
|
|
259
|
+
6. **禁止**把续约计数写进账本(§6)。
|
|
260
|
+
7. **禁止**因续约而改变角色 `timeoutMs` 本身的语义(每次续约是新的完整预算,不是递增)。
|
|
@@ -33,9 +33,12 @@ applicable decision for the child cwd:
|
|
|
33
33
|
|
|
34
34
|
**Ops:** to load project settings / project extensions in an RPC child, either
|
|
35
35
|
`/trust` that cwd first (writes trust.json) or set `defaultProjectTrust=always`.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
Children inherit host extensions by default (4.0.1), so this matters more than
|
|
37
|
+
it used to: a child loads the parent's discovered extensions unless the card
|
|
38
|
+
subtracts them. Note the two axes are different — inheritance is about *which
|
|
39
|
+
extensions*, trust is about whether a *project* may contribute any at all. A
|
|
40
|
+
child's `--no-extensions` still blocks pi's own project-extension discovery, and
|
|
41
|
+
the inherited list is passed explicitly as `--extension` paths.
|
|
39
42
|
|
|
40
43
|
### Opt-in `--approve` (legion settings)
|
|
41
44
|
|
|
@@ -1,38 +1,48 @@
|
|
|
1
1
|
# In-process AgentSession mode (#197)
|
|
2
2
|
|
|
3
|
-
Default backend for children. **In-process by default**, with
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Default backend for children. **In-process by default**, with `rpc` used when
|
|
4
|
+
the launch needs an OS worktree (`worktree: true`, which only a `centurion` can
|
|
5
|
+
hold — #214).
|
|
6
|
+
|
|
7
|
+
There is no `spawnMode` config field. The backend is derived from the launch:
|
|
8
|
+
|
|
9
|
+
| Launch needs a worktree? | Backend |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| no | in-process |
|
|
12
|
+
| yes | `rpc` |
|
|
13
|
+
|
|
14
|
+
Since only a `centurion` may hold a worktree, only a centurion can reach
|
|
15
|
+
`rpc`; every other role is in-process. A non-centurion card that asks for
|
|
16
|
+
`worktree: true` is demoted to `worktree: false` with a once-per-run warning,
|
|
17
|
+
which also keeps it in-process.
|
|
8
18
|
|
|
9
19
|
## When to use
|
|
10
20
|
|
|
21
|
+
The choice is not per-role — it follows from whether the work needs a worktree.
|
|
22
|
+
|
|
11
23
|
| Mode | Good for | Avoid when |
|
|
12
24
|
|---|---|---|
|
|
13
|
-
| `inprocess`
|
|
14
|
-
| `rpc`
|
|
25
|
+
| `inprocess` | Short, trusted fan-out; lower spawn latency; simpler approval / UI fan-in | Need OS worktree, separate credentials, crash isolation |
|
|
26
|
+
| `rpc` | Long runs, untrusted tasks, need crash / env / cwd isolation, **worktree** | Cold-start cost dominates |
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the worktree — and since #214 that is only `centurion`: every other role is
|
|
20
|
-
downgraded to `worktree: false` with a one-time warning, so it stays
|
|
21
|
-
in-process.
|
|
28
|
+
Both backends enforce the same legion tree budgets (`maxDepth` /
|
|
29
|
+
`maxChildrenPerNode` / `maxActiveNodes`); the `subagents.inprocess.*` pools apply
|
|
30
|
+
on top and the stricter gate wins.
|
|
22
31
|
|
|
23
32
|
**Non-goals:** no OS worktree isolation; no promise of process crash containment; OpenAaaS Go in-process transport is out of scope.
|
|
24
33
|
|
|
25
34
|
## Config
|
|
26
35
|
|
|
27
|
-
|
|
36
|
+
There is no `spawnMode` key to configure — not in frontmatter, not in
|
|
37
|
+
`agentOverrides`. The old field was removed (4.0.1); a card that still declares
|
|
38
|
+
it is now rejected at load time, because unknown frontmatter is a hard error.
|
|
28
39
|
|
|
29
40
|
```yaml
|
|
30
41
|
# agents/scout.md
|
|
31
42
|
---
|
|
32
43
|
name: scout
|
|
33
44
|
description: Fast read-only recon
|
|
34
|
-
|
|
35
|
-
worktree: false # explicit inprocess + worktree: true is still refused
|
|
45
|
+
worktree: false # a non-centurion asking for true is demoted + warned
|
|
36
46
|
---
|
|
37
47
|
```
|
|
38
48
|
|
|
@@ -40,7 +50,7 @@ worktree: false # explicit inprocess + worktree: true is still refused
|
|
|
40
50
|
{
|
|
41
51
|
"subagents": {
|
|
42
52
|
"agentOverrides": {
|
|
43
|
-
"scout": { "
|
|
53
|
+
"scout": { "worktree": false }
|
|
44
54
|
},
|
|
45
55
|
"inprocess": {
|
|
46
56
|
"maxSpawnsPerSession": 32,
|
|
@@ -68,6 +78,39 @@ worktree: false # explicit inprocess + worktree: true is still refused
|
|
|
68
78
|
Never fall back across columns: in-process limits are read from
|
|
69
79
|
`subagents.inprocess.*` only, never from the `legion.*` keys.
|
|
70
80
|
|
|
81
|
+
### Renewing a budget casualty (#270)
|
|
82
|
+
|
|
83
|
+
An agent's `timeoutMs` is enforced by the host, so pi-legion cannot prevent the
|
|
84
|
+
cut — but it can make the loss recoverable. When a child is terminated **and**
|
|
85
|
+
it measurably progressed, the completion notice ends with a ready-made command:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
✗ W2 (worker) failed (terminated during tool call)
|
|
89
|
+
progressed: 47 tool results, 12 assistant messages, 6 files changed
|
|
90
|
+
renew: subagent({ action: "continue", name: "W2", message: "<what to continue>" })
|
|
91
|
+
renewals: 1/2
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Renewal is `continue` — there is no separate action — so the child resumes from
|
|
95
|
+
its own session file with its context intact. It is **never automatic**: an
|
|
96
|
+
auto-renewed stuck child would hold a concurrency slot indefinitely.
|
|
97
|
+
|
|
98
|
+
Two role frontmatter keys control it:
|
|
99
|
+
|
|
100
|
+
| Key | Default | Meaning |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `renewable` | `true` | `false` for roles where a timeout means the task was mis-scoped (a timed-out `scout` is a search that was too broad, not work to finish) |
|
|
103
|
+
| `maxRenewals` | `2` | `0` disables renewal. Each renewal is a **fresh full budget**, never an increment |
|
|
104
|
+
|
|
105
|
+
"Progressed" means ≥ 3 new `toolResult` rows or ≥ 1 changed file since the last
|
|
106
|
+
baseline — `assistant` messages are shown but never count: a model looping on the
|
|
107
|
+
same sentence grows that counter without doing any work. With no progress the
|
|
108
|
+
notice says so instead of offering the command, and once the budget is spent it
|
|
109
|
+
tells you to split the task.
|
|
110
|
+
|
|
111
|
+
`wait`/`collect` timeouts never produce a proposal: those return while the child
|
|
112
|
+
is still alive, which is the opposite situation.
|
|
113
|
+
|
|
71
114
|
### Launch admission queue (#266)
|
|
72
115
|
|
|
73
116
|
A launch that hits a **queueable** gate waits in an in-memory queue instead of
|
|
@@ -119,8 +162,9 @@ a read-only child's write bans, and a parent read-only role wrongly banned a
|
|
|
119
162
|
`writer` child.
|
|
120
163
|
|
|
121
164
|
So in-process identity travels in an `AsyncLocalStorage` child context instead:
|
|
122
|
-
node id, db path, acceptance role, `allowNested`, `utility`, `spawnMode
|
|
123
|
-
|
|
165
|
+
node id, db path, acceptance role, `allowNested`, `utility`, `spawnMode` (the
|
|
166
|
+
arbitrated backend, for self-reporting), the per-child `budget`, and the nested
|
|
167
|
+
parent path. The guards and the legion runtime read that context, and
|
|
124
168
|
only fall back to env for RPC children, whose process really does own its env.
|
|
125
169
|
|
|
126
170
|
Two consequences worth knowing:
|
|
@@ -143,8 +187,8 @@ Do not replace this with env injection for in-process children.
|
|
|
143
187
|
```
|
|
144
188
|
Orchestrator
|
|
145
189
|
→ RoutingSupervisor (LegionSupervisor)
|
|
146
|
-
├─
|
|
147
|
-
└─
|
|
190
|
+
├─ worktree ⇒ rpc → RpcSupervisor (unchanged)
|
|
191
|
+
└─ otherwise → InProcessSupervisor
|
|
148
192
|
createAgentSession + SessionManager.inMemory
|
|
149
193
|
(tools / model / systemPrompt wired)
|
|
150
194
|
```
|
|
@@ -223,12 +267,14 @@ a decode rate.
|
|
|
223
267
|
|
|
224
268
|
## Refusals
|
|
225
269
|
|
|
226
|
-
- `worktree: true` + explicit `inprocess` → `INVALID_PARAMS` (an omitted
|
|
227
|
-
`spawnMode` falls back to `rpc` instead)
|
|
228
270
|
- A non-centurion requesting `worktree: true` is **not** a refusal: it is
|
|
229
271
|
downgraded to `worktree: false` with a one-time warning (#214), so the launch
|
|
230
272
|
proceeds in-process. `worktree` is reserved for `centurion`.
|
|
231
|
-
-
|
|
273
|
+
- `worktree: true` reaching the supervisor with a pinned in-process backend →
|
|
274
|
+
`INVALID_PARAMS`. Unreachable from a card now that the field is gone; kept as
|
|
275
|
+
the last line against an internal caller building that shape.
|
|
276
|
+
- Unknown frontmatter (including a leftover `spawnMode:`) → loud throw at parse
|
|
277
|
+
time
|
|
232
278
|
|
|
233
279
|
## Tests
|
|
234
280
|
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
> 状态:**FROZEN-1**(2026-10-11 CEO 批准冻结;修订必须走 §15 流程)
|
|
4
4
|
> 依据:2026-10-10/11 军团需求对齐(侦察底稿:`r-584dd563` scout-0/scout-1、`r-20cc52a2` scout-2,共 226 条带行号事实;RPC/SQLite/浮窗三项本机实测通过)
|
|
5
5
|
> 修订规则:见 §15。worker 发现契约矛盾时**停下上报**,禁止自行解释。
|
|
6
|
+
>
|
|
7
|
+
> **阅读须知(2026-10-26)**:本文是 v2 的冻结快照,保留原样作为历史依据。
|
|
8
|
+
> 其中三处已被后续实现取代:一是 §7 的「四阶段状态机 + 按角色名判定迁移」,
|
|
9
|
+
> 已改为每队声明式状态机、按条件判定(`children_settled` / `verdict_ok` …),
|
|
10
|
+
> 不再出现角色名(#298);二是术语表与架构图里的「组员:scout/worker/reviewer…」,
|
|
11
|
+
> 现只内置 `centurion` / `worker` / `scout`(#301);三是「每个子节点都是
|
|
12
|
+
> RpcClient 进程」,默认已是进程内 `AgentSession`,只有持 worktree 的 centurion
|
|
13
|
+
> 走 rpc(#279)。
|
|
14
|
+
> 现行行为以 `README.md` 与 `docs/contracts/declarative-phase-machine-v5.md` 为准。
|
|
6
15
|
|
|
7
16
|
---
|
|
8
17
|
|
|
@@ -25,7 +25,10 @@ Facts established by recon (do not re-verify):
|
|
|
25
25
|
## D1 — Wording (backend-neutral) for parent guidance
|
|
26
26
|
|
|
27
27
|
Children are **in-process by default**; a separate `pi` process is used only when
|
|
28
|
-
the launch needs a worktree
|
|
28
|
+
the launch needs a worktree — and since only a centurion may hold one, only a
|
|
29
|
+
centurion reaches `rpc`. (The `spawnMode` role field described here was removed
|
|
30
|
+
in 4.0.1; `handle.spawnMode` below remains the arbitrated backend and is still
|
|
31
|
+
what callers must render.)
|
|
29
32
|
|
|
30
33
|
Replace "RPC child / headless RPC children / started via RPC / RPC launch" with
|
|
31
34
|
backend-neutral phrasing in these exact places:
|