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.
Files changed (43) hide show
  1. package/CHANGELOG.md +168 -1
  2. package/README.md +101 -44
  3. package/agents/centurion.md +20 -16
  4. package/agents/scout.md +3 -1
  5. package/agents/worker.md +5 -7
  6. package/docs/contracts/declarative-phase-machine-v5.md +128 -0
  7. package/docs/contracts/subagent-renewal-v1.md +260 -0
  8. package/docs/headless-child-contract.md +6 -3
  9. package/docs/inprocess-supervisor.md +70 -24
  10. package/docs/legion-v2-contract.md +9 -0
  11. package/docs/spawn-mode-visibility-v1.md +4 -1
  12. package/index.ts +598 -44
  13. package/package.json +2 -2
  14. package/src/agents/agents.ts +293 -36
  15. package/src/agents/overrides.ts +3 -1
  16. package/src/agents/settings.ts +6 -18
  17. package/src/extension/child-guard.ts +25 -1
  18. package/src/extension/legion-tools.ts +66 -21
  19. package/src/extension/notify.ts +136 -4
  20. package/src/extension/playbook.ts +2 -2
  21. package/src/extension/runtime.ts +56 -1
  22. package/src/extension/summary.ts +13 -4
  23. package/src/extension/timing.ts +8 -4
  24. package/src/legion/phase-conditions.ts +164 -0
  25. package/src/legion/phase-machine.ts +403 -0
  26. package/src/legion/state-machine.ts +219 -115
  27. package/src/runs/args.ts +32 -18
  28. package/src/runs/extension-inheritance.ts +220 -0
  29. package/src/runs/orchestrator.ts +137 -44
  30. package/src/runs/progress-signal.ts +137 -0
  31. package/src/runs/store-lookup.ts +270 -0
  32. package/src/shared/types.ts +88 -3
  33. package/src/supervisor/child-context.ts +19 -0
  34. package/src/supervisor/inprocess-supervisor.ts +29 -5
  35. package/src/supervisor/routing-supervisor.ts +2 -1
  36. package/src/supervisor/rpc-supervisor.ts +2 -2
  37. package/src/tui/tree-view.ts +56 -0
  38. package/src/tui/viz/palette.ts +0 -3
  39. package/agents/advisor.md +0 -110
  40. package/agents/designer.md +0 -87
  41. package/agents/planner.md +0 -53
  42. package/agents/prototype.md +0 -72
  43. 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 { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.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";
@@ -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(Type.String()),
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(Type.String()),
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(Type.String()),
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
- pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
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 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 });
555
- if (node.role === "centurion") void resumeLegionTeam({ identity, input });
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
- let legionIdentity: LegionRuntimeIdentity | undefined;
765
- const stopLegionRuntime = (): void => {
766
- if (!legionIdentity) return;
767
- legionIdentity.postman.stop();
768
- legionIdentity.dbEventSource.stop();
769
- // #40: bound the ledger and fold the WAL back before closing.
770
- if (hygieneEnabled() && !isChild) applyEventRetention(legionIdentity.db);
771
- else checkpoint(legionIdentity.db);
772
- legionIdentity.db.close();
773
- legionIdentity = undefined;
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
- const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
784
- if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
785
- legionIdentity = next;
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: legionIdentity ? [legionIdentity.rootRunId] : [] });
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
- stopLegionRuntime();
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: legionIdentity,
1110
+ legion: ownLegionIdentity(),
917
1111
  eventBus: legionBus,
918
1112
  onLegion: (identity) => {
919
- if (legionIdentity && legionIdentity !== identity) stopLegionRuntime();
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
- catalog.agents,
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 = legionIdentity;
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 node = legionIdentity && getChildByName(legionIdentity.db, legionIdentity.nodeId, name);
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 = legionIdentity?.childNodeId(name);
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: legionIdentity, sessionFile: ctx.sessionManager?.getSessionFile() });
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) legionIdentity = 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 = legionIdentity?.childNodeId(pending.childName);
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 new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
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
- const found = findChild(runs, params.name);
1628
- if (!found) return unknownChild(params.name, store, cwd);
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 = findChild(store.listRuns(), params.name);
1653
- if (!found) return unknownChild(params.name, store, cwd);
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
- const known = store.listRuns().flatMap((run) => run.children.map((child) => child.name));
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
- `unknown child: ${name} (no run under ${cwd}/.pi-subagents` +
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,