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 +29 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +217 -19
- package/package.json +1 -1
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +12 -1
- package/src/runs/orchestrator.ts +12 -9
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
|
-
→
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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=`
|
|
3751
|
-
//
|
|
3752
|
-
//
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
5497
|
+
const resolved = stopResolutionFromRecord(found.child, found.runId);
|
|
5498
|
+
targets = resolved.targets;
|
|
5499
|
+
alreadyRetired = resolved.note;
|
|
5320
5500
|
}
|
|
5321
5501
|
} else {
|
|
5322
|
-
|
|
5502
|
+
const resolved = stopResolutionFromJob(target);
|
|
5503
|
+
targets = resolved.targets;
|
|
5504
|
+
alreadyRetired = resolved.note;
|
|
5323
5505
|
}
|
|
5324
5506
|
}
|
|
5325
|
-
if (targets.length === 0)
|
|
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
|
|
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
|
|
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
|
|
5350
|
-
|
|
5351
|
-
|
|
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.
|
|
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",
|
package/src/extension/notify.ts
CHANGED
|
@@ -72,7 +72,14 @@ export interface RenewalProposal {
|
|
|
72
72
|
export interface CompletionInput {
|
|
73
73
|
name: string;
|
|
74
74
|
agent?: string;
|
|
75
|
-
execution: {
|
|
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: {
|
|
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.",
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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: {
|
|
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;
|
package/src/runs/orchestrator.ts
CHANGED
|
@@ -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
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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 (
|
|
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
|
-
|
|
2894
|
-
|
|
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 {
|