@abelxiaoxing/cadence 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +130 -8
  2. package/agents/design-explorer.md +17 -11
  3. package/agents/diagnosis-worker.md +9 -1
  4. package/agents/implementation-worker.md +3 -1
  5. package/config/plan-draft.example.json +122 -0
  6. package/config/plan-draft.multiple-tasks.example.json +345 -0
  7. package/config/plan-draft.quick.example.json +78 -0
  8. package/package.json +10 -4
  9. package/prompts/abel-design.md +101 -44
  10. package/prompts/abel-implement.md +9 -0
  11. package/src/activity-contracts.ts +82 -0
  12. package/src/candidate-artifact.ts +143 -0
  13. package/src/candidate-patch.ts +31 -11
  14. package/src/candidate-workspace.ts +74 -0
  15. package/src/change-contract-schema.ts +191 -0
  16. package/src/change-contract.ts +5 -0
  17. package/src/change-verification.ts +920 -0
  18. package/src/child-budget.ts +58 -0
  19. package/src/child-model.ts +142 -0
  20. package/src/child-session.ts +289 -326
  21. package/src/contracts.ts +200 -76
  22. package/src/delivery-compiler.ts +105 -120
  23. package/src/delivery-revision.ts +42 -0
  24. package/src/design-control.ts +113 -4
  25. package/src/design-diagnostics.ts +150 -0
  26. package/src/durable-contracts.ts +213 -0
  27. package/src/durable-workflow.ts +148 -3570
  28. package/src/evidence-draft.ts +122 -0
  29. package/src/execution-profile.ts +38 -0
  30. package/src/implement-graph.ts +3 -0
  31. package/src/implement-plan.ts +70 -0
  32. package/src/index.ts +59 -522
  33. package/src/isolation-backend.ts +62 -40
  34. package/src/model-source.ts +22 -0
  35. package/src/operator-cli-entry.mjs +24 -0
  36. package/src/operator-cli.mjs +1421 -0
  37. package/src/operator-tools.ts +249 -0
  38. package/src/package-candidate.ts +227 -0
  39. package/src/package-verification.ts +86 -8
  40. package/src/package-workflow.ts +291 -0
  41. package/src/packet-runtime.ts +27 -48
  42. package/src/parent-provider.ts +86 -198
  43. package/src/phase-execution.ts +2248 -0
  44. package/src/pi-adapter.ts +19 -0
  45. package/src/plan-draft-summary.ts +149 -0
  46. package/src/plan-draft.ts +493 -0
  47. package/src/single-task-draft.ts +142 -0
  48. package/src/subagent-activity.ts +32 -66
  49. package/src/submit-tool.ts +20 -59
  50. package/src/transport-budget.ts +52 -0
  51. package/src/verification-capability.ts +5 -3
  52. package/src/verification-diagnostics.ts +76 -0
  53. package/src/verification-environment-io.ts +280 -0
  54. package/src/verification-environment.ts +56 -154
  55. package/src/verification-scan.ts +64 -0
  56. package/src/worker-broker.ts +39 -57
  57. package/src/workflow-policy.ts +14 -53
  58. package/src/workflow-recovery-policy.ts +95 -0
  59. package/src/workflow-scheduling.ts +105 -0
  60. package/src/workflow-state-machine.ts +105 -497
  61. package/src/workflow-status.ts +384 -0
  62. package/src/workspace-io-entry.mjs +19 -1
  63. package/src/workspace-io-worker.mjs +275 -50
  64. package/src/workspace-io.ts +6 -1
  65. package/src/empty-resource-loader.ts +0 -38
  66. package/src/parent-payload-bridge.ts +0 -523
@@ -1,13 +1,21 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import path from "node:path";
2
- import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
3
3
  import {
4
- createAgentSession,
5
- defineTool,
6
- type ModelRuntime,
7
- SessionManager,
8
- SettingsManager,
9
- } from "@earendil-works/pi-coding-agent";
4
+ type AssistantMessage,
5
+ type Context,
6
+ type Model,
7
+ type Usage,
8
+ validateToolArguments,
9
+ } from "@earendil-works/pi-ai";
10
+ import { defineTool } from "@earendil-works/pi-coding-agent";
10
11
  import { Type } from "typebox";
