pi-legion 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,35 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [5.2.0] — 2026-11-04
15
+
16
+ 一次以「回复说真话」为中心的发布。批量派发的每个子节点可以带自己的 handle;`stop` 与超时的回复不再把
17
+ 「已经跑完」「还在跑」说成相反的东西;`collect` 与 `stop` 撞车时不再丢最后一轮产物;文档里「`timeoutMs`
18
+ 会终止子节点」的说法被容器实测推翻并改正。
19
+
20
+ ### Added
21
+
22
+ - **`tasks[]` 的每个条目现在可以带自己的 `name`,而且这个 handle 一定生效。** 以前条目里的 `name` 被
23
+ 静默丢弃——schema 里没有这个字段、`launchStep` 也从不读它,于是调用方明明写了名字,批量出来的子节点仍被
24
+ 自动编号成 `scout-N`/`worker-N`,没有任何提示;现在条目名优先、其次顶层 `name=`、都没有才自动编号。名字不合
25
+ 语法、同一批内重名(含大小写/空格差异)、或单条目同时给了两个不同的名字,都在**任何子节点启动之前**整批拒绝,
26
+ 不会留下「一半按调用方的名字、一半按自动名」的半启动状态。`vexillatio` 自己为每一步分配唯一 handle,因此对
27
+ 条目里的 `name` 直接报错拒绝,而不是静默忽略。
28
+
29
+ ### Fixed
30
+
31
+ - **`stop` 的回复现在带状态,不再把「已经跑完」说成「被停掉」。** 名字在子节点 retire 之后才到达时,回复是
32
+ `No non-retired children to stop. worker-2 is already retired (execution: success, retired at …)`;命中
33
+ settle→retire 那一小段窗口时,回复 `Retired 1 already-settled child: x (execution: success)` 而不是
34
+ `Stopped 1 child`——后者等于宣称一次从未发生的打断。retire 行为本身不变,只有措辞。
35
+
36
+ - **超时不再被说成会终止子节点,`stop` 竞态也不再丢最后一轮产物。** 角色 `timeoutMs` 实际只是父侧等待预算:
37
+ 容器实测 `worker.timeoutMs=10s` 的 child 存活 56s 并正常 `success`,且 pi 1.4 的 CLI / settings 都没有 turn 级超时;
38
+ 文档与 renewal 契约据此改正,`collect deadline (cumulative since launch)` 改为 `(per collect attempt)`(原文案
39
+ 本来就是错的)。`finishCollect` 在「child 已被 retire」时仍写 `<name>.output.md`,不再因 `stop` 撞上 collect
40
+ 而丢掉那一轮文本。失败通知携带 `stopReason` / `errorMessage` / `errorKind`,当合成 reason 表达不了 provider
41
+ 错误时多打一行 `error:`;`wait` 超时回执的 `hint` 改为「只是停止等待,child 还在跑」,不再把 `stop` 摆在建议位。
42
+
14
43
  ## [5.1.0] — 2026-11-02
15
44
 
16
45
  一次以**可恢复、可声明的子树**为中心的发布。三个新能力:`centurion` 的状态机改为由 root 派发时用 `phases:` 声明(不声明就是普通 agent);同一个 session 重开能接回上次的树,退出等于暂停、恢复必须显式;长等待中的 `wait`/`collect` 会在收到子节点邮件时立刻返回。`/legion` 一侧:整帧分配降到 2.0MB 以内(120×40 −59%、200×60 −67%),overlay 改为主屏挂载,状态行能说清子节点正在做什么,且不再每写一格分配一个 `Cell`。另有三十余项稳定性修复:失败队不再静默消失、内联相位机器进树、headless 账本不再漏记、`ctx is stale` 与宿主 `turn_end` 的报错消失。
@@ -41,10 +41,12 @@
41
41
  - `wait { timeoutMs }` / `collect { timeoutMs }`(`index.ts:1768`, `:2616`)
42
42
  → **父侧放弃等待,不杀 child**。`inprocess-supervisor.ts:1084` 只 `resolve({settled:false, reason:"timeout"})`。
43
43
  - 角色 `timeoutMs`(`agents/worker.md:9` 等)
44
- → 由 **pi 宿主**执行,child 被终止。pi-legion 源码里**没有** `terminated` 这个字面量
45
- (全仓 grep 只命中 `src/shared/session.ts:912` 的 "terminated during tool call" 文案)。
44
+ → **父侧等待预算,不终止 child**(`orchestrator.ts` 的 collect deadline;到点返回 `running`,watch 重新武装)。
45
+ 宿主没有 turn 级超时:pi 1.4 的 CLI / settings 都没有该开关,子进程 argv 也不带 timeout(2026-11 容器实测:
46
+ `worker.timeoutMs=10s` 的 child 存活 56s 并正常 `success`)。真正终止 child 的只有 `stop`、session shutdown、
47
+ 工具预算切断。
46
48
 
