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.
Files changed (48) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +124 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +32 -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 +24 -4
  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/roles.js +1 -0
  42. package/dist/stateTransfer.d.ts +57 -0
  43. package/dist/stateTransfer.js +644 -0
  44. package/dist/statusReporter.d.ts +2 -0
  45. package/dist/statusReporter.js +6 -0
  46. package/dist/workflowDb.d.ts +245 -2
  47. package/dist/workflowDb.js +647 -6
  48. package/package.json +15 -3
@@ -1,9 +1,12 @@
1
- import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
- import { changeManifestAsync, deterministicChangeSummaryAsync } from "../qaSnapshot.js";
3
- import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
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 { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
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
- if (baseWorktreePolicy === "warn") {
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
- journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
148
- if (resumeSession)
149
- cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
150
- cmdUpdate(opts.projectDir, node.ticket.id, {
151
- status: "in_progress", actor: "foreman",
152
- summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
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
- builderWorkSessions += 1;
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 } = opts.observer
184
- ? await opts.observer.withContext({ role: "builder", stream: "builder", ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, turn)
185
- : await turn();
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
- if (opts.qaEnabled) {
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
- fix: async (request) => {
381
+ deliverFailure: async (request) => {
273
382
  if (!builder)
274
383
  return { ok: false, detail: "Builder session unavailable" };
275
384
  builderWorkSessions += 1;
276
- const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
277
- const changeSummary = await deterministicChangeSummaryAsync(worktreePath);
278
- const fixInstruction = buildDurableQaFixHandoff(node.ticket, request, worktreePath, request.latestBuilderResult, digest, changeSummary);
279
- const strategy = opts.builderSessionStrategy ?? "compact";
280
- if (opts.builderSessionBoundary) {
281
- builder = await opts.builderSessionBoundary(builder, fixInstruction, strategy, worktreePath);
282
- }
283
- else if (strategy === "compact" && builder.sessionId()) {
284
- const compacted = await compactWithRetry(builder);
285
- if (!compacted.ok) {
286
- await builder.close();
287
- builder = await opts.createBuilder(worktreePath);
288
- }
289
- }
290
- else if (strategy === "fresh") {
291
- await builder.close();
292
- builder = await opts.createBuilder(worktreePath);
293
- }
294
- if (opts.beforeBuilderTurn)
295
- builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
296
- let fixed = await builder.sendTurn(fixInstruction);
297
- let fixedStatus = parseStepStatus(fixed.text);
298
- if (!fixed.isError && fixedStatus.kind === "blocked") {
299
- const resolved = await foreman.resolveBlocker(builder, fixedStatus.reason ?? "Builder QA fix reported an unspecified blocker");
300
- fixed = resolved.result;
301
- fixedStatus = resolved.status;
302
- }
303
- const fixedSessionId = builder.sessionId();
304
- const fixedSessionRef = builder.sessionRef?.();
305
- if (fixedSessionId)
306
- builderStream = { sessionId: fixedSessionId, ...(fixedSessionRef ? { sessionRef: fixedSessionRef } : {}), worktreePath };
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 = undefined;
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 = `${opts.runId}:commit:${node.ticket.id}`;
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
- if (!opts.keepWorktrees)
484
- removeTicketWorktree(opts.projectDir, worktreePath);
485
- const mergeCommit = await observeNode(opts, node, "git", "merging ticket branch to local base", () => mergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`, opts.mergeMethod ?? "squash"));
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
- if (opts.cleanupBranches ?? true)
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
- planJournal(opts.projectDir, opts.runId, completionOperation, "ticket-complete", { ticket: node.ticket.id });
499
- cmdComplete(opts.projectDir, node.ticket.id, {
500
- actor: "foreman",
501
- summary: status.summary ?? `Completed ${node.ticket.id}`,
502
- validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable",
503
- validationNotes: qaWaived ? "User explicitly waived unresolved QA failures" : opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
504
- evidence: opts.qaEnabled ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass")) : undefined,
505
- });
506
- confirmJournal(opts.projectDir, completionOperation, node.ticket.id, { status: "done" });
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
- db.updateOperation(key, "in_progress");
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();
@@ -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
- const existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
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.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
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;