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.
- package/README.md +33 -3
- package/dist/adapters/claude.d.ts +5 -0
- package/dist/adapters/claude.js +122 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +30 -8
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +6 -0
- package/dist/adapters/types.d.ts +22 -0
- package/dist/agentRun.js +21 -3
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +9 -0
- package/dist/branch/finalization.d.ts +22 -0
- package/dist/branch/finalization.js +120 -0
- package/dist/branch/git.js +7 -2
- package/dist/branch/runner.d.ts +3 -2
- package/dist/branch/runner.js +277 -75
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +33 -3
- package/dist/cli/start.js +314 -86
- package/dist/cli/state.d.ts +4 -0
- package/dist/cli/state.js +69 -0
- package/dist/continuity.d.ts +4 -0
- package/dist/continuity.js +26 -3
- package/dist/foreman.d.ts +22 -4
- package/dist/foreman.js +105 -19
- package/dist/handoffs.d.ts +5 -2
- package/dist/handoffs.js +93 -12
- package/dist/index.js +2 -0
- package/dist/qaFailureDelivery.d.ts +100 -0
- package/dist/qaFailureDelivery.js +570 -0
- package/dist/qaProtocolV2.d.ts +233 -0
- package/dist/qaProtocolV2.js +114 -0
- package/dist/qaRecovery.d.ts +15 -3
- package/dist/qaRecovery.js +477 -83
- package/dist/qaReview.d.ts +42 -17
- package/dist/qaReview.js +1216 -457
- package/dist/qaRuntime.d.ts +14 -0
- package/dist/qaRuntime.js +44 -0
- package/dist/qaSnapshot.d.ts +34 -0
- package/dist/qaSnapshot.js +302 -34
- package/dist/stateTransfer.d.ts +57 -0
- package/dist/stateTransfer.js +644 -0
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +6 -0
- package/dist/workflowDb.d.ts +245 -2
- package/dist/workflowDb.js +647 -6
- package/package.json +15 -3
|
@@ -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
|
+
}
|
package/dist/continuity.d.ts
CHANGED
|
@@ -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;
|
package/dist/continuity.js
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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 {
|
|
10
|
-
import {
|
|
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.
|
|
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
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
:
|
|
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";
|
package/dist/handoffs.d.ts
CHANGED
|
@@ -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;
|