47
- **结论:pi-legion 无法阻止宿主终止 child,但能在终止后识别、评估、续约。** 契约按此边界设计。
49
+ **结论:宿主不会因 `timeoutMs` 终止 child。真正的终止来自 stop / shutdown / 工具预算,pi-legion 无法阻止,但能在终止后识别、评估、续约。** 契约按此边界设计。
48
50
 
49
51
  ---
50
52
 
@@ -159,3 +159,4 @@ npm run test:integration
159
159
  | 2026-10-19 | v1 | 首次冻结 | #227 三缺陷拆分:D1/D2/D3(A)、D4/D3b/D5(B);D6/D7 显式排除 |
160
160
  | 2026-10-19 | v1.1 | 勘误 §1 D1 轮询常量位置:`src/shared/types.ts` → 实为 `src/runs/orchestrator.ts:143` 私有常量,改由 `src/supervisor/spawn-mode.ts` 导出同值常量;§2 Worker A 文件表同步 | Worker A 指出契约原文事实错误;`grep` 确认 `POLL_INTERVAL_MS` 从未在 `src/shared/types.ts` 出现过。属笔误型而非设计型矛盾,advisor 裁决按勘误继续(不回退重派),父会话在此落库 |
161
161
  | 2026-10-19 | v1.2 | 新增 **D1a**:首次 prompt 在「受理之前」reject 时 `spawnChild` 仍须抛出(保 `fallbackModels`);§3 验收清单同步为 8 条 | 父会话审查 PR #228 实测发现回归:非阻塞改造把受理前 reject 吞成「已 spawn」,`canFallbackStart` 失效。HEAD 抛错 / 首版实现返回 handle,属 #227 之外的回归,必须以先红后绿用例守住 |
162
+ | 2026-11-04 | v1.3 | **D6 复议**:`COLLECT_DEADLINE_REASON` 的 `cumulative since launch` 措辞改为 `per collect attempt`,同步更新 5 个测试文件的字面断言 | 该字面量本来就是错的(deadline 从 collect 调用起算),当初只因「测试钉住了」而冻结;容器实测(role `timeoutMs`=10s 的 child 存活 56s 并 success)证明它不是 kill 预算,留错字面会继续误导排障 |
@@ -14,7 +14,7 @@ result includes structured fields for the **parent** to decide:
14
14
  - `status`: `wait_timeout` | `still_running` | `budget_exceeded` (budget optional)
15
15
  - `done` / `stillRunning[{ name, elapsedMs, budgetMs? }]`
16
16
  - `decisions`: `extend` | `steer_again` | `stop` | `force_stop` (model hint enum, not a new RPC)
17
- - `hint`: wait does not stop children; choose stop/force_stop or extend/steer
17
+ - `hint`: wait only stopped waiting — the children are still running and keep their context; prefer `status`/`extend`/`steer_again`, and use `stop`/`force_stop` only to abandon the work
18
18
 
19
19
  The parent then calls existing tools (`wait` again, `steer`, or `stop` with
20
20
  optional `force: true`). Tooling must not auto-SIGKILL on timeout.
