@abelxiaoxing/cadence 1.0.1 → 1.0.2

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,29 +1,199 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
1
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
4
  import { Activation, type ActivationState } from "./activation.ts";
3
5
  import { loadAgentDefinitions } from "./agent-registry.ts";
6
+ import type { BaselineEntry } from "./candidate-preflight.ts";
4
7
  import { runChildSession } from "./child-session.ts";
5
8
  import {
6
9
  ACTIONS,
7
10
  type DiffResult,
8
11
  LIMITS,
12
+ type RecoveryCode,
13
+ type RecoveryNext,
14
+ type RecoveryRecord,
9
15
  type RequestEnvelope,
10
16
  validateRequestEnvelope,
11
17
  } from "./contracts.ts";
12
18
  import { drainStage } from "./drain.ts";
13
- import { type Bound, mergeBounds } from "./file-snapshot.ts";
19
+ import {
20
+ type Bound,
21
+ type DirBound,
22
+ type FileBound,
23
+ isCurrent,
24
+ mergeBounds,
25
+ snapshotDirManifest,
26
+ snapshotFile,
27
+ snapshotFiles,
28
+ } from "./file-snapshot.ts";
29
+ import type { ParentPayloadBridge } from "./parent-payload-bridge.ts";
14
30
  import { runtimeFromContext } from "./parent-provider.ts";
15
31
  import { applyRetainedPatch } from "./patch.ts";
16
32
  import { ResultStore } from "./result-store.ts";
17
33
  import { Scheduler } from "./scheduler.ts";
18
34
  import {
19
35
  contractOf,
36
+ type LogicalWorker,
20
37
  sameContract,
38
+ samePhaseContract,
21
39
  WorkerRegistry,
22
40
  workerIdentity,
23
41
  } from "./worker.ts";
24
42
 
25
43
  const SHA256_HEX = /^[0-9a-f]{64}$/;
26
44
 
