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.
@@ -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
- const modelRuntime = await ModelRuntime.create();
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}'; use spawnMode "rpc" or set worktree: false`,
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
- await session.prompt(
513
- formatChildTask(input.task, {
514
- allowNested: input.allowNestedSubagents === true,
515
- ...(input.worktreeBranch
516
- ? { worktreeBranch: input.worktreeBranch }
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 {