ai-foreman 1.6.2 → 1.7.0

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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/statusReporter.d.ts +40 -0
  33. package/dist/statusReporter.js +31 -0
  34. package/dist/ticketPopulation.d.ts +47 -0
  35. package/dist/ticketPopulation.js +205 -0
  36. package/dist/tickets/blockers.d.ts +1 -1
  37. package/dist/tickets/blockers.js +2 -0
  38. package/dist/tickets/commands.d.ts +1 -0
  39. package/dist/tickets/commands.js +30 -1
  40. package/dist/tickets/delivery.d.ts +36 -0
  41. package/dist/tickets/delivery.js +204 -4
  42. package/dist/tickets/queue.d.ts +3 -0
  43. package/dist/tickets/queue.js +50 -1
  44. package/dist/tickets/stateDb.d.ts +2 -1
  45. package/dist/tickets/stateDb.js +24 -1
  46. package/dist/tickets/ticketSchema.d.ts +44 -0
  47. package/dist/tickets/ticketSchema.js +10 -0
  48. package/dist/tickets/validate.js +1 -1
  49. package/dist/workflowDb.d.ts +113 -0
  50. package/dist/workflowDb.js +261 -0
  51. package/package.json +16 -3
@@ -1,4 +1,6 @@
1
1
  import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
2
+ import type { SessionStrategy } from "rafi-spec";
3
+ import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "../qaReview.js";
2
4
  import type { Log } from "../log.js";
3
5
  import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, MergeMethod, ReviewProvider } from "./types.js";
4
6
  export interface DeliveryUnitSession {
@@ -39,7 +41,11 @@ export interface BranchRunnerOptions {
39
41
  sessionId: string;
40
42
  }>;
41
43
  createBuilder: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
44
+ createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
45
+ builderSessionStrategy?: SessionStrategy;
46
+ qaSessionStrategy?: SessionStrategy;
42
47
  observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
48
+ qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
43
49
  }
44
50
  export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
45
51
  export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
@@ -1,4 +1,6 @@
1
- import { Foreman, MARKER_SPEC } from "../foreman.js";
1
+ import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
+ import { changeManifest } from "../qaSnapshot.js";
3
+ import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
2
4
  import { fireNotification } from "../notify.js";
3
5
  import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
4
6
  import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
@@ -6,6 +8,7 @@ import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranch
6
8
  import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
7
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
10
  import { join } from "node:path";
11
+ import { WorkflowDb } from "../workflowDb.js";
9
12
  export function readDeliveryUnitSession(projectDir, unitId) {
10
13
  const path = deliverySessionPath(projectDir, unitId);
11
14
  if (!existsSync(path))
@@ -29,6 +32,9 @@ export async function runBranchPlan(opts) {
29
32
  const summaries = [];
30
33
  const successfulBranches = new Set();
31
34
  const pushedBranches = new Set();
35
+ let builderStreamSession;
36
+ let builderWorkSessions = 0;
37
+ const qaStream = { reviews: 0, modificationViolations: 0 };
32
38
  for (const issue of opts.plan.issues) {
33
39
  opts.log.write("branch-issue", { ...issue });
34
40
  notifyIssue(opts.notificationsEnabled, issue.message);
@@ -102,6 +108,7 @@ export async function runBranchPlan(opts) {
102
108
  base: node.baseBranch,
103
109
  dependencies: node.dependencies,
104
110
  });
111
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
105
112
  const resumeSession = opts.resumeSessions?.get(node.ticket.id);
106
113
  const worktreePath = resumeSession?.worktreePath
107
114
  ?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
@@ -119,16 +126,26 @@ export async function runBranchPlan(opts) {
119
126
  sessionId: resumeSession.sessionId,
120
127
  });
121
128
  }
