pi-legion 3.0.0 → 3.0.3

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/docs/design.md CHANGED
@@ -219,18 +219,48 @@ launch (async) ──→ 子 pane 跑
219
219
  │
220
220
  ├─ TUI:输入框上方 aboveEditor widget(live agent 面板)
221
221
  │ (pi-subagents fleet-status;footer 不再重复计数,见 #192)
222
- └─ collect 在后台等 turn 结束
222
+ └─ collect 在后台等 run 结束
223
223
  │
224
- └─ pi.sendMessage({ customType: "subagent-notify" },
225
- { triggerTurn: true, deliverAs: "followUp" })
226
- 父会话空闲则立刻唤醒;若父会话还在跑当前 turn(工具循环),
227
- 等这一轮结束后再投递,避免 steer 打断正在干的事。
228
- 所有状态都 display:true(transcript 留下完成/失败记录);
229
- 成功由 notice-renderer 压成单行并按 pane 宽度硬截断
230
- (控制字符已消毒,ctrl+o 展开),
231
- 失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
224
+ └─ runtime 完成通道(唯一的延迟源,按 **run** 级判定)
232
225
  ```
233
226
 
227
+ 完成通知的投递分两段,**只有一处 run 级延迟**:join 窗口是纯批处理延迟,二者串行叠加
228
+ (最坏串联约 10s + 80s)——除此之外不存在第二个"等父会话忙完"的延迟源。
229
+
230
+ 1. **join 批处理窗口**(`src/extension/join.ts`,默认 10s):同一 runId 的多个 child
231
+ 一起启动、先后结束,窗口把 straggler 合成一条消息(`Background tasks completed
232
+ (N of M)`)。窗口**只做批处理**,到期无条件 flush 到下一个环节,**不**因为父会话忙
233
+ 而延长——turn 级的 busy 是错误预测器(工具循环里父会话按定义就是忙的),而且会在
234
+ outbox 之外叠加第二层延迟。
235
+ 2. **run 级 outbox**(`src/extension/runtime.ts`,`MAX_PENDING_AGE_MS = 80_000`):
236
+ 父 **run** 活跃时不 `pi.sendMessage`,通知入 FIFO 队列;`agent_settled` 到达后
237
+ 一次性 burst 发出(一条通知一条消息),并重绘 UI。硬上限 80s(= 2× 旧的
238
+ `(1+3)×10s`)到点强制发送并计入 `noticeQueueStats().forcedFlushes`,宁可回到旧
239
+ 行为也不无限隐藏。父 run 不活跃时**立即**发送(含 `mode: "each"`、单成员批次、
240
+ `triggerTurn:false` 项)。
241
+
242
+ 上限是 **per-notice** 而不是 per-run:到点判定用 notice 自己的 `enqueuedAt` 算
243
+ (`runtime.ts` 的 `armOutboxTimer` 从 `outbox[0]` 起算,
244
+ `noticeQueueStats().oldestAgeMs` 也报最老一条)。所以父 run 持续 10 分钟时,不是
245
+ 给整个 run 记一个 80s 预算、也不是因为 run 还在跑就无限顺延:队列里最老一条到点就
246
+ 触发(`flushOutbox` 一次 `splice(0)`,连同队列里更年轻的 notice 一起按 FIFO burst
247
+ 发出)。T3d 把「arm 与 oldestAgeMs 都跟最老一条」钉成测试。
248
+
249
+ 为什么不能只发 `followUp`:宿主只在 **live run 内部** drain `agent.followUpQueue`,
250
+ 所以父 run 活跃时发出去的 followUp 会一直躺在队列里,直到用户再发一条消息才出现。
251
+ 这正是「完成通知看起来没来」的根因。发送时形状不变——
252
+ `pi.sendMessage({ customType: "subagent-notify" }, { triggerTurn: true, deliverAs: "followUp" })`
253
+ ——只是**时机**改到 run 结束时。
254
+
255
+ 所有状态都 display:true(transcript 留下完成/失败记录);
256
+ 成功由 notice-renderer 压成单行并按 pane 宽度硬截断
257
+ (控制字符已消毒,ctrl+o 展开),
258
+ 失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
259
+
260
+ > print/json 模式:`session_shutdown` → `dispose()` 丢弃未投递的 pending。child 的
261
+ > session 文件仍在,可用 `collect` 从磁盘恢复;但 `finished` 缓存**不**保留
262
+ > (`dispose()` 开头就 `jobs.clear()` / `finished.clear()`,此后才丢弃 outbox)。
263
+
234
264
  父 agent 的正确用法:`subagent` 工具 launch 之后把控制权交回用户,等 completion
235
265
  message,而不是轮询,也不是让孩子来拍自己。
236
266
 
@@ -1,8 +1,8 @@
1
1
  # In-process AgentSession mode (#197)
2
2
 
3
3
  Default backend for children. **In-process by default**, with an automatic `rpc`
4
- fallback when the resolved spawn needs an OS worktree (`worktree: true`, the
5
- writer/centurion default). An explicit
4
+ fallback when the resolved spawn needs an OS worktree (`worktree: true`, which
5
+ only a `centurion` can hold — #214). An explicit
6
6
  `spawnMode: "inprocess"` combined with `worktree: true` is still **refused**
7
7
  with `INVALID_PARAMS` — the fallback only covers the implicit default.
8
8
 
@@ -15,8 +15,10 @@ with `INVALID_PARAMS` — the fallback only covers the implicit default.
15
15
 
16
16
  There is no single global default in the config sense: omitting `spawnMode`
17
17
  means "inprocess if this role can run in-process, otherwise rpc". Roles that
18
- default to `worktree: true` (writer, centurion) therefore resolve to `rpc`
19
- unless they opt out of the worktree.
18
+ resolve to `worktree: true` therefore resolve to `rpc` unless they opt out of
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.
20
22
 
21
23
  **Non-goals:** no OS worktree isolation; no promise of process crash containment; OpenAaaS Go in-process transport is out of scope.
22
24
 
@@ -29,7 +31,7 @@ Per agent (frontmatter → `agentOverrides` → team member override):
29
31
  ---
30
32
  name: scout
31
33
  description: Fast read-only recon
32
- spawnMode: inprocess # omit = inprocess (worktree roles auto-fall back to rpc)
34
+ spawnMode: inprocess # omit = inprocess (only a centurion resolves to a worktree, hence rpc)
33
35
  worktree: false # explicit inprocess + worktree: true is still refused
34
36
  ---
35
37
  ```
@@ -126,6 +128,63 @@ Production `defaultSessionFactory` calls `createAgentSession` with:
126
128
 
127
129
  See `buildInProcessSessionPlan` / `test/unit/inprocess-supervisor.test.ts`.
128
130
 
131
+ ## Observability: live token rate during the first run
132
+
133
+ `spawnChild` returns as soon as the child **accepts** the first prompt, not when
134
+ that turn ends (#227 D1). Acceptance is read off the child transcript — a user
135
+ line for this turn appearing past the size recorded at spawn time (`F7`: the SDK
136
+ appends the user message before it runs the agent) — with
137
+ `DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS` (15s, injectable as
138
+ `spawnStartTimeoutMs`) as a bound that is **not** an error: on expiry the handle
139
+ is still returned and `onSpawnDelayed` is called once. The turn itself keeps
140
+ running on a background drain, inside the child's identity scope.
141
+
142
+ The one exception is **rejection before acceptance** (#227 D1a): no model, no
143
+ API key, or a compaction in progress makes the SDK throw before it appends the
144
+ user message, so nothing ever lands in the transcript. In that case
145
+ `spawnChild` still **throws** `SubagentError(..., START_FAILED)` and rolls the
146
+ child back rather than returning a handle. That is deliberate: `Orchestrator`
147
+ only retries the next `fallbackModels` entry when the spawn throws
148
+ (`canFallbackStart`), so returning a handle there would silently disable the
149
+ fallback chain. A turn that was accepted and *then* failed stays a runtime
150
+ failure: the handle is returned, `SettleResult.reason` reads
151
+ `child turn failed: …`, and `onSpawnRefused` reports it.
152
+
153
+ So the *reason* the ledger needs a direct read has changed, but the read is
154
+ still required. Two windows remain uncovered by `statusEntryFromJob`:
155
+
156
+ 1. **Before acceptance** (normally milliseconds, up to the start bound): the
157
+ launch has not yet written `node_launched` / `recordChild`, so no job exists.
158
+ 2. **From acceptance to settle**: the job exists, but its usage columns stay
159
+ empty until the turn ends — `pollUsage` only samples nodes that are already
160
+ `running`, and a spawning node is `starting`.
161
+
162
+ During window 2 the ledger row still carries no live token rate, so the status
163
+ board keeps seeing only the ledger node row. That row must still render a live
164
+ token rate, which needs two facts the ledger does not carry during a run:
165
+
166
+ | Fact | Ledger during a run | Source used instead |
167
+ |---|---|---|
168
+ | `tokensOut` | empty until settle: `pollUsage` skips nodes whose status is not `running`, and a spawning node is `starting` | `nodes.session_file` parsed directly |
169
+ | `generationMs` | never stored (`usage` has no such column) | same session parse |
170
+
171
+ Reading `node.sessionFile` stays correct for the same underlying reason as
172
+ before: the transcript is the only live source while the turn runs. What changed
173
+ is that the child is no longer *invisible* to the orchestrator for the whole of
174
+ its first turn — only its **usage ledger** still lags.
175
+
176
+ `statusEntryFromNode` reads `node.sessionFile` through `progressFromSessionFile`
177
+ for both, falling back to the ledger for nodes with no session file (rebuilt or
178
+ foreign rows). Without this the railway header showed `0 tok/s` for the whole
179
+ in-process lifetime while `rpc` looked correct.
180
+
181
+ `generationMs` itself is the sum of measured model-call lengths
182
+ (`message.durationMs`, or entry-to-entry write times). It must never be derived
183
+ from the nested `message.timestamp`, which marks when generation *started* and
184
+ yields only prompt-to-first-token latency — that mistake pinned the denominator
185
+ near zero, so `tok/s` silently fell back to a wall-clock window and stopped being
186
+ a decode rate.
187
+
129
188
  ## Refusals
130
189
 
131
190
  - `kind !== "pi"` → `INVALID_PARAMS` regardless of `spawnMode`: only `pi` roles
@@ -133,6 +192,9 @@ See `buildInProcessSessionPlan` / `test/unit/inprocess-supervisor.test.ts`.
133
192
  worktree fallback
134
193
  - `worktree: true` + explicit `inprocess` → `INVALID_PARAMS` (an omitted
135
194
  `spawnMode` falls back to `rpc` instead)
195
+ - A non-centurion requesting `worktree: true` is **not** a refusal: it is
196
+ downgraded to `worktree: false` with a one-time warning (#214), so the launch
197
+ proceeds in-process. `worktree` is reserved for `centurion`.
136
198
  - Invalid `spawnMode` string → loud throw at settings / frontmatter parse
137
199
 
138
200
  ## Tests
@@ -208,7 +208,7 @@ description: 百夫长/组长:携带一支团队递归派生的指挥角色。
208
208
  kind: pi
209
209
  allowNestedSubagents: true
210
210
  maxSubagentDepth: 2 # R=本角色子树代数上限(含自身;与所在深度无关;只收紧不放宽):1=叶子禁派生;2=可派一层组员、组员不可再派;3=组员(如孙代 centurion)可再派一层
211
- worktree: true # 子树 worktree:本人 cwd 即队的工作区(§9)
211
+ worktree: true # 子树 worktree:本人 cwd 即队的工作区(§9;该权限经 REV-17 收为 centurion 专属)
212
212
  team: # 可选:默认队(须是 settings.teams 里真实存在的队名);省略 = 全 roster 可选;launch 可覆盖(subagent({team:"fe-premium"}))
213
213
  ---
214
214
  ```
@@ -320,7 +320,9 @@ legion_phase({ to, note?, evidence? }) // 仅 centurion(§7)
320
320
 
321
321
  ## 9. 隔离与交付(子树 worktree、MR 树)
322
322
 
323
- - **per-subtree worktree**:centurion launch 时由**其父**创建(复用现有 `createChildWorktree`:`git worktree add -b pi-subagent/<slug>-<nonce>`,D8 仅 git);centurion 的 cwd 即该 worktree;**组员一律 `worktree:false` 原地干活**。隔离边界从"每个 writer"上移到"每个子树",MR 数量从 O(组员数) 降到 O(子树数)
323
+ - **per-subtree worktree**:centurion launch 时由**其父**创建(复用现有 `createChildWorktree`:`git worktree add -b pi-subagent/<slug>-<nonce>`,D8 仅 git);centurion 的 cwd 即该 worktree;**组员一律 `worktree:false` 原地干活**。隔离边界从"每个 writer"上移到"每个子树",MR 数量从 O(组员数) 降到 O(子树数)。**N 并行隔离 writer** = 根直派 N 个 centurion(各 1 worktree),centurion 内组员 inprocess;不要给每个 worker 开 worktree
324
+ - **worktree 仅 centurion 可用**(REV-17,#214):`worktree: true` 是 centurion 角色的专属权限,不存在其他持有者。判定以角色名 `centurion` 为准:centurion 卡一律拿到子树 worktree(嵌套 centurion 拿 nested worktree,不受「父是 centurion ⇒ 组员不隔离」压制);centurion 的组员一律不拿;其余角色(`worker` / `prototype` / `scout` / `reviewer` / `planner` / `advisor` / `designer`,含用户自建 writer 角色)既不自动获得、也不会因 `acceptance.role: writer` 而获得 worktree
325
+ - **违规处理为降级,不是报错**(REV-17):非 centurion 角色卡声明 `worktree: true`、或 `subagent({ worktree: true })` 显式传参,一律**降级为 `worktree: false`**,并 `console.warn` 一次(每 Orchestrator 实例一次,同 `depthWarningEmitted` 语义)。**不抛错**——抛错会阻断 launch,而降级的语义是「回到契约本来的边界」;被降级的组员与父共享 checkout,其改动留在父的工作区里
324
326
  - 孙代 centurion 在子树 worktree 内再开 nested worktree(git 原生支持),递归成立
325
327
  - **MR 树**(D6):每棵子树一个 squash MR → 父节点(或其队)合并 → 逐级上交 → 根合入默认分支。合并权在各级父节点,叶子永不 merge(现有纪律的递归推广)
326
328
  - 文件所有权清单随契约(§7.2)逐层传递;契约矛盾 = 正确拒绝,上报修订后重派
@@ -414,7 +416,7 @@ interface LegionEventBus {
414
416
 
415
417
  ### M2 军团树(组织层:db + centurion + 状态机 + 邮件)
416
418
 
417
- - **归属**:新增 `src/legion/{db,nodes,events,usage,mail,state-machine,budget,rebuild}.ts`、`agents/centurion.md`、`src/extension/legion-tools.ts`;修改 `orchestrator.ts`(launch 落库/树命名/预算查库/子树 worktree)、`src/runs/worktree.ts`(subtree 模式)、`src/runs/args.ts`(env 增 `PI_LEGION_DB`/`PI_LEGION_NODE_ID`)、`src/agents/agents.ts`、`index.ts`(队级 resume、collect 落库)
419
+ - **归属**:新增 `src/legion/{db,nodes,events,usage,mail,state-machine,budget,rebuild}.ts`、`agents/centurion.md`、`src/extension/legion-tools.ts`;修改 `orchestrator.ts`(launch 落库/树命名/预算查库/子树 worktree——经 REV-17 收为 centurion 专属,非 centurion 请求 warns 一次后降级)、`src/runs/worktree.ts`(subtree 模式)、`src/runs/args.ts`(env 增 `PI_LEGION_DB`/`PI_LEGION_NODE_ID`)、`src/agents/agents.ts`、`index.ts`(队级 resume、collect 落库)
418
420
  - **验收**:(a) 三层树 e2e:CEO→centurion→workers×2 全阶段走通(契约落盘→并行实施→review verdict→verify 实跑→done 收队 + squash MR);(b) rework 循环:review major→回 implementing→复审通过;(c) rework≥2 → failed 上抛;(d) kill centurion 进程 → 孤儿报警 → resume 恢复整队、settled 组员不复活、运行中组员上下文延续;(e) `maxActiveNodes` 超限拒派 + 事件;(f) 邮件:同级/父子/父←子通、跨子树拒发并提示路由、retired 收件人唤醒投递、终态收件人退信;(g) WAL 压测:60 模拟 supervisor 并发写 5 分钟,busy 超时率 <0.1%;(h) `legion-rebuild` 从 run.json+jsonl 重建 nodes/events;(i) 非 pi kind 明确拒绝
419
421
 
420
422
  ### M3 可视化
@@ -459,3 +461,4 @@ interface LegionEventBus {
459
461
  | 2026-10-11 | REV-14 | §10.1 点击双通道降级:OSC 8 超链接仅作视觉锚点(宿主 TUI 截获链接点击去 openBrowser,pi-tui tui-alt-screen.js:1097-1104 + extensions types 无 URL 回调 API——worker-14 读源码实证);浮窗打开 = 组件鼠标事件 + 键盘路径(/legion <name>)。鼠标事件在全屏模式路由到组件(tree-view 自管分页的点击 handler 已支持),regular 模式无鼠标为宿主限制。触发:worker-14 按 §15-2 正确拒绝(WIP 已由合并权人固化 2dff211) |
460
462
  | 2026-10-11 | REV-15 | §6.1 centurion 角色卡的 `team: be-value` 改为可选省略:be-value 是示例值,仓库/用户 settings 均未定义该队,不覆盖时子进程收警告并退化全 roster。team 字段语义 = 可选的默认队绑定(须真实存在),省略即全 roster。触发:reviewer-6 对 PR #13 的范围外观察 |
461
463
  | 2026-10-12 | REV-16 | §3.2 retire 链加固:SIGTERM 超时后升级 SIGKILL(新 option `killTimeoutMs`,默认 1s),并在 retire 时枚举子 pi 的后代进程(/proc 祖先链),按进程组 SIGTERM→SIGKILL 清场(排除 supervisor 自身进程组与子 pi 自身 pid)。触发:2026-10-12 孤儿 `node --test` 进程泄露事故(两个 PPID=1 进程各烧 4h20m CPU,SIGTERM 无效,人工 SIGKILL 收场)。子契约:`docs/retire-hardening-contract.md`(FROZEN-1) |
464
+ | 2026-10-19 | REV-17 | §9 worktree 收为 centurion 专属(权限变更,按 §15-4 走修订):① 判定以角色名 `centurion` 为准,centurion 卡一律拿子树 worktree、嵌套 centurion 拿 nested worktree;② 其余角色一律不拿——删除「`acceptance.role: writer` 自动补 `worktree: true`」的隐式默认,`agents/worker.md` 从 `worktree: true` 改为显式 `worktree: false`(`agents/centurion.md` 的 `true` 与 `agents/prototype.md` 的 `false` 不变);③ 违规一律**降级为 `false` + `console.warn` 一次**,**不抛错**(抛错会阻断 launch);`subagent({ worktree: true })` 显式传参同样降级。不改 `resolveLaunchWorktree` 的优先级链(不变式 I5),不新增事件类型,不改 `spawnMode` 仲裁逻辑。原因:issue #214(worktree 收为 centurion 专属,与 §9 已冻结的「组员一律 `worktree: false`」对齐;实现向契约收敛,隔离边界继续由「每棵子树」承载) |
@@ -102,12 +102,16 @@ and override semantics.
102
102
  ## spawnMode (rpc | inprocess)
103
103
 
104
104
  Omitted → `inprocess`, with an automatic `rpc` fallback when the resolved spawn
105
- needs an OS worktree (`worktree: true`, the writer/centurion default). A
106
- non-`pi` role is rejected before this point, so it never reaches the fallback.
107
- An explicit `spawnMode: "inprocess"` + `worktree: true` is
108
- still refused with `INVALID_PARAMS`. Precedence: frontmatter →
105
+ needs an OS worktree (`worktree: true`, which only a `centurion` can hold —
106
+ #214). A non-`pi` role is rejected before this point, so it never reaches the
107
+ fallback. An explicit `spawnMode: "inprocess"` + `worktree: true` is still
108
+ refused with `INVALID_PARAMS`. Precedence: frontmatter →
109
109
  `subagents.agentOverrides.<role>` → team member override.
110
110
 
111
+ `worktree: true` is centurion-only: any other role that asks for it is
112
+ downgraded to `worktree: false` with a one-time warning, so squad members always
113
+ run in-process in the centurion's subtree worktree.
114
+
111
115
  See [inprocess-supervisor.md](./inprocess-supervisor.md) (#197) for the backend
112
116
  details, the separate `subagents.inprocess.*` limits, and the
113
117
  AsyncLocalStorage identity channel.
@@ -54,7 +54,7 @@ const VexillatioParams = Type.Object({
54
54
 
55
55
  1. 解析 agent:默认 `scout`;目标必须在 catalog 里且 `utility === true`,否则报错 `not a utility agent: <name> (mark it with 'utility: true')`。
56
56
  2. 走**既有 `orchestrator.launch`**(复用 budget/session 管线),但 `SpawnInput.env` 显式剔除 legion 身份四变量:`PI_LEGION_DB`、`PI_LEGION_NODE_ID`、`PI_SUBAGENT_PARENT_PATH`、`PI_SUBAGENT_MAX_DEPTH`(防幽灵节点 + 防工具人继承指挥链身份)。**不剔除** `PI_SUBAGENT_CHILD`、`PI_SUBAGENT_CHILD_ROLE`、`PI_SUBAGENT_MAX_TOOL_CALLS` 等预算变量(child-guard 仍要工作)。utility 目标(两通道)**剥除 `PI_SUBAGENT_ALLOW_NESTED`**(§2.5.1,utility 语义优先);`lineageEnv` 统一设置 `PI_SUBAGENT_UTILITY_CHILD`(§2.2)。
57
- 3. launch 后 `awaitSupervisor` + 静默宽限(复用 `collect` 的 grace 逻辑),产出直接以文本返回(`renderCollect` 简化版:`── <name> ──\nexecution: …\n<output>`),同 `collect` 的 `outputFile` 惯例写 `<name>.output.md`。
57
+ 3. launch 后 `awaitSupervisor` + 静默宽限(复用 `collect` 的 grace 逻辑),产出直接以文本返回(`renderCollect` 简化版:`── <name> ──\nexecution: …\n<output>`),同 `collect` 的 `outputFile` 惯例写 `<name>.output.md`。**中断返回语义(REV-17.9)**:父轮结束时(`signal.aborted`)不丢弃已收集的结果——返回文本 = 冻结头行 `interrupted (parent turn ended; children still running)` + 已收集的每一段(顺序 = handles 顺序,含 launch 失败行 `✗ <agent>: <error>`);`details` 带 `{ runId, handles, async: false, interrupted: true, stillRunning? }`。没有 handle 时文本退化为只有头行 + 失败行,不出现空段;`interruptedCollect` 仍给 `status:"running"` + `reason:"aborted by parent"`,照旧渲染、不改写成 success。
58
58
  4. 返回值含 `outputFile`/`sessionFile` 路径,父可后续用 `subagent({action:'collect'|'steer'|'retire', name})` 控制——**节点进 legion 树**, parent = 派发进程自己的 nodeId;无 legion 身份的**child** 派发者走普通 run 目录管线(子记录必须 `store.addChild` 落 run.json,同 launchStep 惯例——REV-17.8 修正:原措辞导致实现只 updateChild 丢记录);**root** 派发者照 subagent 通道惯例创建 root legion(原措辞含糊,见 REV-17.8 修订记录)。
59
59
 
60
60
  ### 2.3 env 语义(冻结表)
@@ -160,3 +160,4 @@ const VexillatioParams = Type.Object({
160
160
  | 2026-10-12 | REV-17.6 | §2.2/§2.3/§2.5.1/§2.5.3 四处统一:**utility 语义优先,allowNested 被忽略**——两通道对 utility 目标都剥除 ALLOW_NESTED,不存在持有 subagent 的工具人,误配不恢复递归路径(§2.5.1 原句“此时持有 subagent”是 17.4 引入的后门,与硬叶子不变量自相矛盾) | worker-5 指出硬叶子规则与误配时持有 subagent 的规定冲突——拒绝正确,修订后重派 |
161
161
  | 2026-10-12 | REV-17.7 | 新增 §2.7 工具白名单注入规则(`pushToolArgs` 对非 utility 角色追加 vexillatio);§3 新增 `src/runs/args.ts` 所有权 + 两个 `read,bash` 断言授权(orchestrator.test:99 / supervisor.test:265)。分支已合入 master 最新(PR #22) | worker-6 完成全部实现后指出:受限角色(显式 tools: 白名单)调不到未列名的 vexillatio,需在 args 层追加且两个既有断言未授权——拒绝正确;从原会话恢复继续,勿重写已完成的 90% |
162
162
  | 2026-10-13 | REV-17.8 | 三路 review 门禁退回后的修复授权:M1=非 legion 派发者子记录持久化(persistChild 改 findChild 守卫式 addChild+updateChild,补集成测试);M3=package.json 四脚本加 `-u PI_SUBAGENT_UTILITY_CHILD`;测试缺口补:跨通道同名续接(subagent 先派 scout-0 后 vexillatio 应派 scout-1)与超时行为(不 autoSettle 场景);契约自身修正:§2.2 步骤 4 措辞(child 走 addChild 惯例/root 走 createRootLegion)、rpc-supervisor/types.ts 追认、§3 新增 package.json。四个 minor 采纳:abort signal 贯通、strip 列表常量化、verify 字段报错、child-mode 越授权改动追认;其余 minor(F5 重构收敛等)记档不阻塞 | 四路 reviewer 独立印证 3 major(M1 探针实证 children:[]、M2 部署惰性、M3 32 假失败实测);采纳 worker-0/2/3 交叉发现 |
163
+ | 2026-10-19 | REV-17.9 | §2.2 步骤 3 补中断返回语义:父轮结束时返回已收集结果 + `details.interrupted`,冻结头行保持逐字(#227 D4) | issue #227 缺陷 2(中断丢弃已收集结果,`index.ts:2416` / `:743-745` 两层丢弃);`docs/contracts/vexillatio-nonblocking-launch-v1.md` 冻结 |
package/index.ts CHANGED
@@ -94,6 +94,7 @@ import {
94
94
  type OrchestratorLegionContext,
95
95
  } from "./src/runs/orchestrator.ts";
96
96
  import { RunStore, pickChildByName } from "./src/runs/store.ts";
97
+ import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
97
98
  import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
98
99
  import {
99
100
  type AgentConfig,
@@ -103,6 +104,7 @@ import {
103
104
  ErrorCodes,
104
105
  type ModelOrigin,
105
106
  type RetireOptions,
107
+ type SpawnMode,
106
108
  SubagentError,
107
109
  type SubagentsSettings,
108
110
  } from "./src/shared/types.ts";
@@ -122,7 +124,7 @@ const TaskItem = Type.Object({
122
124
  worktree: Type.Optional(
123
125
  Type.Boolean({
124
126
  description:
125
- "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout.",
127
+ "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout. Reserved for `centurion` (#214): any other role asking for true is downgraded to false with a one-time warning.",
126
128
  }),
127
129
  ),
128
130
  verify: Type.Optional(
@@ -174,7 +176,7 @@ const SubagentParams = Type.Object({
174
176
  worktree: Type.Optional(
175
177
  Type.Boolean({
176
178
  description:
177
- "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Omit to use the role default (bundled worker: true).",
179
+ "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Reserved for `centurion` (#214): only a centurion gets a subtree worktree, every other role is downgraded to false with a one-time warning — dispatch a centurion when you need an isolated delivery unit.",
178
180
  }),
179
181
  ),
180
182
  verify: Type.Optional(
@@ -293,6 +295,19 @@ function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
293
295
  return new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
294
296
  }
295
297
 
298
+ /**
299
+ * Backend THIS process was started on (#209), for `run.json`'s `herdr.supervisor`.
300
+ *
301
+ * A host/root session and an RPC child are both ordinary `pi` processes, so the
302
+ * historic `rpc` marker is the truth for them. An in-process child shares this
303
+ * process, and its backend travels on the ALS child context — the one case where
304
+ * the truthful marker is not `rpc`. Reading it from there (instead of writing a
305
+ * literal) is what stops the record from claiming RPC for an inprocess child.
306
+ */
307
+ function currentProcessSpawnMode(): SpawnMode {
308
+ return currentChildContext()?.spawnMode ?? "rpc";
309
+ }
310
+
296
311
  function notice(pi: ExtensionAPI, text: string): void {
297
312
  pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
298
313
  }
@@ -399,6 +414,9 @@ function resolveLegionIdentity(input: {
399
414
  const nodeRuns = input.store.listRuns().filter((run) => run.nodeId === nodeId || run.herdr.node_id === nodeId);
400
415
  let nodeRun = nodeRuns.at(-1);
401
416
  if (!nodeRun) {
417
+ // Contract D3: a run synthesized while attaching to an already-existing
418
+ // node has no launch arbitration behind it, so it keeps the historic
419
+ // marker instead of inventing a backend.
402
420
  nodeRun = input.store.createRun({ task: node.taskSummary ?? `Resume ${node.role} node ${node.id}`, cwd: input.cwd, path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), maxDepth: effectiveMaxDepth(input.settings.legion?.maxDepth), nodeId, herdr: { supervisor: "rpc", node_id: nodeId } });
403
421
  }
404
422
  if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
@@ -442,7 +460,7 @@ async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input:
442
460
  }
443
461
 
444
462
  function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
445
- const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: "rpc" } });
463
+ const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: currentProcessSpawnMode() } });
446
464
  const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
447
465
  const db = openLegionDb(dbPath);
448
466
  const now = Date.now();
@@ -722,9 +740,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
722
740
  },
723
741
  onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
724
742
  });
725
- if (signal?.aborted) {
726
- return ok("interrupted (parent turn ended; children still running)");
727
- }
743
+ // `launchUtilityFamily` already folds the abort into its own return
744
+ // (head line + collected segments + `interrupted` details, #227 D4).
745
+ // Returning `result` unconditionally is what keeps that work visible;
746
+ // re-checking `signal.aborted` here would throw it away again.
728
747
  return result;
729
748
  },
730
749
  });
@@ -760,10 +779,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
760
779
  const childActivitySources = new Set<string>();
761
780
  let statusNodeOpener: ((nodeId: string) => void) | undefined;
762
781
  let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
763
- // turn_start/turn_end maintain this so a flush window can wait out a
764
- // busy parent instead of queueing behind the whole turn (join extends
765
- // the window at most MAX_BUSY_EXTENSIONS times).
766
- let parentTurnActive = false;
782
+ // Run-scoped completion deferral lives in the runtime outbox (D-A): a child
783
+ // finishing while this agent run is still active is held until
784
+ // `agent_settled` instead of being queued as an undrained followUp.
767
785
  const rpcEventSource = supervisor as LegionSupervisor & RpcEventSource;
768
786
  const runtime = createSessionRuntime({
769
787
  sendMessage: (message, options) => pi.sendMessage(message, options),
@@ -999,7 +1017,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
999
1017
  runtime.setJoinConfig({
1000
1018
  mode: settings.joinMode ?? DEFAULT_JOIN_MODE,
1001
1019
  flushMs: settings.joinFlushMs ?? DEFAULT_FLUSH_MS,
1002
- parentBusy: () => parentTurnActive,
1003
1020
  });
1004
1021
 
1005
1022
  if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
@@ -1175,19 +1192,22 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1175
1192
  });
1176
1193
  pi.on("session_info_changed", bindUi);
1177
1194
  pi.on("turn_start", (event, ctx) => {
1178
- parentTurnActive = true;
1179
1195
  bindUi(event, ctx);
1180
1196
  });
1181
1197
  pi.on("turn_end", (event, ctx) => {
1182
- parentTurnActive = false;
1183
1198
  bindUi(event, ctx);
1184
1199
  });
1185
1200
  pi.on("agent_start", (event, ctx) => {
1201
+ runtime.notifyParentRunStart();
1186
1202
  bindUi(event, ctx);
1187
1203
  });
1188
1204
  pi.on("agent_end", (event, ctx) => {
1189
1205
  bindUi(event, ctx);
1190
1206
  });
1207
+ pi.on("agent_settled", (event, ctx) => {
1208
+ runtime.notifyParentSettled();
1209
+ bindUi(event, ctx);
1210
+ });
1191
1211
  pi.on("input", bindUi);
1192
1212
  pi.on("tool_result", (_event, ctx) => {
1193
1213
  bindUi(_event, ctx);
@@ -1317,6 +1337,7 @@ async function controlAction(input: {
1317
1337
  return stopAction({
1318
1338
  params,
1319
1339
  runtime: input.runtime,
1340
+ store: input.store,
1320
1341
  retire: async (name, opts) => {
1321
1342
  const merged = opts ?? retireOpts;
1322
1343
  const job = input.runtime.get(name);
@@ -1358,18 +1379,23 @@ async function controlAction(input: {
1358
1379
  );
1359
1380
  }
1360
1381
  if (!params.name) {
1361
- const live = listLiveChildrenForStatus(store.listRuns());
1382
+ const runs = store.listRuns();
1383
+ const live = listLiveChildrenForStatus(runs);
1362
1384
  return ok(
1363
1385
  renderStatusOverview(
1364
- live.map((child) => ({
1365
- child,
1366
- alive: input.supervisor.isAlive(child.name),
1367
- })),
1386
+ live.map((child) => {
1387
+ const alive = input.supervisor.isAlive(child.name);
1388
+ const owner = ownerVerdictFor(store, runs, child.name, alive);
1389
+ return { child, alive, ...(owner ? { owner } : {}) };
1390
+ }),
1368
1391
  ),
1369
1392
  );
1370
1393
  }
1371
- const found = findChild(store.listRuns(), params.name);
1394
+ const runs = store.listRuns();
1395
+ const found = findChild(runs, params.name);
1372
1396
  if (!found) return unknownChild(params.name, store, cwd);
1397
+ const alive = input.supervisor.isAlive(params.name);
1398
+ const owner = ownerVerdictFor(store, runs, params.name, alive);
1373
1399
  return ok(renderLiveChildStatus({
1374
1400
  name: found.child.name,
1375
1401
  state: found.child.state,
@@ -1378,7 +1404,9 @@ async function controlAction(input: {
1378
1404
  sessionFile: found.child.sessionFile,
1379
1405
  execution: found.child.execution,
1380
1406
  acceptance: found.child.acceptance,
1381
- alive: input.supervisor.isAlive(params.name),
1407
+ spawnMode: found.child.spawnMode,
1408
+ alive,
1409
+ ...(owner ? { owner } : {}),
1382
1410
  }));
1383
1411
  }
1384
1412
 
@@ -1652,7 +1680,7 @@ async function launchFamily(input: {
1652
1680
  task: plan.task,
1653
1681
  cwd,
1654
1682
  maxDepth,
1655
- ...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: "rpc" as const, node_id: legion.nodeId } } : { herdr: { supervisor: "rpc" as const } }),
1683
+ ...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: currentProcessSpawnMode(), node_id: legion.nodeId } } : { herdr: { supervisor: currentProcessSpawnMode() } }),
1656
1684
  });
1657
1685
  if (legion && legion.nodeId !== "root" && legion.parentRunId) {
1658
1686
  const node = getNode(legion.db, legion.nodeId);
@@ -1919,7 +1947,7 @@ async function launchStep(
1919
1947
  return;
1920
1948
  }
1921
1949
  session.results.push(
1922
- `▶ ${handle.name} (${effective.name}) supervisor=rpc`,
1950
+ `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
1923
1951
  );
1924
1952
  // An inherited model the target CLI cannot express is dropped on purpose
1925
1953
  // (the parent's pi model means nothing to cursor). Say so, or the user
@@ -2226,7 +2254,7 @@ async function launchUtilityFamily(input: {
2226
2254
  cwd: input.cwd,
2227
2255
  maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
2228
2256
  path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH),
2229
- herdr: { supervisor: "rpc" },
2257
+ herdr: { supervisor: currentProcessSpawnMode() },
2230
2258
  });
2231
2259
  if (!run) return fail("vexillatio parent run is not available", ErrorCodes.NOT_FOUND);
2232
2260
  const runId = run.runId;
@@ -2386,7 +2414,13 @@ async function launchUtilityFamily(input: {
2386
2414
  return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2387
2415
  }
2388
2416
  }));