45
+ interface PreparedPreflight {
46
+ snapshot: Bound;
47
+ baseline: BaselineEntry[];
48
+ packageManifest: FileBound;
49
+ lockfile: FileBound;
50
+ dependencyTarget: FileBound | DirBound;
51
+ }
52
+
53
+ function addBaselineFile(
54
+ root: string,
55
+ relative: string,
56
+ executable: boolean,
57
+ entries: Map<string, BaselineEntry>,
58
+ observed: Bound,
59
+ ): boolean {
60
+ const bound = snapshotFile(root, relative);
61
+ if (!bound) return false;
62
+ observed[relative] = bound;
63
+ entries.set(relative, {
64
+ path: relative,
65
+ kind: "file",
66
+ sha256: bound.sha256,
67
+ bytes: bound.bytes,
68
+ executable,
69
+ });
70
+ return true;
71
+ }
72
+
73
+ function addDirectoryFiles(
74
+ root: string,
75
+ relative: string,
76
+ entries: Map<string, BaselineEntry>,
77
+ observed: Bound,
78
+ ): boolean {
79
+ const directory = path.resolve(root, relative);
80
+ for (const name of readdirSync(directory).sort()) {
81
+ const child = path.posix.join(relative, name);
82
+ const stat = lstatSync(path.resolve(root, child));
83
+ if (stat.isSymbolicLink()) return false;
84
+ if (stat.isDirectory()) {
85
+ if (!addDirectoryFiles(root, child, entries, observed)) return false;
86
+ continue;
87
+ }
88
+ if (!stat.isFile()) return false;
89
+ if (
90
+ !addBaselineFile(
91
+ root,
92
+ child,
93
+ Boolean(stat.mode & 0o111),
94
+ entries,
95
+ observed,
96
+ )
97
+ )
98
+ return false;
99
+ }
100
+ return true;
101
+ }
102
+
103
+ function preparePreflight(
104
+ root: string,
105
+ envelope: RequestEnvelope,
106
+ requestSnapshot: Bound,
107
+ ): PreparedPreflight | null {
108
+ if (!envelope.verification) return null;
109
+ const packageManifest = snapshotFile(root, "package.json");
110
+ const lockPath = [
111
+ "bun.lock",
112
+ "bun.lockb",
113
+ "package-lock.json",
114
+ "pnpm-lock.yaml",
115
+ "yarn.lock",
116
+ ].find((candidate) => snapshotFile(root, candidate) !== null);
117
+ const lockfile = lockPath ? snapshotFile(root, lockPath) : null;
118
+ const dependencyTarget =
119
+ snapshotDirManifest(root, "node_modules") ??
120
+ snapshotFile(root, "node_modules");
121
+ if (!packageManifest || !lockPath || !lockfile || !dependencyTarget)
122
+ return null;
123
+ if (!isCurrent(root, requestSnapshot)) {
124
+ return {
125
+ snapshot: mergeBounds(
126
+ snapshotFiles(root, ["package.json", lockPath]),
127
+ { node_modules: dependencyTarget },
128
+ requestSnapshot,
129
+ ),
130
+ baseline: [],
131
+ packageManifest,
132
+ lockfile,
133
+ dependencyTarget,
134
+ };
135
+ }
136
+
137
+ const paths = new Set([
138
+ ...Object.keys(requestSnapshot),
139
+ ...envelope.declared.read,
140
+ ...envelope.declared.write,
141
+ "package.json",
142
+ lockPath,
143
+ ...envelope.verification.argv.slice(3),
144
+ ]);
145
+ const entries = new Map<string, BaselineEntry>();
146
+ const observed: Bound = {};
147
+ for (const relative of [...paths].sort()) {
148
+ const absolute = path.resolve(root, relative);
149
+ if (
150
+ absolute === root ||
151
+ !absolute.startsWith(`${path.resolve(root)}${path.sep}`)
152
+ ) {
153
+ continue;
154
+ }
155
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
156
+ if (!stat) {
157
+ observed[relative] = { kind: "absent", absent: true };
158
+ entries.set(relative, { path: relative, kind: "deleted" });
159
+ continue;
160
+ }
161
+ if (stat.isDirectory()) {
162
+ if (relative === "node_modules") continue;
163
+ try {
164
+ if (!addDirectoryFiles(root, relative, entries, observed)) return null;
165
+ } catch {
166
+ return null;
167
+ }
168
+ continue;
169
+ }
170
+ if (!stat.isFile() || stat.isSymbolicLink()) return null;
171
+ if (
172
+ !addBaselineFile(
173
+ root,
174
+ relative,
175
+ Boolean(stat.mode & 0o111),
176
+ entries,
177
+ observed,
178
+ )
179
+ )
180
+ return null;
181
+ }
182
+ const baseline = [...entries.values()].sort((left, right) =>
183
+ left.path.localeCompare(right.path),
184
+ );
185
+ const dependencyBound: Bound = {
186
+ node_modules: dependencyTarget,
187
+ };
188
+ return {
189
+ snapshot: mergeBounds(observed, dependencyBound, requestSnapshot),
190
+ baseline,
191
+ packageManifest,
192
+ lockfile,
193
+ dependencyTarget,
194
+ };
195
+ }
196
+
27
197
  function cancellationError(signal: AbortSignal): { ok: false; error: string } {
28
198
  const reason = signal.reason;
29
199
  return {
@@ -37,6 +207,64 @@ function cancellationError(signal: AbortSignal): { ok: false; error: string } {
37
207
  };
38
208
  }
39
209
 
210
+ type FailureClass = "transport" | "artifact" | "environment";
211
+
212
+ type InternalDispatchResult = DispatchResult & {
213
+ failureKind?: RunFailureKind;
214
+ failureClass?: FailureClass;
215
+ launchConsumed?: true;
216
+ };
217
+
218
+ interface RecoveryIdentity {
219
+ taskId?: string;
220
+ id: string;
221
+ phase: string;
222
+ }
223
+
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
+ };
244
+ return {
245
+ ok: false,
246
+ error: `${code}: ${detail}`,
247
+ recovery,
248
+ failureKind,
249
+ };
250
+ }
251
+
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()}`
258
+ : "generated-artifact-rejection";
259
+ }
260
+
261
+ interface RetainedRunIdentity {
262
+ taskId: string;
263
+ requestId: string;
264
+ phase: string;
265
+ launchIndex: 0 | 1;
266
+ }
267
+
40
268
  export type RunFailureKind = "failed" | "cancelled" | "timed-out";
41
269
  export type RuntimeFailureReason =
42
270
  | "subagent failed"
@@ -123,7 +351,12 @@ export type DispatchResult =
123
351
  resultId?: string;
124
352
  usage?: unknown;
125
353
  }
126
- | { ok: false; notReady?: true; error: string };
354
+ | {
355
+ ok: false;
356
+ notReady?: true;
357
+ error: string;
358
+ recovery?: RecoveryRecord;
359
+ };
127
360
 
128
361
  export interface ApplyResult {
129
362
  targets: string[];
@@ -134,6 +367,7 @@ export interface ApplyResult {
134
367
 
135
368
  export interface RuntimeOptions {
136
369
  activation?: Activation;
370
+ parentPayloadBridge: ParentPayloadBridge;
137
371
  }
138
372
 
139
373
  type RunContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
@@ -153,15 +387,18 @@ export class Runtime {
153
387
  private applyTail: Promise<void> = Promise.resolve();
154
388
  private applySeq = 0;
155
389
  private batchSeq = 0;
390
+ private readonly parentPayloadBridge: ParentPayloadBridge;
156
391
  private readonly registry = new WorkerRegistry();
392
+ private readonly retainedRuns = new Map<string, RetainedRunIdentity>();
157
393
  private readonly runContexts = new WeakMap<
158
394
  RequestEnvelope,
159
395
  StoredRunContext
160
396
  >();
161
397
  private readonly scheduler: Scheduler<ScheduledRunResult>;
162
398
 
163
- constructor(opts: RuntimeOptions = {}) {
399
+ constructor(opts: RuntimeOptions) {
164
400
  this.activation = opts.activation ?? new Activation();
401
+ this.parentPayloadBridge = opts.parentPayloadBridge;
165
402
  this.scheduler = new Scheduler({
166
403
  limit: LIMITS.maxActiveChildSessions,
167
404
  execute: async (request, signal) => {
@@ -197,12 +434,12 @@ export class Runtime {
197
434
  if (action === "apply") {
198
435
  if (!ctx || !params.resultId)
199
436
  return { ok: false, error: "apply requires context and resultId" };
200
- return this.enqueueApply(ctx.cwd, params.resultId);
437
+ return this.enqueueApply(ctx.cwd, params.resultId, signal);
201
438
  }
202
439
  if (action === "discard") {
203
440
  if (!params.resultId)
204
441
  return { ok: false, error: "discard requires resultId" };
205
- return this.results.discard(params.resultId)
442
+ return this.discardRetainedCandidate(params.resultId)
206
443
  ? { ok: true, action }
207
444
  : { ok: false, error: "retained result not found" };
208
445
  }
@@ -214,10 +451,36 @@ export class Runtime {
214
451
  return { ok: true, action: "finish" };
215
452
  }
216
453
 
217
- private enqueueApply(root: string, id: string): Promise<DispatchResult> {
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);
460
+ if (
461
+ worker?.currentPhase.requestId !== identity.requestId ||
462
+ worker.currentPhase.phase !== identity.phase
463
+ ) {
464
+ return true;
465
+ }
466
+ if (identity.launchIndex === 0) {
467
+ worker.currentPhase.correctionIndex = 1;
468
+ worker.state = { kind: "ready" };
469
+ } else {
470
+ worker.state = { kind: "blocked", reason: "mechanical" };
471
+ }
472
+ return true;
473
+ }
474
+
475
+ private enqueueApply(
476
+ root: string,
477
+ id: string,
478
+ signal?: AbortSignal,
479
+ ): Promise<DispatchResult> {
480
+ const retainedIdentity = this.retainedRuns.get(id);
218
481
  const run = this.applyTail.then(
219
- () => applyRetainedPatch({ root, id, store: this.results }),
220
- () => applyRetainedPatch({ root, id, store: this.results }),
482
+ () => applyRetainedPatch({ root, id, store: this.results, signal }),
483
+ () => applyRetainedPatch({ root, id, store: this.results, signal }),
221
484
  );
222
485
  this.applyTail = run.then(
223
486
  () => undefined,
@@ -225,11 +488,143 @@ export class Runtime {
225
488
  );
226
489
  const seq = ++this.applySeq;
227
490
  return run.then(
228
- (result) =>
229
- result.ok
230
- ? { ok: true, action: "apply", result: { ...result, sequence: seq } }
231
- : result,
232
- (error) => ({ ok: false, error: (error as Error).message }),
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" };
501
+ }
502
+ }
503
+ return {
504
+ ok: true,
505
+ action: "apply",
506
+ result: { ...result, sequence: seq },
507
+ };
508
+ }
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
+ ),
523
+ );
524
+ }
525
+
526
+ private presentApplyFailure(
527
+ resultId: string,
528
+ identity: RetainedRunIdentity | undefined,
529
+ error: string,
530
+ signal?: AbortSignal,
531
+ ): 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,
542
+ 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" };
555
+ }
556
+ return recoveryFailure(
557
+ {
558
+ taskId: identity.taskId,
559
+ id: identity.requestId,
560
+ 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) {
577
+ if (identity.launchIndex === 0) {
578
+ worker.state = { kind: "stale-redispatch-pending" };
579
+ } else {
580
+ worker.state = { kind: "blocked", reason: "mechanical" };
581
+ }
582
+ }
583
+ if (identity.launchIndex === 1) {
584
+ return recoveryFailure(
585
+ {
586
+ taskId: identity.taskId,
587
+ id: identity.requestId,
588
+ phase: identity.phase,
589
+ },
590
+ "mechanical-redispatch-exhausted",
591
+ "finish-unaffected",
592
+ 1,
593
+ "failed",
594
+ "phase launch budget is exhausted",
595
+ );
596
+ }
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" };
614
+ }
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",
233
628
  );
234
629
  }
235
630
 
@@ -238,7 +633,8 @@ export class Runtime {
238
633
  envelope: RequestEnvelope,
239
634
  ctx: RunContext,
240
635
  signal: AbortSignal,
241
- ): Promise<DispatchResult & { failureKind?: RunFailureKind }> {
636
+ artifactRejection?: string,
637
+ ): Promise<InternalDispatchResult> {
242
638
  if (signal.aborted)
243
639
  return {
244
640
  ...cancellationError(signal),
@@ -246,14 +642,22 @@ export class Runtime {
246
642
  };
247
643
  let phase: Awaited<ReturnType<typeof runtimeFromContext>>;
248
644
  try {
249
- phase = await runtimeFromContext(ctx, signal);
645
+ phase = await runtimeFromContext(ctx, this.parentPayloadBridge, signal);
250
646
  } catch (error) {
647
+ const bridgeUnavailable =
648
+ error instanceof Error &&
649
+ error.message === "parent payload bridge is unavailable";
251
650
  return {
252
651
  ok: false,
253
652
  error: signal.aborted
254
653
  ? cancellationError(signal).error
255
654
  : (error as Error).message,
256
655
  failureKind: signal.aborted ? "cancelled" : "failed",
656
+ failureClass: signal.aborted
657
+ ? undefined
658
+ : bridgeUnavailable
659
+ ? "transport"
660
+ : "environment",
257
661
  };
258
662
  }
259
663
  if (signal.aborted)
@@ -264,11 +668,23 @@ export class Runtime {
264
668
  const roots = envelope.roots.map(
265
669
  (root) => new URL(root, `file://${ctx.cwd}/`).pathname,
