pi-legion 3.1.0 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +215 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +25 -21
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/vexillatio-contract.md +3 -1
  15. package/index.ts +557 -76
  16. package/package.json +4 -2
  17. package/skills/pi-legion/SKILL.md +2 -3
  18. package/src/agents/agents.ts +17 -10
  19. package/src/agents/overrides.ts +0 -1
  20. package/src/agents/presets.ts +14 -40
  21. package/src/agents/settings.ts +26 -77
  22. package/src/agents/step-model.ts +7 -22
  23. package/src/api.ts +1 -35
  24. package/src/extension/event-bus.ts +80 -8
  25. package/src/extension/legion-commands.ts +41 -17
  26. package/src/extension/legion-doctor.ts +1 -1
  27. package/src/extension/legion-stop.ts +39 -13
  28. package/src/extension/legion-tools.ts +21 -4
  29. package/src/extension/liveness.ts +200 -0
  30. package/src/extension/playbook.ts +2 -2
  31. package/src/extension/recycle.ts +4 -0
  32. package/src/extension/runtime.ts +379 -15
  33. package/src/extension/slash.ts +0 -21
  34. package/src/extension/summary.ts +1 -1
  35. package/src/legion/db.ts +165 -1
  36. package/src/legion/events.ts +81 -16
  37. package/src/legion/nodes.ts +13 -2
  38. package/src/legion/rebuild.ts +7 -1
  39. package/src/legion/usage.ts +58 -4
  40. package/src/runs/orchestrator.ts +1165 -62
  41. package/src/runs/worktree.ts +190 -21
  42. package/src/shared/ignored-kind.ts +57 -0
  43. package/src/shared/progress.ts +11 -0
  44. package/src/shared/session.ts +199 -19
  45. package/src/shared/types.ts +50 -66
  46. package/src/supervisor/admission-queue.ts +516 -0
  47. package/src/supervisor/inprocess-supervisor.ts +108 -33
  48. package/src/supervisor/proc-memory.ts +67 -0
  49. package/src/supervisor/routing-supervisor.ts +58 -1
  50. package/src/tui/session-messages-file.ts +100 -0
  51. package/src/tui/status.ts +91 -18
  52. package/src/tui/tree-view.ts +28 -3
  53. package/src/tui/viz/layout.ts +54 -3
  54. package/src/tui/viz/model.ts +67 -14
  55. package/src/tui/viz/palette.ts +11 -0
  56. package/src/tui/viz/panels.ts +222 -8
  57. package/src/tui/viz/view.ts +110 -23
  58. package/src/backends/legacy/client.ts +0 -638
  59. package/src/backends/legacy/cursor-chat.ts +0 -348
  60. package/src/backends/legacy/runner.ts +0 -101
  61. package/src/backends/legacy/types.ts +0 -138
  62. 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, makeName } from "./src/shared/name.ts";
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,
@@ -108,6 +109,7 @@ import {
108
109
  type SpawnMode,
109
110
  SubagentError,
110
111
  type SubagentsSettings,
112
+ type Handle,
111
113
  } from "./src/shared/types.ts";
112
114
 
113
115
  // ---------------------------------------------------------------------------
@@ -234,7 +236,17 @@ const SubagentParams = Type.Object({
234
236
  type SubagentParams = Static<typeof SubagentParams>;
235
237
 
236
238
  const VexillatioParams = Type.Object({
237
- task: Type.String({ description: "Recon task: one clear question list" }),
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
+ ),
238
250
  agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
239
251
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
240
252
  cwd: Type.Optional(Type.String()),
@@ -309,10 +321,129 @@ function currentProcessSpawnMode(): SpawnMode {
309
321
  return currentChildContext()?.spawnMode ?? "rpc";
310
322
  }
311
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
+
312
330
  function notice(pi: ExtensionAPI, text: string): void {
313
331
  pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
314
332
  }
315
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
+
316
447
  function createLegionRuntime(input: {
317
448
  pi: ExtensionAPI;
318
449
  supervisor: LegionSupervisor;
@@ -329,8 +460,7 @@ function createLegionRuntime(input: {
329
460
  }): LegionRuntimeIdentity {
330
461
  const self = getNode(input.db, input.nodeId);
331
462
  const eventBus = input.eventBus;
332
- const dbEventSource = createDbEventSource(input.db);
333
- dbEventSource.start(eventBus);
463
+ const dbEventSource = attachLegionRuntime(input);
334
464
  const postman = new LegionPostman({
335
465
  db: input.db,
336
466
  nodeId: input.nodeId,
@@ -508,7 +638,7 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
508
638
  const childRun = store.readRun(input.node.runId);
509
639
  if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
510
640
  const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
511
- 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 } });
512
642
  orchestrator.restore(parentRun);
513
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) });
514
644
  await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
@@ -537,6 +667,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
537
667
  else lastUi?.setStatus("legion-verify", undefined);
538
668
  } catch { /* no UI */ }
539
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
+ });
540
689
  }