12
+ import { childContextBudget, classifyProviderFailure } from "./child-budget.ts";
13
+ import {
14
+ abortable,
15
+ type ChildModelClient,
16
+ disposeChildTransport,
17
+ requestChildTurn,
18
+ } from "./child-model.ts";
11
19
  import type {
12
20
  ChildFailure,
13
21
  DiffResult,
@@ -15,7 +23,6 @@ import type {
15
23
  IdentityDimension,
16
24
  SafeFailureDetails,
17
25
  } from "./contracts.ts";
18
- import { EmptyResourceLoader } from "./empty-resource-loader.ts";
19
26
  import {
20
27
  createScopedTools,
21
28
  type Observation,
@@ -28,40 +35,15 @@ import {
28
35
  createCandidateArtifactTool,
29
36
  createStructuredPatchTool,
30
37
  createSubmitTool,
31
- type FinalCategory,
32
38
  type SubmitClassification,
33
39
  } from "./submit-tool.ts";
34
40
  import { serializeTaskLedgerProjection } from "./task-ledger.ts";
41
+ import { TransportTimeout } from "./transport-budget.ts";
35
42
 
36
- function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
37
- signal.throwIfAborted();
38
- return new Promise<T>((resolve, reject) => {
39
- const onAbort = () => {
40
- signal.removeEventListener("abort", onAbort);
41
- reject(signal.reason);
42
- };
43
- signal.addEventListener("abort", onAbort, { once: true });
44
- promise
45
- .then(resolve, reject)
46
- .finally(() => signal.removeEventListener("abort", onAbort));
47
- });
48
- }
49
-
50
- const ABORT_SETTLE_GRACE_MS = 250;
51
-
52
- async function settleAbort(promise: Promise<void>): Promise<void> {
53
- let timer: ReturnType<typeof setTimeout> | undefined;
54
- try {
55
- await Promise.race([
56
- promise.catch(() => undefined),
57
- new Promise<void>((resolve) => {
58
- timer = setTimeout(resolve, ABORT_SETTLE_GRACE_MS);
59
- }),
60
- ]);
61
- } finally {
62
- if (timer !== undefined) clearTimeout(timer);
63
- }
64
- }
43
+ export const CHILD_EXECUTION_LIMITS = Object.freeze({
44
+ maxTurns: 64,
45
+ maxContextBytes: 4 * 1024 * 1024,
46
+ });
65
47
 
66
48
  const ZERO_USAGE: Usage = {
67
49
  input: 0,
@@ -189,26 +171,10 @@ function finalDeliveryContent(message: AssistantMessage | undefined) {
189
171
  );
190
172
  }
191
173
 
192
- function hasStructuralSubmit(message: AssistantMessage | undefined) {
193
- return finalDeliveryContent(message).some(
194
- (content) =>
195
- content.type === "toolCall" && content.name === "abel_submit_result",
196
- );
197
- }
198
-
199
- function structuralSubmitCount(message: AssistantMessage | undefined) {
200
- return finalDeliveryContent(message).filter(
201
- (content) =>
202
- content.type === "toolCall" && content.name === "abel_submit_result",
203
- ).length;
204
- }
205
-
206
174
  function safeChildError(failure: ChildFailure): string {
207
175
  switch (failure.kind) {
208
176
  case "environment":
209
- return failure.code === "child-session-create-failed"
210
- ? "child session creation failed"
211
- : "child environment unavailable";
177
+ return "child environment unavailable";
212
178
  case "transport":
213
179
  switch (failure.code) {
214
180
  case "child-timeout":
@@ -232,6 +198,7 @@ function safeChildError(failure: ChildFailure): string {
232
198
  case "stale":
233
199
  case "approval-boundary":
234
200
  case "verification-adapter":
201
+ case "execution-limit":
235
202
  case "result-limit":
236
203
  return "child result rejected";
237
204
  }
@@ -291,7 +258,7 @@ export type ChildSessionResult =
291
258
 
292
259
  export async function runChildSession(input: {
293
260
  cwd: string;
294
- modelRuntime: ModelRuntime;
261
+ modelRuntime: ChildModelClient;
295
262
  model: Model<string>;
296
263
  systemPrompt: string;
297
264
  requestId: string;
@@ -303,7 +270,6 @@ export async function runChildSession(input: {
303
270
  allowedPaths?: string[];
304
271
  timeoutMs: number;
305
272
  signal?: AbortSignal;
306
- failureOverride?: () => ChildFailure | undefined;
307
273
  ledgerProjection?: unknown;
308
274
  captureObservations?: boolean;
309
275
  candidateArtifact?: CandidateArtifactSubmission;
@@ -313,9 +279,9 @@ export async function runChildSession(input: {
313
279
  deletePaths: string[];
314
280
  };
315
281
  onStreamStart?: () => void;
282
+ onStreamHeaders?: () => void;
316
283
  onStreamProgress?: () => void;
317
284
  }): Promise<ChildSessionResult> {
318
- const candidateProtocol = input.candidateArtifact !== undefined;
319
285
  const submit = input.candidateArtifact
320
286
  ? createCandidateArtifactTool(input.candidateArtifact)
321
287
  : input.structuredPatch
@@ -375,303 +341,300 @@ export async function runChildSession(input: {
375
341
  timedOut = true;
376
342
  abort.abort(new Error("child phase timeout"));
377
343
  }, input.timeoutMs);
378
- let session:
379
- | Awaited<ReturnType<typeof createAgentSession>>["session"]
380
- | undefined;
381
- let unsubscribe: (() => void) | undefined;
382
- let abortSettlement: Promise<void> | undefined;
344
+ const context: Context = {
345
+ systemPrompt: effectivePrompt,
346
+ messages: [
347
+ {
348
+ role: "user",
349
+ content: "Complete the bounded task and submit its result.",
350
+ timestamp: Date.now(),
351
+ },
352
+ ],
353
+ tools: customTools.map(({ name, description, parameters }) => ({
354
+ name,
355
+ description,
356
+ parameters,
357
+ })),
358
+ };
359
+ const sessionId = randomUUID();
383
360
  let assistantSequence = 0;
384
- let streamStarted = false;
385
- const notifyStreamStart = () => {
386
- if (streamStarted) return;
387
- streamStarted = true;
361
+ let structuralAttempts = 0;
362
+ let last: AssistantMessage | undefined;
363
+ let preflightRejected = false;
364
+ let turns = 0;
365
+ const contextBudget = childContextBudget(input.model);
366
+ let reminderUsed = false;
367
+ const observeStart = () => {
388
368
  try {
389
369
  input.onStreamStart?.();
390
370
  } catch {
391
- // Stream observation cannot influence child execution.
371
+ /* observation only */
392
372
  }
393
373
  };
394
- const notifyStreamProgress = () => {
395
- notifyStreamStart();
374
+ const observeProgress = () => {
396
375
  try {
397
376
  input.onStreamProgress?.();
398
377
  } catch {
399
- // Stream observation cannot influence child execution.
400
- }
401
- };
402
- const disposeOnce = () => {
403
- if (session) {
404
- session.dispose();
405
- disposeCount++;
406
- session = undefined;
407
- }
408
- };
409
- const classifySession = (): SubmitClassification => {
410
- const assistants =
411
- session?.messages.filter((m) => m.role === "assistant") ?? [];
412
- const last = assistants.at(-1);
413
- const structuralAttempts = assistants.reduce(
414
- (count, message) => count + structuralSubmitCount(message),
415
- 0,
416
- );
417
- let finalCategory: FinalCategory;
418
- if (submit.getResult() !== undefined) {
419
- finalCategory =
420
- structuralAttempts > 1 ? "multiple-submit" : "single-submit-only";
421
- } else if (!last) {
422
- finalCategory = "no-final-assistant";
423
- } else {
424
- const content = finalDeliveryContent(last);
425
- finalCategory =
426
- content.length === 1 && content[0]?.type === "text"
427
- ? "text-only"
428
- : "mixed";
429
- }
430
- return {
431
- finalCategory,
432
- attempts: submit.getAttempts(),
433
- schema: submit.getSchema(),
434
- identity: submit.getIdentity(),
435
- };
436
- };
437
- const transportFailure = ():
438
- | Extract<ChildFailure, { kind: "transport" }>
439
- | undefined => {
440
- const final = session?.messages
441
- .filter((message) => message.role === "assistant")
442
- .at(-1);
443
- if (final === undefined) {
444
- return {
445
- kind: "transport",
446
- code: "child-no-final-assistant",
447
- stage: "child-finalization",
448
- };
449
- }
450
- if (final.stopReason === "error") {
451
- return submit.getAttempts() === 0 &&
452
- !final.content.some(
453
- (content) =>
454
- content.type === "toolCall" &&
455
- content.name === "abel_submit_result",
456
- )
457
- ? {
458
- kind: "transport",
459
- code: "child-provider-stream-error",
460
- stage: "child-provider-stream",
461
- }
462
- : undefined;
463
- }
464
- if (final.stopReason === "aborted") {
465
- return {
466
- kind: "transport",
467
- code: "child-provider-stream-aborted",
468
- stage: "child-provider-stream",
469
- };
378
+ /* observation only */
470
379
  }
471
- return undefined;
472
380
  };
473
- const noStructuralSubmit = (): ChildFailure => {
474
- const final = session?.messages
475
- .filter((message) => message.role === "assistant")
476
- .at(-1);
477
- return submit.getAttempts() === 0 && !hasStructuralSubmit(final)
478
- ? {
479
- kind: "artifact",
480
- code: "child-no-structural-submit",
381
+ const classification = (): SubmitClassification => ({
382
+ finalCategory:
383
+ submit.getResult() !== undefined
384
+ ? structuralAttempts > 1
385
+ ? "multiple-submit"
386
+ : "single-submit-only"
387
+ : !last
388
+ ? "no-final-assistant"
389
+ : finalDeliveryContent(last).length === 1 &&
390
+ finalDeliveryContent(last)[0]?.type === "text"
391
+ ? "text-only"
392
+ : "mixed",
393
+ attempts: structuralAttempts,
394
+ schema: preflightRejected ? "invalid" : submit.getSchema(),
395
+ identity: submit.getIdentity(),
396
+ });
397
+ let failure: ChildFailure | undefined;
398
+ const missingResult = (): ChildFailure => ({
399
+ kind: "artifact",
400
+ code:
401
+ structuralAttempts === 0
402
+ ? "child-no-structural-submit"
403
+ : "invalid-structural-result",
404
+ stage:
405
+ structuralAttempts === 0 ? "child-finalization" : "structural-submit",
406
+ });
407
+ try {
408
+ for (;;) {
409
+ abort.signal.throwIfAborted();
410
+ if (turns >= CHILD_EXECUTION_LIMITS.maxTurns) {
411
+ failure = { kind: "execution-limit", code: "child-turn-limit" };
412
+ break;
413
+ }
414
+ if (Buffer.byteLength(JSON.stringify(context), "utf8") > contextBudget) {
415
+ failure = { kind: "execution-limit", code: "child-context-limit" };
416
+ break;
417
+ }
418
+ turns++;
419
+ const message = await requestChildTurn({
420
+ client: input.modelRuntime,
421
+ model: input.model,
422
+ context,
423
+ signal: abort.signal,
424
+ sessionId,
425
+ onStart: observeStart,
426
+ onHeaders: input.onStreamHeaders,
427
+ onProgress: observeProgress,
428
+ onMessage: (message) => {
429
+ usage.add(`assistant:${assistantSequence++}`, message.usage);
430
+ },
431
+ });
432
+ last = message;
433
+ if (!message) {
434
+ failure = {
435
+ kind: "transport",
436
+ code: "child-no-final-assistant",
481
437
  stage: "child-finalization",
482
- }
483
- : {
484
- kind: "artifact",
485
- code: "invalid-structural-result",
486
- stage: "structural-submit",
487
438
  };
488
- };
489
- try {
490
- abort.signal.throwIfAborted();
491
- const creation = createAgentSession({
492
- cwd: input.cwd,
493
- modelRuntime: input.modelRuntime,
494
- model: input.model,
495
- thinkingLevel: "low",
496
- tools: toolNames,
497
- customTools,
498
- resourceLoader: new EmptyResourceLoader(effectivePrompt),
499
- sessionManager: SessionManager.inMemory(input.cwd),
500
- settingsManager: SettingsManager.inMemory({
501
- compaction: { enabled: false },
502
- retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
503
- }),
504
- });
505
- try {
506
- ({ session } = await abortable(creation, abort.signal));
507
- } catch (error) {
508
- void creation
509
- .then(({ session: lateSession }) => lateSession.dispose())
510
- .catch(() => undefined);
511
- throw error;
512
- }
513
- const childAgent = session.agent;
514
- if (childAgent) {
515
- const previousShouldStopAfterTurn = childAgent.shouldStopAfterTurn;
516
- childAgent.shouldStopAfterTurn = async (turn, signal) => {
517
- if (submit.getResult() !== undefined) {
518
- return true;
439
+ break;
440
+ }
441
+ context.messages.push(message);
442
+ if (Buffer.byteLength(JSON.stringify(context), "utf8") > contextBudget) {
443
+ failure = { kind: "execution-limit", code: "child-context-limit" };
444
+ break;
445
+ }
446
+ // Only complete, normal turns may execute tools, including terminal submit.
447
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
448
+ failure =
449
+ message.stopReason === "error"
450
+ ? classifyProviderFailure(message.errorMessage)
451
+ : {
452
+ kind: "transport",
453
+ code: "child-provider-stream-aborted",
454
+ stage: "child-provider-stream",
455
+ };
456
+ break;
457
+ }
458
+ if (message.stopReason !== "toolUse" && message.stopReason !== "stop") {
459
+ failure = missingResult();
460
+ break;
461
+ }
462
+ const calls = message.content.filter((part) => part.type === "toolCall");
463
+ if (calls.length === 0) {
464
+ if (
465
+ !reminderUsed &&
466
+ structuralAttempts === 0 &&
467
+ message.stopReason === "stop" &&
468
+ finalDeliveryContent(message).length > 0 &&
469
+ finalDeliveryContent(message).every((part) => part.type === "text")
470
+ ) {
471
+ reminderUsed = true;
472
+ context.messages.push({
473
+ role: "user",
474
+ timestamp: Date.now(),
475
+ content:
476
+ "No result has been accepted. Submit existing findings through abel_submit_result, not prose. Do not repeat investigation or expand scope. Keep evidence gaps explicit; use context-request if available and needed. This is the only missing-submit reminder; the original deadline still applies.",
477
+ });
478
+ continue;
519
479
  }
520
- const structuralAttempts =
521
- session?.messages
522
- .filter((message) => message.role === "assistant")
523
- .reduce(
524
- (count, message) => count + structuralSubmitCount(message),
525
- 0,
526
- ) ?? 0;
527
- if (structuralAttempts >= 2) return true;
528
- return (await previousShouldStopAfterTurn?.(turn, signal)) ?? false;
529
- };
530
- }
531
- unsubscribe = session.subscribe((event) => {
532
- if (
533
- event.type === "message_start" &&
534
- event.message.role === "assistant"
535
- ) {
536
- notifyStreamStart();
480
+ failure = missingResult();
481
+ break;
482
+ }
483
+ // Source-order execution is a Cadence contract, not Pi's batch scheduling.
484
+ // Process the batch even after acceptance to reject a duplicate terminal
485
+ // submission while still allowing harmless scoped reads in that batch.
486
+ for (const call of calls) {
487
+ abort.signal.throwIfAborted();
488
+ const isSubmit = call.name === "abel_submit_result";
489
+ if (isSubmit) {
490
+ structuralAttempts++;
491
+ if (structuralAttempts > 2) {
492
+ failure = {
493
+ kind: "artifact",
494
+ code: "invalid-structural-result",
495
+ stage: "structural-submit",
496
+ };
497
+ break;
498
+ }
499
+ if (submit.getResult() !== undefined) {
500
+ preflightRejected = true;
501
+ failure = {
502
+ kind: "artifact",
503
+ code: "invalid-structural-result",
504
+ stage: "structural-submit",
505
+ };
506
+ break;
507
+ }
508
+ }
509
+ const tool = customTools.find((tool) => tool.name === call.name);
510
+ let content: Array<{ type: "text"; text: string }>;
511
+ let isError = false;
512
+ let validated = false;
513
+ try {
514
+ if (!tool) throw new Error("unknown scoped tool");
515
+ const params = validateToolArguments(tool, call);
516
+ validated = true;
517
+ if (isSubmit) preflightRejected = false;
518
+ const result = await abortable(
519
+ tool.execute(
520
+ call.id,
521
+ params as never,
522
+ abort.signal,
523
+ undefined,
524
+ {} as never,
525
+ ),
526
+ abort.signal,
527
+ );
528
+ content = result.content as typeof content;
529
+ } catch (error) {
530
+ abort.signal.throwIfAborted();
531
+ isError = true;
532
+ if (isSubmit && !validated) preflightRejected = true;
533
+ content = [
534
+ {
535
+ type: "text",
536
+ text:
537
+ error instanceof Error
538
+ ? error.message
539
+ : "tool execution failed",
540
+ },
541
+ ];
542
+ }
543
+ context.messages.push({
544
+ role: "toolResult",
545
+ toolCallId: call.id,
546
+ toolName: call.name,
547
+ content,
548
+ isError,
549
+ timestamp: Date.now(),
550
+ });
537
551
  }
552
+ if (failure) break;
553
+ if (submit.getResult() !== undefined) break;
538
554
  if (
539
- event.type === "message_update" &&
540
- event.message.role === "assistant"
555
+ structuralAttempts >= 2 ||
556
+ submit.getFailure()?.kind === "result-limit"
541
557
  ) {
542
- notifyStreamProgress();
543
- }
544
- if (event.type === "message_end" && event.message.role === "assistant") {
545
- usage.add(`assistant:${assistantSequence++}`, event.message.usage);
558
+ failure = missingResult();
559
+ break;
546
560
  }
547
- });
548
- const startAbortSettlement = () => {
549
- if (session && !abortSettlement) abortSettlement = session.abort();
550
- return abortSettlement;
551
- };
552
- const onAbort = () => {
553
- void startAbortSettlement()?.catch(() => undefined);
554
- };
555
- abort.signal.addEventListener("abort", onAbort, { once: true });
556
- try {
557
- if (abort.signal.aborted) {
558
- onAbort();
559
- throw abort.signal.reason;
560
- }
561
- await Promise.race([
562
- session.prompt(effectivePrompt, { expandPromptTemplates: false }),
563
- new Promise<never>((_, reject) =>
564
- abort.signal.addEventListener(
565
- "abort",
566
- () => reject(abort.signal.reason),
567
- { once: true },
568
- ),
569
- ),
570
- ]);
571
- } finally {
572
- abort.signal.removeEventListener("abort", onAbort);
573
561
  }
574
- const result = submit.getResult();
575
- const attempts = submit.getAttempts();
576
- const classification = classifySession();
577
- const terminalSubmitFailure = submit.getFailure();
578
- const candidateComplete =
579
- candidateProtocol &&
580
- result !== undefined &&
581
- (result.kind === "sealed-candidate" || result.kind === "context-request");
582
- if (
583
- !result ||
584
- terminalSubmitFailure !== undefined ||
585
- (candidateProtocol && !candidateComplete)
586
- ) {
587
- const failure = withSubmitDetails(
588
- terminalSubmitFailure ??
589
- input.failureOverride?.() ??
590
- transportFailure() ??
591
- noStructuralSubmit(),
592
- classification,
593
- );
594
- const isTransport = failure.kind === "transport";
595
- disposeOnce();
596
- return {
597
- ok: false,
598
- error: safeChildError(failure),
599
- failure,
600
- failureKind: "failed",
601
- transportFailure: isTransport,
602
- disposeCount,
603
- usage: usage.total(),
604
- classification,
605
- ...observationMetadata(),
606
- };
607
- }
608
- // The trusted submit tool already validates and seals the one accepted
609
- // result. Harmless text or read-only calls do not alter that admission;
610
- // another terminal submission does.
611
- disposeOnce();
612
- return {
613
- ok: true,
614
- result,
615
- toolNames,
616
- submitCount: attempts,
617
- disposeCount,
618
- usage: usage.total(),
619
- classification,
620
- ...observationMetadata(),
621
- };
622
562
  } catch (error) {
623
- if (abort.signal.aborted && session) {
624
- const settlement = abortSettlement ?? session.abort();
625
- abortSettlement = settlement;
626
- await settleAbort(settlement);
627
- }
628
- const failureKind: ChildFailureKind = timedOut
629
- ? "timed-out"
630
- : input.signal?.aborted === true
631
- ? "cancelled"
632
- : "failed";
633
- const classification = classifySession();
634
- const failure: ChildFailure = withSubmitDetails(
635
- timedOut
563
+ failure =
564
+ error instanceof TransportTimeout
636
565
  ? {
637
566
  kind: "transport",
638
- code: "child-timeout",
639
- stage: "child-timeout",
567
+ code: error.code,
568
+ stage: "child-provider-stream",
640
569
  }
641
- : failureKind === "cancelled"
642
- ? { kind: "cancelled", code: "cancelled" }
643
- : session === undefined
644
- ? {
645
- kind: "environment",
646
- code: "child-session-create-failed",
647
- stage: "child-session-create",
648
- }
649
- : (submit.getFailure() ??
650
- input.failureOverride?.() ??
651
- transportFailure() ??
652
- noStructuralSubmit()),
653
- classification,
654
- );
655
- const isTransport = failure.kind === "transport";
656
- disposeOnce();
570
+ : classifyProviderFailure(error);
571
+ } finally {
572
+ clearTimeout(timer);
573
+ input.signal?.removeEventListener("abort", forwardCancellation);
574
+ // Dispose our conversation and only this child's provider session resources.
575
+ context.messages.length = 0;
576
+ try {
577
+ disposeChildTransport(sessionId);
578
+ } catch {
579
+ failure ??= {
580
+ kind: "environment",
581
+ code: "sandbox-runtime-unavailable",
582
+ stage: "child-finalization",
583
+ };
584
+ }
585
+ disposeCount++;
586
+ }
587
+ const result = submit.getResult();
588
+ // All exit paths share the same priority, including thrown/empty streams.
589
+ // A later transport failure cannot erase a rejected submission. Preflight
590
+ // rejection never reaches execute, so retain it independently of the tool.
591
+ // A valid correction clears both the tool failure and preflightRejected.
592
+ const terminalFailure: ChildFailure | undefined = timedOut
593
+ ? { kind: "transport", code: "child-timeout", stage: "child-timeout" }
594
+ : input.signal?.aborted
595
+ ? { kind: "cancelled", code: "cancelled" }
596
+ : (submit.getFailure() ??
597
+ (preflightRejected ? missingResult() : undefined) ??
598
+ failure ??
599
+ (!result ? missingResult() : undefined));
600
+ const projection = classification();
601
+ if (terminalFailure) {
602
+ const typed = withSubmitDetails(terminalFailure, projection);
657
603
  return {
658
604
  ok: false,
659
605
  error:
660
- failure.kind === "cancelled" && error instanceof Error
661
- ? error.message
662
- : safeChildError(failure),
663
- failure,
664
- failureKind,
665
- transportFailure: isTransport,
606
+ typed.kind === "cancelled" && input.signal?.reason instanceof Error
607
+ ? input.signal.reason.message
608
+ : safeChildError(typed),
609
+ failure: typed,
610
+ failureKind:
611
+ timedOut ||
612
+ (typed.kind === "transport" &&
613
+ [
614
+ "first-progress-timeout",
615
+ "stream-idle-timeout",
616
+ "attempt-timeout",
617
+ ].includes(typed.code))
618
+ ? "timed-out"
619
+ : typed.kind === "cancelled"
620
+ ? "cancelled"
621
+ : "failed",
622
+ transportFailure: typed.kind === "transport",
666
623
  disposeCount,
667
624
  usage: usage.total(),
668
- classification,
625
+ classification: projection,
669
626
  ...observationMetadata(),
670
627
  };
671
- } finally {
672
- clearTimeout(timer);
673
- input.signal?.removeEventListener("abort", forwardCancellation);
674
- unsubscribe?.();
675
- disposeOnce();
676
628
  }
629
+ if (!result) throw new Error("child-result-invariant");
630
+ return {
631
+ ok: true,
632
+ result,
633
+ toolNames,
634
+ submitCount: structuralAttempts,
635
+ disposeCount,
636
+ usage: usage.total(),
637
+ classification: projection,
638
+ ...observationMetadata(),
639
+ };
677
640
  }