266
670
  );
671
+ const phaseContract = {
672
+ taskId: envelope.taskId ?? envelope.id,
673
+ requestId: envelope.id,
674
+ phase: envelope.phase,
675
+ readSet: [...envelope.declared.read],
676
+ writeSet: [...envelope.declared.write],
677
+ verification: envelope.verification ?? null,
678
+ };
267
679
  const systemPrompt = [
268
680
  agent.content,
269
681
  envelope.objective,
270
682
  envelope.context.agents,
271
683
  envelope.context.contract,
684
+ `<phase-contract>${JSON.stringify(phaseContract)}</phase-contract>`,
685
+ ...(artifactRejection
686
+ ? [`<artifact-rejection>${artifactRejection}</artifact-rejection>`]
687
+ : []),
272
688
  ].join("\n\n");
273
689
  const child = await runChildSession({
274
690
  cwd: ctx.cwd,
@@ -276,10 +692,14 @@ export class Runtime {
276
692
  model: phase.model,
277
693
  systemPrompt,
278
694
  requestId: envelope.id,
695
+ taskId: envelope.taskId,
279
696
  role: envelope.role,
280
697
  phase: envelope.phase,
281
698
  output: envelope.output as "evidence" | "diff",
282
699
  roots,
700
+ allowedPaths: [
701
+ ...new Set([...envelope.declared.read, ...envelope.declared.write]),
702
+ ],
283
703
  timeoutMs: LIMITS.phaseTimeoutMs,
284
704
  signal,
285
705
  });
@@ -288,7 +708,23 @@ export class Runtime {
288
708
  ...cancellationError(signal),
289
709
  failureKind: "cancelled",
290
710
  };
291
- if (!child.ok) return { ...child, failureKind: child.failureKind };
711
+ 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
+ return {
718
+ ...child,
719
+ failureKind: child.failureKind,
720
+ failureClass:
721
+ child.failureKind === "timed-out" || child.transportFailure
722
+ ? "transport"
723
+ : generatedArtifact
724
+ ? "artifact"
725
+ : "transport",
726
+ };
727
+ }
292
728
  if (envelope.output === "evidence") {
293
729
  return {
294
730
  ok: true,
@@ -298,12 +734,46 @@ export class Runtime {
298
734
  };
299
735
  }
300
736
  const diff = child.result as DiffResult;
737
+ const requestSnapshot = isSafeBound(envelope.snapshot)
738
+ ? envelope.snapshot
739
+ : {};
740
+ let prepared: PreparedPreflight | null;
741
+ try {
742
+ prepared = preparePreflight(ctx.cwd, envelope, requestSnapshot);
743
+ } catch {
744
+ return {
745
+ ok: false,
746
+ error: "candidate preflight inputs are unavailable",
747
+ failureKind: "failed",
748
+ failureClass: "environment",
749
+ launchConsumed: true,
750
+ };
751
+ }
752
+ if (envelope.verification && !prepared) {
753
+ return {
754
+ ok: false,
755
+ error: "candidate preflight inputs are unavailable",
756
+ failureKind: "failed",
757
+ failureClass: "environment",
758
+ launchConsumed: true,
759
+ };
760
+ }
301
761
  const resultId = this.results.retain({
302
762
  diff: diff.diff,
303
763
  writeSet: envelope.declared.write,
304
764
  root: ctx.cwd,
765
+ ...(prepared
766
+ ? {
767
+ snapshot: prepared.snapshot,
768
+ baseline: prepared.baseline,
769
+ verification: envelope.verification,
770
+ packageManifest: prepared.packageManifest,
771
+ lockfile: prepared.lockfile,
772
+ dependencyTarget: prepared.dependencyTarget,
773
+ }
774
+ : {}),
305
775
  });
306
- if (isSafeBound(envelope.snapshot)) {
776
+ if (!prepared && isSafeBound(envelope.snapshot)) {
307
777
  const retained = this.results.get(resultId);
308
778
  if (retained) {
309
779
  retained.snapshot = mergeBounds(retained.snapshot, envelope.snapshot);
@@ -330,6 +800,7 @@ export class Runtime {
330
800
  if (!validation.ok) return { ok: false, error: validation.reason };
331
801
  const envelope: RequestEnvelope = {
332
802
  ...validation.value,
803
+ taskId: validation.value.taskId ?? validation.value.id,
333
804
  roots: [...validation.value.roots],
334
805
  context: { ...validation.value.context },
335
806
  declared: {
@@ -339,6 +810,14 @@ export class Runtime {
339
810
  conflicts: [...validation.value.declared.conflicts],
340
811
  resources: [...validation.value.declared.resources],
341
812
  },
813
+ ...(validation.value.verification === undefined
814
+ ? {}
815
+ : {
816
+ verification: {
817
+ ...validation.value.verification,
818
+ argv: [...validation.value.verification.argv],
819
+ },
820
+ }),
342
821
  };
343
822
  const context: StoredRunContext = {
344
823
  ctx,
@@ -365,8 +844,13 @@ export class Runtime {
365
844
  if (outcome.status === "succeeded" && outcome.value) {
366
845
  const internalResult = outcome.value.dispatch as DispatchResult & {
367
846
  failureKind?: RunFailureKind;
847
+ failureClass?: FailureClass;
368
848
  };
369
- const { failureKind, ...publicResult } = internalResult;
849
+ const {
850
+ failureKind,
851
+ failureClass: _failureClass,
852
+ ...publicResult
853
+ } = internalResult;
370
854
  if (publicResult.ok) {
371
855
  this.notify(envelope, context, "completed");
372
856
  return publicResult;
@@ -434,97 +918,314 @@ export class Runtime {
434
918
  envelope: RequestEnvelope,
435
919
  ctx: RunContext,
436
920
  signal: AbortSignal,
437
- ): Promise<DispatchResult & { failureKind?: RunFailureKind }> {
921
+ ): Promise<InternalDispatchResult> {
438
922
  if (signal.aborted)
439
923
  return {
440
924
  ...cancellationError(signal),
441
925
  failureKind: "cancelled",
442
926
  };
927
+ const taskId = envelope.taskId ?? envelope.id;
443
928
  const agent = loadAgentDefinitions().find(
444
929
  (item) => item.role === envelope.role,
445
930
  );
446
931
  if (!agent)
447
932
  return {
448
- ok: false,
449
- error: "package Agent role not found",
450
- failureKind: "failed",
933
+ ...recoveryFailure(
934
+ envelope,
935
+ "environment-blocked",
936
+ "repair-environment",
937
+ 0,
938
+ ),
451
939
  };
452
940
  if (!ctx.model)
453
- return {
454
- ok: false,
455
- error: "run requires a model identity",
456
- failureKind: "failed",
457
- };
941
+ return recoveryFailure(
942
+ envelope,
943
+ "environment-blocked",
944
+ "repair-environment",
945
+ 0,
946
+ "failed",
947
+ "phase runtime is unavailable",
948
+ );
458
949
  const identity = workerIdentity(ctx.model);
459
950
  const contract = contractOf(envelope);
460
- const existing = this.registry.get(envelope.id);
951
+ const existing = this.registry.get(taskId);
461
952
  if (existing) {
462
953
  if (existing.identity !== identity) {
463
- return {
464
- ok: false,
465
- error:
466
- "blocked: logical Worker pinned provider/model identity differs",
467
- failureKind: "failed",
468
- };
954
+ return recoveryFailure(
955
+ envelope,
956
+ "environment-blocked",
957
+ "repair-environment",
958
+ existing.currentPhase.correctionIndex,
959
+ "failed",
960
+ "pinned provider/model identity differs",
961
+ );
469
962
  }
470
- if (!sameContract(existing.contract, contract)) {
471
- return {
472
- ok: false,
473
- error:
474
- "blocked: logical Worker contract changed for a recovery scope",
475
- failureKind: "failed",
476
- };
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
+ }
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") {
1022
+ return {
1023
+ ok: false,
1024
+ error: "current phase candidate is awaiting parent apply",
1025
+ failureKind: "failed",
1026
+ };
1027
+ }
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
+ );
477
1056
  }
478
- if (existing.redispatchUsed) {
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") {
479
1064
  return {
480
1065
  ok: false,
481
- error: "blocked: mechanical redispatch is already exhausted",
1066
+ error: "current phase candidate is not applied",
482
1067
  failureKind: "failed",
483
1068
  };
484
1069
  }
485
- } else {
486
- this.registry.pin(contract, identity);
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
+ );
1096
+ }
1097
+ pinned = this.registry.setCurrentPhase(taskId, contract, 0) ?? pinned;
1098
+ } else if (pinned.state.kind === "phase-applied") {
1099
+ return {
1100
+ ok: false,
1101
+ error: "current phase candidate is already applied",
1102
+ failureKind: "failed",
1103
+ };
487
1104
  }
488
- const pinned = this.registry.get(envelope.id);
489
- const first = await this.dispatchChild(agent, envelope, ctx, signal);
1105
+
1106
+ const first = await this.dispatchChild(
1107
+ agent,
1108
+ envelope,
1109
+ ctx,
1110
+ signal,
1111
+ artifactRejection,
1112
+ );
490
1113
  if (signal.aborted)
491
1114
  return {
492
1115
  ...cancellationError(signal),
493
1116
  failureKind: "cancelled",
494
1117
  };
495
1118
  if (first.ok) {
496
- if (pinned) pinned.redispatchUsed = false;
1119
+ this.rememberRetainedResult(first, envelope, pinned);
497
1120
  return first;
498
1121
  }
499
- if (pinned?.redispatchUsed) {
500
- return {
501
- ok: false,
502
- error: "blocked: mechanical redispatch failed and is exhausted",
503
- failureKind: first.failureKind ?? "failed",
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
+ );
1133
+ }
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",
504
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",
1150
+ );
505
1151
  }
506
- if (!pinned) {
507
- return {
508
- ok: false,
509
- error: "blocked: logical Worker is not pinned",
510
- failureKind: "failed",
1152
+
1153
+ if (first.failureClass === "artifact") {
1154
+ pinned.state = {
1155
+ kind: "artifact-correction-pending",
1156
+ rejection: normalizedArtifactRejection(first.error),
511
1157
  };
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
+ );
512
1166
  }
513
- pinned.redispatchUsed = true;
1167
+
1168
+ pinned = this.registry.setCurrentPhase(taskId, contract, 1) ?? pinned;
514
1169
  const second = await this.dispatchChild(agent, envelope, ctx, signal);
515
1170
  if (signal.aborted)
516
1171
  return {
517
1172
  ...cancellationError(signal),
518
1173
  failureKind: "cancelled",
519
1174
  };
520
- if (!second.ok) {
521
- return {
522
- ok: false,
523
- error: "blocked: identical mechanical redispatch failed again",
524
- failureKind: second.failureKind ?? first.failureKind ?? "failed",
525
- };
1175
+ if (second.ok) {
1176
+ this.rememberRetainedResult(second, envelope, pinned);
1177
+ return second;
1178
+ }
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
+ );
526
1189
  }
527
- return second;
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",
1204
+ );
1205
+ }
1206
+
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" };
1214
+ }
1215
+
1216
+ private rememberRetainedResult(
1217
+ result: InternalDispatchResult,
1218
+ envelope: RequestEnvelope,
1219
+ worker: LogicalWorker,
1220
+ ): 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,
1227
+ });
1228
+ worker.state = { kind: "candidate-pending" };
528
1229
  }
529
1230
 
530
1231
  validateRequest(
@@ -534,7 +1235,9 @@ export class Runtime {
534
1235
  }
535
1236
 
536
1237
  async drain(): Promise<void> {
1238
+ this.parentPayloadBridge?.clear();
537
1239
  const settled = this.scheduler.cancelAll();
1240
+ this.retainedRuns.clear();
538
1241
  drainStage({
539
1242
  results: this.results,
540
1243
  registry: this.registry,
@@ -547,3 +1250,9 @@ export class Runtime {
547
1250
  return this.activation.state;
548
1251
  }
549
1252
  }
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
+ }