package/docs/design.md CHANGED
@@ -928,7 +928,10 @@ subagent({
928
928
 
929
929
  // launch
930
930
  agent?: string, task?: string,
931
- tasks?: Array<{ agent: string; task: string }>, // 并行
931
+ tasks?: Array<{ agent: string; task: string; name?: string }>, // 并行
932
+ // name 是该条目自己的 handle(批量内必须互不相同,
933
+ // 省略则由 launch 自动编号 role-N);单条目时也可
934
+ // 用顶层 name=,两处给不同值会被拒
932
935
  async?: boolean, // 默认 true
933
936
  model?: string, cwd?: string, placement?: string,
934
937
  worktree?: boolean, // 隔离 git worktree(默认随角色)
@@ -80,9 +80,15 @@ Never fall back across columns: in-process limits are read from
80
80
 
81
81
  ### Renewing a budget casualty (#270)
82
82
 
83
- An agent's `timeoutMs` is 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:
83
+ An agent's `timeoutMs` is the parent's **wait** budget, not a kill switch: when
84
+ it elapses, `collect`/`wait` stop waiting and report `running`, and the child
85
+ keeps running (the watch re-arms). Nothing in pi or pi-legion terminates a child
86
+ at that deadline — verified against pi 1.4: no CLI flag, no settings key, and no
87
+ timeout is passed in a child's argv.
88
+
89
+ A child is terminated by an explicit `stop`, by session shutdown, or by a
90
+ tool-call budget cut. When that happens **and** it measurably progressed, the
91
+ completion notice ends with a ready-made command:
86
92
 
87
93
  ```
88
94
  ✗ W2 (worker) failed (terminated during tool call)
@@ -51,7 +51,11 @@ const VexillatioParams = Type.Object({
51
51
  });
52
52
  ```
53
53
 
54
- **只有 launch 语义**(无 status/collect/wait/steer/retire——用 `subagent` 工具的既有控制动作管理同名子节点;见 2.6)。执行流程(同步):
54
+ **只有 launch 语义**(无 status/collect/wait/steer/retire——用 `subagent` 工具的既有控制动作管理同名子节点;见 2.6)。
55
+
56
+ **先于下列步骤的前置校验**:`tasks[]` 条目带 `name` → 硬拒(`INVALID_PARAMS`),消息为 ``tasks[] step <i> cannot include `name`; vexillatio allocates one handle per step itself.``。命名权归 vexillatio(§2.6 `<role>-N` 接续父节点既有 children),接受该字段等于把「调用方指定的 handle 静默失效」搬进工具人通道。
57
+
58
+ 执行流程(同步):
55
59
 
56
60
  1. 解析 agent:默认 `scout`;目标必须在 catalog 里且 `utility === true`,否则报错 `not a utility agent: <name> (mark it with 'utility: true')`。
57
61
  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)。
package/index.ts CHANGED
@@ -61,7 +61,7 @@ import { appendEvent, maxEventId } from "./src/legion/events.ts";
61
61
  import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
62
62
  import { getChildByName, getChildren, getNode, getSubtree, insertNode, isTerminalNode, LIVE_CHILD_STATUSES, type LegionNode, updateNode } from "./src/legion/nodes.ts";
63
63
  import { markSessionInterrupted } from "./src/legion/interrupt.ts";
64
- import { type LegionSettings } from "./src/shared/types.ts";
64
+ import { type ExecutionStatus, type LegionSettings } from "./src/shared/types.ts";
65
65
  import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
66
66
  import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
67
67
  import {
@@ -247,6 +247,12 @@ void phaseMachineSchemaParity;
247
247
  const TaskItem = Type.Object({
248
248
  agent: Type.String({ description: "Name of the agent to invoke" }),
249
249
  task: Type.String({ description: "Task to delegate" }),
250
+ name: Type.Optional(
251
+ Type.String({
252
+ description:
253
+ "Explicit handle for THIS child (same grammar as the top-level `name`: [a-z][a-z0-9_-]{0,31}). Omit to auto-allocate `<role>-N`. Every handle in one tasks[] batch must be distinct. A one-entry batch may name its child here or at the call level, but not two different values. `vexillatio` allocates names itself and refuses this field.",
254
+ }),
255
+ ),
250
256
  cwd: Type.Optional(
251
257
  Type.String({
252
258
  description:
@@ -288,7 +294,7 @@ const SubagentParams = Type.Object({
288
294
  Type.Literal("stop"),
289
295
  Type.Literal("diagnose"),
290
296
  ],
291
- { description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
297
+ { description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. A `stop` that reaches a child which already finished reports it as `already retired` / `Retired … already-settled` (nothing was interrupted), not as a failed stop. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
292
298
  ),
293
299
  ),
294
300
  agent: Type.Optional(
@@ -3677,7 +3683,7 @@ interface LaunchSession {
3677
3683
  */
3678
3684
  async function launchStep(
3679
3685
  session: LaunchSession,
3680
- step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
3686
+ step: { agent: string; task: string; name?: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
3681
3687
  ): Promise<void> {
3682
3688
  if (session.signal?.aborted) return;
3683
3689
  const agent = findAgent(session.agents, step.agent);
@@ -3747,13 +3753,16 @@ async function launchStep(
3747
3753
  return;
3748
3754
  }
3749
3755
 
3750
- // Honor `name=` when the caller asked for one; otherwise let `launch()`
3751
- // allocate from the supervisor-local registry (scout-N). The index must
3752
- // not race separate subagent tool calls for auto names.
3756
+ // Honor `name=` in this order: a per-item `tasks[]` handle wins over the
3757
+ // call-level `name=` (which applies to a single-step launch), and when
3758
+ // neither is given `launch()` allocates from the supervisor-local registry
3759
+ // (scout-N). The index must not race separate subagent tool calls for auto
3760
+ // names.
3761
+ const name = step.name ?? session.launchName;
3753
3762
  const handle = await session.orchestrator.launch({
3754
3763
  agent: launchedAgent,
3755
3764
  task: step.task,
3756
- ...(session.launchName ? { name: session.launchName } : {}),
3765
+ ...(name ? { name } : {}),
3757
3766
  ...(session.teamOverride ? { team: session.teamOverride } : {}),
3758
3767
  ...(resolved.model ? { model: resolved.model } : {}),
3759
3768
  // Pin the provider (#123): a bare id like `workbuddy/...` may also
@@ -4010,6 +4019,7 @@ type Plan =
4010
4019
  steps: Array<{
4011
4020
  agent: string;
4012
4021
  task: string;
4022
+ name?: string;
4013
4023
  model?: string;
4014
4024
  preset?: string;
4015
4025
  worktree?: boolean;
@@ -4042,6 +4052,16 @@ function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
4042
4052
  message: `tasks[] step ${unsupportedVerify + 1} cannot include \`verify\`; vexillatio does not run verification commands.`,
4043
4053
  };
4044
4054
  }
4055
+ // `name` is a `subagent` concept: vexillatio allocates one handle per step
4056
+ // itself (`<role>-N`, continuing the parent's existing children), so a
4057
+ // per-item handle cannot be honored — refuse it instead of silently dropping.
4058
+ const unsupportedName = steps.findIndex((item) => item.name !== undefined);
4059
+ if (unsupportedName >= 0) {
4060
+ return {
4061
+ ok: false,
4062
+ message: `tasks[] step ${unsupportedName + 1} cannot include \`name\`; vexillatio allocates one handle per step itself.`,
4063
+ };
4064
+ }
4045
4065
  const invalid = steps.findIndex((item) => !item.agent.trim() || !item.task.trim());
4046
4066
  if (invalid >= 0) {
4047
4067
  return { ok: false, message: `tasks[] step ${invalid + 1} must include non-blank \`agent\` and \`task\`.` };
@@ -4485,10 +4505,12 @@ export function resolveExplicitLaunchName(
4485
4505
  export function buildPlan(params: {
4486
4506
  agent?: string;
4487
4507
  task?: string;
4508
+ name?: string;
4488
4509
  preset?: string;
4489
4510
  tasks?: Array<{
4490
4511
  agent: string;
4491
4512
  task: string;
4513
+ name?: string;
4492
4514
  model?: string;
4493
4515
  preset?: string;
4494
4516
  worktree?: boolean;
@@ -4534,19 +4556,85 @@ export function buildPlan(params: {
4534
4556
  const tasks = params.tasks as PlanStep[];
4535
4557
  const problem = firstInvalidStep(tasks, "tasks[]");
4536
4558
  if (problem) return { ok: false, message: problem };
4537
- return { ok: true, task: `${tasks.length} parallel tasks`, steps: tasks };
4559
+ // A per-item `name` is a handle the caller will later steer/collect by, so a
4560
+ // name that cannot be honored is refused for the WHOLE batch (see
4561
+ // `resolveStepNames`) before any child is allocated.
4562
+ const named = resolveStepNames(tasks, "tasks[]", params.name);
4563
+ if (!named.ok) return { ok: false, message: named.message };
4564
+ return { ok: true, task: `${tasks.length} parallel tasks`, steps: named.steps };
4538
4565
  }
4539
4566
 
4540
4567
  /** One entry of `tasks[]`. */
4541
4568
  interface PlanStep {
4542
4569
  agent: string;
4543
4570
  task: string;
4571
+ name?: string;
4544
4572
  model?: string;
4545
4573
  preset?: string;
4546
4574
  worktree?: boolean;
4547
4575
  verify?: string[];
4548
4576
  }
4549
4577
 
4578
+ /**
4579
+ * Resolve the per-item `name` handles of one `tasks[]` batch.
4580
+ *
4581
+ * Whole-batch rejection on purpose: a half-named batch would launch some
4582
+ * children under the caller's handles and the rest under auto `role-N`, so the
4583
+ * caller could neither predict nor control which child it later steers by name.
4584
+ * Nothing is allocated yet (this runs before every launch), so refusing the
4585
+ * batch costs nothing — unlike a partial start, which cannot be un-spawned.
4586
+ *
4587
+ * Returns a NEW array carrying the normalized handles; the caller's array is
4588
+ * never rewritten, and an entry without a name keeps no `name` key at all.
4589
+ */
4590
+ function resolveStepNames(
4591
+ steps: PlanStep[],
4592
+ label: string,
4593
+ topLevelName: string | undefined,
4594
+ ): { ok: true; steps: PlanStep[] } | { ok: false; message: string } {
4595
+ const firstByName = new Map<string, number>();
4596
+ const resolved: PlanStep[] = [];
4597
+ for (let i = 0; i < steps.length; i += 1) {
4598
+ const step = steps[i] ?? ({} as PlanStep);
4599
+ if (step.name === undefined) {
4600
+ resolved.push(step);
4601
+ continue;
4602
+ }
4603
+ // Reuse the single-launch gate so trim/lowercase normalization and the
4604
+ // grammar refusal can never drift between `name=` and `tasks[].name`.
4605
+ const gate = resolveExplicitLaunchName(step.name, 1);
4606
+ if (!gate.ok) {
4607
+ return { ok: false, message: `${label}: step ${i + 1}: ${gate.message}` };
4608
+ }
4609
+ const name = gate.name as string;
4610
+ const first = firstByName.get(name);
4611
+ if (first !== undefined) {
4612
+ return {
4613
+ ok: false,
4614
+ message: `${label}: steps ${first + 1} and ${i + 1} both ask for the name "${name}"; each child needs its own handle.`,
4615
+ };
4616
+ }
4617
+ firstByName.set(name, i);
4618
+ resolved.push({ ...step, name });
4619
+ }
4620
+ // A one-entry batch can name the child in both places. Agreement passes; a
4621
+ // disagreement is refused rather than letting one silently win, because the
4622
+ // child would then answer to a handle the caller did not mean to use.
4623
+ const only = resolved[0];
4624
+ if (resolved.length === 1 && only?.name !== undefined && topLevelName !== undefined) {
4625
+ const top = resolveExplicitLaunchName(topLevelName, 1);
4626
+ // An invalid top-level name keeps its own existing refusal (`name=` is
4627
+ // validated by the launch handler); only a valid, differing name conflicts.
4628
+ if (top.ok && top.name !== undefined && top.name !== only.name) {
4629
+ return {
4630
+ ok: false,
4631
+ message: `${label}: step 1 asks for the name "${only.name}", but the call also passes name="${topLevelName.trim()}"; pass one.`,
4632
+ };
4633
+ }
4634
+ }
4635
+ return { ok: true, steps: resolved };
4636
+ }
4637
+
4550
4638
  /**
4551
4639
  * True when a value is a non-blank string.
4552
4640
  * `Boolean(" ")` is true, so a whitespace-only task would otherwise pass and
@@ -5042,7 +5130,7 @@ export const WAIT_PARENT_DECISIONS = [
5042
5130
  export type WaitParentDecision = (typeof WAIT_PARENT_DECISIONS)[number];
5043
5131
 
5044
5132
  export const WAIT_TIMEOUT_HINT =
5045
- "wait does not stop children; choose stop/force_stop or extend/steer";
5133
+ "wait only stopped waiting: the children are still running and keep their context. Prefer status/extend/steer_again; stop/force_stop retire them and discard that context, so use them only to abandon the work.";
5046
5134
 
5047
5135
  export interface WaitStillRunningChild {
5048
5136
  name: string;
@@ -5160,10 +5248,93 @@ export interface StopRetireScope {
5160
5248
  runId?: string | undefined;
5161
5249
  }
5162
5250
 
5251
+ /** What a child had already done by the time `stop` reached it. */
5252
+ interface StopSettled {
5253
+ status?: ExecutionStatus;
5254
+ /** ISO settle time, when the runtime observed one. */
5255
+ at?: string;
5256
+ }
5257
+
5163
5258
  /** One node `stop` will retire, with the run that owns it (#312). */
5164
5259
  interface StopTarget {
5165
5260
  name: string;
5166
5261
  runId: string;
5262
+ /**
5263
+ * Set when the child had already finished: retiring it only reaps it, and the
5264
+ * reply has to say so instead of claiming an interruption.
5265
+ */
5266
+ settled?: StopSettled;
5267
+ }
5268
+
5269
+ /** The targets a named `stop` resolved, plus why it resolved none. */
5270
+ interface StopResolution {
5271
+ targets: StopTarget[];
5272
+ /** Present when the name matched a record that is already retired. */
5273
+ note?: string;
5274
+ }
5275
+
5276
+ /**
5277
+ * A child that already finished is reaped, not stopped.
5278
+ *
5279
+ * `awaiting` means the runtime already collected a terminal snapshot; `exited`
5280
+ * means its process is gone. A record whose execution is still `running` is NOT
5281
+ * settled, even when a lifecycle state raced ahead of the process.
5282
+ */
5283
+ function settledFromRecord(child: ChildRecord): StopSettled | undefined {
5284
+ if (child.state !== "awaiting" && child.state !== "exited") return undefined;
5285
+ const status = child.execution?.status;
5286
+ if (status === "running") return undefined;
5287
+ return status ? { status } : {};
5288
+ }
5289
+
5290
+ /** The runtime job knows WHEN it settled; the outcome lives on the record, not on it. */
5291
+ function settledFromJob(job: { state: string; settledAt?: number }): StopSettled | undefined {
5292
+ if (job.state !== "awaiting") return undefined;
5293
+ return job.settledAt ? { at: new Date(job.settledAt).toISOString() } : {};
5294
+ }
5295
+
5296
+ /**
5297
+ * Resolve ONE named target from a persisted record.
5298
+ *
5299
+ * A `retired` record yields no target plus a note carrying its outcome and retire
5300
+ * time — otherwise the caller learns only "nothing to stop", which is exactly how
5301
+ * a child that finished a second earlier reads as a failed stop.
5302
+ */
5303
+ function stopResolutionFromRecord(child: ChildRecord, runId: string): StopResolution {
5304
+ if (child.state === "retired") {
5305
+ return { targets: [], note: retiredRecordNote(child) };
5306
+ }
5307
+ const settled = settledFromRecord(child);
5308
+ return { targets: [{ name: child.name, runId, ...(settled ? { settled } : {}) }] };
5309
+ }
5310
+
5311
+ /** Same, for a live job the session tracks (the job table never holds `retired` in production). */
5312
+ function stopResolutionFromJob(job: {
5313
+ name: string;
5314
+ runId: string;
5315
+ state: string;
5316
+ settledAt?: number;
5317
+ }): StopResolution {
5318
+ if (job.state === "retired") return { targets: [], note: `${job.name} is already retired.` };
5319
+ const settled = settledFromJob(job);
5320
+ return { targets: [{ name: job.name, runId: job.runId, ...(settled ? { settled } : {}) }] };
5321
+ }
5322
+
5323
+ /** `worker-2 is already retired (execution: success, retired at 2026-…).` */
5324
+ function retiredRecordNote(child: ChildRecord): string {
5325
+ const bits: string[] = [];
5326
+ const status = child.execution?.status;
5327
+ if (status) bits.push(`execution: ${status}`);
5328
+ if (child.retiredAt) bits.push(`retired at ${child.retiredAt}`);
5329
+ return `${child.name} is already retired${bits.length > 0 ? ` (${bits.join(", ")})` : ""}.`;
5330
+ }
5331
+
5332
+ /** ` (execution: success, settled at 2026-…)`, or "" when nothing is known. */
5333
+ function stopSettledNote(settled: StopSettled | undefined): string {
5334
+ const bits: string[] = [];
5335
+ if (settled?.status) bits.push(`execution: ${settled.status}`);
5336
+ if (settled?.at) bits.push(`settled at ${settled.at}`);
5337
+ return bits.length > 0 ? ` (${bits.join(", ")})` : "";
5167
5338
  }
5168
5339
 
5169
5340
  /**
@@ -5258,8 +5429,13 @@ export async function stopAction(input: {
5258
5429
  const jobs = runtime.activeJobs();
5259
5430
  const active = jobs.filter((job) => job.state !== "retired");
5260
5431
  let targets: StopTarget[];
5432
+ /** Describes the already-retired record a NAMED stop resolved to no target. */
5433
+ let alreadyRetired: string | undefined;
5261
5434
  if (params.all) {
5262
- targets = active.map((job) => ({ name: job.name, runId: job.runId }));
5435
+ targets = active.map((job) => {
5436
+ const settled = settledFromJob(job);
5437
+ return { name: job.name, runId: job.runId, ...(settled ? { settled } : {}) };
5438
+ });
5263
5439
  } else {
5264
5440
  // Local const so the earlier `name`/`all` guard narrows through the lookup.
5265
5441
  const name = params.name;
@@ -5311,33 +5487,48 @@ export async function stopAction(input: {
5311
5487
  if (!permissive) {
5312
5488
  return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
5313
5489
  }
5314
- targets = permissive.child.state === "retired" ? [] : [{ name: permissive.child.name, runId: permissive.runId }];
5490
+ const resolved = stopResolutionFromRecord(permissive.child, permissive.runId);
5491
+ targets = resolved.targets;
5492
+ alreadyRetired = resolved.note;
5315
5493
  } else {
5316
5494
  return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
5317
5495
  }
5318
5496
  } else {
5319
- targets = found.child.state === "retired" ? [] : [{ name: found.child.name, runId: found.runId }];
5497
+ const resolved = stopResolutionFromRecord(found.child, found.runId);
5498
+ targets = resolved.targets;
5499
+ alreadyRetired = resolved.note;
5320
5500
  }
5321
5501
  } else {
5322
- targets = target.state === "retired" ? [] : [{ name: target.name, runId: target.runId }];
5502
+ const resolved = stopResolutionFromJob(target);
5503
+ targets = resolved.targets;
5504
+ alreadyRetired = resolved.note;
5323
5505
  }
5324
5506
  }
5325
- if (targets.length === 0) return ok("No non-retired children to stop.");
5507
+ if (targets.length === 0) {
5508
+ return ok(
5509
+ alreadyRetired ? `No non-retired children to stop. ${alreadyRetired}` : "No non-retired children to stop.",
5510
+ );
5511
+ }
5326
5512
 
5327
5513
  // Stop every watcher before any retire starts: a later target must not finish
5328
5514
  // its own watch (and notify/release) while an earlier target is being retired.
5329
5515
  for (const { name } of targets) runtime.cancelWatch(name);
5330
5516
 
5331
5517
  const stopped: string[] = [];
5518
+ const reaped: string[] = [];
5332
5519
  const failures: string[] = [];
5333
- for (const { name, runId } of targets) {
5520
+ for (const target of targets) {
5521
+ const { name, runId } = target;
5334
5522
  try {
5335
5523
  // The runtime job may have been released after the target snapshot was
5336
5524
  // taken. `scope` hands the resolve-time run to the executor, which reads
5337
5525
  // THAT record rather than picking one across runs (#312).
5338
5526
  await retire(name, retireOpts, { runId });
5339
5527
  runtime.release(name);
5340
- stopped.push(name);
5528
+ // A child that had already finished is reaped, not stopped: "Stopped 1
5529
+ // child" for a success claims an interruption that never happened.
5530
+ if (target.settled) reaped.push(`${name}${stopSettledNote(target.settled)}`);
5531
+ else stopped.push(name);
5341
5532
  } catch (error) {
5342
5533
  // If the retire chain fails, restore the completion watcher so a
5343
5534
  // still-live child is not orphaned by the explicit stop attempt.
@@ -5346,9 +5537,16 @@ export async function stopAction(input: {
5346
5537
  }
5347
5538
  }
5348
5539
  const mode = force ? "force-stopped" : "Stopped";
5349
- const summary = stopped.length
5350
- ? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
5351
- : "No children were stopped.";
5540
+ const lines: string[] = [];
5541
+ if (stopped.length > 0) {
5542
+ lines.push(`${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`);
5543
+ }
5544
+ if (reaped.length > 0) {
5545
+ lines.push(
5546
+ `Retired ${reaped.length} already-settled child${reaped.length === 1 ? "" : "ren"}: ${reaped.join(", ")}.`,
5547
+ );
5548
+ }
5549
+ const summary = lines.length > 0 ? lines.join("\n") : "No children were stopped.";
5352
5550
  return failures.length
5353
5551
  ? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
5354
5552
  : ok(summary);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,7 +72,14 @@ export interface RenewalProposal {
72
72
  export interface CompletionInput {
73
73
  name: string;
74
74
  agent?: string;
75
- execution: { status: string; reason?: string };
75
+ execution: {
76
+ status: string;
77
+ reason?: string;
78
+ /** Provider transcript detail; shown only when `reason` cannot carry it. */
79
+ stopReason?: string | null;
80
+ errorMessage?: string | null;
81
+ errorKind?: string;
82
+ };
76
83
  output: string;
77
84
  /** Full output on disk; the truncated preview names this path. */
78
85
  outputFile?: string;
@@ -106,13 +113,31 @@ export interface CompletionDetails {
106
113
  status: CompletionStatus;
107
114
  name: string;
108
115
  agent?: string;
109
- execution: { status: string; reason?: string };
116
+ execution: {
117
+ status: string;
118
+ reason?: string;
119
+ stopReason?: string | null;
120
+ errorMessage?: string | null;
121
+ errorKind?: string;
122
+ };
110
123
  acceptance?: { status: string; level?: string };
111
124
  sessionFile?: string;
112
125
  outputBytes: number;
113
126
  recycled?: boolean;
114
127
  }
115
128
 
129
+ /**
130
+ * Provider text the synthetic `reason` cannot carry (`unknown stopReason: …`).
131
+ *
132
+ * Display-only and bounded: an HTTP error body can be a whole HTML page, and a
133
+ * notice is a summary. Absent when the message is already the reason.
134
+ */
135
+ function errorDetailFor(execution: { reason?: string; errorMessage?: string | null }): string | undefined {
136
+ const message = execution.errorMessage?.trim();
137
+ if (!message || message === execution.reason?.trim()) return undefined;
138
+ return `error: ${message.length > 400 ? `${message.slice(0, 400)}…` : message}`;
139
+ }
140
+
116
141
  /** `1234` -> `1.2 KB`, so the collapsed line says how much landed. */
117
142
  export function formatSize(bytes: number): string {
118
143
  if (bytes < 1024) return `${bytes} B`;
@@ -318,6 +343,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
318
343
  `Background task ${status}: **${label}**`,
319
344
  "",
320
345
  `execution: ${input.execution.status}${reason}`,
346
+ errorDetailFor(input.execution),
321
347
  acceptance,
322
348
  "",
323
349
  preview,
@@ -344,6 +370,9 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
344
370
  execution: {
345
371
  status: input.execution.status,
346
372
  reason: input.execution.reason,
373
+ ...(input.execution.stopReason !== undefined ? { stopReason: input.execution.stopReason } : {}),
374
+ ...(input.execution.errorMessage !== undefined ? { errorMessage: input.execution.errorMessage } : {}),
375
+ ...(input.execution.errorKind !== undefined ? { errorKind: input.execution.errorKind } : {}),
347
376
  },
348
377
  acceptance: input.acceptance
349
378
  ? {
@@ -8,10 +8,10 @@ export const PARENT_PLAYBOOK = [
8
8
  "Pi Legion playbook (frozen — do not invent a launch recipe):",
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
- " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
11
+ " Parallel: subagent({ tasks: [{ agent, task, name?: \"scout-a\" }, { agent, task }] }) // a per-item name= is that child's handle; every handle in one batch must differ",
12
12
  "2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree — and since only a centurion may hold one, that is the only launch that goes over rpc. No pane or tab is created.",
13
13
  "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
- "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
14
+ "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. A `stop` that reaches a child which already finished answers `Retired … already-settled` (or `… is already retired`) — nothing was interrupted, so that is not a failed stop. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation belongs to centurion: each centurion owns one worktree, and members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
@@ -60,7 +60,18 @@ import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
60
60
  import type { PhaseMachine } from "../legion/phase-machine.ts";
61
61
 
62
62
  export interface CollectSnapshot {
63
- execution: { status: string; reason?: string };
63
+ execution: {
64
+ status: string;
65
+ reason?: string;
66
+ /**
67
+ * Failure detail carried to the parent notice. `reason` is often a synthetic
68
+ * sentence (`unknown stopReason: …`), so the provider text is kept beside it
69
+ * instead of being lost between the transcript and the notice.
70
+ */
71
+ stopReason?: string | null;
72
+ errorMessage?: string | null;
73
+ errorKind?: string;
74
+ };
64
75
  output: string;
65
76
  /** Full output persisted to disk; named in the notice so a truncated preview has a one-step recovery. */
66
77
  outputFile?: string;
@@ -309,13 +309,15 @@ export const COLLECT_GRACE_EXTENSIONS = 2;
309
309
  /**
310
310
  * Shown when this collect() call's budget elapses and the child is still in-turn.
311
311
  *
312
- * The "cumulative since launch" wording below is legacy: `collect()` starts its
313
- * deadline from the moment it is called (`orchestrator.ts` `let deadline = ...`),
314
- * not from launch. The literal is user-visible and asserted in integration tests,
315
- * so D6 of `docs/contracts/vexillatio-nonblocking-launch-v1.md` deliberately left
316
- * it untouched; do not read it as the actual budget origin.
312
+ * The budget is per collect call, never cumulative since launch: the deadline is
313
+ * `now + timeoutMs` at the top of `collect()`. The old literal claimed otherwise
314
+ * and was frozen only because integration tests pinned the string; the wording is
315
+ * honest now.
316
+ *
317
+ * Elapsing here NEVER terminates the child: `classifyOutcome` reports `running`,
318
+ * `shouldRecycleAfterCollect` refuses to recycle it, and the watch re-arms.
317
319
  */
318
- export const COLLECT_DEADLINE_REASON = "collect deadline (cumulative since launch) exceeded; child still alive";
320
+ export const COLLECT_DEADLINE_REASON = "collect deadline (per collect attempt) exceeded; child still alive";
319
321
  /** Shown when a live child's session ends on an OpenAI/GPT connection blip. */
320
322
  export const TRANSIENT_CONNECTION_REASON = "transient connection error; RPC child still alive";
321
323
  /**
@@ -2890,9 +2892,10 @@ export class Orchestrator {
2890
2892
  if (execution.status !== "running") this.recordSettled(child, execution, isLastTurnComplete(parsed));
2891
2893
  if (!isRetired()) this.onChildUpdate(child);
2892
2894
  const output = execution.status !== "running" && parsed.lastTurnOutput === null ? "" : parsed.output;
2893
- if (isRetired()) {
2894
- return { execution, output, usage: parsed.usage, model: parsed.model ?? child.model ?? null, acceptance };
2895
- }
2895
+ // A stop can land between the snapshot and here. The lifecycle state must not
2896
+ // be revived (above), but the terminal text still belongs on disk: this collect
2897
+ // is the only writer of the artifact, so returning early here dropped the
2898
+ // child's last answer whenever an explicit stop won the race.
2896
2899
  let outputFile: string | undefined;
2897
2900
  if (execution.status === "running") child.outputFile = undefined;
2898
2901
  else {