@abelxiaoxing/cadence 1.4.0 → 1.4.1

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