pi-legion 5.0.1 → 5.2.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 (79) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/CONTRIBUTING.md +33 -2
  3. package/README.md +10 -5
  4. package/agents/centurion.md +32 -7
  5. package/agents/scout.md +11 -0
  6. package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
  7. package/docs/contracts/centurion-declared-phases-v1.md +104 -0
  8. package/docs/contracts/declarative-phase-machine-v5.md +43 -0
  9. package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
  10. package/docs/contracts/finished-cache-eviction-v1.md +169 -0
  11. package/docs/contracts/galaxy-scratch-v1.md +78 -0
  12. package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
  13. package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
  14. package/docs/contracts/legion-page-floor-v1.md +129 -0
  15. package/docs/contracts/legion-session-continuity-v1.md +118 -0
  16. package/docs/contracts/mailbox-delivery-v1.md +162 -0
  17. package/docs/contracts/notice-claim-v1.md +206 -0
  18. package/docs/contracts/partial-failure-exit-v1.md +235 -0
  19. package/docs/contracts/proc-sweep-v1.md +107 -0
  20. package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
  21. package/docs/contracts/stall-visibility-v1.md +218 -0
  22. package/docs/contracts/subagent-renewal-v1.md +5 -3
  23. package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
  24. package/docs/contracts/viz-frame-alloc-v1.md +123 -0
  25. package/docs/contracts/wait-mail-preempt-v1.md +117 -0
  26. package/docs/contracts/wait-not-stop.md +1 -1
  27. package/docs/design.md +4 -1
  28. package/docs/inprocess-supervisor.md +9 -3
  29. package/docs/legion-v2-contract.md +122 -20
  30. package/docs/vexillatio-contract.md +5 -1
  31. package/index.ts +1903 -130
  32. package/package.json +5 -5
  33. package/skills/pi-legion/SKILL.md +2 -1
  34. package/src/agents/agents.ts +26 -1
  35. package/src/agents/settings.ts +67 -2
  36. package/src/api.ts +17 -0
  37. package/src/extension/child-guard.ts +2 -1
  38. package/src/extension/join.ts +54 -0
  39. package/src/extension/legion-commands.ts +15 -4
  40. package/src/extension/legion-doctor.ts +48 -6
  41. package/src/extension/legion-status.ts +44 -2
  42. package/src/extension/legion-tools.ts +1044 -109
  43. package/src/extension/notify.ts +31 -2
  44. package/src/extension/playbook.ts +6 -3
  45. package/src/extension/runtime.ts +882 -81
  46. package/src/extension/spawn-hints.ts +52 -12
  47. package/src/extension/summary.ts +7 -15
  48. package/src/extension/timing.ts +4 -15
  49. package/src/legion/db.ts +23 -1
  50. package/src/legion/events.ts +7 -0
  51. package/src/legion/interrupt.ts +98 -0
  52. package/src/legion/mail-release.ts +266 -0
  53. package/src/legion/mail.ts +456 -29
  54. package/src/legion/nodes.ts +31 -0
  55. package/src/legion/phase-conditions.ts +7 -1
  56. package/src/legion/phase-machine.ts +160 -8
  57. package/src/legion/state-machine.ts +34 -2
  58. package/src/runs/acceptance.ts +105 -6
  59. package/src/runs/args.ts +16 -0
  60. package/src/runs/child-scope.ts +323 -0
  61. package/src/runs/extension-inheritance.ts +145 -19
  62. package/src/runs/orchestrator.ts +613 -55
  63. package/src/runs/store.ts +149 -5
  64. package/src/shared/proc-sweep.ts +317 -0
  65. package/src/shared/progress.ts +11 -2
  66. package/src/shared/session-map.ts +29 -0
  67. package/src/shared/session.ts +308 -6
  68. package/src/shared/types.ts +34 -0
  69. package/src/supervisor/inprocess-supervisor.ts +158 -12
  70. package/src/supervisor/race-timeout.ts +55 -0
  71. package/src/supervisor/routing-supervisor.ts +55 -4
  72. package/src/supervisor/rpc-supervisor.ts +215 -29
  73. package/src/supervisor/types.ts +439 -2
  74. package/src/tui/status.ts +2 -1
  75. package/src/tui/viz/canvas.ts +358 -36
  76. package/src/tui/viz/palette.ts +18 -2
  77. package/src/tui/viz/panels.ts +507 -104
  78. package/src/tui/viz/rate.ts +120 -15
  79. package/src/tui/viz/view.ts +72 -4
package/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * is still working. Running children are painted next to the input box.
12
12
  */
13
13
 
14
+ import * as fs from "node:fs";
14
15
  import * as path from "node:path";
15
16
  import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
