ai-foreman 1.7.13 → 1.7.15
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 +124 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +32 -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 +24 -4
- 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/roles.js +1 -0
- 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
package/dist/branch/runner.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Foreman, MARKER_SPEC
|
|
1
|
+
import { beginQaFinalization, completeQaFinalization, verifyPendingQaFinalizationSource, compactWithRetry, runIsolatedQa } from "../qaReview.js";
|
|
2
|
+
import { QaFailureDeliveryService } from "../qaFailureDelivery.js";
|
|
3
|
+
import { Foreman, MARKER_SPEC } from "../foreman.js";
|
|
4
4
|
import { fireNotification } from "../notify.js";
|
|
5
5
|
import { cmdBlock, cmdComplete, cmdUnblock, cmdUpdate } from "../tickets/commands.js";
|
|
6
|
-
import {
|
|
6
|
+
import { loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
|
|
7
|
+
import { StateDb } from "../tickets/stateDb.js";
|
|
8
|
+
import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, runGit, } from "./git.js";
|
|
9
|
+
import { DirectMergeSourceChangedError, executeDirectMerge, hasExactStagedDirectMerge, prepareDirectMerge, readDirectMergeIntent, reconcileDirectMerge, removeDirectMergeWorktree, verifyDirectMergeWorktree } from "./finalization.js";
|
|
7
10
|
import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
|
|
8
11
|
import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
|
|
9
12
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -28,13 +31,51 @@ function deliverySessionPath(projectDir, unitId) {
|
|
|
28
31
|
}
|
|
29
32
|
export async function runBranchPlan(opts) {
|
|
30
33
|
let qaResumedRecovery = opts.qaResumedRecovery;
|
|
34
|
+
if (qaResumedRecovery) {
|
|
35
|
+
const matches = opts.plan.nodes.filter((node) => node.ticket.id === qaResumedRecovery.manifest.ticketId);
|
|
36
|
+
if (matches.length !== 1 || opts.plan.nodes.length !== 1) {
|
|
37
|
+
throw new Error(`exact QA recovery must be confined to its single packet ticket ${qaResumedRecovery.manifest.ticketId}`);
|
|
38
|
+
}
|
|
39
|
+
if (!opts.resumeSessions?.has(qaResumedRecovery.manifest.ticketId)) {
|
|
40
|
+
throw new Error(`exact QA recovery requires the preserved worktree/session for ${qaResumedRecovery.manifest.ticketId}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (opts.qaProtocolResumeTicket) {
|
|
44
|
+
const matches = opts.plan.nodes.filter((node) => node.ticket.id === opts.qaProtocolResumeTicket);
|
|
45
|
+
if (matches.length !== 1 || opts.plan.nodes.length !== 1 || !opts.resumeSessions?.has(opts.qaProtocolResumeTicket)) {
|
|
46
|
+
throw new Error(`exact QA protocol recovery must be confined to its preserved single ticket ${opts.qaProtocolResumeTicket}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
31
49
|
const baseWorktreePolicy = opts.baseWorktreePolicy ?? "enforce";
|
|
32
50
|
if (baseWorktreePolicy !== "skip") {
|
|
33
51
|
try {
|
|
34
52
|
ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
|
|
35
53
|
}
|
|
36
54
|
catch (error) {
|
|
37
|
-
|
|
55
|
+
const recoveryDb = new WorkflowDb(opts.projectDir);
|
|
56
|
+
let exactInterruptedMerge = false;
|
|
57
|
+
try {
|
|
58
|
+
exactInterruptedMerge = opts.plan.nodes.some((node) => {
|
|
59
|
+
if (recoveryDb.qaTicketHead(opts.runId, node.ticket.id).state !== "finalizing")
|
|
60
|
+
return false;
|
|
61
|
+
const operation = recoveryDb.operation(finalizationOperationKey(recoveryDb, opts.runId, node.ticket.id, "direct-merge"));
|
|
62
|
+
if (operation?.status !== "in_progress")
|
|
63
|
+
return false;
|
|
64
|
+
try {
|
|
65
|
+
return hasExactStagedDirectMerge(opts.projectDir, readDirectMergeIntent(operation.intent));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
recoveryDb.close();
|
|
74
|
+
}
|
|
75
|
+
if (exactInterruptedMerge) {
|
|
76
|
+
opts.log.write("branch-resume", { detail: "Base contains exactly the staged tree of its durable interrupted merge" });
|
|
77
|
+
}
|
|
78
|
+
else if (baseWorktreePolicy === "warn") {
|
|
38
79
|
console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
|
|
39
80
|
}
|
|
40
81
|
else {
|
|
@@ -68,6 +109,68 @@ export async function runBranchPlan(opts) {
|
|
|
68
109
|
if (opts.plan.issues.some((issue) => issue.blocking))
|
|
69
110
|
return summaries;
|
|
70
111
|
for (const node of orderNodes(opts.plan.nodes)) {
|
|
112
|
+
const protocolDb = new WorkflowDb(opts.projectDir);
|
|
113
|
+
const protocolHead = protocolDb.qaTicketHead(opts.runId, node.ticket.id);
|
|
114
|
+
const finalizationRecovery = protocolHead.state === "finalizing";
|
|
115
|
+
const directMergeOperation = finalizationOperationKey(protocolDb, opts.runId, node.ticket.id, "direct-merge");
|
|
116
|
+
const commitOperation = finalizationOperationKey(protocolDb, opts.runId, node.ticket.id, "commit");
|
|
117
|
+
const completionOperation = finalizationOperationKey(protocolDb, opts.runId, node.ticket.id, "ticket-complete");
|
|
118
|
+
let completedDirectMerge = finalizationRecovery ? protocolDb.operation(directMergeOperation) : undefined;
|
|
119
|
+
if (completedDirectMerge?.status === "in_progress") {
|
|
120
|
+
try {
|
|
121
|
+
const intent = readDirectMergeIntent(completedDirectMerge.intent);
|
|
122
|
+
const mergeCommit = reconcileDirectMerge(opts.projectDir, intent)
|
|
123
|
+
?? await observeNode(opts, node, "git", "resuming durable direct merge", () => executeDirectMerge(opts.projectDir, intent, `${node.ticket.id}: ${node.ticket.title}`));
|
|
124
|
+
if (mergeCommit) {
|
|
125
|
+
confirmJournal(opts.projectDir, directMergeOperation, mergeCommit, { branch: node.branch, base: node.baseBranch, mergeCommit, sourceCommit: intent.sourceCommit });
|
|
126
|
+
completedDirectMerge = { ...completedDirectMerge, status: "confirmed" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
protocolDb.close();
|
|
131
|
+
const paused = invalidateUnpublishedMergeDrift(opts.projectDir, opts.runId, node.ticket.id, directMergeOperation, error);
|
|
132
|
+
summaries.push(summaryFor(node, "blocked", paused instanceof Error ? paused.message : String(paused)));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
protocolDb.close();
|
|
137
|
+
if (finalizationRecovery && completedDirectMerge?.status === "confirmed") {
|
|
138
|
+
let existingWorktree;
|
|
139
|
+
try {
|
|
140
|
+
const intent = readDirectMergeIntent(completedDirectMerge.intent);
|
|
141
|
+
if (!reconcileDirectMerge(opts.projectDir, intent))
|
|
142
|
+
throw new Error("Confirmed direct merge no longer exists on its base branch");
|
|
143
|
+
existingWorktree = verifyDirectMergeWorktree(opts.projectDir, intent);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
summaries.push(summaryFor(node, "blocked", error instanceof Error ? error.message : String(error)));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (existingWorktree && !opts.keepWorktrees)
|
|
150
|
+
removeDirectMergeWorktree(opts.projectDir, readDirectMergeIntent(completedDirectMerge.intent), existingWorktree);
|
|
151
|
+
if (!opts.keepWorktrees && (opts.cleanupBranches ?? true)) {
|
|
152
|
+
try {
|
|
153
|
+
deleteLocalBranch(opts.projectDir, node.branch);
|
|
154
|
+
}
|
|
155
|
+
catch { /* already removed or retained by repository policy */ }
|
|
156
|
+
}
|
|
157
|
+
if (!ticketIsDone(opts.projectDir, node.ticket.id)) {
|
|
158
|
+
cmdComplete(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Completed ${node.ticket.id} after reconciling its durable direct merge`, validationResult: "passed", validationNotes: "Durable QA pass and direct-merge receipt reconciled", evidence: "Durable direct-merge receipt" });
|
|
159
|
+
}
|
|
160
|
+
completeQaFinalization(opts.projectDir, opts.runId, node.ticket.id);
|
|
161
|
+
const resumeDb = new WorkflowDb(opts.projectDir);
|
|
162
|
+
try {
|
|
163
|
+
resumeDb.completeBranchResumeSession(opts.runId, node.ticket.id);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
resumeDb.close();
|
|
167
|
+
}
|
|
168
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-after", node.ticket.id, { status: "done", reconciledDirectMerge: true });
|
|
169
|
+
successfulBranches.add(node.ticket.id);
|
|
170
|
+
summaries.push(summaryFor(node, "done"));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const qaOnlyRecovery = qaResumedRecovery?.manifest.ticketId === node.ticket.id || opts.qaProtocolResumeTicket === node.ticket.id || finalizationRecovery;
|
|
71
174
|
const sharedUnit = Boolean(node.deliveryUnitId);
|
|
72
175
|
const completesSharedUnit = !sharedUnit || Boolean(node.deliveryUnitFinal);
|
|
73
176
|
const createsReviewForNode = createsReview && completesSharedUnit;
|
|
@@ -144,14 +247,15 @@ export async function runBranchPlan(opts) {
|
|
|
144
247
|
sessionRef: resumeSession.sessionRef,
|
|
145
248
|
});
|
|
146
249
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
250
|
+
if (!qaOnlyRecovery)
|
|
251
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
|
|
252
|
+
if (resumeSession)
|
|
253
|
+
cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
|
|
254
|
+
cmdUpdate(opts.projectDir, node.ticket.id, {
|
|
255
|
+
status: "in_progress", actor: "foreman",
|
|
256
|
+
summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
|
|
257
|
+
});
|
|
153
258
|
});
|
|
154
|
-
});
|
|
155
259
|
const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
|
|
156
260
|
// Reattach the predecessor even for a `fresh` strategy so the host can
|
|
157
261
|
// publish and validate a cumulative handoff before creating its successor.
|
|
@@ -159,7 +263,7 @@ export async function runBranchPlan(opts) {
|
|
|
159
263
|
const continuedBuilderSession = resumeSession?.sessionId ?? sameWorktreeStream?.sessionId;
|
|
160
264
|
const continuedBuilderRef = resumeSession?.sessionRef ?? sameWorktreeStream?.sessionRef;
|
|
161
265
|
builder = await opts.createBuilder(worktreePath, continuedBuilderSession, continuedBuilderRef);
|
|
162
|
-
if (builderWorkSessions > 0 && continuedBuilderSession) {
|
|
266
|
+
if (!qaOnlyRecovery && builderWorkSessions > 0 && continuedBuilderSession) {
|
|
163
267
|
const strategy = opts.builderSessionStrategy ?? "compact";
|
|
164
268
|
if (opts.builderSessionBoundary) {
|
|
165
269
|
builder = await opts.builderSessionBoundary(builder, ticketInstruction, strategy, worktreePath);
|
|
@@ -176,13 +280,16 @@ export async function runBranchPlan(opts) {
|
|
|
176
280
|
builder = await opts.createBuilder(worktreePath);
|
|
177
281
|
}
|
|
178
282
|
}
|
|
179
|
-
|
|
283
|
+
if (!qaOnlyRecovery)
|
|
284
|
+
builderWorkSessions += 1;
|
|
180
285
|
viewer = opts.observeBuilder?.(builder);
|
|
181
286
|
const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
|
|
182
287
|
const turn = () => foreman.runInstruction(ticketInstruction);
|
|
183
|
-
const { result, status } =
|
|
184
|
-
?
|
|
185
|
-
:
|
|
288
|
+
const { result, status } = qaOnlyRecovery
|
|
289
|
+
? { result: { text: "Exact QA-only recovery; Builder work dispatch was intentionally skipped.", isError: false, numTurns: 0, costUsd: 0 }, status: { kind: "done", summary: "Resuming exact QA boundary", ticket: node.ticket.id } }
|
|
290
|
+
: opts.observer
|
|
291
|
+
? await opts.observer.withContext({ role: "builder", stream: "builder", ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, turn)
|
|
292
|
+
: await turn();
|
|
186
293
|
builder = foreman.builderAdapter();
|
|
187
294
|
workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
|
|
188
295
|
const sessionId = builder.sessionId();
|
|
@@ -252,7 +359,9 @@ export async function runBranchPlan(opts) {
|
|
|
252
359
|
}
|
|
253
360
|
let qaSummary;
|
|
254
361
|
let qaWaived = false;
|
|
255
|
-
|
|
362
|
+
let qaPassCertificateId;
|
|
363
|
+
let qaSourceStateDigest;
|
|
364
|
+
if (opts.qaEnabled && !finalizationRecovery) {
|
|
256
365
|
if (!opts.createQa)
|
|
257
366
|
throw new Error("independent disposable QA factory is required when QA is enabled");
|
|
258
367
|
workflowCheckpoint(opts.projectDir, opts.runId, "qa-before", node.ticket.id, { worktree: worktreePath });
|
|
@@ -265,54 +374,58 @@ export async function runBranchPlan(opts) {
|
|
|
265
374
|
continuityManaged: opts.qaContinuityManaged,
|
|
266
375
|
onReportRecovery: opts.qaReportRecovery,
|
|
267
376
|
resumedRecovery: qaResumedRecovery,
|
|
268
|
-
sessionBoundary: opts.qaSessionBoundary,
|
|
377
|
+
sessionBoundary: opts.qaSessionBoundary ?? (async () => { throw new Error("QA recovery requires a validated durable session boundary"); }),
|
|
269
378
|
observeNativeCompactions: opts.observeQaNativeCompactions,
|
|
270
379
|
resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason, "qa"),
|
|
271
380
|
evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
|
|
272
|
-
|
|
381
|
+
deliverFailure: async (request) => {
|
|
273
382
|
if (!builder)
|
|
274
383
|
return { ok: false, detail: "Builder session unavailable" };
|
|
275
384
|
builderWorkSessions += 1;
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (fixedSessionId) {
|
|
308
|
-
opts.recordBuilderSession?.(fixedSessionRef ?? fixedSessionId, node.ticket.id, worktreePath);
|
|
309
|
-
workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId: fixedSessionId, sessionRef: fixedSessionRef, worktree: worktreePath, branch: node.branch });
|
|
310
|
-
}
|
|
311
|
-
return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error, response: fixed.text, summary: fixedStatus.summary ?? fixed.text };
|
|
385
|
+
const delivery = new QaFailureDeliveryService();
|
|
386
|
+
const result = await delivery.deliver(request, {
|
|
387
|
+
adapter: () => builder,
|
|
388
|
+
setAdapter: (adapter) => { builder = adapter; },
|
|
389
|
+
sessionStrategy: opts.builderSessionStrategy ?? "compact",
|
|
390
|
+
prepareBoundary: async (adapter, instruction, strategy) => {
|
|
391
|
+
if (opts.builderSessionBoundary)
|
|
392
|
+
return opts.builderSessionBoundary(adapter, instruction, strategy, worktreePath);
|
|
393
|
+
if (strategy === "compact" && adapter.sessionId()) {
|
|
394
|
+
const compacted = await compactWithRetry(adapter);
|
|
395
|
+
if (compacted.ok)
|
|
396
|
+
return adapter;
|
|
397
|
+
await adapter.close();
|
|
398
|
+
return opts.createBuilder(worktreePath);
|
|
399
|
+
}
|
|
400
|
+
if (strategy === "fresh") {
|
|
401
|
+
await adapter.close();
|
|
402
|
+
return opts.createBuilder(worktreePath);
|
|
403
|
+
}
|
|
404
|
+
return adapter;
|
|
405
|
+
},
|
|
406
|
+
beforeTurn: async (adapter, instruction, worktree) => opts.beforeBuilderTurn ? opts.beforeBuilderTurn(adapter, instruction, worktree) : adapter,
|
|
407
|
+
recordSession: (session) => {
|
|
408
|
+
const sessionRef = typeof session === "string" ? undefined : session;
|
|
409
|
+
const sessionId = typeof session === "string" ? session : session.sessionId;
|
|
410
|
+
builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
|
|
411
|
+
opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
|
|
412
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
return result;
|
|
312
416
|
},
|
|
313
417
|
onNonconvergence: opts.qaNonconvergence,
|
|
314
418
|
});
|
|
315
|
-
qaResumedRecovery
|
|
419
|
+
if (qaResumedRecovery) {
|
|
420
|
+
const recoveryDb = new WorkflowDb(opts.projectDir);
|
|
421
|
+
try {
|
|
422
|
+
if (recoveryDb.qaRecoveryHead(opts.runId, qaResumedRecovery.manifest.ticketId)?.pendingAction === "resolved")
|
|
423
|
+
qaResumedRecovery = undefined;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
recoveryDb.close();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
316
429
|
if (qaStream.sessionId)
|
|
317
430
|
opts.recordQaSession?.(qaStream.sessionRef ?? qaStream.sessionId, node.ticket.id, worktreePath);
|
|
318
431
|
workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
|
|
@@ -324,8 +437,10 @@ export async function runBranchPlan(opts) {
|
|
|
324
437
|
}
|
|
325
438
|
qaWaived = qa.outcome === "waived";
|
|
326
439
|
qaSummary = qa.summary;
|
|
440
|
+
qaPassCertificateId = qa.passCertificateId;
|
|
441
|
+
qaSourceStateDigest = qa.sourceStateDigest;
|
|
327
442
|
}
|
|
328
|
-
else {
|
|
443
|
+
else if (!finalizationRecovery) {
|
|
329
444
|
opts.log.write("qa", {
|
|
330
445
|
stepIndex: summaries.length + 1,
|
|
331
446
|
cycle: 0,
|
|
@@ -335,6 +450,14 @@ export async function runBranchPlan(opts) {
|
|
|
335
450
|
isError: false,
|
|
336
451
|
});
|
|
337
452
|
}
|
|
453
|
+
if (opts.qaEnabled && !qaWaived && !finalizationRecovery) {
|
|
454
|
+
if (!qaPassCertificateId || !qaSourceStateDigest)
|
|
455
|
+
throw new Error("QA passed without a durable pass certificate");
|
|
456
|
+
await beginQaFinalization(opts.projectDir, worktreePath, opts.runId, node.ticket.id, qaPassCertificateId, qaSourceStateDigest, `branch-finalization:${node.ticket.id}`);
|
|
457
|
+
}
|
|
458
|
+
if (opts.qaEnabled && !qaWaived && finalizationRecovery) {
|
|
459
|
+
await verifyPendingQaFinalizationSource(opts.projectDir, worktreePath, opts.runId, node.ticket.id, !hasWorktreeChanges(worktreePath), commitOperation);
|
|
460
|
+
}
|
|
338
461
|
const currentBranch = currentWorktreeBranch(worktreePath);
|
|
339
462
|
if (currentBranch !== node.branch) {
|
|
340
463
|
throw new Error(`builder switched branch from ${node.branch} to ${currentBranch}`);
|
|
@@ -344,8 +467,10 @@ export async function runBranchPlan(opts) {
|
|
|
344
467
|
}
|
|
345
468
|
let commit;
|
|
346
469
|
if (hasWorktreeChanges(worktreePath)) {
|
|
470
|
+
if (opts.qaEnabled && !qaWaived)
|
|
471
|
+
await verifyPendingQaFinalizationSource(opts.projectDir, worktreePath, opts.runId, node.ticket.id, false);
|
|
347
472
|
workflowCheckpoint(opts.projectDir, opts.runId, "commit-before", node.ticket.id, { branch: node.branch });
|
|
348
|
-
const operation =
|
|
473
|
+
const operation = commitOperation;
|
|
349
474
|
planJournal(opts.projectDir, opts.runId, operation, "commit", { ticket: node.ticket.id, branch: node.branch, worktreePath });
|
|
350
475
|
try {
|
|
351
476
|
commit = await observeNode(opts, node, "git", "committing ticket changes", () => commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`));
|
|
@@ -360,6 +485,9 @@ export async function runBranchPlan(opts) {
|
|
|
360
485
|
if (!commit && (createsReviewForNode || (completionMode === "direct-merge" && completesSharedUnit))) {
|
|
361
486
|
commit = headCommitIfAhead(worktreePath, node.baseBranch);
|
|
362
487
|
}
|
|
488
|
+
if (opts.qaEnabled && !qaWaived) {
|
|
489
|
+
await verifyPendingQaFinalizationSource(opts.projectDir, worktreePath, opts.runId, node.ticket.id, true, commitOperation);
|
|
490
|
+
}
|
|
363
491
|
const summary = summaryFor(node, "done", commit ? undefined : "no_changes");
|
|
364
492
|
summary.commit = commit;
|
|
365
493
|
if (commit && createsReviewForNode) {
|
|
@@ -480,9 +608,24 @@ export async function runBranchPlan(opts) {
|
|
|
480
608
|
summary.pr = { status: "skipped", error: commit ? undefined : "no_changes" };
|
|
481
609
|
}
|
|
482
610
|
else if (commit && completionMode === "direct-merge" && completesSharedUnit) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
611
|
+
const mergeDb = new WorkflowDb(opts.projectDir);
|
|
612
|
+
let existingMerge;
|
|
613
|
+
try {
|
|
614
|
+
existingMerge = mergeDb.operation(directMergeOperation);
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
mergeDb.close();
|
|
618
|
+
}
|
|
619
|
+
const mergeIntent = existingMerge ? readDirectMergeIntent(existingMerge.intent)
|
|
620
|
+
: prepareDirectMerge(opts.projectDir, node.ticket.id, node.branch, node.baseBranch, opts.mergeMethod ?? "squash");
|
|
621
|
+
planJournal(opts.projectDir, opts.runId, directMergeOperation, "direct-merge", mergeIntent);
|
|
622
|
+
let mergeCommit;
|
|
623
|
+
try {
|
|
624
|
+
mergeCommit = await observeNode(opts, node, "git", "merging ticket branch to local base", () => executeDirectMerge(opts.projectDir, mergeIntent, `${node.ticket.id}: ${node.ticket.title}`));
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
throw invalidateUnpublishedMergeDrift(opts.projectDir, opts.runId, node.ticket.id, directMergeOperation, error);
|
|
628
|
+
}
|
|
486
629
|
summary.pr = { status: "merged", url: mergeCommit };
|
|
487
630
|
opts.log.write("branch-direct-merge", {
|
|
488
631
|
ticket: node.ticket.id,
|
|
@@ -490,20 +633,30 @@ export async function runBranchPlan(opts) {
|
|
|
490
633
|
base: node.baseBranch,
|
|
491
634
|
commit: mergeCommit,
|
|
492
635
|
});
|
|
493
|
-
|
|
636
|
+
confirmJournal(opts.projectDir, directMergeOperation, mergeCommit, { branch: node.branch, base: node.baseBranch, mergeCommit, sourceCommit: mergeIntent.sourceCommit });
|
|
637
|
+
verifyDirectMergeWorktree(opts.projectDir, mergeIntent);
|
|
638
|
+
if (!opts.keepWorktrees)
|
|
639
|
+
removeDirectMergeWorktree(opts.projectDir, mergeIntent, worktreePath);
|
|
640
|
+
if (!opts.keepWorktrees && (opts.cleanupBranches ?? true))
|
|
494
641
|
deleteLocalBranch(opts.projectDir, node.branch);
|
|
495
642
|
}
|
|
496
|
-
const completionOperation = `${opts.runId}:ticket-complete:${node.ticket.id}`;
|
|
497
643
|
workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-before", node.ticket.id, { validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable" });
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
644
|
+
const priorCompletion = operationStatus(opts.projectDir, completionOperation);
|
|
645
|
+
if (priorCompletion !== "confirmed") {
|
|
646
|
+
planJournal(opts.projectDir, opts.runId, completionOperation, "ticket-complete", { ticket: node.ticket.id });
|
|
647
|
+
if (!ticketIsDone(opts.projectDir, node.ticket.id)) {
|
|
648
|
+
cmdComplete(opts.projectDir, node.ticket.id, {
|
|
649
|
+
actor: "foreman",
|
|
650
|
+
summary: status.summary ?? `Completed ${node.ticket.id}`,
|
|
651
|
+
validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable",
|
|
652
|
+
validationNotes: qaWaived ? "User explicitly waived unresolved QA failures" : opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
|
|
653
|
+
evidence: opts.qaEnabled ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass")) : undefined,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
confirmJournal(opts.projectDir, completionOperation, node.ticket.id, { status: "done" });
|
|
657
|
+
}
|
|
658
|
+
if (opts.qaEnabled && !qaWaived)
|
|
659
|
+
completeQaFinalization(opts.projectDir, opts.runId, node.ticket.id);
|
|
507
660
|
workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-after", node.ticket.id, { status: "done" });
|
|
508
661
|
successfulBranches.add(node.ticket.id);
|
|
509
662
|
summaries.push(summary);
|
|
@@ -558,8 +711,57 @@ function planJournal(projectDir, runId, key, kind, intent) {
|
|
|
558
711
|
try {
|
|
559
712
|
if (!db.getRun(runId))
|
|
560
713
|
db.createRun({ runId, kind: "build", originalWork: {}, state: {} });
|
|
561
|
-
db.planOperation({ runId, idempotencyKey: key, kind, intent });
|
|
562
|
-
|
|
714
|
+
const operation = db.planOperation({ runId, idempotencyKey: key, kind, intent });
|
|
715
|
+
if (operation.status !== "confirmed")
|
|
716
|
+
db.updateOperation(key, "in_progress");
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
db.close();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function finalizationOperationKey(db, runId, ticketId, kind) {
|
|
723
|
+
const invalidated = db.qaFinalizationSteps(runId, ticketId).filter((step) => step.status === "invalidated").length;
|
|
724
|
+
return `${runId}:${kind}:${ticketId}${invalidated ? `:qa-recheck-${invalidated}` : ""}`;
|
|
725
|
+
}
|
|
726
|
+
function invalidateUnpublishedMergeDrift(projectDir, runId, ticketId, operationId, error) {
|
|
727
|
+
if (!(error instanceof DirectMergeSourceChangedError))
|
|
728
|
+
return error;
|
|
729
|
+
const db = new WorkflowDb(projectDir);
|
|
730
|
+
try {
|
|
731
|
+
const head = db.qaTicketHead(runId, ticketId);
|
|
732
|
+
const operation = db.operation(operationId);
|
|
733
|
+
if (head.state !== "finalizing" || !operation || !["planned", "in_progress"].includes(operation.status))
|
|
734
|
+
return error;
|
|
735
|
+
const intent = readDirectMergeIntent(operation.intent);
|
|
736
|
+
if (runGit(projectDir, ["rev-parse", intent.base]).stdout !== intent.baseCommit || hasWorktreeChanges(projectDir))
|
|
737
|
+
return error;
|
|
738
|
+
// No base ref/index/worktree mutation occurred. Keep the abandoned intent
|
|
739
|
+
// and source edits, but retire its dispatch slot before scheduling new QA.
|
|
740
|
+
db.updateOperation(operationId, "failed", { error: error.message });
|
|
741
|
+
const paused = db.invalidateQaFinalization(runId, ticketId, head.revision, error.message, intent.branch);
|
|
742
|
+
return new Error(`${error.message}. A complete QA recheck is required. Resume with: rafi build:resume ${projectDir} --run ${runId} --ticket ${ticketId} --qa-revision ${paused.revision} --fresh-with-handoff`);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return error;
|
|
746
|
+
}
|
|
747
|
+
finally {
|
|
748
|
+
db.close();
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function operationStatus(projectDir, key) {
|
|
752
|
+
const db = new WorkflowDb(projectDir);
|
|
753
|
+
try {
|
|
754
|
+
return db.operation(key)?.status;
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
db.close();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function ticketIsDone(projectDir, ticketId) {
|
|
761
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
762
|
+
const db = new StateDb(paths.stateDb);
|
|
763
|
+
try {
|
|
764
|
+
return db.getState(ticketId)?.status === "done";
|
|
563
765
|
}
|
|
564
766
|
finally {
|
|
565
767
|
db.close();
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export declare function resumeBuildRun(projectDir: string, runId: string, patch:
|
|
|
29
29
|
qa?: LegacyResolvedAgentSettings;
|
|
30
30
|
builderSessionId?: string | null;
|
|
31
31
|
builderSessionRef?: ProviderSessionRefV1 | null;
|
|
32
|
+
expectedRecoveryDecisionDigest?: string;
|
|
32
33
|
}, now?: Date): BuildRunRecordV2;
|
|
33
34
|
type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision" | "display_session_cost" | "auto_compact_threshold_percent" | "compact_maximum"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision" | "display_session_cost" | "auto_compact_threshold_percent" | "compact_maximum">>;
|
|
34
35
|
export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
package/dist/buildRuns.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
@@ -71,14 +71,36 @@ export function createBuildRun(input) {
|
|
|
71
71
|
return saved;
|
|
72
72
|
}
|
|
73
73
|
export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
74
|
-
|
|
74
|
+
let existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
75
75
|
if (!existing)
|
|
76
76
|
throw new Error(`recoverable build run not found: ${runId}`);
|
|
77
77
|
if (existing.status === "completed")
|
|
78
78
|
throw new Error(`build run ${runId} is already complete`);
|
|
79
79
|
const workflow = new WorkflowDb(projectDir);
|
|
80
80
|
try {
|
|
81
|
-
workflow.
|
|
81
|
+
const held = workflow.currentLease();
|
|
82
|
+
if (held && held.runId === runId && held.pid === process.pid && held.host === hostname() && held.processStart === processStartIdentity(process.pid)) {
|
|
83
|
+
workflow.heartbeatLease(held, now);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
87
|
+
}
|
|
88
|
+
if (patch.expectedRecoveryDecisionDigest) {
|
|
89
|
+
const decision = workflow.getRun(runId)?.state.recoveryDecision;
|
|
90
|
+
if (createHash("sha256").update(JSON.stringify(decision ?? null)).digest("hex") !== patch.expectedRecoveryDecisionDigest) {
|
|
91
|
+
const lease = workflow.currentLease();
|
|
92
|
+
if (lease?.runId === runId && lease.pid === process.pid)
|
|
93
|
+
workflow.releaseLease(lease, now);
|
|
94
|
+
throw new Error("Recovery decision changed before the child supervisor acquired its lease; inspect and retry");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
98
|
+
if (!existing || existing.status === "completed") {
|
|
99
|
+
const lease = workflow.currentLease();
|
|
100
|
+
if (lease?.runId === runId && lease.pid === process.pid)
|
|
101
|
+
workflow.releaseLease(lease, now);
|
|
102
|
+
throw new Error("Recovery run completed or disappeared before lease acquisition");
|
|
103
|
+
}
|
|
82
104
|
}
|
|
83
105
|
finally {
|
|
84
106
|
workflow.close();
|
|
@@ -194,6 +216,7 @@ export function recordBuildReceipt(projectDir, run, operationId, detail) {
|
|
|
194
216
|
const workflow = new WorkflowDb(projectDir);
|
|
195
217
|
try {
|
|
196
218
|
workflow.planOperation({ runId: run.runId, idempotencyKey: operationId, kind: operationId.split(":", 1)[0] ?? "operation", intent: { checkpoint: run.checkpoint } });
|
|
219
|
+
workflow.updateOperation(operationId, "in_progress");
|
|
197
220
|
workflow.updateOperation(operationId, "confirmed", { externalId: detail?.externalId, result: { detail: detail?.detail } });
|
|
198
221
|
}
|
|
199
222
|
finally {
|
|
@@ -214,6 +237,13 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
|
214
237
|
return saved;
|
|
215
238
|
}
|
|
216
239
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
240
|
+
const workflow = new WorkflowDb(projectDir);
|
|
241
|
+
try {
|
|
242
|
+
workflow.assertQaRunFinalizable(run.runId);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
workflow.close();
|
|
246
|
+
}
|
|
217
247
|
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
|
|
218
248
|
releaseWorkflowLease(projectDir, run.runId, now);
|
|
219
249
|
return saved;
|