@abelxiaoxing/cadence 1.3.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 (76) hide show
  1. package/README.md +172 -13
  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 +9 -5
  8. package/prompts/abel-design.md +141 -38
  9. package/prompts/abel-diagnose.md +10 -1
  10. package/prompts/abel-implement.md +118 -20
  11. package/prompts/abel-init.md +9 -2
  12. package/src/activity-contracts.ts +82 -0
  13. package/src/apply-transaction.ts +16 -14
  14. package/src/artifact-store.ts +17 -0
  15. package/src/candidate-artifact.ts +143 -0
  16. package/src/candidate-patch.ts +33 -12
  17. package/src/candidate-workspace.ts +74 -0
  18. package/src/canonical.ts +16 -0
  19. package/src/change-contract-schema.ts +191 -0
  20. package/src/change-contract.ts +273 -0
  21. package/src/change-verification.ts +896 -0
  22. package/src/child-model.ts +129 -0
  23. package/src/child-session.ts +296 -328
  24. package/src/contracts.ts +325 -109
  25. package/src/control-contracts.ts +51 -1
  26. package/src/delivery-compiler.ts +241 -141
  27. package/src/delivery-revision.ts +42 -0
  28. package/src/design-control.ts +182 -15
  29. package/src/design-diagnostics.ts +150 -0
  30. package/src/design-journal.ts +216 -72
  31. package/src/durable-contracts.ts +210 -0
  32. package/src/durable-workflow.ts +963 -0
  33. package/src/evidence-draft.ts +122 -0
  34. package/src/implement-graph.ts +14 -16
  35. package/src/implement-plan.ts +70 -0
  36. package/src/index.ts +341 -1405
  37. package/src/isolation-backend.ts +84 -23
  38. package/src/model-source.ts +22 -0
  39. package/src/package-candidate.ts +224 -0
  40. package/src/package-delivery.ts +486 -0
  41. package/src/package-verification.ts +509 -0
  42. package/src/package-workflow.ts +253 -0
  43. package/src/packet-runtime.ts +27 -48
  44. package/src/parent-provider.ts +86 -198
  45. package/src/phase-execution.ts +2248 -0
  46. package/src/pi-adapter.ts +19 -0
  47. package/src/plan-draft-summary.ts +146 -0
  48. package/src/plan-draft.ts +493 -0
  49. package/src/route-policy.ts +1 -11
  50. package/src/run-state.ts +4 -16
  51. package/src/run-store.ts +5 -144
  52. package/src/scoped-grep-worker.mjs +23 -0
  53. package/src/scoped-tools.ts +312 -116
  54. package/src/sqlite-schema.ts +171 -0
  55. package/src/storage-schema.ts +352 -0
  56. package/src/subagent-activity.ts +46 -67
  57. package/src/submit-tool.ts +40 -61
  58. package/src/task-ledger.ts +14 -55
  59. package/src/transport-budget.ts +32 -0
  60. package/src/verification-capability.ts +298 -102
  61. package/src/verification-environment.ts +213 -0
  62. package/src/verification-identity.ts +77 -0
  63. package/src/worker-broker.ts +134 -91
  64. package/src/workflow-engine.ts +28 -8285
  65. package/src/workflow-policy.ts +1388 -0
  66. package/src/workflow-recovery-policy.ts +95 -0
  67. package/src/workflow-scheduling.ts +105 -0
  68. package/src/workflow-state-machine.ts +4086 -0
  69. package/src/workflow-status.ts +384 -0
  70. package/src/workspace-io-entry.mjs +58 -0
  71. package/src/workspace-io-worker.mjs +991 -0
  72. package/src/workspace-io.ts +84 -0
  73. package/src/workspace-store.ts +125 -19
  74. package/skills/abel-workflow/SKILL.md +0 -205
  75. package/src/empty-resource-loader.ts +0 -38
  76. 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,