16
17
  import type {
@@ -23,9 +24,11 @@ import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
23
24
  import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
24
25
  import { runAtAbsoluteDepth } from "./src/supervisor/inprocess-depth.ts";
25
26
  import { notifyInProcessSlotReleased, RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
26
- import type { LegionSupervisor } from "./src/supervisor/types.ts";
27
+ import type { AbortOutcome, InFlightToolStack, LegionSupervisor, SteerDisposition } from "./src/supervisor/types.ts";
28
+ import { inFlightToolStackOf } from "./src/supervisor/types.ts";
29
+ import { withInFlightToolLine } from "./src/extension/legion-status.ts";
27
30
  import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
28
- import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
31
+ import { discoverAgents, findAgent, formatAgentRoster, parsePhaseMachine, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
29
32
  import {
30
33
  applyAgentOverrides,
31
34
  applyDefaultModel,
@@ -42,21 +45,23 @@ import { providerForModel, type ProviderLookup } from "./src/runs/provider-pin.t
42
45
  import { resolveStepModel } from "./src/agents/step-model.ts";
43
46
  import { checkModelScope } from "./src/agents/model-scope.ts";
44
47
  import {
48
+ legionSettingsForRun,
45
49
  loadSubagentSettings,
46
50
  resolveSubagentSettings,
47
51
  } from "./src/agents/settings.ts";
48
52
  import { resolveTeamContext } from "./src/agents/context.ts";
49
- import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
53
+ import { createSessionRuntime, renderLiveChildStatus, type ClaimedCollect, type CollectSnapshot, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
50
54
  import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
51
55
  import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
52
- import type { PhaseMachine } from "./src/legion/phase-machine.ts";
53
- import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
56
+ import type { PhaseDefinition, PhaseMachine, PhaseTransitionRule } from "./src/legion/phase-machine.ts";
57
+ import { LEGION_DB_ENV, LEGION_NODE_ID_ENV, LEGION_PHASES_ENV } from "./src/runs/args.ts";
54
58
  import type { VerifyRunner } from "./src/runs/acceptance.ts";
55
59
  import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
56
60
  import { appendEvent, maxEventId } from "./src/legion/events.ts";
57
61
  import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
58
- import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
59
- import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
62
+ import { getChildByName, getChildren, getNode, getSubtree, insertNode, isTerminalNode, LIVE_CHILD_STATUSES, type LegionNode, updateNode } from "./src/legion/nodes.ts";
63
+ import { markSessionInterrupted } from "./src/legion/interrupt.ts";
64
+ import { type ExecutionStatus, type LegionSettings } from "./src/shared/types.ts";
60
65
  import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
61
66
  import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
62
67
  import {
@@ -106,6 +111,24 @@ import {
106
111
  type OrchestratorLegionContext,
107
112
  } from "./src/runs/orchestrator.ts";
108
113
  import { RunStore, pickChildByName } from "./src/runs/store.ts";
114
+ import {
115
+ beginMailHold,
116
+ endMailHold,
117
+ pollInbox,
118
+ redeliverMail,
119
+ renderReleasedMail,
120
+ takePendingMail,
121
+ type MailReleaseSeam,
122
+ } from "./src/legion/mail-release.ts";
123
+ import type { LegionMessage } from "./src/legion/mail.ts";
124
+ import {
125
+ describeChildScopeCandidates,
126
+ foundElsewhere,
127
+ foundInScope,
128
+ resolveScopedChild,
129
+ scopeHitFor,
130
+ type ChildScopeCandidate,
131
+ } from "./src/runs/child-scope.ts";
109
132
  import {
110
133
  candidateStoreRoots,
111
134
  resolveChildAcrossStores,
@@ -113,6 +136,7 @@ import {
113
136
  type CandidateStore,
114
137
  } from "./src/runs/store-lookup.ts";
115
138
  import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
139
+ import { isStaleUiError } from "./src/tui/status.ts";
116
140
  import { measureProgress, readSessionLines } from "./src/runs/progress-signal.ts";
117
141
  import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
118
142
  import {
@@ -127,6 +151,7 @@ import {
127
151
  type Execution,
128
152
  type ModelOrigin,
129
153
  type RetireOptions,
154
+ type RunRecord,
130
155
  type SpawnMode,
131
156
  SubagentError,
132
157
  type SubagentsSettings,
@@ -138,9 +163,96 @@ import {
138
163
  // Parameters
139
164
  // ---------------------------------------------------------------------------
140
165
 
166
+ /**
167
+ * One transition of a declared phase machine (#371).
168
+ *
169
+ * Mirrors `PhaseTransitionRule` in `src/legion/phase-machine.ts`. The shape is
170
+ * declared here rather than accepting `Type.Any()` so the MODEL sees what a
171
+ * machine is made of; the authoritative validation still happens once, in
172
+ * `parsePhaseMachine`, at launch (`orchestrator.declaredPhaseMachine`).
173
+ */
174
+ const PhaseRuleParams = Type.Object({
175
+ to: Type.String({ description: "Target phase id." }),
176
+ require: Type.Optional(
177
+ Type.Array(Type.String(), {
178
+ description: 'Conditions ANDed into this edge, e.g. ["contract_file","children_settled","verdict_ok","verification_passed"].',
179
+ }),
180
+ ),
181
+ actorRole: Type.Optional(Type.String({ description: "When set, only this actor role may take the edge." })),
182
+ rework: Type.Optional(Type.Boolean({ description: "Counts as a rework step (increments rework_count, capped by maxRework)." })),
183
+ notifyParent: Type.Optional(Type.Boolean({ description: "Notify the parent when taken." })),
184
+ });
185
+
186
+ /** One phase of a declared machine (#371). */
187
+ const PhaseParams = Type.Object({
188
+ id: Type.String({ description: "Phase id." }),
189
+ require: Type.Optional(
190
+ Type.Array(Type.String(), { description: "Conditions ANDed into every edge leaving this phase." }),
191
+ ),
192
+ next: Type.Optional(Type.Array(PhaseRuleParams, { description: "Allowed exits. A phase with none must be `terminal`." })),
193
+ timeoutMs: Type.Optional(Type.Number({ description: "Advisory clock only; never kills work." })),
194
+ terminal: Type.Optional(Type.Boolean({ description: "Terminal phases may not transition out." })),
195
+ });
196
+
197
+ /** A whole machine, with its entry phase and rework cap (#371). */
198
+ const PhaseMachineParams = Type.Object({
199
+ phases: Type.Array(PhaseParams),
200
+ start: Type.Optional(Type.String({ description: "Entry phase; defaults to the first declared phase." })),
201
+ maxRework: Type.Optional(Type.Integer({ minimum: 0 })),
202
+ });
203
+
204
+ /**
205
+ * #371 (m9): the schema above and the real machine types must not drift.
206
+ *
207
+ * `parsePhaseMachine` is the authority, but the model reads THIS schema: a field
208
+ * added to `PhaseDefinition` and forgotten here would be invisible to every
209
+ * caller, and a field offered here that the parser rejects would be advertised
210
+ * and then refused. Both are silent at runtime, so they are made loud at compile
211
+ * time.
212
+ *
213
+ * The comparison is against a LOOSENED copy of the real types, because the schema
214
+ * is deliberately wider in exactly two documented places: `require` arrives as
215
+ * raw strings (the validator resolves them into `PhaseCondition`s) and `next` may
216
+ * be omitted (a phase with no exits must be `terminal`, which the validator
217
+ * checks). `injected` is excluded for the same reason — it is the normalizer's
218
+ * marker, never the caller's.
219
+ */
220
+ type PhaseRuleSchema = Static<typeof PhaseRuleParams>;
221
+ type PhaseSchema = Static<typeof PhaseParams>;
222
+ type PhaseMachineSchema = Static<typeof PhaseMachineParams>;
223
+ type CallerRule = Omit<PhaseTransitionRule, "injected" | "require"> & { require?: string[] };
224
+ type CallerPhase = Omit<PhaseDefinition, "next" | "require" | "injected"> & { next?: CallerRule[]; require?: string[] };
225
+ type CallerMachine = Omit<PhaseMachine, "phases"> & { phases: CallerPhase[] };
226
+ /**
227
+ * Mutual assignability plus an exact KEY SET. Assignability alone would miss a
228
+ * property added as optional on one side (extra optional properties are legal to
229
+ * assign), which is precisely how a schema silently stops describing a field the
230
+ * parser requires.
231
+ */
232
+ type SameShape<A, B> = [A] extends [B]
233
+ ? [B] extends [A]
234
+ ? [keyof A] extends [keyof B]
235
+ ? [keyof B] extends [keyof A] ? true : never
236
+ : never
237
+ : never
238
+ : never;
239
+ const phaseRuleSchemaParity: SameShape<CallerRule, PhaseRuleSchema> = true;
240
+ const phaseSchemaParity: SameShape<CallerPhase, PhaseSchema> = true;
241
+ const phaseMachineSchemaParity: SameShape<CallerMachine, PhaseMachineSchema> = true;
242
+ // Referenced so `noUnusedLocals` cannot quietly delete the assertions above.
243
+ void phaseRuleSchemaParity;
244
+ void phaseSchemaParity;
245
+ void phaseMachineSchemaParity;
246
+
141
247
  const TaskItem = Type.Object({
142
248
  agent: Type.String({ description: "Name of the agent to invoke" }),
143
249
  task: Type.String({ description: "Task to delegate" }),
250
+ name: Type.Optional(
251
+ Type.String({
252
+ description:
253
+ "Explicit handle for THIS child (same grammar as the top-level `name`: [a-z][a-z0-9_-]{0,31}). Omit to auto-allocate `<role>-N`. Every handle in one tasks[] batch must be distinct. A one-entry batch may name its child here or at the call level, but not two different values. `vexillatio` allocates names itself and refuses this field.",
254
+ }),
255
+ ),
144
256
  cwd: Type.Optional(
145
257
  Type.String({
146
258
  description:
@@ -154,7 +266,7 @@ const TaskItem = Type.Object({
154
266
  worktree: Type.Optional(
155
267
  Type.Boolean({
156
268
  description:
157
- "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout. Reserved for `centurion` (#214): any other role asking for true is downgraded to false with a one-time warning.",
269
+ "Parent decides isolation for this child. true = own git branch, child delivers that branch (an MR when the repository has a remote). false = write the current checkout. Reserved for `centurion` (#214): any other role asking for true is downgraded to false with a one-time warning.",
158
270
  }),
159
271
  ),
160
272
  verify: Type.Optional(
@@ -172,6 +284,7 @@ const SubagentParams = Type.Object({
172
284
  Type.Literal("launch"),
173
285
  Type.Literal("continue"),
174
286
  Type.Literal("steer"),
287
+ Type.Literal("abort"),
175
288
  Type.Literal("resume"),
176
289
  Type.Literal("retire"),
177
290
  Type.Literal("status"),
@@ -181,7 +294,7 @@ const SubagentParams = Type.Object({
181
294
  Type.Literal("stop"),
182
295
  Type.Literal("diagnose"),
183
296
  ],
184
- { description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
297
+ { description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. A `stop` that reaches a child which already finished reports it as `already retired` / `Retired … already-settled` (nothing was interrupted), not as a failed stop. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
185
298
  ),
186
299
  ),
187
300
  agent: Type.Optional(
@@ -211,7 +324,7 @@ const SubagentParams = Type.Object({
211
324
  worktree: Type.Optional(
212
325
  Type.Boolean({
213
326
  description:
214
- "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Reserved for `centurion` (#214): only a centurion gets a subtree worktree, every other role is downgraded to false with a one-time warning — dispatch a centurion when you need an isolated delivery unit.",
327
+ "Parent decides isolation. true = isolated git branch, child delivers that branch (an MR when the repository has a remote). false = edit the current checkout. Reserved for `centurion` (#214): only a centurion gets a subtree worktree, every other role is downgraded to false with a one-time warning — dispatch a centurion when you need an isolated delivery unit.",
215
328
  }),
216
329
  ),
217
330
  verify: Type.Optional(
@@ -220,6 +333,35 @@ const SubagentParams = Type.Object({
220
333
  "Verification commands the extension runs itself in the child worktree at collect time (launch only). Each must exit 0; the first non-zero exit rejects the child's verdict as `rejected (verified-path)`. The worker does not need to paste test output.",
221
334
  }),
222
335
  ),
336
+ phases: Type.Optional(
337
+ Type.Union(
338
+ [
339
+ Type.Array(PhaseParams, {
340
+ description:
341
+ "A phase list, in pipeline order. The team is born in `start` (or the first entry).",
342
+ }),
343
+ PhaseMachineParams,
344
+ Type.String({
345
+ description:
346
+ 'One-line JSON for the same machine, or an indented list of " - id: <name>" entries.',
347
+ }),
348
+ ],
349
+ {
350
+ description:
351
+ "ROOT-ONLY, centurion-only, launch-only (#371). The team's state machine, declared by the root at dispatch time. " +
352
+ "Where a machine is declared the team walks it exactly as it did before; where none is, the centurion gets no phase, no `legion_phase` and no collection prompt — it just does the work and returns a verdict. " +
353
+ "So declare this only for a real multi-agent delivery; a one-file change belongs to a worker. " +
354
+ "A machine must be able to REACH a terminal phase (`done`/`failed`/`aborted`) or it is refused before anything is created. " +
355
+ 'Example — solo, no children required: `phases:[{id:"planning"},{id:"implementing",next:[{to:"done"}]},{id:"done",terminal:true}]`.',
356
+ },
357
+ ),
358
+ ),
359
+ start: Type.Optional(
360
+ Type.String({
361
+ description:
362
+ "Entry phase for a `phases` LIST (a machine object or JSON string carries its own `start`, which wins). Only meaningful with `phases`.",
363
+ }),
364
+ ),
223
365
  agentScope: Type.Optional(
224
366
  Type.Union(
225
367
  [Type.Literal("user"), Type.Literal("project"), Type.Literal("both")],
@@ -239,6 +381,12 @@ const SubagentParams = Type.Object({
239
381
  description: "wait: REQUIRED unless name is set — wait for every live (working/blocked) child; mutually exclusive with name. stop: stop every non-retired child; status: overview of all live (non-retired) children under cwd",
240
382
  }),
241
383
  ),
384
+ runId: Type.Optional(
385
+ Type.String({
386
+ description:
387
+ 'control actions (status/collect/stop/continue/resume/steer/abort/retire): name the run that holds the child, instead of the run this session owns. A name is resolved against this session\'s direct children first; a same-named node in another run is reported (as `not a direct child` or as an ambiguity), never silently adopted. Example: subagent({ action: "collect", name: "worker-0", runId: "r-4fd626ee" }).',
388
+ }),
389
+ ),
242
390
  timeoutMs: Type.Optional(
243
391
  Type.Number({
244
392
  description: "wait: per-child timeout in ms; event-driven completion returns early (default: role turnTimeoutMs)",
@@ -250,6 +398,12 @@ const SubagentParams = Type.Object({
250
398
  'stop only: when true, hard-retire via SIGTERM→SIGKILL + descendant sweep (retire hardening). Omit/false = graceful retire. Example: subagent({ action: "stop", name: "worker-0", force: true }).',
251
399
  }),
252
400
  ),
401
+ clearQueued: Type.Optional(
402
+ Type.Boolean({
403
+ description:
404
+ 'abort only: clear the child\'s queued steering/follow-up messages before interrupting it (default true). Omit for true. Example: subagent({ action: "abort", name: "worker-0" }).',
405
+ }),
406
+ ),
253
407
  message: Type.Optional(
254
408
  Type.String({ description: "Text for steer/continue" }),
255
409
  ),
@@ -330,10 +484,22 @@ interface LegionRuntimeIdentity {
330
484
  dbEventSource: DbEventSource;
331
485
  postman: LegionPostman;
332
486
  childNodeId(name: string): string | undefined;
487
+ /**
488
+ * #351: the phase machine a node's role card declares, by role. Carried on the
489
+ * identity (i.e. recorded when the tree is built) so every orchestrator this
490
+ * process constructs can resolve a custom phase name for a parent retire.
491
+ */
492
+ machineForRole?: (role: string | null | undefined) => PhaseMachine | undefined;
333
493
  }
334
494
 
495
+ /**
496
+ * #358 review (major 1): the defaults are filled, but the explicitness of every
497
+ * value travels with them — see `legionSettingsForRun`. A plain spread here tagged
498
+ * the built-in `phaseTimeouts` as operator decisions, and a card's `timeoutMs` on
499
+ * `planning`/`implementing`/… was silently ignored.
500
+ */
335
501
  function settingsForLegion(settings: ReturnType<typeof loadSubagentSettings>): LegionSettings {
336
- return { ...DEFAULT_LEGION_SETTINGS, ...(settings.legion ?? {}), phaseTimeouts: { ...DEFAULT_LEGION_SETTINGS.phaseTimeouts, ...(settings.legion?.phaseTimeouts ?? {}) } };
502
+ return legionSettingsForRun(settings);
337
503
  }
338
504
 
339
505
  function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
@@ -513,6 +679,157 @@ function phaseMachineForRole(role: string | null | undefined, cwd: string, setti
513
679
  }
514
680
  }
515
681
 
682
+ /**
683
+ * The phase machine for THIS node, declaration first (#371 contract §2).
684
+ *
685
+ * Resolution order, exactly as the contract freezes it:
686
+ *
687
+ * 1. the machine the root declared in this dispatch (`PI_LEGION_PHASES`)
688
+ * 2. the role card's `phases:`
689
+ * 3. neither — and then the node has NO machine, which is a different answer
690
+ * from "use the built-in one": no phase, no `legion_phase`, no collection
691
+ * prompt (invariant I2).
692
+ *
693
+ * Gated on the role because the environment is inherited: an in-process member
694
+ * literally shares its lead's `process.env`, and a worker must never adopt its
695
+ * lead's team machine as its own. A nested centurion cannot exist (G1), so the
696
+ * gate loses nothing.
697
+ *
698
+ * A pre-existing ledger row with a phase but no obtainable declaration keeps the
699
+ * card path and, failing that, no machine — the same degrade-don't-throw rule the
700
+ * card lookup above follows. The row's phase is still enough for `legion_phase`
701
+ * to stay registered (see `registerLegionTools`), so an in-flight team is never
702
+ * stranded by an upgrade.
703
+ */
704
+ function phaseMachineForNode(role: string | null | undefined, cwd: string, settings: SubagentsSettings): PhaseMachine | undefined {
705
+ if (role === "centurion") {
706
+ const declared = process.env[LEGION_PHASES_ENV];
707
+ if (declared) {
708
+ try {
709
+ const machine = parsePhaseMachine(declared, LEGION_PHASES_ENV);
710
+ if (machine) return machine;
711
+ } catch (error) {
712
+ console.warn(`[pi-legion] ignoring an unreadable ${LEGION_PHASES_ENV} declaration: ${error instanceof Error ? error.message : String(error)}`);
713
+ }
714
+ }
715
+ }
716
+ return phaseMachineForRole(role, cwd, settings);
717
+ }
718
+
719
+ /**
720
+ * #372 B: the machine a team declared INLINE at dispatch, as it was PERSISTED.
721
+ *
722
+ * `phaseMachineForRole` above answers "what does this role's card declare", and
723
+ * `phaseMachineForNode` answers it for a child process through the inherited
724
+ * `PI_LEGION_PHASES` environment. Neither can answer it for the `/legion` tree:
725
+ * the tree renders OTHER nodes, and since #371 the machine can be declared per
726
+ * DISPATCH (`subagent({ phases })`, contract §2) with no card involved — so the
727
+ * most explicitly configured teams were exactly the ones the panel could not
728
+ * describe.
729
+ *
730
+ * The durable record of that declaration is the child record the launch wrote
731
+ * (`ChildRecord.phases`, contract `legion-session-continuity-v1` §4), which is
732
+ * why this reads run.json rather than this process's `Orchestrator` snapshot:
733
+ * the snapshot dies with the launch call, while the panel must still answer after
734
+ * a reload, in a later process, and in a reopened session.
735
+ *
736
+ * The record lives in the run of the process that DISPATCHED the node, not in the
737
+ * node's own run, so this cannot read `store.readRun(node.runId)` — it is given
738
+ * the dispatch run when the caller knows it (`dispatchRunId`) and searches the
739
+ * registry only as a fallback.
740
+ *
741
+ * #372 M1: a bare node id is NOT an identity. Every run in a checkout contains a
742
+ * `root.team`, and `listRuns()` is oldest-first, so "the first run whose children
743
+ * mention this id" picked the OLDEST run — an old team's declaration would paint
744
+ * today's node, and a team that declared nothing today would inherit a machine
745
+ * from a run that ended long ago. Two rules follow, and both are load-bearing:
746
+ *
747
+ * 1. with `dispatchRunId`, ONLY that run answers (and "no record with `phases`"
748
+ * there means "declared nothing", not "look elsewhere");
749
+ * 2. without it, the registry answer is used only when exactly ONE run owns the
750
+ * id — ambiguity is a MISS, never a guess (the same convention
751
+ * `storedNodeForJob` uses for identities it cannot disambiguate).
752
+ *
753
+ * Only the team that declared the machine has a record; a worker never does, and
754
+ * nothing is inferred for it here (it inherits from its lead while rendering,
755
+ * #298).
756
+ */
757
+ export function inlinePhaseMachineForNode(
758
+ nodeId: string,
759
+ runs: readonly Pick<RunRecord, "runId" | "children">[],
760
+ dispatchRunId?: string | null,
761
+ ): PhaseMachine | undefined {
762
+ if (dispatchRunId) {
763
+ const dispatchRun = runs.find((run) => run.runId === dispatchRunId);
764
+ return dispatchRun?.children.find((child) => child.nodeId === nodeId)?.phases;
765
+ }
766
+ const owners = runs.filter((run) => run.children.some((child) => child.nodeId === nodeId));
767
+ if (owners.length !== 1) return undefined;
768
+ const [owner] = owners;
769
+ return owner?.children.find((child) => child.nodeId === nodeId)?.phases;
770
+ }
771
+
772
+ /**
773
+ * #372 B/M1/m4: the `/legion` tree's inline-declaration lookup.
774
+ *
775
+ * A factory rather than an inline closure so the plumbing is testable without
776
+ * booting the extension: the run lookup, the clock and the cwd are all injected.
777
+ * The review found the previous closure had NO test coverage at all — dropping the
778
+ * `parentRunId` argument or keying the cache by `node.id` alone kept every test
779
+ * green while the panel leaked another run's declaration.
780
+ *
781
+ * Three properties, each load-bearing:
782
+ *
783
+ * - **run-scoped cache keys** `(parentRunId, node.id)`. `node.id` repeats in
784
+ * every run of a checkout (`root.team`), so an id-only key serves one run's
785
+ * answer for another run's node — and, once cached, for the rest of the
786
+ * process (#372 M1).
787
+ * - **misses are cached, briefly.** A miss is a real answer ("this team declared
788
+ * nothing") and without caching it every repaint paid a runs-registry read per
789
+ * undeclared team (#372 m4). `MISS_TTL_MS` bounds both that cost and the one
790
+ * race the cache can lose: the node row exists a moment before `persistChild`
791
+ * writes the record, and the TTL is the same 1s the store itself uses for a
792
+ * foreign in-place `run.json` rewrite, so a miss observed in that window heals
793
+ * by itself instead of freezing until restart.
794
+ * - **the dispatching run is passed through** to `inlinePhaseMachineForNode`,
795
+ * which is the only thing that can pick the right record (#372 M1).
796
+ */
797
+ export function createInlineMachineLookup(input: {
798
+ /** This process's cwd, read at call time (it changes with the session). */
799
+ cwd: () => string | undefined;
800
+ /** The store for a checkout — injected so a test can count registry reads. */
801
+ runStoreFor: (cwd: string) => Pick<RunStore, "listRuns">;
802
+ now?: () => number;
803
+ }): (node: { id: string; role: string | null; parentRunId: string | null }) => PhaseMachine | undefined {
804
+ const positives = new Map<string, PhaseMachine>();
805
+ const misses = new Map<string, number>();
806
+ const now = input.now ?? Date.now;
807
+ const MISS_TTL_MS = 1_000;
808
+ return (node) => {
809
+ const key = `${node.parentRunId ?? "-"}\u0000${node.id}`;
810
+ const hit = positives.get(key);
811
+ if (hit) return hit;
812
+ const nowMs = now();
813
+ const missUntil = misses.get(key);
814
+ if (missUntil !== undefined && nowMs < missUntil) return undefined;
815
+ const cwd = input.cwd();
816
+ if (!cwd) return undefined;
817
+ let machine: PhaseMachine | undefined;
818
+ try {
819
+ machine = inlinePhaseMachineForNode(node.id, input.runStoreFor(cwd).listRuns(), node.parentRunId);
820
+ } catch {
821
+ // The panel must never break on an unreadable runs directory; the role
822
+ // lookup still runs, so a card-declared team keeps rendering.
823
+ machine = undefined;
824
+ }
825
+ if (machine) {
826
+ positives.set(key, machine);
827
+ misses.delete(key);
828
+ } else misses.set(key, nowMs + MISS_TTL_MS);
829
+ return machine;
830
+ };
831
+ }
832
+
516
833
  function createLegionRuntime(input: {
517
834
  pi: ExtensionAPI;
518
835
  supervisor: LegionSupervisor;
@@ -528,6 +845,8 @@ function createLegionRuntime(input: {
528
845
  registerTools: boolean;
529
846
  /** #298: the team root's declared phase machine, when its card supplies one. */
530
847
  machine?: PhaseMachine;
848
+ /** #351: by-role resolver for the abort path (see LegionRuntimeIdentity). */
849
+ machineForRole?: (role: string | null | undefined) => PhaseMachine | undefined;
531
850
  }): LegionRuntimeIdentity {
532
851
  const self = getNode(input.db, input.nodeId);
533
852
  const eventBus = input.eventBus;
@@ -545,6 +864,7 @@ function createLegionRuntime(input: {
545
864
  pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
546
865
  eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
547
866
  parentNodeId: input.nodeId, node, message: mail.text,
867
+ ...(input.machineForRole ? { machineForRole: input.machineForRole } : {}),
548
868
  }),
549
869
  });
550
870
  const identity: LegionRuntimeIdentity = {
@@ -564,6 +884,7 @@ function createLegionRuntime(input: {
564
884
  dbEventSource,
565
885
  postman,
566
886
  childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
887
+ ...(input.machineForRole ? { machineForRole: input.machineForRole } : {}),
567
888
  };
568
889
  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 } : {}) });
569
890
  postman.start();
@@ -623,8 +944,8 @@ function resolveLegionIdentity(input: {
623
944
  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 } });
624
945
  }
625
946
  if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
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 } : {}) });
947
+ const machine = phaseMachineForNode(node.role, input.cwd, input.settings);
948
+ 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 } : {}), machineForRole: (role) => phaseMachineForRole(role, input.cwd, input.settings) });
628
949
  // #273: `createLegionRuntime` runs during extension LOADING, where host
629
950
  // actions (sendMessage, spawn, ui) are not available yet. Defer the team
630
951
  // recovery to the next tick so a failure inside it cannot crash the load
@@ -680,22 +1001,207 @@ async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input:
680
1001
  }
681
1002
  }
682
1003
  try {
683
- await resumeLegionChild({ pi: input.input.pi, supervisor: input.input.supervisor, db: input.identity.db, dbPath: input.identity.dbPath, eventBus: input.identity.eventBus, settings: input.identity.settings, cwd: input.input.cwd, storeRootDir: input.identity.storeRootDir, parentNodeId: input.identity.nodeId, node, message: `Resume from saved session ${node.sessionFile} and continue interrupted work.` });
1004
+ await resumeLegionChild({ pi: input.input.pi, supervisor: input.input.supervisor, db: input.identity.db, dbPath: input.identity.dbPath, eventBus: input.identity.eventBus, settings: input.identity.settings, cwd: input.input.cwd, storeRootDir: input.identity.storeRootDir, parentNodeId: input.identity.nodeId, node, message: `Resume from saved session ${node.sessionFile} and continue interrupted work.`, ...(input.identity.machineForRole ? { machineForRole: input.identity.machineForRole } : {}) });
684
1005
  } catch (error) { notice(input.input.pi, `Could not resume ${node.id}: ${error instanceof Error ? error.message : String(error)}`); }
685
1006
  }
686
1007
  }
687
1008
 
688
- function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
689
- const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: currentProcessSpawnMode() } });
1009
+ function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus; rootSessionFile?: string | undefined }): LegionRuntimeIdentity {
1010
+ // #374 §2: this session may already own a tree. Reopening it (same run, same
1011
+ // ledger, same rows) is the whole point of the contract — a fresh run would
1012
+ // build a SHADOW tree whose `legion_tree`, phases and mail are all detached
1013
+ // from the ones the interrupted session left behind.
1014
+ const reopened = reopenSessionRun(input);
1015
+ if (reopened) return reopened;
1016
+ const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", ...(input.rootSessionFile ? { sessionFile: input.rootSessionFile } : {}), herdr: { supervisor: currentProcessSpawnMode() } });
690
1017
  const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
691
1018
  const db = openLegionDb(dbPath);
692
1019
  const now = Date.now();
693
- insertNode(db, { id: "root", parentId: null, name: "root", role: "root", kind: "pi", depth: 0, runId: rootRun.runId, status: "running", taskSummary: input.task.slice(0, 2_000), createdAt: now, updatedAt: now }, () => now);
1020
+ // #322/#338 M1: record THIS session's file on the root row. The postman's
1021
+ // DELIVERED evidence scan reads `nodes.session_file` per recipient, and the root
1022
+ // node is a recipient (`to: "parent"` from a top-level centurion) — without
1023
+ // this, every mail addressed to root sat at `ACCEPTED` forever. The child path
1024
+ // writes the same column from `reserveNode`; this is the root equivalent.
1025
+ insertNode(db, { id: "root", parentId: null, name: "root", role: "root", kind: "pi", depth: 0, runId: rootRun.runId, status: "running", taskSummary: input.task.slice(0, 2_000), ...(input.rootSessionFile ? { sessionFile: input.rootSessionFile } : {}), createdAt: now, updatedAt: now }, () => now);
694
1026
  appendEvent(db, "root", "node_launched", { runId: rootRun.runId, task: input.task }, { now: () => now });
695
- return createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath, nodeId: "root", rootRunId: rootRun.runId, runDir: input.store.runDir(rootRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: false });
1027
+ return createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath, nodeId: "root", rootRunId: rootRun.runId, runDir: input.store.runDir(rootRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: false, machineForRole: (role) => phaseMachineForRole(role, input.cwd, input.settings) });
1028
+ }
1029
+
1030
+ /**
1031
+ * #374 §1: the ONE session-identity judgement in this repo.
1032
+ *
1033
+ * Two processes belong to the same session iff their session transcript paths
1034
+ * are the same file (`path.resolve`). There is no session-id concept here and
1035
+ * none is invented — `ChildRecord.sessionId` is a dead field.
1036
+ *
1037
+ * A session with no transcript (`SessionManager.inMemory()` hands out
1038
+ * `undefined`) has NO identity: nothing is recorded, nothing is found, nothing
1039
+ * is resumed.
1040
+ */
1041
+ export function findSessionRun(store: RunStore, sessionFile: string | undefined): RunRecord | undefined {
1042
+ if (!sessionFile) return undefined;
1043
+ const target = path.resolve(sessionFile);
1044
+ let newest: RunRecord | undefined;
1045
+ for (const run of store.listRuns()) {
1046
+ if (typeof run.sessionFile !== "string") continue;
1047
+ if (path.resolve(run.sessionFile) !== target) continue;
1048
+ // `listRuns()` is oldest-first, but the ordering is the store's business:
1049
+ // compare explicitly so "the most recent run of this session" is true by
1050
+ // construction rather than by luck.
1051
+ if (!newest || run.createdAt >= newest.createdAt) newest = run;
1052
+ }
1053
+ return newest;
1054
+ }
1055
+
1056
+ /**
1057
+ * Reopen the ledger of THIS session's previous run (#374 §2).
1058
+ *
1059
+ * Returns `undefined` whenever the situation is not a clean reopen — no session
1060
+ * credential, no matching run, an unreadable/corrupt ledger, or a run whose root
1061
+ * row is gone. Every one of those degrades to "behave exactly like today" (a
1062
+ * new run), never to a throw: losing continuity must not cost the user a launch.
1063
+ *
1064
+ * Deliberately NOT written here: a new run record, a `node_launched` event, a
1065
+ * second root row (`insertNode` would throw `NodeNameConflictError`), or any
1066
+ * touch of the phase columns (§3 leaves them exactly where the pause left
1067
+ * them).
1068
+ */
1069
+ function reopenSessionRun(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; settings: ReturnType<typeof loadSubagentSettings>; eventBus: LegionEventBus; rootSessionFile?: string | undefined }): LegionRuntimeIdentity | undefined {
1070
+ const sessionFile = input.rootSessionFile;
1071
+ const previous = findSessionRun(input.store, sessionFile);
1072
+ if (!previous) return undefined;
1073
+ const dbPath = path.resolve(legionDbPath(input.store.runDir(previous.runId)));
1074
+ let db: DatabaseSync | undefined;
1075
+ try {
1076
+ db = openLegionDb(dbPath);
1077
+ if (!getNode(db, "root")) {
1078
+ // A ledger without its root row is not a tree we can hand back.
1079
+ db.close();
1080
+ return undefined;
1081
+ }
1082
+ } catch (error) {
1083
+ try { db?.close(); } catch { /* the open failed; nothing to release */ }
1084
+ console.warn(
1085
+ `[pi-legion] could not reopen the ledger of this session (${dbPath}): ${error instanceof Error ? error.message : String(error)}; starting a new run.`,
1086
+ );
1087
+ return undefined;
1088
+ }
1089
+ try {
1090
+ const now = Date.now();
1091
+ // The row exists: UPDATE it. `insertNode` would throw on the name conflict,
1092
+ // and a second root row is not a thing this schema can hold.
1093
+ updateNode(db, "root", { status: "running", ...(sessionFile ? { sessionFile: path.resolve(sessionFile) } : {}) }, () => now);
1094
+ // §2: "不新写 run.json(只更新 updatedAt)" — every other field of the
1095
+ // record is preserved verbatim, including the task the run was created for.
1096
+ input.store.writeRun(previous);
1097
+ } catch (error) {
1098
+ try { db.close(); } catch { /* nothing to release */ }
1099
+ console.warn(
1100
+ `[pi-legion] could not adopt the previous run ${previous.runId}: ${error instanceof Error ? error.message : String(error)}; starting a new run.`,
1101
+ );
1102
+ return undefined;
1103
+ }
1104
+ reopenedSessionLedgers.add(dbPath);
1105
+ return createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath, nodeId: "root", rootRunId: previous.runId, runDir: input.store.runDir(previous.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: false, machineForRole: (role) => phaseMachineForRole(role, input.cwd, input.settings) });
696
1106
  }
697
1107
 
698
- async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; db: DatabaseSync; dbPath: string; eventBus: LegionEventBus; settings: LegionSettings; cwd: string; storeRootDir: string; parentNodeId: string; node: LegionNode; message: string }): Promise<void> {
1108
+ /**
1109
+ * Read-only probe used by `before_agent_start` before this entry owns a ledger.
1110
+ *
1111
+ * Mirrors what `reopenSessionRun` will accept — same run lookup, same "the root
1112
+ * row must exist" gate — so the hint can never advertise continuity that the
1113
+ * adoption would then refuse. A corrupt or absent ledger degrades to "nothing to
1114
+ * report", never to a throw, and run.json is never written (invariant I1).
1115
+ */
1116
+ function probeInterruptedNodes(store: RunStore, sessionFile: string): LegionNode[] {
1117
+ const run = findSessionRun(store, sessionFile);
1118
+ if (!run) return [];
1119
+ const dbPath = path.resolve(legionDbPath(store.runDir(run.runId)));
1120
+ if (!fs.existsSync(dbPath)) return [];
1121
+ let db: DatabaseSync | undefined;
1122
+ try {
1123
+ db = openLegionDb(dbPath);
1124
+ return getNode(db, "root") ? interruptedChildren(db) : [];
1125
+ } catch {
1126
+ return [];
1127
+ } finally {
1128
+ try { db?.close(); } catch { /* probe is best-effort */ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * #374 §5: the children a previous exit left behind, i.e. the ones this session
1134
+ * may resume.
1135
+ *
1136
+ * Two ways a child counts as interrupted:
1137
+ * - it is still LIVE in the ledger (`starting`/`running`/`blocked`): its process
1138
+ * died with the session, so nothing will ever move it again;
1139
+ * - or it was paused by an exit: `retired` AND the last `node_retired` on it
1140
+ * carries `reason: "session-shutdown"` (§3 writes that). The reason matters —
1141
+ * a `retired` node without it was stopped deliberately (or finished), and
1142
+ * advertising it as "interrupted" would invite the agent to undo a user's
1143
+ * explicit stop.
1144
+ *
1145
+ * The phase is NOT part of the predicate: only teams that declare `phases:` get
1146
+ * one (#371), so requiring a non-null phase would hide exactly the most common
1147
+ * case — an interrupted worker. Resumability is checked separately by
1148
+ * `childFromNode`, which refuses `failed`/terminal-phase nodes only.
1149
+ *
1150
+ * Only DIRECT children are listed. A name is resolvable from here only if this
1151
+ * node is its parent (`subagent({action:"resume", name})` looks under the
1152
+ * caller), so advertising a grandchild would name an action that cannot work;
1153
+ * resuming the team brings its own squad back with it.
1154
+ */
1155
+ export function interruptedChildren(db: DatabaseSync, parentNodeId = "root"): LegionNode[] {
1156
+ const paused = new Set<string>();
1157
+ const rows = db
1158
+ .prepare(
1159
+ "SELECT node_id, data FROM events WHERE type = 'node_retired' AND id IN " +
1160
+ "(SELECT MAX(id) FROM events WHERE type = 'node_retired' GROUP BY node_id)",
1161
+ )
1162
+ .all() as Array<{ node_id: string; data: string | null }>;
1163
+ for (const row of rows) {
1164
+ let reason: unknown;
1165
+ try {
1166
+ reason = row.data ? (JSON.parse(row.data) as { reason?: unknown }).reason : undefined;
1167
+ } catch {
1168
+ reason = undefined;
1169
+ }
1170
+ if (reason === "session-shutdown") paused.add(row.node_id);
1171
+ }
1172
+ return getChildren(db, parentNodeId).filter((node) => {
1173
+ if (isTerminalNode(node) || !node.sessionFile) return false;
1174
+ if ((LIVE_CHILD_STATUSES as readonly string[]).includes(node.status)) return true;
1175
+ return node.status === "retired" && paused.has(node.id);
1176
+ });
1177
+ }
1178
+
1179
+ /** The frozen §5 wording. Quoted, not paraphrased: it is the rule the agent must read. */
1180
+ export const INTERRUPTED_NODES_NOTE =
1181
+ "These subagents were interrupted when the previous session exited.\n" +
1182
+ "Do NOT resume them on your own; ask the user first, then call\n" +
1183
+ "subagent({action:\"resume\", name}) for each one they approve.";
1184
+
1185
+ /** How many interrupted nodes the prompt lists before it starts counting. */
1186
+ export const INTERRUPTED_NODES_PROMPT_CAP = 10;
1187
+
1188
+ /**
1189
+ * The system-prompt section for {@link interruptedChildren}, or `""` when there
1190
+ * is nothing to report.
1191
+ *
1192
+ * Rendered fresh on every `before_agent_start`, so it DISAPPEARS by itself once
1193
+ * the last listed node has been resumed or dropped — the section is a view of
1194
+ * the ledger, never an accumulating log.
1195
+ */
1196
+ export function renderInterruptedSection(nodes: readonly Pick<LegionNode, "name" | "phase">[]): string {
1197
+ if (nodes.length === 0) return "";
1198
+ const shown = nodes.slice(0, INTERRUPTED_NODES_PROMPT_CAP).map((node) => `- ${node.name} · ${node.phase ?? "<none>"}`);
1199
+ const hidden = nodes.length - INTERRUPTED_NODES_PROMPT_CAP;
1200
+ if (hidden > 0) shown.push(`- …and ${hidden} more`);
1201
+ return `## Interrupted subagents\n\n${INTERRUPTED_NODES_NOTE}\n\n${shown.join("\n")}`;
1202
+ }
1203
+
1204
+ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; db: DatabaseSync; dbPath: string; eventBus: LegionEventBus; settings: LegionSettings; cwd: string; storeRootDir: string; parentNodeId: string; node: LegionNode; message: string; machineForRole?: (role: string | null | undefined) => PhaseMachine | undefined }): Promise<void> {
699
1205
  const parent = getNode(input.db, input.parentNodeId);
700
1206
  if (!parent || !input.node.runId || !input.node.sessionFile) throw new Error(`resume credentials missing for ${input.node.id}`);
701
1207
  const store = new RunStore({ rootDir: input.storeRootDir });
@@ -733,9 +1239,9 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
733
1239
  const childRun = store.readRun(input.node.runId);
734
1240
  if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
735
1241
  const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
736
- const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), launchQueue: launchQueueBounds(catalog.settings), onLaunchQueueNotice: launchQueueNoticeSink(input.pi), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
1242
+ const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), launchQueue: launchQueueBounds(catalog.settings), onLaunchQueueNotice: launchQueueNoticeSink(input.pi), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus, ...(input.machineForRole ? { machineForRole: input.machineForRole } : {}) } });
737
1243
  orchestrator.restore(parentRun);
