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.
Files changed (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  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/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. 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,
@@ -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
- 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
+ ),
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 = createDbEventSource(input.db);
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
- 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
+ });
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 found = findChild(runStoreFor(cwd).listRuns(), name);
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
- 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 } : {}),
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
- return ok("interrupted (parent turn ended; children still running)");
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
- 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);
1384
1617
  return ok(
1385
1618
  renderStatusOverview(
1386
- live.map((child) => {
1387
- const alive = input.supervisor.isAlive(child.name);
1388
- const owner = ownerVerdictFor(store, runs, child.name, alive);
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 RPC child while preserving its outcome and session for resume. */
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
- `RPC process stopped; session kept for resume: ${child.sessionFile}`,
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 RPC child steered).`,
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(", ")} → RPC launch → watch`,
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: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
1754
- details: { runId: run.runId, handles: [], async: params.async ?? true },
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 RPC 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.",
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 decides whether an unacceptable model is refused or
1926
- // dropped, and only this layer can tell them apart (a parent model
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 via RPC`,
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
- 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) {
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.orchestrator.collect(name, {
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
- if (!params.task.trim()) return { ok: false, message: "`task` must not be blank." };
2110
- const steps: UtilityTaskItem[] = params.tasks?.length
2111
- ? params.tasks
2112
- : [{ 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 }];
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) return interruptedUtilityResult({
2418
- runId,
2419
- handles,
2420
- stillRunning,
2421
- results,
2422
- collected,
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
- 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);
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 }) => ({ 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
- * 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.
2787
3248
  *
2788
- * Names can be reused after retire; prefer the newest live record per name
2789
- * (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}.
2790
3252
  */
2791
- 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(
2792
3288
  runs: ReturnType<RunStore["listRuns"]>,
2793
- ): NonNullable<ReturnType<RunStore["findChild"]>>[] {
3289
+ factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
3290
+ ): StatusChildClassification[] {
2794
3291
  const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2795
- const seen = new Set<string>();
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 (child.state === "retired" || child.state === "exited") continue;
2800
- if (seen.has(child.name)) continue;
2801
- seen.add(child.name);
2802
- 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 });
2803
3302
  }
2804
3303
  }
2805
- 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);
2806
3314
  }
2807
3315
 
2808
- /** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
2809
- export function renderStatusOverview(
2810
- children: Array<{
2811
- child: NonNullable<ReturnType<RunStore["findChild"]>>;
2812
- alive: boolean;
2813
- /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
2814
- owner?: "live" | "dead" | "unknown";
2815
- }>,
2816
- ): string {
2817
- if (children.length === 0) return "No live children.";
2818
- const noun = children.length === 1 ? "child" : "children";
2819
- 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 }) =>
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
- 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");
2834
3383
  }
2835
3384
 
2836
3385
  /**