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.
- package/CHANGELOG.md +858 -0
- package/CONTRIBUTING.md +33 -2
- package/README.md +10 -5
- package/agents/centurion.md +32 -7
- package/agents/scout.md +11 -0
- package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
- package/docs/contracts/centurion-declared-phases-v1.md +104 -0
- package/docs/contracts/declarative-phase-machine-v5.md +43 -0
- package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
- package/docs/contracts/finished-cache-eviction-v1.md +169 -0
- package/docs/contracts/galaxy-scratch-v1.md +78 -0
- package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
- package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
- package/docs/contracts/legion-page-floor-v1.md +129 -0
- package/docs/contracts/legion-session-continuity-v1.md +118 -0
- package/docs/contracts/mailbox-delivery-v1.md +162 -0
- package/docs/contracts/notice-claim-v1.md +206 -0
- package/docs/contracts/partial-failure-exit-v1.md +235 -0
- package/docs/contracts/proc-sweep-v1.md +107 -0
- package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
- package/docs/contracts/stall-visibility-v1.md +218 -0
- package/docs/contracts/subagent-renewal-v1.md +5 -3
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
- package/docs/contracts/viz-frame-alloc-v1.md +123 -0
- package/docs/contracts/wait-mail-preempt-v1.md +117 -0
- package/docs/contracts/wait-not-stop.md +1 -1
- package/docs/design.md +4 -1
- package/docs/inprocess-supervisor.md +9 -3
- package/docs/legion-v2-contract.md +122 -20
- package/docs/vexillatio-contract.md +5 -1
- package/index.ts +1903 -130
- package/package.json +5 -5
- package/skills/pi-legion/SKILL.md +2 -1
- package/src/agents/agents.ts +26 -1
- package/src/agents/settings.ts +67 -2
- package/src/api.ts +17 -0
- package/src/extension/child-guard.ts +2 -1
- package/src/extension/join.ts +54 -0
- package/src/extension/legion-commands.ts +15 -4
- package/src/extension/legion-doctor.ts +48 -6
- package/src/extension/legion-status.ts +44 -2
- package/src/extension/legion-tools.ts +1044 -109
- package/src/extension/notify.ts +31 -2
- package/src/extension/playbook.ts +6 -3
- package/src/extension/runtime.ts +882 -81
- package/src/extension/spawn-hints.ts +52 -12
- package/src/extension/summary.ts +7 -15
- package/src/extension/timing.ts +4 -15
- package/src/legion/db.ts +23 -1
- package/src/legion/events.ts +7 -0
- package/src/legion/interrupt.ts +98 -0
- package/src/legion/mail-release.ts +266 -0
- package/src/legion/mail.ts +456 -29
- package/src/legion/nodes.ts +31 -0
- package/src/legion/phase-conditions.ts +7 -1
- package/src/legion/phase-machine.ts +160 -8
- package/src/legion/state-machine.ts +34 -2
- package/src/runs/acceptance.ts +105 -6
- package/src/runs/args.ts +16 -0
- package/src/runs/child-scope.ts +323 -0
- package/src/runs/extension-inheritance.ts +145 -19
- package/src/runs/orchestrator.ts +613 -55
- package/src/runs/store.ts +149 -5
- package/src/shared/proc-sweep.ts +317 -0
- package/src/shared/progress.ts +11 -2
- package/src/shared/session-map.ts +29 -0
- package/src/shared/session.ts +308 -6
- package/src/shared/types.ts +34 -0
- package/src/supervisor/inprocess-supervisor.ts +158 -12
- package/src/supervisor/race-timeout.ts +55 -0
- package/src/supervisor/routing-supervisor.ts +55 -4
- package/src/supervisor/rpc-supervisor.ts +215 -29
- package/src/supervisor/types.ts +439 -2
- package/src/tui/status.ts +2 -1
- package/src/tui/viz/canvas.ts +358 -36
- package/src/tui/viz/palette.ts +18 -2
- package/src/tui/viz/panels.ts +507 -104
- package/src/tui/viz/rate.ts +120 -15
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1609
|
-
? (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",
|
|
1671
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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(
|
|
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
|
|
2504
|
+
...(input.legion ? { legion: { ...input.legion, runId, runStore: input.store } } : {}),
|
|
1842
2505
|
onBudgetRefused: input.onBudgetRefused,
|
|
1843
2506
|
});
|
|
1844
|
-
orchestrator.restore(
|
|
2507
|
+
orchestrator.restore(run);
|
|
1845
2508
|
await orchestrator.retire(name, merged);
|
|
1846
|
-
await persistChild(input.store,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
/**
|
|
2019
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
2035
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
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
|
-
|
|
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=`
|
|
2669
|
-
//
|
|
2670
|
-
//
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
//
|
|
4080
|
-
//
|
|
4081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5502
|
+
const resolved = stopResolutionFromJob(target);
|
|
5503
|
+
targets = resolved.targets;
|
|
5504
|
+
alreadyRetired = resolved.note;
|
|
4088
5505
|
}
|
|
4089
5506
|
}
|
|
4090
|
-
if (targets.length === 0)
|
|
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
|
|
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.
|
|
4102
|
-
|
|
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
|
|
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
|
|
4114
|
-
|
|
4115
|
-
|
|
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
|
|
4139
|
-
// side-effect free
|
|
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
|
|
4142
|
-
if (
|
|
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
|
-
|
|
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
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
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(
|