pi-legion 3.0.1 → 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/CHANGELOG.md +138 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +23 -18
- package/agents/worker.md +6 -6
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +161 -0
- package/docs/design.md +39 -9
- package/docs/inprocess-supervisor.md +67 -5
- package/docs/legion-v2-contract.md +6 -3
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +149 -58
- package/package.json +15 -22
- package/src/agents/agents.ts +5 -5
- package/src/api.ts +6 -1
- package/src/extension/auto-doctor.ts +21 -3
- package/src/extension/join.ts +7 -18
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/playbook.ts +1 -1
- package/src/extension/runtime.ts +206 -8
- package/src/extension/spawn-hints.ts +25 -4
- package/src/runs/orchestrator.ts +78 -8
- package/src/runs/worktree.ts +5 -4
- package/src/shared/session.ts +83 -7
- package/src/shared/types.ts +17 -2
- package/src/supervisor/inprocess-supervisor.ts +449 -16
- package/src/supervisor/routing-supervisor.ts +1 -1
- package/src/supervisor/spawn-mode.ts +25 -0
|
@@ -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`,
|
|
5
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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 (
|
|
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
|
|
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`」对齐;实现向契约收敛,隔离边界继续由「每棵子树」承载) |
|
package/docs/subagents-teams.md
CHANGED
|
@@ -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`,
|
|
106
|
-
non-`pi` role is rejected before this point, so it never reaches the
|
|
107
|
-
An explicit `spawnMode: "inprocess"` + `worktree: true` is
|
|
108
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
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
|
|
1382
|
+
const runs = store.listRuns();
|
|
1383
|
+
const live = listLiveChildrenForStatus(runs);
|
|
1362
1384
|
return ok(
|
|
1363
1385
|
renderStatusOverview(
|
|
1364
|
-
live.map((child) =>
|
|
1365
|
-
child
|
|
1366
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3169
|
-
|
|
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
|
/**
|