pi-legion 2.0.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LegionSupervisor backed by in-process AgentSession (#197).
|
|
3
|
+
*
|
|
4
|
+
* Uses createAgentSession + `SessionManager.open(input.sessionFile, ...)` so the
|
|
5
|
+
* child transcript lands on the file the Orchestrator pre-created (D7; an
|
|
6
|
+
* in-memory manager would leave it empty), or an injectable factory in unit
|
|
7
|
+
* tests. Production factory wires role tools / model / system prompt (#199).
|
|
8
|
+
* Does NOT use RpcClient. Process-tree / SIG retire is skipped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { formatChildTask } from "../extension/child-guard.ts";
|
|
12
|
+
import {
|
|
13
|
+
applyThinkingSuffix,
|
|
14
|
+
childInheritsExtensions,
|
|
15
|
+
collectChildExtensions,
|
|
16
|
+
} from "../runs/args.ts";
|
|
17
|
+
import {
|
|
18
|
+
ErrorCodes,
|
|
19
|
+
isThinkingLevel,
|
|
20
|
+
type InProcessLimits,
|
|
21
|
+
SubagentError,
|
|
22
|
+
DEFAULT_LEGION_SETTINGS,
|
|
23
|
+
} from "../shared/types.ts";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_INPROCESS_MAX_ACTIVE,
|
|
26
|
+
} from "./spawn-mode.ts";
|
|
27
|
+
import {
|
|
28
|
+
type ChildLaunchContext,
|
|
29
|
+
runInChildContext,
|
|
30
|
+
runOutsideChildContext,
|
|
31
|
+
} from "./child-context.ts";
|
|
32
|
+
import {
|
|
33
|
+
inProcessDepth,
|
|
34
|
+
runAtAbsoluteDepth,
|
|
35
|
+
runAtInProcessChildDepthAsync,
|
|
36
|
+
} from "./inprocess-depth.ts";
|
|
37
|
+
import {
|
|
38
|
+
InMemoryUIProxy,
|
|
39
|
+
type UIProxy,
|
|
40
|
+
} from "./ui-proxy.ts";
|
|
41
|
+
import type {
|
|
42
|
+
ChildHandle,
|
|
43
|
+
EventSubscriptionOptions,
|
|
44
|
+
LegionSupervisor,
|
|
45
|
+
SettleResult,
|
|
46
|
+
SpawnInput,
|
|
47
|
+
SupervisorEvent,
|
|
48
|
+
SupervisorEventListener,
|
|
49
|
+
UsageSnapshot,
|
|
50
|
+
} from "./types.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stats shape this supervisor reads. Permissive on purpose: it must be
|
|
54
|
+
* structurally assignable from the SDK's `SessionStats` (nested `tokens` +
|
|
55
|
+
* `cost`) AND from lightweight test doubles that report flat counters. Both
|
|
56
|
+
* shapes are declared, and both are actually read in `stats()` — a shape that
|
|
57
|
+
* is declared but never read would silently report zero (REV2-6).
|
|
58
|
+
*/
|
|
59
|
+
export interface InProcessSessionStats {
|
|
60
|
+
/** Flat counters (test doubles). */
|
|
61
|
+
tokensIn?: number;
|
|
62
|
+
tokensOut?: number;
|
|
63
|
+
costUsd?: number;
|
|
64
|
+
totalCost?: number;
|
|
65
|
+
/** SDK `SessionStats` shape. */
|
|
66
|
+
tokens?: {
|
|
67
|
+
input?: number;
|
|
68
|
+
output?: number;
|
|
69
|
+
cacheRead?: number;
|
|
70
|
+
cacheWrite?: number;
|
|
71
|
+
total?: number;
|
|
72
|
+
};
|
|
73
|
+
cost?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Narrow session surface InProcessSupervisor needs from AgentSession. */
|
|
77
|
+
export interface InProcessSession {
|
|
78
|
+
subscribe(listener: (event: unknown) => void): () => void;
|
|
79
|
+
prompt(text: string): Promise<unknown>;
|
|
80
|
+
steer?(text: string): Promise<unknown>;
|
|
81
|
+
followUp?(text: string): Promise<unknown>;
|
|
82
|
+
abort?(): Promise<unknown>;
|
|
83
|
+
dispose(): void;
|
|
84
|
+
getSessionStats?(): InProcessSessionStats | Promise<InProcessSessionStats>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Input to the production / injectable session factory (#199). */
|
|
88
|
+
export interface InProcessSessionFactoryInput {
|
|
89
|
+
name: string;
|
|
90
|
+
cwd: string;
|
|
91
|
+
agent: SpawnInput["agent"];
|
|
92
|
+
sessionFile: string;
|
|
93
|
+
/** Resolved model id from Orchestrator (same source as RPC `--model`). */
|
|
94
|
+
model?: string;
|
|
95
|
+
modelProvider?: string;
|
|
96
|
+
thinking?: string | false;
|
|
97
|
+
/**
|
|
98
|
+
* Settings-injected child context. Joined with the role prompt here the
|
|
99
|
+
* same way RPC's `pushSystemPromptArgs` does — Orchestrator must not also
|
|
100
|
+
* inject it through another path for the same spawn.
|
|
101
|
+
*/
|
|
102
|
+
childContext?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type InProcessSessionFactory = (
|
|
106
|
+
input: InProcessSessionFactoryInput,
|
|
107
|
+
) => Promise<InProcessSession>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pure createAgentSession plan derived from spawn input (#199).
|
|
111
|
+
* Unit-tested without constructing a real SDK session.
|
|
112
|
+
*/
|
|
113
|
+
export interface InProcessSessionPlan {
|
|
114
|
+
cwd: string;
|
|
115
|
+
/** Undefined → omit `tools` so the SDK keeps its default selection. */
|
|
116
|
+
tools?: string[];
|
|
117
|
+
modelRef?: string;
|
|
118
|
+
modelProvider?: string;
|
|
119
|
+
/** Combined role prompt (+ childContext); undefined when neither is set. */
|
|
120
|
+
systemPromptText?: string;
|
|
121
|
+
systemPromptMode: "replace" | "append";
|
|
122
|
+
noExtensions: boolean;
|
|
123
|
+
additionalExtensionPaths: string[];
|
|
124
|
+
noSkills: boolean;
|
|
125
|
+
noContextFiles: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build the v1 in-process session plan: role tools whitelist, resolved model
|
|
130
|
+
* ref, and system prompt (replace/append) aligned with RPC child argv.
|
|
131
|
+
*/
|
|
132
|
+
export function buildInProcessSessionPlan(
|
|
133
|
+
input: InProcessSessionFactoryInput,
|
|
134
|
+
): InProcessSessionPlan {
|
|
135
|
+
const tools = resolveInProcessTools(input.agent);
|
|
136
|
+
const modelRef = applyThinkingSuffix(input.model, input.thinking);
|
|
137
|
+
const { text: systemPromptText, mode: systemPromptMode } =
|
|
138
|
+
resolveInProcessSystemPrompt(input);
|
|
139
|
+
|
|
140
|
+
const noExtensions = !childInheritsExtensions(input.agent);
|
|
141
|
+
const additionalExtensionPaths = noExtensions
|
|
142
|
+
? collectChildExtensions(input.agent)
|
|
143
|
+
: [];
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
cwd: input.cwd,
|
|
147
|
+
...(tools ? { tools } : {}),
|
|
148
|
+
...(modelRef ? { modelRef } : {}),
|
|
149
|
+
...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
|
|
150
|
+
...(systemPromptText ? { systemPromptText } : {}),
|
|
151
|
+
systemPromptMode,
|
|
152
|
+
noExtensions,
|
|
153
|
+
additionalExtensionPaths,
|
|
154
|
+
noSkills: input.agent.inheritSkills === false,
|
|
155
|
+
noContextFiles: input.agent.inheritProjectContext === false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Mirror RPC `pushToolArgs` whitelist (+ vexillatio for non-utility roles). */
|
|
160
|
+
function resolveInProcessTools(
|
|
161
|
+
agent: SpawnInput["agent"],
|
|
162
|
+
): string[] | undefined {
|
|
163
|
+
const tools = (agent.tools ?? []).filter(Boolean);
|
|
164
|
+
if (tools.length === 0) return undefined;
|
|
165
|
+
if (!agent.utility && !tools.includes("vexillatio")) {
|
|
166
|
+
tools.push("vexillatio");
|
|
167
|
+
}
|
|
168
|
+
return tools;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Mirror RPC `pushSystemPromptArgs` join + mode rules. */
|
|
172
|
+
function resolveInProcessSystemPrompt(input: InProcessSessionFactoryInput): {
|
|
173
|
+
text?: string;
|
|
174
|
+
mode: "replace" | "append";
|
|
175
|
+
} {
|
|
176
|
+
const systemPrompt = input.agent.systemPrompt?.trim();
|
|
177
|
+
const childContext = input.childContext?.trim();
|
|
178
|
+
if (!systemPrompt && !childContext) {
|
|
179
|
+
return { mode: input.agent.systemPromptMode };
|
|
180
|
+
}
|
|
181
|
+
const text = [systemPrompt, childContext].filter(Boolean).join("\n\n");
|
|
182
|
+
// When only childContext is present, never replace the SDK base prompt.
|
|
183
|
+
const mode = systemPrompt
|
|
184
|
+
? input.agent.systemPromptMode
|
|
185
|
+
: input.agent.systemPromptMode === "replace"
|
|
186
|
+
? "append"
|
|
187
|
+
: input.agent.systemPromptMode;
|
|
188
|
+
return { text, mode };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface InProcessSupervisorOptions {
|
|
192
|
+
uiProxy?: UIProxy;
|
|
193
|
+
/**
|
|
194
|
+
* Injected in tests; production uses createAgentSession +
|
|
195
|
+
* `SessionManager.open(input.sessionFile, ...)` (D7 transcript persistence).
|
|
196
|
+
*/
|
|
197
|
+
sessionFactory?: InProcessSessionFactory;
|
|
198
|
+
/** Limits for this supervisor instance (inprocess.* only). */
|
|
199
|
+
limits?: InProcessLimits;
|
|
200
|
+
/** Resolve limits dynamically (settings reload). Wins over static `limits`. */
|
|
201
|
+
getLimits?: () => InProcessLimits | undefined;
|
|
202
|
+
onSpawnRefused?: (message: string) => void;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface EventSubscription {
|
|
206
|
+
listener: SupervisorEventListener;
|
|
207
|
+
includeTextDelta: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface SettleWaiter {
|
|
211
|
+
resolve(result: SettleResult): void;
|
|
212
|
+
timer?: NodeJS.Timeout;
|
|
213
|
+
cleanup?: () => void;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type ChildState = "starting" | "running" | "idle" | "retiring" | "exited" | "retired";
|
|
217
|
+
|
|
218
|
+
interface ChildRecord {
|
|
219
|
+
name: string;
|
|
220
|
+
sessionFile: string;
|
|
221
|
+
session: InProcessSession | null;
|
|
222
|
+
state: ChildState;
|
|
223
|
+
alive: boolean;
|
|
224
|
+
turnActive: boolean;
|
|
225
|
+
retireRequested: boolean;
|
|
226
|
+
lastTurnResult?: SettleResult;
|
|
227
|
+
exitResult?: SettleResult;
|
|
228
|
+
waiters: Set<SettleWaiter>;
|
|
229
|
+
subscriptions: Set<EventSubscription>;
|
|
230
|
+
unsubscribeSession?: () => void;
|
|
231
|
+
retirePromise?: Promise<void>;
|
|
232
|
+
abortedPending: boolean;
|
|
233
|
+
spawnDepth: number;
|
|
234
|
+
parentKey: string;
|
|
235
|
+
/** Identity reused by every later dispatch into this child (tools inherit it). */
|
|
236
|
+
context: ChildLaunchContext;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const COARSE_EVENT_TYPES = new Set([
|
|
240
|
+
"agent_start",
|
|
241
|
+
"agent_end",
|
|
242
|
+
"agent_settled",
|
|
243
|
+
"turn_start",
|
|
244
|
+
"turn_end",
|
|
245
|
+
"tool_execution_start",
|
|
246
|
+
"tool_execution_end",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
/** Parent key for maxChildrenPerNode (name before last `.`, or "" for roots). */
|
|
250
|
+
function parentKeyOf(name: string): string {
|
|
251
|
+
const idx = name.lastIndexOf(".");
|
|
252
|
+
return idx === -1 ? "" : name.slice(0, idx);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Identity scope for one in-process child (spawn-default-inprocess-v3 D5).
|
|
257
|
+
*
|
|
258
|
+
* A caller may hand over a full `childLaunchContext`; when it does not, we
|
|
259
|
+
* still establish a minimal context so that the child never observes "no
|
|
260
|
+
* identity" and mistakes itself for the root session. The fallback is derived
|
|
261
|
+
* from the spawn input only — it carries no db/node lineage, which is exactly
|
|
262
|
+
* why partial identity must not be mistaken for a complete one downstream.
|
|
263
|
+
*/
|
|
264
|
+
function resolveChildLaunchContext(input: SpawnInput): ChildLaunchContext {
|
|
265
|
+
const provided = input.childLaunchContext;
|
|
266
|
+
if (provided) {
|
|
267
|
+
// REV2-3: store a deep-enough snapshot, never the caller's live object.
|
|
268
|
+
// A caller that reuses one template object (or one `parentPath` array)
|
|
269
|
+
// across spawns would otherwise mutate the identity of an already-spawned
|
|
270
|
+
// child for its later dispatches — the A-becomes-B leak D5 exists to kill.
|
|
271
|
+
return {
|
|
272
|
+
...provided,
|
|
273
|
+
// Element-level copy too: after the path became `NestedPathEntry[]`
|
|
274
|
+
// (objects), `[...path]` would still alias every entry, so a caller
|
|
275
|
+
// mutating `template.parentPath[0].runId` would rewrite an
|
|
276
|
+
// already-spawned child's lineage — the exact A-becomes-B leak.
|
|
277
|
+
...(provided.parentPath
|
|
278
|
+
? { parentPath: provided.parentPath.map((entry) => ({ ...entry })) }
|
|
279
|
+
: {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const acceptanceRole = input.agent.acceptance?.role;
|
|
283
|
+
return {
|
|
284
|
+
allowNested: input.allowNestedSubagents === true,
|
|
285
|
+
utility: Boolean(input.agent.utility),
|
|
286
|
+
spawnMode: "inprocess",
|
|
287
|
+
...(acceptanceRole ? { acceptanceRole } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
292
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
293
|
+
? (value as Record<string, unknown>)
|
|
294
|
+
: null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function defaultSessionFactory(
|
|
298
|
+
input: InProcessSessionFactoryInput,
|
|
299
|
+
): Promise<InProcessSession> {
|
|
300
|
+
const {
|
|
301
|
+
createAgentSession,
|
|
302
|
+
SessionManager,
|
|
303
|
+
DefaultResourceLoader,
|
|
304
|
+
getAgentDir,
|
|
305
|
+
ModelRuntime,
|
|
306
|
+
resolveCliModel,
|
|
307
|
+
} = await import("@earendil-works/pi-coding-agent");
|
|
308
|
+
|
|
309
|
+
const plan = buildInProcessSessionPlan(input);
|
|
310
|
+
const agentDir = getAgentDir();
|
|
311
|
+
|
|
312
|
+
const loaderOptions: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
|
|
313
|
+
cwd: plan.cwd,
|
|
314
|
+
agentDir,
|
|
315
|
+
noExtensions: plan.noExtensions,
|
|
316
|
+
...(plan.additionalExtensionPaths.length > 0
|
|
317
|
+
? { additionalExtensionPaths: plan.additionalExtensionPaths }
|
|
318
|
+
: {}),
|
|
319
|
+
...(plan.noSkills ? { noSkills: true } : {}),
|
|
320
|
+
...(plan.noContextFiles ? { noContextFiles: true } : {}),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (plan.systemPromptText) {
|
|
324
|
+
if (plan.systemPromptMode === "replace") {
|
|
325
|
+
loaderOptions.systemPromptOverride = () => plan.systemPromptText;
|
|
326
|
+
// Avoid host APPEND_SYSTEM.md appending onto a replaced role prompt.
|
|
327
|
+
loaderOptions.appendSystemPromptOverride = () => [];
|
|
328
|
+
} else {
|
|
329
|
+
loaderOptions.appendSystemPromptOverride = (base) => [
|
|
330
|
+
...base,
|
|
331
|
+
plan.systemPromptText!,
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const resourceLoader = new DefaultResourceLoader(loaderOptions);
|
|
337
|
+
await resourceLoader.reload();
|
|
338
|
+
|
|
339
|
+
// D7 — the transcript must land on the file the Orchestrator pre-created
|
|
340
|
+
// (`orchestrator.ts` `preCreateSessionFile`). With `SessionManager.inMemory()`
|
|
341
|
+
// `getSessionFile()` returns undefined and nothing is ever written, so
|
|
342
|
+
// `deriveOutcome(parseSessionFile(...))` sees an empty file and every
|
|
343
|
+
// in-process child "succeeds with zero output". `open()` on the pre-created
|
|
344
|
+
// empty file works and reports that path. Deliberately no try/catch fallback
|
|
345
|
+
// to inMemory: a silent fallback would restore exactly that zero-output bug.
|
|
346
|
+
const createOptions: Parameters<typeof createAgentSession>[0] = {
|
|
347
|
+
cwd: plan.cwd,
|
|
348
|
+
sessionManager: SessionManager.open(input.sessionFile, undefined, plan.cwd),
|
|
349
|
+
resourceLoader,
|
|
350
|
+
...(plan.tools ? { tools: plan.tools } : {}),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (plan.modelRef) {
|
|
354
|
+
const modelRuntime = await ModelRuntime.create();
|
|
355
|
+
const resolved = resolveCliModel({
|
|
356
|
+
cliModel: plan.modelRef,
|
|
357
|
+
...(plan.modelProvider ? { cliProvider: plan.modelProvider } : {}),
|
|
358
|
+
modelRuntime,
|
|
359
|
+
});
|
|
360
|
+
if (resolved.error || !resolved.model) {
|
|
361
|
+
throw new SubagentError(
|
|
362
|
+
`inprocess model resolve failed for '${plan.modelRef}'` +
|
|
363
|
+
(resolved.error ? `: ${resolved.error}` : ""),
|
|
364
|
+
ErrorCodes.START_FAILED,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
createOptions.modelRuntime = modelRuntime;
|
|
368
|
+
createOptions.model = resolved.model;
|
|
369
|
+
if (resolved.thinkingLevel && isThinkingLevel(resolved.thinkingLevel)) {
|
|
370
|
+
createOptions.thinkingLevel = resolved.thinkingLevel;
|
|
371
|
+
} else if (
|
|
372
|
+
typeof input.thinking === "string" &&
|
|
373
|
+
isThinkingLevel(input.thinking)
|
|
374
|
+
) {
|
|
375
|
+
createOptions.thinkingLevel = input.thinking;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const { session } = await createAgentSession(createOptions);
|
|
380
|
+
return session;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Host-side in-process adapter for the frozen §3.1 LegionSupervisor contract.
|
|
385
|
+
*/
|
|
386
|
+
export class InProcessSupervisor implements LegionSupervisor {
|
|
387
|
+
private readonly children = new Map<string, ChildRecord>();
|
|
388
|
+
private readonly subscriptionsBeforeSpawn = new Map<string, Set<EventSubscription>>();
|
|
389
|
+
private readonly sessionFactory: InProcessSessionFactory;
|
|
390
|
+
private readonly getLimits: () => InProcessLimits | undefined;
|
|
391
|
+
private readonly onSpawnRefused?: (message: string) => void;
|
|
392
|
+
readonly uiProxy: UIProxy;
|
|
393
|
+
private spawnCount = 0;
|
|
394
|
+
|
|
395
|
+
constructor(options: InProcessSupervisorOptions = {}) {
|
|
396
|
+
this.sessionFactory = options.sessionFactory ?? defaultSessionFactory;
|
|
397
|
+
this.uiProxy = options.uiProxy ?? new InMemoryUIProxy();
|
|
398
|
+
this.getLimits = options.getLimits
|
|
399
|
+
?? (() => options.limits);
|
|
400
|
+
this.onSpawnRefused = options.onSpawnRefused;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Effective in-process limits.
|
|
405
|
+
*
|
|
406
|
+
* RECORDED CONTRACT CHANGE (spawn-default-inprocess-v3 D4): the old rule was
|
|
407
|
+
* "no `subagents.inprocess` block → unlimited". Now that inprocess is the
|
|
408
|
+
* default backend, "no config" would mean unbounded recursive spawning, so
|
|
409
|
+
* an absent block falls back to the same safe numbers RPC uses
|
|
410
|
+
* (`DEFAULT_LEGION_SETTINGS.maxDepth/maxChildrenPerNode/maxActiveNodes`).
|
|
411
|
+
*
|
|
412
|
+
* An *explicit* block keeps `DEFAULT_INPROCESS_MAX_ACTIVE` when it omits
|
|
413
|
+
* `maxActive` (existing behaviour; tests depend on it).
|
|
414
|
+
* `maxSpawnsPerSession` / `waitTimeoutMs` stay undefined by default — same
|
|
415
|
+
* asymmetry as the RPC knobs, which have no defaults either.
|
|
416
|
+
*/
|
|
417
|
+
private limits(): Required<
|
|
418
|
+
Pick<InProcessLimits, "maxDepth" | "maxChildrenPerNode" | "maxActive">
|
|
419
|
+
> &
|
|
420
|
+
InProcessLimits {
|
|
421
|
+
const configured = this.getLimits();
|
|
422
|
+
if (configured === undefined) {
|
|
423
|
+
return {
|
|
424
|
+
maxDepth: DEFAULT_LEGION_SETTINGS.maxDepth,
|
|
425
|
+
maxChildrenPerNode: DEFAULT_LEGION_SETTINGS.maxChildrenPerNode,
|
|
426
|
+
maxActive: DEFAULT_LEGION_SETTINGS.maxActiveNodes,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
...configured,
|
|
431
|
+
maxDepth: configured.maxDepth ?? DEFAULT_LEGION_SETTINGS.maxDepth,
|
|
432
|
+
maxChildrenPerNode:
|
|
433
|
+
configured.maxChildrenPerNode ??
|
|
434
|
+
DEFAULT_LEGION_SETTINGS.maxChildrenPerNode,
|
|
435
|
+
maxActive: configured.maxActive ?? DEFAULT_INPROCESS_MAX_ACTIVE,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async spawnChild(input: SpawnInput): Promise<ChildHandle> {
|
|
440
|
+
if (input.agent.kind !== "pi") {
|
|
441
|
+
throw new SubagentError(
|
|
442
|
+
`InProcessSupervisor only supports pi children (received kind '${input.agent.kind}')`,
|
|
443
|
+
ErrorCodes.INVALID_PARAMS,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (input.agent.worktree === true) {
|
|
447
|
+
throw new SubagentError(
|
|
448
|
+
`spawnMode "inprocess" cannot use worktree isolation for '${input.name}'; use spawnMode "rpc" or set worktree: false`,
|
|
449
|
+
ErrorCodes.INVALID_PARAMS,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.assertWithinLimits(input.name);
|
|
454
|
+
|
|
455
|
+
const previous = this.children.get(input.name);
|
|
456
|
+
if (previous && previous.state !== "exited" && previous.state !== "retired") {
|
|
457
|
+
throw new SubagentError(
|
|
458
|
+
`child is already registered and alive: ${input.name}`,
|
|
459
|
+
ErrorCodes.NAME_TAKEN,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (previous) this.disposeRecord(previous);
|
|
463
|
+
|
|
464
|
+
const registration: ChildRecord = {
|
|
465
|
+
name: input.name,
|
|
466
|
+
sessionFile: input.sessionFile,
|
|
467
|
+
session: null,
|
|
468
|
+
state: "starting",
|
|
469
|
+
alive: true,
|
|
470
|
+
turnActive: false,
|
|
471
|
+
retireRequested: false,
|
|
472
|
+
abortedPending: false,
|
|
473
|
+
waiters: new Set(),
|
|
474
|
+
subscriptions:
|
|
475
|
+
this.subscriptionsBeforeSpawn.get(input.name) ?? new Set(),
|
|
476
|
+
spawnDepth: inProcessDepth() + 1,
|
|
477
|
+
parentKey: parentKeyOf(input.name),
|
|
478
|
+
context: resolveChildLaunchContext(input),
|
|
479
|
+
};
|
|
480
|
+
this.subscriptionsBeforeSpawn.delete(input.name);
|
|
481
|
+
this.children.set(input.name, registration);
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
return await runAtInProcessChildDepthAsync(() =>
|
|
485
|
+
// Identity scope: the session factory and the first prompt both run
|
|
486
|
+
// inside it, so every tool the child executes during that turn
|
|
487
|
+
// inherits the child's context instead of seeing the root's.
|
|
488
|
+
runInChildContext(registration.context, async () => {
|
|
489
|
+
const session = await this.sessionFactory({
|
|
490
|
+
name: input.name,
|
|
491
|
+
cwd: input.cwd,
|
|
492
|
+
agent: input.agent,
|
|
493
|
+
sessionFile: input.sessionFile,
|
|
494
|
+
...(input.model !== undefined ? { model: input.model } : {}),
|
|
495
|
+
...(input.modelProvider !== undefined
|
|
496
|
+
? { modelProvider: input.modelProvider }
|
|
497
|
+
: {}),
|
|
498
|
+
...(input.thinking !== undefined
|
|
499
|
+
? { thinking: input.thinking }
|
|
500
|
+
: {}),
|
|
501
|
+
...(input.childContext !== undefined
|
|
502
|
+
? { childContext: input.childContext }
|
|
503
|
+
: {}),
|
|
504
|
+
});
|
|
505
|
+
registration.session = session;
|
|
506
|
+
registration.unsubscribeSession = session.subscribe((event) =>
|
|
507
|
+
this.handleSessionEvent(registration, event),
|
|
508
|
+
);
|
|
509
|
+
registration.state = "idle";
|
|
510
|
+
this.spawnCount += 1;
|
|
511
|
+
this.beginTurn(registration);
|
|
512
|
+
await session.prompt(
|
|
513
|
+
formatChildTask(input.task, {
|
|
514
|
+
allowNested: input.allowNestedSubagents === true,
|
|
515
|
+
...(input.worktreeBranch
|
|
516
|
+
? { worktreeBranch: input.worktreeBranch }
|
|
517
|
+
: {}),
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
if (!this.isAlive(input.name)) {
|
|
521
|
+
throw new SubagentError(
|
|
522
|
+
`in-process session ended after its first prompt: ${input.name}`,
|
|
523
|
+
ErrorCodes.START_FAILED,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
name: registration.name,
|
|
528
|
+
sessionFile: registration.sessionFile,
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
await this.rollbackSpawn(registration);
|
|
534
|
+
if (error instanceof SubagentError) {
|
|
535
|
+
this.onSpawnRefused?.(error.message);
|
|
536
|
+
}
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async prompt(name: string, text: string): Promise<void> {
|
|
542
|
+
const child = this.requireChild(name);
|
|
543
|
+
await this.dispatch(child, "prompt", text);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async steer(name: string, text: string): Promise<void> {
|
|
547
|
+
const child = this.requireChild(name);
|
|
548
|
+
await this.dispatch(child, "steer", text);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async followUp(name: string, text: string): Promise<void> {
|
|
552
|
+
const child = this.requireChild(name);
|
|
553
|
+
await this.dispatch(child, "followUp", text);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async abort(name: string): Promise<void> {
|
|
557
|
+
const child = this.requireChild(name);
|
|
558
|
+
this.assertCanSend(child);
|
|
559
|
+
if (!child.turnActive) return;
|
|
560
|
+
// Same identity scope and nesting depth as the child's own turn: abort can
|
|
561
|
+
// settle tool promises whose continuations still run inside it.
|
|
562
|
+
await runAtAbsoluteDepth(child.spawnDepth, () =>
|
|
563
|
+
runInChildContext(child.context, async () => {
|
|
564
|
+
await child.session?.abort?.();
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
waitSettled(name: string, timeoutMs = 900_000, signal?: AbortSignal): Promise<SettleResult> {
|
|
570
|
+
const child = this.requireChild(name);
|
|
571
|
+
return runAtAbsoluteDepth(child.spawnDepth, () =>
|
|
572
|
+
runInChildContext(child.context, () =>
|
|
573
|
+
this.awaitSettled(child, timeoutMs, signal),
|
|
574
|
+
),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private awaitSettled(
|
|
579
|
+
child: ChildRecord,
|
|
580
|
+
timeoutMs: number,
|
|
581
|
+
signal: AbortSignal | undefined,
|
|
582
|
+
): Promise<SettleResult> {
|
|
583
|
+
const name = child.name;
|
|
584
|
+
const limits = this.limits();
|
|
585
|
+
const effectiveTimeout =
|
|
586
|
+
timeoutMs === 900_000 && limits.waitTimeoutMs !== undefined
|
|
587
|
+
? limits.waitTimeoutMs
|
|
588
|
+
: timeoutMs;
|
|
589
|
+
if (signal?.aborted) {
|
|
590
|
+
return Promise.resolve({ settled: false, abnormal: false, reason: "aborted by parent" });
|
|
591
|
+
}
|
|
592
|
+
if (!child.turnActive && child.lastTurnResult) {
|
|
593
|
+
return Promise.resolve(child.lastTurnResult);
|
|
594
|
+
}
|
|
595
|
+
if (child.exitResult) return Promise.resolve(child.exitResult);
|
|
596
|
+
if (!this.isAlive(name)) {
|
|
597
|
+
return Promise.resolve(
|
|
598
|
+
child.exitResult ?? {
|
|
599
|
+
settled: false,
|
|
600
|
+
abnormal: !child.retireRequested,
|
|
601
|
+
reason: child.retireRequested ? "child retired" : "in-process session ended",
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
if (!child.turnActive) {
|
|
606
|
+
return Promise.resolve(
|
|
607
|
+
child.lastTurnResult ?? {
|
|
608
|
+
settled: false,
|
|
609
|
+
abnormal: false,
|
|
610
|
+
reason: "no active turn",
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return new Promise((resolve) => {
|
|
616
|
+
const waiter: SettleWaiter = { resolve };
|
|
617
|
+
let abortListener: (() => void) | undefined;
|
|
618
|
+
waiter.cleanup = () => {
|
|
619
|
+
if (waiter.timer) {
|
|
620
|
+
clearTimeout(waiter.timer);
|
|
621
|
+
waiter.timer = undefined;
|
|
622
|
+
}
|
|
623
|
+
if (signal && abortListener) {
|
|
624
|
+
signal.removeEventListener("abort", abortListener);
|
|
625
|
+
abortListener = undefined;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
child.waiters.add(waiter);
|
|
629
|
+
if (effectiveTimeout > 0) {
|
|
630
|
+
waiter.timer = setTimeout(() => {
|
|
631
|
+
child.waiters.delete(waiter);
|
|
632
|
+
waiter.cleanup?.();
|
|
633
|
+
resolve({ settled: false, abnormal: false, reason: "timeout" });
|
|
634
|
+
}, effectiveTimeout);
|
|
635
|
+
}
|
|
636
|
+
if (signal) {
|
|
637
|
+
abortListener = () => {
|
|
638
|
+
if (!child.waiters.delete(waiter)) return;
|
|
639
|
+
waiter.cleanup?.();
|
|
640
|
+
resolve({ settled: false, abnormal: false, reason: "aborted by parent" });
|
|
641
|
+
};
|
|
642
|
+
if (signal.aborted) abortListener();
|
|
643
|
+
else signal.addEventListener("abort", abortListener, { once: true });
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
isAlive(name: string): boolean {
|
|
649
|
+
const child = this.children.get(name);
|
|
650
|
+
if (!child || !child.alive) return false;
|
|
651
|
+
return child.state !== "retired" && child.state !== "exited";
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async stats(name: string): Promise<UsageSnapshot | null> {
|
|
655
|
+
const child = this.children.get(name);
|
|
656
|
+
if (!child || !child.session || !this.isAlive(name)) return null;
|
|
657
|
+
try {
|
|
658
|
+
const raw = await child.session.getSessionStats?.();
|
|
659
|
+
if (!raw) return null;
|
|
660
|
+
// REV2-6: the real AgentSession reports `tokens: {input, output}` + `cost`
|
|
661
|
+
// (agent-session.d.ts SessionStats), while test doubles use flat counters.
|
|
662
|
+
// Reading only the flat keys made production usage read as 0 and let
|
|
663
|
+
// stats() overwrite the correct usage the Orchestrator had already
|
|
664
|
+
// written to the transcript.
|
|
665
|
+
return {
|
|
666
|
+
tokensIn: Number(raw.tokensIn ?? raw.tokens?.input ?? 0),
|
|
667
|
+
tokensOut: Number(raw.tokensOut ?? raw.tokens?.output ?? 0),
|
|
668
|
+
costUsd: Number(raw.costUsd ?? raw.totalCost ?? raw.cost ?? 0),
|
|
669
|
+
};
|
|
670
|
+
} catch {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
retire(name: string, opts: { graceful?: boolean } = {}): Promise<void> {
|
|
676
|
+
const child = this.requireChild(name);
|
|
677
|
+
if (child.retirePromise) return child.retirePromise;
|
|
678
|
+
const pending = this.retireChild(child, opts.graceful !== false);
|
|
679
|
+
child.retirePromise = pending;
|
|
680
|
+
void pending.catch(() => {
|
|
681
|
+
if (child.retirePromise === pending) child.retirePromise = undefined;
|
|
682
|
+
});
|
|
683
|
+
return pending;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
onEvent(
|
|
687
|
+
name: string,
|
|
688
|
+
listener: SupervisorEventListener,
|
|
689
|
+
options: EventSubscriptionOptions = {},
|
|
690
|
+
): () => void {
|
|
691
|
+
const child = this.children.get(name);
|
|
692
|
+
const subscription: EventSubscription = {
|
|
693
|
+
listener,
|
|
694
|
+
includeTextDelta: options.includeTextDelta === true,
|
|
695
|
+
};
|
|
696
|
+
const subscriptions = child?.subscriptions ?? this.pendingSubscriptions(name);
|
|
697
|
+
subscriptions.add(subscription);
|
|
698
|
+
return () => {
|
|
699
|
+
subscriptions.delete(subscription);
|
|
700
|
+
if (
|
|
701
|
+
!child &&
|
|
702
|
+
subscriptions.size === 0 &&
|
|
703
|
+
this.subscriptionsBeforeSpawn.get(name) === subscriptions
|
|
704
|
+
) {
|
|
705
|
+
this.subscriptionsBeforeSpawn.delete(name);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private pendingSubscriptions(name: string): Set<EventSubscription> {
|
|
711
|
+
let subscriptions = this.subscriptionsBeforeSpawn.get(name);
|
|
712
|
+
if (!subscriptions) {
|
|
713
|
+
subscriptions = new Set();
|
|
714
|
+
this.subscriptionsBeforeSpawn.set(name, subscriptions);
|
|
715
|
+
}
|
|
716
|
+
return subscriptions;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private assertWithinLimits(name: string): void {
|
|
720
|
+
const limits = this.limits();
|
|
721
|
+
const active = [...this.children.values()].filter(
|
|
722
|
+
(c) => c.state !== "exited" && c.state !== "retired",
|
|
723
|
+
);
|
|
724
|
+
if (limits.maxSpawnsPerSession !== undefined && this.spawnCount >= limits.maxSpawnsPerSession) {
|
|
725
|
+
const message =
|
|
726
|
+
`inprocess spawn budget exhausted (${this.spawnCount}/${limits.maxSpawnsPerSession}); ` +
|
|
727
|
+
`raise subagents.inprocess.maxSpawnsPerSession to allow more`;
|
|
728
|
+
this.onSpawnRefused?.(message);
|
|
729
|
+
throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
|
|
730
|
+
}
|
|
731
|
+
if (limits.maxActive !== undefined && active.length >= limits.maxActive) {
|
|
732
|
+
const message =
|
|
733
|
+
`inprocess maxActive reached (${active.length}/${limits.maxActive}); ` +
|
|
734
|
+
`raise subagents.inprocess.maxActive to allow more`;
|
|
735
|
+
this.onSpawnRefused?.(message);
|
|
736
|
+
throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
|
|
737
|
+
}
|
|
738
|
+
const nextDepth = inProcessDepth() + 1;
|
|
739
|
+
if (limits.maxDepth !== undefined && nextDepth > limits.maxDepth) {
|
|
740
|
+
const message =
|
|
741
|
+
`inprocess nesting limit reached (depth ${inProcessDepth()}, max ${limits.maxDepth}); ` +
|
|
742
|
+
`raise subagents.inprocess.maxDepth to allow deeper nesting`;
|
|
743
|
+
this.onSpawnRefused?.(message);
|
|
744
|
+
throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
|
|
745
|
+
}
|
|
746
|
+
if (limits.maxChildrenPerNode !== undefined) {
|
|
747
|
+
const parent = parentKeyOf(name);
|
|
748
|
+
const siblings = active.filter((c) => c.parentKey === parent).length;
|
|
749
|
+
if (siblings >= limits.maxChildrenPerNode) {
|
|
750
|
+
const message =
|
|
751
|
+
`inprocess maxChildrenPerNode reached for '${parent || "<root>"}' ` +
|
|
752
|
+
`(${siblings}/${limits.maxChildrenPerNode})`;
|
|
753
|
+
this.onSpawnRefused?.(message);
|
|
754
|
+
throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private dispatch(
|
|
760
|
+
child: ChildRecord,
|
|
761
|
+
kind: "prompt" | "steer" | "followUp",
|
|
762
|
+
text: string,
|
|
763
|
+
): Promise<void> {
|
|
764
|
+
// Every turn is dispatched inside the child's identity scope AND at the
|
|
765
|
+
// child's recorded absolute nesting depth. The Orchestrator drives turn 2+
|
|
766
|
+
// from the root chain, where `inProcessDepth()` is 0; without pinning the
|
|
767
|
+
// recorded depth here a grandchild would look like a first-generation
|
|
768
|
+
// child and `maxDepth` would never fire (REV2-1).
|
|
769
|
+
return runAtAbsoluteDepth(child.spawnDepth, () =>
|
|
770
|
+
runInChildContext(child.context, () =>
|
|
771
|
+
this.dispatchInContext(child, kind, text),
|
|
772
|
+
),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private async dispatchInContext(
|
|
777
|
+
child: ChildRecord,
|
|
778
|
+
kind: "prompt" | "steer" | "followUp",
|
|
779
|
+
text: string,
|
|
780
|
+
): Promise<void> {
|
|
781
|
+
this.assertCanSend(child);
|
|
782
|
+
const session = child.session!;
|
|
783
|
+
const wasActive = child.turnActive;
|
|
784
|
+
if (!wasActive) this.beginTurn(child);
|
|
785
|
+
try {
|
|
786
|
+
if (kind === "followUp" && wasActive && session.followUp) {
|
|
787
|
+
await session.followUp(text);
|
|
788
|
+
} else if ((kind === "steer" || kind === "prompt") && wasActive && session.steer) {
|
|
789
|
+
await session.steer(text);
|
|
790
|
+
} else {
|
|
791
|
+
await session.prompt(text);
|
|
792
|
+
}
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (!wasActive && child.turnActive) {
|
|
795
|
+
child.turnActive = false;
|
|
796
|
+
child.state = "idle";
|
|
797
|
+
child.lastTurnResult = {
|
|
798
|
+
settled: false,
|
|
799
|
+
abnormal: false,
|
|
800
|
+
reason: "session command was rejected",
|
|
801
|
+
};
|
|
802
|
+
this.resolveWaiters(child, child.lastTurnResult);
|
|
803
|
+
}
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private beginTurn(child: ChildRecord): void {
|
|
809
|
+
child.lastTurnResult = undefined;
|
|
810
|
+
child.exitResult = undefined;
|
|
811
|
+
child.turnActive = true;
|
|
812
|
+
child.state = "running";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async rollbackSpawn(child: ChildRecord): Promise<void> {
|
|
816
|
+
child.state = "retiring";
|
|
817
|
+
child.alive = false;
|
|
818
|
+
child.turnActive = false;
|
|
819
|
+
child.subscriptions.clear();
|
|
820
|
+
child.unsubscribeSession?.();
|
|
821
|
+
child.unsubscribeSession = undefined;
|
|
822
|
+
this.uiProxy.forgetChild(child.name);
|
|
823
|
+
this.resolveWaiters(child, {
|
|
824
|
+
settled: false,
|
|
825
|
+
abnormal: true,
|
|
826
|
+
reason: "child spawn failed",
|
|
827
|
+
});
|
|
828
|
+
try {
|
|
829
|
+
child.session?.dispose();
|
|
830
|
+
} catch {
|
|
831
|
+
/* preserve launch error */
|
|
832
|
+
} finally {
|
|
833
|
+
child.session = null;
|
|
834
|
+
child.state = "exited";
|
|
835
|
+
if (this.children.get(child.name) === child) {
|
|
836
|
+
this.children.delete(child.name);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private assertCanSend(child: ChildRecord): void {
|
|
842
|
+
if (!this.isAlive(child.name) || child.retireRequested || !child.session) {
|
|
843
|
+
throw new SubagentError(
|
|
844
|
+
`child is not available: ${child.name}`,
|
|
845
|
+
ErrorCodes.NOT_FOUND,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private requireChild(name: string): ChildRecord {
|
|
851
|
+
const child = this.children.get(name);
|
|
852
|
+
if (!child) {
|
|
853
|
+
throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
|
|
854
|
+
}
|
|
855
|
+
return child;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private handleSessionEvent(child: ChildRecord, event: unknown): void {
|
|
859
|
+
const record = recordOf(event);
|
|
860
|
+
if (!record || typeof record.type !== "string") return;
|
|
861
|
+
this.updateTurnState(child, record);
|
|
862
|
+
this.publishEvent(child, record);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private updateTurnState(child: ChildRecord, event: Record<string, unknown>): void {
|
|
866
|
+
if (event.type === "agent_start" || event.type === "turn_start") {
|
|
867
|
+
child.turnActive = true;
|
|
868
|
+
if (event.type === "agent_start") child.abortedPending = false;
|
|
869
|
+
if (child.state !== "retiring") child.state = "running";
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (event.type === "turn_end" || event.type === "agent_end") {
|
|
873
|
+
child.abortedPending = this.eventContainsAbort(event, child.abortedPending);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (event.type !== "agent_settled") return;
|
|
877
|
+
const result: SettleResult = {
|
|
878
|
+
settled: true,
|
|
879
|
+
abnormal: false,
|
|
880
|
+
aborted: child.abortedPending,
|
|
881
|
+
};
|
|
882
|
+
child.abortedPending = false;
|
|
883
|
+
child.turnActive = false;
|
|
884
|
+
child.lastTurnResult = result;
|
|
885
|
+
if (child.state !== "retiring") child.state = "idle";
|
|
886
|
+
this.resolveWaiters(child, result);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private eventContainsAbort(
|
|
890
|
+
event: Record<string, unknown>,
|
|
891
|
+
previous: boolean,
|
|
892
|
+
): boolean {
|
|
893
|
+
const message = recordOf(event.message);
|
|
894
|
+
if (message?.stopReason === "aborted") return true;
|
|
895
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
896
|
+
return (
|
|
897
|
+
previous ||
|
|
898
|
+
messages.some((item) => recordOf(item)?.stopReason === "aborted")
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private publishEvent(child: ChildRecord, record: Record<string, unknown>): void {
|
|
903
|
+
// Sessions emit synchronously from inside the child's await chain, so a
|
|
904
|
+
// parent-side listener would otherwise run with the child's ALS identity
|
|
905
|
+
// (currentChildContext() would report the child). Host callbacks run at
|
|
906
|
+
// root identity (REV2-7).
|
|
907
|
+
runOutsideChildContext(() => this.publishEventAtRoot(child, record));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private publishEventAtRoot(
|
|
911
|
+
child: ChildRecord,
|
|
912
|
+
record: Record<string, unknown>,
|
|
913
|
+
): void {
|
|
914
|
+
const type = record.type;
|
|
915
|
+
if (typeof type !== "string") return;
|
|
916
|
+
if (type === "message_update") {
|
|
917
|
+
const assistantEvent = recordOf(record.assistantMessageEvent);
|
|
918
|
+
if (!assistantEvent || typeof assistantEvent.type !== "string") return;
|
|
919
|
+
const updateType = assistantEvent.type;
|
|
920
|
+
const isTextDelta = updateType === "text_delta";
|
|
921
|
+
const toolName =
|
|
922
|
+
typeof assistantEvent.toolName === "string"
|
|
923
|
+
? assistantEvent.toolName
|
|
924
|
+
: undefined;
|
|
925
|
+
if (!isTextDelta && !toolName) return;
|
|
926
|
+
const coarse: SupervisorEvent = {
|
|
927
|
+
name: child.name,
|
|
928
|
+
type,
|
|
929
|
+
updateType,
|
|
930
|
+
...(toolName ? { toolName } : {}),
|
|
931
|
+
};
|
|
932
|
+
const detailed: SupervisorEvent = {
|
|
933
|
+
name: child.name,
|
|
934
|
+
type,
|
|
935
|
+
updateType,
|
|
936
|
+
...(isTextDelta && typeof assistantEvent.delta === "string"
|
|
937
|
+
? { delta: assistantEvent.delta }
|
|
938
|
+
: {}),
|
|
939
|
+
...(toolName ? { toolName } : {}),
|
|
940
|
+
};
|
|
941
|
+
for (const subscription of [...child.subscriptions]) {
|
|
942
|
+
if (isTextDelta && !subscription.includeTextDelta) continue;
|
|
943
|
+
this.notify(subscription, isTextDelta ? detailed : coarse);
|
|
944
|
+
}
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (!COARSE_EVENT_TYPES.has(type)) return;
|
|
948
|
+
const event: SupervisorEvent = { name: child.name, type };
|
|
949
|
+
for (const key of ["toolName", "toolCallId", "aborted", "willRetry", "reason"]) {
|
|
950
|
+
if (record[key] !== undefined) event[key] = record[key];
|
|
951
|
+
}
|
|
952
|
+
for (const subscription of [...child.subscriptions]) {
|
|
953
|
+
this.notify(subscription, event);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private notify(subscription: EventSubscription, event: SupervisorEvent): void {
|
|
958
|
+
try {
|
|
959
|
+
subscription.listener(event);
|
|
960
|
+
} catch {
|
|
961
|
+
/* UI callback must not break lifecycle */
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private resolveWaiters(child: ChildRecord, result: SettleResult): void {
|
|
966
|
+
const waiters = [...child.waiters];
|
|
967
|
+
child.waiters.clear();
|
|
968
|
+
for (const waiter of waiters) {
|
|
969
|
+
waiter.cleanup?.();
|
|
970
|
+
waiter.resolve(result);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private async retireChild(child: ChildRecord, _graceful: boolean): Promise<void> {
|
|
975
|
+
if (child.state === "retired" || child.state === "exited") return;
|
|
976
|
+
child.retireRequested = true;
|
|
977
|
+
child.state = "retiring";
|
|
978
|
+
child.alive = false;
|
|
979
|
+
child.turnActive = false;
|
|
980
|
+
child.unsubscribeSession?.();
|
|
981
|
+
child.unsubscribeSession = undefined;
|
|
982
|
+
this.uiProxy.forgetChild(child.name);
|
|
983
|
+
const result: SettleResult = {
|
|
984
|
+
settled: false,
|
|
985
|
+
abnormal: false,
|
|
986
|
+
reason: "child retired",
|
|
987
|
+
};
|
|
988
|
+
child.exitResult = result;
|
|
989
|
+
child.lastTurnResult = result;
|
|
990
|
+
this.resolveWaiters(child, result);
|
|
991
|
+
try {
|
|
992
|
+
child.session?.dispose();
|
|
993
|
+
} catch {
|
|
994
|
+
/* dispose best-effort */
|
|
995
|
+
} finally {
|
|
996
|
+
child.session = null;
|
|
997
|
+
child.state = "retired";
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private disposeRecord(child: ChildRecord): void {
|
|
1002
|
+
child.unsubscribeSession?.();
|
|
1003
|
+
try {
|
|
1004
|
+
child.session?.dispose();
|
|
1005
|
+
} catch {
|
|
1006
|
+
/* ignore */
|
|
1007
|
+
}
|
|
1008
|
+
this.children.delete(child.name);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|