541
690
  const onSpawnRefused = (message: string) => {
542
691
  try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
@@ -546,8 +695,20 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
546
695
  let lastCwd = process.cwd();
547
696
  const supervisor = deps.supervisor ?? new RoutingSupervisor({
548
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).
549
705
  inprocess: new InProcessSupervisor({
550
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
+ },
551
712
  getLimits: () => {
552
713
  try {
553
714
  return loadSubagentSettings({
@@ -562,7 +723,23 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
562
723
  onSpawnRefused,
563
724
  });
564
725
  const verifyRunner = deps.verifyRunner;
565
- const legionBus = createLegionEventBus();
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
+ });
566
743
  const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
567
744
  const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
568
745
  const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
@@ -925,9 +1102,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
925
1102
  stop: stopChildren,
926
1103
  childStatus: (name, cwd) => {
927
1104
  try {
928
- const found = findChild(runStoreFor(cwd).listRuns(), name);
1105
+ const store = runStoreFor(cwd);
1106
+ const runs = store.listRuns();
1107
+ const found = findChild(runs, name);
929
1108
  if (!found) return undefined;
930
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);
931
1116
  return {
932
1117
  name: child.name,
933
1118
  state: child.state,
@@ -938,7 +1123,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
938
1123
  ...(child.acceptance
939
1124
  ? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
940
1125
  : {}),
941
- alive: supervisor.isAlive(child.name),
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 } : {}),
942
1131
  };
943
1132
  } catch {
944
1133
  return undefined;
@@ -1113,6 +1302,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1113
1302
  params,
1114
1303
  signal,
1115
1304
  ctx,
1305
+ pi,
1116
1306
  supervisor,
1117
1307
  uiProxy,
1118
1308
  onBudgetRefused,
@@ -1139,7 +1329,16 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1139
1329
  details !== null && typeof details === "object" && !Array.isArray(details)
1140
1330
  ? (details as Record<string, unknown>)
1141
1331
  : undefined;
1142
- return ok("interrupted (parent turn ended; children still running)", carried);
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);
1143
1342
  }
1144
1343
  return result;
1145
1344
  },
