@abelxiaoxing/cadence 1.0.2 → 1.0.4

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