@@ -120,6 +101,7 @@ function wrapScopedTools(
120
101
  ) {
121
102
  const order = ["read", "grep", "find", "ls"];
122
103
  return createScopedTools({
104
+ cwd,
123
105
  roots,
124
106
  ...(allowedPaths
125
107
  ? {
@@ -162,9 +144,10 @@ function wrapScopedTools(
162
144
  : Type.Object({
163
145
  path: Type.Optional(Type.String()),
164
146
  }),
165
- async execute(_id, params) {
147
+ async execute(_id, params, signal) {
166
148
  const result = await scoped.execute(
167
149
  params as Record<string, unknown>,
150
+ signal,
168
151
  );
169
152
  return {
170
153
  content: [{ type: "text" as const, text: JSON.stringify(result) }],
@@ -187,26 +170,10 @@ function finalDeliveryContent(message: AssistantMessage | undefined) {
187
170
  );
188
171
  }
189
172
 
190
- function hasStructuralSubmit(message: AssistantMessage | undefined) {
191
- return finalDeliveryContent(message).some(
192
- (content) =>
193
- content.type === "toolCall" && content.name === "abel_submit_result",
194
- );
195
- }
196
-
197
- function structuralSubmitCount(message: AssistantMessage | undefined) {
198
- return finalDeliveryContent(message).filter(
199
- (content) =>
200
- content.type === "toolCall" && content.name === "abel_submit_result",
201
- ).length;
202
- }
203
-
204
173
  function safeChildError(failure: ChildFailure): string {
205
174
  switch (failure.kind) {
206
175
  case "environment":
207
- return failure.code === "child-session-create-failed"
208
- ? "child session creation failed"
209
- : "child environment unavailable";
176
+ return "child environment unavailable";
210
177
  case "transport":
211
178
  switch (failure.code) {
212
179
  case "child-timeout":
@@ -230,6 +197,7 @@ function safeChildError(failure: ChildFailure): string {
230
197
  case "stale":
231
198
  case "approval-boundary":
232
199
  case "verification-adapter":
200
+ case "execution-limit":
233
201
  case "result-limit":
234
202
  return "child result rejected";
235
203
  }
@@ -289,7 +257,7 @@ export type ChildSessionResult =
289
257
 
290
258
  export async function runChildSession(input: {
291
259
  cwd: string;
292
- modelRuntime: ModelRuntime;
260
+ modelRuntime: ChildModelClient;
293
261
  model: Model<string>;
294
262
  systemPrompt: string;
295
263
  requestId: string;
@@ -301,7 +269,6 @@ export async function runChildSession(input: {
301
269
  allowedPaths?: string[];
302
270
  timeoutMs: number;
303
271
  signal?: AbortSignal;
304
- failureOverride?: () => ChildFailure | undefined;
305
272
  ledgerProjection?: unknown;
306
273
  captureObservations?: boolean;
307
274
  candidateArtifact?: CandidateArtifactSubmission;
@@ -311,9 +278,9 @@ export async function runChildSession(input: {
311
278
  deletePaths: string[];
312
279
  };
313
280
  onStreamStart?: () => void;
281
+ onStreamHeaders?: () => void;
314
282
  onStreamProgress?: () => void;
315
283
  }): Promise<ChildSessionResult> {
316
- const candidateProtocol = input.candidateArtifact !== undefined;
317
284
  const submit = input.candidateArtifact
318
285
  ? createCandidateArtifactTool(input.candidateArtifact)
319
286
  : input.structuredPatch
@@ -373,304 +340,305 @@ export async function runChildSession(input: {
373
340
  timedOut = true;
374
341
  abort.abort(new Error("child phase timeout"));
375
342
  }, input.timeoutMs);
376
- let session:
377
- | Awaited<ReturnType<typeof createAgentSession>>["session"]
378
- | undefined;
379
- let unsubscribe: (() => void) | undefined;
380
- 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();
381
359
  let assistantSequence = 0;
382
- let streamStarted = false;
383
- const notifyStreamStart = () => {
384
- if (streamStarted) return;
385
- 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 = () => {
386
366
  try {
387
367
  input.onStreamStart?.();
388
368
  } catch {
389
- // Stream observation cannot influence child execution.
369
+ /* observation only */
390
370
  }
391
371
  };
392
- const notifyStreamProgress = () => {
393
- notifyStreamStart();
372
+ const observeProgress = () => {
394
373
  try {
395
374
  input.onStreamProgress?.();
396
375
  } catch {
397
- // Stream observation cannot influence child execution.
376
+ /* observation only */
398
377
  }
399
378
  };
400
- const disposeOnce = () => {
401
- if (session) {
402
- session.dispose();
403
- disposeCount++;
404
- session = undefined;
405
- }
406
- };
407
- const classifySession = (): SubmitClassification => {
408
- const assistants =
409
- session?.messages.filter((m) => m.role === "assistant") ?? [];
410
- const last = assistants.at(-1);
411
- const structuralAttempts = assistants.reduce(
412
- (count, message) => count + structuralSubmitCount(message),
413
- 0,
414
- );
415
- let finalCategory: FinalCategory;
416
- if (submit.getResult() !== undefined) {
417
- finalCategory =
418
- structuralAttempts > 1 ? "multiple-submit" : "single-submit-only";
419
- } else if (!last) {
420
- finalCategory = "no-final-assistant";
421
- } else {
422
- const content = finalDeliveryContent(last);
423
- finalCategory =
424
- content.length === 1 && content[0]?.type === "text"
425
- ? "text-only"
426
- : "mixed";
427
- }
428
- return {
429
- finalCategory,
430
- attempts: submit.getAttempts(),
431
- schema: submit.getSchema(),
432
- identity: submit.getIdentity(),
433
- };
434
- };
435
- const transportFailure = ():
436
- | Extract<ChildFailure, { kind: "transport" }>
437
- | undefined => {
438
- const final = session?.messages
439
- .filter((message) => message.role === "assistant")
440
- .at(-1);
441
- if (final === undefined) {
442
- return {
443
- kind: "transport",
444
- code: "child-no-final-assistant",
445
- stage: "child-finalization",
446
- };
447
- }
448
- if (final.stopReason === "error") {
449
- return submit.getAttempts() === 0 &&
450
- !final.content.some(
451
- (content) =>
452
- content.type === "toolCall" &&
453
- content.name === "abel_submit_result",
454
- )
455
- ? {
456
- kind: "transport",
457
- code: "child-provider-stream-error",
458
- stage: "child-provider-stream",
459
- }
460
- : undefined;
461
- }
462
- if (final.stopReason === "aborted") {
463
- return {
464
- kind: "transport",
465
- code: "child-provider-stream-aborted",
466
- stage: "child-provider-stream",
467
- };
468
- }
469
- return undefined;
470
- };
471
- const noStructuralSubmit = (): ChildFailure => {
472
- const final = session?.messages
473
- .filter((message) => message.role === "assistant")
474
- .at(-1);
475
- return submit.getAttempts() === 0 && !hasStructuralSubmit(final)
476
- ? {
477
- kind: "artifact",
478
- code: "child-no-structural-submit",
479
- stage: "child-finalization",
480
- }
481
- : {
482
- kind: "artifact",
483
- code: "invalid-structural-result",
484
- stage: "structural-submit",
485
- };
486
- };
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
+ });
487
405
  try {
488
- abort.signal.throwIfAborted();
489
- const creation = createAgentSession({
490
- cwd: input.cwd,
491
- modelRuntime: input.modelRuntime,
492
- model: input.model,
493
- thinkingLevel: "low",
494
- tools: toolNames,
495
- customTools,
496
- resourceLoader: new EmptyResourceLoader(effectivePrompt),
497
- sessionManager: SessionManager.inMemory(input.cwd),
498
- settingsManager: SettingsManager.inMemory({
499
- compaction: { enabled: false },
500
- retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
501
- }),
502
- });
503
- try {
504
- ({ session } = await abortable(creation, abort.signal));
505
- } catch (error) {
506
- void creation
507
- .then(({ session: lateSession }) => lateSession.dispose())
508
- .catch(() => undefined);
509
- throw error;
510
- }
511
- const childAgent = session.agent;
512
- if (childAgent) {
513
- const previousShouldStopAfterTurn = childAgent.shouldStopAfterTurn;
514
- childAgent.shouldStopAfterTurn = async (turn, signal) => {
515
- if (submit.getResult() !== undefined) {
516
- return true;
517
- }
518
- const structuralAttempts =
519
- session?.messages
520
- .filter((message) => message.role === "assistant")
521
- .reduce(
522
- (count, message) => count + structuralSubmitCount(message),
523
- 0,
524
- ) ?? 0;
525
- if (structuralAttempts >= 2) return true;
526
- return (await previousShouldStopAfterTurn?.(turn, signal)) ?? false;
527
- };
528
- }
529
- 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
+ }
530
412
  if (
531
- event.type === "message_start" &&
532
- event.message.role === "assistant"
413
+ Buffer.byteLength(JSON.stringify(context), "utf8") >
414
+ CHILD_EXECUTION_LIMITS.maxContextBytes
533
415
  ) {
534
- 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;
535
441
  }
442
+ context.messages.push(message);
536
443
  if (
537
- event.type === "message_update" &&
538
- event.message.role === "assistant"
444
+ Buffer.byteLength(JSON.stringify(context), "utf8") >
445
+ CHILD_EXECUTION_LIMITS.maxContextBytes
539
446
  ) {
540
- notifyStreamProgress();
447
+ failure = { kind: "execution-limit", code: "child-context-limit" };
448
+ break;
541
449
  }
542
- if (event.type === "message_end" && event.message.role === "assistant") {
543
- 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;
544
461
  }
545
- });
546
- const startAbortSettlement = () => {
547
- if (session && !abortSettlement) abortSettlement = session.abort();
548
- return abortSettlement;
549
- };
550
- const onAbort = () => {
551
- void startAbortSettlement()?.catch(() => undefined);
552
- };
553
- abort.signal.addEventListener("abort", onAbort, { once: true });
554
- try {
555
- if (abort.signal.aborted) {
556
- onAbort();
557
- 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;
558
564
  }
559
- await Promise.race([
560
- session.prompt(effectivePrompt, { expandPromptTemplates: false }),
561
- new Promise<never>((_, reject) =>
562
- abort.signal.addEventListener(
563
- "abort",
564
- () => reject(abort.signal.reason),
565
- { once: true },
566
- ),
567
- ),
568
- ]);
569
- } finally {
570
- abort.signal.removeEventListener("abort", onAbort);
571
- }
572
- const result = submit.getResult();
573
- const attempts = submit.getAttempts();
574
- const classification = classifySession();
575
- const terminalSubmitFailure = submit.getFailure();
576
- const candidateComplete =
577
- candidateProtocol &&
578
- result !== undefined &&
579
- (result.kind === "sealed-candidate" || result.kind === "context-request");
580
- if (
581
- !result ||
582
- terminalSubmitFailure !== undefined ||
583
- (candidateProtocol && !candidateComplete)
584
- ) {
585
- const failure = withSubmitDetails(
586
- terminalSubmitFailure ??
587
- input.failureOverride?.() ??
588
- transportFailure() ??
589
- noStructuralSubmit(),
590
- classification,
591
- );
592
- const isTransport = failure.kind === "transport";
593
- disposeOnce();
594
- return {
595
- ok: false,
596
- error: safeChildError(failure),
597
- failure,
598
- failureKind: "failed",
599
- transportFailure: isTransport,
600
- disposeCount,
601
- usage: usage.total(),
602
- classification,
603
- ...observationMetadata(),
604
- };
605
565
  }
606
- // The trusted submit tool already validates and seals the one accepted
607
- // result. Harmless text or read-only calls do not alter that admission;
608
- // another terminal submission does.
609
- disposeOnce();
610
- return {
611
- ok: true,
612
- result,
613
- toolNames,
614
- submitCount: attempts,
615
- disposeCount,
616
- usage: usage.total(),
617
- classification,
618
- ...observationMetadata(),
619
- };
620
566
  } catch (error) {
621
- if (abort.signal.aborted && session) {
622
- const settlement = abortSettlement ?? session.abort();
623
- abortSettlement = settlement;
624
- 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
+ };
625
588
  }
626
- const failureKind: ChildFailureKind = timedOut
627
- ? "timed-out"
628
- : input.signal !== undefined &&
629
- (input.signal.aborted || abort.signal.reason === input.signal.reason)
630
- ? "cancelled"
631
- : "failed";
632
- const classification = classifySession();
633
- const failure: ChildFailure = withSubmitDetails(
634
- timedOut
635
- ? {
636
- kind: "transport",
637
- code: "child-timeout",
638
- stage: "child-timeout",
639
- }
640
- : failureKind === "cancelled"
641
- ? { kind: "cancelled", code: "cancelled" }
642
- : session === undefined
643
- ? {
644
- kind: "environment",
645
- code: "child-session-create-failed",
646
- stage: "child-session-create",
647
- }
648
- : (submit.getFailure() ??
649
- input.failureOverride?.() ??
650
- transportFailure() ??
651
- noStructuralSubmit()),
652
- classification,
653
- );
654
- const isTransport = failure.kind === "transport";
655
- 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);
656
607
  return {
657
608
  ok: false,
658
609
  error:
659
- failure.kind === "cancelled" && error instanceof Error
660
- ? error.message
661
- : safeChildError(failure),
662
- failure,
663
- failureKind,
664
- 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",
665
627
  disposeCount,
666
628
  usage: usage.total(),
667
- classification,
629
+ classification: projection,
668
630
  ...observationMetadata(),
669
631
  };
670
- } finally {
671
- clearTimeout(timer);
672
- input.signal?.removeEventListener("abort", forwardCancellation);
673
- unsubscribe?.();
674
- disposeOnce();
675
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
+ };
676
644
  }