pi-legion 3.1.0 → 5.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 +382 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +127 -66
- package/agents/centurion.md +20 -16
- package/agents/scout.md +3 -1
- package/agents/worker.md +6 -8
- package/docs/contracts/declarative-phase-machine-v5.md +128 -0
- 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/contracts/subagent-renewal-v1.md +260 -0
- package/docs/headless-child-contract.md +6 -3
- package/docs/inprocess-supervisor.md +106 -27
- package/docs/legion-v2-contract.md +27 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +4 -1
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +1155 -120
- package/package.json +5 -3
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +310 -46
- package/src/agents/overrides.ts +3 -2
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +32 -95
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/child-guard.ts +25 -1
- 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 +87 -25
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +136 -4
- package/src/extension/playbook.ts +4 -4
- package/src/extension/recycle.ts +4 -0
- package/src/extension/runtime.ts +435 -16
- package/src/extension/slash.ts +0 -21
- package/src/extension/summary.ts +14 -5
- package/src/extension/timing.ts +8 -4
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/phase-conditions.ts +164 -0
- package/src/legion/phase-machine.ts +403 -0
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/state-machine.ts +219 -115
- package/src/legion/usage.ts +58 -4
- package/src/runs/args.ts +32 -18
- package/src/runs/extension-inheritance.ts +220 -0
- package/src/runs/orchestrator.ts +1292 -96
- package/src/runs/progress-signal.ts +137 -0
- package/src/runs/store-lookup.ts +270 -0
- 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 +138 -69
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/child-context.ts +19 -0
- package/src/supervisor/inprocess-supervisor.ts +137 -38
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +60 -2
- package/src/supervisor/rpc-supervisor.ts +2 -2
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +91 -18
- package/src/tui/tree-view.ts +84 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -3
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/view.ts +110 -23
- package/agents/advisor.md +0 -110
- package/agents/designer.md +0 -87
- package/agents/planner.md +0 -53
- package/agents/prototype.md +0 -72
- package/agents/reviewer.md +0 -113
- 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,8 @@ 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 {
|
|
24
|
+
import { runAtAbsoluteDepth } from "./src/supervisor/inprocess-depth.ts";
|
|
25
|
+
import { notifyInProcessSlotReleased, RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
25
26
|
import type { LegionSupervisor } from "./src/supervisor/types.ts";
|
|
26
27
|
import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
|
|
27
28
|
import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
|
|
@@ -48,19 +49,26 @@ import { resolveTeamContext } from "./src/agents/context.ts";
|
|
|
48
49
|
import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
|
|
49
50
|
import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
|
|
50
51
|
import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
|
|
52
|
+
import type { PhaseMachine } from "./src/legion/phase-machine.ts";
|
|
51
53
|
import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
|
|
52
54
|
import type { VerifyRunner } from "./src/runs/acceptance.ts";
|
|
53
55
|
import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
|
|
54
|
-
import { appendEvent } from "./src/legion/events.ts";
|
|
56
|
+
import { appendEvent, maxEventId } from "./src/legion/events.ts";
|
|
55
57
|
import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
|
|
56
58
|
import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
|
|
57
59
|
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
58
60
|
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
59
|
-
import { allocateSiblingNames, isValidAgentName
|
|
60
|
-
import {
|
|
61
|
+
import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
|
|
62
|
+
import {
|
|
63
|
+
deriveOutcome,
|
|
64
|
+
isLastTurnComplete,
|
|
65
|
+
NO_ASSISTANT_ABORT_REASON,
|
|
66
|
+
parseSessionFile,
|
|
67
|
+
} from "./src/shared/session.ts";
|
|
61
68
|
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
62
69
|
import { currentChildContext } from "./src/supervisor/child-context.ts";
|
|
63
70
|
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
71
|
+
import { setLaunchQueueListener } from "./src/supervisor/admission-queue.ts";
|
|
64
72
|
import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
|
|
65
73
|
import {
|
|
66
74
|
createStatusNodeOpener,
|
|
@@ -80,11 +88,14 @@ import {
|
|
|
80
88
|
} from "./src/extension/blocked.ts";
|
|
81
89
|
import { formatAlreadyRecycled } from "./src/extension/recycle.ts";
|
|
82
90
|
import {
|
|
91
|
+
completionStatusOf,
|
|
92
|
+
type RenewalProposal,
|
|
83
93
|
SUBAGENT_NOTIFY_TYPE,
|
|
84
94
|
} from "./src/extension/notify.ts";
|
|
85
95
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
86
96
|
import { renderSpawnResult } from "./src/extension/spawn-render.ts";
|
|
87
97
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
98
|
+
import { CHILD_ROLE_ENV } from "./src/extension/child-guard.ts";
|
|
88
99
|
import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
|
|
89
100
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
90
101
|
import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
@@ -95,19 +106,32 @@ import {
|
|
|
95
106
|
type OrchestratorLegionContext,
|
|
96
107
|
} from "./src/runs/orchestrator.ts";
|
|
97
108
|
import { RunStore, pickChildByName } from "./src/runs/store.ts";
|
|
109
|
+
import {
|
|
110
|
+
candidateStoreRoots,
|
|
111
|
+
resolveChildAcrossStores,
|
|
112
|
+
unknownChildMessage,
|
|
113
|
+
type CandidateStore,
|
|
114
|
+
} from "./src/runs/store-lookup.ts";
|
|
98
115
|
import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
|
|
116
|
+
import { measureProgress, readSessionLines } from "./src/runs/progress-signal.ts";
|
|
99
117
|
import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
|
|
100
118
|
import {
|
|
101
119
|
type AgentConfig,
|
|
102
120
|
type AgentScope,
|
|
121
|
+
type ChildRecord,
|
|
103
122
|
DEFAULTS,
|
|
123
|
+
DEFAULT_MAX_RENEWALS,
|
|
104
124
|
DEFAULT_ON_BLOCKED,
|
|
125
|
+
DEFAULT_RENEWABLE,
|
|
105
126
|
ErrorCodes,
|
|
127
|
+
type Execution,
|
|
106
128
|
type ModelOrigin,
|
|
107
129
|
type RetireOptions,
|
|
108
130
|
type SpawnMode,
|
|
109
131
|
SubagentError,
|
|
110
132
|
type SubagentsSettings,
|
|
133
|
+
unknownChildError,
|
|
134
|
+
type Handle,
|
|
111
135
|
} from "./src/shared/types.ts";
|
|
112
136
|
|
|
113
137
|
// ---------------------------------------------------------------------------
|
|
@@ -117,7 +141,12 @@ import {
|
|
|
117
141
|
const TaskItem = Type.Object({
|
|
118
142
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
119
143
|
task: Type.String({ description: "Task to delegate" }),
|
|
120
|
-
cwd: Type.Optional(
|
|
144
|
+
cwd: Type.Optional(
|
|
145
|
+
Type.String({
|
|
146
|
+
description:
|
|
147
|
+
"Directory the child records live under (`<cwd>/.pi-subagents`), used by launch and by every control action. Defaults to the session cwd. A name miss searches the neighbouring stores too, so this is rarely needed.",
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
121
150
|
model: Type.Optional(Type.String()),
|
|
122
151
|
preset: Type.Optional(
|
|
123
152
|
Type.String({ description: "Named kind+model+thinking preset" }),
|
|
@@ -173,7 +202,12 @@ const SubagentParams = Type.Object({
|
|
|
173
202
|
preset: Type.Optional(
|
|
174
203
|
Type.String({ description: "Named kind+model+thinking preset" }),
|
|
175
204
|
),
|
|
176
|
-
cwd: Type.Optional(
|
|
205
|
+
cwd: Type.Optional(
|
|
206
|
+
Type.String({
|
|
207
|
+
description:
|
|
208
|
+
"Directory the child records live under (`<cwd>/.pi-subagents`) for launch and every control action. Defaults to the session cwd. A name miss also searches neighbouring stores, so setting this is rarely needed.",
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
177
211
|
worktree: Type.Optional(
|
|
178
212
|
Type.Boolean({
|
|
179
213
|
description:
|
|
@@ -234,10 +268,25 @@ const SubagentParams = Type.Object({
|
|
|
234
268
|
type SubagentParams = Static<typeof SubagentParams>;
|
|
235
269
|
|
|
236
270
|
const VexillatioParams = Type.Object({
|
|
237
|
-
|
|
271
|
+
// Optional on purpose: `tasks[]` is a complete request on its own, exactly like
|
|
272
|
+
// `subagent({ tasks })`. Leaving this required made pi's own argument check
|
|
273
|
+
// reject `{ tasks: [...] }` before `execute` ran, so every `tasks[]`-only call
|
|
274
|
+
// failed with `task: must have required properties task` and had to be retried
|
|
275
|
+
// with a dummy `task` label (models copy the `subagent` shape).
|
|
276
|
+
task: Type.Optional(
|
|
277
|
+
Type.String({
|
|
278
|
+
description:
|
|
279
|
+
"Recon task: one clear question list. Optional when `tasks[]` is provided (then it only labels the parent run).",
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
238
282
|
agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
|
|
239
283
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
|
|
240
|
-
cwd: Type.Optional(
|
|
284
|
+
cwd: Type.Optional(
|
|
285
|
+
Type.String({
|
|
286
|
+
description:
|
|
287
|
+
"Directory the child records live under (`<cwd>/.pi-subagents`) for launch and every control action. Defaults to the session cwd. A name miss also searches neighbouring stores, so setting this is rarely needed.",
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
241
290
|
model: Type.Optional(Type.String()),
|
|
242
291
|
timeoutMs: Type.Optional(Type.Number({ description: "Per-child wait in ms, default 600000" })),
|
|
243
292
|
});
|
|
@@ -309,8 +358,159 @@ function currentProcessSpawnMode(): SpawnMode {
|
|
|
309
358
|
return currentChildContext()?.spawnMode ?? "rpc";
|
|
310
359
|
}
|
|
311
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The frozen interruption head line (`test/unit/teams.test.ts` matches it, and
|
|
363
|
+
* `esc-no-retire-v3` records why it leads every interrupted result).
|
|
364
|
+
*/
|
|
365
|
+
const INTERRUPTED_HEAD = "interrupted (parent turn ended; children still running)";
|
|
366
|
+
|
|
312
367
|
function notice(pi: ExtensionAPI, text: string): void {
|
|
313
|
-
|
|
368
|
+
// #273: `sendMessage` is a host ACTION and pi throws
|
|
369
|
+
// "Extension runtime not initialized. Action methods cannot be called during
|
|
370
|
+
// extension loading." if it is reached before the runtime is up. An error
|
|
371
|
+
// handler that is more fatal than the error it reports turns "a resume
|
|
372
|
+
// failed" into "the whole agent process died", which is what happened on the
|
|
373
|
+
// centurion recovery path. Degrade to stderr instead; the caller still gets
|
|
374
|
+
// the fact, and nothing crashes.
|
|
375
|
+
try {
|
|
376
|
+
pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
379
|
+
console.error(`[pi-legion] notice not delivered (${reason}): ${text}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Launch-admission-queue bounds from settings (issue #266, contract D6-a/D10).
|
|
385
|
+
*
|
|
386
|
+
* Frozen key names are `subagents.inprocess.maxQueuedLaunches` /
|
|
387
|
+
* `subagents.inprocess.launchQueueTimeoutMs`. An absent key is *omitted* rather
|
|
388
|
+
* than defaulted here: the single source of the defaults is
|
|
389
|
+
* `src/supervisor/admission-queue.ts`, and passing a literal 32/900000 from the
|
|
390
|
+
* adapter would create a second copy that can drift from the docs and sentinel.
|
|
391
|
+
*/
|
|
392
|
+
function launchQueueBounds(settings: {
|
|
393
|
+
inprocess?: { maxQueuedLaunches?: number; launchQueueTimeoutMs?: number };
|
|
394
|
+
}): { maxPending?: number; timeoutMs?: number } {
|
|
395
|
+
const bounds: { maxPending?: number; timeoutMs?: number } = {};
|
|
396
|
+
if (settings.inprocess?.maxQueuedLaunches !== undefined) bounds.maxPending = settings.inprocess.maxQueuedLaunches;
|
|
397
|
+
if (settings.inprocess?.launchQueueTimeoutMs !== undefined) bounds.timeoutMs = settings.inprocess.launchQueueTimeoutMs;
|
|
398
|
+
return bounds;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* D9/§13.4: the one user-visible line per queued / admitted / expired / stopped
|
|
403
|
+
* launch.
|
|
404
|
+
*
|
|
405
|
+
* Deliberately `triggerTurn: false` (unlike {@link notice}): admission is a
|
|
406
|
+
* mid-turn fact about work the caller already asked for, so waking the parent
|
|
407
|
+
* with a fresh turn for every queue transition would re-enter the agent for a
|
|
408
|
+
* purely cosmetic line. Anything the caller must act on still arrives through
|
|
409
|
+
* the tool result and, for `async:false`, the inline `↳ waited Ns` line.
|
|
410
|
+
*/
|
|
411
|
+
function launchQueueNoticeSink(pi: ExtensionAPI): (line: string) => void {
|
|
412
|
+
return (line: string) => {
|
|
413
|
+
try {
|
|
414
|
+
pi.sendMessage(
|
|
415
|
+
{ customType: SUBAGENT_NOTIFY_TYPE, content: line, display: true },
|
|
416
|
+
{ triggerTurn: false },
|
|
417
|
+
);
|
|
418
|
+
} catch {
|
|
419
|
+
// Cosmetic: a gone parent session must never break admission.
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* D9-1: the launch line's queue suffix, or `""` for a launch that spawned now.
|
|
426
|
+
*
|
|
427
|
+
* `▶ scout-7 (scout) supervisor=inprocess queued(3)` — the position is what the
|
|
428
|
+
* operator needs to know the launch is waiting rather than broken. Only the
|
|
429
|
+
* trailing segment is added: the line's published prefix and `details.spawns`
|
|
430
|
+
* (a renderer input) keep their existing shapes.
|
|
431
|
+
*/
|
|
432
|
+
function launchLineQueueSuffix(handle: Pick<Handle, "queued" | "queuePosition">): string {
|
|
433
|
+
if (handle.queued !== true) return "";
|
|
434
|
+
return handle.queuePosition === undefined ? " queued" : ` queued(${handle.queuePosition})`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* D8/A20: the honest wait line for an `async:false` child that queued.
|
|
439
|
+
*
|
|
440
|
+
* Read from the admitted `ChildRecord.queueWaitedMs` — never re-derived from a
|
|
441
|
+
* clock here, or the number would describe the adapter's own latency instead of
|
|
442
|
+
* the queue's. `""` when the child never waited.
|
|
443
|
+
*/
|
|
444
|
+
function queuedWaitLine(child: { name: string; queueWaitedMs?: number } | undefined): string {
|
|
445
|
+
if (!child || child.queueWaitedMs === undefined || child.queueWaitedMs <= 0) return "";
|
|
446
|
+
return `↳ ${child.name} waited ${Math.round(child.queueWaitedMs / 1000)}s in the launch queue`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* D6/D7: report a queue that an abort cleared out.
|
|
451
|
+
*
|
|
452
|
+
* "Empty queue and zero spawns" is only half the contract; the caller must also
|
|
453
|
+
* learn that N launches it asked for never ran. One line, names in `seq` order.
|
|
454
|
+
*/
|
|
455
|
+
function launchQueueFlushLine(flushed: readonly string[]): string {
|
|
456
|
+
if (flushed.length === 0) return "";
|
|
457
|
+
return `${flushed.length} queued launch(es) dropped: ${flushed.join(", ")}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* #238: create and start the DB event source that feeds the legion bus on attach.
|
|
462
|
+
*
|
|
463
|
+
* This is the production attach path and it is exported so a unit test can drive
|
|
464
|
+
* it directly (same seam style as `buildPlan`/`resolveWaitTargets`): the seeding
|
|
465
|
+
* behaviour is a property of THIS call, not of `createDbEventSource`, and a test
|
|
466
|
+
* that only exercised the factory would keep passing if this seed were removed.
|
|
467
|
+
*
|
|
468
|
+
* Seeding at `MAX(id)` matters because `createDbEventSource`'s own default is 0,
|
|
469
|
+
* i.e. "replay the whole log". On a ledger with history that re-delivered every
|
|
470
|
+
* stored event through `onWidgetEvent` — one full `snapshotNodes()` plus a
|
|
471
|
+
* repaint each, ~2.5s of blocked UI at 120 nodes / 1000 events — to reach a
|
|
472
|
+
* state the DB already describes.
|
|
473
|
+
*
|
|
474
|
+
* Rows committed in the window between this seed query and the first poll are
|
|
475
|
+
* not lost: `poll()` only skips when `lastDataVersion` matches, that variable
|
|
476
|
+
* starts `undefined` (so the first poll always reads), and the cursor is only
|
|
477
|
+
* ever advanced from ids actually returned by `SELECT ... WHERE id > cursor`.
|
|
478
|
+
* A row committed after the seed is therefore either picked up by the first
|
|
479
|
+
* poll or dirties `data_version` and is picked up on the next tick. The seed is
|
|
480
|
+
* not atomic with `start()`, but it does not need to be.
|
|
481
|
+
*
|
|
482
|
+
* Note the drain loop's early `return`: it exits BEFORE `lastDataVersion` is
|
|
483
|
+
* assigned (that assignment is after the loop), so an interrupted drain leaves
|
|
484
|
+
* the version unlatched. That errs safe — the next poll re-reads from the
|
|
485
|
+
* cursor instead of skipping — but it is a re-read, not a "set then return".
|
|
486
|
+
*/
|
|
487
|
+
export function attachLegionRuntime(input: {
|
|
488
|
+
db: DatabaseSync;
|
|
489
|
+
eventBus: LegionEventBus;
|
|
490
|
+
}): DbEventSource {
|
|
491
|
+
const dbEventSource = createDbEventSource(input.db, { cursor: maxEventId(input.db) });
|
|
492
|
+
dbEventSource.start(input.eventBus);
|
|
493
|
+
return dbEventSource;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The phase machine for a legion node, from its role card (#298).
|
|
498
|
+
*
|
|
499
|
+
* Reads `phases:` off the agent definition for the node's role. Absent means the
|
|
500
|
+
* built-in machine, so an undeclared team is unaffected. A card that declares an
|
|
501
|
+
* invalid machine never reaches here: `parseAgentDocument` rejects it at load.
|
|
502
|
+
*/
|
|
503
|
+
function phaseMachineForRole(role: string | null | undefined, cwd: string, settings: SubagentsSettings): PhaseMachine | undefined {
|
|
504
|
+
if (!role) return undefined;
|
|
505
|
+
try {
|
|
506
|
+
const discovery = discoverAgents(cwd, "user", { includeBuiltin: settings.disableBuiltins !== true });
|
|
507
|
+
const overridden = applyAgentOverrides(discovery.agents, settings.agentOverrides);
|
|
508
|
+
return findAgent(overridden, role)?.phases;
|
|
509
|
+
} catch {
|
|
510
|
+
// Discovery is best-effort here: a broken project dir must not stop a team
|
|
511
|
+
// from attaching, and the built-in machine still works.
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
314
514
|
}
|
|
315
515
|
|
|
316
516
|
function createLegionRuntime(input: {
|
|
@@ -326,11 +526,12 @@ function createLegionRuntime(input: {
|
|
|
326
526
|
settings: LegionSettings;
|
|
327
527
|
eventBus: LegionEventBus;
|
|
328
528
|
registerTools: boolean;
|
|
529
|
+
/** #298: the team root's declared phase machine, when its card supplies one. */
|
|
530
|
+
machine?: PhaseMachine;
|
|
329
531
|
}): LegionRuntimeIdentity {
|
|
330
532
|
const self = getNode(input.db, input.nodeId);
|
|
331
533
|
const eventBus = input.eventBus;
|
|
332
|
-
const dbEventSource =
|
|
333
|
-
dbEventSource.start(eventBus);
|
|
534
|
+
const dbEventSource = attachLegionRuntime(input);
|
|
334
535
|
const postman = new LegionPostman({
|
|
335
536
|
db: input.db,
|
|
336
537
|
nodeId: input.nodeId,
|
|
@@ -339,6 +540,7 @@ function createLegionRuntime(input: {
|
|
|
339
540
|
eventBus,
|
|
340
541
|
sendRootMessage: (text: string) => notice(input.pi, text),
|
|
341
542
|
sendSelfMessage: (text: string) => notice(input.pi, text),
|
|
543
|
+
...(input.machine ? { machine: input.machine } : {}),
|
|
342
544
|
resumeChild: (node: LegionNode, mail: LegionMailCommand) => resumeLegionChild({
|
|
343
545
|
pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
|
|
344
546
|
eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
|
|
@@ -363,7 +565,7 @@ function createLegionRuntime(input: {
|
|
|
363
565
|
postman,
|
|
364
566
|
childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
|
|
365
567
|
};
|
|
366
|
-
if (input.registerTools && input.nodeId !== "root") registerLegionTools(input.pi, { db: input.db, nodeId: input.nodeId, role: self?.role, settings: input.settings, cwd: input.cwd, runDir: input.runDir, eventBus, sendSelfMessage: (text: string) => notice(input.pi, text) });
|
|
568
|
+
if (input.registerTools && input.nodeId !== "root") registerLegionTools(input.pi, { db: input.db, nodeId: input.nodeId, role: self?.role, settings: input.settings, cwd: input.cwd, runDir: input.runDir, eventBus, sendSelfMessage: (text: string) => notice(input.pi, text), ...(input.machine ? { machine: input.machine } : {}) });
|
|
367
569
|
postman.start();
|
|
368
570
|
return identity;
|
|
369
571
|
}
|
|
@@ -421,8 +623,31 @@ function resolveLegionIdentity(input: {
|
|
|
421
623
|
nodeRun = input.store.createRun({ task: node.taskSummary ?? `Resume ${node.role} node ${node.id}`, cwd: input.cwd, path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), maxDepth: effectiveMaxDepth(input.settings.legion?.maxDepth), nodeId, herdr: { supervisor: "rpc", node_id: nodeId } });
|
|
422
624
|
}
|
|
423
625
|
if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
|
|
424
|
-
const
|
|
425
|
-
|
|
626
|
+
const machine = phaseMachineForRole(node.role, input.cwd, input.settings);
|
|
627
|
+
const identity = createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath: absoluteDbPath, nodeId, rootRunId: getNode(db, "root")?.runId ?? path.basename(path.dirname(absoluteDbPath)), runDir: input.store.runDir(nodeRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: true, ...(machine ? { machine } : {}) });
|
|
628
|
+
// #273: `createLegionRuntime` runs during extension LOADING, where host
|
|
629
|
+
// actions (sendMessage, spawn, ui) are not available yet. Defer the team
|
|
630
|
+
// recovery to the next tick so a failure inside it cannot crash the load
|
|
631
|
+
// path, and so any notice it emits has a live runtime to go to.
|
|
632
|
+
if (node.role === "centurion") {
|
|
633
|
+
// #275: run the recovery at depth 0 explicitly. `queueMicrotask`
|
|
634
|
+
// INHERITS the AsyncLocalStorage depth (verified: `als.getStore()`
|
|
635
|
+
// inside a queued microtask still reads the parent's value), so a
|
|
636
|
+
// recovery queued from a nested context would start already deep and
|
|
637
|
+
// report `nesting limit reached (depth N, max 4)` for a two-level
|
|
638
|
+
// chain. `runAtAbsoluteDepth(0, …)` is the mechanism this codebase
|
|
639
|
+
// already uses for exactly that reason (see inprocess-depth.ts
|
|
640
|
+
// "REV2-1"): the recovery runs at the TOP of the tree, not under
|
|
641
|
+
// whatever context happened to enqueue it.
|
|
642
|
+
queueMicrotask(() => {
|
|
643
|
+
void runAtAbsoluteDepth(0, async () => {
|
|
644
|
+
await resumeLegionTeam({ identity, input });
|
|
645
|
+
}).catch((error: unknown) => {
|
|
646
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
647
|
+
console.error(`[pi-legion] team recovery for ${identity.nodeId} failed: ${reason}`);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
}
|
|
426
651
|
return identity;
|
|
427
652
|
}
|
|
428
653
|
if (input.cached?.nodeId === "root" && path.resolve(input.cached.storeRootDir) === path.resolve(input.store.rootDir)) return input.cached;
|
|
@@ -508,12 +733,57 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
508
733
|
const childRun = store.readRun(input.node.runId);
|
|
509
734
|
if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
|
|
510
735
|
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 } });
|
|
736
|
+
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
737
|
orchestrator.restore(parentRun);
|
|
513
738
|
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
739
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
515
740
|
}
|
|
516
741
|
|
|
742
|
+
/**
|
|
743
|
+
* The live legion runtime for THIS process, held at module scope rather than in
|
|
744
|
+
* the `piLegion` closure (#283).
|
|
745
|
+
*
|
|
746
|
+
* `pi` re-runs the extension factory for every `DefaultResourceLoader.reload()`,
|
|
747
|
+
* and an in-process child session creates its own loader
|
|
748
|
+
* (`inprocess-supervisor.ts` builds one per `createAgentSession`). So a single
|
|
749
|
+
* process can enter `piLegion` many times. A closure-local `let` reset to
|
|
750
|
+
* `undefined` on each entry defeated the cache inside `resolveLegionIdentity`,
|
|
751
|
+
* which then opened a fresh `legion.db` — and never closed it. Measured on a
|
|
752
|
+
* centurion dispatching one in-process scout: one extra open per entry, ~2.5k
|
|
753
|
+
* leaked descriptors per 20 s, RSS climbing to 6 GB until V8 aborted with
|
|
754
|
+
* `JavaScript heap out of memory`.
|
|
755
|
+
*/
|
|
756
|
+
/**
|
|
757
|
+
* Runtime handles for THIS process, keyed by ledger path (#283).
|
|
758
|
+
*
|
|
759
|
+
* Two independent facts force a map rather than a single slot:
|
|
760
|
+
*
|
|
761
|
+
* 1. `pi` re-runs the extension factory for every `DefaultResourceLoader.reload()`,
|
|
762
|
+
* and an in-process child session builds its OWN loader per
|
|
763
|
+
* `createAgentSession` — so one process enters `piLegion` many times. A
|
|
764
|
+
* closure-local binding reset on every entry defeated the `cached`
|
|
765
|
+
* short-circuit in `resolveLegionIdentity`, which then opened a fresh
|
|
766
|
+
* `legion.db` per entry and never closed it: measured on a centurion
|
|
767
|
+
* dispatching one in-process scout, ~2.5k leaked descriptors per 20 s and RSS
|
|
768
|
+
* climbing to 6 GB until V8 aborted with `JavaScript heap out of memory`.
|
|
769
|
+
* 2. Those entries do NOT share one ledger. A parent and its in-process child
|
|
770
|
+
* legitimately own different `legion.db` files, and each registers its own
|
|
771
|
+
* `session_shutdown`. With a single slot the child's shutdown closed the
|
|
772
|
+
* PARENT's database, surfacing later as `database is not open` from whichever
|
|
773
|
+
* pump or admission probe ran next.
|
|
774
|
+
*
|
|
775
|
+
* Keying by (resolved path, nodeId) keeps the reuse (2) while giving each owner
|
|
776
|
+
* its own lifecycle (1).
|
|
777
|
+
*
|
|
778
|
+
* The path alone is NOT enough: a parent and its in-process child share ONE
|
|
779
|
+
* ledger file (the child inherits `dbPath` from the orchestrator's launch
|
|
780
|
+
* context) and differ only by the node row they own. Keying by path made them
|
|
781
|
+
* collide, so the child's shutdown closed the parent's handle and a sibling's
|
|
782
|
+
* next spawn failed with `database is not open` (#285 follow-up).
|
|
783
|
+
*/
|
|
784
|
+
const legionIdentityKey = (dbPath: string, nodeId: string): string => `${dbPath}\u0000${nodeId}`;
|
|
785
|
+
const legionIdentities = new Map<string, LegionRuntimeIdentity>();
|
|
786
|
+
|
|
517
787
|
export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
518
788
|
// #197 D8: a session is a child if either transport says so. RPC children
|
|
519
789
|
// are separate processes marked in env; in-process children share the
|
|
@@ -537,6 +807,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
537
807
|
else lastUi?.setStatus("legion-verify", undefined);
|
|
538
808
|
} catch { /* no UI */ }
|
|
539
809
|
});
|
|
810
|
+
// #266 D9-3: the launch admission queue's own pill. Deliberately a separate
|
|
811
|
+
// listener from verify's — different phases, and verify's is poll-driven.
|
|
812
|
+
// The contract's baseline is `legion-queue: N queued`; the `· 8/8 slots busy`
|
|
813
|
+
// tail needs `slotsInUse`/`slots`, which the pure queue does not know (the
|
|
814
|
+
// limit lives in the supervisor), so that dimension is omitted until the
|
|
815
|
+
// event carries it — the contract allows the omission explicitly.
|
|
816
|
+
setLaunchQueueListener((event) => {
|
|
817
|
+
try {
|
|
818
|
+
if (event.phase === "queued" || event.phase === "admitted") {
|
|
819
|
+
const suffix = event.slotsInUse !== undefined && event.slots !== undefined
|
|
820
|
+
? ` · ${event.slotsInUse}/${event.slots} slots busy`
|
|
821
|
+
: "";
|
|
822
|
+
lastUi?.setStatus("legion-queue", `legion-queue: ${event.queued} queued${suffix}`);
|
|
823
|
+
if (event.queued === 0) lastUi?.setStatus("legion-queue", undefined);
|
|
824
|
+
} else if (event.phase === "overflow" || event.phase === "expired" || event.phase === "flushed") {
|
|
825
|
+
lastUi?.setStatus("legion-queue", undefined);
|
|
826
|
+
}
|
|
827
|
+
} catch { /* no UI */ }
|
|
828
|
+
});
|
|
540
829
|
}
|
|
541
830
|
const onSpawnRefused = (message: string) => {
|
|
542
831
|
try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
|
|
@@ -546,8 +835,20 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
546
835
|
let lastCwd = process.cwd();
|
|
547
836
|
const supervisor = deps.supervisor ?? new RoutingSupervisor({
|
|
548
837
|
rpc: new RpcSupervisor({ onSpawnRefused }),
|
|
838
|
+
// The in-process supervisor is constructed here (not by RoutingSupervisor) so
|
|
839
|
+
// it can read live settings per spawn. That means `onSlotReleased` must be
|
|
840
|
+
// wired EXPLICITLY: RoutingSupervisor only adds it when it builds the
|
|
841
|
+
// backend itself (`options.inprocess ?? new InProcessSupervisor({...})`).
|
|
842
|
+
// Passing a pre-built instance used to drop the hook silently, so a freed
|
|
843
|
+
// slot never woke a launch-queue pump in production — admission then waited
|
|
844
|
+
// for the fallback recheck instead of firing on the release (#266 D2-d-2).
|
|
549
845
|
inprocess: new InProcessSupervisor({
|
|
550
846
|
onSpawnRefused,
|
|
847
|
+
// The event payload is not needed here: every Orchestrator on this run dir
|
|
848
|
+
// re-probes its own items, so a bare wake-up edge is the whole contract.
|
|
849
|
+
onSlotReleased: () => {
|
|
850
|
+
notifyInProcessSlotReleased();
|
|
851
|
+
},
|
|
551
852
|
getLimits: () => {
|
|
552
853
|
try {
|
|
553
854
|
return loadSubagentSettings({
|
|
@@ -562,7 +863,23 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
562
863
|
onSpawnRefused,
|
|
563
864
|
});
|
|
564
865
|
const verifyRunner = deps.verifyRunner;
|
|
565
|
-
|
|
866
|
+
// #238: the resident status widget defers its repaint to a microtask, which puts
|
|
867
|
+
// a paint failure outside the per-subscriber try/catch in `createLegionEventBus`.
|
|
868
|
+
// The runtime funnels that failure through `reportError` → `onHandlerError`, so
|
|
869
|
+
// a handler must actually be configured or the containment is silent. A widget
|
|
870
|
+
// repaint that throws (a stale UI context, a SQLITE_IOERR on the tree read) must
|
|
871
|
+
// never take the host down, and must leave a trace.
|
|
872
|
+
const legionBus = createLegionEventBus({
|
|
873
|
+
onHandlerError: (error, event, handler) => {
|
|
874
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
875
|
+
// `event === null` means a deferred failure with no event to attribute;
|
|
876
|
+
// `handler` is then an origin tag rather than a subscriber function.
|
|
877
|
+
const where = event
|
|
878
|
+
? `${event.type} node=${event.nodeId}`
|
|
879
|
+
: typeof handler === "string" ? handler : "deferred";
|
|
880
|
+
console.error(`[pi-legion] legion event handler failed (${where}): ${detail}`);
|
|
881
|
+
},
|
|
882
|
+
});
|
|
566
883
|
const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
|
|
567
884
|
const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
|
|
568
885
|
const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
|
|
@@ -584,16 +901,50 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
584
901
|
return true;
|
|
585
902
|
}
|
|
586
903
|
};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
904
|
+
/**
|
|
905
|
+
* Close one ledger handle (by resolved path) or every handle this process
|
|
906
|
+
* opened. Shutdown handlers pass their own path so a child session teardown
|
|
907
|
+
* cannot close the parent's database (#283).
|
|
908
|
+
*/
|
|
909
|
+
const stopLegionRuntime = (key?: string): void => {
|
|
910
|
+
const targets = key === undefined
|
|
911
|
+
? [...legionIdentities.entries()]
|
|
912
|
+
: (legionIdentities.has(key) ? [[key, legionIdentities.get(key)!] as const] : []);
|
|
913
|
+
for (const [key, identity] of targets) {
|
|
914
|
+
legionIdentities.delete(key);
|
|
915
|
+
identity.postman.stop();
|
|
916
|
+
identity.dbEventSource.stop();
|
|
917
|
+
// #40: bound the ledger and fold the WAL back before closing.
|
|
918
|
+
if (hygieneEnabled() && !isChild) applyEventRetention(identity.db);
|
|
919
|
+
else checkpoint(identity.db);
|
|
920
|
+
identity.db.close();
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
/** The handle this extension entry owns, if any (see `legionIdentities`). */
|
|
924
|
+
let ownLegionKey: string | undefined;
|
|
925
|
+
const ownLegionIdentity = (): LegionRuntimeIdentity | undefined =>
|
|
926
|
+
ownLegionKey === undefined ? undefined : legionIdentities.get(ownLegionKey);
|
|
927
|
+
/**
|
|
928
|
+
* Register a resolved identity and make it this entry's own (#283/#285).
|
|
929
|
+
*
|
|
930
|
+
* One rule for every call site: the map holds at most one live handle per
|
|
931
|
+
* ledger path, and an existing handle is only closed when THIS entry is
|
|
932
|
+
* replacing the path it already owned. Reassigning across paths never closes
|
|
933
|
+
* anything — a sibling entry (parent vs in-process child) may still be using
|
|
934
|
+
* the handle, and closing it surfaced as `database is not open` from that
|
|
935
|
+
* sibling's next spawn.
|
|
936
|
+
*/
|
|
937
|
+
const adoptLegionIdentity = (identity: LegionRuntimeIdentity): void => {
|
|
938
|
+
const key = legionIdentityKey(identity.dbPath, identity.nodeId);
|
|
939
|
+
const existing = legionIdentities.get(key);
|
|
940
|
+
// Only close when this entry is replacing the key it ALREADY owns: that
|
|
941
|
+
// handle is provably ours. A handle owned by a sibling (same ledger file,
|
|
942
|
+
// different node) is not ours to close.
|
|
943
|
+
if (existing && existing !== identity && ownLegionKey === key) {
|
|
944
|
+
stopLegionRuntime(key);
|
|
945
|
+
}
|
|
946
|
+
if (existing !== identity) legionIdentities.set(key, identity);
|
|
947
|
+
ownLegionKey = key;
|
|
597
948
|
};
|
|
598
949
|
const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string; legionNode?: { dbPath: string; nodeId: string } }): void => {
|
|
599
950
|
// The injected node's dbPath is authoritative: an in-process child shares the
|
|
@@ -603,9 +954,27 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
603
954
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
604
955
|
projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
|
|
605
956
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
957
|
+
// Resolve the ledger path BEFORE asking for a cache entry: a fresh factory
|
|
958
|
+
// entry starts with no owned path, but the same database may already have a
|
|
959
|
+
// live handle from an earlier entry in this process (#283).
|
|
960
|
+
const targetPath = ctx.legionNode?.dbPath ?? process.env[LEGION_DB_ENV];
|
|
961
|
+
const targetNode = ctx.legionNode?.nodeId ?? process.env[LEGION_NODE_ID_ENV];
|
|
962
|
+
const cached = targetPath !== undefined && targetNode !== undefined && path.isAbsolute(targetPath)
|
|
963
|
+
? legionIdentities.get(legionIdentityKey(path.resolve(targetPath), targetNode))
|
|
964
|
+
: ownLegionIdentity();
|
|
965
|
+
const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
|
|
966
|
+
if (next === undefined) return;
|
|
967
|
+
// Index by ledger path: a re-entry for the SAME database reuses the cached
|
|
968
|
+
// handle, a different database (parent vs its in-process child) gets its
|
|
969
|
+
// own slot.
|
|
970
|
+
//
|
|
971
|
+
// Never close `cached` here just because this entry resolved elsewhere.
|
|
972
|
+
// `resolveLegionIdentity` returns a DIFFERENT object for the same path when
|
|
973
|
+
// the node/session changed, and that earlier handle may still be in use by
|
|
974
|
+
// the entry that opened it — closing it produced `database is not open`
|
|
975
|
+
// from a sibling's spawn (#285 follow-up). Only the entry that owns a path
|
|
976
|
+
// closes it, via its own session_shutdown.
|
|
977
|
+
adoptLegionIdentity(next);
|
|
609
978
|
};
|
|
610
979
|
if (isChild) {
|
|
611
980
|
registerChildGuard(pi);
|
|
@@ -659,7 +1028,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
659
1028
|
}
|
|
660
1029
|
const { storeRoot, notify } = pendingHygiene;
|
|
661
1030
|
try {
|
|
662
|
-
const report = runStartupHygiene({ storeRoot, protectRunIds:
|
|
1031
|
+
const report = runStartupHygiene({ storeRoot, protectRunIds: ownLegionIdentity() ? [ownLegionIdentity()!.rootRunId] : [] });
|
|
663
1032
|
const text = formatHygieneReport(report);
|
|
664
1033
|
if (text) notify?.(text);
|
|
665
1034
|
} catch (error) {
|
|
@@ -703,7 +1072,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
703
1072
|
...children.map((job) => job.retire?.(shutdownRetire).catch(() => {})),
|
|
704
1073
|
...pendingUtilityRetires.map((retire) => retire(shutdownRetire).catch(() => {})),
|
|
705
1074
|
]);
|
|
706
|
-
|
|
1075
|
+
// Scope to the ledger THIS entry opened: a parent and its in-process child
|
|
1076
|
+
// both register a shutdown handler, and each must close only its own (#283).
|
|
1077
|
+
stopLegionRuntime(ownLegionKey);
|
|
707
1078
|
};
|
|
708
1079
|
if (process.env[UTILITY_CHILD_ENV] !== "1") {
|
|
709
1080
|
pi.registerTool({
|
|
@@ -736,11 +1107,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
736
1107
|
sessionCwd: ctx.cwd,
|
|
737
1108
|
signal,
|
|
738
1109
|
isChild,
|
|
739
|
-
legion:
|
|
1110
|
+
legion: ownLegionIdentity(),
|
|
740
1111
|
eventBus: legionBus,
|
|
741
1112
|
onLegion: (identity) => {
|
|
742
|
-
|
|
743
|
-
legionIdentity = identity;
|
|
1113
|
+
adoptLegionIdentity(identity);
|
|
744
1114
|
},
|
|
745
1115
|
onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
|
|
746
1116
|
});
|
|
@@ -764,16 +1134,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
764
1134
|
catalog.settings,
|
|
765
1135
|
catalog.team,
|
|
766
1136
|
);
|
|
1137
|
+
// A centurion running as a NESTED child is still a "parent" for templating
|
|
1138
|
+
// purposes — it dispatches its own squad — but it must not be offered itself:
|
|
1139
|
+
// the runtime refuses a non-root centurion dispatch (a nested centurion is
|
|
1140
|
+
// a second command chain the root cannot collect), so listing it invites a
|
|
1141
|
+
// call that can only fail. Leaving it out keeps the roster truthful.
|
|
1142
|
+
const selfRole = process.env[CHILD_ROLE_ENV];
|
|
1143
|
+
const rosterAgents = selfRole
|
|
1144
|
+
? catalog.agents.filter((agent) => agent.name !== selfRole)
|
|
1145
|
+
: catalog.agents;
|
|
767
1146
|
return {
|
|
768
1147
|
systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
|
|
769
|
-
|
|
1148
|
+
rosterAgents,
|
|
770
1149
|
catalog.team,
|
|
771
1150
|
catalog.teamWarnings,
|
|
772
1151
|
)}${parentContext ? `\n\n${parentContext}` : ""}`,
|
|
773
1152
|
};
|
|
774
1153
|
});
|
|
775
1154
|
if (process.env[UTILITY_CHILD_ENV] === "1") {
|
|
776
|
-
pi.on("session_shutdown", () => stopLegionRuntime());
|
|
1155
|
+
pi.on("session_shutdown", () => stopLegionRuntime(ownLegionKey));
|
|
777
1156
|
return;
|
|
778
1157
|
}
|
|
779
1158
|
let lastUi: ExtensionUIContext | undefined;
|
|
@@ -793,13 +1172,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
793
1172
|
statusStrip: process.env.PI_LEGION_STRIP === "tree" ? "tree" : "railway",
|
|
794
1173
|
eventBus: legionBus,
|
|
795
1174
|
getTreeNodes: () => {
|
|
796
|
-
const identity =
|
|
1175
|
+
const identity = ownLegionIdentity();
|
|
797
1176
|
return identity
|
|
798
1177
|
? attachUsageTotals(identity.db, getSubtree(identity.db, identity.nodeId))
|
|
799
1178
|
: [];
|
|
800
1179
|
},
|
|
1180
|
+
// #298: let the tree render a team's declared workflow. Cached per role
|
|
1181
|
+
// because agent discovery touches the filesystem and the tree re-renders on
|
|
1182
|
+
// every event.
|
|
1183
|
+
getPhaseMachine: (() => {
|
|
1184
|
+
const cache = new Map<string, PhaseMachine | undefined>();
|
|
1185
|
+
return (role) => {
|
|
1186
|
+
if (!role) return undefined;
|
|
1187
|
+
if (cache.has(role)) return cache.get(role);
|
|
1188
|
+
let machine: PhaseMachine | undefined;
|
|
1189
|
+
try {
|
|
1190
|
+
// `loadCatalog` already applies agentOverrides and team selection, so
|
|
1191
|
+
// this is the same definition the launch path resolves.
|
|
1192
|
+
const catalog = loadCatalog(lastCwd, lastCwd, undefined);
|
|
1193
|
+
machine = findAgent(catalog.agents, role)?.phases;
|
|
1194
|
+
} catch {
|
|
1195
|
+
// Discovery must never break rendering: a malformed project dir would
|
|
1196
|
+
// otherwise blank the tree.
|
|
1197
|
+
machine = undefined;
|
|
1198
|
+
}
|
|
1199
|
+
cache.set(role, machine);
|
|
1200
|
+
return machine;
|
|
1201
|
+
};
|
|
1202
|
+
})(),
|
|
801
1203
|
getTreeIdentity: (name) => {
|
|
802
|
-
const
|
|
1204
|
+
const own = ownLegionIdentity();
|
|
1205
|
+
const node = own && getChildByName(own.db, own.nodeId, name);
|
|
803
1206
|
return node ? {
|
|
804
1207
|
nodeId: node.id,
|
|
805
1208
|
parentId: node.parentId,
|
|
@@ -807,10 +1210,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
807
1210
|
supervisorName: name,
|
|
808
1211
|
} : undefined;
|
|
809
1212
|
},
|
|
1213
|
+
/**
|
|
1214
|
+
* subagent-renewal-v1 D3: the one renewal proposal the completion notice
|
|
1215
|
+
* may carry.
|
|
1216
|
+
*
|
|
1217
|
+
* Read from the persisted ChildRecord so a proposal survives process
|
|
1218
|
+
* boundaries (each tool call is a fresh process). Every "do not offer"
|
|
1219
|
+
* rule — D1 completed, D7 still running, D5 role opt-out, D8 no baseline —
|
|
1220
|
+
* lives inside `renewalProposalFor`, so returning its undefined is the
|
|
1221
|
+
* whole decision.
|
|
1222
|
+
*/
|
|
1223
|
+
getRenewalProposal: (name, execution) => {
|
|
1224
|
+
// Each tool call is a fresh process, so the child record comes off disk
|
|
1225
|
+
// rather than from any in-memory launch state.
|
|
1226
|
+
const cwd = lastCwd;
|
|
1227
|
+
if (!cwd) return undefined;
|
|
1228
|
+
const located = findChild(runStoreFor(cwd).listRuns(), name);
|
|
1229
|
+
if (!located) return undefined;
|
|
1230
|
+
const catalog = loadCatalog(cwd, cwd, undefined);
|
|
1231
|
+
const def = findControlAgent(catalog.agents, catalog.allAgents, located.child.agent);
|
|
1232
|
+
return renewalProposalFor({
|
|
1233
|
+
agent: def,
|
|
1234
|
+
child: located.child,
|
|
1235
|
+
sessionFile: located.child.sessionFile,
|
|
1236
|
+
execution,
|
|
1237
|
+
name,
|
|
1238
|
+
});
|
|
1239
|
+
},
|
|
810
1240
|
subscribeSupervisorEvents: (name, listener, options) => rpcEventSource.onEvent?.(
|
|
811
1241
|
name,
|
|
812
1242
|
(event) => {
|
|
813
|
-
const nodeId =
|
|
1243
|
+
const nodeId = ownLegionIdentity()?.childNodeId(name);
|
|
814
1244
|
if (nodeId) publishSupervisorEvent(legionBus, nodeId, event, options);
|
|
815
1245
|
listener(event);
|
|
816
1246
|
},
|
|
@@ -925,9 +1355,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
925
1355
|
stop: stopChildren,
|
|
926
1356
|
childStatus: (name, cwd) => {
|
|
927
1357
|
try {
|
|
928
|
-
const
|
|
1358
|
+
const store = runStoreFor(cwd);
|
|
1359
|
+
const runs = store.listRuns();
|
|
1360
|
+
const found = findChild(runs, name);
|
|
929
1361
|
if (!found) return undefined;
|
|
930
1362
|
const child = found.child;
|
|
1363
|
+
const alive = supervisor.isAlive(child.name);
|
|
1364
|
+
// Run-dir ownership verdict (#209-3), the same one `subagent status`
|
|
1365
|
+
// uses: `alive` is this process's in-memory registry and never holds a
|
|
1366
|
+
// child launched elsewhere, so a foreign record needs the owner marker.
|
|
1367
|
+
// Undefined while alive — liveness already wins there.
|
|
1368
|
+
const owner = ownerVerdictFor(store, runs, child.name, alive);
|
|
931
1369
|
return {
|
|
932
1370
|
name: child.name,
|
|
933
1371
|
state: child.state,
|
|
@@ -938,7 +1376,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
938
1376
|
...(child.acceptance
|
|
939
1377
|
? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
|
|
940
1378
|
: {}),
|
|
941
|
-
|
|
1379
|
+
// run.json records the arbitrated backend; a legacy record has none,
|
|
1380
|
+
// and `renderLiveChildStatus` renders that as `unknown` — never `rpc`.
|
|
1381
|
+
...(child.spawnMode ? { spawnMode: child.spawnMode } : {}),
|
|
1382
|
+
alive,
|
|
1383
|
+
...(owner ? { owner } : {}),
|
|
942
1384
|
};
|
|
943
1385
|
} catch {
|
|
944
1386
|
return undefined;
|
|
@@ -1070,12 +1512,12 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1070
1512
|
|
|
1071
1513
|
let activeLegion: LegionRuntimeIdentity | undefined;
|
|
1072
1514
|
try {
|
|
1073
|
-
activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached:
|
|
1515
|
+
activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: ownLegionIdentity(), sessionFile: ctx.sessionManager?.getSessionFile() });
|
|
1074
1516
|
if (!activeLegion && action === "launch" && !isChild) {
|
|
1075
1517
|
const plan = buildPlan(params);
|
|
1076
1518
|
if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
|
|
1077
1519
|
}
|
|
1078
|
-
if (activeLegion)
|
|
1520
|
+
if (activeLegion) adoptLegionIdentity(activeLegion);
|
|
1079
1521
|
} catch (error) {
|
|
1080
1522
|
return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
|
|
1081
1523
|
}
|
|
@@ -1096,6 +1538,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1096
1538
|
verifyRunner,
|
|
1097
1539
|
store,
|
|
1098
1540
|
cwd,
|
|
1541
|
+
// #270: the session cwd is a second store worth searching when the
|
|
1542
|
+
// call passes an explicit `cwd` that differs from it.
|
|
1543
|
+
...(ctx.cwd && ctx.cwd !== cwd ? { callerCwd: ctx.cwd } : {}),
|
|
1099
1544
|
agents,
|
|
1100
1545
|
allAgents,
|
|
1101
1546
|
team,
|
|
@@ -1113,6 +1558,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1113
1558
|
params,
|
|
1114
1559
|
signal,
|
|
1115
1560
|
ctx,
|
|
1561
|
+
pi,
|
|
1116
1562
|
supervisor,
|
|
1117
1563
|
uiProxy,
|
|
1118
1564
|
onBudgetRefused,
|
|
@@ -1139,7 +1585,16 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1139
1585
|
details !== null && typeof details === "object" && !Array.isArray(details)
|
|
1140
1586
|
? (details as Record<string, unknown>)
|
|
1141
1587
|
: undefined;
|
|
1142
|
-
|
|
1588
|
+
// #266 D7: the inner interrupted result also carries the launch-queue
|
|
1589
|
+
// flush line (`N queued launch(es) dropped: …`), which a bare head line
|
|
1590
|
+
// would silently erase — the caller would think its launches still ran.
|
|
1591
|
+
// An inner result that already reports the interruption is therefore
|
|
1592
|
+
// kept as-is; only an otherwise-successful result is replaced.
|
|
1593
|
+
const inner = (result.content ?? [])
|
|
1594
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
1595
|
+
.join("\n");
|
|
1596
|
+
if (inner.includes(INTERRUPTED_HEAD)) return ok(inner, carried);
|
|
1597
|
+
return ok(INTERRUPTED_HEAD, carried);
|
|
1143
1598
|
}
|
|
1144
1599
|
return result;
|
|
1145
1600
|
},
|
|
@@ -1156,7 +1611,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1156
1611
|
};
|
|
1157
1612
|
|
|
1158
1613
|
uiProxy.onRequest((pending) => {
|
|
1159
|
-
const legionNodeId =
|
|
1614
|
+
const legionNodeId = ownLegionIdentity()?.childNodeId(pending.childName);
|
|
1160
1615
|
if (legionNodeId) publishUIRequest(legionBus, legionNodeId, pending.request.method);
|
|
1161
1616
|
const ui = lastUi;
|
|
1162
1617
|
if (!ui) return;
|
|
@@ -1317,6 +1772,11 @@ async function controlAction(input: {
|
|
|
1317
1772
|
verifyRunner?: VerifyRunner;
|
|
1318
1773
|
store: RunStore;
|
|
1319
1774
|
cwd: string;
|
|
1775
|
+
/**
|
|
1776
|
+
* #270: the session's own cwd, when the call also passes an explicit `cwd`.
|
|
1777
|
+
* Child records may live in either store, so both are searched on a miss.
|
|
1778
|
+
*/
|
|
1779
|
+
callerCwd?: string;
|
|
1320
1780
|
agents: AgentConfig[];
|
|
1321
1781
|
allAgents: AgentConfig[];
|
|
1322
1782
|
team: ActiveTeam;
|
|
@@ -1367,7 +1827,7 @@ async function controlAction(input: {
|
|
|
1367
1827
|
// all names but before the serial loop reaches it. Treat that target as
|
|
1368
1828
|
// stopped; only rebuild if its persisted record is not already retired.
|
|
1369
1829
|
const found = findChild(input.store.listRuns(), name);
|
|
1370
|
-
if (!found) throw
|
|
1830
|
+
if (!found) throw unknownChildError(name);
|
|
1371
1831
|
if (found.child.state === "retired") return;
|
|
1372
1832
|
const orchestrator = new Orchestrator({
|
|
1373
1833
|
supervisor: input.supervisor,
|
|
@@ -1398,20 +1858,42 @@ async function controlAction(input: {
|
|
|
1398
1858
|
}
|
|
1399
1859
|
if (!params.name) {
|
|
1400
1860
|
const runs = store.listRuns();
|
|
1401
|
-
|
|
1861
|
+
// Liveness facts are read once per name and reused by both the filter and
|
|
1862
|
+
// the block, so `status` cannot classify a child one way and render it
|
|
1863
|
+
// another (display-liveness D6 exit 1).
|
|
1864
|
+
const factsByName = new Map<string, StatusLivenessFacts & { pid?: number }>();
|
|
1865
|
+
const factsFor = (child: StatusChildRecord): StatusLivenessFacts & { pid?: number } => {
|
|
1866
|
+
const cached = factsByName.get(child.name);
|
|
1867
|
+
if (cached) return cached;
|
|
1868
|
+
const alive = input.supervisor.isAlive(child.name);
|
|
1869
|
+
const owner = ownerVerdictFor(store, runs, child.name, alive);
|
|
1870
|
+
// A pid only exists for a child this process actually tracks; a
|
|
1871
|
+
// record from another session has none, and none is invented.
|
|
1872
|
+
const pid = input.runtime.get(child.name)?.pid;
|
|
1873
|
+
const facts = { alive, ...(owner ? { owner } : {}), ...(pid ? { pid } : {}) };
|
|
1874
|
+
factsByName.set(child.name, facts);
|
|
1875
|
+
return facts;
|
|
1876
|
+
};
|
|
1877
|
+
const classified = classifyChildrenForStatus(runs, factsFor);
|
|
1402
1878
|
return ok(
|
|
1403
1879
|
renderStatusOverview(
|
|
1404
|
-
|
|
1405
|
-
const
|
|
1406
|
-
|
|
1407
|
-
return { child, alive, ...(owner ? { owner } : {}) };
|
|
1880
|
+
classified.map(({ child, live }) => {
|
|
1881
|
+
const { pid, ...facts } = factsFor(child);
|
|
1882
|
+
return { child, ...facts, ...(pid ? { pid } : {}), live };
|
|
1408
1883
|
}),
|
|
1409
1884
|
),
|
|
1410
1885
|
);
|
|
1411
1886
|
}
|
|
1412
1887
|
const runs = store.listRuns();
|
|
1413
|
-
|
|
1414
|
-
|
|
1888
|
+
// #270: a miss in this store is not a miss overall — a worktree child's
|
|
1889
|
+
// record lives under its own store, which this one cannot enumerate.
|
|
1890
|
+
const found = findChild(runs, params.name) ?? findChildAcrossStores({
|
|
1891
|
+
name: params.name,
|
|
1892
|
+
cwd,
|
|
1893
|
+
callerCwd: input.callerCwd,
|
|
1894
|
+
ownRoot: store.rootDir,
|
|
1895
|
+
});
|
|
1896
|
+
if (!found) return unknownChild(params.name, store, cwd, input.callerCwd);
|
|
1415
1897
|
const alive = input.supervisor.isAlive(params.name);
|
|
1416
1898
|
const owner = ownerVerdictFor(store, runs, params.name, alive);
|
|
1417
1899
|
return ok(renderLiveChildStatus({
|
|
@@ -1435,8 +1917,15 @@ async function controlAction(input: {
|
|
|
1435
1917
|
);
|
|
1436
1918
|
}
|
|
1437
1919
|
|
|
1438
|
-
const found =
|
|
1439
|
-
|
|
1920
|
+
const found =
|
|
1921
|
+
findChild(store.listRuns(), params.name) ??
|
|
1922
|
+
findChildAcrossStores({
|
|
1923
|
+
name: params.name,
|
|
1924
|
+
cwd,
|
|
1925
|
+
callerCwd: input.callerCwd,
|
|
1926
|
+
ownRoot: store.rootDir,
|
|
1927
|
+
});
|
|
1928
|
+
if (!found) return unknownChild(params.name, store, cwd, input.callerCwd);
|
|
1440
1929
|
|
|
1441
1930
|
const orchestrator = new Orchestrator({
|
|
1442
1931
|
supervisor: input.supervisor,
|
|
@@ -1499,13 +1988,29 @@ function unknownChild(
|
|
|
1499
1988
|
name: string,
|
|
1500
1989
|
store: RunStore,
|
|
1501
1990
|
cwd: string,
|
|
1991
|
+
callerCwd?: string,
|
|
1502
1992
|
): AgentToolResult<unknown> {
|
|
1503
|
-
|
|
1993
|
+
// #270: search the stores that can plausibly hold the record before giving up.
|
|
1994
|
+
// A worktree child's store lives under `<runDir>/worktrees/<name>/.pi-subagents`,
|
|
1995
|
+
// which the root store can never enumerate, so a single-store lookup reports a
|
|
1996
|
+
// miss for a child that plainly exists.
|
|
1997
|
+
// The store already searched comes first, then every other candidate
|
|
1998
|
+
// (caller cwd, worktree stores). `resolveChildAcrossStores` re-searches the
|
|
1999
|
+
// own store too — that is deliberate: it returns the `searched` list the
|
|
2000
|
+
// message needs, and a store with no runs simply drops out of it.
|
|
2001
|
+
const { searched } = resolveChildAcrossStores({
|
|
2002
|
+
name,
|
|
2003
|
+
cwd,
|
|
2004
|
+
callerCwd,
|
|
2005
|
+
roots: [
|
|
2006
|
+
{ rootDir: path.resolve(store.rootDir), reason: "cwd" as const },
|
|
2007
|
+
...candidateStoreRoots({ cwd, callerCwd }).filter(
|
|
2008
|
+
(candidate) => candidate.rootDir !== path.resolve(store.rootDir),
|
|
2009
|
+
),
|
|
2010
|
+
],
|
|
2011
|
+
});
|
|
1504
2012
|
return fail(
|
|
1505
|
-
|
|
1506
|
-
`${known.length ? `; known here: ${known.join(", ")}` : ""}). ` +
|
|
1507
|
-
`Child records are scoped to the \`cwd\` they were launched from — ` +
|
|
1508
|
-
`pass the same \`cwd\` you used for \`launch\`.`,
|
|
2013
|
+
unknownChildMessage({ name, cwd, searched: searched as CandidateStore[] }),
|
|
1509
2014
|
ErrorCodes.NOT_FOUND,
|
|
1510
2015
|
);
|
|
1511
2016
|
}
|
|
@@ -1596,6 +2101,171 @@ async function retireChild(
|
|
|
1596
2101
|
}
|
|
1597
2102
|
}
|
|
1598
2103
|
|
|
2104
|
+
/**
|
|
2105
|
+
* D8: a fresh progress baseline, taken from the session file as it is NOW.
|
|
2106
|
+
*
|
|
2107
|
+
* A child that has just been spawned (or relaunched from its session) may have
|
|
2108
|
+
* an empty or not-yet-created session file; `lines: 0` is then the honest
|
|
2109
|
+
* answer and everything written afterwards counts as progress. Never throws —
|
|
2110
|
+
* an unreadable file is "nothing written yet", not a launch failure.
|
|
2111
|
+
*/
|
|
2112
|
+
export function renewalBaselineFor(
|
|
2113
|
+
sessionFile: string,
|
|
2114
|
+
at: number = Date.now(),
|
|
2115
|
+
readLines: (file: string) => string[] | undefined = readSessionLines,
|
|
2116
|
+
): { at: number; lines: number } {
|
|
2117
|
+
return { at, lines: readLines(sessionFile)?.length ?? 0 };
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* D5-a: was this child **cut off by its turn budget** rather than finishing?
|
|
2122
|
+
*
|
|
2123
|
+
* `continue`/`resume` works on every terminal child, but only a budget casualty
|
|
2124
|
+
* should spend a renewal (D6) — otherwise an ordinary retry after a normal
|
|
2125
|
+
* failure quietly eats the budget, and the budget stops meaning anything.
|
|
2126
|
+
*
|
|
2127
|
+
* Judgement order is frozen by D5-a and follows `src/shared/session.ts`'s
|
|
2128
|
+
* measured semantics (F29), most reliable first:
|
|
2129
|
+
*
|
|
2130
|
+
* 1. `stopReason === "toolUse"` — the final assistant row was still waiting on
|
|
2131
|
+
* a tool when the process went away: the classic budget-exhaustion shape.
|
|
2132
|
+
* 2. No assistant message for the last prompt — F29: a hard kill writes no
|
|
2133
|
+
* assistant row at all, so "missing reply" IS the abort signal.
|
|
2134
|
+
* 3. `reason` mentions `terminated` — the host's own label, the weakest of the
|
|
2135
|
+
* three because wording is not a contract.
|
|
2136
|
+
*
|
|
2137
|
+
* A clean `stop` / `length` turn (the user simply asked for another round)
|
|
2138
|
+
* matches none of them and does **not** spend a renewal.
|
|
2139
|
+
*
|
|
2140
|
+
* `shouldRecycleAfterCollect` is deliberately NOT used: it only separates
|
|
2141
|
+
* terminal from non-terminal, which is a different question.
|
|
2142
|
+
*/
|
|
2143
|
+
export function wasBudgetCut(execution: Execution | undefined): boolean {
|
|
2144
|
+
if (!execution) return false;
|
|
2145
|
+
// 1. Terminated while a tool call was outstanding.
|
|
2146
|
+
if (execution.stopReason === "toolUse") return true;
|
|
2147
|
+
// 2. F29 hard kill: the turn has no assistant row at all.
|
|
2148
|
+
if (
|
|
2149
|
+
execution.reason === NO_ASSISTANT_ABORT_REASON &&
|
|
2150
|
+
execution.stopReason !== "stop" &&
|
|
2151
|
+
execution.stopReason !== "length"
|
|
2152
|
+
) {
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
// 3. Host wording, last resort.
|
|
2156
|
+
return execution.reason?.toLowerCase().includes("terminated") ?? false;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* D3: the renewal proposal to attach to a **real termination** notice, or
|
|
2161
|
+
* `undefined` when the contract says not to offer one.
|
|
2162
|
+
*
|
|
2163
|
+
* Extracted as a pure function (not inlined at the notify call site) because
|
|
2164
|
+
* the whole decision is testable without launching a child: "was the budget
|
|
2165
|
+
* cut + is the role renewable + has it a baseline" is arithmetic, not I/O.
|
|
2166
|
+
*
|
|
2167
|
+
* The four exits, all locked by tests:
|
|
2168
|
+
* - **D1** a `completed` child is never a renewal candidate: it produced a
|
|
2169
|
+
* verdict, so offering `continue` would relaunch finished work.
|
|
2170
|
+
* - **D7/A3** a `running` snapshot is a `wait`/`collect` deadline on a **live**
|
|
2171
|
+
* child (`inprocess-supervisor` merely resolved `{settled:false}`); the child
|
|
2172
|
+
* never lost a budget, so renewing it would spend a fresh one for nothing.
|
|
2173
|
+
* - **D5/A4** `renewable: false` → no proposal at all, so the notice keeps the
|
|
2174
|
+
* exact pre-W3 bytes (no renewal lines).
|
|
2175
|
+
* - **A2** no measurable progress is still **reported**: the caller passes the
|
|
2176
|
+
* measurement through and `notify.ts` prints "no measurable progress". The
|
|
2177
|
+
* proposal is withheld only for the three reasons above, never for "we could
|
|
2178
|
+
* not tell".
|
|
2179
|
+
*
|
|
2180
|
+
* **A5/D6**: `used >= max` still gets a proposal — the renderer turns it into
|
|
2181
|
+
* `renewals exhausted (used/max) — split the task instead of renewing`. That is
|
|
2182
|
+
* a deliberate hand-off to the human, not silence.
|
|
2183
|
+
*
|
|
2184
|
+
* `maxRenewals: 0` (D6) is "renewal disabled": `renewable` is the role-level
|
|
2185
|
+
* switch, `0` the budget-level one, and both must agree before an offer is made.
|
|
2186
|
+
*/
|
|
2187
|
+
export function renewalProposalFor(input: {
|
|
2188
|
+
agent: Pick<AgentConfig, "renewable" | "maxRenewals"> | undefined;
|
|
2189
|
+
child: Pick<ChildRecord, "renewalBaseline" | "renewalsUsed">;
|
|
2190
|
+
/** Session file the progress measurement reads; the resume credential (D8). */
|
|
2191
|
+
sessionFile: string;
|
|
2192
|
+
execution: { status: string; reason?: string };
|
|
2193
|
+
name: string;
|
|
2194
|
+
/** Injectable for tests; defaults to W1's measurer over the real session file. */
|
|
2195
|
+
measure?: (sessionFile: string, baseline: { at: number; lines: number }) => {
|
|
2196
|
+
toolResults: number;
|
|
2197
|
+
assistantMessages: number;
|
|
2198
|
+
filesChanged?: number;
|
|
2199
|
+
};
|
|
2200
|
+
}): RenewalProposal | undefined {
|
|
2201
|
+
const status = completionStatusOf(
|
|
2202
|
+
input.execution.status,
|
|
2203
|
+
// Acceptance is not part of this decision: D1 keys on the completion
|
|
2204
|
+
// status alone, and `unknown` + a parsed verdict is handled by the
|
|
2205
|
+
// caller's own status, not re-derived here.
|
|
2206
|
+
undefined,
|
|
2207
|
+
);
|
|
2208
|
+
// D1: a finished child is not a renewal candidate.
|
|
2209
|
+
if (status === "completed") return undefined;
|
|
2210
|
+
// D7: `running` is a live child behind a `wait`/`collect` deadline.
|
|
2211
|
+
if (status === "running") return undefined;
|
|
2212
|
+
// D5: the role forbids renewal (e.g. scout: a timed-out search was mis-scoped).
|
|
2213
|
+
if ((input.agent?.renewable ?? DEFAULT_RENEWABLE) === false) return undefined;
|
|
2214
|
+
const max = input.agent?.maxRenewals ?? DEFAULT_MAX_RENEWALS;
|
|
2215
|
+
// D6: `0` disables renewal outright; offering `0/0` would be a lie.
|
|
2216
|
+
if (max <= 0) return undefined;
|
|
2217
|
+
// D8: no baseline means nothing to measure against (#266-era records).
|
|
2218
|
+
const baseline = input.child.renewalBaseline;
|
|
2219
|
+
if (!baseline) return undefined;
|
|
2220
|
+
|
|
2221
|
+
const used = input.child.renewalsUsed ?? 0;
|
|
2222
|
+
// D2 puts the worktree probe last, so the measurement is session-file only.
|
|
2223
|
+
const measured = (input.measure ?? defaultRenewalMeasure)(
|
|
2224
|
+
input.sessionFile,
|
|
2225
|
+
baseline,
|
|
2226
|
+
);
|
|
2227
|
+
return {
|
|
2228
|
+
progress: {
|
|
2229
|
+
toolResults: measured.toolResults,
|
|
2230
|
+
assistantMessages: measured.assistantMessages,
|
|
2231
|
+
...(measured.filesChanged !== undefined
|
|
2232
|
+
? { filesChanged: measured.filesChanged }
|
|
2233
|
+
: {}),
|
|
2234
|
+
},
|
|
2235
|
+
used,
|
|
2236
|
+
max,
|
|
2237
|
+
// D4/A8: renewal IS `continue`; the parent gets a finished command.
|
|
2238
|
+
command: `subagent({ action: "continue", name: "${input.name}", message: "<what to continue>" })`,
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/** Session-file half of {@link renewalProposalFor}; no worktree probe (D2 puts it last). */
|
|
2243
|
+
function defaultRenewalMeasure(
|
|
2244
|
+
sessionFile: string,
|
|
2245
|
+
baseline: { at: number; lines: number },
|
|
2246
|
+
): { toolResults: number; assistantMessages: number; filesChanged?: number } {
|
|
2247
|
+
return measureProgress({ sessionFile, baseline });
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* D6/D8: charge one renewal to a child, D5-a's way.
|
|
2252
|
+
*
|
|
2253
|
+
* Split out from `reviveChild` so the arithmetic is testable in isolation: the
|
|
2254
|
+
* caller has already decided (via {@link wasBudgetCut}) whether this continue
|
|
2255
|
+
* was a budget casualty, and only then does the counter move.
|
|
2256
|
+
*
|
|
2257
|
+
* Absent `renewalsUsed` means "never renewed" (#266-era record), so it reads as
|
|
2258
|
+
* 0 rather than throwing. The baseline is the caller's business: it must come
|
|
2259
|
+
* from the **relaunched** session file, which only that layer holds.
|
|
2260
|
+
*/
|
|
2261
|
+
export function chargeRenewal(
|
|
2262
|
+
child: Pick<ChildRecord, "renewalsUsed">,
|
|
2263
|
+
): number {
|
|
2264
|
+
const used = (child.renewalsUsed ?? 0) + 1;
|
|
2265
|
+
child.renewalsUsed = used;
|
|
2266
|
+
return used;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
1599
2269
|
/**
|
|
1600
2270
|
* Continue or resume a child.
|
|
1601
2271
|
*
|
|
@@ -1641,6 +2311,32 @@ async function reviveChild(
|
|
|
1641
2311
|
name,
|
|
1642
2312
|
...(found.child.model ? { model: found.child.model } : {}),
|
|
1643
2313
|
});
|
|
2314
|
+
// D6/D8/D5-a: spend one renewal, but only if this child was actually cut
|
|
2315
|
+
// off by its turn budget.
|
|
2316
|
+
//
|
|
2317
|
+
// `continue` is legal on every terminal child (D4), yet an ordinary retry
|
|
2318
|
+
// after a normal `stop` must not eat the budget — otherwise two harmless
|
|
2319
|
+
// retries would silently exhaust the allowance that exists for genuine
|
|
2320
|
+
// budget casualties. `wasBudgetCut` answers that from the session shape
|
|
2321
|
+
// (F29/D5-a), not from `shouldRecycleAfterCollect` (terminal vs not, a
|
|
2322
|
+
// different question).
|
|
2323
|
+
//
|
|
2324
|
+
// The baseline is reset in both branches. A non-budget continue still
|
|
2325
|
+
// moved the child's own "since" line forward: keeping the old baseline
|
|
2326
|
+
// would count this whole extra turn as progress at the next termination
|
|
2327
|
+
// and overstate how much the child achieved under the current budget.
|
|
2328
|
+
const resumedChild =
|
|
2329
|
+
orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name) ??
|
|
2330
|
+
found.child;
|
|
2331
|
+
// Read the verdict from `found.child`, NOT the orchestrator's record: `launch`
|
|
2332
|
+
// clears `previous.execution` before returning (it is a relaunch), and
|
|
2333
|
+
// `found` is a freshly parsed copy from `store.listRuns()`, so the pre-resume
|
|
2334
|
+
// outcome is still intact there. Reading the orchestrator's object here
|
|
2335
|
+
// would see `undefined` on every single resume and never charge anything.
|
|
2336
|
+
const budgetCut = wasBudgetCut(found.child.execution);
|
|
2337
|
+
resumedChild.renewalBaseline = renewalBaselineFor(handle.sessionFile);
|
|
2338
|
+
if (budgetCut) chargeRenewal(resumedChild);
|
|
2339
|
+
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, handle.name);
|
|
1644
2340
|
followChild(ctx, { watch: true });
|
|
1645
2341
|
return ok(
|
|
1646
2342
|
`Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
|
|
@@ -1658,6 +2354,7 @@ async function reviveChild(
|
|
|
1658
2354
|
async function launchFamily(input: {
|
|
1659
2355
|
params: SubagentParams;
|
|
1660
2356
|
ctx: ExtensionContext;
|
|
2357
|
+
pi: ExtensionAPI;
|
|
1661
2358
|
supervisor: LegionSupervisor;
|
|
1662
2359
|
uiProxy: UIProxy;
|
|
1663
2360
|
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
@@ -1735,6 +2432,10 @@ async function launchFamily(input: {
|
|
|
1735
2432
|
worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1736
2433
|
...teamForChildren(input.team),
|
|
1737
2434
|
...rpcApproveFrom(settings),
|
|
2435
|
+
// #266 D6-a/D10: queue bounds + the D9 notice channel. The command chain is
|
|
2436
|
+
// the bug's main path (a 12-step `tasks[]` fanout against `maxChildrenPerNode`).
|
|
2437
|
+
launchQueue: launchQueueBounds(settings),
|
|
2438
|
+
onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
|
|
1738
2439
|
...(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
2440
|
onBudgetRefused: input.onBudgetRefused,
|
|
1740
2441
|
});
|
|
@@ -1764,6 +2465,7 @@ async function launchFamily(input: {
|
|
|
1764
2465
|
// abort (interrupted results run after that), so it is kept per name
|
|
1765
2466
|
// here — same precedent as `timeoutByName`.
|
|
1766
2467
|
spawnByName: new Map(),
|
|
2468
|
+
pidByName: new Map(),
|
|
1767
2469
|
...(nameGate.name ? { launchName: nameGate.name } : {}),
|
|
1768
2470
|
};
|
|
1769
2471
|
|
|
@@ -1775,10 +2477,28 @@ async function launchFamily(input: {
|
|
|
1775
2477
|
|
|
1776
2478
|
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
1777
2479
|
if (!input.signal?.aborted) return undefined;
|
|
2480
|
+
// #266 D7/A5: a parent turn end empties the launch queue and spawns nothing.
|
|
2481
|
+
// Deliberately only HERE (and in the vexillatio interrupt): Esc/wait
|
|
2482
|
+
// interruptions end the parent's *wait*, not its turn, and must leave
|
|
2483
|
+
// children — queued ones included — running (`esc-no-retire-v3`).
|
|
2484
|
+
const flushed = orchestrator.flushLaunchQueue("parent turn ended");
|
|
1778
2485
|
for (const name of session.handles) await abandonLaunchedChild(session, name);
|
|
1779
2486
|
session.handles.length = 0;
|
|
2487
|
+
const flushLine = launchQueueFlushLine(flushed);
|
|
1780
2488
|
return {
|
|
1781
|
-
content: [{
|
|
2489
|
+
content: [{
|
|
2490
|
+
type: "text",
|
|
2491
|
+
text: [
|
|
2492
|
+
// The head line LEADS (same frozen shape as the vexillatio interrupt):
|
|
2493
|
+
// the outer abort net keeps an inner result that already reports the
|
|
2494
|
+
// interruption, and only a leading head makes that detection reliable
|
|
2495
|
+
// — otherwise each `parent turn ended; child still running (watched)`
|
|
2496
|
+
// line reads like a standalone notice and the flush line is lost.
|
|
2497
|
+
INTERRUPTED_HEAD,
|
|
2498
|
+
session.results.join("\n"),
|
|
2499
|
+
flushLine,
|
|
2500
|
+
].filter(Boolean).join("\n"),
|
|
2501
|
+
}],
|
|
1782
2502
|
details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
|
|
1783
2503
|
};
|
|
1784
2504
|
};
|
|
@@ -1858,6 +2578,13 @@ interface LaunchSession {
|
|
|
1858
2578
|
timeoutByName: Map<string, number>;
|
|
1859
2579
|
/** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
|
|
1860
2580
|
spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
|
|
2581
|
+
/**
|
|
2582
|
+
* Own process of an `rpc` child, keyed by name — same reason `timeoutByName`
|
|
2583
|
+
* exists: `Handle.pid` is not persisted, so a re-follow from `ChildRecord`
|
|
2584
|
+
* cannot recover it. Kept out of `spawnByName` so `details.spawns` (a
|
|
2585
|
+
* renderer input) keeps its published shape.
|
|
2586
|
+
*/
|
|
2587
|
+
pidByName: Map<string, number>;
|
|
1861
2588
|
/** Explicit child name for this single-step launch; omit to auto-allocate. */
|
|
1862
2589
|
launchName?: string;
|
|
1863
2590
|
}
|
|
@@ -1952,9 +2679,8 @@ async function launchStep(
|
|
|
1952
2679
|
// `--no-extensions` the child would resolve it there and die with
|
|
1953
2680
|
// "No API key found". The parent registry knows the live provider.
|
|
1954
2681
|
...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
|
|
1955
|
-
// The origin
|
|
1956
|
-
//
|
|
1957
|
-
// arrives as the same `dispatch` source as a per-run override).
|
|
2682
|
+
// The origin distinguishes a deliberate per-run model from the parent's
|
|
2683
|
+
// model falling through; the launch line reports which one won.
|
|
1958
2684
|
modelOrigin: model.modelOrigin,
|
|
1959
2685
|
worktree: resolveLaunchWorktree({
|
|
1960
2686
|
roleDefault: effective.worktree,
|
|
@@ -1976,6 +2702,7 @@ async function launchStep(
|
|
|
1976
2702
|
agent: effective.name,
|
|
1977
2703
|
spawnMode: handle.spawnMode,
|
|
1978
2704
|
});
|
|
2705
|
+
if (handle.pid) session.pidByName.set(handle.name, handle.pid);
|
|
1979
2706
|
if (effective.timeoutMs !== undefined) {
|
|
1980
2707
|
session.timeoutByName.set(handle.name, effective.timeoutMs);
|
|
1981
2708
|
}
|
|
@@ -1984,18 +2711,8 @@ async function launchStep(
|
|
|
1984
2711
|
return;
|
|
1985
2712
|
}
|
|
1986
2713
|
session.results.push(
|
|
1987
|
-
`▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
|
|
2714
|
+
`▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}${launchLineQueueSuffix(handle)}`,
|
|
1988
2715
|
);
|
|
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
2716
|
session.onUpdate?.({
|
|
2000
2717
|
content: [
|
|
2001
2718
|
{
|
|
@@ -2094,23 +2811,100 @@ function resolveStep(
|
|
|
2094
2811
|
};
|
|
2095
2812
|
}
|
|
2096
2813
|
|
|
2814
|
+
/**
|
|
2815
|
+
* Collect one child, tolerating the transient gap an admission creates.
|
|
2816
|
+
*
|
|
2817
|
+
* `pumpAdmissions` deletes the `queued` placeholder before replaying the real
|
|
2818
|
+
* launch, so between those two steps the name exists in neither the placeholder
|
|
2819
|
+
* map nor the freshly spawned record. `collect()` resolves names through
|
|
2820
|
+
* `requireChild`, so a collect that lands in that window throws
|
|
2821
|
+
* `unknown child: <name>` — a race, not a real failure, and one only a queued
|
|
2822
|
+
* child can hit. The window is a microtask-or-two long (the replay is awaited),
|
|
2823
|
+
* so a short bounded retry is enough; anything genuinely gone still surfaces.
|
|
2824
|
+
*
|
|
2825
|
+
* Only used by the sequential `async:false` path. `async:true` never collects
|
|
2826
|
+
* here — its watcher goes through the same `collect` but is re-armed, so a retry
|
|
2827
|
+
* loop there would fight the watcher instead of helping it.
|
|
2828
|
+
*/
|
|
2829
|
+
async function collectThroughAdmission(
|
|
2830
|
+
session: LaunchSession,
|
|
2831
|
+
name: string,
|
|
2832
|
+
signal?: AbortSignal,
|
|
2833
|
+
): Promise<Awaited<ReturnType<Orchestrator["collect"]>>> {
|
|
2834
|
+
const deadline = Date.now() + ADMISSION_SETTLE_TIMEOUT_MS;
|
|
2835
|
+
for (;;) {
|
|
2836
|
+
try {
|
|
2837
|
+
return await session.orchestrator.collect(name, {
|
|
2838
|
+
timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
|
|
2839
|
+
...(signal ? { signal } : {}),
|
|
2840
|
+
});
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
const transient = error instanceof SubagentError && error.code === ErrorCodes.NOT_FOUND;
|
|
2843
|
+
if (!transient || Date.now() >= deadline || signal?.aborted) throw error;
|
|
2844
|
+
await new Promise((resolve) => setTimeout(resolve, ADMISSION_SETTLE_POLL_MS));
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* How long a collect may retry across the admission gap before reporting it.
|
|
2851
|
+
*
|
|
2852
|
+
* Deliberately much shorter than a turn timeout: the gap is two `await`s wide,
|
|
2853
|
+
* so anything still missing after this long is not racing — it is gone.
|
|
2854
|
+
*/
|
|
2855
|
+
const ADMISSION_SETTLE_TIMEOUT_MS = 5_000;
|
|
2856
|
+
const ADMISSION_SETTLE_POLL_MS = 25;
|
|
2857
|
+
|
|
2097
2858
|
/** Wait for each launched child's turn, appending its outcome. */
|
|
2098
2859
|
async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
|
|
2099
|
-
|
|
2860
|
+
// #266 D8: collect children that already hold a slot FIRST.
|
|
2861
|
+
//
|
|
2862
|
+
// A queued handle resolves synchronously (`launch()` returns it before any
|
|
2863
|
+
// spawn), so it lands in `handles` BEFORE the admitted siblings. Collecting in
|
|
2864
|
+
// raw launch order would therefore block on the queued child first — waiting
|
|
2865
|
+
// for a slot that only those very siblings can release once THEY are
|
|
2866
|
+
// collected — i.e. a deadlock, not a wait. Launch order is preserved within
|
|
2867
|
+
// each group, so a run with no queue keeps its exact previous order.
|
|
2868
|
+
const queuedHere = new Set(
|
|
2869
|
+
session.orchestrator
|
|
2870
|
+
.childrenSnapshot()
|
|
2871
|
+
.filter((child) => child.state === "queued")
|
|
2872
|
+
.map((child) => child.name),
|
|
2873
|
+
);
|
|
2874
|
+
const ordered = [
|
|
2875
|
+
...session.handles.filter((name) => !queuedHere.has(name)),
|
|
2876
|
+
...session.handles.filter((name) => queuedHere.has(name)),
|
|
2877
|
+
];
|
|
2878
|
+
for (const name of ordered) {
|
|
2100
2879
|
if (signal?.aborted) {
|
|
2101
2880
|
session.results.push("interrupted (parent turn ended; children still running)");
|
|
2102
2881
|
return;
|
|
2103
2882
|
}
|
|
2104
2883
|
try {
|
|
2105
|
-
const collected = await session
|
|
2106
|
-
timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
|
|
2107
|
-
...(signal ? { signal } : {}),
|
|
2108
|
-
});
|
|
2884
|
+
const collected = await collectThroughAdmission(session, name, signal);
|
|
2109
2885
|
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
2110
2886
|
session.results.push("interrupted (parent turn ended; children still running)");
|
|
2111
2887
|
return;
|
|
2112
2888
|
}
|
|
2113
2889
|
session.results.push(renderCollect(name, collected));
|
|
2890
|
+
// #266 D8/A20: `async:false` must report the real wait, not just look slow.
|
|
2891
|
+
// Read AFTER collect: `queueWaitedMs` is written at admission, so an item
|
|
2892
|
+
// that is still waiting when the collect deadline fires has no line yet.
|
|
2893
|
+
const waitedLine = queuedWaitLine(
|
|
2894
|
+
session.orchestrator.childrenSnapshot().find((child) => child.name === name),
|
|
2895
|
+
);
|
|
2896
|
+
if (waitedLine) session.results.push(waitedLine);
|
|
2897
|
+
// A finished child must release its slot BEFORE the next collect starts,
|
|
2898
|
+
// or a queue can never drain: `collectInline` is sequential, and the
|
|
2899
|
+
// caller's retire loop runs after every collect (so with `maxChildrenPerNode:
|
|
2900
|
+
// 1` the second child would wait for a slot the first still holds).
|
|
2901
|
+
// Gated on a non-empty queue so the un-queued fast path is unchanged.
|
|
2902
|
+
if (
|
|
2903
|
+
session.orchestrator.launchQueueEntries().length > 0 &&
|
|
2904
|
+
shouldRecycleAfterCollect(collected.execution.status)
|
|
2905
|
+
) {
|
|
2906
|
+
await session.orchestrator.retire(name);
|
|
2907
|
+
}
|
|
2114
2908
|
} catch (error) {
|
|
2115
2909
|
session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
|
|
2116
2910
|
}
|
|
@@ -2143,10 +2937,16 @@ type UtilityPlan =
|
|
|
2143
2937
|
| { ok: false; message: string };
|
|
2144
2938
|
|
|
2145
2939
|
function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
|
|
2146
|
-
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
2940
|
+
const singleTask = params.task ?? "";
|
|
2941
|
+
const parallel = params.tasks?.length ? params.tasks : undefined;
|
|
2942
|
+
// `tasks[]` wins and `task` degrades to a parent-run label, so both shapes
|
|
2943
|
+
// accepted by the schema reach a launch: `{ task }`, `{ tasks }`, `{ task, tasks }`.
|
|
2944
|
+
if (!parallel && !singleTask.trim()) {
|
|
2945
|
+
return { ok: false, message: "Provide one of: `task` (single recon) or `tasks[]` (parallel recon)." };
|
|
2946
|
+
}
|
|
2947
|
+
const steps: UtilityTaskItem[] = parallel
|
|
2948
|
+
? parallel
|
|
2949
|
+
: [{ agent: params.agent?.trim() || "scout", task: singleTask }];
|
|
2150
2950
|
const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
|
|
2151
2951
|
if (unsupportedVerify >= 0) {
|
|
2152
2952
|
return {
|
|
@@ -2323,6 +3123,10 @@ async function launchUtilityFamily(input: {
|
|
|
2323
3123
|
childContext: resolveTeamContext("child", catalog.settings, catalog.team),
|
|
2324
3124
|
team: catalog.team,
|
|
2325
3125
|
...rpcApproveFrom(catalog.settings),
|
|
3126
|
+
// #266: the parallel-recon path shares the same bounds and notice channel
|
|
3127
|
+
// as the command chain — one scheduler for every dispatch family (D2).
|
|
3128
|
+
launchQueue: launchQueueBounds(catalog.settings),
|
|
3129
|
+
onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
|
|
2326
3130
|
onBudgetRefused: input.onBudgetRefused,
|
|
2327
3131
|
...(legion ? {
|
|
2328
3132
|
legion: {
|
|
@@ -2381,6 +3185,7 @@ async function launchUtilityFamily(input: {
|
|
|
2381
3185
|
agent: item.agent.name,
|
|
2382
3186
|
sessionFile: handle.sessionFile,
|
|
2383
3187
|
spawnMode: handle.spawnMode,
|
|
3188
|
+
...(handle.pid ? { pid: handle.pid } : {}),
|
|
2384
3189
|
orchestrator,
|
|
2385
3190
|
retire,
|
|
2386
3191
|
},
|
|
@@ -2397,6 +3202,7 @@ async function launchUtilityFamily(input: {
|
|
|
2397
3202
|
agent: string;
|
|
2398
3203
|
sessionFile: string;
|
|
2399
3204
|
spawnMode: SpawnMode;
|
|
3205
|
+
pid?: number;
|
|
2400
3206
|
orchestrator: Orchestrator;
|
|
2401
3207
|
retire: (opts?: RetireOptions) => Promise<void>;
|
|
2402
3208
|
}> = [];
|
|
@@ -2414,6 +3220,7 @@ async function launchUtilityFamily(input: {
|
|
|
2414
3220
|
timeoutMs,
|
|
2415
3221
|
// Arbitrated at launch; never re-derived here (#209).
|
|
2416
3222
|
spawnMode: handle.spawnMode,
|
|
3223
|
+
...(handle.pid ? { pid: handle.pid } : {}),
|
|
2417
3224
|
orchestrator: handle.orchestrator,
|
|
2418
3225
|
store,
|
|
2419
3226
|
watch,
|
|
@@ -2445,6 +3252,17 @@ async function launchUtilityFamily(input: {
|
|
|
2445
3252
|
lines.push(`sessionFile: ${handle.sessionFile}`);
|
|
2446
3253
|
return lines.join("\n");
|
|
2447
3254
|
} catch (error) {
|
|
3255
|
+
// #266: a queued item is never "failed" — it never started. Treating it as
|
|
3256
|
+
// still running keeps it out of the failure list and lets the retry/watch
|
|
3257
|
+
// path own it, instead of retiring a launch the caller only asked to wait on.
|
|
3258
|
+
const queuedNow = handle.orchestrator
|
|
3259
|
+
.childrenSnapshot()
|
|
3260
|
+
.find((entry) => entry.name === handle.name)?.state === "queued";
|
|
3261
|
+
if (queuedNow) {
|
|
3262
|
+
followUtility(handle, true);
|
|
3263
|
+
stillRunning.add(handle.name);
|
|
3264
|
+
return `── ${handle.name} ──\nexecution: queued (waiting for a free slot)\nsessionFile: ${handle.sessionFile}`;
|
|
3265
|
+
}
|
|
2448
3266
|
if (input.supervisor.isAlive(handle.name)) {
|
|
2449
3267
|
followUtility(handle, true);
|
|
2450
3268
|
stillRunning.add(handle.name);
|
|
@@ -2455,13 +3273,27 @@ async function launchUtilityFamily(input: {
|
|
|
2455
3273
|
return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
|
|
2456
3274
|
}
|
|
2457
3275
|
}));
|
|
2458
|
-
if (input.signal?.aborted)
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
3276
|
+
if (input.signal?.aborted) {
|
|
3277
|
+
// #266 D7/A5: same rule as the command chain — a parent turn end empties
|
|
3278
|
+
// every dispatch family's queue and spawns nothing. Each utility item owns
|
|
3279
|
+
// its own Orchestrator, so each one flushes its own view of the shared queue.
|
|
3280
|
+
const flushed: string[] = [];
|
|
3281
|
+
for (const handle of handles) {
|
|
3282
|
+
const child = handle.orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name);
|
|
3283
|
+
// D9: a queued item produced no result, so it must never be reported as
|
|
3284
|
+
// one. It is either dropped by the flush below (reported by name) or, if
|
|
3285
|
+
// it was already admitted, genuinely still running.
|
|
3286
|
+
if (child?.state === "queued") flushed.push(...handle.orchestrator.flushLaunchQueue("parent turn ended"));
|
|
3287
|
+
}
|
|
3288
|
+
return interruptedUtilityResult({
|
|
3289
|
+
runId,
|
|
3290
|
+
handles,
|
|
3291
|
+
stillRunning,
|
|
3292
|
+
results,
|
|
3293
|
+
collected,
|
|
3294
|
+
flushed,
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
2465
3297
|
results.push(...collected);
|
|
2466
3298
|
return {
|
|
2467
3299
|
content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
|
|
@@ -2493,9 +3325,18 @@ function interruptedUtilityResult(input: {
|
|
|
2493
3325
|
stillRunning: Set<string>;
|
|
2494
3326
|
results: string[];
|
|
2495
3327
|
collected: string[];
|
|
3328
|
+
/** #266 D7: names the abort cleared out of the launch queue (seq order). */
|
|
3329
|
+
flushed?: readonly string[];
|
|
2496
3330
|
}): AgentToolResult<unknown> {
|
|
2497
3331
|
const head = "interrupted (parent turn ended; children still running)";
|
|
2498
|
-
|
|
3332
|
+
// #266 D7: a dropped queued launch is the reason the rest of the result is
|
|
3333
|
+
// short, so it is reported even when nothing was collected at all. Trailing,
|
|
3334
|
+
// like the command chain's line: it reads as a summary of what was cleared.
|
|
3335
|
+
const segments = [
|
|
3336
|
+
...input.results,
|
|
3337
|
+
...input.collected,
|
|
3338
|
+
launchQueueFlushLine(input.flushed ?? []),
|
|
3339
|
+
].filter((segment) => segment.length > 0);
|
|
2499
3340
|
const stillRunning = input.handles
|
|
2500
3341
|
.filter(({ name }) => input.stillRunning.has(name))
|
|
2501
3342
|
.map(({ name }) => name);
|
|
@@ -2663,6 +3504,13 @@ function followJob(
|
|
|
2663
3504
|
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
2664
3505
|
/** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
|
|
2665
3506
|
spawnMode?: SpawnMode;
|
|
3507
|
+
/**
|
|
3508
|
+
* Own process of an `rpc` child (`Handle.pid`), for the memory probe.
|
|
3509
|
+
* Omitted when the caller has no handle at hand — a rebuilt orchestrator
|
|
3510
|
+
* only knows `ChildRecord`, and a pid is not persisted, so it must not be
|
|
3511
|
+
* guessed or looked up elsewhere.
|
|
3512
|
+
*/
|
|
3513
|
+
pid?: number;
|
|
2666
3514
|
},
|
|
2667
3515
|
): void {
|
|
2668
3516
|
const child = input.orchestrator
|
|
@@ -2683,6 +3531,7 @@ function followJob(
|
|
|
2683
3531
|
...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
|
|
2684
3532
|
...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
|
|
2685
3533
|
...(spawnMode ? { spawnMode } : {}),
|
|
3534
|
+
...(input.pid ? { pid: input.pid } : {}),
|
|
2686
3535
|
...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
|
|
2687
3536
|
nodeId: child.nodeId,
|
|
2688
3537
|
parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
|
|
@@ -2760,16 +3609,21 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
|
|
|
2760
3609
|
}
|
|
2761
3610
|
|
|
2762
3611
|
/**
|
|
2763
|
-
* The arbitrated backend recorded for one handled child of this request
|
|
3612
|
+
* The arbitrated backend recorded for one handled child of this request, plus
|
|
3613
|
+
* the OS process of that child when the launch reported one.
|
|
2764
3614
|
*
|
|
2765
3615
|
* Spreads to nothing when unknown, so the caller never has to invent a value.
|
|
2766
3616
|
*/
|
|
2767
3617
|
function spawnModeOf(
|
|
2768
3618
|
session: LaunchSession,
|
|
2769
3619
|
name: string,
|
|
2770
|
-
): { spawnMode?: SpawnMode } {
|
|
3620
|
+
): { spawnMode?: SpawnMode; pid?: number } {
|
|
2771
3621
|
const recorded = session.spawnByName.get(name);
|
|
2772
|
-
|
|
3622
|
+
const pid = session.pidByName.get(name);
|
|
3623
|
+
return {
|
|
3624
|
+
...(recorded ? { spawnMode: recorded.spawnMode } : {}),
|
|
3625
|
+
...(pid ? { pid } : {}),
|
|
3626
|
+
};
|
|
2773
3627
|
}
|
|
2774
3628
|
|
|
2775
3629
|
function followLaunched(
|
|
@@ -2801,6 +3655,39 @@ function followLaunched(
|
|
|
2801
3655
|
});
|
|
2802
3656
|
}
|
|
2803
3657
|
|
|
3658
|
+
/**
|
|
3659
|
+
* Persist the orchestrator's current view of one child.
|
|
3660
|
+
*
|
|
3661
|
+
* RunStore is async while Orchestrator's `onChildUpdate` is synchronous, so
|
|
3662
|
+
* persistence is done explicitly here rather than from inside the callback
|
|
3663
|
+
* (which would leave floating promises).
|
|
3664
|
+
*/
|
|
3665
|
+
/**
|
|
3666
|
+
* D8: give a child its spawn-time progress baseline exactly once.
|
|
3667
|
+
*
|
|
3668
|
+
* Called before every persist, so whichever launch path created the record
|
|
3669
|
+
* seeds it at (as near as makes no difference) spawn time: `persistChild` runs
|
|
3670
|
+
* immediately after `launch()` on every path — fresh launch, `tasks[]` fan-out,
|
|
3671
|
+
* utility dispatch — and the session file is empty-ish at that moment by
|
|
3672
|
+
* definition (`lines: 0` is then the honest answer, see {@link renewalBaselineFor}).
|
|
3673
|
+
*
|
|
3674
|
+
* The guard is "absent", never "stale":
|
|
3675
|
+
* - a record written before this field existed seeds on its next write, which
|
|
3676
|
+
* is the first moment anything is known about it;
|
|
3677
|
+
* - a renewed child keeps the baseline `reviveChild` handed it, so D8's
|
|
3678
|
+
* "count only what happened since the renewal" cannot be undone here.
|
|
3679
|
+
*/
|
|
3680
|
+
export function seedRenewalState(
|
|
3681
|
+
child: Pick<ChildRecord, "sessionFile" | "renewalBaseline" | "renewalsUsed">,
|
|
3682
|
+
now: number = Date.now(),
|
|
3683
|
+
readLines: (file: string) => string[] | undefined = readSessionLines,
|
|
3684
|
+
): void {
|
|
3685
|
+
if (!child.renewalBaseline) {
|
|
3686
|
+
child.renewalBaseline = renewalBaselineFor(child.sessionFile, now, readLines);
|
|
3687
|
+
}
|
|
3688
|
+
if (child.renewalsUsed === undefined) child.renewalsUsed = 0;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
2804
3691
|
/**
|
|
2805
3692
|
* Persist the orchestrator's current view of one child.
|
|
2806
3693
|
*
|
|
@@ -2817,6 +3704,10 @@ async function persistChild(
|
|
|
2817
3704
|
): Promise<void> {
|
|
2818
3705
|
const child = orchestrator.childrenSnapshot().find((c) => c.name === name);
|
|
2819
3706
|
if (!child) return;
|
|
3707
|
+
// D8: every persist path is a launch path on its first call, so this is where
|
|
3708
|
+
// a fresh child gets its baseline (see `seedRenewalState` for why it is safe
|
|
3709
|
+
// to run unconditionally on later persists too).
|
|
3710
|
+
seedRenewalState(child);
|
|
2820
3711
|
try {
|
|
2821
3712
|
const recorded = store.findChild(runId, name);
|
|
2822
3713
|
if (!recorded) {
|
|
@@ -2840,6 +3731,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2840
3731
|
session.runtime.release(name);
|
|
2841
3732
|
return;
|
|
2842
3733
|
}
|
|
3734
|
+
// #266: a launch that never started cannot be "still running". Reporting it as
|
|
3735
|
+
// watched would contradict the `N queued launch(es) dropped` line and tell the
|
|
3736
|
+
// caller to wait for a child that will never produce a turn.
|
|
3737
|
+
if (child.state === "queued") return;
|
|
2843
3738
|
// Esc / parent-turn abort must not retire: child keeps running; follow+watch
|
|
2844
3739
|
// so settle still notifies the parent (esc-no-retire-v3).
|
|
2845
3740
|
followLaunched(session, name, { watch: true });
|
|
@@ -2851,40 +3746,132 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2851
3746
|
// ---------------------------------------------------------------------------
|
|
2852
3747
|
|
|
2853
3748
|
/**
|
|
2854
|
-
*
|
|
3749
|
+
* Child-record states that are terminal, so a record carrying one is never live
|
|
3750
|
+
* (display-liveness D6, exit 1). `failed`/`settled` live on the execution side
|
|
3751
|
+
* rather than in `TaskState`, but a run.json written by another version (or a
|
|
3752
|
+
* hand-edited record) can carry them and they are terminal either way, so the
|
|
3753
|
+
* set is keyed on strings instead of the narrow state union.
|
|
3754
|
+
*/
|
|
3755
|
+
const TERMINAL_STATUS_CHILD_STATES = new Set<string>([
|
|
3756
|
+
"retired",
|
|
3757
|
+
"exited",
|
|
3758
|
+
"failed",
|
|
3759
|
+
"settled",
|
|
3760
|
+
]);
|
|
3761
|
+
|
|
3762
|
+
/** The persisted child record shape, as resolved through the run store. */
|
|
3763
|
+
type StatusChildRecord = NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
3764
|
+
|
|
3765
|
+
/**
|
|
3766
|
+
* Liveness facts for one child, supplied by the caller.
|
|
2855
3767
|
*
|
|
2856
|
-
*
|
|
2857
|
-
*
|
|
3768
|
+
* `alive` is this process's in-memory supervisor answer (it never holds a child
|
|
3769
|
+
* launched by another session); `owner` is the run-dir ownership verdict that
|
|
3770
|
+
* covers that hole (#209-3). See {@link ownerVerdictFor}.
|
|
2858
3771
|
*/
|
|
2859
|
-
export
|
|
3772
|
+
export interface StatusLivenessFacts {
|
|
3773
|
+
alive: boolean;
|
|
3774
|
+
/** `undefined` while `alive` — in-memory liveness already decided (#209-3). */
|
|
3775
|
+
owner?: "live" | "dead" | "unknown";
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
/** One run record after the status filter ran (display-liveness D6 exit 1). */
|
|
3779
|
+
export interface StatusChildClassification {
|
|
3780
|
+
child: StatusChildRecord;
|
|
3781
|
+
/**
|
|
3782
|
+
* Counted in `Status · N live children`. `false` = listed but NOT counted:
|
|
3783
|
+
* the record's run-dir owner pid is gone (#209-3 / A3).
|
|
3784
|
+
*/
|
|
3785
|
+
live: boolean;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
/**
|
|
3789
|
+
* Split the persisted child records under this cwd into `live` and `dead`.
|
|
3790
|
+
*
|
|
3791
|
+
* A record is dead when its owner pid is gone and this process is not running
|
|
3792
|
+
* it — not merely when its `state` says so. `owner === "unknown"` counts as
|
|
3793
|
+
* live: filtering unknown out would hide a child that may well be starting, and
|
|
3794
|
+
* D2 forbids collapsing unknown into dead. Its block still reports
|
|
3795
|
+
* `owner: unknown` + `state=working`, so it is never *displayed* as confirmed
|
|
3796
|
+
* live — it is only not hidden.
|
|
3797
|
+
*
|
|
3798
|
+
* Names can be reused after retire, so the newest record per name wins, except
|
|
3799
|
+
* that a dead record never shadows an older record of the same name that is
|
|
3800
|
+
* still alive (same preference as {@link pickChildByName}).
|
|
3801
|
+
*
|
|
3802
|
+
* Without `factsFor` the filter falls back to the record state alone; that keeps
|
|
3803
|
+
* this callable from a context that has no store (and keeps the pre-liveness
|
|
3804
|
+
* signature working), but the `status` exit must pass facts.
|
|
3805
|
+
*/
|
|
3806
|
+
export function classifyChildrenForStatus(
|
|
2860
3807
|
runs: ReturnType<RunStore["listRuns"]>,
|
|
2861
|
-
|
|
3808
|
+
factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
|
|
3809
|
+
): StatusChildClassification[] {
|
|
2862
3810
|
const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2863
|
-
const
|
|
2864
|
-
const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
|
|
3811
|
+
const candidates: StatusChildClassification[] = [];
|
|
2865
3812
|
for (const run of ranked) {
|
|
2866
3813
|
for (const child of run.children) {
|
|
2867
|
-
if (
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
3814
|
+
if (TERMINAL_STATUS_CHILD_STATES.has(child.state)) continue;
|
|
3815
|
+
const facts = factsFor?.(child);
|
|
3816
|
+
// Dead = the run dir's owner pid is gone AND this process is not running
|
|
3817
|
+
// it. `owner === "unknown"` (no marker, or no pid in it) is never dead:
|
|
3818
|
+
// unknown must not be collapsed into dead (display-liveness D2).
|
|
3819
|
+
const live = !(facts?.owner === "dead" && !facts.alive);
|
|
3820
|
+
candidates.push({ child, live });
|
|
2871
3821
|
}
|
|
2872
3822
|
}
|
|
2873
|
-
|
|
3823
|
+
// Name reuse: a newer DEAD record must not shadow an older record of the
|
|
3824
|
+
// same name that is still live (mirror of `pickChildByName`, which prefers a
|
|
3825
|
+
// live match over a newer retired one). Among equals the newest wins, hence
|
|
3826
|
+
// the newest-first iteration order.
|
|
3827
|
+
const chosen = new Map<string, StatusChildClassification>();
|
|
3828
|
+
for (const entry of candidates) {
|
|
3829
|
+
const prior = chosen.get(entry.child.name);
|
|
3830
|
+
if (!prior || !prior.live) chosen.set(entry.child.name, entry);
|
|
3831
|
+
}
|
|
3832
|
+
return candidates.filter((entry) => chosen.get(entry.child.name) === entry);
|
|
2874
3833
|
}
|
|
2875
3834
|
|
|
2876
|
-
/**
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
3835
|
+
/**
|
|
3836
|
+
* Live children under this cwd for the status overview.
|
|
3837
|
+
*
|
|
3838
|
+
* Pass `factsFor` to decide liveness from owner evidence instead of the record
|
|
3839
|
+
* state alone; see {@link classifyChildrenForStatus}.
|
|
3840
|
+
*/
|
|
3841
|
+
export function listLiveChildrenForStatus(
|
|
3842
|
+
runs: ReturnType<RunStore["listRuns"]>,
|
|
3843
|
+
factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
|
|
3844
|
+
): StatusChildRecord[] {
|
|
3845
|
+
return classifyChildrenForStatus(runs, factsFor).flatMap((entry) =>
|
|
3846
|
+
entry.live ? [entry.child] : [],
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
/** One child block for {@link renderStatusOverview}. */
|
|
3851
|
+
export interface StatusChildEntry {
|
|
3852
|
+
child: StatusChildRecord;
|
|
3853
|
+
alive: boolean;
|
|
3854
|
+
/** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
|
|
3855
|
+
owner?: "live" | "dead" | "unknown";
|
|
3856
|
+
/**
|
|
3857
|
+
* Own process of an `rpc` child, when this session launched it. A pid is
|
|
3858
|
+
* not persisted, so a record from another session has none — and none is
|
|
3859
|
+
* invented here (D3).
|
|
3860
|
+
*/
|
|
3861
|
+
pid?: number;
|
|
3862
|
+
/**
|
|
3863
|
+
* `false` = listed but not counted in the header (owner pid gone, see
|
|
3864
|
+
* {@link classifyChildrenForStatus}). Absent means live, so callers that only
|
|
3865
|
+
* hold live children keep their meaning.
|
|
3866
|
+
*/
|
|
3867
|
+
live?: boolean;
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
/** Multi-child status text: one {@link renderLiveChildStatus} block per child. */
|
|
3871
|
+
export function renderStatusOverview(children: StatusChildEntry[]): string {
|
|
3872
|
+
const deadCount = children.filter((entry) => entry.live === false).length;
|
|
3873
|
+
const liveCount = children.length - deadCount;
|
|
3874
|
+
const blocks = children.map(({ child, alive, owner, pid }) =>
|
|
2888
3875
|
renderLiveChildStatus({
|
|
2889
3876
|
name: child.name,
|
|
2890
3877
|
state: child.state,
|
|
@@ -2896,9 +3883,22 @@ export function renderStatusOverview(
|
|
|
2896
3883
|
spawnMode: child.spawnMode,
|
|
2897
3884
|
alive,
|
|
2898
3885
|
...(owner ? { owner } : {}),
|
|
3886
|
+
...(pid ? { pid } : {}),
|
|
2899
3887
|
}),
|
|
2900
3888
|
);
|
|
2901
|
-
|
|
3889
|
+
const parts: string[] = [];
|
|
3890
|
+
if (liveCount === 0) {
|
|
3891
|
+
// Nothing live. With no record at all this stays the exact literal the
|
|
3892
|
+
// tests pin; with records that are all dead the count line below says so,
|
|
3893
|
+
// instead of implying the run directory is empty.
|
|
3894
|
+
parts.push("No live children.");
|
|
3895
|
+
} else {
|
|
3896
|
+
const noun = liveCount === 1 ? "child" : "children";
|
|
3897
|
+
parts.push(`Status · ${liveCount} live ${noun}`);
|
|
3898
|
+
}
|
|
3899
|
+
if (deadCount > 0) parts.push(`(${deadCount} dead)`);
|
|
3900
|
+
if (blocks.length > 0) parts.push("", blocks.join("\n\n"));
|
|
3901
|
+
return parts.join("\n");
|
|
2902
3902
|
}
|
|
2903
3903
|
|
|
2904
3904
|
/**
|
|
@@ -3242,6 +4242,41 @@ function findControlAgent(
|
|
|
3242
4242
|
);
|
|
3243
4243
|
}
|
|
3244
4244
|
|
|
4245
|
+
/**
|
|
4246
|
+
* #270: resolve a child from another store when this one has no record.
|
|
4247
|
+
*
|
|
4248
|
+
* Only reached on a miss, so the common case (child launched here) costs
|
|
4249
|
+
* nothing. The returned shape matches {@link findChild} so callers do not care
|
|
4250
|
+
* which store answered — except that `runDir` now belongs to the owning store,
|
|
4251
|
+
* which is why the caller re-derives its orchestrator run directory from it.
|
|
4252
|
+
*/
|
|
4253
|
+
function findChildAcrossStores(input: {
|
|
4254
|
+
name: string;
|
|
4255
|
+
cwd: string;
|
|
4256
|
+
callerCwd?: string | undefined;
|
|
4257
|
+
ownRoot: string;
|
|
4258
|
+
}): {
|
|
4259
|
+
runId: string;
|
|
4260
|
+
run: ReturnType<RunStore["listRuns"]>[number];
|
|
4261
|
+
child: NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
4262
|
+
} | null {
|
|
4263
|
+
const { found } = resolveChildAcrossStores({
|
|
4264
|
+
name: input.name,
|
|
4265
|
+
cwd: input.cwd,
|
|
4266
|
+
callerCwd: input.callerCwd,
|
|
4267
|
+
roots: candidateStoreRoots({ cwd: input.cwd, callerCwd: input.callerCwd }).filter(
|
|
4268
|
+
(candidate) => path.resolve(candidate.rootDir) !== path.resolve(input.ownRoot),
|
|
4269
|
+
),
|
|
4270
|
+
});
|
|
4271
|
+
if (!found) return null;
|
|
4272
|
+
// Read the owning store again for the full record: `resolveChildAcrossStores`
|
|
4273
|
+
// only reports where the name was seen.
|
|
4274
|
+
const runs = new RunStore({ rootDir: found.storeRoot }).listRuns();
|
|
4275
|
+
const picked = pickChildByName(runs, input.name);
|
|
4276
|
+
if (!picked) return null;
|
|
4277
|
+
return { runId: picked.run.runId, run: picked.run, child: picked.child };
|
|
4278
|
+
}
|
|
4279
|
+
|
|
3245
4280
|
function findChild(
|
|
3246
4281
|
runs: ReturnType<RunStore["listRuns"]>,
|
|
3247
4282
|
name: string,
|