738
- const handle = await orchestrator.launch({ agent, task: input.message, name: saved.name, ...(input.node.team ? { team: input.node.team } : {}), ...(saved.model ? { model: saved.model } : {}), worktree: Boolean(saved.worktreePath) });
1244
+ const handle = await orchestrator.launch({ agent, task: input.message, name: saved.name, ...(input.node.team ? { team: input.node.team } : {}), ...(saved.model ? { model: saved.model } : {}), ...(saved.phases ? { phases: saved.phases } : {}), worktree: Boolean(saved.worktreePath) });
739
1245
  await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
740
1246
  }
741
1247
 
@@ -784,6 +1290,16 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
784
1290
  const legionIdentityKey = (dbPath: string, nodeId: string): string => `${dbPath}\u0000${nodeId}`;
785
1291
  const legionIdentities = new Map<string, LegionRuntimeIdentity>();
786
1292
 
1293
+ /**
1294
+ * Ledgers THIS process reopened because the session had been here before (#374).
1295
+ *
1296
+ * The §5 prompt hint is scoped to them on purpose: a fresh tree has nothing
1297
+ * interrupted to report, and a tree adopted through `PI_LEGION_DB` env belongs to
1298
+ * a live or being-resumed session, not to a restart. Keyed by resolved ledger
1299
+ * path, like {@link legionIdentities}.
1300
+ */
1301
+ const reopenedSessionLedgers = new Set<string>();
1302
+
787
1303
  export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
788
1304
  // #197 D8: a session is a child if either transport says so. RPC children
789
1305
  // are separate processes marked in env; in-process children share the
@@ -1055,6 +1571,18 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1055
1571
  const pendingUtilityRetires: Array<(opts?: RetireOptions) => Promise<void>> = [];
1056
1572
  let sessionRuntime: SessionRuntime | undefined;
1057
1573
  const shutdownRetireAll = async (_event?: unknown, ctx?: { mode?: string }): Promise<void> => {
1574
+ // #374 §3: record what this exit interrupts BEFORE anything drains or kills.
1575
+ // `--print`/`--json` drain first, and a child whose turn ends during that drain
1576
+ // (including one the exit just killed) parses as a finished turn and would be
1577
+ // recorded `settled/success` — the interrupt would be lost. The marker only
1578
+ // writes the ledger, so a child that genuinely finishes during the drain still
1579
+ // ends up `settled` (measured in the container before this line existed).
1580
+ const own = ownLegionIdentity();
1581
+ if (own) {
1582
+ try {
1583
+ markSessionInterrupted(own.db, own.nodeId, () => Date.now());
1584
+ } catch { /* the ledger may already be closing; never block shutdown */ }
1585
+ }
1058
1586
  // #181: short --print exits before the deferred timer; flush now so
1059
1587
  // orphan rpc-child-* dirs still get swept on a clean exit.
1060
1588
  if (!isChild) flushHygieneIfPending();
@@ -1122,6 +1650,44 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1122
1650
  },
1123
1651
  });
1124
1652
  }
1653
+ /**
1654
+ * #374 §5: what THIS session's previous run left interrupted, for the prompt.
1655
+ *
1656
+ * Two sources, one predicate. Once this entry owns a ledger it is read live, so
1657
+ * a recovered or dropped node leaves the section on the very next turn. Before
1658
+ * that — which is the FIRST prompt of a reopened session, because the tree is
1659
+ * only adopted when a launch needs it — the same lookup `createRootLegion`
1660
+ * will use runs READ-ONLY: `findSessionRun` on this session's own transcript
1661
+ * path, then that run's ledger. Memoized, so one scan per session at most.
1662
+ *
1663
+ * A session with no transcript path has no identity (§1) and never looks at
1664
+ * anything; a different session's runs are never matched, so they are never
1665
+ * read, hinted at or touched (invariant I1).
1666
+ */
1667
+ let continuityProbe: { key: string; nodes: LegionNode[] } | undefined;
1668
+ /** Same startup, one notice (§5). Keyed like {@link continuityProbe}. */
1669
+ const continuityNotified = new Set<string>();
1670
+ const sessionContinuity = (ctx: Pick<ExtensionContext, "cwd"> & { sessionManager?: { getSessionFile?: () => string | undefined } } | undefined): { key: string; nodes: LegionNode[] } | undefined => {
1671
+ // A node THIS process is running right now is not interrupted, whatever its
1672
+ // row still says (`resume` flips it to `running`, which §5's status set
1673
+ // contains). Liveness is the one fact the ledger cannot answer, and without
1674
+ // it the section would keep nagging about a team that was just recovered.
1675
+ const interrupted = (nodes: LegionNode[]): LegionNode[] => nodes.filter((node) => !supervisor.isAlive(node.name));
1676
+ const own = ownLegionIdentity();
1677
+ if (own) {
1678
+ const key = path.resolve(own.dbPath);
1679
+ // Only a session that REOPENED its own run reports anything; a tree adopted
1680
+ // through env belongs to a session that is still live (or being resumed).
1681
+ return reopenedSessionLedgers.has(key) ? { key, nodes: interrupted(interruptedChildren(own.db)) } : undefined;
1682
+ }
1683
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1684
+ if (!sessionFile) return undefined;
1685
+ const key = path.resolve(sessionFile);
1686
+ if (continuityProbe?.key === key) return continuityProbe;
1687
+ const store = runStoreFor(ctx?.cwd ?? lastCwd);
1688
+ continuityProbe = { key, nodes: interrupted(probeInterruptedNodes(store, key)) };
1689
+ return continuityProbe;
1690
+ };
1125
1691
  pi.on("before_agent_start", (event, ctx) => {
1126
1692
  if (process.env[UTILITY_CHILD_ENV] === "1") return;
1127
1693
  if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
@@ -1143,12 +1709,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1143
1709
  const rosterAgents = selfRole
1144
1710
  ? catalog.agents.filter((agent) => agent.name !== selfRole)
1145
1711
  : catalog.agents;
1712
+ // #374 §5: the same session coming back finds its paused children named here.
1713
+ // The section is derived from the ledger on every prompt, so it vanishes by
1714
+ // itself once nothing is left to resume (idempotent, never accumulating).
1715
+ const continuity = sessionContinuity(ctx);
1716
+ const interrupted = continuity ? renderInterruptedSection(continuity.nodes) : "";
1717
+ if (continuity && continuity.nodes.length > 0) {
1718
+ // One notice per STARTUP, not per turn. Keyed by the session transcript when
1719
+ // there is one, because the same session is described by two different
1720
+ // handles over its life (the read-only probe before adoption, the live
1721
+ // ledger after it) and both must count as "the same startup".
1722
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1723
+ const noticeKey = sessionFile ? path.resolve(sessionFile) : continuity.key;
1724
+ if (!continuityNotified.has(noticeKey)) {
1725
+ continuityNotified.add(noticeKey);
1726
+ const count = continuity.nodes.length;
1727
+ try {
1728
+ ctx?.ui?.notify?.(
1729
+ `pi-legion: ${count} subagent${count === 1 ? "" : "s"} from this session were interrupted when it exited — nothing was restarted. Ask me to resume the ones you want.`,
1730
+ "info",
1731
+ );
1732
+ } catch {
1733
+ // no UI (print mode / headless): the prompt section still carries the fact
1734
+ }
1735
+ }
1736
+ }
1146
1737
  return {
1147
1738
  systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
1148
1739
  rosterAgents,
1149
1740
  catalog.team,
1150
1741
  catalog.teamWarnings,
1151
- )}${parentContext ? `\n\n${parentContext}` : ""}`,
1742
+ )}${parentContext ? `\n\n${parentContext}` : ""}${interrupted ? `\n\n${interrupted}` : ""}`,
1152
1743
  };
1153
1744
  });
1154
1745
  if (process.env[UTILITY_CHILD_ENV] === "1") {
@@ -1200,6 +1791,13 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1200
1791
  return machine;
1201
1792
  };
1202
1793
  })(),
1794
+ // #372 B: /legion must show the machine an inline-declared team was
1795
+ // DISPATCHED with (`subagent({ phases })`, #371 §2). That declaration has no
1796
+ // card to look up, so it is answered per NODE from the dispatching run's
1797
+ // run.json — the one source that survives a reload, a later process and a
1798
+ // reopened session. The caching and the run plumbing live in the factory so
1799
+ // they can be tested without booting the extension (#372 review, minor 1).
1800
+ getPhaseMachineForNode: createInlineMachineLookup({ cwd: () => lastCwd, runStoreFor }),
1203
1801
  getTreeIdentity: (name) => {
1204
1802
  const own = ownLegionIdentity();
1205
1803
  const node = own && getChildByName(own.db, own.nodeId, name);
@@ -1365,7 +1963,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1365
1963
  // uses: `alive` is this process's in-memory registry and never holds a
1366
1964
  // child launched elsewhere, so a foreign record needs the owner marker.
1367
1965
  // Undefined while alive — liveness already wins there.
1368
- const owner = ownerVerdictFor(store, runs, child.name, alive);
1966
+ const owner = ownerVerdictFor(store, () => runs, child.name, alive);
1967
+ // #315/#331: same display-only activity fact `subagent status` prints,
1968
+ // so the slash command cannot drift from the tool surface.
1969
+ const inFlightTools = inFlightToolStackOf(supervisor, child.name);
1369
1970
  return {
1370
1971
  name: child.name,
1371
1972
  state: child.state,
@@ -1381,6 +1982,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1381
1982
  ...(child.spawnMode ? { spawnMode: child.spawnMode } : {}),
1382
1983
  alive,
1383
1984
  ...(owner ? { owner } : {}),
1985
+ ...(inFlightTools ? { inFlightTools } : {}),
1384
1986
  };
1385
1987
  } catch {
1386
1988
  return undefined;
@@ -1515,7 +2117,15 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1515
2117
  activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: ownLegionIdentity(), sessionFile: ctx.sessionManager?.getSessionFile() });
