pi-legion 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
|
@@ -48,9 +48,9 @@ import {
|
|
|
48
48
|
import type { StatusVizSnapshot } from "../tui/status.ts";
|
|
49
49
|
import { createLegionViewFactory } from "../tui/viz/view.ts";
|
|
50
50
|
import {
|
|
51
|
-
parseSessionMessages,
|
|
52
51
|
type ChatItemSettings,
|
|
53
52
|
} from "../tui/chat-items.ts";
|
|
53
|
+
import { readSessionMessagesFile } from "../tui/session-messages-file.ts";
|
|
54
54
|
import {
|
|
55
55
|
createTreeViewFactory,
|
|
56
56
|
type TreeNodeSnapshot,
|
|
@@ -96,8 +96,8 @@ export interface LegionCommandDeps {
|
|
|
96
96
|
bus: LegionEventBus;
|
|
97
97
|
runtime: LegionCommandRuntime;
|
|
98
98
|
supervisor: RpcEventSource;
|
|
99
|
-
/**
|
|
100
|
-
stop?: (params: { name?: string; all?: boolean }) => Promise<{ ok: boolean; text: string }>;
|
|
99
|
+
/** Retire via the subagent `stop` action (see legion-stop.ts); pass `force: true` for hard retire. */
|
|
100
|
+
stop?: (params: { name?: string; all?: boolean; force?: boolean }) => Promise<{ ok: boolean; text: string }>;
|
|
101
101
|
/**
|
|
102
102
|
* Persisted child snapshot used by `subagent({ action: "status" })`.
|
|
103
103
|
* Undefined when this process has no run-store record for `name`.
|
|
@@ -255,13 +255,13 @@ function readSessionBackfill(
|
|
|
255
255
|
sessionFile: string,
|
|
256
256
|
_maxBackfillTurns: number,
|
|
257
257
|
): LiveOverlayBackfillResult | undefined {
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
if (fileSize === undefined) return undefined;
|
|
258
|
+
// Incremental reader (#234): cold cache reads from byte 0 once; later polls
|
|
259
|
+
// seek past the fingerprint offset. Take size via the reader so a concurrent
|
|
260
|
+
// append is visible on the next tick.
|
|
262
261
|
try {
|
|
263
|
-
const
|
|
264
|
-
|
|
262
|
+
const loaded = readSessionMessagesFile(sessionFile);
|
|
263
|
+
if (!loaded) return undefined;
|
|
264
|
+
return { messages: loaded.messages, cursor: sessionCursor(loaded.messages, loaded.fileSize) };
|
|
265
265
|
} catch {
|
|
266
266
|
return undefined;
|
|
267
267
|
}
|
|
@@ -271,27 +271,29 @@ function readSessionDelta(
|
|
|
271
271
|
sessionFile: string,
|
|
272
272
|
cursor: LiveOverlayBackfillCursor,
|
|
273
273
|
): LiveOverlayBackfillResult | undefined {
|
|
274
|
-
//
|
|
274
|
+
// Size gate first: unchanged append-only file ⇒ no open/parse (#234).
|
|
275
275
|
const fileSize = readSessionSize(sessionFile);
|
|
276
276
|
if (fileSize === undefined || fileSize === cursor.fileSize) return undefined;
|
|
277
|
-
let
|
|
277
|
+
let loaded: ReturnType<typeof readSessionMessagesFile>;
|
|
278
278
|
try {
|
|
279
|
-
|
|
279
|
+
loaded = readSessionMessagesFile(sessionFile);
|
|
280
280
|
} catch {
|
|
281
281
|
return undefined;
|
|
282
282
|
}
|
|
283
|
-
|
|
283
|
+
if (!loaded) return undefined;
|
|
284
|
+
const messages = loaded.messages;
|
|
285
|
+
const nextCursor = sessionCursor(messages, loaded.fileSize);
|
|
284
286
|
if (
|
|
285
287
|
nextCursor.messageCount < cursor.messageCount ||
|
|
286
288
|
nextCursor.turnCount < cursor.turnCount ||
|
|
287
289
|
nextCursor.assistantMessageCount < cursor.assistantMessageCount ||
|
|
288
|
-
fileSize < cursor.fileSize
|
|
290
|
+
loaded.fileSize < cursor.fileSize
|
|
289
291
|
) {
|
|
290
292
|
// Do not move a monotonic cursor backwards during a concurrent partial write.
|
|
291
293
|
return undefined;
|
|
292
294
|
}
|
|
293
|
-
// Return the
|
|
294
|
-
//
|
|
295
|
+
// Return the accumulated transcript; the viewer slices by message cursor.
|
|
296
|
+
// Hot path only parsed bytes past the shared session.ts fingerprint offset.
|
|
295
297
|
return { messages, cursor: nextCursor };
|
|
296
298
|
}
|
|
297
299
|
|
|
@@ -312,6 +314,8 @@ function liveNode(
|
|
|
312
314
|
...(node.turns === undefined ? {} : { turns: node.turns }),
|
|
313
315
|
sessionFile: node.sessionFile,
|
|
314
316
|
worktreePath: node.worktreePath,
|
|
317
|
+
// #209/D5: absent for legacy nodes; the footer omits the segment entirely.
|
|
318
|
+
...(node.spawnMode === undefined ? {} : { spawnMode: node.spawnMode }),
|
|
315
319
|
};
|
|
316
320
|
}
|
|
317
321
|
|
|
@@ -355,7 +359,18 @@ function treeForLiveSource(
|
|
|
355
359
|
deps.bus.on(type, (event) => {
|
|
356
360
|
if (event.nodeId !== nodeId) return;
|
|
357
361
|
if (event.id === undefined && !processEventTypes.has(event.type)) return;
|
|
358
|
-
|
|
362
|
+
// #261: `text_delta` is one streamed token. The token carries no tree state, so
|
|
363
|
+
// the snapshot below would only re-read what it already has — and
|
|
364
|
+
// `deps.runtime.treeNodes()` is a synchronous SQLite subtree scan (~0.95–1.09 ms
|
|
365
|
+
// p50 at 120 nodes), once per token for the whole life of the modal. Usage rows
|
|
366
|
+
// land via other paths, so a skipped snapshot defers their pickup to the next
|
|
367
|
+
// non-delta event (the backfill poll keeps the transcript current). Same
|
|
368
|
+
// exclusion the /legion tree view's subscription in this file and the status
|
|
369
|
+
// board in runtime.ts already apply.
|
|
370
|
+
//
|
|
371
|
+
// The listener below still fires: this event's only job here is the transcript,
|
|
372
|
+
// so do not re-add a refresh on the `text_delta` branch.
|
|
373
|
+
if (event.type !== "text_delta") refresh();
|
|
359
374
|
listener({
|
|
360
375
|
...(event.id === undefined ? {} : { id: event.id }),
|
|
361
376
|
domain: event.id === undefined ? "process" : "stored",
|
|
@@ -429,6 +444,17 @@ function openLiveOverlay(
|
|
|
429
444
|
nodeId: string,
|
|
430
445
|
onStop?: (nodeId: string) => void,
|
|
431
446
|
): void {
|
|
447
|
+
// #261/H1: every caller path funnels here (the view's `o`, the classic tree's
|
|
448
|
+
// `o`, a mouse click, `/legion <node>`, the status widget). Opening the same
|
|
449
|
+
// node twice used to mount a second host overlay while the first stayed
|
|
450
|
+
// pending: `controller.open()` closes the previous session but cannot resolve
|
|
451
|
+
// the previous `ui.custom()` promise, whose `done` is passed by pi into the
|
|
452
|
+
// factory. The orphaned `/legion` command promise never settled, and pi queues
|
|
453
|
+
// every later prompt behind it — the modal was there but frozen. Guarding the
|
|
454
|
+
// already-open node keeps that promise alive so closing it resolves normally.
|
|
455
|
+
// `controller.getCurrent()` is set synchronously by `open()`, so this also
|
|
456
|
+
// catches a double-open arriving in the same stdin chunk.
|
|
457
|
+
if (controller.getCurrent()?.nodeId === nodeId) return;
|
|
432
458
|
// #193: the overlay must cover the floating frame only. A full-screen overlay
|
|
433
459
|
// would make pi-tui discard every host cell (its composite has no transparency
|
|
434
460
|
// option), so the area around the frame collapsed to the terminal's default
|
|
@@ -80,7 +80,7 @@ export const LEGION_SLASH_HELP_LINES = [
|
|
|
80
80
|
"/legion summary [--all] [<name>]",
|
|
81
81
|
"/legion timing [--json] [<name>] — wall / generation / other timing for children",
|
|
82
82
|
"/legion doctor [section]",
|
|
83
|
-
"/legion stop [name|all]",
|
|
83
|
+
"/legion stop [--force|-f] [name|all]",
|
|
84
84
|
"/legion --tree | /legion <node>",
|
|
85
85
|
"/legion help",
|
|
86
86
|
] as const;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* - `/legion stop` (all) / `/legion stop <name>` — thin wrapper over the
|
|
4
4
|
* subagent tool's `stop` action (graceful retire; a retired parent's own
|
|
5
5
|
* session shutdown retires its subtree, so stopping a parent cascades).
|
|
6
|
+
* - `/legion stop --force` / `-f` — same targets with `force: true` (hard
|
|
7
|
+
* retire; see #179 / StopDeps.force).
|
|
6
8
|
* - `x` on the selected agent in the /legion view → confirm → stop it.
|
|
7
9
|
* Esc no longer stops children (esc-no-retire-v3); only `/legion stop` or
|
|
8
10
|
* process exit retires. Everything here is pure/injectable for unit tests.
|
|
@@ -49,6 +51,18 @@ function jobForNode(node: StopNode, live: readonly StopJob[]): StopJob | undefin
|
|
|
49
51
|
return live.find((job) => job.nodeId === node.id) ?? (node.depth <= 1 ? live.find((job) => !job.nodeId && job.name === node.name) : undefined);
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/** Strip `--force` / `-f` from `/legion stop` args; remainder is the name|all query. */
|
|
55
|
+
export function parseStopArg(arg: string): { query: string; force: boolean } {
|
|
56
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
57
|
+
let force = false;
|
|
58
|
+
const rest: string[] = [];
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
if (token === "--force" || token === "-f") force = true;
|
|
61
|
+
else rest.push(token);
|
|
62
|
+
}
|
|
63
|
+
return { query: rest.join(" "), force };
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
export function resolveStopTarget(arg: string, jobs: readonly StopJob[], nodes: readonly StopNode[]): StopTarget {
|
|
53
67
|
const query = arg.trim();
|
|
54
68
|
if (query === "" || query === "all") return { kind: "all" };
|
|
@@ -69,21 +83,32 @@ export function resolveStopTarget(arg: string, jobs: readonly StopJob[], nodes:
|
|
|
69
83
|
return { kind: "notFound", query };
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
/** Completions for `/legion stop <prefix>`: `all`, live
|
|
86
|
+
/** Completions for `/legion stop <prefix>`: optional `--force`, `all`, live names, nested parent/name. */
|
|
73
87
|
export function stopCompletions(prefix: string, jobs: readonly StopJob[], nodes: readonly StopNode[]): Array<{ value: string; label: string }> {
|
|
88
|
+
const { query, force } = parseStopArg(prefix);
|
|
89
|
+
const flagPrefix = force ? "--force " : "";
|
|
74
90
|
const live = liveJobs(jobs);
|
|
75
91
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
76
|
-
const out: Array<{ value: string; label: string }> = [{ value:
|
|
77
|
-
for (const job of live) out.push({ value: job.name
|
|
92
|
+
const out: Array<{ value: string; label: string }> = [{ value: `${flagPrefix}all`, label: `all (${live.length} live)` }];
|
|
93
|
+
for (const job of live) out.push({ value: `${flagPrefix}${job.name}`, label: job.name });
|
|
78
94
|
const seen = new Set(out.map((item) => item.value));
|
|
79
95
|
for (const node of nodes) {
|
|
80
96
|
if (node.depth <= 1 || !isLiveNode(node)) continue;
|
|
81
97
|
const label = nodeLabel(node, byId);
|
|
82
|
-
|
|
83
|
-
seen.
|
|
84
|
-
|
|
98
|
+
const value = `${flagPrefix}${label}`;
|
|
99
|
+
if (seen.has(value)) continue;
|
|
100
|
+
seen.add(value);
|
|
101
|
+
out.push({ value, label: `${label} (nested)` });
|
|
85
102
|
}
|
|
86
|
-
|
|
103
|
+
const trimmed = prefix.trim();
|
|
104
|
+
const offerForce =
|
|
105
|
+
!force && (trimmed === "" || "--force".startsWith(trimmed) || trimmed === "-f" || (trimmed.startsWith("-") && "-f".startsWith(trimmed)));
|
|
106
|
+
const names = out.filter((item) => {
|
|
107
|
+
const bare = force ? item.value.slice(flagPrefix.length) : item.value;
|
|
108
|
+
return bare.startsWith(query);
|
|
109
|
+
});
|
|
110
|
+
if (offerForce) names.unshift({ value: "--force", label: "--force (hard retire)" });
|
|
111
|
+
return names;
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
export interface StopDeps {
|
|
@@ -91,16 +116,16 @@ export interface StopDeps {
|
|
|
91
116
|
nodes(): readonly StopNode[];
|
|
92
117
|
/**
|
|
93
118
|
* Retire via the subagent `stop` action; resolves to its summary text.
|
|
94
|
-
*
|
|
95
|
-
* (hard retire). Default remains graceful.
|
|
119
|
+
* `/legion stop --force` passes `force: true` (hard retire). Default remains graceful.
|
|
96
120
|
*/
|
|
97
121
|
stop(params: { name?: string; all?: boolean; force?: boolean }): Promise<{ ok: boolean; text: string }>;
|
|
98
122
|
notify(message: string, type: "info" | "warning" | "error"): void;
|
|
99
123
|
}
|
|
100
124
|
|
|
101
|
-
/** `/legion stop [name|all]`. Returns the notified message (for tests). */
|
|
125
|
+
/** `/legion stop [--force|-f] [name|all]`. Returns the notified message (for tests). */
|
|
102
126
|
export async function runLegionStop(arg: string, deps: StopDeps): Promise<string> {
|
|
103
|
-
const
|
|
127
|
+
const { query, force } = parseStopArg(arg);
|
|
128
|
+
const target = resolveStopTarget(query, deps.jobs(), deps.nodes());
|
|
104
129
|
const say = (message: string, type: "info" | "warning" | "error" = "info"): string => {
|
|
105
130
|
deps.notify(message, type);
|
|
106
131
|
return message;
|
|
@@ -113,10 +138,11 @@ export async function runLegionStop(arg: string, deps: StopDeps): Promise<string
|
|
|
113
138
|
return say(`${target.label} is owned by ${target.owner}; stop ${target.owner} to retire it with its subtree (/legion stop ${target.owner}).`, "warning");
|
|
114
139
|
}
|
|
115
140
|
if (target.kind === "all" && liveJobs(deps.jobs()).length === 0) return say("Nothing running: no live subagents.");
|
|
116
|
-
const
|
|
141
|
+
const base = target.kind === "all" ? { all: true as const } : { name: target.name };
|
|
142
|
+
const result = await deps.stop(force ? { ...base, force: true } : base);
|
|
117
143
|
return say(result.text, result.ok ? "info" : "error");
|
|
118
144
|
}
|
|
119
145
|
|
|
120
146
|
/** Dim hint shown in the status strip while children are live (aboveEditor / tree). */
|
|
121
|
-
export const IDLE_STOP_HINT = "/legion stop [name|all]";
|
|
147
|
+
export const IDLE_STOP_HINT = "/legion stop [--force] [name|all]";
|
|
122
148
|
export const IDLE_STOP_HINT_SHORT = "/legion stop";
|
|
@@ -11,7 +11,7 @@ import { Type } from "typebox";
|
|
|
11
11
|
import type { LegionSettings } from "../shared/types.ts";
|
|
12
12
|
import { extractVerdict, parseSessionFile } from "../shared/session.ts";
|
|
13
13
|
import type { LegionSupervisor } from "../supervisor/types.ts";
|
|
14
|
-
import { sqliteHasRow, type DatabaseSync } from "../legion/db.ts";
|
|
14
|
+
import { sqliteHasRow, withTransaction, type DatabaseSync } from "../legion/db.ts";
|
|
15
15
|
import type { LegionEventBus } from "./event-bus.ts";
|
|
16
16
|
import { appendEvent } from "../legion/events.ts";
|
|
17
17
|
import {
|
|
@@ -137,9 +137,13 @@ export class LegionPostman {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
private refreshHeartbeats(children: readonly LegionNode[]): void {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
const alive = children.filter((child) => this.supervisor.isAlive(child.name));
|
|
141
|
+
if (alive.length === 0) return;
|
|
142
|
+
// One transaction for the whole tick: avoids per-child implicit
|
|
143
|
+
// autocommit fsync under PRAGMA synchronous=FULL (#236 P1).
|
|
144
|
+
withTransaction(this.db, () => {
|
|
145
|
+
for (const child of alive) heartbeatNode(this.db, child.id, this.now);
|
|
146
|
+
});
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
private async deliverChildren(children: readonly LegionNode[]): Promise<void> {
|
|
@@ -244,6 +248,19 @@ export class LegionPostman {
|
|
|
244
248
|
const firstRootChild = this.firstRootChild(node);
|
|
245
249
|
if (firstRootChild && this.supervisor.isAlive(firstRootChild.name)) continue;
|
|
246
250
|
if (wasOrphanReportedRecently(this.db, node.id, timestamp - ORPHAN_REPORT_WINDOW_MS)) continue;
|
|
251
|
+
// D7 (display-liveness v1): converge the ledger, do not merely report.
|
|
252
|
+
// `orphan_detected` alone only described a suspicion, so a dead child stayed
|
|
253
|
+
// `running` forever and every display exit had to special-case it. Commit the
|
|
254
|
+
// fact first and let the event stream describe a state that already exists:
|
|
255
|
+
// `liveness()` then reads a terminal row and answers `dead` on its own. `node`
|
|
256
|
+
// is the pre-convergence snapshot — `updateNode` does not mutate it — so
|
|
257
|
+
// `node.updatedAt` still names the frozen heartbeat this scan judged.
|
|
258
|
+
updateNode(this.db, node.id, { status: "failed" }, () => timestamp);
|
|
259
|
+
appendEvent(this.db, node.id, "node_abandoned", {
|
|
260
|
+
reason: "no_active_supervisor",
|
|
261
|
+
staleMs,
|
|
262
|
+
updatedAt: node.updatedAt,
|
|
263
|
+
}, { now: () => timestamp });
|
|
247
264
|
const event = appendEvent(this.db, node.id, "orphan_detected", {
|
|
248
265
|
parentId: node.parentId,
|
|
249
266
|
updatedAt: node.updatedAt,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single authority for "is this child alive?" (contract
|
|
3
|
+
* `docs/contracts/display-liveness-v1.md`, D1 / D2 / D5).
|
|
4
|
+
*
|
|
5
|
+
* Before this module every display surface answered the question its own way:
|
|
6
|
+
* the status list looked at `child.state`, the text renderer additionally
|
|
7
|
+
* computed an owner verdict, and the railway strip arbitrated an event
|
|
8
|
+
* timestamp against a ledger heartbeat written by a *different* process. The
|
|
9
|
+
* same child could therefore be `live` (list), `failed` (text) and `running`
|
|
10
|
+
* (rail) at the same instant. `liveness()` replaces all three with one ordered
|
|
11
|
+
* projection over the facts available at the moment of rendering.
|
|
12
|
+
*
|
|
13
|
+
* ## Order, not union (D1)
|
|
14
|
+
*
|
|
15
|
+
* The eight rules below form a *total order* of evidence strength, not a set of
|
|
16
|
+
* predicates OR'd together. A union would let a leftover `running` ledger row
|
|
17
|
+
* outvote "this process already knows the child is gone"; the order makes the
|
|
18
|
+
* stronger evidence win:
|
|
19
|
+
*
|
|
20
|
+
* 1. `inMemory` present and non-terminal → `live` (we are holding it)
|
|
21
|
+
* 2. `inMemory` present and terminal → `dead`
|
|
22
|
+
* 3. `pidAlive === true` → `live` (a real process runs)
|
|
23
|
+
* 4. `pidAlive === false` → `dead` (D4: no heartbeat grace)
|
|
24
|
+
* 5. `ledger` non-terminal, heartbeat fresh → `live`
|
|
25
|
+
* 6. `ledger` non-terminal, heartbeat stale → `dead` (orphan; D7 converges it)
|
|
26
|
+
* 7. `record` terminal → `dead`
|
|
27
|
+
* 8. everything else (incl. all-undefined) → `unknown`
|
|
28
|
+
*
|
|
29
|
+
* ## Three states, not two (D2)
|
|
30
|
+
*
|
|
31
|
+
* `unknown` exists and must never be rendered as `live`. Treating unknown as
|
|
32
|
+
* live is exactly the bug that printed `Status · 1 live child` next to
|
|
33
|
+
* `owner: dead (pid gone)`; treating it as dead would kill children that are
|
|
34
|
+
* merely starting. The third state is the only honest answer.
|
|
35
|
+
*
|
|
36
|
+
* ## Purity (D1, §7-7)
|
|
37
|
+
*
|
|
38
|
+
* Pure by construction: no fs, no path, no database, no timer, and **no clock
|
|
39
|
+
* read**. `now` is a parameter because a module that reads its own clock cannot
|
|
40
|
+
* be asked "what would this render as at time T" — and two reads at slightly
|
|
41
|
+
* different times would reintroduce the off-by-a-tick behaviour R2 describes.
|
|
42
|
+
* `test/unit/liveness.test.ts` asserts these properties by reading this file's
|
|
43
|
+
* source text.
|
|
44
|
+
*
|
|
45
|
+
* ## No timestamp arbitration (D3)
|
|
46
|
+
*
|
|
47
|
+
* Nothing here compares timestamps that came from different writers. The only
|
|
48
|
+
* time comparison is `now - ledger.updatedAt < staleMs`, where `now` is the
|
|
49
|
+
* caller's render clock and `updatedAt` is that same ledger row's own heartbeat —
|
|
50
|
+
* one clock family, and the comparison decides freshness rather than "whose
|
|
51
|
+
* conclusion wins". Event-bus timestamps are never consulted: events only
|
|
52
|
+
* trigger a recompute (D8).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** D2: the three truthful answers. `unknown` is a state, not a synonym for live. */
|
|
56
|
+
export type Liveness = "live" | "dead" | "unknown";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Facts for one child at one instant. Every source is optional because every
|
|
60
|
+
* source can be absent: a child may exist only in the in-memory registry, only
|
|
61
|
+
* in the ledger, only in `run.json`, or in none of them (replayed history).
|
|
62
|
+
*/
|
|
63
|
+
export interface LivenessFacts {
|
|
64
|
+
/** Entry in this process's in-memory child registry, if any. */
|
|
65
|
+
readonly inMemory?: { readonly state: string } | undefined;
|
|
66
|
+
/** Ledger row plus the heartbeat timestamp its own process last wrote. */
|
|
67
|
+
readonly ledger?: { readonly status: string; readonly updatedAt: number } | undefined;
|
|
68
|
+
/** `run.json` child record (scheduling layer). */
|
|
69
|
+
readonly record?: { readonly state: string } | undefined;
|
|
70
|
+
/** Pid-marker verdict; `undefined` means there is no marker to consult. */
|
|
71
|
+
readonly pidAlive?: boolean | undefined;
|
|
72
|
+
/** Render clock, supplied by the caller — never read inside this module. */
|
|
73
|
+
readonly now: number;
|
|
74
|
+
/** Age after which a non-terminal ledger row is an orphan (default 90s). */
|
|
75
|
+
readonly staleMs: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* States that mean "this lifecycle is over". Shared by W1/W2/W4 so the seven
|
|
80
|
+
* divergent sets listed in R3 collapse into one.
|
|
81
|
+
*
|
|
82
|
+
* Note what is *absent*: `idle` and `queued` are non-terminal (D5). An `idle`
|
|
83
|
+
* child is still alive and still holds a concurrency slot, and a `queued` child
|
|
84
|
+
* has been admitted but not started — calling either one terminal is what made
|
|
85
|
+
* the strip paint them as `retired`.
|
|
86
|
+
*/
|
|
87
|
+
export const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
|
88
|
+
"settled",
|
|
89
|
+
"success",
|
|
90
|
+
"completed",
|
|
91
|
+
"done",
|
|
92
|
+
"failed",
|
|
93
|
+
"aborted",
|
|
94
|
+
"error",
|
|
95
|
+
"truncated",
|
|
96
|
+
"retired",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/** States known to be in flight. `idle`/`queued` are alive: slot held, not working. */
|
|
100
|
+
export const NON_TERMINAL: ReadonlySet<string> = new Set([
|
|
101
|
+
"starting",
|
|
102
|
+
"working",
|
|
103
|
+
"running",
|
|
104
|
+
"blocked",
|
|
105
|
+
"idle",
|
|
106
|
+
"queued",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A state is terminal only when it is in `TERMINAL_STATES`. Anything unknown —
|
|
111
|
+
* including `""` — is *not* terminal: we do not get to declare a lifecycle over
|
|
112
|
+
* because we failed to recognise the label.
|
|
113
|
+
*/
|
|
114
|
+
export function isTerminalState(state: string | undefined): boolean {
|
|
115
|
+
return state !== undefined && TERMINAL_STATES.has(state.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* D1: one ordered projection over the facts of a single instant.
|
|
120
|
+
*
|
|
121
|
+
* Total and side-effect free: the same `facts` always produce the same answer,
|
|
122
|
+
* which is what makes "render is a projection, not an event replay" (D8)
|
|
123
|
+
* checkable — see A6.
|
|
124
|
+
*/
|
|
125
|
+
export function liveness(facts: LivenessFacts): Liveness {
|
|
126
|
+
// 1 + 2: the strongest evidence is a live handle in this very process.
|
|
127
|
+
const inMemory = facts.inMemory;
|
|
128
|
+
if (inMemory !== undefined) {
|
|
129
|
+
return isTerminalState(inMemory.state) ? "dead" : "live";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3 + 4: an actual OS process. D4 accepts the seconds-wide window where a
|
|
133
|
+
// just-exited pid still reads alive rather than inventing a grace period.
|
|
134
|
+
if (facts.pidAlive === true) return "live";
|
|
135
|
+
if (facts.pidAlive === false) return "dead";
|
|
136
|
+
|
|
137
|
+
// 5 + 6: last-written heartbeat. A frozen `updatedAt` from a dead writer is
|
|
138
|
+
// exactly the orphan case, and staleness is the only timestamp comparison
|
|
139
|
+
// here — same row, same writer, measuring age instead of ranking writers.
|
|
140
|
+
//
|
|
141
|
+
// "Non-terminal" is read as the complement of `TERMINAL_STATES` (D1's wording)
|
|
142
|
+
// rather than as membership in `NON_TERMINAL`: a row carrying a label we do
|
|
143
|
+
// not recognise has not told us the lifecycle is over, and rule 8 still keeps
|
|
144
|
+
// an *absent* ledger out of this branch.
|
|
145
|
+
const ledger = facts.ledger;
|
|
146
|
+
if (ledger !== undefined && !isTerminalState(ledger.status)) {
|
|
147
|
+
return facts.now - ledger.updatedAt < facts.staleMs ? "live" : "dead";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 7 + 8: a terminal verdict from either writer. The ledger row coming first is
|
|
151
|
+
// D1-a's 2026-10-24 amendment: a ledger row that reached `settled`/`failed`
|
|
152
|
+
// states a finished lifecycle even when the scheduler record is already gone,
|
|
153
|
+
// and leaving that case at `unknown` would make the status panel call a
|
|
154
|
+
// finished child undecided.
|
|
155
|
+
if (ledger !== undefined && isTerminalState(ledger.status)) return "dead";
|
|
156
|
+
if (facts.record !== undefined && isTerminalState(facts.record.state)) return "dead";
|
|
157
|
+
|
|
158
|
+
// 9: never guess (D2).
|
|
159
|
+
return "unknown";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The display states a node can take. Declared here rather than in
|
|
164
|
+
* `src/tui/viz/model.ts` so this module stays dependency-free and every W can
|
|
165
|
+
* import the mapping without importing the TUI; W4 feeds `VizStatus` with
|
|
166
|
+
* these values. `idle` and `queued` are new first-class states (D5), and
|
|
167
|
+
* `unknown` replaces the old `retired` fallback — an unrecognised state must
|
|
168
|
+
* not be painted as "retired".
|
|
169
|
+
*/
|
|
170
|
+
export type DisplayStatus =
|
|
171
|
+
| "running"
|
|
172
|
+
| "idle"
|
|
173
|
+
| "queued"
|
|
174
|
+
| "settled"
|
|
175
|
+
| "failed"
|
|
176
|
+
| "retired"
|
|
177
|
+
| "unknown";
|
|
178
|
+
|
|
179
|
+
const DISPLAY_RUNNING: ReadonlySet<string> = new Set(["starting", "working", "running", "blocked"]);
|
|
180
|
+
const DISPLAY_SETTLED: ReadonlySet<string> = new Set(["settled", "success", "completed", "done"]);
|
|
181
|
+
const DISPLAY_FAILED: ReadonlySet<string> = new Set(["failed", "aborted", "error", "truncated"]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* D5: raw lifecycle state → display state.
|
|
185
|
+
*
|
|
186
|
+
* `idle` maps to `idle` and `queued` to `queued` (rendered grey / `queued(n)`),
|
|
187
|
+
* the four in-flight states collapse to `running`, and everything unrecognised
|
|
188
|
+
* — including `undefined` — becomes `unknown` rather than the old `retired`
|
|
189
|
+
* fallback that made a starting child look finished.
|
|
190
|
+
*/
|
|
191
|
+
export function displayStatusFromState(state: string | undefined): DisplayStatus {
|
|
192
|
+
const value = (state ?? "").toLowerCase();
|
|
193
|
+
if (value === "idle") return "idle";
|
|
194
|
+
if (value === "queued") return "queued";
|
|
195
|
+
if (DISPLAY_SETTLED.has(value)) return "settled";
|
|
196
|
+
if (DISPLAY_FAILED.has(value)) return "failed";
|
|
197
|
+
if (DISPLAY_RUNNING.has(value)) return "running";
|
|
198
|
+
if (value === "retired") return "retired";
|
|
199
|
+
return "unknown";
|
|
200
|
+
}
|
package/src/extension/notify.ts
CHANGED
|
@@ -197,7 +197,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
|
|
|
197
197
|
input.sessionFile ? `Session file: ${input.sessionFile}` : undefined,
|
|
198
198
|
input.recycled === false
|
|
199
199
|
? undefined
|
|
200
|
-
: "
|
|
200
|
+
: "Child retired. Resume from the session file if you need this child again.",
|
|
201
201
|
]
|
|
202
202
|
.filter((line) => line !== undefined)
|
|
203
203
|
.join("\n");
|
|
@@ -234,7 +234,7 @@ export function collectFailureInput(
|
|
|
234
234
|
// Live children never reach here: watch() re-arms a connection blip.
|
|
235
235
|
// A dead child's notice names that, instead of a bare "Connection error."
|
|
236
236
|
const reason = isTransientConnectionFailure(raw)
|
|
237
|
-
? `${raw} (
|
|
237
|
+
? `${raw} (child is not alive)`
|
|
238
238
|
: raw;
|
|
239
239
|
return {
|
|
240
240
|
name,
|
|
@@ -275,8 +275,8 @@ export interface GroupedCompletionInput {
|
|
|
275
275
|
* Still running: slow-2 (notifies separately when it finishes)
|
|
276
276
|
*
|
|
277
277
|
* Retirement is per-entry (a wait()-released sibling can race the flush, so a
|
|
278
|
-
* blanket footer could be inaccurate): entries whose
|
|
279
|
-
* carry an inline "(
|
|
278
|
+
* blanket footer could be inaccurate): entries whose child is retired
|
|
279
|
+
* carry an inline "(child retired)" marker.
|
|
280
280
|
* Aggregate status: any failed → "failed"; else any stopped → "stopped";
|
|
281
281
|
* else "completed". `display` follows the single-notice rule.
|
|
282
282
|
*/
|
|
@@ -311,8 +311,8 @@ export function formatGroupedNotice(
|
|
|
311
311
|
? ` — acceptance: ${entry.acceptance.status}${entry.acceptance.level ? ` (${entry.acceptance.level})` : ""}`
|
|
312
312
|
: "";
|
|
313
313
|
// Retirement is per-entry truth: a wait()-consumed sibling may already
|
|
314
|
-
// be released while another
|
|
315
|
-
const recycled = entry.recycled === false ? "" : " (
|
|
314
|
+
// be released while another child is still alive, so no blanket footer.
|
|
315
|
+
const recycled = entry.recycled === false ? "" : " (child retired)";
|
|
316
316
|
lines.push(`- ${label}: ${entry.status}${reason}${acceptance}${recycled}`);
|
|
317
317
|
// Prefer the durable full-text artifact over the session file as the
|
|
318
318
|
// recovery pointer: one `read` beats re-deriving from jsonl/chat store.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parent-side
|
|
2
|
+
* Parent-side child dispatch guidance shared by tool descriptions and prompts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export const PLAYBOOK_NAME = "pi-legion-playbook";
|
|
@@ -9,14 +9,14 @@ export const PARENT_PLAYBOOK = [
|
|
|
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
11
|
" Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
|
|
12
|
-
"2. Prefer tasks[] for independent parallel work.
|
|
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 (centurion) or the role pins `spawnMode: 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
|
|
15
|
-
"5. A collect timeout with a live
|
|
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.",
|
|
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. For N parallel isolated writers, dispatch N centurions (each owns one worktree); 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.",
|
|
18
|
-
"Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. The `subagent` tool stays for the command chain (worker/centurion).",
|
|
19
|
-
"Forbidden: direct bash access to legion.db,
|
|
18
|
+
"Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. Its request shape matches `subagent`: either `vexillatio({ task, agent? })` or `vexillatio({ tasks: [{ agent, task }, …] })` (no top-level `task` needed; passing both only labels the parent run). The `subagent` tool stays for the command chain (worker/centurion).",
|
|
19
|
+
"Forbidden: direct bash access to legion.db, or launching child Pi processes by hand (use the subagent tool).",
|
|
20
20
|
].join("\n");
|
|
21
21
|
|
|
22
22
|
export const TOOL_DESCRIPTION = [
|
package/src/extension/recycle.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Helpers for collect/retire calls made after
|
|
1
|
+
/** Helpers for collect/retire calls made after a child is auto-retired. */
|
|
2
2
|
|
|
3
3
|
import type { TaskState } from "../shared/types.ts";
|
|
4
4
|
|
|
@@ -7,13 +7,17 @@ export function formatAlreadyRecycled(
|
|
|
7
7
|
sessionFile: string,
|
|
8
8
|
): string {
|
|
9
9
|
return (
|
|
10
|
-
`Already retired ${name}. This is a no-op — the
|
|
10
|
+
`Already retired ${name}. This is a no-op — the child is already stopped. ` +
|
|
11
11
|
`Session kept for resume: ${sessionFile}`
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** States where collect should return the snapshot already on the child. */
|
|
16
16
|
export function canUseCachedCollect(state: TaskState): boolean {
|
|
17
|
+
// `queued` is deliberately NOT cacheable (#266 D3/§4.4): the record has no
|
|
18
|
+
// execution, no acceptance and only a PLACEHOLDER session file, so a cached
|
|
19
|
+
// collect would hand back a stale/empty snapshot as if the launch had finished.
|
|
20
|
+
// A queued child must go through `collect`'s wait-for-admission path (D8).
|
|
17
21
|
return (
|
|
18
22
|
state === "retired" ||
|
|
19
23
|
state === "awaiting" ||
|