pi-legion 3.0.0 → 3.0.3
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 +163 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +23 -18
- package/agents/worker.md +6 -6
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +161 -0
- package/docs/design.md +39 -9
- package/docs/inprocess-supervisor.md +67 -5
- package/docs/legion-v2-contract.md +6 -3
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +149 -58
- package/package.json +4 -7
- package/src/agents/agents.ts +5 -5
- package/src/api.ts +6 -1
- package/src/extension/auto-doctor.ts +21 -3
- package/src/extension/join.ts +7 -18
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/playbook.ts +1 -1
- package/src/extension/runtime.ts +206 -8
- package/src/extension/spawn-hints.ts +25 -4
- package/src/runs/orchestrator.ts +78 -8
- package/src/runs/worktree.ts +5 -4
- package/src/shared/session.ts +83 -7
- package/src/shared/types.ts +17 -2
- package/src/supervisor/inprocess-supervisor.ts +449 -16
- package/src/supervisor/routing-supervisor.ts +1 -1
- package/src/supervisor/spawn-mode.ts +25 -0
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* Does NOT use RpcClient. Process-tree / SIG retire is skipped.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { statSync } from "node:fs";
|
|
11
12
|
import { formatChildTask } from "../extension/child-guard.ts";
|
|
13
|
+
import { parseSessionFile } from "../shared/session.ts";
|
|
12
14
|
import {
|
|
13
15
|
applyThinkingSuffix,
|
|
14
16
|
childInheritsExtensions,
|
|
@@ -22,7 +24,9 @@ import {
|
|
|
22
24
|
DEFAULT_LEGION_SETTINGS,
|
|
23
25
|
} from "../shared/types.ts";
|
|
24
26
|
import {
|
|
27
|
+
POLL_INTERVAL_MS,
|
|
25
28
|
DEFAULT_INPROCESS_MAX_ACTIVE,
|
|
29
|
+
DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS,
|
|
26
30
|
} from "./spawn-mode.ts";
|
|
27
31
|
import {
|
|
28
32
|
type ChildLaunchContext,
|
|
@@ -199,7 +203,25 @@ export interface InProcessSupervisorOptions {
|
|
|
199
203
|
limits?: InProcessLimits;
|
|
200
204
|
/** Resolve limits dynamically (settings reload). Wins over static `limits`. */
|
|
201
205
|
getLimits?: () => InProcessLimits | undefined;
|
|
206
|
+
/**
|
|
207
|
+
* Diagnostics for a child the supervisor could not use. Two cases:
|
|
208
|
+
* - a spawn refused before any session existed (budget / limits), and
|
|
209
|
+
* - since #227, a child whose **first turn failed at runtime** (the prompt
|
|
210
|
+
* was rejected after `spawnChild` already returned its handle).
|
|
211
|
+
*/
|
|
202
212
|
onSpawnRefused?: (message: string) => void;
|
|
213
|
+
/**
|
|
214
|
+
* Called at most once per spawn when the child had still not accepted its
|
|
215
|
+
* first prompt when `spawnStartTimeoutMs` elapsed (#227 D1). Not an error:
|
|
216
|
+
* the handle was returned anyway.
|
|
217
|
+
*/
|
|
218
|
+
onSpawnDelayed?: (message: string) => void;
|
|
219
|
+
/**
|
|
220
|
+
* How long to wait for first-prompt acceptance before returning anyway.
|
|
221
|
+
* Defaults to `DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS`; tests inject a
|
|
222
|
+
* short value instead of waiting 15s.
|
|
223
|
+
*/
|
|
224
|
+
spawnStartTimeoutMs?: number;
|
|
203
225
|
}
|
|
204
226
|
|
|
205
227
|
interface EventSubscription {
|
|
@@ -213,6 +235,29 @@ interface SettleWaiter {
|
|
|
213
235
|
cleanup?: () => void;
|
|
214
236
|
}
|
|
215
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Shared hand-off between the first turn's drain and the acceptance gate
|
|
240
|
+
* (#227 D1a).
|
|
241
|
+
*
|
|
242
|
+
* The drain is the only place that learns *how* the prompt ended, while the gate
|
|
243
|
+
* is the only place that knows whether the turn had been accepted by then.
|
|
244
|
+
* `rejected` is set by the drain; `released` is set by the gate once
|
|
245
|
+
* `spawnChild` has committed to returning a handle for this child — after that
|
|
246
|
+
* point a failure is a runtime failure (D2), never a spawn failure.
|
|
247
|
+
*/
|
|
248
|
+
interface FirstTurnGate {
|
|
249
|
+
/** The prompt rejected. */
|
|
250
|
+
rejected?: { message: string };
|
|
251
|
+
/** `spawnChild` is returning a handle; the failure is no longer a spawn error. */
|
|
252
|
+
released: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* The D2 runtime-failure bookkeeping has run for `rejected`. Exactly-once:
|
|
255
|
+
* a rejection can be recorded before the gate commits (so the drain defers)
|
|
256
|
+
* and still need applying afterwards.
|
|
257
|
+
*/
|
|
258
|
+
applied?: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
216
261
|
type ChildState = "starting" | "running" | "idle" | "retiring" | "exited" | "retired";
|
|
217
262
|
|
|
218
263
|
interface ChildRecord {
|
|
@@ -232,6 +277,12 @@ interface ChildRecord {
|
|
|
232
277
|
abortedPending: boolean;
|
|
233
278
|
spawnDepth: number;
|
|
234
279
|
parentKey: string;
|
|
280
|
+
/**
|
|
281
|
+
* Failure surfaced by an `error` event during the current turn (#227 D3).
|
|
282
|
+
* Cleared when the next turn starts and consumed by the next `agent_settled`,
|
|
283
|
+
* which is the only place it becomes visible (as `SettleResult.reason`).
|
|
284
|
+
*/
|
|
285
|
+
pendingFailure?: string;
|
|
235
286
|
/** Identity reused by every later dispatch into this child (tools inherit it). */
|
|
236
287
|
context: ChildLaunchContext;
|
|
237
288
|
}
|
|
@@ -294,6 +345,135 @@ function recordOf(value: unknown): Record<string, unknown> | null {
|
|
|
294
345
|
: null;
|
|
295
346
|
}
|
|
296
347
|
|
|
348
|
+
/** Human-readable text for a rejected prompt / emitted `error` payload. */
|
|
349
|
+
function failureMessage(error: unknown): string {
|
|
350
|
+
if (error instanceof Error) return error.message;
|
|
351
|
+
const record = recordOf(error);
|
|
352
|
+
if (record) {
|
|
353
|
+
// SDK `error` events carry the failing assistant message under `error`
|
|
354
|
+
// (or a bare `message`), and that message holds the provider text.
|
|
355
|
+
const nested = recordOf(record.error) ?? recordOf(record.message);
|
|
356
|
+
const text = nested?.errorMessage ?? record.message ?? record.error;
|
|
357
|
+
if (typeof text === "string" && text.trim() !== "") return text;
|
|
358
|
+
}
|
|
359
|
+
return String(error);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Provider registrations an extension queued during loading (#209-1).
|
|
364
|
+
*
|
|
365
|
+
* Structurally typed on purpose: this file reaches the SDK through a dynamic
|
|
366
|
+
* `import()` (no static SDK type imports), so only the three arrays the drain
|
|
367
|
+
* touches are declared — and `unknown` for the payloads, which are just
|
|
368
|
+
* forwarded to the model runtime.
|
|
369
|
+
*/
|
|
370
|
+
export interface ExtensionRegistrationQueues {
|
|
371
|
+
pendingProviderRegistrations: Array<{
|
|
372
|
+
name: string;
|
|
373
|
+
config: unknown;
|
|
374
|
+
extensionPath?: string;
|
|
375
|
+
}>;
|
|
376
|
+
pendingNativeProviderRegistrations: Array<{
|
|
377
|
+
provider: unknown;
|
|
378
|
+
extensionPath?: string;
|
|
379
|
+
}>;
|
|
380
|
+
pendingVirtualModelRegistrations: Array<{
|
|
381
|
+
definition: unknown;
|
|
382
|
+
extensionPath?: string;
|
|
383
|
+
}>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Minimal `ModelRuntime` surface the drain needs (structurally typed). */
|
|
387
|
+
export interface ModelRuntimeLike {
|
|
388
|
+
registerProvider(name: string, config: unknown): void;
|
|
389
|
+
registerNativeProvider(provider: unknown): void;
|
|
390
|
+
registerVirtualModel(definition: unknown): void;
|
|
391
|
+
/** Must reset the *synchronous* catalog snapshot the resolver reads. */
|
|
392
|
+
refresh(options?: { allowNetwork?: boolean }): Promise<unknown>;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The `{ type, message }` diagnostic shape the SDK's runtime APIs return. */
|
|
396
|
+
export interface DiagnosticLike {
|
|
397
|
+
type: "info" | "warning" | "error";
|
|
398
|
+
message: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function registrationDiagnostic(
|
|
402
|
+
what: string,
|
|
403
|
+
extensionPath: string | undefined,
|
|
404
|
+
error: unknown,
|
|
405
|
+
): DiagnosticLike {
|
|
406
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
407
|
+
return {
|
|
408
|
+
type: "error",
|
|
409
|
+
message: `Extension "${extensionPath ?? "<unknown>"}" failed to register ${what}: ${message}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Drain extension provider registrations into a `ModelRuntime` (#209-1).
|
|
415
|
+
*
|
|
416
|
+
* `DefaultResourceLoader.reload()` really does load and execute the extension
|
|
417
|
+
* files, but before the extension runner binds, `pi.registerProvider()` and
|
|
418
|
+
* friends only push onto `runtime.pending*Registrations`. Nothing in Legion
|
|
419
|
+
* binds that runner for a child session, so the queues were never consumed and
|
|
420
|
+
* an extension-provided provider (e.g. `pi-any-endpoint`) was invisible.
|
|
421
|
+
*
|
|
422
|
+
* Consuming the queues alone is not enough: `registerProvider()` kicks off its
|
|
423
|
+
* catalog refresh fire-and-forget (`void this.refresh(...)`) while
|
|
424
|
+
* `resolveCliModel()` reads the *synchronous* `getModels()` snapshot. The
|
|
425
|
+
* awaited offline refresh below is what makes the extension's self-discovered
|
|
426
|
+
* catalog land in that snapshot (`allowNetwork: false` — `pi-any-endpoint`
|
|
427
|
+
* reads offline `stored.models`, so no network is involved).
|
|
428
|
+
*
|
|
429
|
+
* Failures are collected as diagnostics and never thrown: a broken extension
|
|
430
|
+
* must not fail the child spawn. Queues are emptied either way so a retry does
|
|
431
|
+
* not replay them onto the next runtime.
|
|
432
|
+
*/
|
|
433
|
+
export async function drainExtensionRegistrations(
|
|
434
|
+
resourceLoader: { getExtensions(): { runtime: ExtensionRegistrationQueues } },
|
|
435
|
+
modelRuntime: ModelRuntimeLike,
|
|
436
|
+
): Promise<DiagnosticLike[]> {
|
|
437
|
+
const diagnostics: DiagnosticLike[] = [];
|
|
438
|
+
const { runtime } = resourceLoader.getExtensions();
|
|
439
|
+
|
|
440
|
+
for (const { name, config, extensionPath } of runtime.pendingProviderRegistrations) {
|
|
441
|
+
try {
|
|
442
|
+
modelRuntime.registerProvider(name, config);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
diagnostics.push(
|
|
445
|
+
registrationDiagnostic(`provider "${name}"`, extensionPath, error),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
runtime.pendingProviderRegistrations = [];
|
|
450
|
+
|
|
451
|
+
for (const { provider, extensionPath } of runtime.pendingNativeProviderRegistrations) {
|
|
452
|
+
try {
|
|
453
|
+
modelRuntime.registerNativeProvider(provider);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
diagnostics.push(
|
|
456
|
+
registrationDiagnostic("native provider", extensionPath, error),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
runtime.pendingNativeProviderRegistrations = [];
|
|
461
|
+
|
|
462
|
+
for (const { definition, extensionPath } of runtime.pendingVirtualModelRegistrations) {
|
|
463
|
+
try {
|
|
464
|
+
modelRuntime.registerVirtualModel(definition);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
diagnostics.push(
|
|
467
|
+
registrationDiagnostic("virtual model", extensionPath, error),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
runtime.pendingVirtualModelRegistrations = [];
|
|
472
|
+
|
|
473
|
+
await modelRuntime.refresh({ allowNetwork: false });
|
|
474
|
+
return diagnostics;
|
|
475
|
+
}
|
|
476
|
+
|
|
297
477
|
async function defaultSessionFactory(
|
|
298
478
|
input: InProcessSessionFactoryInput,
|
|
299
479
|
): Promise<InProcessSession> {
|
|
@@ -336,6 +516,24 @@ async function defaultSessionFactory(
|
|
|
336
516
|
const resourceLoader = new DefaultResourceLoader(loaderOptions);
|
|
337
517
|
await resourceLoader.reload();
|
|
338
518
|
|
|
519
|
+
// #209-1: the extension files above were loaded and executed, but their
|
|
520
|
+
// provider registrations are still sitting in `runtime.pending*Registrations`
|
|
521
|
+
// (they only take effect once an extension runner binds, which a child
|
|
522
|
+
// session without `createAgentSessionServices()` never does). Drain them into
|
|
523
|
+
// the runtime handed to `createAgentSession` so extension-provided providers
|
|
524
|
+
// are resolvable by both the `--model` resolve below and the session itself.
|
|
525
|
+
const modelRuntime = await ModelRuntime.create();
|
|
526
|
+
for (const diagnostic of await drainExtensionRegistrations(
|
|
527
|
+
resourceLoader,
|
|
528
|
+
modelRuntime,
|
|
529
|
+
)) {
|
|
530
|
+
if (diagnostic.type === "error") {
|
|
531
|
+
// Non-fatal by design (same as the SDK): the spawn only fails if the
|
|
532
|
+
// model itself cannot be resolved.
|
|
533
|
+
console.warn(`[pi-legion] inprocess child '${input.name}': ${diagnostic.message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
339
537
|
// D7 — the transcript must land on the file the Orchestrator pre-created
|
|
340
538
|
// (`orchestrator.ts` `preCreateSessionFile`). With `SessionManager.inMemory()`
|
|
341
539
|
// `getSessionFile()` returns undefined and nothing is ever written, so
|
|
@@ -347,11 +545,16 @@ async function defaultSessionFactory(
|
|
|
347
545
|
cwd: plan.cwd,
|
|
348
546
|
sessionManager: SessionManager.open(input.sessionFile, undefined, plan.cwd),
|
|
349
547
|
resourceLoader,
|
|
548
|
+
// Injected unconditionally — including when `plan.modelRef` is empty. Left
|
|
549
|
+
// out, `createAgentSession` would build its own runtime and be blind to
|
|
550
|
+
// the extension providers drained above all over again.
|
|
551
|
+
modelRuntime,
|
|
350
552
|
...(plan.tools ? { tools: plan.tools } : {}),
|
|
351
553
|
};
|
|
352
554
|
|
|
353
555
|
if (plan.modelRef) {
|
|
354
|
-
|
|
556
|
+
// Same runtime instance as the drain: its snapshot is the one that
|
|
557
|
+
// contains the extension-registered providers.
|
|
355
558
|
const resolved = resolveCliModel({
|
|
356
559
|
cliModel: plan.modelRef,
|
|
357
560
|
...(plan.modelProvider ? { cliProvider: plan.modelProvider } : {}),
|
|
@@ -364,7 +567,6 @@ async function defaultSessionFactory(
|
|
|
364
567
|
ErrorCodes.START_FAILED,
|
|
365
568
|
);
|
|
366
569
|
}
|
|
367
|
-
createOptions.modelRuntime = modelRuntime;
|
|
368
570
|
createOptions.model = resolved.model;
|
|
369
571
|
if (resolved.thinkingLevel && isThinkingLevel(resolved.thinkingLevel)) {
|
|
370
572
|
createOptions.thinkingLevel = resolved.thinkingLevel;
|
|
@@ -389,6 +591,8 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
389
591
|
private readonly sessionFactory: InProcessSessionFactory;
|
|
390
592
|
private readonly getLimits: () => InProcessLimits | undefined;
|
|
391
593
|
private readonly onSpawnRefused?: (message: string) => void;
|
|
594
|
+
private readonly onSpawnDelayed?: (message: string) => void;
|
|
595
|
+
private readonly spawnStartTimeoutMs: number;
|
|
392
596
|
readonly uiProxy: UIProxy;
|
|
393
597
|
private spawnCount = 0;
|
|
394
598
|
|
|
@@ -398,6 +602,9 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
398
602
|
this.getLimits = options.getLimits
|
|
399
603
|
?? (() => options.limits);
|
|
400
604
|
this.onSpawnRefused = options.onSpawnRefused;
|
|
605
|
+
this.onSpawnDelayed = options.onSpawnDelayed;
|
|
606
|
+
this.spawnStartTimeoutMs =
|
|
607
|
+
options.spawnStartTimeoutMs ?? DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS;
|
|
401
608
|
}
|
|
402
609
|
|
|
403
610
|
/**
|
|
@@ -445,7 +652,7 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
445
652
|
}
|
|
446
653
|
if (input.agent.worktree === true) {
|
|
447
654
|
throw new SubagentError(
|
|
448
|
-
`spawnMode "inprocess" cannot use worktree isolation for '${input.name}';
|
|
655
|
+
`spawnMode "inprocess" cannot use worktree isolation for '${input.name}'; set worktree: false`,
|
|
449
656
|
ErrorCodes.INVALID_PARAMS,
|
|
450
657
|
);
|
|
451
658
|
}
|
|
@@ -486,6 +693,11 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
486
693
|
// inside it, so every tool the child executes during that turn
|
|
487
694
|
// inherits the child's context instead of seeing the root's.
|
|
488
695
|
runInChildContext(registration.context, async () => {
|
|
696
|
+
// #227 D1: the acceptance baseline is the transcript size at the
|
|
697
|
+
// moment *this spawn* starts (a missing file counts as 0), so a
|
|
698
|
+
// line the factory or a previous turn already wrote cannot pass
|
|
699
|
+
// for this child having accepted the prompt.
|
|
700
|
+
const baselineSize = this.sessionFileSize(registration.sessionFile);
|
|
489
701
|
const session = await this.sessionFactory({
|
|
490
702
|
name: input.name,
|
|
491
703
|
cwd: input.cwd,
|
|
@@ -508,21 +720,34 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
508
720
|
);
|
|
509
721
|
registration.state = "idle";
|
|
510
722
|
this.spawnCount += 1;
|
|
723
|
+
// `beginTurn` must precede the prompt (R2): the turn's events are
|
|
724
|
+
// already streaming by the time this frame yields.
|
|
511
725
|
this.beginTurn(registration);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
726
|
+
// #227 D1/D2: start the first turn and hand it to a background
|
|
727
|
+
// drain instead of awaiting the whole agent run here. The drain is
|
|
728
|
+
// created *inside* this ALS scope on purpose, so every later
|
|
729
|
+
// continuation (and any tool promise it settles) still runs at the
|
|
730
|
+
// child's identity and nesting depth (R1).
|
|
731
|
+
const gate: FirstTurnGate = { released: false };
|
|
732
|
+
const promptSettled = this.drainFirstTurn(
|
|
733
|
+
registration,
|
|
734
|
+
session.prompt(
|
|
735
|
+
formatChildTask(input.task, {
|
|
736
|
+
allowNested: input.allowNestedSubagents === true,
|
|
737
|
+
...(input.worktreeBranch
|
|
738
|
+
? { worktreeBranch: input.worktreeBranch }
|
|
739
|
+
: {}),
|
|
740
|
+
}),
|
|
741
|
+
),
|
|
742
|
+
gate,
|
|
743
|
+
);
|
|
744
|
+
// Return once the child has *accepted* the turn, not when it ends.
|
|
745
|
+
await this.awaitFirstTurnAccepted(
|
|
746
|
+
registration,
|
|
747
|
+
baselineSize,
|
|
748
|
+
promptSettled,
|
|
749
|
+
gate,
|
|
519
750
|
);
|
|
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
751
|
return {
|
|
527
752
|
name: registration.name,
|
|
528
753
|
sessionFile: registration.sessionFile,
|
|
@@ -538,6 +763,195 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
538
763
|
}
|
|
539
764
|
}
|
|
540
765
|
|
|
766
|
+
/** Bytes in the child transcript so far; a missing file counts as 0 (#227 D1). */
|
|
767
|
+
private sessionFileSize(sessionFile: string): number {
|
|
768
|
+
try {
|
|
769
|
+
return statSync(sessionFile).size;
|
|
770
|
+
} catch {
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Has this child accepted the prompt? True once its transcript holds a user
|
|
777
|
+
* turn *and* has grown past the pre-spawn baseline (`F7`: the SDK appends the
|
|
778
|
+
* user message before running the agent).
|
|
779
|
+
*
|
|
780
|
+
* Contract F7 forbids using `isLastTurnComplete` here: while the prompt is
|
|
781
|
+
* merely accepted the last turn legitimately has a user message and no
|
|
782
|
+
* assistant yet, so that predicate would contradict itself.
|
|
783
|
+
*/
|
|
784
|
+
private firstTurnAccepted(child: ChildRecord, baselineSize: number): boolean {
|
|
785
|
+
if (this.sessionFileSize(child.sessionFile) <= baselineSize) return false;
|
|
786
|
+
return parseSessionFile(child.sessionFile).turns.length > 0;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Resolve when the child has accepted the first prompt, when the prompt has
|
|
791
|
+
* already settled (it then certainly was accepted — a fast turn is done and
|
|
792
|
+
* waiting for the bound would only add latency), or when the start bound
|
|
793
|
+
* elapses (#227 D1). The bound is never an error: the caller returns its
|
|
794
|
+
* handle either way and is told once through `onSpawnDelayed`.
|
|
795
|
+
*/
|
|
796
|
+
private awaitFirstTurnAccepted(
|
|
797
|
+
child: ChildRecord,
|
|
798
|
+
baselineSize: number,
|
|
799
|
+
promptSettled: Promise<void>,
|
|
800
|
+
gate: FirstTurnGate,
|
|
801
|
+
): Promise<void> {
|
|
802
|
+
const deadline = Date.now() + this.spawnStartTimeoutMs;
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
let timer: NodeJS.Timeout | undefined;
|
|
805
|
+
let done = false;
|
|
806
|
+
/**
|
|
807
|
+
* Commit to returning a handle for this child. From here on the drain
|
|
808
|
+
* treats a later failure as a runtime failure (D2), not a spawn error.
|
|
809
|
+
*/
|
|
810
|
+
const finish = (delayed: boolean): void => {
|
|
811
|
+
if (done) return;
|
|
812
|
+
done = true;
|
|
813
|
+
gate.released = true;
|
|
814
|
+
if (timer) {
|
|
815
|
+
clearTimeout(timer);
|
|
816
|
+
timer = undefined;
|
|
817
|
+
}
|
|
818
|
+
// A rejection recorded before this commit still has to become the
|
|
819
|
+
// visible runtime failure (D2): the drain deferred it because the
|
|
820
|
+
// spawn had not yet committed to returning a handle.
|
|
821
|
+
this.applyFirstTurnFailure(child, gate);
|
|
822
|
+
if (delayed) {
|
|
823
|
+
this.onSpawnDelayed?.(
|
|
824
|
+
`in-process child '${child.name}' did not accept its first prompt within ` +
|
|
825
|
+
`${this.spawnStartTimeoutMs}ms; returning the handle anyway (its first turn ` +
|
|
826
|
+
"keeps running in the background)",
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
resolve();
|
|
830
|
+
};
|
|
831
|
+
/**
|
|
832
|
+
* #227 D1a: the prompt failed and nothing was accepted, so this child
|
|
833
|
+
* never started. Returning a handle here would let `Orchestrator.launch`
|
|
834
|
+
* believe the spawn succeeded and silently disable the `fallbackModels`
|
|
835
|
+
* chain (`canFallbackStart` only reacts to a thrown SubagentError). Fail
|
|
836
|
+
* the spawn instead; the caller's existing catch rolls the child back.
|
|
837
|
+
*/
|
|
838
|
+
const fail = (message: string): void => {
|
|
839
|
+
done = true;
|
|
840
|
+
if (timer) {
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
timer = undefined;
|
|
843
|
+
}
|
|
844
|
+
reject(new SubagentError(message, ErrorCodes.START_FAILED));
|
|
845
|
+
};
|
|
846
|
+
const tick = (): void => {
|
|
847
|
+
if (done) return;
|
|
848
|
+
if (this.firstTurnAccepted(child, baselineSize)) return finish(false);
|
|
849
|
+
if (gate.rejected) return fail(gate.rejected.message);
|
|
850
|
+
const remaining = deadline - Date.now();
|
|
851
|
+
if (remaining <= 0) return finish(true);
|
|
852
|
+
// Reuse the shared poll cadence, but never oversleep the bound.
|
|
853
|
+
timer = setTimeout(tick, Math.min(POLL_INTERVAL_MS, remaining));
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* The prompt settled. `drainFirstTurn` reports the rejection through
|
|
857
|
+
* `gate` and then resolves, so this is the one place that decides
|
|
858
|
+
* between "spawn succeeded" and "spawn failed" (#227 D1a).
|
|
859
|
+
*
|
|
860
|
+
* A plain `finish(false)` here would be exactly the regression: a
|
|
861
|
+
* pre-acceptance reject would read as a successful spawn.
|
|
862
|
+
*/
|
|
863
|
+
const onSettled = (): void => {
|
|
864
|
+
if (done) return;
|
|
865
|
+
// Accepted at any point → the turn ran, so a failure is a runtime
|
|
866
|
+
// failure (D2) and `spawnChild` still returns its handle.
|
|
867
|
+
if (this.firstTurnAccepted(child, baselineSize)) return finish(false);
|
|
868
|
+
// Rejected without ever being accepted → the child never started.
|
|
869
|
+
// Report it as a spawn failure so `canFallbackStart` can retry.
|
|
870
|
+
if (gate.rejected) return fail(gate.rejected.message);
|
|
871
|
+
// Prompt resolved but no user line is visible yet (a fake whose
|
|
872
|
+
// transcript is not on disk, or a write still in flight): the turn
|
|
873
|
+
// ended, so there is nothing left to wait for.
|
|
874
|
+
finish(false);
|
|
875
|
+
};
|
|
876
|
+
// `drainFirstTurn` deliberately resolves rather than rejects, and the
|
|
877
|
+
// second handler keeps a throwing diagnostic callback from becoming an
|
|
878
|
+
// unhandled rejection on a promise nobody awaits.
|
|
879
|
+
void promptSettled.then(onSettled, onSettled);
|
|
880
|
+
tick();
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Detach the first turn's completion from `spawnChild` (#227 D2).
|
|
886
|
+
*
|
|
887
|
+
* Resolves (never rejects) once the prompt settles, so both callers can
|
|
888
|
+
* await it without risking an unhandled rejection.
|
|
889
|
+
*/
|
|
890
|
+
private drainFirstTurn(
|
|
891
|
+
child: ChildRecord,
|
|
892
|
+
prompt: Promise<unknown>,
|
|
893
|
+
gate: FirstTurnGate,
|
|
894
|
+
): Promise<void> {
|
|
895
|
+
return prompt.then(
|
|
896
|
+
() => {
|
|
897
|
+
// Normal completion. `agent_settled` already carried the turn state
|
|
898
|
+
// (the SDK emits it in the run's `finally`, before this resolves), so
|
|
899
|
+
// only a child that died mid-turn needs reconciling here.
|
|
900
|
+
if (!this.isAlive(child.name) && child.turnActive) {
|
|
901
|
+
child.turnActive = false;
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
(error: unknown) => {
|
|
905
|
+
const message = failureMessage(error);
|
|
906
|
+
// #227 D1a: publish the failure for the acceptance gate *first*, so
|
|
907
|
+
// the gate can turn a pre-acceptance reject into a spawn failure.
|
|
908
|
+
gate.rejected = { message };
|
|
909
|
+
// Until `spawnChild` commits to returning a handle, this is a spawn
|
|
910
|
+
// failure: the caller's catch runs `rollbackSpawn` + rethrow, and it
|
|
911
|
+
// owns the waiter/diagnostic reporting. Doing it here as well would
|
|
912
|
+
// report twice and leave state on a child that is being torn down.
|
|
913
|
+
if (!gate.released) return;
|
|
914
|
+
this.applyFirstTurnFailure(child, gate);
|
|
915
|
+
},
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Turn a rejected first prompt into the visible runtime failure (#227 D2).
|
|
921
|
+
*
|
|
922
|
+
* Exactly-once per rejection, because it can be reached from two places: the
|
|
923
|
+
* drain (the usual path, after `spawnChild` committed to a handle) and
|
|
924
|
+
* `awaitFirstTurnAccepted`'s `finish()` (a prompt that rejected before the
|
|
925
|
+
* gate committed and then had its turn accepted anyway).
|
|
926
|
+
*
|
|
927
|
+
* Never `rollbackSpawn` here: the handle was already handed to the caller,
|
|
928
|
+
* and rollback would `children.delete` it out from under them.
|
|
929
|
+
*/
|
|
930
|
+
private applyFirstTurnFailure(child: ChildRecord, gate: FirstTurnGate): void {
|
|
931
|
+
const message = gate.rejected?.message;
|
|
932
|
+
if (message === undefined || gate.applied) return;
|
|
933
|
+
gate.applied = true;
|
|
934
|
+
child.turnActive = false;
|
|
935
|
+
if (child.state !== "retiring") child.state = "idle";
|
|
936
|
+
const result: SettleResult = {
|
|
937
|
+
settled: false,
|
|
938
|
+
abnormal: false,
|
|
939
|
+
reason: `child turn failed: ${message}`,
|
|
940
|
+
};
|
|
941
|
+
child.lastTurnResult = result;
|
|
942
|
+
this.resolveWaiters(child, result);
|
|
943
|
+
// #227 D2: the callback's semantics now also cover a child failing at
|
|
944
|
+
// runtime, not just a refused spawn.
|
|
945
|
+
this.onSpawnRefused?.(message);
|
|
946
|
+
// ...and leave a machine-readable trace for D3's settle mapping, so the
|
|
947
|
+
// failure survives a later `agent_settled` rather than only living in the
|
|
948
|
+
// transient `lastTurnResult`.
|
|
949
|
+
this.handleSessionEvent(child, {
|
|
950
|
+
type: "error",
|
|
951
|
+
message: `child turn failed: ${message}`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
541
955
|
async prompt(name: string, text: string): Promise<void> {
|
|
542
956
|
const child = this.requireChild(name);
|
|
543
957
|
await this.dispatch(child, "prompt", text);
|
|
@@ -806,8 +1220,11 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
806
1220
|
}
|
|
807
1221
|
|
|
808
1222
|
private beginTurn(child: ChildRecord): void {
|
|
1223
|
+
// "clear first, set later" (R2): a stale result from a previous turn must
|
|
1224
|
+
// never satisfy the `waitSettled` fast path for the new one.
|
|
809
1225
|
child.lastTurnResult = undefined;
|
|
810
1226
|
child.exitResult = undefined;
|
|
1227
|
+
child.pendingFailure = undefined;
|
|
811
1228
|
child.turnActive = true;
|
|
812
1229
|
child.state = "running";
|
|
813
1230
|
}
|
|
@@ -873,12 +1290,28 @@ export class InProcessSupervisor implements LegionSupervisor {
|
|
|
873
1290
|
child.abortedPending = this.eventContainsAbort(event, child.abortedPending);
|
|
874
1291
|
return;
|
|
875
1292
|
}
|
|
1293
|
+
if (event.type === "error") {
|
|
1294
|
+
// #227 D3: `error` used to fall through to the bottom `return` and be
|
|
1295
|
+
// dropped entirely (F8), so a failed child looked identical to a clean
|
|
1296
|
+
// one once `agent_settled` landed. Remember it and let the settle below
|
|
1297
|
+
// turn it into a visible fact. Deliberately NOT read from ALS: an
|
|
1298
|
+
// `error` can arrive on the drain chain, outside this child's scope.
|
|
1299
|
+
child.pendingFailure = failureMessage(event);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
876
1302
|
if (event.type !== "agent_settled") return;
|
|
877
1303
|
const result: SettleResult = {
|
|
878
1304
|
settled: true,
|
|
879
1305
|
abnormal: false,
|
|
880
1306
|
aborted: child.abortedPending,
|
|
881
1307
|
};
|
|
1308
|
+
// Only surface `reason` when there is a recorded failure: adding it
|
|
1309
|
+
// unconditionally would change the shape every clean settle returns, and
|
|
1310
|
+
// `aborted` stays the sole stop-reason signal (no invented stopReason).
|
|
1311
|
+
if (!result.aborted && child.pendingFailure) {
|
|
1312
|
+
result.reason = child.pendingFailure;
|
|
1313
|
+
}
|
|
1314
|
+
child.pendingFailure = undefined;
|
|
882
1315
|
child.abortedPending = false;
|
|
883
1316
|
child.turnActive = false;
|
|
884
1317
|
child.lastTurnResult = result;
|
|
@@ -78,7 +78,7 @@ export class RoutingSupervisor implements LegionSupervisor {
|
|
|
78
78
|
}
|
|
79
79
|
if (input.agent.worktree === true) {
|
|
80
80
|
throw new SubagentError(
|
|
81
|
-
`spawnMode "inprocess" cannot combine with worktree: true for '${input.name}'`,
|
|
81
|
+
`spawnMode "inprocess" cannot combine with worktree: true for '${input.name}'; set worktree: false`,
|
|
82
82
|
ErrorCodes.INVALID_PARAMS,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
@@ -19,6 +19,31 @@ export const DEFAULT_SPAWN_MODE: SpawnMode = "inprocess";
|
|
|
19
19
|
/** Safe default for `inprocess.maxActive` when the inprocess block is set without it. */
|
|
20
20
|
export const DEFAULT_INPROCESS_MAX_ACTIVE = 8;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* How long `InProcessSupervisor.spawnChild` waits for the child to *accept* the
|
|
24
|
+
* first prompt before returning anyway (#227 D1).
|
|
25
|
+
*
|
|
26
|
+
* The acceptance signal itself is the child transcript gaining its first user
|
|
27
|
+
* line (`F7`: the SDK appends the user message before it runs the agent), so
|
|
28
|
+
* this bound only covers a child that never writes one — a stuck session factory
|
|
29
|
+
* or a provider that swallows the prompt. Hitting it is **not** an error: the
|
|
30
|
+
* spawn still owns a live session and returns a handle, and the caller is told
|
|
31
|
+
* once through `onSpawnDelayed`.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS = 15_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Poll cadence for the spawn-acceptance check (#227 D1).
|
|
37
|
+
*
|
|
38
|
+
* CONTRACT ERRATUM (#227, `vexillatio-nonblocking-launch-v1.md` §D1): that line
|
|
39
|
+
* says the constant already lives in `src/shared/types.ts`; it never did. The
|
|
40
|
+
* only pre-existing `POLL_INTERVAL_MS` is module-private in
|
|
41
|
+
* `src/runs/orchestrator.ts` (a Worker B file this change must not touch), so
|
|
42
|
+
* the same 500 ms cadence is exported from here instead of inventing a second
|
|
43
|
+
* value. Parent session to record this erratum in the contract document.
|
|
44
|
+
*/
|
|
45
|
+
export const POLL_INTERVAL_MS = 500;
|
|
46
|
+
|
|
22
47
|
export function resolveSpawnMode(
|
|
23
48
|
agent: Pick<AgentConfig, "spawnMode"> | undefined,
|
|
24
49
|
): SpawnMode {
|