pi-legion 4.0.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 +168 -1
- package/README.md +101 -44
- package/agents/centurion.md +20 -16
- package/agents/scout.md +3 -1
- package/agents/worker.md +5 -7
- package/docs/contracts/declarative-phase-machine-v5.md +128 -0
- package/docs/contracts/subagent-renewal-v1.md +260 -0
- package/docs/headless-child-contract.md +6 -3
- package/docs/inprocess-supervisor.md +70 -24
- package/docs/legion-v2-contract.md +9 -0
- package/docs/spawn-mode-visibility-v1.md +4 -1
- package/index.ts +598 -44
- package/package.json +2 -2
- package/src/agents/agents.ts +293 -36
- package/src/agents/overrides.ts +3 -1
- package/src/agents/settings.ts +6 -18
- package/src/extension/child-guard.ts +25 -1
- package/src/extension/legion-tools.ts +66 -21
- package/src/extension/notify.ts +136 -4
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +56 -1
- package/src/extension/summary.ts +13 -4
- package/src/extension/timing.ts +8 -4
- package/src/legion/phase-conditions.ts +164 -0
- package/src/legion/phase-machine.ts +403 -0
- package/src/legion/state-machine.ts +219 -115
- package/src/runs/args.ts +32 -18
- package/src/runs/extension-inheritance.ts +220 -0
- package/src/runs/orchestrator.ts +137 -44
- package/src/runs/progress-signal.ts +137 -0
- package/src/runs/store-lookup.ts +270 -0
- package/src/shared/types.ts +88 -3
- package/src/supervisor/child-context.ts +19 -0
- package/src/supervisor/inprocess-supervisor.ts +29 -5
- package/src/supervisor/routing-supervisor.ts +2 -1
- package/src/supervisor/rpc-supervisor.ts +2 -2
- package/src/tui/tree-view.ts +56 -0
- package/src/tui/viz/palette.ts +0 -3
- 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/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { Type, type Static } from "typebox";
|
|
22
22
|
import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
|
|
23
23
|
import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
|
|
24
|
+
import { runAtAbsoluteDepth } from "./src/supervisor/inprocess-depth.ts";
|
|
24
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";
|
|
@@ -48,6 +49,7 @@ 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";
|
|
@@ -57,7 +59,12 @@ import { getChildByName, getChildren, getNode, getSubtree, insertNode, type Legi
|
|
|
57
59
|
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
58
60
|
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
59
61
|
import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
|
|
60
|
-
import {
|
|
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";
|
|
@@ -81,11 +88,14 @@ import {
|
|
|
81
88
|
} from "./src/extension/blocked.ts";
|
|
82
89
|
import { formatAlreadyRecycled } from "./src/extension/recycle.ts";
|
|
83
90
|
import {
|
|
91
|
+
completionStatusOf,
|
|
92
|
+
type RenewalProposal,
|
|
84
93
|
SUBAGENT_NOTIFY_TYPE,
|
|
85
94
|
} from "./src/extension/notify.ts";
|
|
86
95
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
87
96
|
import { renderSpawnResult } from "./src/extension/spawn-render.ts";
|
|
88
97
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
98
|
+
import { CHILD_ROLE_ENV } from "./src/extension/child-guard.ts";
|
|
89
99
|
import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
|
|
90
100
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
91
101
|
import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
@@ -96,19 +106,31 @@ import {
|
|
|
96
106
|
type OrchestratorLegionContext,
|
|
97
107
|
} from "./src/runs/orchestrator.ts";
|
|
98
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";
|
|
99
115
|
import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
|
|
116
|
+
import { measureProgress, readSessionLines } from "./src/runs/progress-signal.ts";
|
|
100
117
|
import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
|
|
101
118
|
import {
|
|
102
119
|
type AgentConfig,
|
|
103
120
|
type AgentScope,
|
|
121
|
+
type ChildRecord,
|
|
104
122
|
DEFAULTS,
|
|
123
|
+
DEFAULT_MAX_RENEWALS,
|
|
105
124
|
DEFAULT_ON_BLOCKED,
|
|
125
|
+
DEFAULT_RENEWABLE,
|
|
106
126
|
ErrorCodes,
|
|
127
|
+
type Execution,
|
|
107
128
|
type ModelOrigin,
|
|
108
129
|
type RetireOptions,
|
|
109
130
|
type SpawnMode,
|
|
110
131
|
SubagentError,
|
|
111
132
|
type SubagentsSettings,
|
|
133
|
+
unknownChildError,
|
|
112
134
|
type Handle,
|
|
113
135
|
} from "./src/shared/types.ts";
|
|
114
136
|
|
|
@@ -119,7 +141,12 @@ import {
|
|
|
119
141
|
const TaskItem = Type.Object({
|
|
120
142
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
121
143
|
task: Type.String({ description: "Task to delegate" }),
|
|
122
|
-
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
|
+
),
|
|
123
150
|
model: Type.Optional(Type.String()),
|
|
124
151
|
preset: Type.Optional(
|
|
125
152
|
Type.String({ description: "Named kind+model+thinking preset" }),
|
|
@@ -175,7 +202,12 @@ const SubagentParams = Type.Object({
|
|
|
175
202
|
preset: Type.Optional(
|
|
176
203
|
Type.String({ description: "Named kind+model+thinking preset" }),
|
|
177
204
|
),
|
|
178
|
-
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
|
+
),
|
|
179
211
|
worktree: Type.Optional(
|
|
180
212
|
Type.Boolean({
|
|
181
213
|
description:
|
|
@@ -249,7 +281,12 @@ const VexillatioParams = Type.Object({
|
|
|
249
281
|
),
|
|
250
282
|
agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
|
|
251
283
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
|
|
252
|
-
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
|
+
),
|
|
253
290
|
model: Type.Optional(Type.String()),
|
|
254
291
|
timeoutMs: Type.Optional(Type.Number({ description: "Per-child wait in ms, default 600000" })),
|
|
255
292
|
});
|
|
@@ -328,7 +365,19 @@ function currentProcessSpawnMode(): SpawnMode {
|
|
|
328
365
|
const INTERRUPTED_HEAD = "interrupted (parent turn ended; children still running)";
|
|
329
366
|
|
|
330
367
|
function notice(pi: ExtensionAPI, text: string): void {
|
|
331
|
-
|
|
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
|
+
}
|
|
332
381
|
}
|
|
333
382
|
|
|
334
383
|
/**
|
|
@@ -444,6 +493,26 @@ export function attachLegionRuntime(input: {
|
|
|
444
493
|
return dbEventSource;
|
|
445
494
|
}
|
|
446
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
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
447
516
|
function createLegionRuntime(input: {
|
|
448
517
|
pi: ExtensionAPI;
|
|
449
518
|
supervisor: LegionSupervisor;
|
|
@@ -457,6 +526,8 @@ function createLegionRuntime(input: {
|
|
|
457
526
|
settings: LegionSettings;
|
|
458
527
|
eventBus: LegionEventBus;
|
|
459
528
|
registerTools: boolean;
|
|
529
|
+
/** #298: the team root's declared phase machine, when its card supplies one. */
|
|
530
|
+
machine?: PhaseMachine;
|
|
460
531
|
}): LegionRuntimeIdentity {
|
|
461
532
|
const self = getNode(input.db, input.nodeId);
|
|
462
533
|
const eventBus = input.eventBus;
|
|
@@ -469,6 +540,7 @@ function createLegionRuntime(input: {
|
|
|
469
540
|
eventBus,
|
|
470
541
|
sendRootMessage: (text: string) => notice(input.pi, text),
|
|
471
542
|
sendSelfMessage: (text: string) => notice(input.pi, text),
|
|
543
|
+
...(input.machine ? { machine: input.machine } : {}),
|
|
472
544
|
resumeChild: (node: LegionNode, mail: LegionMailCommand) => resumeLegionChild({
|
|
473
545
|
pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
|
|
474
546
|
eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
|
|
@@ -493,7 +565,7 @@ function createLegionRuntime(input: {
|
|
|
493
565
|
postman,
|
|
494
566
|
childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
|
|
495
567
|
};
|
|
496
|
-
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 } : {}) });
|
|
497
569
|
postman.start();
|
|
498
570
|
return identity;
|
|
499
571
|
}
|
|
@@ -551,8 +623,31 @@ function resolveLegionIdentity(input: {
|
|
|
551
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 } });
|
|
552
624
|
}
|
|
553
625
|
if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
|
|
554
|
-
const
|
|
555
|
-
|
|
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
|
+
}
|
|
556
651
|
return identity;
|
|
557
652
|
}
|
|
558
653
|
if (input.cached?.nodeId === "root" && path.resolve(input.cached.storeRootDir) === path.resolve(input.store.rootDir)) return input.cached;
|
|
@@ -644,6 +739,51 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
644
739
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
645
740
|
}
|
|
646
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
|
+
|
|
647
787
|
export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
648
788
|
// #197 D8: a session is a child if either transport says so. RPC children
|
|
649
789
|
// are separate processes marked in env; in-process children share the
|
|
@@ -761,16 +901,50 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
761
901
|
return true;
|
|
762
902
|
}
|
|
763
903
|
};
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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;
|
|
774
948
|
};
|
|
775
949
|
const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string; legionNode?: { dbPath: string; nodeId: string } }): void => {
|
|
776
950
|
// The injected node's dbPath is authoritative: an in-process child shares the
|
|
@@ -780,9 +954,27 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
780
954
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
781
955
|
projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
|
|
782
956
|
});
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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);
|
|
786
978
|
};
|
|
787
979
|
if (isChild) {
|
|
788
980
|
registerChildGuard(pi);
|
|
@@ -836,7 +1028,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
836
1028
|
}
|
|
837
1029
|
const { storeRoot, notify } = pendingHygiene;
|
|
838
1030
|
try {
|
|
839
|
-
const report = runStartupHygiene({ storeRoot, protectRunIds:
|
|
1031
|
+
const report = runStartupHygiene({ storeRoot, protectRunIds: ownLegionIdentity() ? [ownLegionIdentity()!.rootRunId] : [] });
|
|
840
1032
|
const text = formatHygieneReport(report);
|
|
841
1033
|
if (text) notify?.(text);
|
|
842
1034
|
} catch (error) {
|
|
@@ -880,7 +1072,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
880
1072
|
...children.map((job) => job.retire?.(shutdownRetire).catch(() => {})),
|
|
881
1073
|
...pendingUtilityRetires.map((retire) => retire(shutdownRetire).catch(() => {})),
|
|
882
1074
|
]);
|
|
883
|
-
|
|
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);
|
|
884
1078
|
};
|
|
885
1079
|
if (process.env[UTILITY_CHILD_ENV] !== "1") {
|
|
886
1080
|
pi.registerTool({
|
|
@@ -913,11 +1107,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
913
1107
|
sessionCwd: ctx.cwd,
|
|
914
1108
|
signal,
|
|
915
1109
|
isChild,
|
|
916
|
-
legion:
|
|
1110
|
+
legion: ownLegionIdentity(),
|
|
917
1111
|
eventBus: legionBus,
|
|
918
1112
|
onLegion: (identity) => {
|
|
919
|
-
|
|
920
|
-
legionIdentity = identity;
|
|
1113
|
+
adoptLegionIdentity(identity);
|
|
921
1114
|
},
|
|
922
1115
|
onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
|
|
923
1116
|
});
|
|
@@ -941,16 +1134,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
941
1134
|
catalog.settings,
|
|
942
1135
|
catalog.team,
|
|
943
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;
|
|
944
1146
|
return {
|
|
945
1147
|
systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
|
|
946
|
-
|
|
1148
|
+
rosterAgents,
|
|
947
1149
|
catalog.team,
|
|
948
1150
|
catalog.teamWarnings,
|
|
949
1151
|
)}${parentContext ? `\n\n${parentContext}` : ""}`,
|
|
950
1152
|
};
|
|
951
1153
|
});
|
|
952
1154
|
if (process.env[UTILITY_CHILD_ENV] === "1") {
|
|
953
|
-
pi.on("session_shutdown", () => stopLegionRuntime());
|
|
1155
|
+
pi.on("session_shutdown", () => stopLegionRuntime(ownLegionKey));
|
|
954
1156
|
return;
|
|
955
1157
|
}
|
|
956
1158
|
let lastUi: ExtensionUIContext | undefined;
|
|
@@ -970,13 +1172,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
970
1172
|
statusStrip: process.env.PI_LEGION_STRIP === "tree" ? "tree" : "railway",
|
|
971
1173
|
eventBus: legionBus,
|
|
972
1174
|
getTreeNodes: () => {
|
|
973
|
-
const identity =
|
|
1175
|
+
const identity = ownLegionIdentity();
|
|
974
1176
|
return identity
|
|
975
1177
|
? attachUsageTotals(identity.db, getSubtree(identity.db, identity.nodeId))
|
|
976
1178
|
: [];
|
|
977
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
|
+
})(),
|
|
978
1203
|
getTreeIdentity: (name) => {
|
|
979
|
-
const
|
|
1204
|
+
const own = ownLegionIdentity();
|
|
1205
|
+
const node = own && getChildByName(own.db, own.nodeId, name);
|
|
980
1206
|
return node ? {
|
|
981
1207
|
nodeId: node.id,
|
|
982
1208
|
parentId: node.parentId,
|
|
@@ -984,10 +1210,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
984
1210
|
supervisorName: name,
|
|
985
1211
|
} : undefined;
|
|
986
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
|
+
},
|
|
987
1240
|
subscribeSupervisorEvents: (name, listener, options) => rpcEventSource.onEvent?.(
|
|
988
1241
|
name,
|
|
989
1242
|
(event) => {
|
|
990
|
-
const nodeId =
|
|
1243
|
+
const nodeId = ownLegionIdentity()?.childNodeId(name);
|
|
991
1244
|
if (nodeId) publishSupervisorEvent(legionBus, nodeId, event, options);
|
|
992
1245
|
listener(event);
|
|
993
1246
|
},
|
|
@@ -1259,12 +1512,12 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1259
1512
|
|
|
1260
1513
|
let activeLegion: LegionRuntimeIdentity | undefined;
|
|
1261
1514
|
try {
|
|
1262
|
-
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() });
|
|
1263
1516
|
if (!activeLegion && action === "launch" && !isChild) {
|
|
1264
1517
|
const plan = buildPlan(params);
|
|
1265
1518
|
if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
|
|
1266
1519
|
}
|
|
1267
|
-
if (activeLegion)
|
|
1520
|
+
if (activeLegion) adoptLegionIdentity(activeLegion);
|
|
1268
1521
|
} catch (error) {
|
|
1269
1522
|
return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
|
|
1270
1523
|
}
|
|
@@ -1285,6 +1538,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1285
1538
|
verifyRunner,
|
|
1286
1539
|
store,
|
|
1287
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 } : {}),
|
|
1288
1544
|
agents,
|
|
1289
1545
|
allAgents,
|
|
1290
1546
|
team,
|
|
@@ -1355,7 +1611,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
1355
1611
|
};
|
|
1356
1612
|
|
|
1357
1613
|
uiProxy.onRequest((pending) => {
|
|
1358
|
-
const legionNodeId =
|
|
1614
|
+
const legionNodeId = ownLegionIdentity()?.childNodeId(pending.childName);
|
|
1359
1615
|
if (legionNodeId) publishUIRequest(legionBus, legionNodeId, pending.request.method);
|
|
1360
1616
|
const ui = lastUi;
|
|
1361
1617
|
if (!ui) return;
|
|
@@ -1516,6 +1772,11 @@ async function controlAction(input: {
|
|
|
1516
1772
|
verifyRunner?: VerifyRunner;
|
|
1517
1773
|
store: RunStore;
|
|
1518
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;
|
|
1519
1780
|
agents: AgentConfig[];
|
|
1520
1781
|
allAgents: AgentConfig[];
|
|
1521
1782
|
team: ActiveTeam;
|
|
@@ -1566,7 +1827,7 @@ async function controlAction(input: {
|
|
|
1566
1827
|
// all names but before the serial loop reaches it. Treat that target as
|
|
1567
1828
|
// stopped; only rebuild if its persisted record is not already retired.
|
|
1568
1829
|
const found = findChild(input.store.listRuns(), name);
|
|
1569
|
-
if (!found) throw
|
|
1830
|
+
if (!found) throw unknownChildError(name);
|
|
1570
1831
|
if (found.child.state === "retired") return;
|
|
1571
1832
|
const orchestrator = new Orchestrator({
|
|
1572
1833
|
supervisor: input.supervisor,
|
|
@@ -1624,8 +1885,15 @@ async function controlAction(input: {
|
|
|
1624
1885
|
);
|
|
1625
1886
|
}
|
|
1626
1887
|
const runs = store.listRuns();
|
|
1627
|
-
|
|
1628
|
-
|
|
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);
|
|
1629
1897
|
const alive = input.supervisor.isAlive(params.name);
|
|
1630
1898
|
const owner = ownerVerdictFor(store, runs, params.name, alive);
|
|
1631
1899
|
return ok(renderLiveChildStatus({
|
|
@@ -1649,8 +1917,15 @@ async function controlAction(input: {
|
|
|
1649
1917
|
);
|
|
1650
1918
|
}
|
|
1651
1919
|
|
|
1652
|
-
const found =
|
|
1653
|
-
|
|
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);
|
|
1654
1929
|
|
|
1655
1930
|
const orchestrator = new Orchestrator({
|
|
1656
1931
|
supervisor: input.supervisor,
|
|
@@ -1713,13 +1988,29 @@ function unknownChild(
|
|
|
1713
1988
|
name: string,
|
|
1714
1989
|
store: RunStore,
|
|
1715
1990
|
cwd: string,
|
|
1991
|
+
callerCwd?: string,
|
|
1716
1992
|
): AgentToolResult<unknown> {
|
|
1717
|
-
|
|
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
|
+
});
|
|
1718
2012
|
return fail(
|
|
1719
|
-
|
|
1720
|
-
`${known.length ? `; known here: ${known.join(", ")}` : ""}). ` +
|
|
1721
|
-
`Child records are scoped to the \`cwd\` they were launched from — ` +
|
|
1722
|
-
`pass the same \`cwd\` you used for \`launch\`.`,
|
|
2013
|
+
unknownChildMessage({ name, cwd, searched: searched as CandidateStore[] }),
|
|
1723
2014
|
ErrorCodes.NOT_FOUND,
|
|
1724
2015
|
);
|
|
1725
2016
|
}
|
|
@@ -1810,6 +2101,171 @@ async function retireChild(
|
|
|
1810
2101
|
}
|
|
1811
2102
|
}
|
|
1812
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
|
+
|
|
1813
2269
|
/**
|
|
1814
2270
|
* Continue or resume a child.
|
|
1815
2271
|
*
|
|
@@ -1855,6 +2311,32 @@ async function reviveChild(
|
|
|
1855
2311
|
name,
|
|
1856
2312
|
...(found.child.model ? { model: found.child.model } : {}),
|
|
1857
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);
|
|
1858
2340
|
followChild(ctx, { watch: true });
|
|
1859
2341
|
return ok(
|
|
1860
2342
|
`Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
|
|
@@ -3173,6 +3655,39 @@ function followLaunched(
|
|
|
3173
3655
|
});
|
|
3174
3656
|
}
|
|
3175
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
|
+
|
|
3176
3691
|
/**
|
|
3177
3692
|
* Persist the orchestrator's current view of one child.
|
|
3178
3693
|
*
|
|
@@ -3189,6 +3704,10 @@ async function persistChild(
|
|
|
3189
3704
|
): Promise<void> {
|
|
3190
3705
|
const child = orchestrator.childrenSnapshot().find((c) => c.name === name);
|
|
3191
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);
|
|
3192
3711
|
try {
|
|
3193
3712
|
const recorded = store.findChild(runId, name);
|
|
3194
3713
|
if (!recorded) {
|
|
@@ -3723,6 +4242,41 @@ function findControlAgent(
|
|
|
3723
4242
|
);
|
|
3724
4243
|
}
|
|
3725
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
|
+
|
|
3726
4280
|
function findChild(
|
|
3727
4281
|
runs: ReturnType<RunStore["listRuns"]>,
|
|
3728
4282
|
name: string,
|