122
- cmdUpdate(opts.projectDir, node.ticket.id, {
123
- status: "in_progress",
124
- actor: "foreman",
129
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => cmdUpdate(opts.projectDir, node.ticket.id, {
130
+ status: "in_progress", actor: "foreman",
125
131
  summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
126
- });
127
- builder = await opts.createBuilder(worktreePath, resumeSession?.sessionId);
132
+ }));
133
+ const continuedBuilderSession = resumeSession?.sessionId ?? (opts.builderSessionStrategy === "compact" ? builderStreamSession : undefined);
134
+ builder = await opts.createBuilder(worktreePath, continuedBuilderSession);
135
+ if (opts.builderSessionStrategy === "compact" && builderWorkSessions > 0 && continuedBuilderSession) {
136
+ const compacted = await compactWithRetry(builder);
137
+ if (!compacted.ok) {
138
+ await builder.close();
139
+ builder = await opts.createBuilder(worktreePath);
140
+ }
141
+ }
142
+ builderWorkSessions += 1;
128
143
  viewer = opts.observeBuilder?.(builder);
129
144
  const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined);
130
145
  const { result, status } = await foreman.runInstruction(resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths));
146
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
131
147
  const sessionId = builder.sessionId();
148
+ builderStreamSession = opts.builderSessionStrategy === "compact" ? sessionId : undefined;
132
149
  if (sessionId) {
133
150
  opts.log.write("branch-session", {
134
151
  ticket: node.ticket.id,
@@ -169,19 +186,51 @@ export async function runBranchPlan(opts) {
169
186
  }
170
187
  if (status.kind !== "done" && status.kind !== "plan_complete") {
171
188
  const detail = status.reason ?? status.error ?? `builder emitted ${status.kind}`;
172
- cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" });
189
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:builder-block`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
173
190
  summaries.push(summaryFor(node, status.kind === "blocked" ? "blocked" : "needs-human", detail));
174
191
  continue;
175
192
  }
176
193
  let qaSummary;
194
+ let qaWaived = false;
177
195
  if (opts.qaEnabled) {
178
- const qa = await foreman.runQaReview(summaries.length + 1);
179
- if (qa.outcome !== "passed") {
196
+ if (!opts.createQa)
197
+ throw new Error("independent disposable QA factory is required when QA is enabled");
198
+ workflowCheckpoint(opts.projectDir, opts.runId, "qa-before", node.ticket.id, { worktree: worktreePath });
199
+ const qa = await runIsolatedQa({
200
+ ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
201
+ qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
202
+ evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
203
+ fix: async (issues) => {
204
+ if (!builder)
205
+ return { ok: false, detail: "Builder session unavailable" };
206
+ builderWorkSessions += 1;
207
+ if (opts.builderSessionStrategy === "compact" && builder.sessionId()) {
208
+ const compacted = await compactWithRetry(builder);
209
+ if (!compacted.ok) {
210
+ await builder.close();
211
+ builder = await opts.createBuilder(worktreePath);
212
+ }
213
+ }
214
+ else if (opts.builderSessionStrategy === "fresh") {
215
+ await builder.close();
216
+ builder = await opts.createBuilder(worktreePath);
217
+ }
218
+ const digest = changeManifest(worktreePath).diffDigest;
219
+ const fixed = await builder.sendTurn(buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest));
220
+ const fixedStatus = parseStepStatus(fixed.text);
221
+ builderStreamSession = opts.builderSessionStrategy === "compact" ? builder.sessionId() : undefined;
222
+ return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error ?? fixed.text.slice(0, 500) };
223
+ },
224
+ onNonconvergence: opts.qaNonconvergence,
225
+ });
226
+ workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
227
+ if (qa.outcome !== "passed" && qa.outcome !== "waived") {
180
228
  const detail = qa.detail ?? "QA did not pass";
181
- cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" });
229
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:qa-block`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
182
230
  summaries.push(summaryFor(node, qa.outcome === "blocked" ? "blocked" : "needs-human", detail));
183
231
  continue;
184
232
  }
233
+ qaWaived = qa.outcome === "waived";
185
234
  qaSummary = qa.summary;
186
235
  }
187
236
  else {
@@ -203,7 +252,18 @@ export async function runBranchPlan(opts) {
203
252
  }
204
253
  let commit;
205
254
  if (hasWorktreeChanges(worktreePath)) {
206
- commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
255
+ workflowCheckpoint(opts.projectDir, opts.runId, "commit-before", node.ticket.id, { branch: node.branch });
256
+ const operation = `${opts.runId}:commit:${node.ticket.id}`;
257
+ planJournal(opts.projectDir, opts.runId, operation, "commit", { ticket: node.ticket.id, branch: node.branch, worktreePath });
258
+ try {
259
+ commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
260
+ confirmJournal(opts.projectDir, operation, commit, { sha: commit });
261
+ workflowCheckpoint(opts.projectDir, opts.runId, "commit-after", node.ticket.id, { sha: commit });
262
+ }
263
+ catch (error) {
264
+ failJournal(opts.projectDir, operation, error, false);
265
+ throw error;
266
+ }
207
267
  }
208
268
  if (!commit && (createsReviewForNode || (completionMode === "direct-merge" && completesSharedUnit))) {
209
269
  commit = headCommitIfAhead(worktreePath, node.baseBranch);
@@ -211,14 +271,20 @@ export async function runBranchPlan(opts) {
211
271
  const summary = summaryFor(node, "done", commit ? undefined : "no_changes");
212
272
  summary.commit = commit;
213
273
  if (commit && createsReviewForNode) {
274
+ workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
275
+ const pushOperation = `${opts.runId}:push:${node.branch}`;
276
+ planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
214
277
  const push = reviewProvider === "gitlab"
215
278
  ? pushBranchForMr(worktreePath, node.branch)
216
279
  : pushBranchForPr(worktreePath, node.branch);
217
280
  if (push.ok) {
281
+ confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
218
282
  opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
219
283
  summary.pushStatus = "pushed";
284
+ workflowCheckpoint(opts.projectDir, opts.runId, "push-after", node.ticket.id, { branch: node.branch, sha: commit });
220
285
  }
221
286
  else {
287
+ failJournal(opts.projectDir, pushOperation, push.message, push.code === "network_or_timeout");
222
288
  summary.pushStatus = "failed";
223
289
  summary.pr = {
224
290
  status: "skipped",
@@ -244,6 +310,9 @@ export async function runBranchPlan(opts) {
244
310
  continue;
245
311
  }
246
312
  pushedBranches.add(node.branch);
313
+ workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
314
+ const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
315
+ planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
247
316
  const pr = reviewProvider === "gitlab"
248
317
  ? createOrReuseMr(opts.projectDir, {
249
318
  node,
@@ -263,6 +332,12 @@ export async function runBranchPlan(opts) {
263
332
  commit,
264
333
  });
265
334
  summary.pr = pr;
335
+ if (pr.status === "created" || pr.status === "existing") {
336
+ confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
337
+ workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-after", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch, url: pr.url });
338
+ }
339
+ else if (pr.status === "failed")
340
+ failJournal(opts.projectDir, reviewOperation, pr.message ?? pr.error ?? "review creation failed", pr.code === "network_or_timeout");
266
341
  if (pr.status === "created")
267
342
  opts.log.write(reviewProvider === "gitlab" ? "mr-created" : "pr-created", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
268
343
  if (pr.status === "existing")
@@ -314,13 +389,18 @@ export async function runBranchPlan(opts) {
314
389
  if (opts.cleanupBranches ?? true)
315
390
  deleteLocalBranch(opts.projectDir, node.branch);
316
391
  }
392
+ const completionOperation = `${opts.runId}:ticket-complete:${node.ticket.id}`;
393
+ workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-before", node.ticket.id, { validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable" });
394
+ planJournal(opts.projectDir, opts.runId, completionOperation, "ticket-complete", { ticket: node.ticket.id });
317
395
  cmdComplete(opts.projectDir, node.ticket.id, {
318
396
  actor: "foreman",
319
397
  summary: status.summary ?? `Completed ${node.ticket.id}`,
320
- validationResult: opts.qaEnabled ? "passed" : "not_applicable",
321
- validationNotes: opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
322
- evidence: opts.qaEnabled ? (qaSummary ?? "Foreman QA emitted qa_pass") : undefined,
398
+ validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable",
399
+ validationNotes: qaWaived ? "User explicitly waived unresolved QA failures" : opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
400
+ evidence: opts.qaEnabled ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass")) : undefined,
323
401
  });
402
+ confirmJournal(opts.projectDir, completionOperation, node.ticket.id, { status: "done" });
403
+ workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-after", node.ticket.id, { status: "done" });
324
404
  successfulBranches.add(node.ticket.id);
325
405
  summaries.push(summary);
326
406
  opts.log.write("branch-complete", {
@@ -344,7 +424,7 @@ export async function runBranchPlan(opts) {
344
424
  : "builder_error";
345
425
  opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
346
426
  notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
347
- cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" });
427
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
348
428
  summaries.push(summaryFor(node, "blocked", message));
349
429
  }
350
430
  finally {
@@ -354,6 +434,59 @@ export async function runBranchPlan(opts) {
354
434
  }
355
435
  return summaries;
356
436
  }
437
+ function planJournal(projectDir, runId, key, kind, intent) {
438
+ const db = new WorkflowDb(projectDir);
439
+ try {
440
+ if (!db.getRun(runId))
441
+ db.createRun({ runId, kind: "build", originalWork: {}, state: {} });
442
+ db.planOperation({ runId, idempotencyKey: key, kind, intent });
443
+ db.updateOperation(key, "in_progress");
444
+ }
445
+ finally {
446
+ db.close();
447
+ }
448
+ }
449
+ function confirmJournal(projectDir, key, externalId, result) {
450
+ const db = new WorkflowDb(projectDir);
451
+ try {
452
+ db.updateOperation(key, "confirmed", { externalId, result });
453
+ }
454
+ finally {
455
+ db.close();
456
+ }
457
+ }
458
+ function failJournal(projectDir, key, error, uncertain) {
459
+ const db = new WorkflowDb(projectDir);
460
+ try {
461
+ db.updateOperation(key, uncertain ? "uncertain" : "failed", { error: error instanceof Error ? error.message : String(error) });
462
+ }
463
+ finally {
464
+ db.close();
465
+ }
466
+ }
467
+ function journalTracker(projectDir, runId, key, intent, action) {
468
+ planJournal(projectDir, runId, key, "tracker-update", intent);
469
+ try {
470
+ action();
471
+ confirmJournal(projectDir, key, undefined, intent);
472
+ }
473
+ catch (error) {
474
+ failJournal(projectDir, key, error, false);
475
+ throw error;
476
+ }
477
+ }
478
+ function workflowCheckpoint(projectDir, runId, checkpoint, ticket, payload) {
479
+ const db = new WorkflowDb(projectDir);
480
+ try {
481
+ const run = db.getRun(runId);
482
+ if (!run)
483
+ return;
484
+ db.transition(runId, { checkpoint, state: { ...run.state, currentTicket: ticket, ...payload }, event: checkpoint, payload: { ticket, ...payload } });
485
+ }
486
+ finally {
487
+ db.close();
488
+ }
489
+ }
357
490
  async function waitForAutoMergeDependencies(opts, node, provider) {
358
491
  const dependencyNodes = node.dependencies
359
492
  .map((dep) => opts.plan.nodes.find((candidate) => candidate.ticket.id === dep))
@@ -435,7 +568,7 @@ function blockBranchIssue(opts, node, summary, code, message, details) {
435
568
  output: details?.output,
436
569
  });
437
570
  notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
438
- cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" });
571
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:remote-block`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
439
572
  }
440
573
  function failureLogFields(node, pr) {
441
574
  return {
@@ -12,11 +12,17 @@ export interface CreateBuildRunInput {
12
12
  branch?: string;
13
13
  baseHead?: string;
14
14
  startHead?: string;
15
- builder?: ResolvedAgentSettings;
16
- qa?: ResolvedAgentSettings;
15
+ builder?: LegacyResolvedAgentSettings;
16
+ qa?: LegacyResolvedAgentSettings;
17
17
  now?: Date;
18
18
  }
19
19
  export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV1;
20
+ export declare function resumeBuildRun(projectDir: string, runId: string, patch: {
21
+ builder?: LegacyResolvedAgentSettings;
22
+ qa?: LegacyResolvedAgentSettings;
23
+ builderSessionId?: string | null;
24
+ }, now?: Date): BuildRunRecordV1;
25
+ type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision">>;
20
26
  export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
21
27
  export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV1, checkpoint: string, patch?: Partial<BuildRunRecordV1>): BuildRunRecordV1;
22
28
  export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV1, role: "builder" | "qa", sessionId: string): BuildRunRecordV1;
@@ -33,3 +39,4 @@ export declare function recoverableBuildRuns(projectDir: string, now?: Date): Ar
33
39
  }>;
34
40
  export declare function isLeaseActive(run: BuildRunRecordV1, now?: Date): boolean;
35
41
  export declare function buildRecoveryPreview(run: BuildRunRecordV1): string[];
42
+ export {};
package/dist/buildRuns.js CHANGED
@@ -2,6 +2,7 @@ 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";
5
+ import { WorkflowDb } from "./workflowDb.js";
5
6
  export const BUILD_RUN_VERSION = 1;
6
7
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
7
8
  export const BUILD_HEARTBEAT_MS = 10_000;
@@ -18,8 +19,8 @@ export function createBuildRun(input) {
18
19
  branchMode: input.branchMode ?? "current",
19
20
  checkpoint: "created",
20
21
  currentTicket: input.tickets[0],
21
- builder: input.builder ? { settings: input.builder } : undefined,
22
- qa: input.qa ? { settings: input.qa } : undefined,
22
+ builder: input.builder ? { settings: normalizeCapturedSettings(input.builder) } : undefined,
23
+ qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
23
24
  repository: {
24
25
  root: resolve(input.repositoryRoot),
25
26
  worktree: resolve(input.worktree ?? input.repositoryRoot),
@@ -33,7 +34,38 @@ export function createBuildRun(input) {
33
34
  createdAt: stamp,
34
35
  updatedAt: stamp,
35
36
  };
36
- return saveBuildRun(input.repositoryRoot, run, now);
37
+ const saved = saveBuildRun(input.repositoryRoot, run, now);
38
+ const workflow = new WorkflowDb(input.repositoryRoot);
39
+ try {
40
+ workflow.acquireLease(saved.runId, undefined, now, BUILD_LEASE_STALE_MS);
41
+ }
42
+ finally {
43
+ workflow.close();
44
+ }
45
+ return saved;
46
+ }
47
+ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
48
+ const existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
49
+ if (!existing)
50
+ throw new Error(`recoverable build run not found: ${runId}`);
51
+ if (existing.status === "completed")
52
+ throw new Error(`build run ${runId} is already complete`);
53
+ const workflow = new WorkflowDb(projectDir);
54
+ try {
55
+ workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
56
+ }
57
+ finally {
58
+ workflow.close();
59
+ }
60
+ return saveBuildRun(projectDir, {
61
+ ...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
62
+ builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(patch.builderSessionId === null ? {} : { sessionId: patch.builderSessionId ?? existing.builder?.sessionId }) } : existing.builder,
63
+ qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
64
+ lease: currentLease(now),
65
+ }, now);
66
+ }
67
+ function normalizeCapturedSettings(settings) {
68
+ return { ...settings, session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"), settings_revision: settings.settings_revision ?? 0 };
37
69
  }
38
70
  export function saveBuildRun(projectDir, run, now = new Date()) {
39
71
  validateBuildRun(run);
@@ -44,6 +76,28 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
44
76
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
45
77
  writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
46
78
  renameSync(temp, target);
79
+ const workflow = new WorkflowDb(projectDir);
80
+ try {
81
+ const existing = workflow.getRun(next.runId);
82
+ if (!existing)
83
+ workflow.createRun({
84
+ runId: next.runId, kind: "build", checkpoint: next.checkpoint,
85
+ originalWork: { tickets: next.tickets, deliveryUnit: next.deliveryUnit, branchMode: next.branchMode },
86
+ remainingWork: { tickets: remainingTickets(next) }, state: next,
87
+ }, now);
88
+ else
89
+ workflow.transition(next.runId, {
90
+ status: workflowStatus(next.status), checkpoint: next.checkpoint,
91
+ remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
92
+ }, now);
93
+ if (next.builder?.sessionId)
94
+ workflow.recordSession(next.runId, "builder", "builder", next.builder.sessionId, "checkpoint", next.builder.settings, now);
95
+ if (next.qa?.sessionId)
96
+ workflow.recordSession(next.runId, "qa", "qa", next.qa.sessionId, "checkpoint", next.qa.settings, now);
97
+ }
98
+ finally {
99
+ workflow.close();
100
+ }
47
101
  return next;
48
102
  }
49
103
  export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
@@ -58,19 +112,42 @@ export function persistBuildSession(projectDir, run, role, sessionId) {
58
112
  export function recordBuildReceipt(projectDir, run, operationId, detail) {
59
113
  if (run.receipts[operationId])
60
114
  return run;
61
- return saveBuildRun(projectDir, {
115
+ const next = saveBuildRun(projectDir, {
62
116
  ...run,
63
117
  receipts: { ...run.receipts, [operationId]: { completedAt: new Date().toISOString(), ...detail } },
64
118
  });
119
+ const workflow = new WorkflowDb(projectDir);
120
+ try {
121
+ workflow.planOperation({ runId: run.runId, idempotencyKey: operationId, kind: operationId.split(":", 1)[0] ?? "operation", intent: { checkpoint: run.checkpoint } });
122
+ workflow.updateOperation(operationId, "confirmed", { externalId: detail?.externalId, result: { detail: detail?.detail } });
123
+ }
124
+ finally {
125
+ workflow.close();
126
+ }
127
+ return next;
65
128
  }
66
129
  export function heartbeatBuildRun(projectDir, run, now = new Date()) {
67
- return saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
130
+ const saved = saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
131
+ const workflow = new WorkflowDb(projectDir);
132
+ try {
133
+ const lease = workflow.currentLease();
134
+ if (lease?.runId === run.runId)
135
+ workflow.heartbeatLease(lease, now);
136
+ }
137
+ finally {
138
+ workflow.close();
139
+ }
140
+ return saved;
68
141
  }
69
142
  export function releaseBuildLease(projectDir, run, status = "interrupted") {
70
- return saveBuildRun(projectDir, { ...run, status, lease: undefined });
143
+ const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
144
+ releaseWorkflowLease(projectDir, run.runId);
145
+ return saved;
71
146
  }
72
147
  export function completeBuildRun(projectDir, run, now = new Date()) {
73
- return saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
148
+ const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
149
+ releaseWorkflowLease(projectDir, run.runId, now);
150
+ return saved;
74
151
  }
75
152
  export function readBuildRuns(projectDir) {
76
153
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
@@ -147,3 +224,29 @@ function validateBuildRun(run) {
147
224
  throw new Error("invalid build run record");
148
225
  }
149
226
  }
227
+ function workflowStatus(status) {
228
+ if (status === "completed")
229
+ return "completed";
230
+ if (status === "failed")
231
+ return "failed";
232
+ if (status === "blocked")
233
+ return "blocked";
234
+ if (status === "interrupted" || status === "recoverable")
235
+ return "paused";
236
+ return "running";
237
+ }
238
+ function remainingTickets(run) {
239
+ const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
240
+ return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
241
+ }
242
+ function releaseWorkflowLease(projectDir, runId, now = new Date()) {
243
+ const workflow = new WorkflowDb(projectDir);
244
+ try {
245
+ const lease = workflow.currentLease();
246
+ if (lease?.runId === runId)
247
+ workflow.releaseLease(lease, now);
248
+ }
249
+ finally {
250
+ workflow.close();
251
+ }
252
+ }
@@ -1,4 +1,8 @@
1
1
  import { Command } from "commander";
2
+ import type { BranchPlan } from "../branch/types.js";
3
+ import { type DeliveryConfig, type DeliveryStack } from "../tickets/delivery.js";
2
4
  export declare function formatStartResumeCommand(executable: "ai-foreman", projectDir: string, steps: number, sessionId: string): string;
3
5
  export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman", projectDir: string, steps: number, sessionId?: string): string[];
4
6
  export declare function buildStartCommand(): Command;
7
+ /** Make the executable branch plan exactly match each explicit root-to-tip stack. */
8
+ export declare function applyExplicitStackTopology(plan: BranchPlan, stacks: DeliveryStack[], delivery: DeliveryConfig, branchPrefix: string): BranchPlan;