@@ -1398,13 +1597,28 @@ async function controlAction(input: {
1398
1597
  }
1399
1598
  if (!params.name) {
1400
1599
  const runs = store.listRuns();
1401
- const live = listLiveChildrenForStatus(runs);
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);
1402
1617
  return ok(
1403
1618
  renderStatusOverview(
1404
- live.map((child) => {
1405
- const alive = input.supervisor.isAlive(child.name);
1406
- const owner = ownerVerdictFor(store, runs, child.name, alive);
1407
- return { child, alive, ...(owner ? { owner } : {}) };
1619
+ classified.map(({ child, live }) => {
1620
+ const { pid, ...facts } = factsFor(child);
1621
+ return { child, ...facts, ...(pid ? { pid } : {}), live };
1408
1622
  }),
1409
1623
  ),
1410
1624
  );
@@ -1658,6 +1872,7 @@ async function reviveChild(
1658
1872
  async function launchFamily(input: {
1659
1873
  params: SubagentParams;
1660
1874
  ctx: ExtensionContext;
1875
+ pi: ExtensionAPI;
1661
1876
  supervisor: LegionSupervisor;
1662
1877
  uiProxy: UIProxy;
1663
1878
  onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
@@ -1735,6 +1950,10 @@ async function launchFamily(input: {
1735
1950
  worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
1736
1951
  ...teamForChildren(input.team),
1737
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),
1738
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 } } : {}),
1739
1958
  onBudgetRefused: input.onBudgetRefused,
1740
1959
  });
@@ -1764,6 +1983,7 @@ async function launchFamily(input: {
1764
1983
  // abort (interrupted results run after that), so it is kept per name
1765
1984
  // here — same precedent as `timeoutByName`.
1766
1985
  spawnByName: new Map(),
1986
+ pidByName: new Map(),
1767
1987
  ...(nameGate.name ? { launchName: nameGate.name } : {}),
1768
1988
  };
1769
1989
 
@@ -1775,10 +1995,28 @@ async function launchFamily(input: {
1775
1995
 
1776
1996
  const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
1777
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");
1778
2003
  for (const name of session.handles) await abandonLaunchedChild(session, name);
1779
2004
  session.handles.length = 0;
2005
+ const flushLine = launchQueueFlushLine(flushed);
1780
2006
  return {
1781
- content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
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
+ }],
1782
2020
  details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
1783
2021
  };
1784
2022
  };
@@ -1858,6 +2096,13 @@ interface LaunchSession {
1858
2096
  timeoutByName: Map<string, number>;
1859
2097
  /** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
1860
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>;
1861
2106
  /** Explicit child name for this single-step launch; omit to auto-allocate. */
1862
2107
  launchName?: string;
1863
2108
  }
@@ -1952,9 +2197,8 @@ async function launchStep(
1952
2197
  // `--no-extensions` the child would resolve it there and die with
1953
2198
  // "No API key found". The parent registry knows the live provider.
1954
2199
  ...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
1955
- // The origin decides whether an unacceptable model is refused or
1956
- // dropped, and only this layer can tell them apart (a parent model
1957
- // 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.
1958
2202
  modelOrigin: model.modelOrigin,
1959
2203
  worktree: resolveLaunchWorktree({
1960
2204
  roleDefault: effective.worktree,
@@ -1976,6 +2220,7 @@ async function launchStep(
1976
2220
  agent: effective.name,
1977
2221
  spawnMode: handle.spawnMode,
1978
2222
  });
2223
+ if (handle.pid) session.pidByName.set(handle.name, handle.pid);
1979
2224
  if (effective.timeoutMs !== undefined) {
1980
2225
  session.timeoutByName.set(handle.name, effective.timeoutMs);
1981
2226
  }
@@ -1984,18 +2229,8 @@ async function launchStep(
1984
2229
  return;
1985
2230
  }
1986
2231
  session.results.push(
1987
- `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
2232
+ `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}${launchLineQueueSuffix(handle)}`,
1988
2233
  );
1989
- // An inherited model the target CLI cannot express is dropped on purpose
1990
- // (the parent's pi model means nothing to cursor). Say so, or the user
1991
- // reasonably believes the child is running the model they see on the
1992
- // parent — the same silent surprise this whole guard exists to remove.
1993
- if (handle.child.modelDropped?.length) {
1994
- session.results.push(
1995
- ` ⚠ ${effective.name} runs on ${effective.kind}'s own default: ` +
1996
- `${handle.child.modelDropped.join(", ")} does not apply to that CLI.`,
1997
- );
1998
- }
1999
2234
  session.onUpdate?.({
2000
2235
  content: [
2001
2236
  {
@@ -2094,23 +2329,100 @@ function resolveStep(
2094
2329
  };
2095
2330
  }
2096
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
+
2097
2376
  /** Wait for each launched child's turn, appending its outcome. */
2098
2377
  async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
2099
- for (const name of session.handles) {
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) {
2100
2397
  if (signal?.aborted) {
2101
2398
  session.results.push("interrupted (parent turn ended; children still running)");
2102
2399
  return;
2103
2400
  }
2104
2401
  try {
2105
- const collected = await session.orchestrator.collect(name, {
2106
- timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
2107
- ...(signal ? { signal } : {}),
2108
- });
2402
+ const collected = await collectThroughAdmission(session, name, signal);
2109
2403
  if (signal?.aborted || collected.execution.reason === "aborted by parent") {
2110
2404
  session.results.push("interrupted (parent turn ended; children still running)");
2111
2405
  return;
2112
2406
  }
2113
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
+ }
2114
2426
  } catch (error) {
2115
2427
  session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
2116
2428
  }
@@ -2143,10 +2455,16 @@ type UtilityPlan =
2143
2455
  | { ok: false; message: string };
2144
2456
 
2145
2457
  function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
2146
- if (!params.task.trim()) return { ok: false, message: "`task` must not be blank." };
2147
- const steps: UtilityTaskItem[] = params.tasks?.length
2148
- ? params.tasks
2149
- : [{ agent: params.agent?.trim() || "scout", task: params.task }];
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 }];
2150
2468
  const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
2151
2469
  if (unsupportedVerify >= 0) {
2152
2470
  return {
@@ -2323,6 +2641,10 @@ async function launchUtilityFamily(input: {
2323
2641
  childContext: resolveTeamContext("child", catalog.settings, catalog.team),
2324
2642
  team: catalog.team,
2325
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),
2326
2648
  onBudgetRefused: input.onBudgetRefused,
2327
2649
  ...(legion ? {
2328
2650
  legion: {
@@ -2381,6 +2703,7 @@ async function launchUtilityFamily(input: {
2381
2703
  agent: item.agent.name,
2382
2704
  sessionFile: handle.sessionFile,
2383
2705
  spawnMode: handle.spawnMode,
2706
+ ...(handle.pid ? { pid: handle.pid } : {}),
2384
2707
  orchestrator,
2385
2708
  retire,
2386
2709
  },
@@ -2397,6 +2720,7 @@ async function launchUtilityFamily(input: {
2397
2720
  agent: string;
2398
2721
  sessionFile: string;
2399
2722
  spawnMode: SpawnMode;
2723
+ pid?: number;
2400
2724
  orchestrator: Orchestrator;
2401
2725
  retire: (opts?: RetireOptions) => Promise<void>;
2402
2726
  }> = [];
@@ -2414,6 +2738,7 @@ async function launchUtilityFamily(input: {
2414
2738
  timeoutMs,
2415
2739
  // Arbitrated at launch; never re-derived here (#209).
2416
2740
  spawnMode: handle.spawnMode,
2741
+ ...(handle.pid ? { pid: handle.pid } : {}),
2417
2742
  orchestrator: handle.orchestrator,
2418
2743
  store,
2419
2744
  watch,
@@ -2445,6 +2770,17 @@ async function launchUtilityFamily(input: {
2445
2770
  lines.push(`sessionFile: ${handle.sessionFile}`);
2446
2771
  return lines.join("\n");
2447
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
+ }
2448
2784
  if (input.supervisor.isAlive(handle.name)) {
2449
2785
  followUtility(handle, true);
2450
2786
  stillRunning.add(handle.name);
@@ -2455,13 +2791,27 @@ async function launchUtilityFamily(input: {
2455
2791
  return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2456
2792
  }
2457
2793
  }));
2458
- if (input.signal?.aborted) return interruptedUtilityResult({
2459
- runId,
2460
- handles,
2461
- stillRunning,
2462
- results,
2463
- collected,
2464
- });
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
+ }
2465
2815
  results.push(...collected);
2466
2816
  return {
2467
2817
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
@@ -2493,9 +2843,18 @@ function interruptedUtilityResult(input: {
2493
2843
  stillRunning: Set<string>;
2494
2844
  results: string[];
2495
2845
  collected: string[];
2846
+ /** #266 D7: names the abort cleared out of the launch queue (seq order). */
2847
+ flushed?: readonly string[];
2496
2848
  }): AgentToolResult<unknown> {
2497
2849
  const head = "interrupted (parent turn ended; children still running)";
2498
- const segments = [...input.results, ...input.collected].filter((segment) => segment.length > 0);
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);
2499
2858
  const stillRunning = input.handles
2500
2859
  .filter(({ name }) => input.stillRunning.has(name))
2501
2860
  .map(({ name }) => name);
@@ -2663,6 +3022,13 @@ function followJob(
2663
3022
  treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
2664
3023
  /** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
2665
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;
2666
3032
  },
2667
3033
  ): void {
2668
3034
  const child = input.orchestrator
@@ -2683,6 +3049,7 @@ function followJob(
2683
3049
  ...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
2684
3050
  ...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
2685
3051
  ...(spawnMode ? { spawnMode } : {}),
3052
+ ...(input.pid ? { pid: input.pid } : {}),
2686
3053
  ...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
2687
3054
  nodeId: child.nodeId,
2688
3055
  parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
@@ -2760,16 +3127,21 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
2760
3127
  }
2761
3128
 
2762
3129
  /**
2763
- * The arbitrated backend recorded for one handled child of this request.
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.
2764
3132
  *
2765
3133
  * Spreads to nothing when unknown, so the caller never has to invent a value.
2766
3134
  */
2767
3135
  function spawnModeOf(
2768
3136
  session: LaunchSession,
2769
3137
  name: string,
2770
- ): { spawnMode?: SpawnMode } {
3138
+ ): { spawnMode?: SpawnMode; pid?: number } {
2771
3139
  const recorded = session.spawnByName.get(name);
2772
- return recorded ? { spawnMode: recorded.spawnMode } : {};
3140
+ const pid = session.pidByName.get(name);
3141
+ return {
3142
+ ...(recorded ? { spawnMode: recorded.spawnMode } : {}),
3143
+ ...(pid ? { pid } : {}),
3144
+ };
2773
3145
  }
2774
3146
 
2775
3147
  function followLaunched(
@@ -2840,6 +3212,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2840
3212
  session.runtime.release(name);
2841
3213
  return;
2842
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;
2843
3219
  // Esc / parent-turn abort must not retire: child keeps running; follow+watch
2844
3220
  // so settle still notifies the parent (esc-no-retire-v3).
2845
3221
  followLaunched(session, name, { watch: true });
@@ -2851,40 +3227,132 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2851
3227
  // ---------------------------------------------------------------------------
2852
3228
 
2853
3229
  /**
2854
- * Live (non-retired / non-exited) children under this cwd for status overview.
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.
2855
3248
  *
2856
- * Names can be reused after retire; prefer the newest live record per name
2857
- * (same preference as {@link pickChildByName}).
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}.
2858
3252
  */
2859
- export function listLiveChildrenForStatus(
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(
2860
3288
  runs: ReturnType<RunStore["listRuns"]>,
2861
- ): NonNullable<ReturnType<RunStore["findChild"]>>[] {
3289
+ factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
3290
+ ): StatusChildClassification[] {
2862
3291
  const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2863
- const seen = new Set<string>();
2864
- const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
3292
+ const candidates: StatusChildClassification[] = [];
2865
3293
  for (const run of ranked) {
2866
3294
  for (const child of run.children) {
2867
- if (child.state === "retired" || child.state === "exited") continue;
2868
- if (seen.has(child.name)) continue;
2869
- seen.add(child.name);
2870
- out.push(child);
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 });
2871
3302
  }
2872
3303
  }
2873
- return out;
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);
2874
3314
  }
2875
3315
 
2876
- /** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
2877
- export function renderStatusOverview(
2878
- children: Array<{
2879
- child: NonNullable<ReturnType<RunStore["findChild"]>>;
2880
- alive: boolean;
2881
- /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
2882
- owner?: "live" | "dead" | "unknown";
2883
- }>,
2884
- ): string {
2885
- if (children.length === 0) return "No live children.";
2886
- const noun = children.length === 1 ? "child" : "children";
2887
- const blocks = children.map(({ child, alive, owner }) =>
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 }) =>
2888
3356
  renderLiveChildStatus({
2889
3357
  name: child.name,
2890
3358
  state: child.state,
@@ -2896,9 +3364,22 @@ export function renderStatusOverview(
2896
3364
  spawnMode: child.spawnMode,
2897
3365
  alive,
2898
3366
  ...(owner ? { owner } : {}),
3367
+ ...(pid ? { pid } : {}),
2899
3368
  }),
2900
3369
  );
2901
- return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
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");
2902
3383
  }
2903
3384
 
2904
3385
  /**