ai-foreman 1.7.13 → 1.7.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +122 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +30 -8
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +6 -0
  8. package/dist/adapters/types.d.ts +22 -0
  9. package/dist/agentRun.js +21 -3
  10. package/dist/branch/currentGuard.d.ts +2 -0
  11. package/dist/branch/currentGuard.js +9 -0
  12. package/dist/branch/finalization.d.ts +22 -0
  13. package/dist/branch/finalization.js +120 -0
  14. package/dist/branch/git.js +7 -2
  15. package/dist/branch/runner.d.ts +3 -2
  16. package/dist/branch/runner.js +277 -75
  17. package/dist/buildRuns.d.ts +1 -0
  18. package/dist/buildRuns.js +33 -3
  19. package/dist/cli/start.js +314 -86
  20. package/dist/cli/state.d.ts +4 -0
  21. package/dist/cli/state.js +69 -0
  22. package/dist/continuity.d.ts +4 -0
  23. package/dist/continuity.js +26 -3
  24. package/dist/foreman.d.ts +22 -4
  25. package/dist/foreman.js +105 -19
  26. package/dist/handoffs.d.ts +5 -2
  27. package/dist/handoffs.js +93 -12
  28. package/dist/index.js +2 -0
  29. package/dist/qaFailureDelivery.d.ts +100 -0
  30. package/dist/qaFailureDelivery.js +570 -0
  31. package/dist/qaProtocolV2.d.ts +233 -0
  32. package/dist/qaProtocolV2.js +114 -0
  33. package/dist/qaRecovery.d.ts +15 -3
  34. package/dist/qaRecovery.js +477 -83
  35. package/dist/qaReview.d.ts +42 -17
  36. package/dist/qaReview.js +1216 -457
  37. package/dist/qaRuntime.d.ts +14 -0
  38. package/dist/qaRuntime.js +44 -0
  39. package/dist/qaSnapshot.d.ts +34 -0
  40. package/dist/qaSnapshot.js +302 -34
  41. package/dist/stateTransfer.d.ts +57 -0
  42. package/dist/stateTransfer.js +644 -0
  43. package/dist/statusReporter.d.ts +2 -0
  44. package/dist/statusReporter.js +6 -0
  45. package/dist/workflowDb.d.ts +245 -2
  46. package/dist/workflowDb.js +647 -6
  47. package/package.json +15 -3
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ export declare function buildStateCommand(opts?: {
3
+ resolveProject?: (project?: string) => string;
4
+ }): Command;
@@ -0,0 +1,69 @@
1
+ import { Command } from "commander";
2
+ import { resolve } from "node:path";
3
+ import { exportStateBundle, hasTransferableState, importStateBundle, inspectStateBundle } from "../stateTransfer.js";
4
+ export function buildStateCommand(opts = {}) {
5
+ const command = new Command("state")
6
+ .description("Export, inspect, and import portable Rafi local state bundles.");
7
+ command
8
+ .command("export")
9
+ .description("Write a portable Rafi state bundle for sequential machine handoff.")
10
+ .argument("[project]", "project directory", ".")
11
+ .requiredOption("-o, --output <file>", "output bundle file")
12
+ .action(async (project, options) => {
13
+ const root = opts.resolveProject ? opts.resolveProject(project) : resolve(project);
14
+ const manifest = await exportStateBundle(root, options.output);
15
+ console.log(`rafi state export: wrote ${options.output}`);
16
+ console.log(` bundle: ${manifest.bundleId}`);
17
+ console.log(` files: ${manifest.files.length}`);
18
+ if (manifest.source.git.branch || manifest.source.git.head) {
19
+ console.log(` git: ${manifest.source.git.branch ?? "(detached)"} ${manifest.source.git.head ?? ""}`.trimEnd());
20
+ }
21
+ });
22
+ command
23
+ .command("inspect")
24
+ .description("Inspect and verify a Rafi state bundle.")
25
+ .argument("<bundle>", "state bundle file")
26
+ .option("--json", "print the manifest as JSON")
27
+ .action((bundle, options) => {
28
+ const result = inspectStateBundle(bundle);
29
+ if (options.json) {
30
+ console.log(JSON.stringify(result.manifest, null, 2));
31
+ return;
32
+ }
33
+ console.log(`rafi state inspect: ${result.valid ? "valid" : "invalid"}`);
34
+ console.log(` bundle: ${result.manifest.bundleId}`);
35
+ console.log(` created: ${result.manifest.createdAt}`);
36
+ console.log(` source: ${result.manifest.source.root}`);
37
+ console.log(` files: ${result.manifest.files.length}`);
38
+ console.log(" provider sessions: not portable; use `rafi build:resume --fresh-with-handoff` when exact session recovery is unavailable");
39
+ });
40
+ command
41
+ .command("import")
42
+ .description("Restore a portable Rafi state bundle into an existing matching source checkout.")
43
+ .argument("<projectOrBundle>", "project directory or bundle file")
44
+ .argument("[bundle]", "state bundle file")
45
+ .option("-y, --yes", "confirm replacement of local Rafi state")
46
+ .action(async (projectOrBundle, bundle, options) => {
47
+ const project = bundle ? projectOrBundle : ".";
48
+ const bundleFile = bundle ?? projectOrBundle;
49
+ const root = opts.resolveProject ? opts.resolveProject(project) : resolve(project);
50
+ let confirmed = Boolean(options.yes);
51
+ if (!confirmed && hasTransferableState(root)) {
52
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
53
+ throw new Error("refusing non-interactive import that replaces existing local Rafi state without --yes");
54
+ const { confirm, isCancel } = await import("@clack/prompts");
55
+ const answer = await confirm({ message: `Replace existing local Rafi state in ${root}?`, initialValue: false });
56
+ if (isCancel(answer) || !answer) {
57
+ console.log("rafi state import: cancelled; no state changed");
58
+ return;
59
+ }
60
+ confirmed = true;
61
+ }
62
+ const result = await importStateBundle(root, bundleFile, { yes: confirmed });
63
+ console.log(`rafi state import: restored bundle ${result.manifest.bundleId}`);
64
+ if (result.backupDir)
65
+ console.log(` previous state backup: ${result.backupDir}`);
66
+ console.log(" provider sessions are historical only; resume with `rafi build:resume --fresh-with-handoff` if exact session recovery fails");
67
+ });
68
+ return command;
69
+ }
@@ -54,6 +54,8 @@ export interface ContinuityAdapterOptions {
54
54
  replaceRecoveryLeaseAfterCheckpoint?: boolean;
55
55
  /** Host-owned validated Builder handoff request processing at a safe turn boundary. */
56
56
  handleHandoffRequest?: (text: string, predecessor: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter | undefined>;
57
+ /** QA journals each provider turn separately; wrappers must not repair or redispatch inside one host turn. */
58
+ durableSingleTurn?: boolean;
57
59
  }
58
60
  /** Enforces a durable role checkpoint after every completed provider turn. */
59
61
  export declare class ContinuityAdapter implements BuilderAdapter {
@@ -67,10 +69,12 @@ export declare class ContinuityAdapter implements BuilderAdapter {
67
69
  sendTurn(instruction: string): Promise<TurnResult>;
68
70
  sessionId(): string | undefined;
69
71
  sessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
72
+ prepareSession(): Promise<import("rafi-spec").ProviderSessionRefV1>;
70
73
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
71
74
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
72
75
  compact(): Promise<CompactResult>;
73
76
  prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
77
+ requiresAutoCompactionSetupTurn(): boolean;
74
78
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
75
79
  drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
76
80
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
@@ -158,6 +158,10 @@ export class ContinuityAdapter {
158
158
  if (parsed.delta) {
159
159
  this.publish(parsed.delta, "turn_completed", original);
160
160
  this.moveRecoveryLeaseAfterCheckpoint();
161
+ if (this.options.durableSingleTurn) {
162
+ return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
163
+ rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
164
+ }
161
165
  const successor = await this.options.handleHandoffRequest?.(original.text, this.adapter, instruction);
162
166
  if (successor && successor !== this.adapter) {
163
167
  await this.adoptValidatedSuccessor(successor);
@@ -166,7 +170,19 @@ export class ContinuityAdapter {
166
170
  instruction,
167
171
  ].join("\n\n"));
168
172
  }
169
- return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction,
173
+ return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
174
+ rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
175
+ }
176
+ if (this.options.durableSingleTurn) {
177
+ const invalidDb = new WorkflowDb(this.options.projectDir);
178
+ try {
179
+ invalidDb.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "continuity_invalid", payload: { problems: parsed.error?.problems }, authoritativeStateRevision: this.revision() });
180
+ }
181
+ finally {
182
+ invalidDb.close();
183
+ }
184
+ return { ...original, isError: true, text: `QA continuity record was invalid: ${parsed.error?.problems.join("; ") ?? "unknown error"}`,
185
+ hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
170
186
  rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
171
187
  }
172
188
  const repair = await this.adapter.sendTurn([
@@ -179,7 +195,7 @@ export class ContinuityAdapter {
179
195
  if (repaired.delta) {
180
196
  this.publish(repaired.delta, "turn_completed_after_repair", original);
181
197
  this.moveRecoveryLeaseAfterCheckpoint();
182
- return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction,
198
+ return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
183
199
  rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
184
200
  }
185
201
  const invalidDb = new WorkflowDb(this.options.projectDir);
@@ -216,7 +232,8 @@ export class ContinuityAdapter {
216
232
  const successor = await this.options.createSuccessor(handoff);
217
233
  const accepted = await successor.sendTurn(`${handoff}\n\nReply with HANDOFF_ACCEPTED on the first line, then ${continuityInstruction()}`);
218
234
  const successorDelta = parseContinuityDelta(accepted.text);
219
- if (!/^HANDOFF_ACCEPTED\b/m.test(accepted.text) || !successorDelta.delta || !successor.sessionId()) {
235
+ const firstAcceptanceLine = accepted.text.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
236
+ if (firstAcceptanceLine !== "HANDOFF_ACCEPTED" || !successorDelta.delta || !successor.sessionId()) {
220
237
  await successor.close().catch(() => { });
221
238
  throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh successor did not validate and accept the cumulative checkpoint");
222
239
  }
@@ -234,10 +251,16 @@ export class ContinuityAdapter {
234
251
  }
235
252
  sessionId() { return this.adapter.sessionId(); }
236
253
  sessionRef() { return this.adapter.sessionRef?.(); }
254
+ prepareSession() {
255
+ if (!this.adapter.prepareSession)
256
+ return Promise.reject(new Error("wrapped adapter cannot establish a provider session without a work turn"));
257
+ return this.adapter.prepareSession();
258
+ }
237
259
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
238
260
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
239
261
  compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
240
262
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
263
+ requiresAutoCompactionSetupTurn() { return this.adapter.requiresAutoCompactionSetupTurn?.() ?? false; }
241
264
  autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
242
265
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
243
266
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
package/dist/foreman.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { BuilderAdapter, PermissionHandler, PermissionRequest, TurnResult }
2
2
  import type { PermissionPolicy } from "./permissions/policy.js";
3
3
  import { type AnsweredProviderQuestion } from "./providerQuestions.js";
4
4
  import type { Log } from "./log.js";
5
- import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaReportRecoveryHandler, type QaSessionBoundaryRecovery, type QaSessionHandle } from "./qaReview.js";
5
+ import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaReportRecoveryHandler, type QaSessionBoundaryRecovery, type QaSessionBoundaryResult, type QaSessionHandle } from "./qaReview.js";
6
6
  import type { SessionStrategy } from "rafi-spec";
7
7
  import type { RunObserver } from "./observability.js";
8
8
  import type { QaRecoveryPacket } from "./qaRecovery.js";
@@ -77,17 +77,20 @@ export declare class Foreman {
77
77
  private readonly qaContinuityManaged;
78
78
  private readonly qaReportRecovery?;
79
79
  private qaResumedRecovery?;
80
+ /** Exact durable build-run scope. Observability is never an identity authority. */
81
+ private readonly qaRunId;
80
82
  private readonly ticketsEnabled;
81
83
  private readonly notificationsEnabled;
82
84
  private readonly terminalBellEnabled;
83
85
  private readonly qaStream;
84
86
  private builderWorkSessions;
85
- private readonly fallbackQaRunId;
86
87
  constructor(builder: BuilderAdapter, log: Log, notifications?: boolean | ForemanNotificationOptions, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined,
87
88
  /** @deprecated QA must be created by qaFactory in a disposable snapshot. */
88
- _deprecatedSameSessionReviewer?: BuilderAdapter, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter | QaSessionHandle>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string, recovery?: QaSessionBoundaryRecovery) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
89
+ _deprecatedSameSessionReviewer?: BuilderAdapter, qaFactory?: ((cwd: string, sessionId?: string) => Promise<QaSessionHandle>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((handle: QaSessionHandle, frozenAction: string, strategy: SessionStrategy, cwd: string, recovery?: QaSessionBoundaryRecovery) => Promise<QaSessionBoundaryResult>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
89
90
  /** Persist a Builder's provider-native event immediately after every turn. */
90
- observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined, observer?: RunObserver | undefined, qaRuntimeContext?: unknown | undefined, qaContinuityManaged?: boolean, qaReportRecovery?: QaReportRecoveryHandler | undefined, qaResumedRecovery?: QaRecoveryPacket | undefined);
91
+ observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined, observer?: RunObserver | undefined, qaRuntimeContext?: unknown | undefined, qaContinuityManaged?: boolean, qaReportRecovery?: QaReportRecoveryHandler | undefined, qaResumedRecovery?: QaRecoveryPacket | undefined,
92
+ /** Exact durable build-run scope. Observability is never an identity authority. */
93
+ qaRunId?: string);
91
94
  private waitForUserInput;
92
95
  /**
93
96
  * Send one instruction and resolve any needs_input exchanges before returning.
@@ -122,6 +125,21 @@ export declare class Foreman {
122
125
  detail?: string;
123
126
  summary?: string;
124
127
  }>;
128
+ /** Resume an already-built ticket at the QA boundary without dispatching Builder preflight/work. */
129
+ runPendingQaRecovery(ticketId: string, builderResult: string): Promise<{
130
+ outcome: "passed" | "blocked" | "needs-human" | "waived";
131
+ detail?: string;
132
+ summary?: string;
133
+ passCertificateId?: string;
134
+ sourceStateDigest?: string;
135
+ }>;
136
+ /** Complete only the pending QA boundary and its tracker transition. No Builder work turn is sent. */
137
+ completePendingQaRecovery(ticketId: string): Promise<{
138
+ outcome: "passed" | "blocked" | "needs-human" | "waived";
139
+ detail?: string;
140
+ }>;
141
+ /** Reconcile a crash between certificate consumption, tracker completion, and the final receipt. */
142
+ completePendingQaFinalization(ticketId: string): Promise<void>;
125
143
  qaSessionId(): string | undefined;
126
144
  qaSessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
127
145
  builderSessionId(): string | undefined;
package/dist/foreman.js CHANGED
@@ -3,15 +3,18 @@ import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
3
  import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
4
4
  import { signalAttention } from "./notify.js";
5
5
  import { pauseActivityForInput } from "./activity.js";
6
- import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
6
+ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
7
+ import { StateDb } from "./tickets/stateDb.js";
7
8
  import { cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdImplementationQueue } from "./tickets/commands.js";
8
9
  import { loadTickets } from "./tickets/ticketLoader.js";
9
- import { buildDurableQaFixHandoff, runIsolatedQa } from "./qaReview.js";
10
- import { changeManifestAsync, deterministicChangeSummaryAsync } from "./qaSnapshot.js";
10
+ import { beginQaFinalization, completeQaFinalization, verifyPendingQaFinalizationSource, runIsolatedQa } from "./qaReview.js";
11
+ import { QaFailureDeliveryService } from "./qaFailureDelivery.js";
12
+ import { generatedTrackerDirtyPaths } from "./branch/git.js";
11
13
  import { createHash, randomUUID } from "node:crypto";
12
14
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
15
  import { join, relative } from "node:path";
14
16
  import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
17
+ import { WorkflowDb } from "./workflowDb.js";
15
18
  /** Phrases that suggest the builder ended its turn by asking the human. */
16
19
  const QUESTION_HINTS = [
17
20
  "should i",
@@ -237,17 +240,19 @@ export class Foreman {
237
240
  qaContinuityManaged;
238
241
  qaReportRecovery;
239
242
  qaResumedRecovery;
243
+ qaRunId;
240
244
  ticketsEnabled;
241
245
  notificationsEnabled;
242
246
  terminalBellEnabled;
243
247
  qaStream = { reviews: 0, modificationViolations: 0 };
244
248
  builderWorkSessions = 0;
245
- fallbackQaRunId = `qa-${randomUUID()}`;
246
249
  constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir,
247
250
  /** @deprecated QA must be created by qaFactory in a disposable snapshot. */
248
251
  _deprecatedSameSessionReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
249
252
  /** Persist a Builder's provider-native event immediately after every turn. */
250
- observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery) {
253
+ observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery,
254
+ /** Exact durable build-run scope. Observability is never an identity authority. */
255
+ qaRunId = `qa-${randomUUID()}`) {
251
256
  this.builder = builder;
252
257
  this.log = log;
253
258
  this.qaEnabled = qaEnabled;
@@ -268,6 +273,7 @@ export class Foreman {
268
273
  this.qaContinuityManaged = qaContinuityManaged;
269
274
  this.qaReportRecovery = qaReportRecovery;
270
275
  this.qaResumedRecovery = qaResumedRecovery;
276
+ this.qaRunId = qaRunId;
271
277
  void _deprecatedSameSessionReviewer;
272
278
  this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
273
279
  this.terminalBellEnabled = typeof notifications === "boolean" ? true : notifications.terminalBell;
@@ -481,7 +487,6 @@ export class Foreman {
481
487
  async runQa(stepIndex, ticketId, builderResult) {
482
488
  if (this.projectDir && this.qaFactory) {
483
489
  const resumedRecovery = this.qaResumedRecovery;
484
- this.qaResumedRecovery = undefined;
485
490
  const review = await runIsolatedQa({
486
491
  ticket: this.ticketForQa(stepIndex, ticketId),
487
492
  builderWorktree: this.projectDir,
@@ -489,34 +494,49 @@ export class Foreman {
489
494
  qaStrategy: this.qaSessionStrategy,
490
495
  state: this.qaStream,
491
496
  createQa: this.qaFactory,
492
- sessionBoundary: this.qaSessionBoundary,
497
+ sessionBoundary: this.qaSessionBoundary ?? (async () => { throw new Error("QA recovery requires a validated durable session boundary"); }),
493
498
  observeNativeCompactions: this.observeQaNativeCompactions,
494
499
  maxCycles: this.qaMaxCycles,
495
- recovery: { projectDir: this.projectDir, runId: this.observer?.runId ?? this.fallbackQaRunId },
500
+ recovery: { projectDir: this.projectDir, runId: this.qaRunId },
496
501
  observer: this.observer,
497
502
  qaRuntimeContext: this.qaRuntimeContext,
498
503
  continuityManaged: this.qaContinuityManaged,
499
504
  onReportRecovery: this.qaReportRecovery,
500
505
  resumedRecovery,
501
- fix: async (request) => {
502
- const manifest = await changeManifestAsync(this.projectDir);
503
- const changeSummary = await deterministicChangeSummaryAsync(this.projectDir);
504
- const instruction = buildDurableQaFixHandoff(this.ticketForQa(stepIndex, ticketId), request, this.projectDir, request.latestBuilderResult, manifest.diffDigest, changeSummary);
505
- await this.prepareBuilderBoundary(instruction);
506
+ deliverFailure: async (request) => {
506
507
  this.builderWorkSessions += 1;
507
- const fix = await this.doTurn(instruction);
508
- this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
509
- return fix.result.isError || fix.status.kind !== "done"
510
- ? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
511
- : { ok: true, response: fix.result.text, summary: fix.status.summary ?? fix.result.text };
508
+ const delivery = new QaFailureDeliveryService();
509
+ const result = await delivery.deliver(request, {
510
+ adapter: () => this.builder,
511
+ setAdapter: (adapter) => { this.builder = adapter; },
512
+ sessionStrategy: this.builderSessionStrategy,
513
+ prepareBoundary: async (_adapter, instruction) => { await this.prepareBuilderBoundary(instruction); return this.builder; },
514
+ beforeTurn: async (adapter, instruction) => this.beforeBuilderTurn ? this.beforeBuilderTurn(adapter, instruction) : adapter,
515
+ recordSession: (session) => {
516
+ const sessionId = typeof session === "string" ? session : session.sessionId;
517
+ this.log.write("qa-fix", { stepIndex, sessionId });
518
+ },
519
+ });
520
+ this.log.write("qa-fix", { stepIndex, ok: result.ok, detail: result.detail, providerTurnId: result.providerTurnId, handoffId: result.handoffId, operationId: result.operationId });
521
+ return result;
512
522
  },
513
523
  evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
514
524
  onNonconvergence: this.qaNonconvergence,
515
525
  resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason, "qa"),
516
526
  });
527
+ if (resumedRecovery) {
528
+ const recoveryDb = new WorkflowDb(this.projectDir);
529
+ try {
530
+ if (recoveryDb.qaRecoveryHead(resumedRecovery.manifest.runId, resumedRecovery.manifest.ticketId)?.pendingAction === "resolved")
531
+ this.qaResumedRecovery = undefined;
532
+ }
533
+ finally {
534
+ recoveryDb.close();
535
+ }
536
+ }
517
537
  if (review.outcome === "nonconverged")
518
538
  return { outcome: "needs-human", detail: review.detail };
519
- return { outcome: review.outcome, detail: review.detail, summary: review.summary };
539
+ return { outcome: review.outcome, detail: review.detail, summary: review.summary, passCertificateId: review.passCertificateId, sourceStateDigest: review.sourceStateDigest };
520
540
  }
521
541
  return {
522
542
  outcome: "needs-human",
@@ -526,6 +546,61 @@ export class Foreman {
526
546
  async runQaReview(stepIndex, builderResult) {
527
547
  return this.runQa(stepIndex, undefined, builderResult);
528
548
  }
549
+ /** Resume an already-built ticket at the QA boundary without dispatching Builder preflight/work. */
550
+ async runPendingQaRecovery(ticketId, builderResult) {
551
+ if (this.qaResumedRecovery && this.qaResumedRecovery.manifest.ticketId !== ticketId)
552
+ throw new Error(`pending QA recovery belongs to ${this.qaResumedRecovery.manifest.ticketId}, not ${ticketId}`);
553
+ return this.runQa(1, ticketId, builderResult);
554
+ }
555
+ /** Complete only the pending QA boundary and its tracker transition. No Builder work turn is sent. */
556
+ async completePendingQaRecovery(ticketId) {
557
+ if (!this.projectDir || !this.ticketsEnabled)
558
+ throw new Error("pending QA recovery requires an initialized ticket project");
559
+ const qa = await this.runPendingQaRecovery(ticketId, "Resuming the exact durable QA failure/review boundary; no new Builder work was dispatched.");
560
+ if (qa.outcome !== "passed" && qa.outcome !== "waived")
561
+ return qa;
562
+ if (qa.outcome === "passed") {
563
+ if (!qa.passCertificateId || !qa.sourceStateDigest)
564
+ throw new Error("QA passed without a durable pass certificate");
565
+ await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qa.passCertificateId, qa.sourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
566
+ }
567
+ cmdComplete(this.projectDir, ticketId, {
568
+ actor: "foreman",
569
+ summary: qa.summary ?? "Completed after exact QA recovery",
570
+ validationResult: qa.outcome === "waived" ? "failed" : "passed",
571
+ validationNotes: qa.outcome === "waived" ? "User explicitly waived unresolved QA failures" : "Durable QA recovery emitted qa_pass",
572
+ evidence: qa.summary ?? (qa.outcome === "waived" ? "Unresolved QA issues preserved in durable run evidence" : "Durable QA recovery emitted qa_pass"),
573
+ });
574
+ if (qa.outcome === "passed")
575
+ completeQaFinalization(this.projectDir, this.qaRunId, ticketId);
576
+ return qa;
577
+ }
578
+ /** Reconcile a crash between certificate consumption, tracker completion, and the final receipt. */
579
+ async completePendingQaFinalization(ticketId) {
580
+ if (!this.projectDir || !this.ticketsEnabled)
581
+ throw new Error("pending QA finalization requires an initialized ticket project");
582
+ const protocol = new WorkflowDb(this.projectDir);
583
+ try {
584
+ if (protocol.qaTicketHead(this.qaRunId, ticketId).state !== "finalizing")
585
+ throw new Error(`QA finalization is not pending for ${ticketId}`);
586
+ }
587
+ finally {
588
+ protocol.close();
589
+ }
590
+ const paths = resolveTicketPaths(loadTicketsConfig(this.projectDir), this.projectDir);
591
+ const stateDb = new StateDb(paths.stateDb);
592
+ const alreadyDone = stateDb.getState(ticketId)?.status === "done";
593
+ stateDb.close();
594
+ await verifyPendingQaFinalizationSource(this.projectDir, this.projectDir, this.qaRunId, ticketId, alreadyDone);
595
+ if (!alreadyDone) {
596
+ cmdComplete(this.projectDir, ticketId, {
597
+ actor: "foreman", summary: "Completed after reconciling durable QA finalization",
598
+ validationResult: "passed", validationNotes: "Pass certificate was consumed before the interrupted tracker transition",
599
+ evidence: "Durable QA pass certificate and finalization intent reconciled after restart",
600
+ });
601
+ }
602
+ completeQaFinalization(this.projectDir, this.qaRunId, ticketId);
603
+ }
529
604
  qaSessionId() { return this.qaStream.sessionId; }
530
605
  qaSessionRef() { return this.qaStream.sessionRef; }
531
606
  builderSessionId() { return this.builder.sessionId(); }
@@ -618,6 +693,8 @@ export class Foreman {
618
693
  if (status.kind === "done" || status.kind === "plan_complete") {
619
694
  let qaSummary;
620
695
  let qaWaived = false;
696
+ let qaPassCertificateId;
697
+ let qaSourceStateDigest;
621
698
  if (this.qaEnabled) {
622
699
  const qa = await this.runQa(i, status.ticket ?? pendingTicketId, result.text);
623
700
  if (qa.outcome === "blocked") {
@@ -632,6 +709,8 @@ export class Foreman {
632
709
  }
633
710
  qaWaived = qa.outcome === "waived";
634
711
  qaSummary = qa.summary;
712
+ qaPassCertificateId = qa.passCertificateId;
713
+ qaSourceStateDigest = qa.sourceStateDigest;
635
714
  }
636
715
  // Update ticket state only after QA has passed, so generated tracker
637
716
  // state does not claim done before verification has completed.
@@ -639,6 +718,11 @@ export class Foreman {
639
718
  const ticketId = status.ticket ?? pendingTicketId;
640
719
  if (ticketId) {
641
720
  try {
721
+ if (this.qaEnabled && !qaWaived) {
722
+ if (!qaPassCertificateId || !qaSourceStateDigest)
723
+ throw new Error("QA passed without a durable pass certificate");
724
+ await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qaPassCertificateId, qaSourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
725
+ }
642
726
  cmdComplete(this.projectDir, ticketId, {
643
727
  actor: "foreman",
644
728
  summary: status.summary ?? `Step ${i} complete`,
@@ -650,6 +734,8 @@ export class Foreman {
650
734
  ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass"))
651
735
  : undefined,
652
736
  });
737
+ if (this.qaEnabled && !qaWaived)
738
+ completeQaFinalization(this.projectDir, this.qaRunId, ticketId);
653
739
  }
654
740
  catch (err) {
655
741
  outcome = "needs-human";
@@ -1,4 +1,4 @@
1
- import type { ContinuityDelta, HandoffLineage, HandoffManifestV1, ProviderSessionRefV1, SessionUsageSample } from "rafi-spec";
1
+ import type { ContinuityDelta, HandoffAcceptanceReceiptV1, HandoffLineage, HandoffManifestV1, ProviderSessionRefV1, SessionUsageSample } from "rafi-spec";
2
2
  import type { BuilderAdapter } from "./adapters/types.js";
3
3
  export declare const HANDOFF_CACHE_RETENTION_DAYS = 30;
4
4
  export declare const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
@@ -29,6 +29,8 @@ export interface CreateHandoffInput {
29
29
  requiredForRecovery?: boolean;
30
30
  mediaType?: string;
31
31
  path?: string;
32
+ purpose?: string;
33
+ bytes?: number;
32
34
  }>;
33
35
  requestedByBuilder?: boolean;
34
36
  /** Internal recovery path: use the last valid checkpoint while its head is marked degraded/invalid. */
@@ -44,12 +46,13 @@ export interface HandoffTransferResult extends StagedHandoff {
44
46
  successor: BuilderAdapter;
45
47
  successorSessionId: string;
46
48
  acceptanceCheckpointDigest: string;
49
+ acceptanceReceipt?: HandoffAcceptanceReceiptV1;
47
50
  }
48
51
  export declare class HandoffLoopError extends Error {
49
52
  readonly runId: string;
50
53
  constructor(runId: string);
51
54
  }
52
- export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "reused-predecessor-session";
55
+ export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "provider-turn-failed" | "successor-identity-mismatch" | "missing-terminal-event" | "reused-predecessor-session";
53
56
  export declare class HandoffAcceptanceError extends Error {
54
57
  readonly code: HandoffAcceptanceFailureCode;
55
58
  readonly runId: string;