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
package/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
import { Type, type Static } from "typebox";
|
|
22
22
|
import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
|
|
23
23
|
import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
|
|
24
|
-
import { RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
24
|
+
import { notifyInProcessSlotReleased, RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
25
25
|
import type { LegionSupervisor } from "./src/supervisor/types.ts";
|
|
26
26
|
import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
|
|
27
27
|
import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
|
|
@@ -51,16 +51,17 @@ import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./sr
|
|
|
51
51
|
import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
|
|
52
52
|
import type { VerifyRunner } from "./src/runs/acceptance.ts";
|
|
53
53
|
import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
|
|
54
|
-
import { appendEvent } from "./src/legion/events.ts";
|
|
54
|
+
import { appendEvent, maxEventId } from "./src/legion/events.ts";
|
|
55
55
|
import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
|
|
56
56
|
import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
|
|
57
57
|
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
58
58
|
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
59
|
-
import { allocateSiblingNames, isValidAgentName
|
|
59
|
+
import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
|
|
60
60
|
import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
|
|
61
61
|
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
62
62
|
import { currentChildContext } from "./src/supervisor/child-context.ts";
|
|
63
63
|
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
64
|
+
import { setLaunchQueueListener } from "./src/supervisor/admission-queue.ts";
|
|
64
65
|
import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
|
|
65
66
|
import {
|
|
66
67
|
createStatusNodeOpener,
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
SUBAGENT_NOTIFY_TYPE,
|
|
84
85
|
} from "./src/extension/notify.ts";
|
|
85
86
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
87
|
+
import { renderSpawnResult } from "./src/extension/spawn-render.ts";
|
|
86
88
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
87
89
|
import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
|
|
88
90
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
@@ -107,6 +109,7 @@ import {
|
|
|
107
109
|
type SpawnMode,
|
|
108
110
|
SubagentError,
|
|
109
111
|
type SubagentsSettings,
|
|
112
|
+
type Handle,
|
|
110
113
|
} from "./src/shared/types.ts";
|
|
111
114
|
|
|
112
115
|
// ---------------------------------------------------------------------------
|
|
@@ -233,7 +236,17 @@ const SubagentParams = Type.Object({
|
|
|
233
236
|
type SubagentParams = Static<typeof SubagentParams>;
|
|
234
237
|
|
|
235
238
|
const VexillatioParams = Type.Object({
|
|
236
|
-
|
|
239
|
+
// Optional on purpose: `tasks[]` is a complete request on its own, exactly like
|
|
240
|
+
// `subagent({ tasks })`. Leaving this required made pi's own argument check
|
|
241
|
+
// reject `{ tasks: [...] }` before `execute` ran, so every `tasks[]`-only call
|
|
242
|
+
// failed with `task: must have required properties task` and had to be retried
|
|
243
|
+
// with a dummy `task` label (models copy the `subagent` shape).
|
|
244
|
+
task: Type.Optional(
|
|
245
|
+
Type.String({
|
|
246
|
+
description:
|
|
247
|
+
"Recon task: one clear question list. Optional when `tasks[]` is provided (then it only labels the parent run).",
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
237
250
|
agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
|
|
238
251
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
|
|
239
252
|
cwd: Type.Optional(Type.String()),
|
|
@@ -308,10 +321,129 @@ function currentProcessSpawnMode(): SpawnMode {
|
|
|
308
321
|
return currentChildContext()?.spawnMode ?? "rpc";
|
|
309
322
|
}
|
|
310
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The frozen interruption head line (`test/unit/teams.test.ts` matches it, and
|
|
326
|
+
* `esc-no-retire-v3` records why it leads every interrupted result).
|
|
327
|
+
*/
|
|
328
|
+
const INTERRUPTED_HEAD = "interrupted (parent turn ended; children still running)";
|
|
329
|
+
|
|
311
330
|
function notice(pi: ExtensionAPI, text: string): void {
|
|
312
331
|
pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
313
332
|
}
|
|
314
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Launch-admission-queue bounds from settings (issue #266, contract D6-a/D10).
|
|
336
|
+
*
|
|
337
|
+
* Frozen key names are `subagents.inprocess.maxQueuedLaunches` /
|
|
338
|
+
* `subagents.inprocess.launchQueueTimeoutMs`. An absent key is *omitted* rather
|
|
339
|
+
* than defaulted here: the single source of the defaults is
|
|
340
|
+
* `src/supervisor/admission-queue.ts`, and passing a literal 32/900000 from the
|
|
341
|
+
* adapter would create a second copy that can drift from the docs and sentinel.
|
|
342
|
+
*/
|
|
343
|
+
function launchQueueBounds(settings: {
|
|
344
|
+
inprocess?: { maxQueuedLaunches?: number; launchQueueTimeoutMs?: number };
|
|
345
|
+
}): { maxPending?: number; timeoutMs?: number } {
|
|
346
|
+
const bounds: { maxPending?: number; timeoutMs?: number } = {};
|
|
347
|
+
if (settings.inprocess?.maxQueuedLaunches !== undefined) bounds.maxPending = settings.inprocess.maxQueuedLaunches;
|
|
348
|
+
if (settings.inprocess?.launchQueueTimeoutMs !== undefined) bounds.timeoutMs = settings.inprocess.launchQueueTimeoutMs;
|
|
349
|
+
return bounds;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* D9/§13.4: the one user-visible line per queued / admitted / expired / stopped
|
|
354
|
+
* launch.
|
|
355
|
+
*
|
|
356
|
+
* Deliberately `triggerTurn: false` (unlike {@link notice}): admission is a
|
|
357
|
+
* mid-turn fact about work the caller already asked for, so waking the parent
|
|
358
|
+
* with a fresh turn for every queue transition would re-enter the agent for a
|
|
359
|
+
* purely cosmetic line. Anything the caller must act on still arrives through
|
|
360
|
+
* the tool result and, for `async:false`, the inline `↳ waited Ns` line.
|
|
361
|
+
*/
|
|
362
|
+
function launchQueueNoticeSink(pi: ExtensionAPI): (line: string) => void {
|
|
363
|
+
return (line: string) => {
|
|
364
|
+
try {
|
|
365
|
+
pi.sendMessage(
|
|
366
|
+
{ customType: SUBAGENT_NOTIFY_TYPE, content: line, display: true },
|
|
367
|
+
{ triggerTurn: false },
|
|
368
|
+
);
|
|
369
|
+
} catch {
|
|
370
|
+
// Cosmetic: a gone parent session must never break admission.
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* D9-1: the launch line's queue suffix, or `""` for a launch that spawned now.
|
|
377
|
+
*
|
|
378
|
+
* `▶ scout-7 (scout) supervisor=inprocess queued(3)` — the position is what the
|
|
379
|
+
* operator needs to know the launch is waiting rather than broken. Only the
|
|
380
|
+
* trailing segment is added: the line's published prefix and `details.spawns`
|
|
381
|
+
* (a renderer input) keep their existing shapes.
|
|
382
|
+
*/
|
|
383
|
+
function launchLineQueueSuffix(handle: Pick<Handle, "queued" | "queuePosition">): string {
|
|
384
|
+
if (handle.queued !== true) return "";
|
|
385
|
+
return handle.queuePosition === undefined ? " queued" : ` queued(${handle.queuePosition})`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* D8/A20: the honest wait line for an `async:false` child that queued.
|
|
390
|
+
*
|
|
391
|
+
* Read from the admitted `ChildRecord.queueWaitedMs` — never re-derived from a
|
|
392
|
+
* clock here, or the number would describe the adapter's own latency instead of
|
|
393
|
+
* the queue's. `""` when the child never waited.
|
|
394
|
+
*/
|
|
395
|
+
function queuedWaitLine(child: { name: string; queueWaitedMs?: number } | undefined): string {
|
|
396
|
+
if (!child || child.queueWaitedMs === undefined || child.queueWaitedMs <= 0) return "";
|
|
397
|
+
return `↳ ${child.name} waited ${Math.round(child.queueWaitedMs / 1000)}s in the launch queue`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* D6/D7: report a queue that an abort cleared out.
|
|
402
|
+
*
|
|
403
|
+
* "Empty queue and zero spawns" is only half the contract; the caller must also
|
|
404
|
+
* learn that N launches it asked for never ran. One line, names in `seq` order.
|
|
405
|
+
*/
|
|
406
|
+
function launchQueueFlushLine(flushed: readonly string[]): string {
|
|
407
|
+
if (flushed.length === 0) return "";
|
|
408
|
+
return `${flushed.length} queued launch(es) dropped: ${flushed.join(", ")}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* #238: create and start the DB event source that feeds the legion bus on attach.
|
|
413
|
+
*
|
|
414
|
+
* This is the production attach path and it is exported so a unit test can drive
|
|
415
|
+
* it directly (same seam style as `buildPlan`/`resolveWaitTargets`): the seeding
|
|
416
|
+
* behaviour is a property of THIS call, not of `createDbEventSource`, and a test
|
|
417
|
+
* that only exercised the factory would keep passing if this seed were removed.
|
|
418
|
+
*
|
|
419
|
+
* Seeding at `MAX(id)` matters because `createDbEventSource`'s own default is 0,
|
|
420
|
+
* i.e. "replay the whole log". On a ledger with history that re-delivered every
|
|
421
|
+
* stored event through `onWidgetEvent` — one full `snapshotNodes()` plus a
|
|
422
|
+
* repaint each, ~2.5s of blocked UI at 120 nodes / 1000 events — to reach a
|
|
423
|
+
* state the DB already describes.
|
|
424
|
+
*
|
|
425
|
+
* Rows committed in the window between this seed query and the first poll are
|
|
426
|
+
* not lost: `poll()` only skips when `lastDataVersion` matches, that variable
|
|
427
|
+
* starts `undefined` (so the first poll always reads), and the cursor is only
|
|
428
|
+
* ever advanced from ids actually returned by `SELECT ... WHERE id > cursor`.
|
|
429
|
+
* A row committed after the seed is therefore either picked up by the first
|
|
430
|
+
* poll or dirties `data_version` and is picked up on the next tick. The seed is
|
|
431
|
+
* not atomic with `start()`, but it does not need to be.
|
|
432
|
+
*
|
|
433
|
+
* Note the drain loop's early `return`: it exits BEFORE `lastDataVersion` is
|
|
434
|
+
* assigned (that assignment is after the loop), so an interrupted drain leaves
|
|
435
|
+
* the version unlatched. That errs safe — the next poll re-reads from the
|
|
436
|
+
* cursor instead of skipping — but it is a re-read, not a "set then return".
|
|
437
|
+
*/
|
|
438
|
+
export function attachLegionRuntime(input: {
|
|
439
|
+
db: DatabaseSync;
|
|
440
|
+
eventBus: LegionEventBus;
|
|
441
|
+
}): DbEventSource {
|
|
442
|
+
const dbEventSource = createDbEventSource(input.db, { cursor: maxEventId(input.db) });
|
|
443
|
+
dbEventSource.start(input.eventBus);
|
|
444
|
+
return dbEventSource;
|
|
445
|
+
}
|
|
446
|
+
|
|
315
447
|
function createLegionRuntime(input: {
|
|
316
448
|
pi: ExtensionAPI;
|
|
317
449
|
supervisor: LegionSupervisor;
|
|
@@ -328,8 +460,7 @@ function createLegionRuntime(input: {
|
|
|
328
460
|
}): LegionRuntimeIdentity {
|
|
329
461
|
const self = getNode(input.db, input.nodeId);
|
|
330
462
|
const eventBus = input.eventBus;
|
|
331
|
-
const dbEventSource =
|
|
332
|
-
dbEventSource.start(eventBus);
|
|
463
|
+
const dbEventSource = attachLegionRuntime(input);
|
|
333
464
|
const postman = new LegionPostman({
|
|
334
465
|
db: input.db,
|
|
335
466
|
nodeId: input.nodeId,
|
|
@@ -507,7 +638,7 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
507
638
|
const childRun = store.readRun(input.node.runId);
|
|
508
639
|
if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
|
|
509
640
|
const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
|
|
510
|
-
const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
|
|
641
|
+
const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), launchQueue: launchQueueBounds(catalog.settings), onLaunchQueueNotice: launchQueueNoticeSink(input.pi), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
|
|
511
642
|
orchestrator.restore(parentRun);
|
|
512
643
|
const handle = await orchestrator.launch({ agent, task: input.message, name: saved.name, ...(input.node.team ? { team: input.node.team } : {}), ...(saved.model ? { model: saved.model } : {}), worktree: Boolean(saved.worktreePath) });
|
|
513
644
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
@@ -536,6 +667,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
536
667
|
else lastUi?.setStatus("legion-verify", undefined);
|
|
537
668
|
} catch { /* no UI */ }
|
|
538
669
|
});
|
|
670
|
+
// #266 D9-3: the launch admission queue's own pill. Deliberately a separate
|
|
671
|
+
// listener from verify's — different phases, and verify's is poll-driven.
|
|
672
|
+
// The contract's baseline is `legion-queue: N queued`; the `· 8/8 slots busy`
|
|
673
|
+
// tail needs `slotsInUse`/`slots`, which the pure queue does not know (the
|
|
674
|
+
// limit lives in the supervisor), so that dimension is omitted until the
|
|
675
|
+
// event carries it — the contract allows the omission explicitly.
|
|
676
|
+
setLaunchQueueListener((event) => {
|
|
677
|
+
try {
|
|
678
|
+
if (event.phase === "queued" || event.phase === "admitted") {
|
|
679
|
+
const suffix = event.slotsInUse !== undefined && event.slots !== undefined
|
|
680
|
+
? ` · ${event.slotsInUse}/${event.slots} slots busy`
|
|
681
|
+
: "";
|
|
682
|
+
lastUi?.setStatus("legion-queue", `legion-queue: ${event.queued} queued${suffix}`);
|
|
683
|
+
if (event.queued === 0) lastUi?.setStatus("legion-queue", undefined);
|
|
684
|
+
} else if (event.phase === "overflow" || event.phase === "expired" || event.phase === "flushed") {
|
|
685
|
+
lastUi?.setStatus("legion-queue", undefined);
|
|
686
|
+
}
|
|
687
|
+
} catch { /* no UI */ }
|
|
688
|
+
});
|
|
539
689
|
}
|
|
540
690
|
const onSpawnRefused = (message: string) => {
|
|
541
691
|
try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
|
|
@@ -545,8 +695,20 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
545
695
|
let lastCwd = process.cwd();
|
|
546
696
|
const supervisor = deps.supervisor ?? new RoutingSupervisor({
|
|
547
697
|
rpc: new RpcSupervisor({ onSpawnRefused }),
|
|
698
|
+
// The in-process supervisor is constructed here (not by RoutingSupervisor) so
|
|
699
|
+
// it can read live settings per spawn. That means `onSlotReleased` must be
|
|
700
|
+
// wired EXPLICITLY: RoutingSupervisor only adds it when it builds the
|
|
701
|
+
// backend itself (`options.inprocess ?? new InProcessSupervisor({...})`).
|
|
702
|
+
// Passing a pre-built instance used to drop the hook silently, so a freed
|
|
703
|
+
// slot never woke a launch-queue pump in production — admission then waited
|
|
704
|
+
// for the fallback recheck instead of firing on the release (#266 D2-d-2).
|
|
548
705
|
inprocess: new InProcessSupervisor({
|
|
549
706
|
onSpawnRefused,
|
|
707
|
+
// The event payload is not needed here: every Orchestrator on this run dir
|
|
708
|
+
// re-probes its own items, so a bare wake-up edge is the whole contract.
|
|
709
|
+
onSlotReleased: () => {
|
|
710
|
+
notifyInProcessSlotReleased();
|
|
711
|
+
},
|
|
550
712
|
getLimits: () => {
|
|
551
713
|
try {
|
|
552
714
|
return loadSubagentSettings({
|
|
@@ -561,7 +723,23 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
561
723
|
onSpawnRefused,
|
|
562
724
|
});
|
|
563
725
|
const verifyRunner = deps.verifyRunner;
|
|
564
|
-
|
|
726
|
+
// #238: the resident status widget defers its repaint to a microtask, which puts
|
|
727
|
+
// a paint failure outside the per-subscriber try/catch in `createLegionEventBus`.
|
|
728
|
+
// The runtime funnels that failure through `reportError` → `onHandlerError`, so
|
|
729
|
+
// a handler must actually be configured or the containment is silent. A widget
|
|
730
|
+
// repaint that throws (a stale UI context, a SQLITE_IOERR on the tree read) must
|
|
731
|
+
// never take the host down, and must leave a trace.
|
|
732
|
+
const legionBus = createLegionEventBus({
|
|
733
|
+
onHandlerError: (error, event, handler) => {
|
|
734
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
735
|
+
// `event === null` means a deferred failure with no event to attribute;
|
|
736
|
+
// `handler` is then an origin tag rather than a subscriber function.
|
|
737
|
+
const where = event
|
|
738
|
+
? `${event.type} node=${event.nodeId}`
|
|
739
|
+
: typeof handler === "string" ? handler : "deferred";
|
|
740
|
+
console.error(`[pi-legion] legion event handler failed (${where}): ${detail}`);
|
|
741
|
+
},
|
|
742
|
+
});
|
|
565
743
|
const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
|
|
566
744
|
const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
|
|
567
745
|
const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
|
|
@@ -710,6 +888,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
710
888
|
label: "Vexillatio",
|
|
711
889
|
description: "Launch only read-only utility agents and return their collected output.",
|
|
712
890
|
parameters: VexillatioParams,
|
|
891
|
+
// D4: pi never renders `details` on its own, so without this the
|
|
892
|
+
// arbitrated spawnMode in `details.handles[].spawnMode` is invisible.
|
|
893
|
+
renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
|
|
713
894
|
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
|
|
714
895
|
if (signal?.aborted) {
|
|
715
896
|
return ok("interrupted (parent turn ended; children still running)");
|
|
@@ -921,9 +1102,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
921
1102
|
stop: stopChildren,
|
|
922
1103
|
childStatus: (name, cwd) => {
|
|
923
1104
|
try {
|
|
924
|
-
const
|
|
1105
|
+
const store = runStoreFor(cwd);
|
|
1106
|
+
const runs = store.listRuns();
|
|
1107
|
+
const found = findChild(runs, name);
|
|
925
1108
|
if (!found) return undefined;
|
|
926
1109
|
const child = found.child;
|
|
1110
|
+
const alive = supervisor.isAlive(child.name);
|
|
1111
|
+
// Run-dir ownership verdict (#209-3), the same one `subagent status`
|
|
1112
|
+
// uses: `alive` is this process's in-memory registry and never holds a
|
|
1113
|
+
// child launched elsewhere, so a foreign record needs the owner marker.
|
|
1114
|
+
// Undefined while alive — liveness already wins there.
|
|
1115
|
+
const owner = ownerVerdictFor(store, runs, child.name, alive);
|
|
927
1116
|
return {
|
|
928
1117
|
name: child.name,
|
|
929
1118
|
state: child.state,
|
|
@@ -934,7 +1123,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
934
1123
|
...(child.acceptance
|
|
935
1124
|
? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
|
|
936
1125
|
: {}),
|
|
937
|
-
|
|
1126
|
+
// run.json records the arbitrated backend; a legacy record has none,
|
|
1127
|
+
// and `renderLiveChildStatus` renders that as `unknown` — never `rpc`.
|
|
1128
|
+
...(child.spawnMode ? { spawnMode: child.spawnMode } : {}),
|
|
1129
|
+
alive,
|
|
1130
|
+
...(owner ? { owner } : {}),
|
|
938
1131
|
};
|
|
939
1132
|
} catch {
|
|
940
1133
|
return undefined;
|
|
@@ -975,6 +1168,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
975
1168
|
description: TOOL_DESCRIPTION,
|
|
976
1169
|
parameters: SubagentParams,
|
|
977
1170
|
|
|
1171
|
+
// D4: the same renderer as `vexillatio` — it reads both detail shapes.
|
|
1172
|
+
renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
|
|
1173
|
+
|
|
978
1174
|
// The parameter list is fixed by Pi's `ExtensionAPI.registerTool`
|
|
979
1175
|
// contract — an options object is not ours to choose.
|
|
980
1176
|
// pi-lens-ignore: long-parameter-list
|
|
@@ -1106,6 +1302,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1106
1302
|
params,
|
|
1107
1303
|
signal,
|
|
1108
1304
|
ctx,
|
|
1305
|
+
pi,
|
|
1109
1306
|
supervisor,
|
|
1110
1307
|
uiProxy,
|
|
1111
1308
|
onBudgetRefused,
|
|
@@ -1121,7 +1318,27 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1121
1318
|
onUpdate,
|
|
1122
1319
|
});
|
|
1123
1320
|
if (signal?.aborted) {
|
|
1124
|
-
|
|
1321
|
+
// The frozen head line still leads, but the machine facts must
|
|
1322
|
+
// survive: `launchFamily` already returns its own interrupted result
|
|
1323
|
+
// (frozen head + `runId` + `spawns`) when it observes the abort; this
|
|
1324
|
+
// outer net only catches an abort seen after that check. Replacing the
|
|
1325
|
+
// whole result with a bare `ok()` used to drop `details.spawns` — the
|
|
1326
|
+
// only record of the arbitrated backend once `handles` is cleared (#209/D3).
|
|
1327
|
+
const details = result.details;
|
|
1328
|
+
const carried =
|
|
1329
|
+
details !== null && typeof details === "object" && !Array.isArray(details)
|
|
1330
|
+
? (details as Record<string, unknown>)
|
|
1331
|
+
: undefined;
|
|
1332
|
+
// #266 D7: the inner interrupted result also carries the launch-queue
|
|
1333
|
+
// flush line (`N queued launch(es) dropped: …`), which a bare head line
|
|
1334
|
+
// would silently erase — the caller would think its launches still ran.
|
|
1335
|
+
// An inner result that already reports the interruption is therefore
|
|
1336
|
+
// kept as-is; only an otherwise-successful result is replaced.
|
|
1337
|
+
const inner = (result.content ?? [])
|
|
1338
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
1339
|
+
.join("\n");
|
|
1340
|
+
if (inner.includes(INTERRUPTED_HEAD)) return ok(inner, carried);
|
|
1341
|
+
return ok(INTERRUPTED_HEAD, carried);
|
|
1125
1342
|
}
|
|
1126
1343
|
return result;
|
|
1127
1344
|
},
|
|
@@ -1380,13 +1597,28 @@ async function controlAction(input: {
|
|
|
1380
1597
|
}
|
|
1381
1598
|
if (!params.name) {
|
|
1382
1599
|
const runs = store.listRuns();
|
|
1383
|
-
|
|
1600
|
+
// Liveness facts are read once per name and reused by both the filter and
|
|
1601
|
+
// the block, so `status` cannot classify a child one way and render it
|
|
1602
|
+
// another (display-liveness D6 exit 1).
|
|
1603
|
+
const factsByName = new Map<string, StatusLivenessFacts & { pid?: number }>();
|
|
1604
|
+
const factsFor = (child: StatusChildRecord): StatusLivenessFacts & { pid?: number } => {
|
|
1605
|
+
const cached = factsByName.get(child.name);
|
|
1606
|
+
if (cached) return cached;
|
|
1607
|
+
const alive = input.supervisor.isAlive(child.name);
|
|
1608
|
+
const owner = ownerVerdictFor(store, runs, child.name, alive);
|
|
1609
|
+
// A pid only exists for a child this process actually tracks; a
|
|
1610
|
+
// record from another session has none, and none is invented.
|
|
1611
|
+
const pid = input.runtime.get(child.name)?.pid;
|
|
1612
|
+
const facts = { alive, ...(owner ? { owner } : {}), ...(pid ? { pid } : {}) };
|
|
1613
|
+
factsByName.set(child.name, facts);
|
|
1614
|
+
return facts;
|
|
1615
|
+
};
|
|
1616
|
+
const classified = classifyChildrenForStatus(runs, factsFor);
|
|
1384
1617
|
return ok(
|
|
1385
1618
|
renderStatusOverview(
|
|
1386
|
-
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
return { child, alive, ...(owner ? { owner } : {}) };
|
|
1619
|
+
classified.map(({ child, live }) => {
|
|
1620
|
+
const { pid, ...facts } = factsFor(child);
|
|
1621
|
+
return { child, ...facts, ...(pid ? { pid } : {}), live };
|
|
1390
1622
|
}),
|
|
1391
1623
|
),
|
|
1392
1624
|
);
|
|
@@ -1554,7 +1786,7 @@ async function collectChild(
|
|
|
1554
1786
|
}
|
|
1555
1787
|
}
|
|
1556
1788
|
|
|
1557
|
-
/** Retire the
|
|
1789
|
+
/** Retire the child while preserving its outcome and session for resume. */
|
|
1558
1790
|
async function retireChild(
|
|
1559
1791
|
ctx: ChildContext,
|
|
1560
1792
|
): Promise<AgentToolResult<unknown>> {
|
|
@@ -1571,7 +1803,7 @@ async function retireChild(
|
|
|
1571
1803
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
|
|
1572
1804
|
return ok(
|
|
1573
1805
|
`Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
|
|
1574
|
-
`
|
|
1806
|
+
`Child stopped; session kept for resume: ${child.sessionFile}`,
|
|
1575
1807
|
);
|
|
1576
1808
|
} catch (error) {
|
|
1577
1809
|
return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
|
|
@@ -1613,7 +1845,7 @@ async function reviveChild(
|
|
|
1613
1845
|
await orchestrator.steer(name, params.message);
|
|
1614
1846
|
followChild(ctx, { watch: true });
|
|
1615
1847
|
return ok(
|
|
1616
|
-
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live
|
|
1848
|
+
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live child steered).`,
|
|
1617
1849
|
);
|
|
1618
1850
|
}
|
|
1619
1851
|
|
|
@@ -1640,6 +1872,7 @@ async function reviveChild(
|
|
|
1640
1872
|
async function launchFamily(input: {
|
|
1641
1873
|
params: SubagentParams;
|
|
1642
1874
|
ctx: ExtensionContext;
|
|
1875
|
+
pi: ExtensionAPI;
|
|
1643
1876
|
supervisor: LegionSupervisor;
|
|
1644
1877
|
uiProxy: UIProxy;
|
|
1645
1878
|
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
@@ -1666,7 +1899,7 @@ async function launchFamily(input: {
|
|
|
1666
1899
|
content: [
|
|
1667
1900
|
{
|
|
1668
1901
|
type: "text",
|
|
1669
|
-
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} →
|
|
1902
|
+
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → launch → watch`,
|
|
1670
1903
|
},
|
|
1671
1904
|
],
|
|
1672
1905
|
details: {},
|
|
@@ -1717,6 +1950,10 @@ async function launchFamily(input: {
|
|
|
1717
1950
|
worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1718
1951
|
...teamForChildren(input.team),
|
|
1719
1952
|
...rpcApproveFrom(settings),
|
|
1953
|
+
// #266 D6-a/D10: queue bounds + the D9 notice channel. The command chain is
|
|
1954
|
+
// the bug's main path (a 12-step `tasks[]` fanout against `maxChildrenPerNode`).
|
|
1955
|
+
launchQueue: launchQueueBounds(settings),
|
|
1956
|
+
onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
|
|
1720
1957
|
...(legion ? { legion: { db: legion.db, dbPath: legion.dbPath, nodeId: legion.nodeId, runId: run.runId, parentNodeId: legion.parentNodeId ?? undefined, parentRunId: legion.parentRunId ?? undefined, runStore: store, settings: legion.settings, eventBus: legion.eventBus } } : {}),
|
|
1721
1958
|
onBudgetRefused: input.onBudgetRefused,
|
|
1722
1959
|
});
|
|
@@ -1742,16 +1979,45 @@ async function launchFamily(input: {
|
|
|
1742
1979
|
results: [],
|
|
1743
1980
|
handles: [],
|
|
1744
1981
|
timeoutByName: new Map(),
|
|
1982
|
+
// #209/D3: the arbitrated mode survives `handles` being cleared on an
|
|
1983
|
+
// abort (interrupted results run after that), so it is kept per name
|
|
1984
|
+
// here — same precedent as `timeoutByName`.
|
|
1985
|
+
spawnByName: new Map(),
|
|
1986
|
+
pidByName: new Map(),
|
|
1745
1987
|
...(nameGate.name ? { launchName: nameGate.name } : {}),
|
|
1746
1988
|
};
|
|
1747
1989
|
|
|
1990
|
+
/** `details.spawns` for the handles this request actually resolved. */
|
|
1991
|
+
const spawnDetails = () => {
|
|
1992
|
+
const spawns = [...session.spawnByName.values()];
|
|
1993
|
+
return spawns.length > 0 ? { spawns } : {};
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1748
1996
|
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
1749
1997
|
if (!input.signal?.aborted) return undefined;
|
|
1998
|
+
// #266 D7/A5: a parent turn end empties the launch queue and spawns nothing.
|
|
1999
|
+
// Deliberately only HERE (and in the vexillatio interrupt): Esc/wait
|
|
2000
|
+
// interruptions end the parent's *wait*, not its turn, and must leave
|
|
2001
|
+
// children — queued ones included — running (`esc-no-retire-v3`).
|
|
2002
|
+
const flushed = orchestrator.flushLaunchQueue("parent turn ended");
|
|
1750
2003
|
for (const name of session.handles) await abandonLaunchedChild(session, name);
|
|
1751
2004
|
session.handles.length = 0;
|
|
2005
|
+
const flushLine = launchQueueFlushLine(flushed);
|
|
1752
2006
|
return {
|
|
1753
|
-
content: [{
|
|
1754
|
-
|
|
2007
|
+
content: [{
|
|
2008
|
+
type: "text",
|
|
2009
|
+
text: [
|
|
2010
|
+
// The head line LEADS (same frozen shape as the vexillatio interrupt):
|
|
2011
|
+
// the outer abort net keeps an inner result that already reports the
|
|
2012
|
+
// interruption, and only a leading head makes that detection reliable
|
|
2013
|
+
// — otherwise each `parent turn ended; child still running (watched)`
|
|
2014
|
+
// line reads like a standalone notice and the flush line is lost.
|
|
2015
|
+
INTERRUPTED_HEAD,
|
|
2016
|
+
session.results.join("\n"),
|
|
2017
|
+
flushLine,
|
|
2018
|
+
].filter(Boolean).join("\n"),
|
|
2019
|
+
}],
|
|
2020
|
+
details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
|
|
1755
2021
|
};
|
|
1756
2022
|
};
|
|
1757
2023
|
|
|
@@ -1798,13 +2064,13 @@ async function launchFamily(input: {
|
|
|
1798
2064
|
|
|
1799
2065
|
if (isAsync && session.handles.length > 0) {
|
|
1800
2066
|
session.results.push(
|
|
1801
|
-
"↳ async: a completion message is queued when each
|
|
2067
|
+
"↳ async: a completion message is queued when each child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
|
|
1802
2068
|
);
|
|
1803
2069
|
}
|
|
1804
2070
|
|
|
1805
2071
|
return {
|
|
1806
2072
|
content: [{ type: "text", text: session.results.join("\n") }],
|
|
1807
|
-
details: { runId: run.runId, handles: session.handles, async: isAsync },
|
|
2073
|
+
details: { runId: run.runId, handles: session.handles, async: isAsync, ...spawnDetails() },
|
|
1808
2074
|
};
|
|
1809
2075
|
}
|
|
1810
2076
|
|
|
@@ -1828,6 +2094,15 @@ interface LaunchSession {
|
|
|
1828
2094
|
results: string[];
|
|
1829
2095
|
handles: string[];
|
|
1830
2096
|
timeoutByName: Map<string, number>;
|
|
2097
|
+
/** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
|
|
2098
|
+
spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
|
|
2099
|
+
/**
|
|
2100
|
+
* Own process of an `rpc` child, keyed by name — same reason `timeoutByName`
|
|
2101
|
+
* exists: `Handle.pid` is not persisted, so a re-follow from `ChildRecord`
|
|
2102
|
+
* cannot recover it. Kept out of `spawnByName` so `details.spawns` (a
|
|
2103
|
+
* renderer input) keeps its published shape.
|
|
2104
|
+
*/
|
|
2105
|
+
pidByName: Map<string, number>;
|
|
1831
2106
|
/** Explicit child name for this single-step launch; omit to auto-allocate. */
|
|
1832
2107
|
launchName?: string;
|
|
1833
2108
|
}
|
|
@@ -1922,9 +2197,8 @@ async function launchStep(
|
|
|
1922
2197
|
// `--no-extensions` the child would resolve it there and die with
|
|
1923
2198
|
// "No API key found". The parent registry knows the live provider.
|
|
1924
2199
|
...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
|
|
1925
|
-
// The origin
|
|
1926
|
-
//
|
|
1927
|
-
// arrives as the same `dispatch` source as a per-run override).
|
|
2200
|
+
// The origin distinguishes a deliberate per-run model from the parent's
|
|
2201
|
+
// model falling through; the launch line reports which one won.
|
|
1928
2202
|
modelOrigin: model.modelOrigin,
|
|
1929
2203
|
worktree: resolveLaunchWorktree({
|
|
1930
2204
|
roleDefault: effective.worktree,
|
|
@@ -1939,6 +2213,14 @@ async function launchStep(
|
|
|
1939
2213
|
await session.store.addChild(session.runId, handle.child);
|
|
1940
2214
|
}
|
|
1941
2215
|
session.handles.push(handle.name);
|
|
2216
|
+
// Recorded before the abort check below: an interrupted result runs after
|
|
2217
|
+
// `handles` is cleared and still has to name the arbitrated backend.
|
|
2218
|
+
session.spawnByName.set(handle.name, {
|
|
2219
|
+
name: handle.name,
|
|
2220
|
+
agent: effective.name,
|
|
2221
|
+
spawnMode: handle.spawnMode,
|
|
2222
|
+
});
|
|
2223
|
+
if (handle.pid) session.pidByName.set(handle.name, handle.pid);
|
|
1942
2224
|
if (effective.timeoutMs !== undefined) {
|
|
1943
2225
|
session.timeoutByName.set(handle.name, effective.timeoutMs);
|
|
1944
2226
|
}
|
|
@@ -1947,23 +2229,13 @@ async function launchStep(
|
|
|
1947
2229
|
return;
|
|
1948
2230
|
}
|
|
1949
2231
|
session.results.push(
|
|
1950
|
-
`▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
|
|
2232
|
+
`▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}${launchLineQueueSuffix(handle)}`,
|
|
1951
2233
|
);
|
|
1952
|
-
// An inherited model the target CLI cannot express is dropped on purpose
|
|
1953
|
-
// (the parent's pi model means nothing to cursor). Say so, or the user
|
|
1954
|
-
// reasonably believes the child is running the model they see on the
|
|
1955
|
-
// parent — the same silent surprise this whole guard exists to remove.
|
|
1956
|
-
if (handle.child.modelDropped?.length) {
|
|
1957
|
-
session.results.push(
|
|
1958
|
-
` ⚠ ${effective.name} runs on ${effective.kind}'s own default: ` +
|
|
1959
|
-
`${handle.child.modelDropped.join(", ")} does not apply to that CLI.`,
|
|
1960
|
-
);
|
|
1961
|
-
}
|
|
1962
2234
|
session.onUpdate?.({
|
|
1963
2235
|
content: [
|
|
1964
2236
|
{
|
|
1965
2237
|
type: "text",
|
|
1966
|
-
text: `playbook: ${handle.name} started
|
|
2238
|
+
text: `playbook: ${handle.name} started (supervisor=${handle.spawnMode})`,
|
|
1967
2239
|
},
|
|
1968
2240
|
],
|
|
1969
2241
|
details: {},
|
|
@@ -2057,23 +2329,100 @@ function resolveStep(
|
|
|
2057
2329
|
};
|
|
2058
2330
|
}
|
|
2059
2331
|
|
|
2332
|
+
/**
|
|
2333
|
+
* Collect one child, tolerating the transient gap an admission creates.
|
|
2334
|
+
*
|
|
2335
|
+
* `pumpAdmissions` deletes the `queued` placeholder before replaying the real
|
|
2336
|
+
* launch, so between those two steps the name exists in neither the placeholder
|
|
2337
|
+
* map nor the freshly spawned record. `collect()` resolves names through
|
|
2338
|
+
* `requireChild`, so a collect that lands in that window throws
|
|
2339
|
+
* `unknown child: <name>` — a race, not a real failure, and one only a queued
|
|
2340
|
+
* child can hit. The window is a microtask-or-two long (the replay is awaited),
|
|
2341
|
+
* so a short bounded retry is enough; anything genuinely gone still surfaces.
|
|
2342
|
+
*
|
|
2343
|
+
* Only used by the sequential `async:false` path. `async:true` never collects
|
|
2344
|
+
* here — its watcher goes through the same `collect` but is re-armed, so a retry
|
|
2345
|
+
* loop there would fight the watcher instead of helping it.
|
|
2346
|
+
*/
|
|
2347
|
+
async function collectThroughAdmission(
|
|
2348
|
+
session: LaunchSession,
|
|
2349
|
+
name: string,
|
|
2350
|
+
signal?: AbortSignal,
|
|
2351
|
+
): Promise<Awaited<ReturnType<Orchestrator["collect"]>>> {
|
|
2352
|
+
const deadline = Date.now() + ADMISSION_SETTLE_TIMEOUT_MS;
|
|
2353
|
+
for (;;) {
|
|
2354
|
+
try {
|
|
2355
|
+
return await session.orchestrator.collect(name, {
|
|
2356
|
+
timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
|
|
2357
|
+
...(signal ? { signal } : {}),
|
|
2358
|
+
});
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
const transient = error instanceof SubagentError && error.code === ErrorCodes.NOT_FOUND;
|
|
2361
|
+
if (!transient || Date.now() >= deadline || signal?.aborted) throw error;
|
|
2362
|
+
await new Promise((resolve) => setTimeout(resolve, ADMISSION_SETTLE_POLL_MS));
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* How long a collect may retry across the admission gap before reporting it.
|
|
2369
|
+
*
|
|
2370
|
+
* Deliberately much shorter than a turn timeout: the gap is two `await`s wide,
|
|
2371
|
+
* so anything still missing after this long is not racing — it is gone.
|
|
2372
|
+
*/
|
|
2373
|
+
const ADMISSION_SETTLE_TIMEOUT_MS = 5_000;
|
|
2374
|
+
const ADMISSION_SETTLE_POLL_MS = 25;
|
|
2375
|
+
|
|
2060
2376
|
/** Wait for each launched child's turn, appending its outcome. */
|
|
2061
2377
|
async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
|
|
2062
|
-
|
|
2378
|
+
// #266 D8: collect children that already hold a slot FIRST.
|
|
2379
|
+
//
|
|
2380
|
+
// A queued handle resolves synchronously (`launch()` returns it before any
|
|
2381
|
+
// spawn), so it lands in `handles` BEFORE the admitted siblings. Collecting in
|
|
2382
|
+
// raw launch order would therefore block on the queued child first — waiting
|
|
2383
|
+
// for a slot that only those very siblings can release once THEY are
|
|
2384
|
+
// collected — i.e. a deadlock, not a wait. Launch order is preserved within
|
|
2385
|
+
// each group, so a run with no queue keeps its exact previous order.
|
|
2386
|
+
const queuedHere = new Set(
|
|
2387
|
+
session.orchestrator
|
|
2388
|
+
.childrenSnapshot()
|
|
2389
|
+
.filter((child) => child.state === "queued")
|
|
2390
|
+
.map((child) => child.name),
|
|
2391
|
+
);
|
|
2392
|
+
const ordered = [
|
|
2393
|
+
...session.handles.filter((name) => !queuedHere.has(name)),
|
|
2394
|
+
...session.handles.filter((name) => queuedHere.has(name)),
|
|
2395
|
+
];
|
|
2396
|
+
for (const name of ordered) {
|
|
2063
2397
|
if (signal?.aborted) {
|
|
2064
2398
|
session.results.push("interrupted (parent turn ended; children still running)");
|
|
2065
2399
|
return;
|
|
2066
2400
|
}
|
|
2067
2401
|
try {
|
|
2068
|
-
const collected = await session
|
|
2069
|
-
timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
|
|
2070
|
-
...(signal ? { signal } : {}),
|
|
2071
|
-
});
|
|
2402
|
+
const collected = await collectThroughAdmission(session, name, signal);
|
|
2072
2403
|
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
2073
2404
|
session.results.push("interrupted (parent turn ended; children still running)");
|
|
2074
2405
|
return;
|
|
2075
2406
|
}
|
|
2076
2407
|
session.results.push(renderCollect(name, collected));
|
|
2408
|
+
// #266 D8/A20: `async:false` must report the real wait, not just look slow.
|
|
2409
|
+
// Read AFTER collect: `queueWaitedMs` is written at admission, so an item
|
|
2410
|
+
// that is still waiting when the collect deadline fires has no line yet.
|
|
2411
|
+
const waitedLine = queuedWaitLine(
|
|
2412
|
+
session.orchestrator.childrenSnapshot().find((child) => child.name === name),
|
|
2413
|
+
);
|
|
2414
|
+
if (waitedLine) session.results.push(waitedLine);
|
|
2415
|
+
// A finished child must release its slot BEFORE the next collect starts,
|
|
2416
|
+
// or a queue can never drain: `collectInline` is sequential, and the
|
|
2417
|
+
// caller's retire loop runs after every collect (so with `maxChildrenPerNode:
|
|
2418
|
+
// 1` the second child would wait for a slot the first still holds).
|
|
2419
|
+
// Gated on a non-empty queue so the un-queued fast path is unchanged.
|
|
2420
|
+
if (
|
|
2421
|
+
session.orchestrator.launchQueueEntries().length > 0 &&
|
|
2422
|
+
shouldRecycleAfterCollect(collected.execution.status)
|
|
2423
|
+
) {
|
|
2424
|
+
await session.orchestrator.retire(name);
|
|
2425
|
+
}
|
|
2077
2426
|
} catch (error) {
|
|
2078
2427
|
session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
|
|
2079
2428
|
}
|
|
@@ -2106,10 +2455,16 @@ type UtilityPlan =
|
|
|
2106
2455
|
| { ok: false; message: string };
|
|
2107
2456
|
|
|
2108
2457
|
function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
|
|
2109
|
-
|
|
2110
|
-
const
|
|
2111
|
-
|
|
2112
|
-
|
|
2458
|
+
const singleTask = params.task ?? "";
|
|
2459
|
+
const parallel = params.tasks?.length ? params.tasks : undefined;
|
|
2460
|
+
// `tasks[]` wins and `task` degrades to a parent-run label, so both shapes
|
|
2461
|
+
// accepted by the schema reach a launch: `{ task }`, `{ tasks }`, `{ task, tasks }`.
|
|
2462
|
+
if (!parallel && !singleTask.trim()) {
|
|
2463
|
+
return { ok: false, message: "Provide one of: `task` (single recon) or `tasks[]` (parallel recon)." };
|
|
2464
|
+
}
|
|
2465
|
+
const steps: UtilityTaskItem[] = parallel
|
|
2466
|
+
? parallel
|
|
2467
|
+
: [{ agent: params.agent?.trim() || "scout", task: singleTask }];
|
|
2113
2468
|
const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
|
|
2114
2469
|
if (unsupportedVerify >= 0) {
|
|
2115
2470
|
return {
|
|
@@ -2286,6 +2641,10 @@ async function launchUtilityFamily(input: {
|
|
|
2286
2641
|
childContext: resolveTeamContext("child", catalog.settings, catalog.team),
|
|
2287
2642
|
team: catalog.team,
|
|
2288
2643
|
...rpcApproveFrom(catalog.settings),
|
|
2644
|
+
// #266: the parallel-recon path shares the same bounds and notice channel
|
|
2645
|
+
// as the command chain — one scheduler for every dispatch family (D2).
|
|
2646
|
+
launchQueue: launchQueueBounds(catalog.settings),
|
|
2647
|
+
onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
|
|
2289
2648
|
onBudgetRefused: input.onBudgetRefused,
|
|
2290
2649
|
...(legion ? {
|
|
2291
2650
|
legion: {
|
|
@@ -2343,6 +2702,8 @@ async function launchUtilityFamily(input: {
|
|
|
2343
2702
|
name: handle.name,
|
|
2344
2703
|
agent: item.agent.name,
|
|
2345
2704
|
sessionFile: handle.sessionFile,
|
|
2705
|
+
spawnMode: handle.spawnMode,
|
|
2706
|
+
...(handle.pid ? { pid: handle.pid } : {}),
|
|
2346
2707
|
orchestrator,
|
|
2347
2708
|
retire,
|
|
2348
2709
|
},
|
|
@@ -2358,6 +2719,8 @@ async function launchUtilityFamily(input: {
|
|
|
2358
2719
|
name: string;
|
|
2359
2720
|
agent: string;
|
|
2360
2721
|
sessionFile: string;
|
|
2722
|
+
spawnMode: SpawnMode;
|
|
2723
|
+
pid?: number;
|
|
2361
2724
|
orchestrator: Orchestrator;
|
|
2362
2725
|
retire: (opts?: RetireOptions) => Promise<void>;
|
|
2363
2726
|
}> = [];
|
|
@@ -2373,6 +2736,9 @@ async function launchUtilityFamily(input: {
|
|
|
2373
2736
|
agent: handle.agent,
|
|
2374
2737
|
sessionFile: handle.sessionFile,
|
|
2375
2738
|
timeoutMs,
|
|
2739
|
+
// Arbitrated at launch; never re-derived here (#209).
|
|
2740
|
+
spawnMode: handle.spawnMode,
|
|
2741
|
+
...(handle.pid ? { pid: handle.pid } : {}),
|
|
2376
2742
|
orchestrator: handle.orchestrator,
|
|
2377
2743
|
store,
|
|
2378
2744
|
watch,
|
|
@@ -2404,6 +2770,17 @@ async function launchUtilityFamily(input: {
|
|
|
2404
2770
|
lines.push(`sessionFile: ${handle.sessionFile}`);
|
|
2405
2771
|
return lines.join("\n");
|
|
2406
2772
|
} catch (error) {
|
|
2773
|
+
// #266: a queued item is never "failed" — it never started. Treating it as
|
|
2774
|
+
// still running keeps it out of the failure list and lets the retry/watch
|
|
2775
|
+
// path own it, instead of retiring a launch the caller only asked to wait on.
|
|
2776
|
+
const queuedNow = handle.orchestrator
|
|
2777
|
+
.childrenSnapshot()
|
|
2778
|
+
.find((entry) => entry.name === handle.name)?.state === "queued";
|
|
2779
|
+
if (queuedNow) {
|
|
2780
|
+
followUtility(handle, true);
|
|
2781
|
+
stillRunning.add(handle.name);
|
|
2782
|
+
return `── ${handle.name} ──\nexecution: queued (waiting for a free slot)\nsessionFile: ${handle.sessionFile}`;
|
|
2783
|
+
}
|
|
2407
2784
|
if (input.supervisor.isAlive(handle.name)) {
|
|
2408
2785
|
followUtility(handle, true);
|
|
2409
2786
|
stillRunning.add(handle.name);
|
|
@@ -2414,19 +2791,33 @@ async function launchUtilityFamily(input: {
|
|
|
2414
2791
|
return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
|
|
2415
2792
|
}
|
|
2416
2793
|
}));
|
|
2417
|
-
if (input.signal?.aborted)
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2794
|
+
if (input.signal?.aborted) {
|
|
2795
|
+
// #266 D7/A5: same rule as the command chain — a parent turn end empties
|
|
2796
|
+
// every dispatch family's queue and spawns nothing. Each utility item owns
|
|
2797
|
+
// its own Orchestrator, so each one flushes its own view of the shared queue.
|
|
2798
|
+
const flushed: string[] = [];
|
|
2799
|
+
for (const handle of handles) {
|
|
2800
|
+
const child = handle.orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name);
|
|
2801
|
+
// D9: a queued item produced no result, so it must never be reported as
|
|
2802
|
+
// one. It is either dropped by the flush below (reported by name) or, if
|
|
2803
|
+
// it was already admitted, genuinely still running.
|
|
2804
|
+
if (child?.state === "queued") flushed.push(...handle.orchestrator.flushLaunchQueue("parent turn ended"));
|
|
2805
|
+
}
|
|
2806
|
+
return interruptedUtilityResult({
|
|
2807
|
+
runId,
|
|
2808
|
+
handles,
|
|
2809
|
+
stillRunning,
|
|
2810
|
+
results,
|
|
2811
|
+
collected,
|
|
2812
|
+
flushed,
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2424
2815
|
results.push(...collected);
|
|
2425
2816
|
return {
|
|
2426
2817
|
content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
|
|
2427
2818
|
details: {
|
|
2428
2819
|
runId,
|
|
2429
|
-
handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
|
|
2820
|
+
handles: handles.map(({ name, agent, sessionFile, spawnMode }) => ({ name, agent, sessionFile, spawnMode })),
|
|
2430
2821
|
async: false,
|
|
2431
2822
|
...(stillRunning.size > 0
|
|
2432
2823
|
? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
|
|
@@ -2448,13 +2839,22 @@ async function launchUtilityFamily(input: {
|
|
|
2448
2839
|
*/
|
|
2449
2840
|
function interruptedUtilityResult(input: {
|
|
2450
2841
|
runId: string;
|
|
2451
|
-
handles: Array<{ name: string; agent: string; sessionFile: string }>;
|
|
2842
|
+
handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>;
|
|
2452
2843
|
stillRunning: Set<string>;
|
|
2453
2844
|
results: string[];
|
|
2454
2845
|
collected: string[];
|
|
2846
|
+
/** #266 D7: names the abort cleared out of the launch queue (seq order). */
|
|
2847
|
+
flushed?: readonly string[];
|
|
2455
2848
|
}): AgentToolResult<unknown> {
|
|
2456
2849
|
const head = "interrupted (parent turn ended; children still running)";
|
|
2457
|
-
|
|
2850
|
+
// #266 D7: a dropped queued launch is the reason the rest of the result is
|
|
2851
|
+
// short, so it is reported even when nothing was collected at all. Trailing,
|
|
2852
|
+
// like the command chain's line: it reads as a summary of what was cleared.
|
|
2853
|
+
const segments = [
|
|
2854
|
+
...input.results,
|
|
2855
|
+
...input.collected,
|
|
2856
|
+
launchQueueFlushLine(input.flushed ?? []),
|
|
2857
|
+
].filter((segment) => segment.length > 0);
|
|
2458
2858
|
const stillRunning = input.handles
|
|
2459
2859
|
.filter(({ name }) => input.stillRunning.has(name))
|
|
2460
2860
|
.map(({ name }) => name);
|
|
@@ -2462,7 +2862,13 @@ function interruptedUtilityResult(input: {
|
|
|
2462
2862
|
// real consumer instead of being a dead signature (#227 review F3).
|
|
2463
2863
|
return ok(segments.length > 0 ? `${head}\n\n${segments.join("\n\n")}` : head, {
|
|
2464
2864
|
runId: input.runId,
|
|
2465
|
-
handles: input.handles.map(({ name, agent, sessionFile }) => ({
|
|
2865
|
+
handles: input.handles.map(({ name, agent, sessionFile, spawnMode }) => ({
|
|
2866
|
+
name,
|
|
2867
|
+
agent,
|
|
2868
|
+
sessionFile,
|
|
2869
|
+
// Optional on purpose: a legacy handle must render as absent, never `rpc`.
|
|
2870
|
+
...(spawnMode ? { spawnMode } : {}),
|
|
2871
|
+
})),
|
|
2466
2872
|
async: false,
|
|
2467
2873
|
interrupted: true,
|
|
2468
2874
|
...(stillRunning.length > 0 ? { stillRunning } : {}),
|
|
@@ -2614,12 +3020,24 @@ function followJob(
|
|
|
2614
3020
|
watch: boolean;
|
|
2615
3021
|
retire?: (opts?: RetireOptions) => Promise<void>;
|
|
2616
3022
|
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
3023
|
+
/** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
|
|
3024
|
+
spawnMode?: SpawnMode;
|
|
3025
|
+
/**
|
|
3026
|
+
* Own process of an `rpc` child (`Handle.pid`), for the memory probe.
|
|
3027
|
+
* Omitted when the caller has no handle at hand — a rebuilt orchestrator
|
|
3028
|
+
* only knows `ChildRecord`, and a pid is not persisted, so it must not be
|
|
3029
|
+
* guessed or looked up elsewhere.
|
|
3030
|
+
*/
|
|
3031
|
+
pid?: number;
|
|
2617
3032
|
},
|
|
2618
3033
|
): void {
|
|
2619
3034
|
const child = input.orchestrator
|
|
2620
3035
|
.childrenSnapshot()
|
|
2621
3036
|
.find((entry) => entry.name === input.name);
|
|
2622
3037
|
const existing = runtime.get(input.name);
|
|
3038
|
+
// The caller's Handle owns the arbitration; the persisted ChildRecord records
|
|
3039
|
+
// the same decision. Neither is re-derived here — no handle, no value.
|
|
3040
|
+
const spawnMode = input.spawnMode ?? child?.spawnMode;
|
|
2623
3041
|
runtime.track({
|
|
2624
3042
|
name: input.name,
|
|
2625
3043
|
runId: input.runId,
|
|
@@ -2630,6 +3048,8 @@ function followJob(
|
|
|
2630
3048
|
...(child?.model ? { model: child.model } : {}),
|
|
2631
3049
|
...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
|
|
2632
3050
|
...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
|
|
3051
|
+
...(spawnMode ? { spawnMode } : {}),
|
|
3052
|
+
...(input.pid ? { pid: input.pid } : {}),
|
|
2633
3053
|
...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
|
|
2634
3054
|
nodeId: child.nodeId,
|
|
2635
3055
|
parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
|
|
@@ -2706,6 +3126,24 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
|
|
|
2706
3126
|
});
|
|
2707
3127
|
}
|
|
2708
3128
|
|
|
3129
|
+
/**
|
|
3130
|
+
* The arbitrated backend recorded for one handled child of this request, plus
|
|
3131
|
+
* the OS process of that child when the launch reported one.
|
|
3132
|
+
*
|
|
3133
|
+
* Spreads to nothing when unknown, so the caller never has to invent a value.
|
|
3134
|
+
*/
|
|
3135
|
+
function spawnModeOf(
|
|
3136
|
+
session: LaunchSession,
|
|
3137
|
+
name: string,
|
|
3138
|
+
): { spawnMode?: SpawnMode; pid?: number } {
|
|
3139
|
+
const recorded = session.spawnByName.get(name);
|
|
3140
|
+
const pid = session.pidByName.get(name);
|
|
3141
|
+
return {
|
|
3142
|
+
...(recorded ? { spawnMode: recorded.spawnMode } : {}),
|
|
3143
|
+
...(pid ? { pid } : {}),
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
|
|
2709
3147
|
function followLaunched(
|
|
2710
3148
|
session: LaunchSession,
|
|
2711
3149
|
name: string,
|
|
@@ -2723,6 +3161,8 @@ function followLaunched(
|
|
|
2723
3161
|
orchestrator: session.orchestrator,
|
|
2724
3162
|
store: session.store,
|
|
2725
3163
|
watch: opts.watch,
|
|
3164
|
+
// Handle-derived, recorded before an abort can clear `handles` (#209).
|
|
3165
|
+
...spawnModeOf(session, name),
|
|
2726
3166
|
...(child?.nodeId ? {
|
|
2727
3167
|
treeIdentity: {
|
|
2728
3168
|
nodeId: child.nodeId,
|
|
@@ -2772,6 +3212,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2772
3212
|
session.runtime.release(name);
|
|
2773
3213
|
return;
|
|
2774
3214
|
}
|
|
3215
|
+
// #266: a launch that never started cannot be "still running". Reporting it as
|
|
3216
|
+
// watched would contradict the `N queued launch(es) dropped` line and tell the
|
|
3217
|
+
// caller to wait for a child that will never produce a turn.
|
|
3218
|
+
if (child.state === "queued") return;
|
|
2775
3219
|
// Esc / parent-turn abort must not retire: child keeps running; follow+watch
|
|
2776
3220
|
// so settle still notifies the parent (esc-no-retire-v3).
|
|
2777
3221
|
followLaunched(session, name, { watch: true });
|
|
@@ -2783,40 +3227,132 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2783
3227
|
// ---------------------------------------------------------------------------
|
|
2784
3228
|
|
|
2785
3229
|
/**
|
|
2786
|
-
*
|
|
3230
|
+
* Child-record states that are terminal, so a record carrying one is never live
|
|
3231
|
+
* (display-liveness D6, exit 1). `failed`/`settled` live on the execution side
|
|
3232
|
+
* rather than in `TaskState`, but a run.json written by another version (or a
|
|
3233
|
+
* hand-edited record) can carry them and they are terminal either way, so the
|
|
3234
|
+
* set is keyed on strings instead of the narrow state union.
|
|
3235
|
+
*/
|
|
3236
|
+
const TERMINAL_STATUS_CHILD_STATES = new Set<string>([
|
|
3237
|
+
"retired",
|
|
3238
|
+
"exited",
|
|
3239
|
+
"failed",
|
|
3240
|
+
"settled",
|
|
3241
|
+
]);
|
|
3242
|
+
|
|
3243
|
+
/** The persisted child record shape, as resolved through the run store. */
|
|
3244
|
+
type StatusChildRecord = NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
3245
|
+
|
|
3246
|
+
/**
|
|
3247
|
+
* Liveness facts for one child, supplied by the caller.
|
|
2787
3248
|
*
|
|
2788
|
-
*
|
|
2789
|
-
*
|
|
3249
|
+
* `alive` is this process's in-memory supervisor answer (it never holds a child
|
|
3250
|
+
* launched by another session); `owner` is the run-dir ownership verdict that
|
|
3251
|
+
* covers that hole (#209-3). See {@link ownerVerdictFor}.
|
|
2790
3252
|
*/
|
|
2791
|
-
export
|
|
3253
|
+
export interface StatusLivenessFacts {
|
|
3254
|
+
alive: boolean;
|
|
3255
|
+
/** `undefined` while `alive` — in-memory liveness already decided (#209-3). */
|
|
3256
|
+
owner?: "live" | "dead" | "unknown";
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/** One run record after the status filter ran (display-liveness D6 exit 1). */
|
|
3260
|
+
export interface StatusChildClassification {
|
|
3261
|
+
child: StatusChildRecord;
|
|
3262
|
+
/**
|
|
3263
|
+
* Counted in `Status · N live children`. `false` = listed but NOT counted:
|
|
3264
|
+
* the record's run-dir owner pid is gone (#209-3 / A3).
|
|
3265
|
+
*/
|
|
3266
|
+
live: boolean;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/**
|
|
3270
|
+
* Split the persisted child records under this cwd into `live` and `dead`.
|
|
3271
|
+
*
|
|
3272
|
+
* A record is dead when its owner pid is gone and this process is not running
|
|
3273
|
+
* it — not merely when its `state` says so. `owner === "unknown"` counts as
|
|
3274
|
+
* live: filtering unknown out would hide a child that may well be starting, and
|
|
3275
|
+
* D2 forbids collapsing unknown into dead. Its block still reports
|
|
3276
|
+
* `owner: unknown` + `state=working`, so it is never *displayed* as confirmed
|
|
3277
|
+
* live — it is only not hidden.
|
|
3278
|
+
*
|
|
3279
|
+
* Names can be reused after retire, so the newest record per name wins, except
|
|
3280
|
+
* that a dead record never shadows an older record of the same name that is
|
|
3281
|
+
* still alive (same preference as {@link pickChildByName}).
|
|
3282
|
+
*
|
|
3283
|
+
* Without `factsFor` the filter falls back to the record state alone; that keeps
|
|
3284
|
+
* this callable from a context that has no store (and keeps the pre-liveness
|
|
3285
|
+
* signature working), but the `status` exit must pass facts.
|
|
3286
|
+
*/
|
|
3287
|
+
export function classifyChildrenForStatus(
|
|
2792
3288
|
runs: ReturnType<RunStore["listRuns"]>,
|
|
2793
|
-
|
|
3289
|
+
factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
|
|
3290
|
+
): StatusChildClassification[] {
|
|
2794
3291
|
const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2795
|
-
const
|
|
2796
|
-
const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
|
|
3292
|
+
const candidates: StatusChildClassification[] = [];
|
|
2797
3293
|
for (const run of ranked) {
|
|
2798
3294
|
for (const child of run.children) {
|
|
2799
|
-
if (
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
3295
|
+
if (TERMINAL_STATUS_CHILD_STATES.has(child.state)) continue;
|
|
3296
|
+
const facts = factsFor?.(child);
|
|
3297
|
+
// Dead = the run dir's owner pid is gone AND this process is not running
|
|
3298
|
+
// it. `owner === "unknown"` (no marker, or no pid in it) is never dead:
|
|
3299
|
+
// unknown must not be collapsed into dead (display-liveness D2).
|
|
3300
|
+
const live = !(facts?.owner === "dead" && !facts.alive);
|
|
3301
|
+
candidates.push({ child, live });
|
|
2803
3302
|
}
|
|
2804
3303
|
}
|
|
2805
|
-
|
|
3304
|
+
// Name reuse: a newer DEAD record must not shadow an older record of the
|
|
3305
|
+
// same name that is still live (mirror of `pickChildByName`, which prefers a
|
|
3306
|
+
// live match over a newer retired one). Among equals the newest wins, hence
|
|
3307
|
+
// the newest-first iteration order.
|
|
3308
|
+
const chosen = new Map<string, StatusChildClassification>();
|
|
3309
|
+
for (const entry of candidates) {
|
|
3310
|
+
const prior = chosen.get(entry.child.name);
|
|
3311
|
+
if (!prior || !prior.live) chosen.set(entry.child.name, entry);
|
|
3312
|
+
}
|
|
3313
|
+
return candidates.filter((entry) => chosen.get(entry.child.name) === entry);
|
|
2806
3314
|
}
|
|
2807
3315
|
|
|
2808
|
-
/**
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3316
|
+
/**
|
|
3317
|
+
* Live children under this cwd for the status overview.
|
|
3318
|
+
*
|
|
3319
|
+
* Pass `factsFor` to decide liveness from owner evidence instead of the record
|
|
3320
|
+
* state alone; see {@link classifyChildrenForStatus}.
|
|
3321
|
+
*/
|
|
3322
|
+
export function listLiveChildrenForStatus(
|
|
3323
|
+
runs: ReturnType<RunStore["listRuns"]>,
|
|
3324
|
+
factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
|
|
3325
|
+
): StatusChildRecord[] {
|
|
3326
|
+
return classifyChildrenForStatus(runs, factsFor).flatMap((entry) =>
|
|
3327
|
+
entry.live ? [entry.child] : [],
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/** One child block for {@link renderStatusOverview}. */
|
|
3332
|
+
export interface StatusChildEntry {
|
|
3333
|
+
child: StatusChildRecord;
|
|
3334
|
+
alive: boolean;
|
|
3335
|
+
/** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
|
|
3336
|
+
owner?: "live" | "dead" | "unknown";
|
|
3337
|
+
/**
|
|
3338
|
+
* Own process of an `rpc` child, when this session launched it. A pid is
|
|
3339
|
+
* not persisted, so a record from another session has none — and none is
|
|
3340
|
+
* invented here (D3).
|
|
3341
|
+
*/
|
|
3342
|
+
pid?: number;
|
|
3343
|
+
/**
|
|
3344
|
+
* `false` = listed but not counted in the header (owner pid gone, see
|
|
3345
|
+
* {@link classifyChildrenForStatus}). Absent means live, so callers that only
|
|
3346
|
+
* hold live children keep their meaning.
|
|
3347
|
+
*/
|
|
3348
|
+
live?: boolean;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
/** Multi-child status text: one {@link renderLiveChildStatus} block per child. */
|
|
3352
|
+
export function renderStatusOverview(children: StatusChildEntry[]): string {
|
|
3353
|
+
const deadCount = children.filter((entry) => entry.live === false).length;
|
|
3354
|
+
const liveCount = children.length - deadCount;
|
|
3355
|
+
const blocks = children.map(({ child, alive, owner, pid }) =>
|
|
2820
3356
|
renderLiveChildStatus({
|
|
2821
3357
|
name: child.name,
|
|
2822
3358
|
state: child.state,
|
|
@@ -2828,9 +3364,22 @@ export function renderStatusOverview(
|
|
|
2828
3364
|
spawnMode: child.spawnMode,
|
|
2829
3365
|
alive,
|
|
2830
3366
|
...(owner ? { owner } : {}),
|
|
3367
|
+
...(pid ? { pid } : {}),
|
|
2831
3368
|
}),
|
|
2832
3369
|
);
|
|
2833
|
-
|
|
3370
|
+
const parts: string[] = [];
|
|
3371
|
+
if (liveCount === 0) {
|
|
3372
|
+
// Nothing live. With no record at all this stays the exact literal the
|
|
3373
|
+
// tests pin; with records that are all dead the count line below says so,
|
|
3374
|
+
// instead of implying the run directory is empty.
|
|
3375
|
+
parts.push("No live children.");
|
|
3376
|
+
} else {
|
|
3377
|
+
const noun = liveCount === 1 ? "child" : "children";
|
|
3378
|
+
parts.push(`Status · ${liveCount} live ${noun}`);
|
|
3379
|
+
}
|
|
3380
|
+
if (deadCount > 0) parts.push(`(${deadCount} dead)`);
|
|
3381
|
+
if (blocks.length > 0) parts.push("", blocks.join("\n\n"));
|
|
3382
|
+
return parts.join("\n");
|
|
2834
3383
|
}
|
|
2835
3384
|
|
|
2836
3385
|
/**
|