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.
- package/CHANGELOG.md +215 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +25 -21
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +557 -76
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +41 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/playbook.ts +2 -2
- package/src/extension/recycle.ts +4 -0
- package/src/extension/runtime.ts +379 -15
- package/src/extension/slash.ts +0 -21
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +91 -18
- package/src/tui/tree-view.ts +28 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
import { Type, type Static } from "typebox";
|
|
22
22
|
import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
|
|
23
23
|
import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
|
|
24
|
-
import { RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
24
|
+
import { notifyInProcessSlotReleased, RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
25
25
|
import type { LegionSupervisor } from "./src/supervisor/types.ts";
|
|
26
26
|
import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
|
|
27
27
|
import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
|
|
@@ -51,16 +51,17 @@ import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./sr
|
|
|
51
51
|
import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
|
|
52
52
|
import type { VerifyRunner } from "./src/runs/acceptance.ts";
|
|
53
53
|
import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
|
|
54
|
-
import { appendEvent } from "./src/legion/events.ts";
|
|
54
|
+
import { appendEvent, maxEventId } from "./src/legion/events.ts";
|
|
55
55
|
import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
|
|
56
56
|
import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
|
|
57
57
|
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
58
58
|
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
59
|
-
import { allocateSiblingNames, isValidAgentName
|
|
59
|
+
import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
|
|
60
60
|
import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
|
|
61
61
|
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
62
62
|
import { currentChildContext } from "./src/supervisor/child-context.ts";
|
|
63
63
|
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
64
|
+
import { setLaunchQueueListener } from "./src/supervisor/admission-queue.ts";
|
|
64
65
|
import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
|
|
65
66
|
import {
|
|
66
67
|
createStatusNodeOpener,
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
-
const
|
|
1406
|
-
|
|
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: [{
|
|
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
|
|
1956
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
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)
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2857
|
-
*
|
|
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
|
|
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
|
-
|
|
3289
|
+
factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
|
|
3290
|
+
): StatusChildClassification[] {
|
|
2862
3291
|
const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2863
|
-
const
|
|
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 (
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
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
|
-
|
|
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
|
/**
|