2389
- if (input.signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
2417
+ if (input.signal?.aborted) return interruptedUtilityResult({
2418
+ runId,
2419
+ handles,
2420
+ stillRunning,
2421
+ results,
2422
+ collected,
2423
+ });
2390
2424
  results.push(...collected);
2391
2425
  return {
2392
2426
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
@@ -2401,6 +2435,40 @@ async function launchUtilityFamily(input: {
2401
2435
  };
2402
2436
  }
2403
2437
 
2438
+ /**
2439
+ * Build the interrupted vexillatio result from what the collect loop already produced.
2440
+ *
2441
+ * `#227 D4`: an abort must not throw away work that already finished. The frozen
2442
+ * head line stays first and verbatim (`test/unit/teams.test.ts` matches it with a
2443
+ * regex), the collected segments are appended below it, and `details` carries the
2444
+ * same machine facts a non-interrupted return publishes plus `interrupted: true`.
2445
+ *
2446
+ * With no handles (every launch failed) the text degrades to the head line plus
2447
+ * the failure lines only — no empty segment.
2448
+ */
2449
+ function interruptedUtilityResult(input: {
2450
+ runId: string;
2451
+ handles: Array<{ name: string; agent: string; sessionFile: string }>;
2452
+ stillRunning: Set<string>;
2453
+ results: string[];
2454
+ collected: string[];
2455
+ }): AgentToolResult<unknown> {
2456
+ const head = "interrupted (parent turn ended; children still running)";
2457
+ const segments = [...input.results, ...input.collected].filter((segment) => segment.length > 0);
2458
+ const stillRunning = input.handles
2459
+ .filter(({ name }) => input.stillRunning.has(name))
2460
+ .map(({ name }) => name);
2461
+ // Routed through `ok()` so the widened `details` parameter has exactly one
2462
+ // real consumer instead of being a dead signature (#227 review F3).
2463
+ return ok(segments.length > 0 ? `${head}\n\n${segments.join("\n\n")}` : head, {
2464
+ runId: input.runId,
2465
+ handles: input.handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
2466
+ async: false,
2467
+ interrupted: true,
2468
+ ...(stillRunning.length > 0 ? { stillRunning } : {}),
2469
+ });
2470
+ }
2471
+
2404
2472
  /**
2405
2473
  * Resolve an explicit `name=` on launch.
2406
2474
  *
@@ -2742,11 +2810,13 @@ export function renderStatusOverview(
2742
2810
  children: Array<{
2743
2811
  child: NonNullable<ReturnType<RunStore["findChild"]>>;
2744
2812
  alive: boolean;
2813
+ /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
2814
+ owner?: "live" | "dead" | "unknown";
2745
2815
  }>,
2746
2816
  ): string {
2747
2817
  if (children.length === 0) return "No live children.";
2748
2818
  const noun = children.length === 1 ? "child" : "children";
2749
- const blocks = children.map(({ child, alive }) =>
2819
+ const blocks = children.map(({ child, alive, owner }) =>
2750
2820
  renderLiveChildStatus({
2751
2821
  name: child.name,
2752
2822
  state: child.state,
@@ -2755,7 +2825,9 @@ export function renderStatusOverview(
2755
2825
  sessionFile: child.sessionFile,
2756
2826
  execution: child.execution,
2757
2827
  acceptance: child.acceptance,
2828
+ spawnMode: child.spawnMode,
2758
2829
  alive,
2830
+ ...(owner ? { owner } : {}),
2759
2831
  }),
2760
2832
  );
2761
2833
  return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
@@ -2882,6 +2954,26 @@ export function renderWait(results: WaitResult[]): string {
2882
2954
  return parts.join("\n");
2883
2955
  }
2884
2956
 
2957
+ /**
2958
+ * Run-dir ownership verdict for a child THIS process does not track (#209-3).
2959
+ *
2960
+ * `supervisor.isAlive` is an in-memory registry of children spawned by this
2961
+ * process, so it is always false for a foreign run (another session, or a
2962
+ * restart). The run dir's `owner.json` pid is the durable liveness marker; it
2963
+ * only needs to be consulted when the in-memory answer is "not alive".
2964
+ */
2965
+ function ownerVerdictFor(
2966
+ store: RunStore,
2967
+ runs: ReturnType<RunStore["listRuns"]>,
2968
+ name: string,
2969
+ alive: boolean,
2970
+ ): "live" | "dead" | "unknown" | undefined {
2971
+ if (alive) return undefined;
2972
+ const picked = pickChildByName(runs, name);
2973
+ if (!picked) return "unknown";
2974
+ return ownerState(path.join(store.runDir(picked.run.runId), RUN_OWNER_FILE));
2975
+ }
2976
+
2885
2977
  /**
2886
2978
  * Retire a named child or every child tracked by this session.
2887
2979
  * `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
@@ -2889,6 +2981,11 @@ export function renderWait(results: WaitResult[]): string {
2889
2981
  export async function stopAction(input: {
2890
2982
  params: { name?: string; all?: boolean; force?: boolean };
2891
2983
  runtime: SessionRuntime;
2984
+ /**
2985
+ * Run store used to resolve a named target this session does not track
2986
+ * (foreign/restarted runs). Omitted callers keep the runtime-only scope.
2987
+ */
2988
+ store?: RunStore;
2892
2989
  retire: (name: string, opts?: RetireOptions) => Promise<void>;
2893
2990
  }): Promise<AgentToolResult<unknown>> {
2894
2991
  const { params, runtime, retire } = input;
@@ -2906,11 +3003,21 @@ export async function stopAction(input: {
2906
3003
  if (params.all) {
2907
3004
  targets = active.map((job) => job.name);
2908
3005
  } else {
2909
- const target = jobs.find((job) => job.name === params.name);
3006
+ // Local const so the earlier `name`/`all` guard narrows through the lookup.
3007
+ const name = params.name;
3008
+ const target = name ? jobs.find((job) => job.name === name) : undefined;
2910
3009
  if (!target) {
2911
- return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
3010
+ // Not tracked by this session, but a foreign/restarted run may still
3011
+ // own a persisted record — the same records `status` can display. Fall
3012
+ // back to it so stop and retire agree on what is reachable.
3013
+ const found = input.store && name ? findChild(input.store.listRuns(), name) : null;
3014
+ if (!found) {
3015
+ return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
3016
+ }
3017
+ targets = found.child.state === "retired" ? [] : [found.child.name];
3018
+ } else {
3019
+ targets = target.state === "retired" ? [] : [target.name];
2912
3020
  }
2913
- targets = target.state === "retired" ? [] : [target.name];
2914
3021
  }
2915
3022
  if (targets.length === 0) return ok("No non-retired children to stop.");
2916
3023
 
@@ -3080,34 +3187,6 @@ function findChild(
3080
3187
  return { runId: picked.run.runId, run: picked.run, child: picked.child };
3081
3188
  }
3082
3189
 
3083
- function renderChild(
3084
- child: NonNullable<ReturnType<RunStore["findChild"]>>,
3085
- action: string,
3086
- ): AgentToolResult<unknown> {
3087
- const lines = [
3088
- `${child.name} — state=${child.state} agent=${child.agent ?? "?"} kind=${child.kind ?? "?"}`,
3089
- "supervisor=rpc",
3090
- `session=${child.sessionFile}`,
3091
- ];
3092
- if (child.execution) {
3093
- lines.push(
3094
- `execution: ${child.execution.status}${child.execution.reason ? ` (${child.execution.reason})` : ""}`,
3095
- );
3096
- if (child.execution.usage) {
3097
- lines.push(
3098
- `usage: in=${child.execution.usage.input} out=${child.execution.usage.output} cost=${child.execution.usage.cost.toFixed(4)}`,
3099
- );
3100
- }
3101
- }
3102
- if (child.acceptance)
3103
- lines.push(
3104
- `acceptance: ${child.acceptance.status} (${child.acceptance.level})`,
3105
- );
3106
- if (action === "collect" && !child.execution)
3107
- lines.push("(not yet collected — call collect while the agent is alive)");
3108
- return ok(lines.join("\n"));
3109
- }
3110
-
3111
3190
  function renderCollect(
3112
3191
  name: string,
3113
3192
  collected: {
@@ -3165,8 +3244,20 @@ function renderCollect(
3165
3244
  return lines.join("\n");
3166
3245
  }
3167
3246
 
3168
- function ok(text: string): AgentToolResult<unknown> {
3169
- return { content: [{ type: "text", text }], details: {} };
3247
+ /**
3248
+ * Render a success/neutral result to the model.
3249
+ *
3250
+ * `details` defaults to `{}`; callers that already computed machine facts
3251
+ * (runId/handles for an interrupted collect) pass them through instead of
3252
+ * discarding them (#227 D4) — `interruptedUtilityResult` is that caller.
3253
+ * `isError` is deliberately never set — the rendering chain and every assertion
3254
+ * in this repo work without it.
3255
+ */
3256
+ function ok(
3257
+ text: string,
3258
+ details: Record<string, unknown> = {},
3259
+ ): AgentToolResult<unknown> {
3260
+ return { content: [{ type: "text", text }], details };
3170
3261
  }
3171
3262
 
3172
3263
  /**