@abelxiaoxing/cadence 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -1,18 +1,33 @@
1
- import { lstatSync, readdirSync } from "node:fs";
1
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import type { Usage } from "@earendil-works/pi-ai";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import { Activation, type ActivationState } from "./activation.ts";
5
6
  import { loadAgentDefinitions } from "./agent-registry.ts";
6
7
  import type { BaselineEntry } from "./candidate-preflight.ts";
7
- import { runChildSession } from "./child-session.ts";
8
+ import { runChildSession, UsageAggregator } from "./child-session.ts";
8
9
  import {
9
10
  ACTIONS,
11
+ type AgentsCheckpointAttempt,
12
+ type AgentsCheckpointRequest,
13
+ type CandidateFailure,
14
+ type ChildFailure,
10
15
  type DiffResult,
16
+ type ImplementApplyOperation,
17
+ type ImplementationPhase,
18
+ type ImplementDiscardOperation,
19
+ type ImplementOutcome,
20
+ type ImplementRunRequest,
11
21
  LIMITS,
12
- type RecoveryCode,
13
- type RecoveryNext,
14
- type RecoveryRecord,
22
+ type PhaseAttempt,
15
23
  type RequestEnvelope,
24
+ type RunRequest,
25
+ type TaskBoundary,
26
+ type TaskFailure,
27
+ validateAgentsCheckpointAttempt,
28
+ validateImplementApplyOperation,
29
+ validateImplementDiscardOperation,
30
+ validatePhaseAttemptAgainstBoundary,
16
31
  validateRequestEnvelope,
17
32
  } from "./contracts.ts";
18
33
  import { drainStage } from "./drain.ts";
@@ -28,20 +43,178 @@ import {
28
43
  } from "./file-snapshot.ts";
29
44
  import type { ParentPayloadBridge } from "./parent-payload-bridge.ts";
30
45
  import { runtimeFromContext } from "./parent-provider.ts";
31
- import { applyRetainedPatch } from "./patch.ts";
32
- import { ResultStore } from "./result-store.ts";
33
- import { Scheduler } from "./scheduler.ts";
46
+ import { applyAgentsCheckpoint, applyRetainedPatch } from "./patch.ts";
34
47
  import {
35
- contractOf,
36
- type LogicalWorker,
37
- sameContract,
38
- samePhaseContract,
48
+ type BoundRetainedResult,
49
+ ResultStore,
50
+ type RetainedCandidateFacts,
51
+ type RetainedCandidateIdentity,
52
+ } from "./result-store.ts";
53
+ import { declarationsConflict, Scheduler } from "./scheduler.ts";
54
+ import {
55
+ type TaskRecord,
56
+ taskConflictOf,
57
+ taskRecordKey,
39
58
  WorkerRegistry,
40
59
  workerIdentity,
41
60
  } from "./worker.ts";
42
61
 
43
62
  const SHA256_HEX = /^[0-9a-f]{64}$/;
44
63
 
