@abelxiaoxing/cadence 1.0.0 → 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,28 +1,302 @@
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";
33
+ import { Scheduler } from "./scheduler.ts";
17
34
  import {
18
35
  contractOf,
36
+ type LogicalWorker,
19
37
  sameContract,
38
+ samePhaseContract,
20
39
  WorkerRegistry,
21
40
  workerIdentity,
22
41
  } from "./worker.ts";
23
42
 
24
43
  const SHA256_HEX = /^[0-9a-f]{64}$/;
25
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
+
197
+ function cancellationError(signal: AbortSignal): { ok: false; error: string } {
198
+ const reason = signal.reason;
199
+ return {
200
+ ok: false,
201
+ error:
202
+ reason instanceof Error
203
+ ? reason.message
204
+ : typeof reason === "string"
205
+ ? reason
206
+ : "child phase cancelled",
207
+ };
208
+ }
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
+
268
+ export type RunFailureKind = "failed" | "cancelled" | "timed-out";
269
+ export type RuntimeFailureReason =
270
+ | "subagent failed"
271
+ | "subagent cancelled"
272
+ | "phase timed out";
273
+ export type RuntimeActivityState =
274
+ | "queued"
275
+ | "running"
276
+ | "completed"
277
+ | "failed"
278
+ | "cancelled"
279
+ | "timed-out";
280
+
281
+ export interface RuntimeActivityEvent {
282
+ state: RuntimeActivityState;
283
+ requestId: string;
284
+ role: string;
285
+ phase: string;
286
+ objective: string;
287
+ sequence: number;
288
+ failureReason?: RuntimeFailureReason;
289
+ }
290
+
291
+ export type RuntimeActivityObserver = (
292
+ event: RuntimeActivityEvent,
293
+ ) => void | Promise<void>;
294
+
295
+ interface ScheduledRunResult {
296
+ dispatch: DispatchResult;
297
+ failureKind?: RunFailureKind;
298
+ }
299
+
26
300
  function isSafeBound(snapshot: unknown): snapshot is Bound {
27
301
  if (
28
302
  typeof snapshot !== "object" ||
@@ -77,7 +351,12 @@ export type DispatchResult =
77
351
  resultId?: string;
78
352
  usage?: unknown;
79
353
  }
80
- | { ok: false; notReady?: true; error: string };
354
+ | {
355
+ ok: false;
356
+ notReady?: true;
357
+ error: string;
358
+ recovery?: RecoveryRecord;
359
+ };
81
360
 
82
361
  export interface ApplyResult {
83
362
  targets: string[];
@@ -88,26 +367,61 @@ export interface ApplyResult {
88
367
 
89
368
  export interface RuntimeOptions {
90
369
  activation?: Activation;
370
+ parentPayloadBridge: ParentPayloadBridge;
91
371
  }
92
372
 
93
373
  type RunContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
94
374
 
375
+ interface StoredRunContext {
376
+ ctx: RunContext;
377
+ observer?: RuntimeActivityObserver;
378
+ sequence: number;
379
+ runningEmitted: boolean;
380
+ terminalEmitted: boolean;
381
+ }
382
+
95
383
  export class Runtime {
96
384
  readonly activation: Activation;
97
385
  readonly limits = LIMITS;
98
386
  readonly results = new ResultStore();
99
387
  private applyTail: Promise<void> = Promise.resolve();
100
388
  private applySeq = 0;
389
+ private batchSeq = 0;
390
+ private readonly parentPayloadBridge: ParentPayloadBridge;
101
391
  private readonly registry = new WorkerRegistry();
392
+ private readonly retainedRuns = new Map<string, RetainedRunIdentity>();
393
+ private readonly runContexts = new WeakMap<
394
+ RequestEnvelope,
395
+ StoredRunContext
396
+ >();
397
+ private readonly scheduler: Scheduler<ScheduledRunResult>;
102
398
 
103
- constructor(opts: RuntimeOptions = {}) {
399
+ constructor(opts: RuntimeOptions) {
104
400
  this.activation = opts.activation ?? new Activation();
401
+ this.parentPayloadBridge = opts.parentPayloadBridge;
402
+ this.scheduler = new Scheduler({
403
+ limit: LIMITS.maxActiveChildSessions,
404
+ execute: async (request, signal) => {
405
+ const context = this.runContexts.get(request);
406
+ if (!context) throw new Error("scheduled run context is unavailable");
407
+ this.notify(request, context, "running");
408
+ const result = await this.runScheduled(request, context.ctx, signal);
409
+ return {
410
+ dispatch: result,
411
+ ...(result.failureKind === undefined
412
+ ? {}
413
+ : { failureKind: result.failureKind }),
414
+ };
415
+ },
416
+ });
105
417
  }
106
418
 
107
419
  async execute(
108
420
  action: string,
109
421
  params: { request?: unknown; resultId?: string },
110
422
  ctx?: RunContext,
423
+ signal?: AbortSignal,
424
+ observer?: RuntimeActivityObserver,
111
425
  ): Promise<DispatchResult> {
112
426
  if (!(ACTIONS as readonly string[]).includes(action)) {
113
427
  return { ok: false, error: `unknown action: ${String(action)}` };
@@ -115,32 +429,58 @@ export class Runtime {
115
429
  if (!this.activation.isActive()) {
116
430
  return { ok: false, notReady: true, error: "dispatcher is not active" };
117
431
  }
118
- if (action === "run") return this.run(params.request, ctx);
432
+ if (action === "run")
433
+ return this.run(params.request, ctx, signal, observer);
119
434
  if (action === "apply") {
120
435
  if (!ctx || !params.resultId)
121
436
  return { ok: false, error: "apply requires context and resultId" };
122
- return this.enqueueApply(ctx.cwd, params.resultId);
437
+ return this.enqueueApply(ctx.cwd, params.resultId, signal);
123
438
  }
124
439
  if (action === "discard") {
125
440
  if (!params.resultId)
126
441
  return { ok: false, error: "discard requires resultId" };
127
- return this.results.discard(params.resultId)
442
+ return this.discardRetainedCandidate(params.resultId)
128
443
  ? { ok: true, action }
129
444
  : { ok: false, error: "retained result not found" };
130
445
  }
131
- if (action === "cancel") return { ok: true, action };
132
- drainStage({
133
- results: this.results,
134
- registry: this.registry,
135
- activation: this.activation,
136
- });
446
+ if (action === "cancel") {
447
+ await this.scheduler.cancelAll();
448
+ return { ok: true, action };
449
+ }
450
+ await this.drain();
137
451
  return { ok: true, action: "finish" };
138
452
  }
139
453
 
140
- 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);
141
481
  const run = this.applyTail.then(
142
- () => applyRetainedPatch({ root, id, store: this.results }),
143
- () => applyRetainedPatch({ root, id, store: this.results }),
482
+ () => applyRetainedPatch({ root, id, store: this.results, signal }),
483
+ () => applyRetainedPatch({ root, id, store: this.results, signal }),
144
484
  );
145
485
  this.applyTail = run.then(
146
486
  () => undefined,
@@ -148,11 +488,143 @@ export class Runtime {
148
488
  );
149
489
  const seq = ++this.applySeq;
150
490
  return run.then(
151
- (result) =>
152
- result.ok
153
- ? { ok: true, action: "apply", result: { ...result, sequence: seq } }
154
- : result,
155
- (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",
156
628
  );
157
629
  }
158
630
 
@@ -160,21 +632,59 @@ export class Runtime {
160
632
  agent: { role: string; content: string },
161
633
  envelope: RequestEnvelope,
162
634
  ctx: RunContext,
163
- ): Promise<DispatchResult> {
635
+ signal: AbortSignal,
636
+ artifactRejection?: string,
637
+ ): Promise<InternalDispatchResult> {
638
+ if (signal.aborted)
639
+ return {
640
+ ...cancellationError(signal),
641
+ failureKind: "cancelled",
642
+ };
164
643
  let phase: Awaited<ReturnType<typeof runtimeFromContext>>;
165
644
  try {
166
- phase = await runtimeFromContext(ctx);
645
+ phase = await runtimeFromContext(ctx, this.parentPayloadBridge, signal);
167
646
  } catch (error) {
168
- return { ok: false, error: (error as Error).message };
647
+ const bridgeUnavailable =
648
+ error instanceof Error &&
649
+ error.message === "parent payload bridge is unavailable";
650
+ return {
651
+ 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",
661
+ };
169
662
  }
663
+ if (signal.aborted)
664
+ return {
665
+ ...cancellationError(signal),
666
+ failureKind: "cancelled",
667
+ };
170
668
  const roots = envelope.roots.map(
171
669
  (root) => new URL(root, `file://${ctx.cwd}/`).pathname,
172
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
+ };
173
679
  const systemPrompt = [
174
680
  agent.content,
175
681
  envelope.objective,
176
682
  envelope.context.agents,
177
683
  envelope.context.contract,
684
+ `<phase-contract>${JSON.stringify(phaseContract)}</phase-contract>`,
685
+ ...(artifactRejection
686
+ ? [`<artifact-rejection>${artifactRejection}</artifact-rejection>`]
687
+ : []),
178
688
  ].join("\n\n");
179
689
  const child = await runChildSession({
180
690
  cwd: ctx.cwd,
@@ -182,13 +692,39 @@ export class Runtime {
182
692
  model: phase.model,
183
693
  systemPrompt,
184
694
  requestId: envelope.id,
695
+ taskId: envelope.taskId,
185
696
  role: envelope.role,
186
697
  phase: envelope.phase,
187
698
  output: envelope.output as "evidence" | "diff",
188
699
  roots,
700
+ allowedPaths: [
701
+ ...new Set([...envelope.declared.read, ...envelope.declared.write]),
702
+ ],
189
703
  timeoutMs: LIMITS.phaseTimeoutMs,
704
+ signal,
190
705
  });
191
- if (!child.ok) return child;
706
+ if (signal.aborted)
707
+ return {
708
+ ...cancellationError(signal),
709
+ failureKind: "cancelled",
710
+ };
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
+ }
192
728
  if (envelope.output === "evidence") {
193
729
  return {
194
730
  ok: true,
@@ -198,12 +734,46 @@ export class Runtime {
198
734
  };
199
735
  }
200
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
+ }
201
761
  const resultId = this.results.retain({
202
762
  diff: diff.diff,
203
763
  writeSet: envelope.declared.write,
204
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
+ : {}),
205
775
  });
206
- if (isSafeBound(envelope.snapshot)) {
776
+ if (!prepared && isSafeBound(envelope.snapshot)) {
207
777
  const retained = this.results.get(resultId);
208
778
  if (retained) {
209
779
  retained.snapshot = mergeBounds(retained.snapshot, envelope.snapshot);
@@ -221,68 +791,441 @@ export class Runtime {
221
791
  private async run(
222
792
  request: unknown,
223
793
  ctx?: RunContext,
794
+ signal?: AbortSignal,
795
+ observer?: RuntimeActivityObserver,
224
796
  ): Promise<DispatchResult> {
225
797
  if (!ctx) return { ok: false, error: "run requires extension context" };
798
+ if (signal?.aborted) return cancellationError(signal);
226
799
  const validation = validateRequestEnvelope(request);
227
800
  if (!validation.ok) return { ok: false, error: validation.reason };
228
- const envelope = validation.value;
801
+ const envelope: RequestEnvelope = {
802
+ ...validation.value,
803
+ taskId: validation.value.taskId ?? validation.value.id,
804
+ roots: [...validation.value.roots],
805
+ context: { ...validation.value.context },
806
+ declared: {
807
+ ...validation.value.declared,
808
+ read: [...validation.value.declared.read],
809
+ write: [...validation.value.declared.write],
810
+ conflicts: [...validation.value.declared.conflicts],
811
+ resources: [...validation.value.declared.resources],
812
+ },
813
+ ...(validation.value.verification === undefined
814
+ ? {}
815
+ : {
816
+ verification: {
817
+ ...validation.value.verification,
818
+ argv: [...validation.value.verification.argv],
819
+ },
820
+ }),
821
+ };
822
+ const context: StoredRunContext = {
823
+ ctx,
824
+ observer,
825
+ sequence: ++this.batchSeq,
826
+ runningEmitted: false,
827
+ terminalEmitted: false,
828
+ };
829
+ this.runContexts.set(envelope, context);
830
+ this.notify(envelope, context, "queued");
831
+ const batchId = `runtime-${context.sequence}`;
832
+ const batch = this.scheduler.schedule(batchId, [
833
+ { request: envelope, prerequisites: [] },
834
+ ]);
835
+ const cancelBatch = () =>
836
+ this.scheduler.cancel(
837
+ batchId,
838
+ signal?.reason ?? new Error("tool call cancelled"),
839
+ );
840
+ if (signal?.aborted) cancelBatch();
841
+ else signal?.addEventListener("abort", cancelBatch, { once: true });
842
+ try {
843
+ const outcome = await batch.result(envelope.id);
844
+ if (outcome.status === "succeeded" && outcome.value) {
845
+ const internalResult = outcome.value.dispatch as DispatchResult & {
846
+ failureKind?: RunFailureKind;
847
+ failureClass?: FailureClass;
848
+ };
849
+ const {
850
+ failureKind,
851
+ failureClass: _failureClass,
852
+ ...publicResult
853
+ } = internalResult;
854
+ if (publicResult.ok) {
855
+ this.notify(envelope, context, "completed");
856
+ return publicResult;
857
+ }
858
+ const state: RuntimeActivityState = failureKind ?? "failed";
859
+ this.notify(envelope, context, state);
860
+ return publicResult;
861
+ }
862
+ const state: RuntimeActivityState =
863
+ outcome.status === "cancelled" ? "cancelled" : "failed";
864
+ const terminal = {
865
+ ok: false as const,
866
+ error: outcome.error ?? `scheduled run ${outcome.status}`,
867
+ };
868
+ this.notify(envelope, context, state);
869
+ return terminal;
870
+ } finally {
871
+ signal?.removeEventListener("abort", cancelBatch);
872
+ this.runContexts.delete(envelope);
873
+ }
874
+ }
875
+
876
+ private notify(
877
+ envelope: RequestEnvelope,
878
+ context: StoredRunContext,
879
+ state: RuntimeActivityState,
880
+ ): void {
881
+ if (!context.observer || context.terminalEmitted) return;
882
+ if (state === "running") {
883
+ if (context.runningEmitted) return;
884
+ context.runningEmitted = true;
885
+ }
886
+ if (
887
+ state === "completed" ||
888
+ state === "failed" ||
889
+ state === "cancelled" ||
890
+ state === "timed-out"
891
+ ) {
892
+ context.terminalEmitted = true;
893
+ }
894
+ const event: RuntimeActivityEvent = {
895
+ state,
896
+ requestId: envelope.id,
897
+ role: envelope.role,
898
+ phase: envelope.phase,
899
+ objective: envelope.objective,
900
+ sequence: context.sequence,
901
+ ...(state === "cancelled"
902
+ ? { failureReason: "subagent cancelled" as const }
903
+ : state === "timed-out"
904
+ ? { failureReason: "phase timed out" as const }
905
+ : state === "failed"
906
+ ? { failureReason: "subagent failed" as const }
907
+ : {}),
908
+ };
909
+ try {
910
+ const result = context.observer(event);
911
+ if (result instanceof Promise) void result.catch(() => undefined);
912
+ } catch {
913
+ // Presentation observers cannot affect orchestration.
914
+ }
915
+ }
916
+
917
+ private async runScheduled(
918
+ envelope: RequestEnvelope,
919
+ ctx: RunContext,
920
+ signal: AbortSignal,
921
+ ): Promise<InternalDispatchResult> {
922
+ if (signal.aborted)
923
+ return {
924
+ ...cancellationError(signal),
925
+ failureKind: "cancelled",
926
+ };
927
+ const taskId = envelope.taskId ?? envelope.id;
229
928
  const agent = loadAgentDefinitions().find(
230
929
  (item) => item.role === envelope.role,
231
930
  );
232
- if (!agent) return { ok: false, error: "package Agent role not found" };
931
+ if (!agent)
932
+ return {
933
+ ...recoveryFailure(
934
+ envelope,
935
+ "environment-blocked",
936
+ "repair-environment",
937
+ 0,
938
+ ),
939
+ };
233
940
  if (!ctx.model)
234
- return { ok: false, error: "run requires a model identity" };
941
+ return recoveryFailure(
942
+ envelope,
943
+ "environment-blocked",
944
+ "repair-environment",
945
+ 0,
946
+ "failed",
947
+ "phase runtime is unavailable",
948
+ );
235
949
  const identity = workerIdentity(ctx.model);
236
950
  const contract = contractOf(envelope);
237
- const existing = this.registry.get(envelope.id);
951
+ const existing = this.registry.get(taskId);
238
952
  if (existing) {
239
953
  if (existing.identity !== identity) {
240
- return {
241
- ok: false,
242
- error:
243
- "blocked: logical Worker pinned provider/model identity differs",
244
- };
954
+ return recoveryFailure(
955
+ envelope,
956
+ "environment-blocked",
957
+ "repair-environment",
958
+ existing.currentPhase.correctionIndex,
959
+ "failed",
960
+ "pinned provider/model identity differs",
961
+ );
245
962
  }
246
- if (!sameContract(existing.contract, contract)) {
247
- return {
248
- ok: false,
249
- error:
250
- "blocked: logical Worker contract changed for a recovery scope",
251
- };
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
+ );
252
985
  }
253
- if (existing.redispatchUsed) {
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
+ );
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") {
254
1064
  return {
255
1065
  ok: false,
256
- error: "blocked: mechanical redispatch is already exhausted",
1066
+ error: "current phase candidate is not applied",
1067
+ failureKind: "failed",
257
1068
  };
258
1069
  }
259
- } else {
260
- 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
+ };
261
1104
  }
262
- const pinned = this.registry.get(envelope.id);
263
- const first = await this.dispatchChild(agent, envelope, ctx);
1105
+
1106
+ const first = await this.dispatchChild(
1107
+ agent,
1108
+ envelope,
1109
+ ctx,
1110
+ signal,
1111
+ artifactRejection,
1112
+ );
1113
+ if (signal.aborted)
1114
+ return {
1115
+ ...cancellationError(signal),
1116
+ failureKind: "cancelled",
1117
+ };
264
1118
  if (first.ok) {
265
- if (pinned) pinned.redispatchUsed = false;
1119
+ this.rememberRetainedResult(first, envelope, pinned);
266
1120
  return first;
267
1121
  }
268
- if (pinned?.redispatchUsed) {
269
- return {
270
- ok: false,
271
- error: "blocked: mechanical redispatch failed and is exhausted",
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",
272
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
+ );
273
1151
  }
274
- if (!pinned) {
275
- return { ok: false, error: "blocked: logical Worker is not pinned" };
1152
+
1153
+ if (first.failureClass === "artifact") {
1154
+ pinned.state = {
1155
+ kind: "artifact-correction-pending",
1156
+ rejection: normalizedArtifactRejection(first.error),
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
+ );
276
1166
  }
277
- pinned.redispatchUsed = true;
278
- const second = await this.dispatchChild(agent, envelope, ctx);
279
- if (!second.ok) {
1167
+
1168
+ pinned = this.registry.setCurrentPhase(taskId, contract, 1) ?? pinned;
1169
+ const second = await this.dispatchChild(agent, envelope, ctx, signal);
1170
+ if (signal.aborted)
280
1171
  return {
281
- ok: false,
282
- error: "blocked: identical mechanical redispatch failed again",
1172
+ ...cancellationError(signal),
1173
+ failureKind: "cancelled",
283
1174
  };
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
+ );
1189
+ }
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;
284
1212
  }
285
- return second;
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" };
286
1229
  }
287
1230
 
288
1231
  validateRequest(
@@ -291,15 +1234,25 @@ export class Runtime {
291
1234
  return validateRequestEnvelope(envelope);
292
1235
  }
293
1236
 
294
- drain(): void {
1237
+ async drain(): Promise<void> {
1238
+ this.parentPayloadBridge?.clear();
1239
+ const settled = this.scheduler.cancelAll();
1240
+ this.retainedRuns.clear();
295
1241
  drainStage({
296
1242
  results: this.results,
297
1243
  registry: this.registry,
298
1244
  activation: this.activation,
299
1245
  });
1246
+ await settled;
300
1247
  }
301
1248
 
302
1249
  get state(): ActivationState {
303
1250
  return this.activation.state;
304
1251
  }
305
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
+ }