1516
2118
  if (!activeLegion && action === "launch" && !isChild) {
1517
2119
  const plan = buildPlan(params);
1518
- if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
2120
+ if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus, rootSessionFile: ctx.sessionManager?.getSessionFile() });
2121
+ } else if (!activeLegion && !isChild && (action === "resume" || action === "continue")) {
2122
+ // #374 §4/§7-2: a resume is the ONE control action that must act on the
2123
+ // reopened tree. Without the adoption `input.legion.nodeId` is unknown to
2124
+ // the launcher and the child would restart OUTSIDE the ledger — visibly
2125
+ // running while its `nodes` row stayed `retired`. Reopen-ONLY: when this
2126
+ // session owns no previous run the call falls through to today's behaviour
2127
+ // (no tree is invented, nothing is written).
2128
+ activeLegion = reopenSessionRun({ pi, supervisor, store, cwd, settings, eventBus: legionBus, rootSessionFile: ctx.sessionManager?.getSessionFile() });
1519
2129
  }
1520
2130
  if (activeLegion) adoptLegionIdentity(activeLegion);
1521
2131
  } catch (error) {
@@ -1549,7 +2159,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1549
2159
  legion: activeLegion,
1550
2160
  });
1551
2161
  if (signal?.aborted && action !== "stop") {
1552
- return ok("interrupted (parent turn ended; children still running)");
2162
+ return interruptedControlResult({ result, runtime, legion: activeLegion });
1553
2163
  }
1554
2164
  return result;
1555
2165
  }
@@ -1601,12 +2211,28 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1601
2211
  });
1602
2212
 
1603
2213
  const bindUi = (_event: unknown, ctx: ExtensionContext): void => {
1604
- lastUi = ctx.hasUI ? ctx.ui : undefined;
1605
- if (ctx.cwd) lastCwd = ctx.cwd;
2214
+ // #377: `session.dispose()` invalidates the extension ctx, but the run's
2215
+ // `finally` still dispatches `turn_end` / `agent_settled`. Reading `hasUI` on
2216
+ // that ctx throws, and the host prints "Extension error (/plugin/index.ts)" at
2217
+ // every exit. The board already treats staleness as "no UI"; the handler entry
2218
+ // has to answer the same way instead of throwing.
2219
+ let ui: ExtensionContext["ui"] | undefined;
2220
+ let hasUi = false;
2221
+ try {
2222
+ hasUi = ctx.hasUI;
2223
+ ui = hasUi ? ctx.ui : undefined;
2224
+ if (ctx.cwd) lastCwd = ctx.cwd;
2225
+ } catch (error) {
2226
+ if (!isStaleUiError(error)) throw error;
2227
+ lastUi = undefined;
2228
+ lastConfirm = undefined;
2229
+ return;
2230
+ }
2231
+ lastUi = ui;
1606
2232
  runtime.bind(ctx);
1607
2233
  lastConfirm =
1608
- ctx.hasUI && typeof ctx.ui?.confirm === "function"
1609
- ? (message) => ctx.ui.confirm("", message)
2234
+ hasUi && typeof ui?.confirm === "function"
2235
+ ? (message) => ui!.confirm("", message)
1610
2236
  : undefined;
1611
2237
  };
1612
2238
 
@@ -1667,9 +2293,21 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1667
2293
  pi.on("turn_start", (event, ctx) => {
1668
2294
  bindUi(event, ctx);
1669
2295
  });