64
+ function isFileSystemFailure(error: unknown): boolean {
65
+ return (
66
+ error instanceof Error &&
67
+ "code" in error &&
68
+ typeof (error as { code?: unknown }).code === "string"
69
+ );
70
+ }
71
+
72
+ function canonicalWorkspaceRoot(root: string): string | null {
73
+ try {
74
+ return realpathSync(root);
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function isImplementRunRequest(
81
+ request: RunRequest,
82
+ ): request is ImplementRunRequest {
83
+ return request.stage === "abel-implement" && "kind" in request;
84
+ }
85
+
86
+ function targetsImplement(request: unknown): boolean {
87
+ return (
88
+ typeof request === "object" &&
89
+ request !== null &&
90
+ !Array.isArray(request) &&
91
+ (request as Record<string, unknown>).stage === "abel-implement"
92
+ );
93
+ }
94
+
95
+ function cloneLegacyEnvelope(request: RequestEnvelope): RequestEnvelope {
96
+ return {
97
+ ...request,
98
+ taskId: request.taskId ?? request.id,
99
+ roots: [...request.roots],
100
+ context: { ...request.context },
101
+ declared: {
102
+ ...request.declared,
103
+ read: [...request.declared.read],
104
+ write: [...request.declared.write],
105
+ conflicts: [...request.declared.conflicts],
106
+ resources: [...request.declared.resources],
107
+ },
108
+ ...(request.approvedDependencies === undefined
109
+ ? {}
110
+ : { approvedDependencies: [...request.approvedDependencies] }),
111
+ ...(request.impactClosure === undefined
112
+ ? {}
113
+ : { impactClosure: structuredClone(request.impactClosure) }),
114
+ ...(request.verification === undefined
115
+ ? {}
116
+ : {
117
+ verification: {
118
+ ...request.verification,
119
+ argv: [...request.verification.argv],
120
+ },
121
+ }),
122
+ ...(request.snapshot === undefined
123
+ ? {}
124
+ : { snapshot: structuredClone(request.snapshot) }),
125
+ };
126
+ }
127
+
128
+ function deriveImplementEnvelope(
129
+ record: TaskRecord,
130
+ attempt: PhaseAttempt,
131
+ ): RequestEnvelope {
132
+ const boundary = record.boundary;
133
+ const phase = boundary.phases[attempt.phase];
134
+ if (!phase) throw new Error("task attempt phase is not declared");
135
+ return {
136
+ stage: "abel-implement",
137
+ role: "implementation-worker",
138
+ taskId: boundary.taskId,
139
+ id: attempt.requestId,
140
+ phase: attempt.phase,
141
+ objective: boundary.objective,
142
+ roots: [...boundary.roots],
143
+ context: { ...boundary.context },
144
+ declared: {
145
+ read: [...phase.read],
146
+ write: [...phase.write],
147
+ conflicts: [...boundary.scheduling.conflicts],
148
+ resources: [...boundary.scheduling.resources],
149
+ ...(phase.verificationLock === undefined
150
+ ? {}
151
+ : { verificationLock: phase.verificationLock }),
152
+ },
153
+ output: "diff",
154
+ agentsImpact: boundary.agents.impact,
155
+ ...(boundary.agents.target === undefined
156
+ ? {}
157
+ : { agentsTarget: boundary.agents.target }),
158
+ agentsManagedOnly: true,
159
+ approvedDependencies: [...boundary.approvedDependencies],
160
+ impactClosure: structuredClone(boundary.impactClosure),
161
+ verification: {
162
+ ...phase.verification,
163
+ argv: [...phase.verification.argv],
164
+ },
165
+ snapshot: structuredClone(attempt.snapshot),
166
+ };
167
+ }
168
+
169
+ function nextDeclaredPhase(
170
+ boundary: TaskBoundary,
171
+ phase: PhaseAttempt["phase"],
172
+ ): "green" | "refactor" | null {
173
+ if (phase === "red") return "green";
174
+ if (phase === "green" && boundary.phases.refactor) return "refactor";
175
+ return null;
176
+ }
177
+
178
+ function assertAttemptAllowed(record: TaskRecord, attempt: PhaseAttempt): void {
179
+ switch (record.state.kind) {
180
+ case "candidate-pending":
181
+ throw new Error("current phase candidate is awaiting parent apply");
182
+ case "agents-checkpoint-pending":
183
+ throw new Error("AGENTS checkpoint is pending");
184
+ case "blocked":
185
+ if (attempt.phase !== record.state.phase) {
186
+ throw new Error("terminal task phase mismatch");
187
+ }
188
+ return;
189
+ case "completed":
190
+ if (attempt.phase !== record.state.finalPhase) {
191
+ throw new Error("terminal task phase mismatch");
192
+ }
193
+ return;
194
+ case "ready":
195
+ if (attempt.phase !== record.state.phase) {
196
+ throw new Error("invalid implementation phase transition");
197
+ }
198
+ }
199
+ }
200
+
201
+ function taskPhase(record: TaskRecord): PhaseAttempt["phase"] {
202
+ return record.state.kind === "agents-checkpoint-pending" ||
203
+ record.state.kind === "completed"
204
+ ? record.state.finalPhase
205
+ : record.state.phase;
206
+ }
207
+
208
+ function taskLaunchIndex(record: TaskRecord): 0 | 1 {
209
+ return record.state.kind === "ready" ||
210
+ record.state.kind === "candidate-pending"
211
+ ? record.state.launchIndex
212
+ : record.state.kind === "blocked" &&
213
+ record.state.failure.kind === "attempts-exhausted"
214
+ ? 1
215
+ : 0;
216
+ }
217
+
45
218
  interface PreparedPreflight {
46
219
  snapshot: Bound;
47
220
  baseline: BaselineEntry[];
@@ -194,7 +367,9 @@ function preparePreflight(
194
367
  };
195
368
  }
196
369
 
197
- function cancellationError(signal: AbortSignal): { ok: false; error: string } {
370
+ function cancellationError(
371
+ signal: AbortSignal,
372
+ ): Extract<WrappedDispatchResult, { ok: false }> {
198
373
  const reason = signal.reason;
199
374
  return {
200
375
  ok: false,
@@ -209,60 +384,140 @@ function cancellationError(signal: AbortSignal): { ok: false; error: string } {
209
384
 
210
385
  type FailureClass = "transport" | "artifact" | "environment";
211
386
 
212
- type InternalDispatchResult = DispatchResult & {
387
+ type InternalResultMetadata = {
213
388
  failureKind?: RunFailureKind;
214
389
  failureClass?: FailureClass;
390
+ failure?: ChildFailure | TaskFailure;
215
391
  launchConsumed?: true;
216
392
  };
217
393
 
218
- interface RecoveryIdentity {
219
- taskId?: string;
220
- id: string;
221
- phase: string;
394
+ type ChildDispatchResult = WrappedDispatchResult & InternalResultMetadata;
395
+ type InternalDispatchResult = (
396
+ | WrappedDispatchResult
397
+ | (ImplementOutcome & { usage?: Usage })
398
+ ) &
399
+ InternalResultMetadata;
400
+ type InternalFailureResult = Extract<WrappedDispatchResult, { ok: false }> &
401
+ InternalResultMetadata;
402
+
403
+ interface OutcomeIdentity {
404
+ taskId: string;
405
+ requestId: string;
406
+ phase: ImplementationPhase;
222
407
  }
223
408
 
224
- function recoveryFailure(
225
- identity: RecoveryIdentity,
226
- code: RecoveryCode,
227
- next: RecoveryNext,
228
- launchIndex: 0 | 1,
229
- failureKind: RunFailureKind = "failed",
230
- detail = "branch recovery is required",
231
- ): InternalDispatchResult {
232
- const recovery: RecoveryRecord = {
233
- code,
234
- taskId: identity.taskId ?? identity.id,
235
- requestId: identity.id,
236
- phase: identity.phase,
237
- launchIndex,
238
- branchBlocked: true,
239
- dependentsBlocked: true,
240
- partialResultUsable: false,
241
- independentResultsPreserved: true,
242
- next,
243
- };
409
+ function blockedOutcome(
410
+ identity: OutcomeIdentity,
411
+ failure: TaskFailure,
412
+ ): ImplementOutcome {
413
+ return { kind: "blocked", ...identity, failure: structuredClone(failure) };
414
+ }
415
+
416
+ function cancelledOutcome(identity: OutcomeIdentity): ImplementOutcome {
417
+ return { kind: "cancelled", ...identity };
418
+ }
419
+
420
+ function retryOutcome(
421
+ identity: OutcomeIdentity,
422
+ scope: "worker" | "checkpoint",
423
+ cause: "artifact" | "stale",
424
+ ): ImplementOutcome {
244
425
  return {
245
- ok: false,
246
- error: `${code}: ${detail}`,
247
- recovery,
248
- failureKind,
426
+ kind: "retry",
427
+ ...identity,
428
+ scope,
429
+ cause,
430
+ remainingAttempts: 1,
249
431
  };
250
432
  }
251
433
 
252
- function normalizedArtifactRejection(error: string): string {
253
- const preflight = /candidate preflight rejected: artifact:([a-z0-9-]+)/i.exec(
254
- error,
255
- );
256
- return preflight
257
- ? `candidate-preflight:${preflight[1].toLowerCase()}`
434
+ function failureClassOf(failure: ChildFailure): FailureClass | undefined {
435
+ switch (failure.kind) {
436
+ case "artifact":
437
+ return "artifact";
438
+ case "environment":
439
+ return "environment";
440
+ case "transport":
441
+ return "transport";
442
+ case "stale":
443
+ case "approval-boundary":
444
+ case "cancelled":
445
+ case "result-limit":
446
+ return undefined;
447
+ }
448
+ }
449
+
450
+ function artifactCorrectionEvidence(failure?: ChildFailure): string {
451
+ return failure?.kind === "artifact"
452
+ ? `candidate:${failure.code}`
258
453
  : "generated-artifact-rejection";
259
454
  }
260
455
 
261
- interface RetainedRunIdentity {
262
- taskId: string;
263
- requestId: string;
264
- phase: string;
265
- launchIndex: 0 | 1;
456
+ function childFailureOf(result: InternalFailureResult): ChildFailure {
457
+ if (result.failure) {
458
+ switch (result.failure.kind) {
459
+ case "artifact":
460
+ case "stale":
461
+ case "environment":
462
+ case "approval-boundary":
463
+ case "cancelled":
464
+ case "result-limit":
465
+ case "transport":
466
+ return result.failure;
467
+ case "attempts-exhausted":
468
+ case "checkpoint-attempts-exhausted":
469
+ break;
470
+ }
471
+ }
472
+ if (result.failureKind === "cancelled") {
473
+ return { kind: "cancelled", code: "cancelled" };
474
+ }
475
+ switch (result.failureClass) {
476
+ case "artifact":
477
+ return { kind: "artifact", code: "invalid-diff" };
478
+ case "environment":
479
+ return { kind: "environment", code: "root-unavailable" };
480
+ default:
481
+ return {
482
+ kind: "transport",
483
+ code:
484
+ result.failureKind === "timed-out" ? "timeout" : "transport-failure",
485
+ };
486
+ }
487
+ }
488
+
489
+ function terminalTaskFailure(failure: ChildFailure): TaskFailure | undefined {
490
+ switch (failure.kind) {
491
+ case "approval-boundary":
492
+ case "environment":
493
+ case "result-limit":
494
+ return failure;
495
+ case "artifact":
496
+ case "stale":
497
+ case "cancelled":
498
+ case "transport":
499
+ return undefined;
500
+ }
501
+ }
502
+
503
+ function attemptFailureCause(
504
+ failure: ChildFailure,
505
+ ): "artifact" | "stale" | "transport" {
506
+ switch (failure.kind) {
507
+ case "artifact":
508
+ case "stale":
509
+ case "transport":
510
+ return failure.kind;
511
+ case "approval-boundary":
512
+ case "environment":
513
+ case "result-limit":
514
+ case "cancelled":
515
+ throw new Error("non-retryable failure reached the launch budget");
516
+ }
517
+ }
518
+
519
+ function assertNever(value: never): never {
520
+ throw new Error(`unhandled candidate failure: ${JSON.stringify(value)}`);
266
521
  }
267
522
 
268
523
  export type RunFailureKind = "failed" | "cancelled" | "timed-out";
@@ -293,7 +548,7 @@ export type RuntimeActivityObserver = (
293
548
  ) => void | Promise<void>;
294
549
 
295
550
  interface ScheduledRunResult {
296
- dispatch: DispatchResult;
551
+ dispatch: InternalDispatchResult;
297
552
  failureKind?: RunFailureKind;
298
553
  }
299
554
 
@@ -343,26 +598,30 @@ function isSafeBound(snapshot: unknown): snapshot is Bound {
343
598
  return true;
344
599
  }
345
600
 
346
- export type DispatchResult =
601
+ export type WrappedDispatchResult =
347
602
  | {
348
603
  ok: true;
349
604
  action: string;
350
605
  result?: unknown;
351
606
  resultId?: string;
352
- usage?: unknown;
607
+ usage?: Usage;
353
608
  }
354
609
  | {
355
610
  ok: false;
356
611
  notReady?: true;
357
612
  error: string;
358
- recovery?: RecoveryRecord;
613
+ failure?: ChildFailure | TaskFailure;
614
+ usage?: Usage;
359
615
  };
360
616
 
361
- export interface ApplyResult {
362
- targets: string[];
363
- checkExitCode: 0;
364
- applyExitCode: 0;
365
- sequence?: number;
617
+ export type DispatchResult =
618
+ | WrappedDispatchResult
619
+ | (ImplementOutcome & { usage?: Usage });
620
+
621
+ export function isWrappedDispatchResult(
622
+ result: DispatchResult,
623
+ ): result is WrappedDispatchResult {
624
+ return "ok" in result;
366
625
  }
367
626
 
368
627
  export interface RuntimeOptions {
@@ -378,6 +637,7 @@ interface StoredRunContext {
378
637
  sequence: number;
379
638
  runningEmitted: boolean;
380
639
  terminalEmitted: boolean;
640
+ usage: UsageAggregator;
381
641
  }
382
642
 
383
643
  export class Runtime {
@@ -389,7 +649,7 @@ export class Runtime {
389
649
  private batchSeq = 0;
390
650
  private readonly parentPayloadBridge: ParentPayloadBridge;
391
651
  private readonly registry = new WorkerRegistry();
392
- private readonly retainedRuns = new Map<string, RetainedRunIdentity>();
652
+ private readonly taskRecords = new WeakMap<RequestEnvelope, TaskRecord>();
393
653
  private readonly runContexts = new WeakMap<
394
654
  RequestEnvelope,
395
655
  StoredRunContext
@@ -404,8 +664,57 @@ export class Runtime {
404
664
  execute: async (request, signal) => {
405
665
  const context = this.runContexts.get(request);
406
666
  if (!context) throw new Error("scheduled run context is unavailable");
667
+ const task = this.taskRecords.get(request);
668
+ let candidateRollback:
669
+ | {
670
+ resultId: string;
671
+ requestId: string;
672
+ phase: ImplementationPhase;
673
+ launchIndex: 0 | 1;
674
+ priorState: TaskRecord["state"];
675
+ }
676
+ | undefined;
677
+ const rollback = () => {
678
+ const pending = candidateRollback;
679
+ if (!pending) return;
680
+ this.results.discard(pending.resultId);
681
+ if (
682
+ task?.state.kind === "candidate-pending" &&
683
+ task.state.resultId === pending.resultId &&
684
+ task.state.originRequestId === pending.requestId &&
685
+ task.state.phase === pending.phase &&
686
+ task.state.launchIndex === pending.launchIndex
687
+ ) {
688
+ task.state = structuredClone(pending.priorState);
689
+ }
690
+ };
691
+ signal.addEventListener("abort", rollback, { once: true });
407
692
  this.notify(request, context, "running");
408
- const result = await this.runScheduled(request, context.ctx, signal);
693
+ const result = await this.runScheduled(request, context, signal);
694
+ if (!isWrappedDispatchResult(result) && result.kind === "candidate") {
695
+ if (!task) throw new Error("candidate TaskRecord is unavailable");
696
+ if (task.state.kind !== "ready") {
697
+ throw new Error("candidate TaskRecord is not ready");
698
+ }
699
+ candidateRollback = {
700
+ resultId: result.resultId,
701
+ requestId: result.requestId,
702
+ phase: result.phase,
703
+ launchIndex: task.state.launchIndex,
704
+ priorState: structuredClone(task.state),
705
+ };
706
+ if (signal.aborted) {
707
+ rollback();
708
+ } else {
709
+ try {
710
+ this.rememberRetainedResult(result, request, task);
711
+ } catch (error) {
712
+ this.results.discard(result.resultId);
713
+ throw error;
714
+ }
715
+ if (signal.aborted) rollback();
716
+ }
717
+ }
409
718
  return {
410
719
  dispatch: result,
411
720
  ...(result.failureKind === undefined
@@ -418,13 +727,19 @@ export class Runtime {
418
727
 
419
728
  async execute(
420
729
  action: string,
421
- params: { request?: unknown; resultId?: string },
730
+ params: {
731
+ request?: unknown;
732
+ resultId?: string;
733
+ requestId?: string;
734
+ rejection?: unknown;
735
+ agentsCheckpoint?: unknown;
736
+ },
422
737
  ctx?: RunContext,
423
738
  signal?: AbortSignal,
424
739
  observer?: RuntimeActivityObserver,
425
740
  ): Promise<DispatchResult> {
426
741
  if (!(ACTIONS as readonly string[]).includes(action)) {
427
- return { ok: false, error: `unknown action: ${String(action)}` };
742
+ throw new Error(`unknown action: ${String(action)}`);
428
743
  }
429
744
  if (!this.activation.isActive()) {
430
745
  return { ok: false, notReady: true, error: "dispatcher is not active" };
@@ -432,13 +747,55 @@ export class Runtime {
432
747
  if (action === "run")
433
748
  return this.run(params.request, ctx, signal, observer);
434
749
  if (action === "apply") {
435
- if (!ctx || !params.resultId)
436
- return { ok: false, error: "apply requires context and resultId" };
437
- return this.enqueueApply(ctx.cwd, params.resultId, signal);
750
+ if (!ctx) throw new Error("apply requires context");
751
+ if (params.agentsCheckpoint !== undefined) {
752
+ if (params.resultId !== undefined) {
753
+ throw new Error("apply accepts either resultId or AGENTS checkpoint");
754
+ }
755
+ const validation = validateAgentsCheckpointAttempt(
756
+ params.agentsCheckpoint,
757
+ );
758
+ if (!validation.ok) {
759
+ throw new Error(`Implement protocol error: ${validation.reason}`);
760
+ }
761
+ return this.enqueueAgentsCheckpoint(ctx.cwd, validation.value, signal);
762
+ }
763
+ if (!params.resultId) {
764
+ throw new Error("apply requires resultId");
765
+ }
766
+ const retained = this.results.get(params.resultId);
767
+ if (
768
+ params.requestId !== undefined ||
769
+ (retained && this.results.hasCandidateIdentity(params.resultId))
770
+ ) {
771
+ const validation = validateImplementApplyOperation(params);
772
+ if (!validation.ok) {
773
+ throw new Error(`Implement protocol error: ${validation.reason}`);
774
+ }
775
+ if (!retained || !this.results.hasCandidateIdentity(params.resultId)) {
776
+ throw new Error("retained Implement result not found");
777
+ }
778
+ return this.enqueueApply(ctx.cwd, validation.value, signal);
779
+ }
780
+ return this.enqueueApply(ctx.cwd, { resultId: params.resultId }, signal);
438
781
  }
439
782
  if (action === "discard") {
440
- if (!params.resultId)
441
- return { ok: false, error: "discard requires resultId" };
783
+ if (!params.resultId) throw new Error("discard requires resultId");
784
+ const retained = this.results.get(params.resultId);
785
+ if (
786
+ params.requestId !== undefined ||
787
+ params.rejection !== undefined ||
788
+ (retained && this.results.hasCandidateIdentity(params.resultId))
789
+ ) {
790
+ const validation = validateImplementDiscardOperation(params);
791
+ if (!validation.ok) {
792
+ throw new Error(`Implement protocol error: ${validation.reason}`);
793
+ }
794
+ if (!retained || !this.results.hasCandidateIdentity(params.resultId)) {
795
+ throw new Error("retained Implement result not found");
796
+ }
797
+ return this.enqueueDiscard(validation.value);
798
+ }
442
799
  return this.discardRetainedCandidate(params.resultId)
443
800
  ? { ok: true, action }
444
801
  : { ok: false, error: "retained result not found" };
@@ -451,181 +808,493 @@ export class Runtime {
451
808
  return { ok: true, action: "finish" };
452
809
  }
453
810
 
454
- private discardRetainedCandidate(resultId: string): boolean {
455
- const identity = this.retainedRuns.get(resultId);
456
- if (!this.results.discard(resultId)) return false;
457
- this.retainedRuns.delete(resultId);
458
- if (!identity) return true;
459
- const worker = this.registry.get(identity.taskId);
811
+ private enqueueAgentsCheckpoint(
812
+ root: string,
813
+ attempt: AgentsCheckpointAttempt,
814
+ signal?: AbortSignal,
815
+ ): Promise<DispatchResult> {
816
+ const sequence = ++this.applySeq;
817
+ return this.enqueueParentApply(() =>
818
+ this.performAgentsCheckpoint(root, attempt, signal),
819
+ ).then((result) => {
820
+ if (
821
+ isWrappedDispatchResult(result) ||
822
+ result.kind !== "completed" ||
823
+ !result.result
824
+ ) {
825
+ return result;
826
+ }
827
+ return {
828
+ ...result,
829
+ result: { ...result.result, sequence },
830
+ };
831
+ });
832
+ }
833
+
834
+ private async performAgentsCheckpoint(
835
+ root: string,
836
+ attempt: AgentsCheckpointAttempt,
837
+ signal?: AbortSignal,
838
+ ): Promise<DispatchResult> {
839
+ const workspaceRoot = canonicalWorkspaceRoot(root);
840
+ if (!workspaceRoot) {
841
+ throw new Error("AGENTS checkpoint workspace is unavailable");
842
+ }
843
+ const worker = this.registry.get(
844
+ taskRecordKey(workspaceRoot, attempt.changeId, attempt.taskId),
845
+ );
846
+ if (!worker) {
847
+ if (
848
+ this.registry
849
+ .values()
850
+ .some((record) => record.workspaceRoot === workspaceRoot)
851
+ ) {
852
+ throw new Error("AGENTS checkpoint identity mismatch");
853
+ }
854
+ throw new Error("logical Worker is unavailable for AGENTS checkpoint");
855
+ }
856
+ const agents = worker.boundary.agents;
857
+ if (agents.impact === "none" || !agents.target || !agents.managedOnly) {
858
+ throw new Error("AGENTS checkpoint contract mismatch");
859
+ }
860
+ if (worker.state.kind !== "agents-checkpoint-pending") {
861
+ throw new Error("AGENTS checkpoint is not pending");
862
+ }
460
863
  if (
461
- worker?.currentPhase.requestId !== identity.requestId ||
462
- worker.currentPhase.phase !== identity.phase
864
+ Object.keys(attempt.snapshot as Record<string, unknown>).length !== 1 ||
865
+ !Object.hasOwn(attempt.snapshot as object, agents.target)
463
866
  ) {
464
- return true;
867
+ throw new Error("AGENTS checkpoint target mismatch");
465
868
  }
466
- if (identity.launchIndex === 0) {
467
- worker.currentPhase.correctionIndex = 1;
468
- worker.state = { kind: "ready" };
469
- } else {
470
- worker.state = { kind: "blocked", reason: "mechanical" };
869
+ const request: AgentsCheckpointRequest = {
870
+ stage: "abel-implement",
871
+ taskId: worker.boundary.taskId,
872
+ agentsImpact: agents.impact,
873
+ agentsTarget: agents.target,
874
+ agentsManagedOnly: true,
875
+ stableCheckpoint: true,
876
+ snapshot: attempt.snapshot,
877
+ diff: attempt.diff,
878
+ };
879
+ const result = await applyAgentsCheckpoint(workspaceRoot, request, signal);
880
+ if (result.ok) {
881
+ const finalPhase = worker.state.finalPhase;
882
+ worker.state = {
883
+ kind: "completed",
884
+ finalPhase,
885
+ };
886
+ const { ok: _ok, ...checkpoint } = result;
887
+ return {
888
+ kind: "completed",
889
+ requestId: attempt.requestId,
890
+ taskId: attempt.taskId,
891
+ finalPhase,
892
+ result: checkpoint,
893
+ };
894
+ }
895
+ return this.presentCheckpointFailure(attempt, worker, result.failure);
896
+ }
897
+
898
+ private presentCheckpointFailure(
899
+ attempt: AgentsCheckpointAttempt,
900
+ worker: TaskRecord,
901
+ failure: CandidateFailure,
902
+ ): DispatchResult {
903
+ if (worker.state.kind !== "agents-checkpoint-pending") {
904
+ throw new Error("AGENTS checkpoint is not pending");
905
+ }
906
+ const phase = worker.state.finalPhase;
907
+ const identity = {
908
+ taskId: attempt.taskId,
909
+ requestId: attempt.requestId,
910
+ phase,
911
+ };
912
+ switch (failure.kind) {
913
+ case "artifact": {
914
+ if (worker.state.attemptIndex === 1) {
915
+ const taskFailure: TaskFailure = {
916
+ kind: "checkpoint-attempts-exhausted",
917
+ cause: "artifact",
918
+ };
919
+ worker.state = {
920
+ kind: "blocked",
921
+ phase,
922
+ failure: structuredClone(taskFailure),
923
+ };
924
+ return blockedOutcome(identity, taskFailure);
925
+ }
926
+ worker.state = {
927
+ kind: "agents-checkpoint-pending",
928
+ finalPhase: phase,
929
+ attemptIndex: 1,
930
+ };
931
+ return retryOutcome(identity, "checkpoint", "artifact");
932
+ }
933
+ case "stale": {
934
+ if (worker.state.attemptIndex === 1) {
935
+ const taskFailure: TaskFailure = {
936
+ kind: "checkpoint-attempts-exhausted",
937
+ cause: "stale",
938
+ };
939
+ worker.state = {
940
+ kind: "blocked",
941
+ phase,
942
+ failure: structuredClone(taskFailure),
943
+ };
944
+ return blockedOutcome(identity, taskFailure);
945
+ }
946
+ worker.state = {
947
+ kind: "agents-checkpoint-pending",
948
+ finalPhase: phase,
949
+ attemptIndex: 1,
950
+ };
951
+ return retryOutcome(identity, "checkpoint", "stale");
952
+ }
953
+ case "environment": {
954
+ const taskFailure: TaskFailure = failure;
955
+ worker.state = {
956
+ kind: "blocked",
957
+ phase,
958
+ failure: structuredClone(taskFailure),
959
+ };
960
+ return blockedOutcome(identity, taskFailure);
961
+ }
962
+ case "approval-boundary": {
963
+ const taskFailure: TaskFailure = failure;
964
+ worker.state = {
965
+ kind: "blocked",
966
+ phase,
967
+ failure: structuredClone(taskFailure),
968
+ };
969
+ return blockedOutcome(identity, taskFailure);
970
+ }
971
+ case "cancelled":
972
+ return cancelledOutcome(identity);
973
+ case "result-limit": {
974
+ const taskFailure: TaskFailure = failure;
975
+ worker.state = {
976
+ kind: "blocked",
977
+ phase,
978
+ failure: structuredClone(taskFailure),
979
+ };
980
+ return blockedOutcome(identity, taskFailure);
981
+ }
982
+ default:
983
+ return assertNever(failure);
471
984
  }
472
- return true;
985
+ }
986
+
987
+ private resolveRetainedCandidate(
988
+ resultId: string,
989
+ operationRoot?: string,
990
+ ): { retained: BoundRetainedResult; worker: TaskRecord } | undefined {
991
+ const candidate = this.results.get(resultId);
992
+ if (!candidate) return undefined;
993
+ if (!this.results.hasCandidateIdentity(resultId)) return undefined;
994
+ if (
995
+ candidate.stage !== "abel-implement" ||
996
+ candidate.canonicalRoot === undefined ||
997
+ candidate.changeId === undefined ||
998
+ candidate.taskId === undefined ||
999
+ candidate.originRequestId === undefined ||
1000
+ candidate.phase === undefined ||
1001
+ candidate.launchIndex === undefined
1002
+ ) {
1003
+ throw new Error("retained candidate identity mismatch");
1004
+ }
1005
+ if (
1006
+ operationRoot !== undefined &&
1007
+ canonicalWorkspaceRoot(operationRoot) !== candidate.canonicalRoot
1008
+ ) {
1009
+ throw new Error("retained candidate identity mismatch");
1010
+ }
1011
+ const worker = this.registry.get(
1012
+ taskRecordKey(
1013
+ candidate.canonicalRoot,
1014
+ candidate.changeId,
1015
+ candidate.taskId,
1016
+ ),
1017
+ );
1018
+ if (!worker) throw new Error("retained candidate identity mismatch");
1019
+ if (
1020
+ worker.state.kind !== "candidate-pending" ||
1021
+ worker.state.resultId !== resultId ||
1022
+ worker.state.originRequestId !== candidate.originRequestId ||
1023
+ worker.state.phase !== candidate.phase ||
1024
+ worker.state.launchIndex !== candidate.launchIndex
1025
+ ) {
1026
+ throw new Error("retained candidate identity mismatch");
1027
+ }
1028
+ const phase = worker.boundary.phases[worker.state.phase];
1029
+ if (!phase || !isSafeBound(candidate.snapshot)) {
1030
+ throw new Error("retained candidate identity mismatch");
1031
+ }
1032
+ const expected: RetainedCandidateFacts = {
1033
+ stage: "abel-implement",
1034
+ canonicalRoot: worker.workspaceRoot,
1035
+ root: worker.workspaceRoot,
1036
+ changeId: worker.boundary.changeId,
1037
+ taskId: worker.boundary.taskId,
1038
+ originRequestId: worker.state.originRequestId,
1039
+ phase: worker.state.phase,
1040
+ launchIndex: worker.state.launchIndex,
1041
+ writeSet: [...phase.write],
1042
+ approvedDependencies: [...worker.boundary.approvedDependencies],
1043
+ snapshot: candidate.snapshot,
1044
+ };
1045
+ return {
1046
+ retained: this.results.resolveIdentity(resultId, expected),
1047
+ worker,
1048
+ };
1049
+ }
1050
+
1051
+ private discardRetainedCandidate(resultId: string): boolean {
1052
+ return this.results.discard(resultId);
473
1053
  }
474
1054
 
475
1055
  private enqueueApply(
476
1056
  root: string,
477
- id: string,
1057
+ operation: ImplementApplyOperation | { resultId: string },
478
1058
  signal?: AbortSignal,
479
1059
  ): Promise<DispatchResult> {
480
- const retainedIdentity = this.retainedRuns.get(id);
481
- const run = this.applyTail.then(
482
- () => applyRetainedPatch({ root, id, store: this.results, signal }),
483
- () => applyRetainedPatch({ root, id, store: this.results, signal }),
484
- );
485
- this.applyTail = run.then(
486
- () => undefined,
487
- () => undefined,
1060
+ const id = operation.resultId;
1061
+ const resolved = this.resolveRetainedCandidate(id, root);
1062
+ const requestId = "requestId" in operation ? operation.requestId : null;
1063
+ if (requestId !== null && !resolved) {
1064
+ throw new Error("retained Implement result not found");
1065
+ }
1066
+ if (resolved && requestId === null) {
1067
+ throw new Error("Implement apply operation identity is missing");
1068
+ }
1069
+ const applyRoot = resolved?.retained.root ?? root;
1070
+ const run = this.enqueueParentApply(() =>
1071
+ applyRetainedPatch({ root: applyRoot, id, store: this.results, signal }),
488
1072
  );
489
1073
  const seq = ++this.applySeq;
490
- return run.then(
491
- (result) => {
492
- if (result.ok) {
493
- this.retainedRuns.delete(id);
494
- if (retainedIdentity) {
495
- const worker = this.registry.get(retainedIdentity.taskId);
496
- if (
497
- worker?.currentPhase.requestId === retainedIdentity.requestId &&
498
- worker.currentPhase.phase === retainedIdentity.phase
499
- ) {
500
- worker.state = { kind: "phase-applied" };
1074
+ return run.then((result) => {
1075
+ if (result.ok) {
1076
+ const applied = { ...result.result, sequence: seq };
1077
+ if (resolved) {
1078
+ if (requestId === null)
1079
+ throw new Error("Implement apply operation identity is missing");
1080
+ const { retained: identity, worker } = resolved;
1081
+ if (worker.state.kind === "candidate-pending") {
1082
+ const readyPhase = nextDeclaredPhase(
1083
+ worker.boundary,
1084
+ identity.phase,
1085
+ );
1086
+ if (readyPhase) {
1087
+ worker.state = {
1088
+ kind: "ready",
1089
+ phase: readyPhase,
1090
+ launchIndex: 0,
1091
+ };
1092
+ return {
1093
+ kind: "applied",
1094
+ requestId,
1095
+ taskId: identity.taskId,
1096
+ phase: identity.phase as "red" | "green",
1097
+ readyPhase,
1098
+ result: applied,
1099
+ } satisfies ImplementOutcome;
1100
+ } else if (worker.boundary.agents.impact === "none") {
1101
+ worker.state = {
1102
+ kind: "completed",
1103
+ finalPhase: identity.phase as "green" | "refactor",
1104
+ };
1105
+ return {
1106
+ kind: "completed",
1107
+ requestId,
1108
+ taskId: identity.taskId,
1109
+ finalPhase: identity.phase as "green" | "refactor",
1110
+ result: applied,
1111
+ } satisfies ImplementOutcome;
1112
+ } else {
1113
+ worker.state = {
1114
+ kind: "agents-checkpoint-pending",
1115
+ finalPhase: identity.phase as "green" | "refactor",
1116
+ attemptIndex: 0,
1117
+ };
1118
+ return {
1119
+ kind: "checkpoint-required",
1120
+ requestId,
1121
+ taskId: identity.taskId,
1122
+ finalPhase: identity.phase as "green" | "refactor",
1123
+ result: applied,
1124
+ } satisfies ImplementOutcome;
501
1125
  }
502
1126
  }
503
- return {
504
- ok: true,
505
- action: "apply",
506
- result: { ...result, sequence: seq },
507
- };
1127
+ throw new Error("retained candidate is not pending");
508
1128
  }
509
- return this.presentApplyFailure(
510
- id,
511
- retainedIdentity,
512
- result.error,
513
- signal,
514
- );
515
- },
516
- (error) =>
517
- this.presentApplyFailure(
518
- id,
519
- retainedIdentity,
520
- (error as Error).message,
521
- signal,
522
- ),
1129
+ return {
1130
+ ok: true,
1131
+ action: "apply",
1132
+ result: applied,
1133
+ };
1134
+ }
1135
+ if (!resolved) {
1136
+ return {
1137
+ ok: false,
1138
+ error: "candidate application failed",
1139
+ failure: result.failure,
1140
+ };
1141
+ }
1142
+ if (requestId === null)
1143
+ throw new Error("Implement apply operation identity is missing");
1144
+ return this.presentApplyFailure(
1145
+ requestId,
1146
+ id,
1147
+ resolved.retained,
1148
+ resolved.worker,
1149
+ result.failure,
1150
+ );
1151
+ });
1152
+ }
1153
+
1154
+ private enqueueDiscard(
1155
+ operation: ImplementDiscardOperation,
1156
+ ): Promise<DispatchResult> {
1157
+ return this.enqueueParentApply(async () => {
1158
+ const resolved = this.resolveRetainedCandidate(operation.resultId);
1159
+ if (!resolved || !this.results.discard(operation.resultId)) {
1160
+ throw new Error("retained Implement result not found");
1161
+ }
1162
+ const { retained: identity, worker } = resolved;
1163
+ const outcomeIdentity = {
1164
+ requestId: operation.requestId,
1165
+ taskId: identity.taskId,
1166
+ phase: identity.phase,
1167
+ };
1168
+ const rejection = operation.rejection;
1169
+ if (rejection.kind === "approval-boundary") {
1170
+ worker.state = {
1171
+ kind: "blocked",
1172
+ phase: identity.phase,
1173
+ failure: structuredClone(rejection),
1174
+ };
1175
+ return blockedOutcome(outcomeIdentity, rejection);
1176
+ }
1177
+ if (identity.launchIndex === 0) {
1178
+ worker.state = {
1179
+ kind: "ready",
1180
+ phase: identity.phase,
1181
+ launchIndex: 1,
1182
+ correction: structuredClone(rejection),
1183
+ };
1184
+ return retryOutcome(outcomeIdentity, "worker", "artifact");
1185
+ }
1186
+ const failure: TaskFailure = {
1187
+ kind: "attempts-exhausted",
1188
+ cause: "artifact",
1189
+ };
1190
+ worker.state = {
1191
+ kind: "blocked",
1192
+ phase: identity.phase,
1193
+ failure: structuredClone(failure),
1194
+ };
1195
+ return blockedOutcome(outcomeIdentity, failure);
1196
+ });
1197
+ }
1198
+
1199
+ private enqueueParentApply<T>(operation: () => Promise<T>): Promise<T> {
1200
+ const run = this.applyTail.then(operation, operation);
1201
+ this.applyTail = run.then(
1202
+ () => undefined,
1203
+ () => undefined,
523
1204
  );
1205
+ return run;
524
1206
  }
525
1207
 
526
1208
  private presentApplyFailure(
1209
+ requestId: string,
527
1210
  resultId: string,
528
- identity: RetainedRunIdentity | undefined,
529
- error: string,
530
- signal?: AbortSignal,
1211
+ identity: BoundRetainedResult,
1212
+ worker: TaskRecord,
1213
+ failure: CandidateFailure,
531
1214
  ): DispatchResult {
532
- if (signal?.aborted) return cancellationError(signal);
533
- if (!identity) return { ok: false, error };
534
- if (/cancelled/i.test(error)) {
535
- return { ok: false, error: "candidate application cancelled" };
536
- }
537
- if (/candidate preflight rejected: environment:/i.test(error)) {
538
- return recoveryFailure(
539
- {
540
- taskId: identity.taskId,
541
- id: identity.requestId,
1215
+ const outcomeIdentity = {
1216
+ taskId: identity.taskId,
1217
+ requestId,
1218
+ phase: identity.phase,
1219
+ };
1220
+ switch (failure.kind) {
1221
+ case "environment": {
1222
+ this.results.discard(resultId);
1223
+ worker.state = {
1224
+ kind: "blocked",
542
1225
  phase: identity.phase,
543
- },
544
- "environment-blocked",
545
- "repair-environment",
546
- identity.launchIndex,
547
- );
548
- }
549
- const worker = this.registry.get(identity.taskId);
550
- if (/candidate preflight rejected: design:/i.test(error)) {
551
- this.results.discard(resultId);
552
- this.retainedRuns.delete(resultId);
553
- if (worker) {
554
- worker.state = { kind: "ready" };
1226
+ failure: structuredClone(failure),
1227
+ };
1228
+ return blockedOutcome(outcomeIdentity, failure);
555
1229
  }
556
- return recoveryFailure(
557
- {
558
- taskId: identity.taskId,
559
- id: identity.requestId,
1230
+ case "approval-boundary": {
1231
+ this.results.discard(resultId);
1232
+ worker.state = {
1233
+ kind: "blocked",
560
1234
  phase: identity.phase,
561
- },
562
- "design-required",
563
- "return-to-design",
564
- identity.launchIndex,
565
- "failed",
566
- "approved Red verification contract is invalid",
567
- );
568
- }
569
- if (
570
- /^(?:candidate preflight rejected: stale:|stale file snapshot)/i.test(
571
- error,
572
- )
573
- ) {
574
- this.results.discard(resultId);
575
- this.retainedRuns.delete(resultId);
576
- if (worker) {
1235
+ failure: structuredClone(failure),
1236
+ };
1237
+ return blockedOutcome(outcomeIdentity, failure);
1238
+ }
1239
+ case "stale": {
1240
+ this.results.discard(resultId);
577
1241
  if (identity.launchIndex === 0) {
578
- worker.state = { kind: "stale-redispatch-pending" };
1242
+ worker.state = {
1243
+ kind: "ready",
1244
+ phase: identity.phase,
1245
+ launchIndex: 1,
1246
+ correction: structuredClone(failure),
1247
+ };
579
1248
  } else {
580
- worker.state = { kind: "blocked", reason: "mechanical" };
1249
+ const taskFailure: TaskFailure = {
1250
+ kind: "attempts-exhausted",
1251
+ cause: "stale",
1252
+ };
1253
+ worker.state = {
1254
+ kind: "blocked",
1255
+ phase: identity.phase,
1256
+ failure: structuredClone(taskFailure),
1257
+ };
1258
+ return blockedOutcome(outcomeIdentity, taskFailure);
581
1259
  }
1260
+ return retryOutcome(outcomeIdentity, "worker", "stale");
582
1261
  }
583
- if (identity.launchIndex === 1) {
584
- return recoveryFailure(
585
- {
586
- taskId: identity.taskId,
587
- id: identity.requestId,
1262
+ case "artifact": {
1263
+ this.results.discard(resultId);
1264
+ const correctionAvailable = identity.launchIndex === 0;
1265
+ if (!correctionAvailable) {
1266
+ const taskFailure: TaskFailure = {
1267
+ kind: "attempts-exhausted",
1268
+ cause: "artifact",
1269
+ };
1270
+ worker.state = {
1271
+ kind: "blocked",
588
1272
  phase: identity.phase,
589
- },
590
- "mechanical-redispatch-exhausted",
591
- "finish-unaffected",
592
- 1,
593
- "failed",
594
- "phase launch budget is exhausted",
595
- );
1273
+ failure: structuredClone(taskFailure),
1274
+ };
1275
+ return blockedOutcome(outcomeIdentity, taskFailure);
1276
+ }
1277
+ worker.state = {
1278
+ kind: "ready",
1279
+ phase: identity.phase,
1280
+ launchIndex: 1,
1281
+ correction: structuredClone(failure),
1282
+ };
1283
+ return retryOutcome(outcomeIdentity, "worker", "artifact");
596
1284
  }
597
- return { ok: false, error };
598
- }
599
- if (/^retained result (?:not found|root mismatch)/i.test(error)) {
600
- return { ok: false, error };
601
- }
602
-
603
- this.results.discard(resultId);
604
- this.retainedRuns.delete(resultId);
605
- const correctionAvailable = identity.launchIndex === 0;
606
- if (worker) {
607
- worker.currentPhase.correctionIndex = identity.launchIndex;
608
- worker.state = correctionAvailable
609
- ? {
610
- kind: "artifact-correction-pending",
611
- rejection: normalizedArtifactRejection(error),
612
- }
613
- : { kind: "blocked", reason: "artifact" };
1285
+ case "cancelled":
1286
+ return cancelledOutcome(outcomeIdentity);
1287
+ case "result-limit":
1288
+ this.results.discard(resultId);
1289
+ worker.state = {
1290
+ kind: "blocked",
1291
+ phase: identity.phase,
1292
+ failure: structuredClone(failure),
1293
+ };
1294
+ return blockedOutcome(outcomeIdentity, failure);
1295
+ default:
1296
+ return assertNever(failure);
614
1297
  }
615
- return recoveryFailure(
616
- {
617
- taskId: identity.taskId,
618
- id: identity.requestId,
619
- phase: identity.phase,
620
- },
621
- "implementation-artifact-delivery-blocked",
622
- correctionAvailable ? "correct-artifact" : "finish-unaffected",
623
- identity.launchIndex,
624
- "failed",
625
- correctionAvailable
626
- ? "generated implementation artifact requires bounded correction"
627
- : "artifact correction launch budget is exhausted",
628
- );
629
1298
  }
630
1299
 
631
1300
  private async dispatchChild(
@@ -634,30 +1303,27 @@ export class Runtime {
634
1303
  ctx: RunContext,
635
1304
  signal: AbortSignal,
636
1305
  artifactRejection?: string,
637
- ): Promise<InternalDispatchResult> {
1306
+ ): Promise<ChildDispatchResult> {
638
1307
  if (signal.aborted)
639
1308
  return {
640
1309
  ...cancellationError(signal),
641
1310
  failureKind: "cancelled",
642
1311
  };
643
- let phase: Awaited<ReturnType<typeof runtimeFromContext>>;
644
- try {
645
- phase = await runtimeFromContext(ctx, this.parentPayloadBridge, signal);
646
- } catch (error) {
647
- const bridgeUnavailable =
648
- error instanceof Error &&
649
- error.message === "parent payload bridge is unavailable";
1312
+ const phase = await runtimeFromContext(
1313
+ ctx,
1314
+ this.parentPayloadBridge,
1315
+ signal,
1316
+ );
1317
+ if (!phase.ok) {
650
1318
  return {
651
1319
  ok: false,
652
- error: signal.aborted
653
- ? cancellationError(signal).error
654
- : (error as Error).message,
655
- failureKind: signal.aborted ? "cancelled" : "failed",
656
- failureClass: signal.aborted
657
- ? undefined
658
- : bridgeUnavailable
659
- ? "transport"
660
- : "environment",
1320
+ error: phase.error,
1321
+ failure: phase.failure,
1322
+ failureKind:
1323
+ phase.failure.kind === "cancelled" ? "cancelled" : "failed",
1324
+ ...(phase.failure.kind === "cancelled"
1325
+ ? {}
1326
+ : { failureClass: phase.failure.kind }),
661
1327
  };
662
1328
  }
663
1329
  if (signal.aborted)
@@ -675,6 +1341,11 @@ export class Runtime {
675
1341
  readSet: [...envelope.declared.read],
676
1342
  writeSet: [...envelope.declared.write],
677
1343
  verification: envelope.verification ?? null,
1344
+ agentsImpact: envelope.agentsImpact ?? "none",
1345
+ agentsTarget: envelope.agentsTarget ?? null,
1346
+ agentsManagedOnly: true,
1347
+ agentsWriteAllowed: false,
1348
+ impactClosure: envelope.impactClosure ?? null,
678
1349
  };
679
1350
  const systemPrompt = [
680
1351
  agent.content,
@@ -707,22 +1378,13 @@ export class Runtime {
707
1378
  return {
708
1379
  ...cancellationError(signal),
709
1380
  failureKind: "cancelled",
1381
+ usage: child.usage,
710
1382
  };
711
1383
  if (!child.ok) {
712
- const generatedArtifact =
713
- child.classification.attempts > 0 ||
714
- child.classification.finalCategory === "text-only" ||
715
- child.classification.finalCategory === "mixed" ||
716
- child.classification.finalCategory === "multiple-submit";
717
1384
  return {
718
1385
  ...child,
719
1386
  failureKind: child.failureKind,
720
- failureClass:
721
- child.failureKind === "timed-out" || child.transportFailure
722
- ? "transport"
723
- : generatedArtifact
724
- ? "artifact"
725
- : "transport",
1387
+ failureClass: failureClassOf(child.failure),
726
1388
  };
727
1389
  }
728
1390
  if (envelope.output === "evidence") {
@@ -740,31 +1402,57 @@ export class Runtime {
740
1402
  let prepared: PreparedPreflight | null;
741
1403
  try {
742
1404
  prepared = preparePreflight(ctx.cwd, envelope, requestSnapshot);
743
- } catch {
1405
+ } catch (error) {
1406
+ if (!isFileSystemFailure(error)) throw error;
744
1407
  return {
745
1408
  ok: false,
746
1409
  error: "candidate preflight inputs are unavailable",
1410
+ failure: { kind: "environment", code: "root-unavailable" },
747
1411
  failureKind: "failed",
748
1412
  failureClass: "environment",
749
1413
  launchConsumed: true,
1414
+ usage: child.usage,
750
1415
  };
751
1416
  }
752
1417
  if (envelope.verification && !prepared) {
753
1418
  return {
754
1419
  ok: false,
755
1420
  error: "candidate preflight inputs are unavailable",
1421
+ failure: { kind: "environment", code: "root-unavailable" },
756
1422
  failureKind: "failed",
757
1423
  failureClass: "environment",
758
1424
  launchConsumed: true,
1425
+ usage: child.usage,
759
1426
  };
760
1427
  }
1428
+ const task = this.taskRecords.get(envelope);
1429
+ const retainedRoot = task?.workspaceRoot ?? ctx.cwd;
1430
+ const retainedSnapshot =
1431
+ prepared?.snapshot ??
1432
+ (isSafeBound(envelope.snapshot)
1433
+ ? mergeBounds(
1434
+ snapshotFiles(retainedRoot, envelope.declared.write),
1435
+ envelope.snapshot,
1436
+ )
1437
+ : undefined);
761
1438
  const resultId = this.results.retain({
762
1439
  diff: diff.diff,
763
1440
  writeSet: envelope.declared.write,
764
- root: ctx.cwd,
1441
+ approvedDependencies: envelope.approvedDependencies ?? [],
1442
+ root: retainedRoot,
1443
+ ...(task
1444
+ ? {
1445
+ stage: "abel-implement" as const,
1446
+ canonicalRoot: task.workspaceRoot,
1447
+ changeId: task.boundary.changeId,
1448
+ taskId: task.boundary.taskId,
1449
+ originRequestId: envelope.id,
1450
+ phase: taskPhase(task),
1451
+ launchIndex: taskLaunchIndex(task),
1452
+ }
1453
+ : {}),
765
1454
  ...(prepared
766
1455
  ? {
767
- snapshot: prepared.snapshot,
768
1456
  baseline: prepared.baseline,
769
1457
  verification: envelope.verification,
770
1458
  packageManifest: prepared.packageManifest,
@@ -772,13 +1460,8 @@ export class Runtime {
772
1460
  dependencyTarget: prepared.dependencyTarget,
773
1461
  }
774
1462
  : {}),
1463
+ ...(retainedSnapshot ? { snapshot: retainedSnapshot } : {}),
775
1464
  });
776
- if (!prepared && isSafeBound(envelope.snapshot)) {
777
- const retained = this.results.get(resultId);
778
- if (retained) {
779
- retained.snapshot = mergeBounds(retained.snapshot, envelope.snapshot);
780
- }
781
- }
782
1465
  return {
783
1466
  ok: true,
784
1467
  action: "run",
@@ -794,37 +1477,136 @@ export class Runtime {
794
1477
  signal?: AbortSignal,
795
1478
  observer?: RuntimeActivityObserver,
796
1479
  ): Promise<DispatchResult> {
797
- if (!ctx) return { ok: false, error: "run requires extension context" };
798
- if (signal?.aborted) return cancellationError(signal);
1480
+ const implementRequest = targetsImplement(request);
1481
+ if (!ctx) {
1482
+ if (implementRequest) throw new Error("run requires extension context");
1483
+ return { ok: false, error: "run requires extension context" };
1484
+ }
799
1485
  const validation = validateRequestEnvelope(request);
800
- if (!validation.ok) return { ok: false, error: validation.reason };
801
- const envelope: RequestEnvelope = {
802
- ...validation.value,
803
- taskId: validation.value.taskId ?? validation.value.id,
804
- roots: [...validation.value.roots],
805
- context: { ...validation.value.context },
806
- declared: {
807
- ...validation.value.declared,
808
- read: [...validation.value.declared.read],
809
- write: [...validation.value.declared.write],
810
- conflicts: [...validation.value.declared.conflicts],
811
- resources: [...validation.value.declared.resources],
812
- },
813
- ...(validation.value.verification === undefined
814
- ? {}
815
- : {
816
- verification: {
817
- ...validation.value.verification,
818
- argv: [...validation.value.verification.argv],
819
- },
820
- }),
821
- };
1486
+ if (!validation.ok) {
1487
+ if (implementRequest) {
1488
+ throw new Error(`Implement protocol error: ${validation.reason}`);
1489
+ }
1490
+ return { ok: false, error: validation.reason };
1491
+ }
1492
+
1493
+ let envelope: RequestEnvelope;
1494
+ if (isImplementRunRequest(validation.value)) {
1495
+ const runRequest = validation.value;
1496
+ const attempt = runRequest.attempt;
1497
+ const workspaceRoot = canonicalWorkspaceRoot(ctx.cwd);
1498
+ if (
1499
+ runRequest.kind === "open-task" &&
1500
+ workspaceRoot &&
1501
+ this.registry.has(
1502
+ taskRecordKey(workspaceRoot, attempt.changeId, attempt.taskId),
1503
+ )
1504
+ ) {
1505
+ throw new Error("duplicate task open protocol error");
1506
+ }
1507
+ if (runRequest.kind === "open-task" && signal?.aborted) {
1508
+ return cancelledOutcome({
1509
+ taskId: attempt.taskId,
1510
+ requestId: attempt.requestId,
1511
+ phase: attempt.phase,
1512
+ });
1513
+ }
1514
+ if (!workspaceRoot) {
1515
+ throw new Error("implementation workspace is unavailable");
1516
+ }
1517
+ const key = taskRecordKey(
1518
+ workspaceRoot,
1519
+ attempt.changeId,
1520
+ attempt.taskId,
1521
+ );
1522
+ if (
1523
+ runRequest.kind === "open-task" &&
1524
+ this.registry
1525
+ .values()
1526
+ .some(
1527
+ (record) =>
1528
+ record.workspaceRoot === workspaceRoot &&
1529
+ record.state.kind !== "blocked" &&
1530
+ record.state.kind !== "completed" &&
1531
+ declarationsConflict(
1532
+ taskConflictOf(runRequest.boundary),
1533
+ record.conflict,
1534
+ ),
1535
+ )
1536
+ ) {
1537
+ return {
1538
+ kind: "deferred",
1539
+ taskId: attempt.taskId,
1540
+ requestId: attempt.requestId,
1541
+ reason: "task-conflict",
1542
+ };
1543
+ }
1544
+ if (!ctx.model) {
1545
+ throw new Error("implementation model is unavailable");
1546
+ }
1547
+ const identity = workerIdentity(ctx.model);
1548
+ let record: TaskRecord;
1549
+ if (runRequest.kind === "open-task") {
1550
+ record = this.registry.open(
1551
+ runRequest.boundary,
1552
+ identity,
1553
+ workspaceRoot,
1554
+ attempt,
1555
+ );
1556
+ } else {
1557
+ const existing = this.registry.get(key);
1558
+ if (!existing) {
1559
+ throw new Error("task identity mismatch or task is not open");
1560
+ }
1561
+ if (existing.workerIdentity !== identity) {
1562
+ throw new Error("provider/model identity mismatch");
1563
+ }
1564
+ const attemptReason = validatePhaseAttemptAgainstBoundary(
1565
+ existing.boundary,
1566
+ attempt,
1567
+ );
1568
+ if (attemptReason !== null) throw new Error(attemptReason);
1569
+ assertAttemptAllowed(existing, attempt);
1570
+ record = existing;
1571
+ }
1572
+ if (record.state.kind === "blocked") {
1573
+ return blockedOutcome(
1574
+ {
1575
+ taskId: record.boundary.taskId,
1576
+ requestId: attempt.requestId,
1577
+ phase: record.state.phase,
1578
+ },
1579
+ record.state.failure,
1580
+ );
1581
+ }
1582
+ if (record.state.kind === "completed") {
1583
+ return {
1584
+ kind: "completed",
1585
+ taskId: record.boundary.taskId,
1586
+ requestId: attempt.requestId,
1587
+ finalPhase: record.state.finalPhase,
1588
+ };
1589
+ }
1590
+ if (signal?.aborted) {
1591
+ return cancelledOutcome({
1592
+ taskId: record.boundary.taskId,
1593
+ requestId: attempt.requestId,
1594
+ phase: taskPhase(record),
1595
+ });
1596
+ }
1597
+ envelope = deriveImplementEnvelope(record, attempt);
1598
+ this.taskRecords.set(envelope, record);
1599
+ } else {
1600
+ if (signal?.aborted) return cancellationError(signal);
1601
+ envelope = cloneLegacyEnvelope(validation.value);
1602
+ }
822
1603
  const context: StoredRunContext = {
823
1604
  ctx,
824
1605
  observer,
825
1606
  sequence: ++this.batchSeq,
826
1607
  runningEmitted: false,
827
1608
  terminalEmitted: false,
1609
+ usage: new UsageAggregator(),
828
1610
  };
829
1611
  this.runContexts.set(envelope, context);
830
1612
  this.notify(envelope, context, "queued");
@@ -842,28 +1624,51 @@ export class Runtime {
842
1624
  try {
843
1625
  const outcome = await batch.result(envelope.id);
844
1626
  if (outcome.status === "succeeded" && outcome.value) {
845
- const internalResult = outcome.value.dispatch as DispatchResult & {
846
- failureKind?: RunFailureKind;
847
- failureClass?: FailureClass;
848
- };
1627
+ const internalResult = outcome.value.dispatch;
849
1628
  const {
850
1629
  failureKind,
851
1630
  failureClass: _failureClass,
1631
+ launchConsumed: _launchConsumed,
852
1632
  ...publicResult
853
1633
  } = internalResult;
854
- if (publicResult.ok) {
1634
+ const dispatch = publicResult as DispatchResult;
1635
+ if (isWrappedDispatchResult(dispatch)) {
1636
+ if (!dispatch.ok) {
1637
+ const state: RuntimeActivityState = failureKind ?? "failed";
1638
+ this.notify(envelope, context, state);
1639
+ return dispatch;
1640
+ }
855
1641
  this.notify(envelope, context, "completed");
856
- return publicResult;
1642
+ return dispatch;
857
1643
  }
858
- const state: RuntimeActivityState = failureKind ?? "failed";
859
- this.notify(envelope, context, state);
860
- return publicResult;
1644
+ this.notify(
1645
+ envelope,
1646
+ context,
1647
+ dispatch.kind === "cancelled" ? "cancelled" : "completed",
1648
+ );
1649
+ return dispatch;
861
1650
  }
862
1651
  const state: RuntimeActivityState =
863
1652
  outcome.status === "cancelled" ? "cancelled" : "failed";
1653
+ const task = this.taskRecords.get(envelope);
1654
+ if (task) {
1655
+ this.notify(envelope, context, state);
1656
+ if (outcome.status === "cancelled") {
1657
+ return {
1658
+ ...cancelledOutcome({
1659
+ taskId: task.boundary.taskId,
1660
+ requestId: envelope.id,
1661
+ phase: taskPhase(task),
1662
+ }),
1663
+ ...this.usageMetadata(context),
1664
+ };
1665
+ }
1666
+ throw new Error(outcome.error ?? `scheduled run ${outcome.status}`);
1667
+ }
864
1668
  const terminal = {
865
1669
  ok: false as const,
866
1670
  error: outcome.error ?? `scheduled run ${outcome.status}`,
1671
+ ...this.usageMetadata(context),
867
1672
  };
868
1673
  this.notify(envelope, context, state);
869
1674
  return terminal;
@@ -916,192 +1721,96 @@ export class Runtime {
916
1721
 
917
1722
  private async runScheduled(
918
1723
  envelope: RequestEnvelope,
919
- ctx: RunContext,
1724
+ context: StoredRunContext,
920
1725
  signal: AbortSignal,
921
1726
  ): Promise<InternalDispatchResult> {
922
- if (signal.aborted)
923
- return {
924
- ...cancellationError(signal),
925
- failureKind: "cancelled",
926
- };
927
- const taskId = envelope.taskId ?? envelope.id;
1727
+ const ctx = context.ctx;
1728
+ const task = this.taskRecords.get(envelope);
1729
+ if (!task && envelope.stage === "abel-implement") {
1730
+ throw new Error("implementation TaskRecord is unavailable");
1731
+ }
1732
+ if (signal.aborted) {
1733
+ if (task) {
1734
+ return cancelledOutcome({
1735
+ taskId: task.boundary.taskId,
1736
+ requestId: envelope.id,
1737
+ phase: taskPhase(task),
1738
+ });
1739
+ }
1740
+ return { ...cancellationError(signal), failureKind: "cancelled" };
1741
+ }
928
1742
  const agent = loadAgentDefinitions().find(
929
1743
  (item) => item.role === envelope.role,
930
1744
  );
931
- if (!agent)
1745
+ if (!agent) {
1746
+ if (task) throw new Error("implementation agent definition is missing");
932
1747
  return {
933
- ...recoveryFailure(
934
- envelope,
935
- "environment-blocked",
936
- "repair-environment",
937
- 0,
938
- ),
1748
+ ok: false,
1749
+ error: "agent definition is unavailable",
1750
+ failure: { kind: "environment", code: "root-unavailable" },
1751
+ failureKind: "failed",
1752
+ failureClass: "environment",
939
1753
  };
940
- if (!ctx.model)
941
- return recoveryFailure(
942
- envelope,
943
- "environment-blocked",
944
- "repair-environment",
945
- 0,
946
- "failed",
947
- "phase runtime is unavailable",
948
- );
949
- const identity = workerIdentity(ctx.model);
950
- const contract = contractOf(envelope);
951
- const existing = this.registry.get(taskId);
952
- if (existing) {
953
- if (existing.identity !== identity) {
954
- return recoveryFailure(
955
- envelope,
956
- "environment-blocked",
957
- "repair-environment",
958
- existing.currentPhase.correctionIndex,
959
- "failed",
960
- "pinned provider/model identity differs",
961
- );
962
- }
963
- if (!sameContract(existing.taskContract, contract)) {
964
- return recoveryFailure(
965
- envelope,
966
- "design-required",
967
- "return-to-design",
968
- existing.currentPhase.correctionIndex,
969
- "failed",
970
- "immutable task contract changed",
971
- );
972
- }
973
- if (
974
- existing.state.kind === "blocked" &&
975
- existing.state.reason === "artifact"
976
- ) {
977
- return recoveryFailure(
978
- envelope,
979
- "implementation-artifact-delivery-blocked",
980
- "finish-unaffected",
981
- 1,
982
- "failed",
983
- "artifact correction launch budget is exhausted",
984
- );
985
- }
986
- if (
987
- existing.state.kind === "blocked" &&
988
- existing.state.reason === "mechanical"
989
- ) {
990
- return recoveryFailure(
991
- envelope,
992
- "mechanical-redispatch-exhausted",
993
- "finish-unaffected",
994
- 1,
995
- "failed",
996
- "phase launch budget is exhausted",
997
- );
998
- }
999
- } else {
1000
- this.registry.pin(contract, identity);
1001
- }
1002
- let pinned = this.registry.get(taskId);
1003
- if (!pinned) {
1004
- return recoveryFailure(
1005
- envelope,
1006
- "environment-blocked",
1007
- "repair-environment",
1008
- 0,
1009
- "failed",
1010
- "logical Worker could not be pinned",
1011
- );
1012
1754
  }
1013
-
1014
- const correctingArtifact =
1015
- pinned.state.kind === "artifact-correction-pending";
1016
- const redispatchingStale = pinned.state.kind === "stale-redispatch-pending";
1017
- let artifactRejection: string | undefined;
1018
- const changedPhase =
1019
- pinned.currentPhase.requestId !== envelope.id ||
1020
- pinned.currentPhase.phase !== envelope.phase;
1021
- if (pinned.state.kind === "candidate-pending") {
1755
+ if (!ctx.model) {
1756
+ if (task) throw new Error("implementation model is unavailable");
1022
1757
  return {
1023
1758
  ok: false,
1024
- error: "current phase candidate is awaiting parent apply",
1759
+ error: "phase runtime is unavailable",
1760
+ failure: {
1761
+ kind: "environment",
1762
+ code: "sandbox-runtime-unavailable",
1763
+ },
1025
1764
  failureKind: "failed",
1765
+ failureClass: "environment",
1026
1766
  };
1027
1767
  }
1028
- if (redispatchingStale) {
1029
- if (
1030
- envelope.id !== pinned.currentPhase.requestId ||
1031
- !samePhaseContract(pinned.currentPhase, contract.currentPhase)
1032
- ) {
1033
- return recoveryFailure(
1034
- envelope,
1035
- "design-required",
1036
- "return-to-design",
1037
- pinned.currentPhase.correctionIndex,
1038
- "failed",
1039
- "stale redispatch changed the current phase contract",
1040
- );
1041
- }
1042
- pinned = this.registry.setCurrentPhase(taskId, contract, 1) ?? pinned;
1043
- } else if (correctingArtifact) {
1044
- if (
1045
- envelope.id === pinned.currentPhase.requestId ||
1046
- !samePhaseContract(pinned.currentPhase, contract.currentPhase)
1047
- ) {
1048
- return recoveryFailure(
1049
- envelope,
1050
- "design-required",
1051
- "return-to-design",
1052
- pinned.currentPhase.correctionIndex,
1053
- "failed",
1054
- "artifact correction changed the current phase contract",
1055
- );
1056
- }
1057
- artifactRejection =
1058
- pinned.state.kind === "artifact-correction-pending"
1059
- ? pinned.state.rejection
1060
- : undefined;
1061
- pinned = this.registry.setCurrentPhase(taskId, contract, 1) ?? pinned;
1062
- } else if (changedPhase) {
1063
- if (pinned.state.kind !== "phase-applied") {
1064
- return {
1065
- ok: false,
1066
- error: "current phase candidate is not applied",
1067
- failureKind: "failed",
1068
- };
1069
- }
1070
- const sameAppliedPhase = pinned.currentPhase.phase === envelope.phase;
1071
- if (
1072
- sameAppliedPhase &&
1073
- !samePhaseContract(pinned.currentPhase, contract.currentPhase)
1074
- ) {
1075
- return recoveryFailure(
1076
- envelope,
1077
- "design-required",
1078
- "return-to-design",
1079
- pinned.currentPhase.correctionIndex,
1080
- "failed",
1081
- "same-phase correction changed the current phase contract",
1082
- );
1083
- }
1084
- if (
1085
- !sameAppliedPhase &&
1086
- !isNextImplementationPhase(pinned.currentPhase.phase, envelope.phase)
1087
- ) {
1088
- return recoveryFailure(
1089
- envelope,
1090
- "design-required",
1091
- "return-to-design",
1092
- pinned.currentPhase.correctionIndex,
1093
- "failed",
1094
- "invalid implementation phase transition",
1095
- );
1768
+ if (!task) {
1769
+ const result = await this.dispatchChild(agent, envelope, ctx, signal);
1770
+ this.captureUsage(context, "launch:0", result.usage);
1771
+ return this.withUsage(context, result);
1772
+ }
1773
+ if (task.workerIdentity !== workerIdentity(ctx.model)) {
1774
+ throw new Error("provider/model identity mismatch");
1775
+ }
1776
+ if (task.state.kind !== "ready") {
1777
+ throw new Error("implementation task is not launchable");
1778
+ }
1779
+ const phase = task.state.phase;
1780
+ const launchIndex = task.state.launchIndex;
1781
+ const correction = task.state.correction;
1782
+ const artifactRejection =
1783
+ correction?.kind === "artifact"
1784
+ ? artifactCorrectionEvidence(correction)
1785
+ : undefined;
1786
+ const identity: OutcomeIdentity = {
1787
+ taskId: task.boundary.taskId,
1788
+ requestId: envelope.id,
1789
+ phase,
1790
+ };
1791
+ const block = (failure: TaskFailure): InternalDispatchResult => {
1792
+ task.state = {
1793
+ kind: "blocked",
1794
+ phase,
1795
+ failure: structuredClone(failure),
1796
+ };
1797
+ return blockedOutcome(identity, failure);
1798
+ };
1799
+ const candidate = (
1800
+ result: Extract<ChildDispatchResult, { ok: true }>,
1801
+ ): InternalDispatchResult => {
1802
+ if (typeof result.resultId !== "string" || result.result === undefined) {
1803
+ throw new Error("implementation candidate result is incomplete");
1096
1804
  }
1097
- pinned = this.registry.setCurrentPhase(taskId, contract, 0) ?? pinned;
1098
- } else if (pinned.state.kind === "phase-applied") {
1099
1805
  return {
1100
- ok: false,
1101
- error: "current phase candidate is already applied",
1102
- failureKind: "failed",
1806
+ kind: "candidate",
1807
+ ...identity,
1808
+ resultId: result.resultId,
1809
+ result: result.result as DiffResult,
1103
1810
  };
1104
- }
1811
+ };
1812
+ const finish = (result: InternalDispatchResult) =>
1813
+ this.withUsage(context, result);
1105
1814
 
1106
1815
  const first = await this.dispatchChild(
1107
1816
  agent,
@@ -1110,149 +1819,161 @@ export class Runtime {
1110
1819
  signal,
1111
1820
  artifactRejection,
1112
1821
  );
1113
- if (signal.aborted)
1114
- return {
1115
- ...cancellationError(signal),
1116
- failureKind: "cancelled",
1117
- };
1822
+ this.captureUsage(context, "launch:0", first.usage);
1823
+ if (signal.aborted) {
1824
+ if (first.ok && typeof first.resultId === "string") {
1825
+ this.results.discard(first.resultId);
1826
+ }
1827
+ return finish(cancelledOutcome(identity));
1828
+ }
1118
1829
  if (first.ok) {
1119
- this.rememberRetainedResult(first, envelope, pinned);
1120
- return first;
1830
+ return finish(candidate(first));
1121
1831
  }
1122
- if (first.failureKind === "cancelled") return first;
1123
- if (first.failureClass === "environment") {
1124
- const launchIndex = pinned.currentPhase.correctionIndex;
1125
- if (first.launchConsumed) this.consumeWorkerLaunch(pinned);
1126
- return recoveryFailure(
1127
- envelope,
1128
- "environment-blocked",
1129
- "repair-environment",
1130
- launchIndex,
1131
- first.failureKind,
1132
- );
1832
+ const firstFailure = childFailureOf(first);
1833
+ if (firstFailure.kind === "cancelled") {
1834
+ return finish(cancelledOutcome(identity));
1133
1835
  }
1134
- if (pinned.currentPhase.correctionIndex === 1) {
1135
- const artifactBlocked =
1136
- correctingArtifact || first.failureClass === "artifact";
1137
- pinned.state = {
1138
- kind: "blocked",
1139
- reason: artifactBlocked ? "artifact" : "mechanical",
1140
- };
1141
- return recoveryFailure(
1142
- envelope,
1143
- artifactBlocked
1144
- ? "implementation-artifact-delivery-blocked"
1145
- : "mechanical-redispatch-exhausted",
1146
- "finish-unaffected",
1147
- 1,
1148
- first.failureKind,
1149
- "phase launch budget is exhausted",
1836
+ const firstTerminal = terminalTaskFailure(firstFailure);
1837
+ if (firstTerminal) return finish(block(firstTerminal));
1838
+ if (launchIndex === 1) {
1839
+ return finish(
1840
+ block({
1841
+ kind: "attempts-exhausted",
1842
+ cause: attemptFailureCause(firstFailure),
1843
+ }),
1150
1844
  );
1151
1845
  }
1152
-
1153
- if (first.failureClass === "artifact") {
1154
- pinned.state = {
1155
- kind: "artifact-correction-pending",
1156
- rejection: normalizedArtifactRejection(first.error),
1846
+ if (firstFailure.kind === "artifact" || firstFailure.kind === "stale") {
1847
+ task.state = {
1848
+ kind: "ready",
1849
+ phase,
1850
+ launchIndex: 1,
1851
+ correction: structuredClone(firstFailure),
1157
1852
  };
1158
- return recoveryFailure(
1159
- envelope,
1160
- "implementation-artifact-delivery-blocked",
1161
- "correct-artifact",
1162
- 0,
1163
- first.failureKind,
1164
- "generated implementation artifact requires bounded correction",
1165
- );
1853
+ return finish(retryOutcome(identity, "worker", firstFailure.kind));
1166
1854
  }
1167
1855
 
1168
- pinned = this.registry.setCurrentPhase(taskId, contract, 1) ?? pinned;
1856
+ task.state = {
1857
+ kind: "ready",
1858
+ phase,
1859
+ launchIndex: 1,
1860
+ };
1169
1861
  const second = await this.dispatchChild(agent, envelope, ctx, signal);
1170
- if (signal.aborted)
1171
- return {
1172
- ...cancellationError(signal),
1173
- failureKind: "cancelled",
1174
- };
1862
+ this.captureUsage(context, "launch:1", second.usage);
1863
+ if (signal.aborted) {
1864
+ if (second.ok && typeof second.resultId === "string") {
1865
+ this.results.discard(second.resultId);
1866
+ }
1867
+ return finish(cancelledOutcome(identity));
1868
+ }
1175
1869
  if (second.ok) {
1176
- this.rememberRetainedResult(second, envelope, pinned);
1177
- return second;
1870
+ return finish(candidate(second));
1178
1871
  }
1179
- if (second.failureKind === "cancelled") return second;
1180
- if (second.failureClass === "environment") {
1181
- if (second.launchConsumed) this.consumeWorkerLaunch(pinned);
1182
- return recoveryFailure(
1183
- envelope,
1184
- "environment-blocked",
1185
- "repair-environment",
1186
- 1,
1187
- second.failureKind,
1188
- );
1872
+ const secondFailure = childFailureOf(second);
1873
+ if (secondFailure.kind === "cancelled") {
1874
+ return finish(cancelledOutcome(identity));
1189
1875
  }
1190
- const artifactBlocked = second.failureClass === "artifact";
1191
- pinned.state = {
1192
- kind: "blocked",
1193
- reason: artifactBlocked ? "artifact" : "mechanical",
1194
- };
1195
- return recoveryFailure(
1196
- envelope,
1197
- artifactBlocked
1198
- ? "implementation-artifact-delivery-blocked"
1199
- : "mechanical-redispatch-exhausted",
1200
- "finish-unaffected",
1201
- 1,
1202
- second.failureKind ?? first.failureKind,
1203
- "phase launch budget is exhausted",
1876
+ const secondTerminal = terminalTaskFailure(secondFailure);
1877
+ if (secondTerminal) return finish(block(secondTerminal));
1878
+ return finish(
1879
+ block({
1880
+ kind: "attempts-exhausted",
1881
+ cause: attemptFailureCause(secondFailure),
1882
+ }),
1204
1883
  );
1205
1884
  }
1206
1885
 
1207
- private consumeWorkerLaunch(worker: LogicalWorker): void {
1208
- if (worker.currentPhase.correctionIndex === 0) {
1209
- worker.currentPhase.correctionIndex = 1;
1210
- worker.state = { kind: "ready" };
1211
- return;
1212
- }
1213
- worker.state = { kind: "blocked", reason: "mechanical" };
1886
+ private captureUsage(
1887
+ context: StoredRunContext,
1888
+ id: string,
1889
+ usage: Usage | undefined,
1890
+ ): void {
1891
+ if (usage) context.usage.add(id, usage);
1892
+ }
1893
+
1894
+ private usageMetadata(context: StoredRunContext): { usage?: Usage } {
1895
+ return context.usage.hasUsage() ? { usage: context.usage.total() } : {};
1896
+ }
1897
+
1898
+ private withUsage<T extends InternalDispatchResult>(
1899
+ context: StoredRunContext,
1900
+ result: T,
1901
+ ): T {
1902
+ return {
1903
+ ...result,
1904
+ ...this.usageMetadata(context),
1905
+ };
1214
1906
  }
1215
1907
 
1216
1908
  private rememberRetainedResult(
1217
- result: InternalDispatchResult,
1909
+ result: Extract<ImplementOutcome, { kind: "candidate" }>,
1218
1910
  envelope: RequestEnvelope,
1219
- worker: LogicalWorker,
1911
+ task: TaskRecord,
1220
1912
  ): void {
1221
- if (!result.ok || typeof result.resultId !== "string") return;
1222
- this.retainedRuns.set(result.resultId, {
1223
- taskId: envelope.taskId ?? envelope.id,
1224
- requestId: envelope.id,
1225
- phase: envelope.phase,
1226
- launchIndex: worker.currentPhase.correctionIndex,
1913
+ if (task.state.kind !== "ready") {
1914
+ throw new Error("retained candidate identity mismatch");
1915
+ }
1916
+ const phaseState = task.state;
1917
+ if (
1918
+ result.requestId !== envelope.id ||
1919
+ result.taskId !== task.boundary.taskId ||
1920
+ result.phase !== phaseState.phase
1921
+ ) {
1922
+ throw new Error("retained candidate identity mismatch");
1923
+ }
1924
+ const phase = task.boundary.phases[phaseState.phase];
1925
+ if (!phase || !isSafeBound(envelope.snapshot)) {
1926
+ throw new Error("retained candidate identity mismatch");
1927
+ }
1928
+ const identity: RetainedCandidateIdentity = {
1929
+ stage: "abel-implement",
1930
+ canonicalRoot: task.workspaceRoot,
1931
+ root: task.workspaceRoot,
1932
+ changeId: task.boundary.changeId,
1933
+ taskId: task.boundary.taskId,
1934
+ originRequestId: envelope.id,
1935
+ phase: phaseState.phase,
1936
+ launchIndex: phaseState.launchIndex,
1937
+ };
1938
+ if (!this.results.get(result.resultId)) {
1939
+ throw new Error("retained candidate is unavailable");
1940
+ }
1941
+ this.results.bindIdentity(result.resultId, identity);
1942
+ this.results.resolveIdentity(result.resultId, {
1943
+ ...identity,
1944
+ writeSet: [...phase.write],
1945
+ approvedDependencies: [...task.boundary.approvedDependencies],
1946
+ snapshot: envelope.snapshot,
1227
1947
  });
1228
- worker.state = { kind: "candidate-pending" };
1948
+ task.state = {
1949
+ kind: "candidate-pending",
1950
+ phase: phaseState.phase,
1951
+ launchIndex: phaseState.launchIndex,
1952
+ originRequestId: envelope.id,
1953
+ resultId: result.resultId,
1954
+ };
1229
1955
  }
1230
1956
 
1231
1957
  validateRequest(
1232
1958
  envelope: unknown,
1233
- ): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
1959
+ ): { ok: true; value: RunRequest } | { ok: false; reason: string } {
1234
1960
  return validateRequestEnvelope(envelope);
1235
1961
  }
1236
1962
 
1237
1963
  async drain(): Promise<void> {
1964
+ this.activation.drain();
1965
+ const scheduled = this.scheduler.cancelAll();
1966
+ const applied = this.applyTail;
1967
+ await Promise.all([scheduled, applied]);
1238
1968
  this.parentPayloadBridge?.clear();
1239
- const settled = this.scheduler.cancelAll();
1240
- this.retainedRuns.clear();
1241
1969
  drainStage({
1242
1970
  results: this.results,
1243
1971
  registry: this.registry,
1244
1972
  activation: this.activation,
1245
1973
  });
1246
- await settled;
1247
1974
  }
1248
1975
 
1249
1976
  get state(): ActivationState {
1250
1977
  return this.activation.state;
1251
1978
  }
1252
1979
  }
1253
-
1254
- function isNextImplementationPhase(current: string, next: string): boolean {
1255
- const phases = ["red", "green", "refactor"];
1256
- const index = phases.indexOf(current);
1257
- return index >= 0 && phases[index + 1] === next;
1258
- }