1670
- pi.on("turn_end", (event, ctx) => {
1671
- bindUi(event, ctx);
1672
- });
2296
+ // Deliberately NOT `pi.on("turn_end", ...)`.
2297
+ //
2298
+ // The host's turn-end boundary (`_dispatchTurnEndBoundary`) only exists to feed
2299
+ // `turn_end` handlers, and it is also where it emits "turn_end could not resolve
2300
+ // the persisted assistant entry ID" — a message it can only produce when
2301
+ // `hasHandlers("turn_end")` is true, because the gate is checked before the
2302
+ // missing-entry case. A turn that ends after `dispose()` aborted it (session
2303
+ // exit with a wake turn in flight, `notice()`'s `triggerTurn`) is exactly that
2304
+ // case, and no amount of care on our side persists that message.
2305
+ //
2306
+ // Registering `turn_end` bought us one extra `bindUi`, and `bindUi` already runs
2307
+ // on turn_start, agent_start, agent_end, agent_settled, input, tool_result and
2308
+ // session_info_changed — the next event re-binds the current ctx anyway. Dropping
2309
+ // it removes the host's only reason to run the boundary, so the line is never
2310
+ // printed. If someone needs a turn-scoped refresh later, use `turn_start`.
1673
2311
  pi.on("agent_start", (event, ctx) => {
1674
2312
  runtime.notifyParentRunStart();
1675
2313
  bindUi(event, ctx);
@@ -1753,6 +2391,14 @@ export function loadCatalog(
1753
2391
  };
1754
2392
  }
1755
2393
 
2394
+ /**
2395
+ * #384: the inbox the blocking tools watch, or `undefined` when this call is not a
2396
+ * legion node (a plain session keeps `wait`/`collect` exactly as they were — I1).
2397
+ */
2398
+ function mailSeamOf(legion: OrchestratorLegionContext | undefined): MailReleaseSeam | undefined {
2399
+ return legion ? { db: legion.db, nodeId: legion.nodeId } : undefined;
2400
+ }
2401
+
1756
2402
  /**
1757
2403
  * Act on an EXISTING child: status, steer, collect, retire, continue, resume.
1758
2404
  *
@@ -1806,6 +2452,7 @@ async function controlAction(input: {
1806
2452
  allAgents: input.allAgents,
1807
2453
  store,
1808
2454
  cwd,
2455
+ mail: mailSeamOf(input.legion),
1809
2456
  });
1810
2457
  }
1811
2458
 
@@ -1816,34 +2463,50 @@ async function controlAction(input: {
1816
2463
  params,
1817
2464
  runtime: input.runtime,
1818
2465
  store: input.store,
1819
- retire: async (name, opts) => {
2466
+ // #312: the run this call is scoped to — the explicit `runId`, else the
2467
+ // run this session owns. `stopAction` resolves the name inside it only.
2468
+ runId: params.runId,
2469
+ sessionRunId: input.legion?.runId,
2470
+ retire: async (name, opts, scope) => {
1820
2471
  const merged = opts ?? retireOpts;
1821
2472
  const job = input.runtime.get(name);
1822
- if (job?.retire) {
2473
+ // #312: a live job is a node of exactly ONE run. Retire it only when the
2474
+ // scope agrees (or the caller named no run) — a same-named job in another
2475
+ // run is a different node, and stopping it would retire a child that was
2476
+ // never reported.
2477
+ if (job?.retire && (scope?.runId === undefined || job.runId === scope.runId)) {
1823
2478
  await job.retire(merged);
1824
2479
  return;
1825
2480
  }
1826
- // A completion watch can retire/release a target after stop snapshots
1827
- // all names but before the serial loop reaches it. Treat that target as
1828
- // stopped; only rebuild if its persisted record is not already retired.
1829
- const found = findChild(input.store.listRuns(), name);
1830
- if (!found) throw unknownChildError(name);
1831
- if (found.child.state === "retired") return;
2481
+ // The persisted record is read in the SAME run `stopAction` resolved the
2482
+ // name in — never re-resolved across runs (`pickChildByName` prefers a live
2483
+ // then the newest record), which is how `stop` came to REPORT this session's
2484
+ // `worker-0` and RETIRE another run's live one.
2485
+ //
2486
+ // A completion watch can retire/release a target after stop snapshots all
2487
+ // names but before the serial loop reaches it. Treat a vanished or already
2488
+ // retired target as stopped; only rebuild the orchestrator when there is
2489
+ // still something to retire.
2490
+ const runId = scope?.runId;
2491
+ const run = runId ? input.store.readRun(runId) : null;
2492
+ const child = run?.children.find((candidate) => candidate.name === name);
2493
+ if (!runId || !run || !child) throw unknownChildError(name);
2494
+ if (child.state === "retired") return;
1832
2495
  const orchestrator = new Orchestrator({
1833
2496
  supervisor: input.supervisor,
1834
2497
  uiProxy: input.uiProxy,
1835
- runDir: input.store.runDir(found.runId),
2498
+ runDir: input.store.runDir(runId),
1836
2499
  cwd: input.cwd,
1837
2500
  maxDepth: input.settings.legion?.maxDepth,
1838
2501
  childContext: resolveTeamContext("child", input.settings, input.team),
1839
2502
  ...teamForChildren(input.team),
1840
2503
  ...rpcApproveFrom(input.settings),
1841
- ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: input.store } } : {}),
2504
+ ...(input.legion ? { legion: { ...input.legion, runId, runStore: input.store } } : {}),
1842
2505
  onBudgetRefused: input.onBudgetRefused,
1843
2506
  });
1844
- orchestrator.restore(found.run);
2507
+ orchestrator.restore(run);
1845
2508
  await orchestrator.retire(name, merged);
1846
- await persistChild(input.store, found.runId, orchestrator, name);
2509
+ await persistChild(input.store, runId, orchestrator, name);
1847
2510
  },
1848
2511
  });
1849
2512
  }
@@ -1861,42 +2524,94 @@ async function controlAction(input: {
1861
2524
  // Liveness facts are read once per name and reused by both the filter and
1862
2525
  // the block, so `status` cannot classify a child one way and render it
1863
2526
  // another (display-liveness D6 exit 1).
1864
- const factsByName = new Map<string, StatusLivenessFacts & { pid?: number }>();
1865
- const factsFor = (child: StatusChildRecord): StatusLivenessFacts & { pid?: number } => {
2527
+ const factsByName = new Map<string, StatusLivenessFacts & { pid?: number; inFlightTools?: InFlightToolStack }>();
2528
+ const factsFor = (child: StatusChildRecord): StatusLivenessFacts & { pid?: number; inFlightTools?: InFlightToolStack } => {
1866
2529
  const cached = factsByName.get(child.name);
1867
2530
  if (cached) return cached;
1868
2531
  const alive = input.supervisor.isAlive(child.name);
1869
- const owner = ownerVerdictFor(store, runs, child.name, alive);
2532
+ const owner = ownerVerdictFor(store, () => runs, child.name, alive);
1870
2533
  // A pid only exists for a child this process actually tracks; a
1871
2534
  // record from another session has none, and none is invented.
1872
2535
  const pid = input.runtime.get(child.name)?.pid;
1873
- const facts = { alive, ...(owner ? { owner } : {}), ...(pid ? { pid } : {}) };
2536
+ // #315/#331: same rule for the in-flight stack — only this process's
2537
+ // live backend can answer, and `undefined` renders no line.
2538
+ const inFlightTools = inFlightToolStackOf(input.supervisor, child.name);
2539
+ const facts = {
2540
+ alive,
2541
+ ...(owner ? { owner } : {}),
2542
+ ...(pid ? { pid } : {}),
2543
+ ...(inFlightTools ? { inFlightTools } : {}),
2544
+ };
1874
2545
  factsByName.set(child.name, facts);
1875
2546
  return facts;
1876
2547
  };
1877
2548
  const classified = classifyChildrenForStatus(runs, factsFor);
2549
+ const now = Date.now();
1878
2550
  return ok(
1879
2551
  renderStatusOverview(
1880
2552
  classified.map(({ child, live }) => {
1881
- const { pid, ...facts } = factsFor(child);
1882
- return { child, ...facts, ...(pid ? { pid } : {}), live };
2553
+ const { pid, inFlightTools, ...facts } = factsFor(child);
2554
+ return {
2555
+ child,
2556
+ ...facts,
2557
+ ...(pid ? { pid } : {}),
2558
+ ...(inFlightTools ? { inFlightTools } : {}),
2559
+ live,
2560
+ };
1883
2561
  }),
2562
+ now,
1884
2563
  ),
1885
2564
  );
1886
2565
  }
1887
- const runs = store.listRuns();
2566
+ // #344: the registry scan is a FALLBACK, not an entry fee. A name this
2567
+ // session is scoped to resolves from one `readRun`; the scan only happens
2568
+ // when the answer depends on other runs.
2569
+ const directRunId = scopeRunIdFor({
2570
+ runId: params.runId,
2571
+ sessionRunId: input.legion?.runId,
2572
+ trackedRunId: input.runtime.get(params.name)?.runId,
2573
+ });
2574
+ const scope = resolveScopedChild({
2575
+ name: params.name,
2576
+ explicitRunId: params.runId,
2577
+ directRunId,
2578
+ readRun: (runId) => store.readRun(runId),
2579
+ listRuns: () => store.listRuns(),
2580
+ });
2581
+ const hit = scope.hit;
2582
+ // #312: `status` is the one action that may SHOW a node it does not own, but
2583
+ // two same-named nodes are an ambiguity it must not resolve by preference —
2584
+ // that preference is how `status` and `collect` came to disagree.
2585
+ if (hit.kind === "elsewhere" && hit.candidates.length > 1) {
2586
+ return fail(ambiguousChildMessage("status", params.name, hit.candidates), ErrorCodes.NOT_FOUND);
2587
+ }
1888
2588
  // #270: a miss in this store is not a miss overall — a worktree child's
1889
2589
  // record lives under its own store, which this one cannot enumerate.
1890
- const found = findChild(runs, params.name) ?? findChildAcrossStores({
2590
+ const found = scope.found ?? findChildAcrossStores({
1891
2591
  name: params.name,
1892
2592
  cwd,
1893
2593
  callerCwd: input.callerCwd,
1894
2594
  ownRoot: store.rootDir,
1895
2595
  });
1896
2596
  if (!found) return unknownChild(params.name, store, cwd, input.callerCwd);
2597
+ // #312: when the record is not this session's own child, say which run it
2598
+ // came from. A direct hit adds nothing — the existing output is unchanged.
2599
+ //
2600
+ // Only an `elsewhere` hit is a foreign record. `absent` means the name is in
2601
+ // no run this store can enumerate and `found` came from the #270 cross-store
2602
+ // lookup — often a child of THIS session whose worktree store is elsewhere,
2603
+ // which this note would have mislabelled as "not a direct child".
2604
+ const resolvedNote =
2605
+ hit.kind === "elsewhere"
2606
+ ? `resolved: ${params.name} is not a direct child of this session — showing the record from run ${found.runId}`
2607
+ : undefined;
1897
2608
  const alive = input.supervisor.isAlive(params.name);
1898
- const owner = ownerVerdictFor(store, runs, params.name, alive);
1899
- return ok(renderLiveChildStatus({
2609
+ // #312: the owner verdict belongs to the run dir of the record just resolved
2610
+ // (`found.runId`) — not to whatever `pickChildByName` prefers across runs.
2611
+ const owner = ownerVerdictFor(store, scope.runs, params.name, alive, found.runId);
2612
+ // #315: what it is doing and for how long, prepended to the block when this
2613
+ // process is actually running a tool for it.
2614
+ const snapshot = renderLiveChildStatus({
1900
2615
  name: found.child.name,
1901
2616
  state: found.child.state,
1902
2617
  agent: found.child.agent,
@@ -1907,7 +2622,10 @@ async function controlAction(input: {
1907
2622
  spawnMode: found.child.spawnMode,
1908
2623
  alive,
1909
2624
  ...(owner ? { owner } : {}),
1910
- }));
2625
+ });
2626
+ const inFlightTools = inFlightToolStackOf(input.supervisor, found.child.name);
2627
+ const rendered = withInFlightToolLine(snapshot, inFlightTools, Date.now());
2628
+ return ok(resolvedNote ? `${resolvedNote}\n${rendered}` : rendered);
1911
2629
  }
1912
2630
 
1913
2631
  if (!params.name) {
@@ -1917,8 +2635,45 @@ async function controlAction(input: {
1917
2635
  );
1918
2636
  }
1919
2637
 
2638
+ // #344: as in `status`, the full registry is the FALLBACK. Resolving a name
2639
+ // this call is already scoped to costs one `readRun` (0.05ms) instead of a
2640
+ // scan of every run ever written (26ms at 352 runs).
2641
+ const directRunId = scopeRunIdFor({
2642
+ runId: params.runId,
2643
+ sessionRunId: input.legion?.runId,
2644
+ trackedRunId: input.runtime.get(params.name)?.runId,
2645
+ });
2646
+ const scope = resolveScopedChild({
2647
+ name: params.name,
2648
+ explicitRunId: params.runId,
2649
+ directRunId,
2650
+ readRun: (runId) => store.readRun(runId),
2651
+ listRuns: () => store.listRuns(),
2652
+ });
2653
+ const hit = scope.hit;
2654
+ if (hit.kind === "elsewhere") {
2655
+ // #312: never guess between two same-named nodes, whichever action asked.
2656
+ if (hit.candidates.length > 1) {
2657
+ return fail(ambiguousChildMessage(action, params.name, hit.candidates), ErrorCodes.NOT_FOUND);
2658
+ }
2659
+ // `collect`/`continue` are defined over this session's direct children only,
2660
+ // so a same-named node elsewhere is a refusal that names the candidates —
2661
+ // not the old silent adoption of whatever `pickChildByName` preferred.
2662
+ //
2663
+ // Only when a scope EXISTS: a session with no run of its own has no direct
2664
+ // children to protect, and #209-3 keeps that path reaching a foreign/
2665
+ // restarted run's persisted record (the pre-existing contract tests build
2666
+ // exactly that store, with no launch in this session at all). The bug #312
2667
+ // describes needs a session run whose name resolution was overridden.
2668
+ if (directRunId !== undefined && STRICT_SCOPE_ACTIONS.has(action)) {
2669
+ return fail(notDirectChildMessage(action, params.name, hit.candidates), ErrorCodes.NOT_FOUND);
2670
+ }
2671
+ }
2672
+ // `steer`/`abort`/`retire` keep their pre-#312 reach (one unambiguous record
2673
+ // outside this session's run stays addressable); the scope run is still tried
2674
+ // first, so the common case never depends on the fallback.
1920
2675
  const found =
1921
- findChild(store.listRuns(), params.name) ??
2676
+ scope.found ??
1922
2677
  findChildAcrossStores({
1923
2678
  name: params.name,
1924
2679
  cwd,
@@ -1952,7 +2707,8 @@ async function controlAction(input: {
1952
2707
  };
1953
2708
 
1954
2709
  if (action === "steer") return steerChild(ctx);
1955
- if (action === "collect") return collectChild(ctx, input.signal);
2710
+ if (action === "abort") return abortChild(ctx);
2711
+ if (action === "collect") return collectChild(ctx, input.signal, mailSeamOf(input.legion));
1956
2712
  if (action === "retire") return retireChild(ctx);
1957
2713
  if (action === "continue" || action === "resume") return reviveChild(ctx);
1958
2714
 
@@ -2015,37 +2771,315 @@ function unknownChild(
2015
2771
  );
2016
2772
  }
2017
2773
 
2018
- /** Send a message to a running child without waiting for its reply. */
2019
- async function steerChild(
2774
+ /**
2775
+ * The run a named control call is scoped to (#312).
2776
+ *
2777
+ * Three sources, in the order the caller can be sure of them:
2778
+ *
2779
+ * 1. `runId` on the call itself — the explicit scope, which wins over every
2780
+ * inference (that is the escape hatch the refusal messages point at);
2781
+ * 2. the run this session owns, from its legion identity: every child this
2782
+ * session launched outside a worktree record belongs here;
2783
+ * 3. the run the runtime is currently tracking the name under — the window
2784
+ * where no legion identity resolves (a reloaded extension, a resumed run)
2785
+ * but the child is still live and known to this process.
2786
+ *
2787
+ * `undefined` is a real answer: no scope means nothing is a direct child, so a
2788
+ * same-named record elsewhere is reported instead of silently adopted.
2789
+ */
2790
+ function scopeRunIdFor(input: {
2791
+ runId?: string | undefined;
2792
+ sessionRunId?: string | undefined;
2793
+ trackedRunId?: string | undefined;
2794
+ }): string | undefined {
2795
+ return input.runId ?? input.sessionRunId ?? input.trackedRunId;
2796
+ }
2797
+
2798
+ /** Actions that may only ever act on this session's own direct child (#312). */
2799
+ const STRICT_SCOPE_ACTIONS = new Set(["collect", "continue", "resume"]);
2800
+
2801
+ /**
2802
+ * Two or more same-named nodes and not one of them is this session's child.
2803
+ *
2804
+ * Never resolved by preference: the incident behind #312 is exactly what a
2805
+ * "prefer the live one, then the newest" tie-break produces. The candidates are
2806
+ * printed so the caller can name the run it meant.
2807
+ */
2808
+ function ambiguousChildMessage(
2809
+ action: string,
2810
+ name: string,
2811
+ candidates: readonly ChildScopeCandidate[],
2812
+ ): string {
2813
+ return (
2814
+ `${name} is ambiguous: ${candidates.length} nodes carry that name and none of them is a direct child of this session, so \`${action}\` will not guess.\n` +
2815
+ describeChildScopeCandidates(candidates) +
2816
+ "\nPass `runId` to name the run that holds the one you mean."
2817
+ );
2818
+ }
2819
+
2820
+ /**
2821
+ * Refuse an action whose contract is "this session's direct child" (#312).
2822
+ *
2823
+ * `collect` used to reach into whatever run `pickChildByName` preferred, which
2824
+ * is how a parent chasing its own FAILED `worker-0` was handed another run's
2825
+ * SUCCESSFUL one — the silent wrong node. The candidates are named so the caller
2826
+ * can either pass `runId` or go through the node's parent instead.
2827
+ */
2828
+ function notDirectChildMessage(
2829
+ action: string,
2830
+ name: string,
2831
+ candidates: readonly ChildScopeCandidate[],
2832
+ ): string {
2833
+ return (
2834
+ `${name} is not a direct child of this session, so \`${action}\` cannot act on it. ` +
2835
+ `${candidates.length} node(s) carry that name:\n` +
2836
+ describeChildScopeCandidates(candidates) +
2837
+ `\n\`${action}\` only acts on this session's direct children; pass \`runId\`, or address the node through its parent's scope, to reach a grandchild.`
2838
+ );
2839
+ }
2840
+
2841
+ /**
2842
+ * The parenthetical a queued steer carries (#313 D5).
2843
+ *
2844
+ * One constant on purpose: `steerReceipt` renders it for the `steer` tool and
2845
+ * the revive/continue branch reuses it for a live child, so the queue wording
2846
+ * exists exactly once.
2847
+ */
2848
+ export const STEER_QUEUED_NOTE =
2849
+ "queued — it takes effect after its current tool batch finishes";
2850
+
2851
+ /** The same, for a steer an input handler swallowed before it was ever queued. */
2852
+ export const STEER_INTERCEPTED_NOTE =
2853
+ "intercepted by an input handler — the child never saw it";
2854
+
2855
+ /**
2856
+ * Render the receipt for one accepted `steer` (#313 D5).
2857
+ *
2858
+ * The only place a steer receipt is built. The three dispositions are the only
2859
+ * three things a caller can honestly claim, and there is no "delivered":
2860
+ *
2861
+ * - `queued`: Pi consumes a steer after the child's current tool batch finishes
2862
+ * and before its next LLM call, so a message sent into a running tool says
2863
+ * nothing about the model having seen it yet. Not an error, and it must not
2864
+ * read like one — but it must not read as success either.
2865
+ * - `accepted`: the child was idle, so this call started a turn with the text.
2866
+ * The model still has to read it; this only says the session took the turn.
2867
+ * - `intercepted`: an input handler ate the text before the queue. The child
2868
+ * never sees it, so the receipt must not open with "Steered".
2869
+ */
2870
+ export function steerReceipt(
2871
+ name: string,
2872
+ disposition: SteerDisposition,
2873
+ ): string {
2874
+ if (disposition === "intercepted") {
2875
+ return `Steer ${name} was ${STEER_INTERCEPTED_NOTE}`;
2876
+ }
2877
+ if (disposition === "accepted") {
2878
+ return `Steered ${name} (a turn was started with it)`;
2879
+ }
2880
+ return `Steered ${name} (${STEER_QUEUED_NOTE})`;
2881
+ }
2882
+
2883
+ /**
2884
+ * Send a message to a running child without waiting for its reply.
2885
+ *
2886
+ * Exported so `test/unit/steer-receipt.test.ts` can drive the receipt end to
2887
+ * end (same reason as `waitAction`/`stopAction`).
2888
+ */
2889
+ export async function steerChild(
2020
2890
  ctx: ChildContext,
2021
2891
  ): Promise<AgentToolResult<unknown>> {
2022
2892
  if (!ctx.params.message) {
2023
2893
  return fail("`message` is required for steer.", ErrorCodes.INVALID_PARAMS);
2024
2894
  }
2025
2895
  try {
2026
- await ctx.orchestrator.steer(ctx.name, ctx.params.message);
2896
+ // Await into a local so the existing `followChild` call keeps its place
2897
+ // between the steer and the receipt; the receipt itself is still
2898
+ // `ok(steerReceipt(...))` with the disposition this steer reported.
2899
+ const disposition = await ctx.orchestrator.steer(
2900
+ ctx.name,
2901
+ ctx.params.message,
2902
+ );
2027
2903
  followChild(ctx, { watch: true });
2028
- return ok(`Steered ${ctx.name}.`);
2904
+ return ok(steerReceipt(ctx.name, disposition));
2029
2905
  } catch (error) {
2030
2906
  return fail(`steer failed: ${String(error)}`, ErrorCodes.NOT_FOUND);
2031
2907
  }
2032
2908
  }
2033
2909
 
2034
- /** Wait for the child's current turn and derive its outcome from the session. */
2035
- async function collectChild(
2910
+ /**
2911
+ * Render the receipt for one `abort` (#314 D7).
2912
+ *
2913
+ * The bounded wait the `timeout` receipt names comes from `outcome.timeoutMs` —
2914
+ * the bound the backend actually applied — and NOT from a default of this
2915
+ * function's own (#325). The tool surface never passes a `timeoutMs` (it stays a
2916
+ * `wait`-only parameter), so a local default would be a second, silent source of
2917
+ * truth: a caller that arms a different bound on the supervisor would still read
2918
+ * "within 5s" here.
2919
+ *
2920
+ * All three statuses are honest outcomes of a *soft* interrupt, and none of
2921
+ * them is a failure:
2922
+ *
2923
+ * - `interrupted`: the turn was cut short. Its context survives (the session is
2924
+ * not retired), but whatever the interrupted tool had already written to disk
2925
+ * stays written — a receipt that only promised "context kept" would let a
2926
+ * caller assume a clean rollback.
2927
+ * - `no-turn`: nothing was running, so nothing was done. Saying "aborted" here
2928
+ * would claim an interrupt that never happened.
2929
+ * - `timeout`: the abort was sent but the child had not settled inside the
2930
+ * bounded wait. A tool that ignores cancellation looks exactly like this, so
2931
+ * the receipt points at `stop` instead of pretending the turn died.
2932
+ */
2933
+ export function abortReceipt(
2934
+ name: string,
2935
+ outcome: AbortOutcome,
2936
+ ): string {
2937
+ if (outcome.status === "no-turn") {
2938
+ return `Nothing to abort: ${name} is not running a turn.`;
2939
+ }
2940
+ if (outcome.status === "timeout") {
2941
+ const timeoutMs = outcome.timeoutMs;
2942
+ const seconds = timeoutMs % 1000 === 0 ? String(timeoutMs / 1000) : (timeoutMs / 1000).toFixed(1);
2943
+ return `Abort sent to ${name}, but it had not settled within ${seconds}s — the running tool may ignore cancellation; use stop (or stop with force) if it must die.`;
2944
+ }
2945
+ const dropped = outcome.cleared.steering + outcome.cleared.followUp;
2946
+ const base = `Aborted ${name}'s current turn (context kept; partial edits may remain)`;
2947
+ return dropped > 0 ? `${base} — dropped ${dropped} queued message(s)` : base;
2948
+ }
2949
+
2950
+ /**
2951
+ * Interrupt a child's current turn without tearing the child down (#314 D7).
2952
+ *
2953
+ * The child keeps its session and context, so it stays continuable — that is
2954
+ * the whole difference from `stop`, which retires the process. Deliberately no
2955
+ * `followChild` here: abort must not register a new watch (the child is already
2956
+ * tracked and settles through its existing one).
2957
+ *
2958
+ * Exported so `test/unit/abort-action.test.ts` can drive the receipt and the
2959
+ * wiring (same reason as `steerChild`/`stopAction`).
2960
+ */
2961
+ export async function abortChild(
2962
+ ctx: ChildContext,
2963
+ ): Promise<AgentToolResult<unknown>> {
2964
+ try {
2965
+ // Only an explicit `clearQueued: false` builds an options object; the
2966
+ // default path passes nothing, so the supervisor's own default (true)
2967
+ // stays the single source of truth.
2968
+ const outcome = await ctx.orchestrator.abort(
2969
+ ctx.name,
2970
+ ctx.params.clearQueued === false ? { clearQueued: false } : undefined,
2971
+ );
2972
+ return ok(abortReceipt(ctx.name, outcome));
2973
+ } catch (error) {
2974
+ return fail(`abort failed: ${String(error)}`, ErrorCodes.NOT_FOUND);
2975
+ }
2976
+ }
2977
+
2978
+ /**
2979
+ * Wait for the child's current turn and derive its outcome from the session.
2980
+ *
2981
+ * Exported alongside `waitAction` for the index-layer unit tests: the claim the
2982
+ * runtime seam takes has to be handed back when this call bails (D2), and that
2983
+ * wiring is only observable from here.
2984
+ */
2985
+ export async function collectChild(
2036
2986
  ctx: ChildContext,
2037
2987
  signal?: AbortSignal,
2988
+ /**
2989
+ * #384: this node's inbox. When set, a collect that receives mail returns with
2990
+ * that mail instead of waiting for the child (`docs/contracts/wait-mail-preempt-v1.md`).
2991
+ * Absent = no query, no timer, no abort controller — today's behaviour byte for
2992
+ * byte (I1).
2993
+ */
2994
+ mail?: MailReleaseSeam,
2038
2995
  ): Promise<AgentToolResult<unknown>> {
2996
+ let stopPolling: (() => void) | undefined;
2997
+ // #384 review round 3: same hold as `wait` — this node's postman stays off its inbox
2998
+ // for the whole collect (entry check, interruptible seam and orchestrator fallback
2999
+ // alike), and the `finally` below hands it back on every exit path.
3000
+ if (mail) beginMailHold(mail.nodeId);
2039
3001
  try {
2040
3002
  if (signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
2041
- const pending = signal
2042
- ? ctx.runtime.consumeCollectInterruptible(ctx.name, signal)
2043
- : ctx.runtime.consumeCollect(ctx.name);
3003
+ // #384: the inbox is checked BEFORE anything claims a result, so a preempted
3004
+ // collect never holds a #352 claim for a result it will not deliver.
3005
+ if (mail) {
3006
+ const released = takePendingMail(mail.db, mail.nodeId);
3007
+ if (released.length > 0) return mailPreemptedCollect(ctx.name, released);
3008
+ }
3009
+ // #312: the runtime's job table is keyed by NAME, while everything below
3010
+ // acts on the node resolved in `ctx.found.runId`. A TRACKED job belonging to
3011
+ // another run is a different node: consuming its snapshot here would answer
3012
+ // with one node's result and persist it into another's run — the same silent
3013
+ // wrong-node answer the resolver exists to stop, one layer down.
3014
+ //
3015
+ // Only a tracked job can be cross-checked. A released one leaves its finished
3016
+ // cache behind (keyed by the run the snapshot CAME from, D9), and that cache is
3017
+ // the only answer left for the node — the #320 bail wiring collects exactly
3018
+ // that window, so an absent job must not be read as "a different run".
3019
+ const trackedJob = ctx.runtime.get(ctx.name);
3020
+ const foreignJob = trackedJob !== undefined && trackedJob.runId !== ctx.found.runId;
3021
+ // #384: the mail abort and the caller's abort race into one signal — whichever
3022
+ // lands first ends the collect, and `combined.reason` says which it was (§4).
3023
+ // With no mail seam the combined signal is the caller's, unchanged.
3024
+ const mailAbort = new AbortController();
3025
+ const combined = mail
3026
+ ? (signal ? AbortSignal.any([signal, mailAbort.signal]) : mailAbort.signal)
3027
+ : signal;
3028
+ if (mail) stopPolling = pollInbox(mail, () => mailAbort.abort(MAIL_PREEMPT_REASON));
3029
+ const pending = foreignJob
3030
+ ? undefined
3031
+ : combined
3032
+ ? ctx.runtime.consumeCollectInterruptible(ctx.name, combined)
3033
+ : ctx.runtime.consumeCollect(ctx.name);
2044
3034
  if (pending) {
2045
3035
  const collected = await pending;
2046
- if (!collected || signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
2047
- await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
2048
- return ok(renderCollect(ctx.name, collected));
3036
+ // D2: the seam claims a terminal snapshot on the way out, because a flush in
3037
+ // that tick would otherwise repeat it. Every bail from here hands the result
3038
+ // to NOBODY, so the claim is handed back — a permanently suppressed notice
3039
+ // for a result the parent never saw is the worse failure of the two. The
3040
+ // rollback is told `claimedNow`, the flag this very call got back: only a claim
3041
+ // THIS call took comes back (#320/1), so a result an earlier delivery already
3042
+ // claimed keeps its evidence, and the job branch restores the notification
3043
+ // route with it (#320/2).
3044
+ //
3045
+ // #384: a mail preempt is the reason the seam answered `undefined`, and it is
3046
+ // resolved AFTER the rollback above so #352's invariant holds either way: no
3047
+ // notice may be left claimed-but-undelivered by a preemption.
3048
+ const callerGone = signal?.aborted === true;
3049
+ const mailWon =
3050
+ mail !== undefined && combined?.reason === MAIL_PREEMPT_REASON && !callerGone;
3051
+ // #384 review P1.1: mail ended the collect, but the ledger is empty — the
3052
+ // postman claimed the row first. Answer with what this collect DID get
3053
+ // (`renderCollect`, the claim it took is the delivery) instead of the frozen
3054
+ // `interrupted …`, which would blame the parent turn for something that
3055
+ // never happened.
3056
+ if (mailWon) {
3057
+ const released = takePendingMail(mail.db, mail.nodeId);
3058
+ if (released.length > 0) {
3059
+ if (collected) {
3060
+ // The mail wins and the child's text is dropped: hand the claim back
3061
+ // so that round's notice can still deliver it (#352).
3062
+ ctx.runtime.unclaimUndelivered(ctx.name, collected.snapshot, collected.claimedNow);
3063
+ }
3064
+ return mailPreemptedCollect(ctx.name, released);
3065
+ }
3066
+ if (collected) return ok(renderCollect(ctx.name, collected.snapshot));
3067
+ }
3068
+ if (!collected || signal?.aborted) {
3069
+ if (collected) {
3070
+ ctx.runtime.unclaimUndelivered(ctx.name, collected.snapshot, collected.claimedNow);
3071
+ }
3072
+ return ok("interrupted (parent turn ended; children still running)");
3073
+ }
3074
+ try {
3075
+ await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
3076
+ } catch (error) {
3077
+ // Same rule for the other bail: `renderCollect` is never reached, so the
3078
+ // tool result will not carry this snapshot either.
3079
+ ctx.runtime.unclaimUndelivered(ctx.name, collected.snapshot, collected.claimedNow);
3080
+ throw error;
3081
+ }
3082
+ return ok(renderCollect(ctx.name, collected.snapshot));
2049
3083
  }
2050
3084
 
2051
3085
  const cached = ctx.orchestrator.cachedCollect(ctx.name);
@@ -2059,8 +3093,26 @@ async function collectChild(
2059
3093
  const timeoutMs = agentDef?.timeoutMs ?? DEFAULTS.turnTimeoutMs;
2060
3094
  const collected = await ctx.orchestrator.collect(ctx.name, {
2061
3095
  timeoutMs,
2062
- ...(signal ? { signal } : {}),
3096
+ ...(combined ? { signal: combined } : {}),
2063
3097
  });
3098
+ // #384: this collect may have been cut short by mail (the orchestrator answers
3099
+ // `aborted by parent` for ANY aborted signal). Only a genuinely interrupted
3100
+ // answer is preempted — a real result is delivered, and the mail stays pending
3101
+ // for the postman rather than replacing it.
3102
+ const mailWon =
3103
+ mail !== undefined &&
3104
+ combined?.reason === MAIL_PREEMPT_REASON &&
3105
+ signal?.aborted !== true &&
3106
+ collected.execution.reason === "aborted by parent";
3107
+ if (mailWon) {
3108
+ const released = takePendingMail(mail.db, mail.nodeId);
3109
+ if (released.length > 0) return mailPreemptedCollect(ctx.name, released);
3110
+ // #384 review P1.1: the mail is gone from the ledger (the postman claimed the
3111
+ // row first), but this collect DID get a snapshot — including the child's
3112
+ // output so far. Answering with `interrupted …` would blame the parent turn
3113
+ // for something that never happened, so the snapshot is what goes out.
3114
+ return ok(renderCollect(ctx.name, collected));
3115
+ }
2064
3116
  if (signal?.aborted || collected.execution.reason === "aborted by parent") {
2065
3117
  return ok("interrupted (parent turn ended; children still running)");
2066
3118
  }
@@ -2070,10 +3122,16 @@ async function collectChild(
2070
3122
  await ctx.orchestrator.retire(ctx.name);
2071
3123
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
2072
3124
  }
2073
- ctx.runtime.release(ctx.name);
3125
+ // Only release a job that IS the node just collected. Releasing by name alone
3126
+ // would drop the tracking of a still-live same-named child in another run.
3127
+ if (!foreignJob) ctx.runtime.release(ctx.name);
2074
3128
  return ok(renderCollect(ctx.name, collected));
2075
3129
  } catch (error) {
2076
3130
  return fail(`collect failed: ${String(error)}`, ErrorCodes.NOT_FOUND);
3131
+ } finally {
3132
+ // #384: never leave a 250ms timer behind a finished collect.
3133
+ stopPolling?.();
3134
+ if (mail) endMailHold(mail.nodeId);
2077
3135
  }
2078
3136
  }
2079
3137
 
@@ -2298,11 +3356,19 @@ async function reviveChild(
2298
3356
 
2299
3357
  try {
2300
3358
  if (supervisor.isAlive(name)) {
2301
- await orchestrator.steer(name, params.message);
3359
+ // #313 D5: this branch steers a live child too, so it reports the same
3360
+ // three outcomes as `steerChild`, reusing the receipt notes rather than
3361
+ // writing a second copy of the wording.
3362
+ const disposition = await orchestrator.steer(name, params.message);
2302
3363
  followChild(ctx, { watch: true });
2303
- return ok(
2304
- `${action === "resume" ? "Resumed" : "Continued"} ${name} (live child steered).`,
2305
- );
3364
+ const prefix = `${action === "resume" ? "Resumed" : "Continued"} ${name}`;
3365
+ if (disposition === "intercepted") {
3366
+ return ok(`${prefix}, but the steer was ${STEER_INTERCEPTED_NOTE}.`);
3367
+ }
3368
+ if (disposition === "queued") {
3369
+ return ok(`${prefix} (live child steered — ${STEER_QUEUED_NOTE}).`);
3370
+ }
3371
+ return ok(`${prefix} (live child steered).`);
2306
3372
  }
2307
3373
 
2308
3374
  const handle = await orchestrator.launch({
@@ -2310,6 +3376,10 @@ async function reviveChild(
2310
3376
  task: params.message,
2311
3377
  name,
2312
3378
  ...(found.child.model ? { model: found.child.model } : {}),
3379
+ // #374 §4: a team declared its machine at FIRST launch; a resume in a
3380
+ // later process must replay that declaration or the child falls back to
3381
+ // the role card and its custom phase names read as `invalid_phase`.
3382
+ ...(found.child.phases ? { phases: found.child.phases } : {}),
2313
3383
  });
2314
3384
  // D6/D8/D5-a: spend one renewal, but only if this child was actually cut
2315
3385
  // off by its turn budget.
@@ -2327,7 +3397,11 @@ async function reviveChild(
2327
3397
  // and overstate how much the child achieved under the current budget.
2328
3398
  const resumedChild =
2329
3399
  orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name) ??
2330
- found.child;
3400
+ // #344 review: `found.child` may be an element of the process-wide
3401
+ // `listRuns` snapshot, so mutating it in place would poison the cache
3402
+ // until the next write. The fallback is latent (launch always registers
3403
+ // the child), but a copy costs nothing and keeps the invariant local.
3404
+ { ...found.child };
2331
3405
  // Read the verdict from `found.child`, NOT the orchestrator's record: `launch`
2332
3406
  // clears `previous.execution` before returning (it is a relaunch), and
2333
3407
  // `found` is a freshly parsed copy from `store.listRuns()`, so the pre-resume
@@ -2342,7 +3416,21 @@ async function reviveChild(
2342
3416
  `Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
2343
3417
  );
2344
3418
  } catch (error) {
2345
- return fail(`${action} failed: ${String(error)}`, ErrorCodes.START_FAILED);
3419
+ // #309 D4: preserve the code the launcher already decided on (e.g.
3420
+ // `CHILD_TERMINAL` for a finished legion node) instead of flattening every
3421
+ // failure into `START_FAILED`. A `SubagentError` carries its code by
3422
+ // construction (`readonly code: string`), so the instance check IS the
3423
+ // condition — testing `&& error.code` would be a branch no input can
3424
+ // distinguish. Every other `Error` falls back to `START_FAILED`.
3425
+ // The code rides in `details.error` only — the body text stays code-free on
3426
+ // purpose, because the visible-text assertions in `test/unit/teams.test.ts`
3427
+ // and `test/integration/launch-queue.test.ts` require that a code never
3428
+ // leaks into what the model reads.
3429
+ const code =
3430
+ error instanceof SubagentError
3431
+ ? error.code
3432
+ : ErrorCodes.START_FAILED;
3433
+ return fail(`${action} failed: ${String(error)}`, code);
2346
3434
  }
2347
3435
  }
2348
3436
 
@@ -2436,7 +3524,7 @@ async function launchFamily(input: {
2436
3524
  // the bug's main path (a 12-step `tasks[]` fanout against `maxChildrenPerNode`).
2437
3525
  launchQueue: launchQueueBounds(settings),
2438
3526
  onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
2439
- ...(legion ? { legion: { db: legion.db, dbPath: legion.dbPath, nodeId: legion.nodeId, runId: run.runId, parentNodeId: legion.parentNodeId ?? undefined, parentRunId: legion.parentRunId ?? undefined, runStore: store, settings: legion.settings, eventBus: legion.eventBus } } : {}),
3527
+ ...(legion ? { legion: { db: legion.db, dbPath: legion.dbPath, nodeId: legion.nodeId, runId: run.runId, parentNodeId: legion.parentNodeId ?? undefined, parentRunId: legion.parentRunId ?? undefined, runStore: store, settings: legion.settings, eventBus: legion.eventBus, ...(legion.machineForRole ? { machineForRole: legion.machineForRole } : {}) } } : {}),
2440
3528
  onBudgetRefused: input.onBudgetRefused,
2441
3529
  });
2442
3530
  orchestrator.restore(run);
@@ -2595,7 +3683,7 @@ interface LaunchSession {
2595
3683
  */
2596
3684
  async function launchStep(
2597
3685
  session: LaunchSession,
2598
- step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
3686
+ step: { agent: string; task: string; name?: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
2599
3687
  ): Promise<void> {
2600
3688
  if (session.signal?.aborted) return;
2601
3689
  const agent = findAgent(session.agents, step.agent);
@@ -2665,13 +3753,16 @@ async function launchStep(
2665
3753
  return;
2666
3754
  }
2667
3755
 
2668
- // Honor `name=` when the caller asked for one; otherwise let `launch()`
2669
- // allocate from the supervisor-local registry (scout-N). The index must
2670
- // not race separate subagent tool calls for auto names.
3756
+ // Honor `name=` in this order: a per-item `tasks[]` handle wins over the
3757
+ // call-level `name=` (which applies to a single-step launch), and when
3758
+ // neither is given `launch()` allocates from the supervisor-local registry
3759
+ // (scout-N). The index must not race separate subagent tool calls for auto
3760
+ // names.
3761
+ const name = step.name ?? session.launchName;
2671
3762
  const handle = await session.orchestrator.launch({
2672
3763
  agent: launchedAgent,
2673
3764
  task: step.task,
2674
- ...(session.launchName ? { name: session.launchName } : {}),
3765
+ ...(name ? { name } : {}),
2675
3766
  ...(session.teamOverride ? { team: session.teamOverride } : {}),
2676
3767
  ...(resolved.model ? { model: resolved.model } : {}),
2677
3768
  // Pin the provider (#123): a bare id like `workbuddy/...` may also
@@ -2687,6 +3778,12 @@ async function launchStep(
2687
3778
  launch: session.params.worktree,
2688
3779
  step: step.worktree,
2689
3780
  }),
3781
+ // #371: a phase machine is declared by the ROOT at dispatch time and is a
3782
+ // property of the launch, not of a step — `tasks[]` fan-out carries the
3783
+ // same declaration to every sibling (G3 then refuses any step that is not
3784
+ // a centurion, rather than silently ignoring it).
3785
+ ...(session.params.phases !== undefined ? { phases: session.params.phases } : {}),
3786
+ ...(session.params.start !== undefined ? { start: session.params.start } : {}),
2690
3787
  });
2691
3788
  const recorded = session.store.findChild(session.runId, handle.name);
2692
3789
  if (recorded?.nodeId && recorded.nodeId === handle.child.nodeId) {
@@ -2922,6 +4019,7 @@ type Plan =
2922
4019
  steps: Array<{
2923
4020
  agent: string;
2924
4021
  task: string;
4022
+ name?: string;
2925
4023
  model?: string;
2926
4024
  preset?: string;
2927
4025
  worktree?: boolean;
@@ -2954,6 +4052,16 @@ function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
2954
4052
  message: `tasks[] step ${unsupportedVerify + 1} cannot include \`verify\`; vexillatio does not run verification commands.`,
2955
4053
  };
2956
4054
  }
4055
+ // `name` is a `subagent` concept: vexillatio allocates one handle per step
4056
+ // itself (`<role>-N`, continuing the parent's existing children), so a
4057
+ // per-item handle cannot be honored — refuse it instead of silently dropping.
4058
+ const unsupportedName = steps.findIndex((item) => item.name !== undefined);
4059
+ if (unsupportedName >= 0) {
4060
+ return {
4061
+ ok: false,
4062
+ message: `tasks[] step ${unsupportedName + 1} cannot include \`name\`; vexillatio allocates one handle per step itself.`,
4063
+ };
4064
+ }
2957
4065
  const invalid = steps.findIndex((item) => !item.agent.trim() || !item.task.trim());
2958
4066
  if (invalid >= 0) {
2959
4067
  return { ok: false, message: `tasks[] step ${invalid + 1} must include non-blank \`agent\` and \`task\`.` };
@@ -3079,6 +4187,7 @@ async function launchUtilityFamily(input: {
3079
4187
  settings: catalog.settings,
3080
4188
  maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
3081
4189
  eventBus: input.eventBus,
4190
+ rootSessionFile: input.ctx.sessionManager?.getSessionFile(),
3082
4191
  });
3083
4192
  }
3084
4193
  if (legion) input.onLegion(legion);
@@ -3139,6 +4248,7 @@ async function launchUtilityFamily(input: {
3139
4248
  runStore: store,
3140
4249
  settings: legion.settings,
3141
4250
  eventBus: legion.eventBus,
4251
+ ...(legion.machineForRole ? { machineForRole: legion.machineForRole } : {}),
3142
4252
  },
3143
4253
  } : {}),
3144
4254
  });
@@ -3395,10 +4505,12 @@ export function resolveExplicitLaunchName(
3395
4505
  export function buildPlan(params: {
3396
4506
  agent?: string;
3397
4507
  task?: string;
4508
+ name?: string;
3398
4509
  preset?: string;
3399
4510
  tasks?: Array<{
3400
4511
  agent: string;
3401
4512
  task: string;
4513
+ name?: string;
3402
4514
  model?: string;
3403
4515
  preset?: string;
3404
4516
  worktree?: boolean;
@@ -3444,19 +4556,85 @@ export function buildPlan(params: {
3444
4556
  const tasks = params.tasks as PlanStep[];
3445
4557
  const problem = firstInvalidStep(tasks, "tasks[]");
3446
4558
  if (problem) return { ok: false, message: problem };
3447
- return { ok: true, task: `${tasks.length} parallel tasks`, steps: tasks };
4559
+ // A per-item `name` is a handle the caller will later steer/collect by, so a
4560
+ // name that cannot be honored is refused for the WHOLE batch (see
4561
+ // `resolveStepNames`) before any child is allocated.
4562
+ const named = resolveStepNames(tasks, "tasks[]", params.name);
4563
+ if (!named.ok) return { ok: false, message: named.message };
4564
+ return { ok: true, task: `${tasks.length} parallel tasks`, steps: named.steps };
3448
4565
  }
3449
4566
 
3450
4567
  /** One entry of `tasks[]`. */
3451
4568
  interface PlanStep {
3452
4569
  agent: string;
3453
4570
  task: string;
4571
+ name?: string;
3454
4572
  model?: string;
3455
4573
  preset?: string;
3456
4574
  worktree?: boolean;
3457
4575
  verify?: string[];
3458
4576
  }
3459
4577
 
4578
+ /**
4579
+ * Resolve the per-item `name` handles of one `tasks[]` batch.
4580
+ *
4581
+ * Whole-batch rejection on purpose: a half-named batch would launch some
4582
+ * children under the caller's handles and the rest under auto `role-N`, so the
4583
+ * caller could neither predict nor control which child it later steers by name.
4584
+ * Nothing is allocated yet (this runs before every launch), so refusing the
4585
+ * batch costs nothing — unlike a partial start, which cannot be un-spawned.
4586
+ *
4587
+ * Returns a NEW array carrying the normalized handles; the caller's array is
4588
+ * never rewritten, and an entry without a name keeps no `name` key at all.
4589
+ */
4590
+ function resolveStepNames(
4591
+ steps: PlanStep[],
4592
+ label: string,
4593
+ topLevelName: string | undefined,
4594
+ ): { ok: true; steps: PlanStep[] } | { ok: false; message: string } {
4595
+ const firstByName = new Map<string, number>();
4596
+ const resolved: PlanStep[] = [];
4597
+ for (let i = 0; i < steps.length; i += 1) {
4598
+ const step = steps[i] ?? ({} as PlanStep);
4599
+ if (step.name === undefined) {
4600
+ resolved.push(step);
4601
+ continue;
4602
+ }
4603
+ // Reuse the single-launch gate so trim/lowercase normalization and the
4604
+ // grammar refusal can never drift between `name=` and `tasks[].name`.
4605
+ const gate = resolveExplicitLaunchName(step.name, 1);
4606
+ if (!gate.ok) {
4607
+ return { ok: false, message: `${label}: step ${i + 1}: ${gate.message}` };
4608
+ }
4609
+ const name = gate.name as string;
4610
+ const first = firstByName.get(name);
4611
+ if (first !== undefined) {
4612
+ return {
4613
+ ok: false,
4614
+ message: `${label}: steps ${first + 1} and ${i + 1} both ask for the name "${name}"; each child needs its own handle.`,
4615
+ };
4616
+ }
4617
+ firstByName.set(name, i);
4618
+ resolved.push({ ...step, name });
4619
+ }
4620
+ // A one-entry batch can name the child in both places. Agreement passes; a
4621
+ // disagreement is refused rather than letting one silently win, because the
4622
+ // child would then answer to a handle the caller did not mean to use.
4623
+ const only = resolved[0];
4624
+ if (resolved.length === 1 && only?.name !== undefined && topLevelName !== undefined) {
4625
+ const top = resolveExplicitLaunchName(topLevelName, 1);
4626
+ // An invalid top-level name keeps its own existing refusal (`name=` is
4627
+ // validated by the launch handler); only a valid, differing name conflicts.
4628
+ if (top.ok && top.name !== undefined && top.name !== only.name) {
4629
+ return {
4630
+ ok: false,
4631
+ message: `${label}: step 1 asks for the name "${only.name}", but the call also passes name="${topLevelName.trim()}"; pass one.`,
4632
+ };
4633
+ }
4634
+ }
4635
+ return { ok: true, steps: resolved };
4636
+ }
4637
+
3460
4638
  /**
3461
4639
  * True when a value is a non-blank string.
3462
4640
  * `Boolean(" ")` is true, so a whitespace-only task would otherwise pass and
@@ -3865,26 +5043,35 @@ export interface StatusChildEntry {
3865
5043
  * hold live children keep their meaning.
3866
5044
  */
3867
5045
  live?: boolean;
5046
+ /**
5047
+ * Tool calls in flight right now (#315, #331), innermost last. Supplied by
5048
+ * the supervisor; absent means no activity fact, which renders no line at all.
5049
+ */
5050
+ inFlightTools?: InFlightToolStack;
3868
5051
  }
3869
5052
 
3870
5053
  /** Multi-child status text: one {@link renderLiveChildStatus} block per child. */
3871
- export function renderStatusOverview(children: StatusChildEntry[]): string {
5054
+ export function renderStatusOverview(children: StatusChildEntry[], now: number = Date.now()): string {
3872
5055
  const deadCount = children.filter((entry) => entry.live === false).length;
3873
5056
  const liveCount = children.length - deadCount;
3874
- const blocks = children.map(({ child, alive, owner, pid }) =>
3875
- renderLiveChildStatus({
3876
- name: child.name,
3877
- state: child.state,
3878
- agent: child.agent,
3879
- kind: child.kind,
3880
- sessionFile: child.sessionFile,
3881
- execution: child.execution,
3882
- acceptance: child.acceptance,
3883
- spawnMode: child.spawnMode,
3884
- alive,
3885
- ...(owner ? { owner } : {}),
3886
- ...(pid ? { pid } : {}),
3887
- }),
5057
+ const blocks = children.map(({ child, alive, owner, pid, inFlightTools }) =>
5058
+ withInFlightToolLine(
5059
+ renderLiveChildStatus({
5060
+ name: child.name,
5061
+ state: child.state,
5062
+ agent: child.agent,
5063
+ kind: child.kind,
5064
+ sessionFile: child.sessionFile,
5065
+ execution: child.execution,
5066
+ acceptance: child.acceptance,
5067
+ spawnMode: child.spawnMode,
5068
+ alive,
5069
+ ...(owner ? { owner } : {}),
5070
+ ...(pid ? { pid } : {}),
5071
+ }),
5072
+ inFlightTools,
5073
+ now,
5074
+ ),
3888
5075
  );
3889
5076
  const parts: string[] = [];
3890
5077
  if (liveCount === 0) {
@@ -3943,7 +5130,7 @@ export const WAIT_PARENT_DECISIONS = [
3943
5130
  export type WaitParentDecision = (typeof WAIT_PARENT_DECISIONS)[number];
3944
5131
 
3945
5132
  export const WAIT_TIMEOUT_HINT =
3946
- "wait does not stop children; choose stop/force_stop or extend/steer";
5133
+ "wait only stopped waiting: the children are still running and keep their context. Prefer status/extend/steer_again; stop/force_stop retire them and discard that context, so use them only to abandon the work.";
3947
5134
 
3948
5135
  export interface WaitStillRunningChild {
3949
5136
  name: string;
@@ -4032,16 +5219,175 @@ export function renderWait(results: WaitResult[]): string {
4032
5219
  */
4033
5220
  function ownerVerdictFor(
4034
5221
  store: RunStore,
4035
- runs: ReturnType<RunStore["listRuns"]>,
5222
+ runs: () => ReturnType<RunStore["listRuns"]>,
4036
5223
  name: string,
4037
5224
  alive: boolean,
5225
+ runId?: string | undefined,
4038
5226
  ): "live" | "dead" | "unknown" | undefined {
4039
5227
  if (alive) return undefined;
4040
- const picked = pickChildByName(runs, name);
5228
+ // #312: a named `status` resolved ONE node, so the verdict has to come from
5229
+ // that node's run dir. Reading it through `pickChildByName` mixed the record
5230
+ // this call displays with the owner marker of another run's same-named node —
5231
+ // a child whose own run dir is owned by this live process could render
5232
+ // `owner: dead (pid gone)` and even be rewritten to `state=failed`.
5233
+ if (runId) return ownerState(path.join(store.runDir(runId), RUN_OWNER_FILE));
5234
+ // Only a caller with no resolved run reaches this: the registry scan happens
5235
+ // here and nowhere earlier (#344).
5236
+ const picked = pickChildByName(runs(), name);
4041
5237
  if (!picked) return "unknown";
4042
5238
  return ownerState(path.join(store.runDir(picked.run.runId), RUN_OWNER_FILE));
4043
5239
  }
4044
5240
 
5241
+ /** The run a resolved `stop` target lives in (#312). */
5242
+ export interface StopRetireScope {
5243
+ /**
5244
+ * The run `stopAction` resolved this name in. The executor must read THIS
5245
+ * record — re-resolving the name across runs is how `stop` came to report one
5246
+ * node and retire another.
5247
+ */
5248
+ runId?: string | undefined;
5249
+ }
5250
+
5251
+ /** What a child had already done by the time `stop` reached it. */
5252
+ interface StopSettled {
5253
+ status?: ExecutionStatus;
5254
+ /** ISO settle time, when the runtime observed one. */
5255
+ at?: string;
5256
+ }
5257
+
5258
+ /** One node `stop` will retire, with the run that owns it (#312). */
5259
+ interface StopTarget {
5260
+ name: string;
5261
+ runId: string;
5262
+ /**
5263
+ * Set when the child had already finished: retiring it only reaps it, and the
5264
+ * reply has to say so instead of claiming an interruption.
5265
+ */
5266
+ settled?: StopSettled;
5267
+ }
5268
+
5269
+ /** The targets a named `stop` resolved, plus why it resolved none. */
5270
+ interface StopResolution {
5271
+ targets: StopTarget[];
5272
+ /** Present when the name matched a record that is already retired. */
5273
+ note?: string;
5274
+ }
5275
+
5276
+ /**
5277
+ * A child that already finished is reaped, not stopped.
5278
+ *
5279
+ * `awaiting` means the runtime already collected a terminal snapshot; `exited`
5280
+ * means its process is gone. A record whose execution is still `running` is NOT
5281
+ * settled, even when a lifecycle state raced ahead of the process.
5282
+ */
5283
+ function settledFromRecord(child: ChildRecord): StopSettled | undefined {
5284
+ if (child.state !== "awaiting" && child.state !== "exited") return undefined;
5285
+ const status = child.execution?.status;
5286
+ if (status === "running") return undefined;
5287
+ return status ? { status } : {};
5288
+ }
5289
+
5290
+ /** The runtime job knows WHEN it settled; the outcome lives on the record, not on it. */
5291
+ function settledFromJob(job: { state: string; settledAt?: number }): StopSettled | undefined {
5292
+ if (job.state !== "awaiting") return undefined;
5293
+ return job.settledAt ? { at: new Date(job.settledAt).toISOString() } : {};
5294
+ }
5295
+
5296
+ /**
5297
+ * Resolve ONE named target from a persisted record.
5298
+ *
5299
+ * A `retired` record yields no target plus a note carrying its outcome and retire
5300
+ * time — otherwise the caller learns only "nothing to stop", which is exactly how
5301
+ * a child that finished a second earlier reads as a failed stop.
5302
+ */
5303
+ function stopResolutionFromRecord(child: ChildRecord, runId: string): StopResolution {
5304
+ if (child.state === "retired") {
5305
+ return { targets: [], note: retiredRecordNote(child) };
5306
+ }
5307
+ const settled = settledFromRecord(child);
5308
+ return { targets: [{ name: child.name, runId, ...(settled ? { settled } : {}) }] };
5309
+ }
5310
+
5311
+ /** Same, for a live job the session tracks (the job table never holds `retired` in production). */
5312
+ function stopResolutionFromJob(job: {
5313
+ name: string;
5314
+ runId: string;
5315
+ state: string;
5316
+ settledAt?: number;
5317
+ }): StopResolution {
5318
+ if (job.state === "retired") return { targets: [], note: `${job.name} is already retired.` };
5319
+ const settled = settledFromJob(job);
5320
+ return { targets: [{ name: job.name, runId: job.runId, ...(settled ? { settled } : {}) }] };
5321
+ }
5322
+
5323
+ /** `worker-2 is already retired (execution: success, retired at 2026-…).` */
5324
+ function retiredRecordNote(child: ChildRecord): string {
5325
+ const bits: string[] = [];
5326
+ const status = child.execution?.status;
5327
+ if (status) bits.push(`execution: ${status}`);
5328
+ if (child.retiredAt) bits.push(`retired at ${child.retiredAt}`);
5329
+ return `${child.name} is already retired${bits.length > 0 ? ` (${bits.join(", ")})` : ""}.`;
5330
+ }
5331
+
5332
+ /** ` (execution: success, settled at 2026-…)`, or "" when nothing is known. */
5333
+ function stopSettledNote(settled: StopSettled | undefined): string {
5334
+ const bits: string[] = [];
5335
+ if (settled?.status) bits.push(`execution: ${settled.status}`);
5336
+ if (settled?.at) bits.push(`settled at ${settled.at}`);
5337
+ return bits.length > 0 ? ` (${bits.join(", ")})` : "";
5338
+ }
5339
+
5340
+ /**
5341
+ * Refuse `stop({name, runId})` when that name's live job sits in another run (#312).
5342
+ *
5343
+ * The runtime's job table is keyed by NAME, so before this check a live child
5344
+ * answered a call scoped to a different run and was retired anyway — the
5345
+ * "reported one node, retired another" failure one layer above the store path.
5346
+ * A live job is a node of exactly one run, so the two are a conflict, never a
5347
+ * preference: neither may be retired by a tie-break. The wording is the same
5348
+ * elsewhere/ambiguity pair the store path uses, because the caller's lever is
5349
+ * the same — name the run that actually holds the node.
5350
+ */
5351
+ function stopScopeMismatchMessage(
5352
+ name: string,
5353
+ live: { runId: string; nodeId?: string | undefined; state: string },
5354
+ runs: ReturnType<RunStore["listRuns"]>,
5355
+ ): string {
5356
+ // The scan names every same-named node; the live job is added on its own
5357
+ // because a just-spawned child may not have a persisted record yet.
5358
+ const scanned = scopeHitFor({ name, directRunId: undefined, explicitRunId: undefined, runs });
5359
+ const candidates = dedupeChildScopeCandidates([
5360
+ ...(scanned.kind === "elsewhere" ? scanned.candidates : []),
5361
+ {
5362
+ runId: live.runId,
5363
+ ...(live.nodeId ? { nodeId: live.nodeId } : {}),
5364
+ state: live.state,
5365
+ },
5366
+ ]);
5367
+ return candidates.length > 1
5368
+ ? ambiguousChildMessage("stop", name, candidates)
5369
+ : notDirectChildMessage("stop", name, candidates);
5370
+ }
5371
+
5372
+ /**
5373
+ * One entry per node: the same run can be seen twice (persisted scan + live job).
5374
+ *
5375
+ * Sorted by `runId` like the resolver's own scan, so the message is stable.
5376
+ */
5377
+ function dedupeChildScopeCandidates(
5378
+ candidates: readonly ChildScopeCandidate[],
5379
+ ): ChildScopeCandidate[] {
5380
+ const seen = new Set<string>();
5381
+ const out: ChildScopeCandidate[] = [];
5382
+ for (const candidate of candidates) {
5383
+ const key = `${candidate.runId}\u0000${candidate.nodeId ?? ""}`;
5384
+ if (seen.has(key)) continue;
5385
+ seen.add(key);
5386
+ out.push(candidate);
5387
+ }
5388
+ return out.sort((a, b) => a.runId.localeCompare(b.runId));
5389
+ }
5390
+
4045
5391
  /**
4046
5392
  * Retire a named child or every child tracked by this session.
4047
5393
  * `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
@@ -4054,7 +5400,22 @@ export async function stopAction(input: {
4054
5400
  * (foreign/restarted runs). Omitted callers keep the runtime-only scope.
4055
5401
  */
4056
5402
  store?: RunStore;
4057
- retire: (name: string, opts?: RetireOptions) => Promise<void>;
5403
+ /**
5404
+ * #312: the run this call is scoped to (`runId` on the tool call, else the
5405
+ * session's own run). Only a child of THIS run may be stopped by name; a
5406
+ * same-named record elsewhere is reported instead of adopted.
5407
+ */
5408
+ runId?: string | undefined;
5409
+ /** The run this session owns, used when the call names no `runId` (#312). */
5410
+ sessionRunId?: string | undefined;
5411
+ /**
5412
+ * Retire one already-resolved target.
5413
+ *
5414
+ * `scope` carries the run the target was resolved IN, so the executor can act
5415
+ * on that exact record instead of resolving the name again by preference
5416
+ * (#312) — one resolver here, one node retired there.
5417
+ */
5418
+ retire: (name: string, opts?: RetireOptions, scope?: StopRetireScope) => Promise<void>;
4058
5419
  }): Promise<AgentToolResult<unknown>> {
4059
5420
  const { params, runtime, retire } = input;
4060
5421
  if (params.name && params.all) {
@@ -4067,41 +5428,107 @@ export async function stopAction(input: {
4067
5428
  const retireOpts: RetireOptions | undefined = force ? { graceful: false } : undefined;
4068
5429
  const jobs = runtime.activeJobs();
4069
5430
  const active = jobs.filter((job) => job.state !== "retired");
4070
- let targets: string[];
5431
+ let targets: StopTarget[];
5432
+ /** Describes the already-retired record a NAMED stop resolved to no target. */
5433
+ let alreadyRetired: string | undefined;
4071
5434
  if (params.all) {
4072
- targets = active.map((job) => job.name);
5435
+ targets = active.map((job) => {
5436
+ const settled = settledFromJob(job);
5437
+ return { name: job.name, runId: job.runId, ...(settled ? { settled } : {}) };
5438
+ });
4073
5439
  } else {
4074
5440
  // Local const so the earlier `name`/`all` guard narrows through the lookup.
4075
5441
  const name = params.name;
4076
5442
  const target = name ? jobs.find((job) => job.name === name) : undefined;
5443
+ if (name && input.runId !== undefined && target && target.runId !== input.runId) {
5444
+ // #312: the job table is keyed by NAME, so a live child answered a call
5445
+ // scoped to another run and got retired anyway — the presented node and the
5446
+ // retired node disagreed again, one layer up (fixing only the store path
5447
+ // would have left this silent). A live job belongs to exactly one run, so
5448
+ // the mismatch is a conflict: refuse it with the wording the store path uses.
5449
+ return fail(
5450
+ stopScopeMismatchMessage(name, target, input.store ? input.store.listRuns() : []),
5451
+ ErrorCodes.NOT_FOUND,
5452
+ );
5453
+ }
4077
5454
  if (!target) {
4078
- // Not tracked by this session, but a foreign/restarted run may still
4079
- // own a persisted record — the same records `status` can display. Fall
4080
- // back to it so stop and retire agree on what is reachable.
4081
- const found = input.store && name ? findChild(input.store.listRuns(), name) : null;
5455
+ // Not tracked by this session, but a foreign/restarted run may still own a
5456
+ // persisted record — the same records `status` can display.
5457
+ //
5458
+ // #312: it has to be this session's OWN child. The old fallback took
5459
+ // whatever `pickChildByName` preferred across every run, and when that
5460
+ // happened to be a different run's retired record the whole call collapsed
5461
+ // into `No non-retired children to stop.` — which told the caller nothing
5462
+ // about the name being ambiguous, and hid the node it was trying to stop.
5463
+ const runs = input.store ? input.store.listRuns() : [];
5464
+ const directRunId = scopeRunIdFor({
5465
+ runId: input.runId,
5466
+ sessionRunId: input.sessionRunId,
5467
+ trackedRunId: name ? runtime.get(name)?.runId : undefined,
5468
+ });
5469
+ const found = name ? foundInScope(runs, directRunId, name) : null;
4082
5470
  if (!found) {
4083
- return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
5471
+ const hit = name
5472
+ ? scopeHitFor({ name, directRunId, explicitRunId: input.runId, runs })
5473
+ : undefined;
5474
+ if (hit?.kind === "elsewhere") {
5475
+ const ambiguous = hit.candidates.length > 1;
5476
+ // A session with no run of its own (#209-3: a foreign or restarted run's
5477
+ // record) has no direct children, so a unique match stays reachable. With
5478
+ // a scope, a name that is not in it is refused instead of adopted — that
5479
+ // adoption is what turned into `No non-retired children to stop.`
5480
+ if (ambiguous || directRunId !== undefined) {
5481
+ const message = ambiguous
5482
+ ? ambiguousChildMessage("stop", name ?? "", hit.candidates)
5483
+ : notDirectChildMessage("stop", name ?? "", hit.candidates);
5484
+ return fail(message, ErrorCodes.NOT_FOUND);
5485
+ }
5486
+ const permissive = name ? foundElsewhere(runs, name, hit) : null;
5487
+ if (!permissive) {
5488
+ return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
5489
+ }
5490
+ const resolved = stopResolutionFromRecord(permissive.child, permissive.runId);
5491
+ targets = resolved.targets;
5492
+ alreadyRetired = resolved.note;
5493
+ } else {
5494
+ return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
5495
+ }
5496
+ } else {
5497
+ const resolved = stopResolutionFromRecord(found.child, found.runId);
5498
+ targets = resolved.targets;
5499
+ alreadyRetired = resolved.note;
4084
5500
  }
4085
- targets = found.child.state === "retired" ? [] : [found.child.name];
4086
5501
  } else {
4087
- targets = target.state === "retired" ? [] : [target.name];
5502
+ const resolved = stopResolutionFromJob(target);
5503
+ targets = resolved.targets;
5504
+ alreadyRetired = resolved.note;
4088
5505
  }
4089
5506
  }
4090
- if (targets.length === 0) return ok("No non-retired children to stop.");
5507
+ if (targets.length === 0) {
5508
+ return ok(
5509
+ alreadyRetired ? `No non-retired children to stop. ${alreadyRetired}` : "No non-retired children to stop.",
5510
+ );
5511
+ }
4091
5512
 
4092
5513
  // Stop every watcher before any retire starts: a later target must not finish
4093
5514
  // its own watch (and notify/release) while an earlier target is being retired.
4094
- for (const name of targets) runtime.cancelWatch(name);
5515
+ for (const { name } of targets) runtime.cancelWatch(name);
4095
5516
 
4096
5517
  const stopped: string[] = [];
5518
+ const reaped: string[] = [];
4097
5519
  const failures: string[] = [];
4098
- for (const name of targets) {
5520
+ for (const target of targets) {
5521
+ const { name, runId } = target;
4099
5522
  try {
4100
5523
  // The runtime job may have been released after the target snapshot was
4101
- // taken. The retire adapter can then resolve its persisted run record.
4102
- await retire(name, retireOpts);
5524
+ // taken. `scope` hands the resolve-time run to the executor, which reads
5525
+ // THAT record rather than picking one across runs (#312).
5526
+ await retire(name, retireOpts, { runId });
4103
5527
  runtime.release(name);
4104
- stopped.push(name);
5528
+ // A child that had already finished is reaped, not stopped: "Stopped 1
5529
+ // child" for a success claims an interruption that never happened.
5530
+ if (target.settled) reaped.push(`${name}${stopSettledNote(target.settled)}`);
5531
+ else stopped.push(name);
4105
5532
  } catch (error) {
4106
5533
  // If the retire chain fails, restore the completion watcher so a
4107
5534
  // still-live child is not orphaned by the explicit stop attempt.
@@ -4110,14 +5537,243 @@ export async function stopAction(input: {
4110
5537
  }
4111
5538
  }
4112
5539
  const mode = force ? "force-stopped" : "Stopped";
4113
- const summary = stopped.length
4114
- ? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
4115
- : "No children were stopped.";
5540
+ const lines: string[] = [];
5541
+ if (stopped.length > 0) {
5542
+ lines.push(`${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`);
5543
+ }
5544
+ if (reaped.length > 0) {
5545
+ lines.push(
5546
+ `Retired ${reaped.length} already-settled child${reaped.length === 1 ? "" : "ren"}: ${reaped.join(", ")}.`,
5547
+ );
5548
+ }
5549
+ const summary = lines.length > 0 ? lines.join("\n") : "No children were stopped.";
4116
5550
  return failures.length
4117
5551
  ? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
4118
5552
  : ok(summary);
4119
5553
  }
4120
5554
 
5555
+ /**
5556
+ * #352: one completion claim a `wait` took for a result the parent never saw.
5557
+ *
5558
+ * `waitAction` claims a settled child's result on its finished-cache probe
5559
+ * (`runtime.consumeCollect`, `index.ts` — the probe is NOT side-effect free since
5560
+ * notice-claim-v1), and the tool layer in this file can still throw the rendered
5561
+ * result away when the parent turn ends. Only a claim THIS call took travels here
5562
+ * (`claimedNow === true`): a key an earlier real delivery claimed is evidence, not
5563
+ * something a bail may spend (#320/1).
5564
+ */
5565
+ export interface WaitClaim {
5566
+ name: string;
5567
+ snapshot: CollectSnapshot;
5568
+ }
5569
+
5570
+ /**
5571
+ * #352: hand back every claim a discarded `wait` result owed.
5572
+ *
5573
+ * `unclaimUndelivered`, never `unclaimReported`: nothing reached the parent, so the
5574
+ * queued notice is the ONLY remaining route — the rollback gives the claim back and
5575
+ * drops no copy (D2/D7). `claimedNow` is always `true` here: `WaitClaim`s only exist
5576
+ * for claims this call took.
5577
+ *
5578
+ * #352 review (major 1): the claim arrives ASYNCHRONOUSLY. `waitAction`'s
5579
+ * finished-cache probe is a promise (`consumeCollect` → `ensureCollect`), and the wait
5580
+ * can bail long before it answers — `signal?.aborted`, `WAIT_INTERRUPTED` and
5581
+ * `WaitTimeoutError` all win their race inside `waitOne` while that collect is still in
5582
+ * flight. The probe then resolves to a terminal snapshot and claims `(runId, name,
5583
+ * round)` AFTER the details object was already built, so the bail has to wait for that
5584
+ * answer before it rolls anything back: `details.waitClaims` is a live array (shared by
5585
+ * reference with `waitAction`), and `details.waitClaimsPending` is the promise that
5586
+ * resolves once the probe has folded its claim in. Awaited first, both windows are
5587
+ * covered: the claim that was already there, and the one that landed late.
5588
+ *
5589
+ * The await is bounded by the probe's own collect, which is what makes the claim
5590
+ * knowable at all; returning now and rolling back from a detached continuation would
5591
+ * hand the claim back after the frozen `interrupted …` line already went out.
5592
+ */
5593
+ export async function releaseWaitClaims(runtime: SessionRuntime, details: unknown): Promise<void> {
5594
+ if (details === null || typeof details !== "object") return;
5595
+ const record = details as { waitClaims?: unknown; waitClaimsPending?: unknown };
5596
+ const pending = record.waitClaimsPending;
5597
+ if (pending !== undefined) {
5598
+ try {
5599
+ await (pending as Promise<unknown>);
5600
+ } catch {
5601
+ // A throwing probe took no claim — nothing to give back.
5602
+ }
5603
+ }
5604
+ const claims = record.waitClaims;
5605
+ if (!Array.isArray(claims)) return;
5606
+ for (const claim of claims as WaitClaim[]) {
5607
+ runtime.unclaimUndelivered(claim.name, claim.snapshot, true);
5608
+ }
5609
+ }
5610
+
5611
+ /**
5612
+ * What a control call answers when the caller's abort lands after it returned
5613
+ * (#352 + #384 review P0-3).
5614
+ *
5615
+ * #352: the frozen head line REPLACES the rendered result, so the parent never sees
5616
+ * it — and `wait` CLAIMS a settled child's completion on its finished-cache probe.
5617
+ * The result is thrown away here, so the claim has to go back (exactly what
5618
+ * `collectChild` owes on its own bails): a claim nobody delivered suppresses that
5619
+ * round's notice forever.
5620
+ *
5621
+ * #384 review P0-3: a MAIL result is thrown away the same way, but a claim is not
5622
+ * the only thing it held — the mails inside it were already `PENDING → ACCEPTED`, and
5623
+ * `takePendingMail` has no way back. Left alone, the text would be lost for good
5624
+ * (before #384 the message was still `PENDING` and a restart would have delivered
5625
+ * it). So the discarded result's mail is re-enqueued as fresh `PENDING` rows
5626
+ * (`Redelivered: ` subject, same sender/recipient/body) and the postman's next tick
5627
+ * delivers it like any other mail.
5628
+ */
5629
+ export async function interruptedControlResult(input: {
5630
+ result: AgentToolResult<unknown>;
5631
+ runtime: SessionRuntime;
5632
+ legion?: OrchestratorLegionContext;
5633
+ }): Promise<AgentToolResult<unknown>> {
5634
+ await releaseWaitClaims(input.runtime, input.result.details);
5635
+ redeliverPreemptedMail(input.legion, input.result.details);
5636
+ return ok("interrupted (parent turn ended; children still running)");
5637
+ }
5638
+
5639
+ /** Re-enqueue the mail a discarded (`state: "mail"`) result was carrying. */
5640
+ function redeliverPreemptedMail(
5641
+ legion: OrchestratorLegionContext | undefined,
5642
+ details: unknown,
5643
+ ): void {
5644
+ if (!legion || details === null || typeof details !== "object") return;
5645
+ const record = details as { state?: unknown; mailIds?: unknown };
5646
+ if (record.state !== "mail" || !Array.isArray(record.mailIds)) return;
5647
+ const ids = record.mailIds.filter((id): id is number => Number.isSafeInteger(id));
5648
+ if (ids.length === 0) return;
5649
+ try {
5650
+ redeliverMail(legion.db, ids);
5651
+ } catch {
5652
+ // The ledger refused the copy (closed DB, a racing shutdown). Nothing further to
5653
+ // promise: the frozen line is still the answer, and swallowing beats failing a
5654
+ // bail that has already been decided.
5655
+ }
5656
+ }
5657
+
5658
+ /**
5659
+ * #384: the abort reason that says "this node's inbox got mail", as opposed to
5660
+ * "the caller left". `AbortSignal.any` forwards the reason of whichever source
5661
+ * aborted FIRST (contract §4), so the combined signal's `reason` is the verdict on
5662
+ * who won the race — no bookkeeping flag to fall out of sync.
5663
+ */
5664
+ const MAIL_PREEMPT_REASON = Symbol("legion mail preempted the blocking call");
5665
+
5666
+ /**
5667
+ * Mount the #352 claim record on a wait result's `details` (both fields, always).
5668
+ *
5669
+ * #352 review (major 1): mount BOTH unconditionally. Deciding on `claims.length` was
5670
+ * a no-op: one probe means at most one claim, and the probe has usually not answered
5671
+ * yet at this point — so the length test mounted nothing in the window that matters,
5672
+ * and when it did mount it shared a reference with a caller that could reach it anyway.
5673
+ * The array stays live (late pushes land in the same object) and the promise says
5674
+ * when the probe got its answer.
5675
+ */
5676
+ function withWaitClaims(
5677
+ details: Record<string, unknown>,
5678
+ claims: WaitClaim[],
5679
+ pending?: Promise<WaitClaim[]>,
5680
+ ): Record<string, unknown> {
5681
+ details.waitClaims = claims;
5682
+ if (pending) {
5683
+ // Non-enumerable: this is a live promise, and `details` is the tool result's
5684
+ // record — a promise has no business in a serialized transcript.
5685
+ Object.defineProperty(details, "waitClaimsPending", {
5686
+ value: pending,
5687
+ enumerable: false,
5688
+ configurable: true,
5689
+ });
5690
+ }
5691
+ return details;
5692
+ }
5693
+
5694
+ /**
5695
+ * Contract §2: the mail that ended the blocking call, as a NORMAL tool result.
5696
+ *
5697
+ * Not an error and not `interrupted …` — the model reads the mail and carries on.
5698
+ * The `details` shape is the one the contract froze: `state`, `mailIds`, `targets`,
5699
+ * plus the wait's claim record when the caller has one (a `collect` takes no claim).
5700
+ *
5701
+ * `sections` are the parts of the blocking call that DID happen, in order: the mail
5702
+ * itself first (that is what ended the call), then whatever the call had already
5703
+ * delivered, then the frozen status line.
5704
+ */
5705
+ function mailPreemptedResult(input: {
5706
+ released: readonly LegionMessage[];
5707
+ targets: readonly string[];
5708
+ sections: readonly string[];
5709
+ claims?: WaitClaim[];
5710
+ pending?: Promise<WaitClaim[]>;
5711
+ }): AgentToolResult<unknown> {
5712
+ return {
5713
+ content: [
5714
+ {
5715
+ type: "text",
5716
+ text: [renderReleasedMail(input.released), ...input.sections].join("\n\n"),
5717
+ },
5718
+ ],
5719
+ details: withWaitClaims(
5720
+ {
5721
+ state: "mail",
5722
+ mailIds: input.released.map((message) => message.id),
5723
+ targets: [...input.targets],
5724
+ },
5725
+ input.claims ?? [],
5726
+ input.pending,
5727
+ ),
5728
+ };
5729
+ }
5730
+
5731
+ /**
5732
+ * `wait`: the mail, the results this wait already collected, and — once — who is
5733
+ * still running (#384 review P0-2).
5734
+ *
5735
+ * Review finding: the earlier shape reused `renderWait`'s body, which labels an
5736
+ * aborted result `interrupted (parent turn ended; children still running)` — a
5737
+ * conclusion about the PARENT that is false here: the wait ended because mail
5738
+ * arrived, and the parent turn is alive, reading that mail. So the interrupted
5739
+ * lines are dropped, every result the wait DID get is kept verbatim, and the
5740
+ * still-running fact is stated once, plainly.
5741
+ */
5742
+ function mailPreemptedWait(input: {
5743
+ released: readonly LegionMessage[];
5744
+ names: readonly string[];
5745
+ /** Results that carried a snapshot — they were delivered, so they stay. */
5746
+ delivered: readonly WaitResult[];
5747
+ /** Names this wait had not finished when the mail landed. */
5748
+ running: readonly string[];
5749
+ claims: WaitClaim[];
5750
+ pending?: Promise<WaitClaim[]>;
5751
+ }): AgentToolResult<unknown> {
5752
+ const sections = input.delivered.map((result) => renderCollect(result.name, result.snapshot!));
5753
+ if (input.running.length > 0) sections.push(`Still running: ${input.running.join(", ")}`);
5754
+ return mailPreemptedResult({
5755
+ released: input.released,
5756
+ targets: input.names,
5757
+ sections,
5758
+ claims: input.claims,
5759
+ ...(input.pending ? { pending: input.pending } : {}),
5760
+ });
5761
+ }
5762
+
5763
+ /** `collect`: the mail plus the frozen "collect again" instruction (contract §2). */
5764
+ function mailPreemptedCollect(
5765
+ name: string,
5766
+ released: readonly LegionMessage[],
5767
+ ): AgentToolResult<unknown> {
5768
+ return mailPreemptedResult({
5769
+ released,
5770
+ targets: [name],
5771
+ sections: [
5772
+ "The collect was interrupted by this mail; call collect again after handling it.",
5773
+ ],
5774
+ });
5775
+ }
5776
+
4121
5777
  export async function waitAction(input: {
4122
5778
  params: SubagentParams;
4123
5779
  signal?: AbortSignal;
@@ -4126,20 +5782,72 @@ export async function waitAction(input: {
4126
5782
  allAgents?: AgentConfig[];
4127
5783
  store: RunStore;
4128
5784
  cwd: string;
5785
+ /**
5786
+ * #384: this node's inbox, when the caller is a legion node. A wait that receives
5787
+ * mail returns AT ONCE with that mail instead of waiting out its timeout
5788
+ * (`docs/contracts/wait-mail-preempt-v1.md`). Absent = no query, no timer, no
5789
+ * abort controller — today's behaviour byte for byte (I1).
5790
+ */
5791
+ mail?: MailReleaseSeam;
4129
5792
  }): Promise<AgentToolResult<unknown>> {
4130
5793
  const { params, signal, runtime, agents, allAgents = agents, store, cwd } = input;
4131
5794
  const jobs = runtime.activeJobs();
5795
+ // #352: every claim THIS call takes. The array is shared by REFERENCE with the
5796
+ // returned `details.waitClaims`, because the probe below answers asynchronously:
5797
+ // a claim that lands after the details object was built must still be visible to
5798
+ // a caller that discards the rendered result.
5799
+ const claims: WaitClaim[] = [];
5800
+ // #352 review (major 1): the probe's answer, absent when this wait has no probe at
5801
+ // all (live targets). It resolves to `claims` once the probe's own claim — if any —
5802
+ // has been folded in, and travels out as `details.waitClaimsPending` so a bail that
5803
+ // lands while it is still in flight can wait for it instead of rolling back nothing.
5804
+ let claimsPending: Promise<WaitClaim[]> | undefined;
4132
5805
  let targets = resolveWaitTargets(
4133
5806
  { name: params.name, all: params.all },
4134
5807
  jobs,
4135
5808
  );
5809
+ const mail = input.mail;
5810
+ // #384: look in the inbox ONCE here — before the finished-cache probe below can
5811
+ // take a #352 claim, so the preempted path is provably claim-free. `signal?.aborted`
5812
+ // at entry means the caller is already gone: I5 keeps that path exactly as it was,
5813
+ // mail or no mail.
5814
+ //
5815
+ // Review P1.2: only a wait that HAS somewhere to wait is preempted. `unknown child`
5816
+ // and the parameter/liveness errors keep precedence — mail is not an answer to a
5817
+ // question this call did not ask, and hiding the error made the miss look like a
5818
+ // successful call whose result was mail.
5819
+ if (targets.ok && mail && signal?.aborted !== true) {
5820
+ const released = takePendingMail(mail.db, mail.nodeId);
5821
+ if (released.length > 0) {
5822
+ return mailPreemptedWait({
5823
+ released,
5824
+ names: targets.names,
5825
+ delivered: [],
5826
+ running: targets.names,
5827
+ claims,
5828
+ });
5829
+ }
5830
+ }
4136
5831
  if (!targets.ok) {
4137
5832
  // A name that is merely finished (not live) is still waitable via the
4138
- // runtime's finished cache — probe it with consumeCollect, which is
4139
- // side-effect free for a job that is not in the live set.
5833
+ // runtime's finished cache — probe it with consumeCollect. NOTE: since
5834
+ // notice-claim-v1 that probe is NOT side-effect free: a cache hit claims the
5835
+ // result, which is correct here — the wait below returns the same result to the
5836
+ // parent, so the pushed notice for it is suppressed instead of duplicating it.
5837
+ // It is also why the claim travels back out (#352): if the caller never hands
5838
+ // the result over, nobody else can, and the claim must be given back.
4140
5839
  if (params.name && !params.all) {
4141
- const cached = runtime.consumeCollect(params.name);
4142
- if (cached) {
5840
+ const probe = runtime.consumeCollect(params.name);
5841
+ if (probe) {
5842
+ const name = params.name;
5843
+ claimsPending = probe.then((claimed: ClaimedCollect) => {
5844
+ // #320/1: only a claim THIS call took may come back later.
5845
+ if (claimed.claimedNow) claims.push({ name, snapshot: claimed.snapshot });
5846
+ return claims;
5847
+ }, () => {
5848
+ // A throwing collect is the `wait` below's problem, not the probe's.
5849
+ return claims;
5850
+ });
4143
5851
  targets = { ok: true, names: [params.name] };
4144
5852
  } else {
4145
5853
  return unknownChild(params.name, store, cwd);
@@ -4162,12 +5870,77 @@ export async function waitAction(input: {
4162
5870
  );
4163
5871
  }),
4164
5872
  );
4165
- const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
5873
+ // #384: the mail abort and the caller's abort race into one signal, so whichever
5874
+ // lands FIRST ends the wait — and `reason` says which one it was (contract §4).
5875
+ // With no mail seam the combined signal is the caller's, unchanged.
5876
+ const mailAbort = new AbortController();
5877
+ const combined = mail
5878
+ ? (signal ? AbortSignal.any([signal, mailAbort.signal]) : mailAbort.signal)
5879
+ : signal;
5880
+ let stopPolling: (() => void) | undefined;
5881
+ if (mail) stopPolling = pollInbox(mail, () => mailAbort.abort(MAIL_PREEMPT_REASON));
5882
+ // #384 review round 3: while this call is blocked, this node's own postman must NOT
5883
+ // deliver — otherwise the mail is `ACCEPTED` and queued behind the host's followUp
5884
+ // long before the first poll tick, and the wait sees an empty inbox for its whole
5885
+ // timeout (the production hole the review found). Held across the ONE blocking
5886
+ // region and released in `finally`, so a timeout, an abort and a throw all give the
5887
+ // inbox back. Taking the mail below happens after that release, with no `await` in
5888
+ // between, so nothing can slip into the gap.
5889
+ if (mail) beginMailHold(mail.nodeId);
5890
+ let results: WaitResult[];
5891
+ try {
5892
+ results = await runtime.wait(targets.names, {
5893
+ timeoutMs,
5894
+ ...(combined ? { signal: combined } : {}),
5895
+ });
5896
+ } finally {
5897
+ // #384: never leave a 250ms timer behind a finished wait.
5898
+ stopPolling?.();
5899
+ if (mail) endMailHold(mail.nodeId);
5900
+ }
5901
+ // #384 review P0-2: mail preempts this wait UNCONDITIONALLY — no "did anything get
5902
+ // delivered" test, no "did the probe run" test. Those two extra conditions were an
5903
+ // invention: they made a wait that received mail answer as if the wait had simply
5904
+ // timed out, with `interrupted (parent turn ended…)` naming a turn that never
5905
+ // ended. What the wait DID collect rides along in the body instead (delivered
5906
+ // results verbatim), and `details.waitClaims` is still mounted, so a claim this
5907
+ // call took is never dropped on the floor.
5908
+ //
5909
+ // Whoever aborted FIRST still wins (contract §4): a caller abort keeps the frozen
5910
+ // `interrupted …` rendering even when mail landed a tick later (I5).
5911
+ if (
5912
+ mail &&
5913
+ combined?.reason === MAIL_PREEMPT_REASON &&
5914
+ signal?.aborted !== true
5915
+ ) {
5916
+ const released = takePendingMail(mail.db, mail.nodeId);
5917
+ // Empty = the postman took it during the abort. Nothing to hand over, so fall
5918
+ // through to the ordinary render rather than inventing a result.
5919
+ if (released.length > 0) {
5920
+ return mailPreemptedWait({
5921
+ released,
5922
+ names: targets.names,
5923
+ delivered: results.filter((result) => result.snapshot !== undefined),
5924
+ running: results
5925
+ .filter((result) => result.snapshot === undefined)
5926
+ .filter((result) => result.interrupted === true || result.stillRunning === true)
5927
+ .map((result) => result.name),
5928
+ claims,
5929
+ ...(claimsPending ? { pending: claimsPending } : {}),
5930
+ });
5931
+ }
5932
+ }
4166
5933
  const text = renderWait(results);
4167
- const details = buildWaitTimeoutDetails(results);
4168
- return details
4169
- ? { content: [{ type: "text", text }], details }
4170
- : ok(text);
5934
+ const timeoutDetails = buildWaitTimeoutDetails(results);
5935
+ // #352: `details.waitClaims` is the only record of what this call took — the tool
5936
+ // layer's abort net replaces the rendered result with the frozen `interrupted …`
5937
+ // line, and without this the claim it leaves behind suppresses that round's notice
5938
+ // forever (see `withWaitClaims`).
5939
+ const details: Record<string, unknown> = timeoutDetails ? { ...timeoutDetails } : {};
5940
+ return {
5941
+ content: [{ type: "text", text }],
5942
+ details: withWaitClaims(details, claims, claimsPending),
5943
+ };
4171
5944
  }
4172
5945
 
4173
5946
  export function renderAgentList(