ai-foreman 1.7.18 → 1.7.19

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 (83) hide show
  1. package/README.md +18 -7
  2. package/dist/activity.d.ts +11 -1
  3. package/dist/activity.js +70 -15
  4. package/dist/adapters/claude.d.ts +11 -1
  5. package/dist/adapters/claude.js +122 -18
  6. package/dist/adapters/codex.d.ts +13 -1
  7. package/dist/adapters/codex.js +148 -31
  8. package/dist/adapters/recovering.d.ts +3 -1
  9. package/dist/adapters/recovering.js +12 -4
  10. package/dist/adapters/types.d.ts +27 -1
  11. package/dist/agentRun.js +2 -0
  12. package/dist/branch/currentGuard.d.ts +3 -1
  13. package/dist/branch/currentGuard.js +11 -2
  14. package/dist/branch/resume.js +1 -1
  15. package/dist/branch/runner.d.ts +2 -0
  16. package/dist/branch/runner.js +90 -11
  17. package/dist/buildApproval.d.ts +9 -0
  18. package/dist/buildApproval.js +126 -0
  19. package/dist/buildRuns.js +70 -32
  20. package/dist/cli/recovery.js +9 -13
  21. package/dist/cli/runtimeAuthPrompt.d.ts +7 -0
  22. package/dist/cli/runtimeAuthPrompt.js +40 -5
  23. package/dist/cli/start.d.ts +16 -1
  24. package/dist/cli/start.js +159 -79
  25. package/dist/cli/tickets.d.ts +11 -0
  26. package/dist/cli/tickets.js +382 -145
  27. package/dist/continuity.d.ts +7 -1
  28. package/dist/continuity.js +86 -7
  29. package/dist/diagnostics.js +35 -1
  30. package/dist/foreman.d.ts +8 -1
  31. package/dist/foreman.js +144 -29
  32. package/dist/handoffs.js +121 -79
  33. package/dist/humanDecision.d.ts +18 -0
  34. package/dist/humanDecision.js +43 -0
  35. package/dist/index.js +2 -1
  36. package/dist/log.d.ts +1 -1
  37. package/dist/managerPacket.js +1 -0
  38. package/dist/observability.js +2 -1
  39. package/dist/providerQuestions.d.ts +5 -0
  40. package/dist/providerQuestions.js +24 -3
  41. package/dist/qaDeliveryJournal.d.ts +70 -0
  42. package/dist/qaDeliveryJournal.js +1 -0
  43. package/dist/qaFailureDelivery.d.ts +26 -10
  44. package/dist/qaFailureDelivery.js +399 -412
  45. package/dist/qaHandbackHistory.d.ts +4 -0
  46. package/dist/qaHandbackHistory.js +30 -0
  47. package/dist/qaHandbackMigration.d.ts +9 -0
  48. package/dist/qaHandbackMigration.js +147 -0
  49. package/dist/qaPrerequisites.d.ts +26 -0
  50. package/dist/qaPrerequisites.js +61 -0
  51. package/dist/qaProtocolV2.d.ts +8 -0
  52. package/dist/qaProtocolV2.js +3 -3
  53. package/dist/qaReview.d.ts +8 -0
  54. package/dist/qaReview.js +54 -10
  55. package/dist/questionTrace.d.ts +31 -0
  56. package/dist/questionTrace.js +85 -0
  57. package/dist/readOnlyProposal.d.ts +8 -0
  58. package/dist/readOnlyProposal.js +41 -0
  59. package/dist/recoveryPolicy.js +14 -3
  60. package/dist/runtimeAuth.d.ts +2 -1
  61. package/dist/runtimeAuth.js +2 -2
  62. package/dist/runtimeReadiness.d.ts +13 -1
  63. package/dist/runtimeReadiness.js +69 -11
  64. package/dist/sessionLifecycle.d.ts +1 -0
  65. package/dist/sessionLifecycle.js +53 -3
  66. package/dist/sources/sourceRegistry.d.ts +7 -0
  67. package/dist/sources/sourceRegistry.js +14 -0
  68. package/dist/stateTransfer.js +2 -0
  69. package/dist/statusReporter.d.ts +3 -1
  70. package/dist/statusReporter.js +8 -2
  71. package/dist/supervisedStart.d.ts +12 -0
  72. package/dist/supervisedStart.js +276 -0
  73. package/dist/supervisor.js +44 -22
  74. package/dist/ticketPopulation.js +1 -0
  75. package/dist/tickets/setupConfig.d.ts +8 -0
  76. package/dist/tickets/setupConfig.js +38 -4
  77. package/dist/util/deadline.d.ts +13 -0
  78. package/dist/util/deadline.js +60 -0
  79. package/dist/workflowDb.d.ts +55 -11
  80. package/dist/workflowDb.js +288 -63
  81. package/dist/workflowReader.d.ts +10 -2
  82. package/dist/workflowReader.js +35 -1
  83. package/package.json +3 -3
@@ -1,3 +1,5 @@
1
+ import { WorkflowReader } from "../workflowReader.js";
2
+ import { buildScopeRevision } from "../buildApproval.js";
1
3
  import { beginQaFinalization, completeQaFinalization, verifyPendingQaFinalizationSource, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
4
  import { QaFailureDeliveryService } from "../qaFailureDelivery.js";
3
5
  import { Foreman, MARKER_SPEC } from "../foreman.js";
@@ -20,7 +22,20 @@ export function readDeliveryUnitSession(projectDir, unitId) {
20
22
  if (!existsSync(path))
21
23
  return undefined;
22
24
  try {
23
- return JSON.parse(readFileSync(path, "utf8"));
25
+ const cached = JSON.parse(readFileSync(path, "utf8"));
26
+ const reader = new WorkflowReader(projectDir);
27
+ try {
28
+ const known = reader.branchResumeSessions(false);
29
+ const active = reader.branchResumeSessions().filter(row => row.deliveryUnitId === unitId && row.worktreePath === cached.worktreePath && row.branch === cached.branch).at(-1);
30
+ if (active)
31
+ return { ...cached, sessionId: active.sessionId, sessionRef: active.sessionRef, ticket: active.ticket };
32
+ if (known.some(row => row.deliveryUnitId === unitId) || reader.buildRuns().length || (cached.runId && reader.getRun(cached.runId)))
33
+ return undefined;
34
+ return cached;
35
+ }
36
+ finally {
37
+ reader.close();
38
+ }
24
39
  }
25
40
  catch {
26
41
  return undefined;
@@ -108,7 +123,40 @@ export async function runBranchPlan(opts) {
108
123
  }
109
124
  if (opts.plan.issues.some((issue) => issue.blocking))
110
125
  return summaries;
111
- for (const node of orderNodes(opts.plan.nodes)) {
126
+ const pendingNodes = orderNodes(opts.plan.nodes);
127
+ while (true) {
128
+ const decisionDb = new WorkflowDb(opts.projectDir);
129
+ const answered = decisionDb.answeredTicketDecisions(opts.runId, buildScopeRevision(opts.projectDir));
130
+ decisionDb.close();
131
+ for (let index = summaries.length - 1; index >= 0; index--) {
132
+ const summary = summaries[index];
133
+ if (summary.buildStatus === "done")
134
+ continue;
135
+ const candidate = opts.plan.nodes.find(item => item.ticket.id === summary.ticket);
136
+ if (!candidate || pendingNodes.some(item => item.ticket.id === candidate.ticket.id))
137
+ continue;
138
+ const hasAnswer = answered.some(decision => decision.interruptionId === `ticket:${candidate.ticket.id}`);
139
+ const dependencyDeferred = summary.detail === "deferred because a dependency or shared delivery unit is blocked";
140
+ const dependenciesReady = candidate.dependencies.every(id => successfulBranches.has(id));
141
+ const sharedReady = !summaries.some(prior => prior.ticket !== candidate.ticket.id && prior.buildStatus !== "done" && prior.detail !== "deferred because a dependency or shared delivery unit is blocked" && opts.plan.nodes.some(item => item.ticket.id === prior.ticket && item.deliveryUnitId && item.deliveryUnitId === candidate.deliveryUnitId));
142
+ if ((hasAnswer || dependencyDeferred) && dependenciesReady && sharedReady) {
143
+ summaries.splice(index, 1);
144
+ pendingNodes.push(candidate);
145
+ }
146
+ }
147
+ const node = pendingNodes.shift();
148
+ if (!node)
149
+ break;
150
+ const continuations = answered.filter(decision => decision.interruptionId === `ticket:${node.ticket.id}`);
151
+ const continuationSuffix = continuations.length ? `:answers:${continuations.map(decision => decision.decisionId).join(",")}` : "";
152
+ const blocked = summaries.filter(summary => summary.buildStatus !== "done");
153
+ if (blocked.length && opts.continueIndependentTickets === false)
154
+ break;
155
+ if (blocked.some(summary => node.dependencies.includes(summary.ticket)
156
+ || opts.plan.nodes.some(prior => prior.ticket.id === summary.ticket && prior.deliveryUnitId && prior.deliveryUnitId === node.deliveryUnitId))) {
157
+ summaries.push(summaryFor(node, "blocked", "deferred because a dependency or shared delivery unit is blocked"));
158
+ continue;
159
+ }
112
160
  const protocolDb = new WorkflowDb(opts.projectDir);
113
161
  const protocolHead = protocolDb.qaTicketHead(opts.runId, node.ticket.id);
114
162
  const finalizationRecovery = protocolHead.state === "finalizing";
@@ -229,7 +277,10 @@ export async function runBranchPlan(opts) {
229
277
  dependencies: node.dependencies,
230
278
  });
231
279
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
232
- const resumeSession = opts.resumeSessions?.get(node.ticket.id);
280
+ const continuationDb = new WorkflowDb(opts.projectDir);
281
+ const continuationSession = continuations.length ? continuationDb.branchResumeSession(opts.runId, node.ticket.id) : undefined;
282
+ continuationDb.close();
283
+ const resumeSession = continuationSession ?? opts.resumeSessions?.get(node.ticket.id);
233
284
  const worktreePath = resumeSession?.worktreePath
234
285
  ?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
235
286
  ?? await observeNode(opts, node, "git", "creating ticket worktree", () => createTicketWorktree(opts.projectDir, opts.runId, node.branch, node.baseBranch));
@@ -248,7 +299,7 @@ export async function runBranchPlan(opts) {
248
299
  });
249
300
  }
250
301
  if (!qaOnlyRecovery)
251
- journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
302
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress${continuationSuffix}`, { ticket: node.ticket.id, status: "in_progress" }, () => {
252
303
  if (resumeSession)
253
304
  cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
254
305
  cmdUpdate(opts.projectDir, node.ticket.id, {
@@ -256,7 +307,9 @@ export async function runBranchPlan(opts) {
256
307
  summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
257
308
  });
258
309
  });
259
- const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
310
+ let ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
311
+ if (continuations.length)
312
+ ticketInstruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decision.answer ?? decision.selectedChoiceId}`).join("\n");
260
313
  // Reattach the predecessor even for a `fresh` strategy so the host can
261
314
  // publish and validate a cumulative handoff before creating its successor.
262
315
  const sameWorktreeStream = builderStream && canonical(builderStream.worktreePath) === canonical(worktreePath) ? builderStream : undefined;
@@ -283,8 +336,26 @@ export async function runBranchPlan(opts) {
283
336
  if (!qaOnlyRecovery)
284
337
  builderWorkSessions += 1;
285
338
  viewer = opts.observeBuilder?.(builder);
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));
287
- const turn = () => foreman.runInstruction(ticketInstruction);
339
+ const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, opts.projectDir, 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), opts.observer, undefined, false, undefined, undefined, opts.runId, opts.continueIndependentTickets ?? true, node.ticket.id);
340
+ const turn = async () => {
341
+ const db = new WorkflowDb(opts.projectDir);
342
+ try {
343
+ db.atomic(() => {
344
+ for (const decision of continuations) {
345
+ const key = `decision-continuation:${decision.decisionId}`;
346
+ db.planOperation({ runId: opts.runId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: node.ticket.id } });
347
+ db.updateOperation(key, "in_progress");
348
+ }
349
+ });
350
+ const response = await foreman.runInstruction(ticketInstruction);
351
+ for (const decision of continuations)
352
+ db.updateOperation(`decision-continuation:${decision.decisionId}`, response.result.failure?.dispatchState === "unknown" ? "uncertain" : "confirmed", { result: { isError: response.result.isError } });
353
+ return response;
354
+ }
355
+ finally {
356
+ db.close();
357
+ }
358
+ };
288
359
  const { result, status } = qaOnlyRecovery
289
360
  ? { 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
361
  : opts.observer
@@ -334,7 +405,7 @@ export async function runBranchPlan(opts) {
334
405
  if (node.deliveryUnitId) {
335
406
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
336
407
  mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
337
- writeFileSync(path, `${JSON.stringify({ unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
408
+ writeFileSync(path, `${JSON.stringify({ runId: opts.runId, unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
338
409
  }
339
410
  }
340
411
  opts.log.write("step", {
@@ -353,7 +424,7 @@ export async function runBranchPlan(opts) {
353
424
  }
354
425
  if (status.kind !== "done" && status.kind !== "plan_complete") {
355
426
  const detail = status.reason ?? status.error ?? `builder emitted ${status.kind}`;
356
- 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" }));
427
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:builder-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
357
428
  summaries.push(summaryFor(node, status.kind === "blocked" ? "blocked" : "needs-human", detail));
358
429
  continue;
359
430
  }
@@ -408,10 +479,18 @@ export async function runBranchPlan(opts) {
408
479
  const sessionRef = typeof session === "string" ? undefined : session;
409
480
  const sessionId = typeof session === "string" ? session : session.sessionId;
410
481
  builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
482
+ const resumeDb = new WorkflowDb(opts.projectDir);
483
+ try {
484
+ resumeDb.recordBranchResumeSession(opts.runId, { ticket: node.ticket.id, branch: node.branch, base: node.baseBranch, worktreePath, sessionId, sessionRef, logPath: "structured-recovery", deliveryUnitId: node.deliveryUnitId });
485
+ }
486
+ finally {
487
+ resumeDb.close();
488
+ }
411
489
  opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
412
490
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
413
491
  },
414
492
  });
493
+ opts.log.write("qa-fix", { ticket: node.ticket.id, outcome: result.outcome, operationId: result.operationId, turnRecordId: result.turnRecordId, detail: result.detail });
415
494
  return result;
416
495
  },
417
496
  onNonconvergence: opts.qaNonconvergence,
@@ -431,7 +510,7 @@ export async function runBranchPlan(opts) {
431
510
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
432
511
  if (qa.outcome !== "passed" && qa.outcome !== "waived") {
433
512
  const detail = qa.detail ?? "QA did not pass";
434
- 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" }));
513
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:qa-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
435
514
  summaries.push(summaryFor(node, qa.outcome === "blocked" ? "blocked" : "needs-human", detail));
436
515
  continue;
437
516
  }
@@ -696,7 +775,7 @@ export async function runBranchPlan(opts) {
696
775
  : "builder_error";
697
776
  opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
698
777
  notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
699
- 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" }));
778
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
700
779
  summaries.push(summaryFor(node, "blocked", message));
701
780
  }
702
781
  finally {
@@ -0,0 +1,9 @@
1
+ /** Reuse only source-bound approval. Ambiguous legacy provenance still asks. */
2
+ export declare function approvedBuildScope(projectDir: string, ticketIds?: readonly string[]): {
3
+ approved: boolean;
4
+ reason: string;
5
+ digest?: string;
6
+ };
7
+ export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
8
+ /** Bind decisions to current files so an old answer cannot approve a changed scope. */
9
+ export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
@@ -0,0 +1,126 @@
1
+ import { loadDeliveryConfig, normalizeDeliveryConfig } from "./tickets/delivery.js";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parse } from "yaml";
6
+ import { WorkflowDb } from "./workflowDb.js";
7
+ import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
8
+ import { loadTickets } from "./tickets/ticketLoader.js";
9
+ import { StateDb } from "./tickets/stateDb.js";
10
+ function canonical(value) {
11
+ const normalize = (entry) => Array.isArray(entry) ? entry.map(normalize) : entry && typeof entry === "object"
12
+ ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)).map(([key, val]) => [key, normalize(val)])) : entry;
13
+ return JSON.stringify(normalize(value));
14
+ }
15
+ function material(ticket) {
16
+ return { id: ticket.id, plan_ref: ticket.plan_ref, title: ticket.title, summary: ticket.summary, acceptance: ticket.acceptance,
17
+ required_tests: ticket.required_tests, depends_on: ticket.depends_on, likely_files: ticket.likely_files, rollback: ticket.rollback, source_refs: ticket.source_refs };
18
+ }
19
+ /** Reuse only source-bound approval. Ambiguous legacy provenance still asks. */
20
+ export function approvedBuildScope(projectDir, ticketIds) {
21
+ try {
22
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
23
+ const all = loadTickets(paths.tickets);
24
+ const state = new StateDb(paths.stateDb);
25
+ let tickets;
26
+ try {
27
+ tickets = ticketIds ? all.filter(ticket => ticketIds.includes(ticket.id)) : all.filter(ticket => !["done", "obsolete", "cancelled", "canceled"].includes(state.getState(ticket.id)?.status ?? "planned"));
28
+ }
29
+ finally {
30
+ state.close();
31
+ }
32
+ if (!tickets.length || (ticketIds && tickets.length !== new Set(ticketIds).size) || tickets.some(ticket => !ticket.plan_ref))
33
+ return { approved: false, reason: "selected tickets lack approved plan provenance" };
34
+ const db = new WorkflowDb(projectDir);
35
+ try {
36
+ const approvals = db.completedPlanApprovals();
37
+ const definitions = db.approvedPopulationDefinitions();
38
+ for (const ticket of tickets) {
39
+ const ref = ticket.plan_ref;
40
+ const receipt = approvals.find(item => item.planId === ref.plan_id && item.revision === ref.revision);
41
+ if (!receipt)
42
+ return { approved: false, reason: `no stored approval for ${ticket.id}` };
43
+ const config = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"));
44
+ const plan = JSON.parse(readFileSync(join(projectDir, config.docs?.root ?? "docs", "rafi-plan.json"), "utf8"));
45
+ const digest = createHash("sha256").update(canonical({ ...plan, content_digest: "" })).digest("hex");
46
+ if (plan.plan_id !== ref.plan_id || plan.revision !== ref.revision || digest !== plan.content_digest || digest !== receipt.digest)
47
+ return { approved: false, reason: "approved plan revision or digest changed" };
48
+ const approved = definitions.find(item => item.id === ticket.id && canonical(item.plan_ref) === canonical(ref));
49
+ // Older populations have no immutable definition receipt. Only an exact
50
+ // match to the digested approved slice is safe to reuse automatically.
51
+ const slice = plan.slices?.find((item) => item.slice_ref === ref.slice_ref);
52
+ const ids = new Map(all.filter(item => item.plan_ref?.plan_id === ref.plan_id && item.plan_ref.revision === ref.revision).map(item => [item.plan_ref.slice_ref, item.id]));
53
+ const legacy = slice && !ticket.rollback ? { ...ticket, title: slice.title, summary: slice.summary,
54
+ acceptance: slice.acceptance, required_tests: slice.required_tests, likely_files: slice.likely_files,
55
+ depends_on: (slice.depends_on ?? []).map((id) => ids.get(id)),
56
+ source_refs: slice.source_refs?.map((source) => ({ source: source.source_id, item: source.item ?? "document", source_id: source.source_id, fingerprint: source.fingerprint, ...(source.url !== undefined ? { url: source.url } : {}), ...(source.note !== undefined ? { note: source.note } : {}) })) } : undefined;
57
+ if (canonical(material(approved ?? legacy ?? {})) !== canonical(material(ticket)))
58
+ return { approved: false, reason: `material ticket changes or missing immutable approval for ${ticket.id}` };
59
+ const actualDelivery = loadDeliveryConfig(projectDir);
60
+ if (actualDelivery?.units.length && !Array.isArray(plan.delivery_units))
61
+ return { approved: false, reason: "delivery configuration has no approved provenance" };
62
+ if (Array.isArray(plan.delivery_units) && (plan.delivery_units.length || actualDelivery?.units.length)) {
63
+ const ids = new Map(all.filter(item => item.plan_ref?.plan_id === ref.plan_id).map(item => [item.plan_ref.slice_ref, item.id]));
64
+ const expected = normalizeDeliveryConfig({ version: 1, plan: { plan_id: plan.plan_id, revision: plan.revision },
65
+ units: plan.delivery_units.map((unit) => ({ ...unit, tickets: unit.slice_refs.map(slice => ids.get(slice)) })),
66
+ stacks: (plan.stacks ?? []).map((stack) => ({ id: stack.stack_id, name: stack.name, units: stack.units, status: "planned" })) });
67
+ const actual = loadDeliveryConfig(projectDir);
68
+ if (!actual || canonical(actual.units) !== canonical(expected.units))
69
+ return { approved: false, reason: "approved delivery consequences changed" };
70
+ }
71
+ const decisions = receipt.decisionReceipt;
72
+ if (!decisions || decisions.planDigest !== digest || decisions.workMode !== config.tickets?.build?.branch_strategy || decisions.branchPrefix !== config.tickets?.build?.branch_prefix)
73
+ return { approved: false, reason: "approved workflow consequences changed" };
74
+ }
75
+ return { approved: true, reason: "unchanged approved plan and ticket definitions", digest: createHash("sha256").update(canonical(tickets.map(material))).digest("hex") };
76
+ }
77
+ finally {
78
+ db.close();
79
+ }
80
+ }
81
+ catch {
82
+ return { approved: false, reason: "approval evidence unavailable" };
83
+ }
84
+ }
85
+ export function requiresBuildApproval(projectDir, ticketIds, explicitYes, consequences) {
86
+ const scope = approvedBuildScope(projectDir, ticketIds);
87
+ let changedConsequences = false;
88
+ if (consequences) {
89
+ try {
90
+ const saved = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"))?.tickets?.build ?? {};
91
+ changedConsequences = Object.entries(consequences).some(([key, value]) => value !== undefined && canonical(value) !== canonical(saved[key] ?? { completion: "none", branch_strategy: "current", branch_prefix: "feature", merge_method: "squash", pr_ready: false }[key]));
92
+ }
93
+ catch {
94
+ changedConsequences = true;
95
+ }
96
+ }
97
+ if (scope.approved && !changedConsequences)
98
+ return false;
99
+ if (!explicitYes)
100
+ return true;
101
+ const db = new WorkflowDb(projectDir);
102
+ try {
103
+ return db.completedPlanApprovals().length > 0;
104
+ }
105
+ finally {
106
+ db.close();
107
+ }
108
+ }
109
+ /** Bind decisions to current files so an old answer cannot approve a changed scope. */
110
+ export function buildScopeRevision(projectDir, consequences) {
111
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
112
+ let docsRoot = "docs";
113
+ try {
114
+ docsRoot = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"))?.docs?.root ?? docsRoot;
115
+ }
116
+ catch { /* unavailable config changes the fingerprint below */ }
117
+ const contents = [join(projectDir, docsRoot, "rafi-plan.json"), paths.tickets, join(projectDir, "rafi-config.yaml"), join(projectDir, ".tickets/delivery.yaml")].map(path => {
118
+ try {
119
+ return readFileSync(path, "utf8");
120
+ }
121
+ catch {
122
+ return "unavailable";
123
+ }
124
+ });
125
+ return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
126
+ }
package/dist/buildRuns.js CHANGED
@@ -52,7 +52,7 @@ export function createBuildRun(input) {
52
52
  phase: "created",
53
53
  qaEnabled: input.qaEnabled ?? Boolean(input.qa),
54
54
  recoveryAttempts: [],
55
- supervisor: { status: frozenPolicy.supervisorEnabled ? "starting" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
55
+ supervisor: { status: frozenPolicy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
56
56
  pendingDecisions: [],
57
57
  deferredTickets: [],
58
58
  runDecisions: input.runDecisions,
@@ -75,8 +75,8 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
75
75
  let existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
76
76
  if (!existing)
77
77
  throw new Error(`recoverable build run not found: ${runId}`);
78
- if (existing.status === "completed")
79
- throw new Error(`build run ${runId} is already complete`);
78
+ if (existing.status === "completed" || existing.status === "superseded")
79
+ throw new Error(`build run ${runId} is ${existing.status} and cannot resume`);
80
80
  const workflow = new WorkflowDb(projectDir);
81
81
  try {
82
82
  const held = workflow.currentLease();
@@ -96,7 +96,7 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
96
96
  }
97
97
  }
98
98
  existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
99
- if (!existing || existing.status === "completed") {
99
+ if (!existing || existing.status === "completed" || existing.status === "superseded") {
100
100
  const lease = workflow.currentLease();
101
101
  if (lease?.runId === runId && lease.pid === process.pid)
102
102
  workflow.releaseLease(lease, now);
@@ -140,33 +140,50 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
140
140
  updatedAt: now.toISOString(),
141
141
  };
142
142
  const workflow = new WorkflowDb(projectDir);
143
+ const target = join(directory, `${run.runId}.json`);
144
+ let publicationId;
143
145
  try {
144
- const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
145
- next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
146
- const existing = workflow.getRun(next.runId);
147
- if (!existing)
148
- workflow.createRun({
149
- runId: next.runId, kind: "build", checkpoint: next.checkpoint,
150
- originalWork: { tickets: next.tickets, deliveryUnit: next.deliveryUnit, branchMode: next.branchMode },
151
- remainingWork: { tickets: remainingTickets(next) }, state: next,
152
- }, now);
153
- else
154
- workflow.transition(next.runId, {
155
- status: workflowStatus(next.status), checkpoint: next.checkpoint,
156
- remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
157
- }, now);
158
- if (next.builder?.sessionId)
159
- workflow.recordSession(next.runId, "builder", "builder", latestSessionBinding(next.sessionBindings, "builder", next.builder.sessionId) ?? next.builder.sessionId, "checkpoint", next.builder.settings, now);
160
- if (next.qa?.sessionId)
161
- workflow.recordSession(next.runId, "qa", "qa", latestSessionBinding(next.sessionBindings, "qa", next.qa.sessionId) ?? next.qa.sessionId, "checkpoint", next.qa.settings, now);
146
+ workflow.atomic(() => {
147
+ const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
148
+ next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
149
+ const existing = workflow.getRun(next.runId);
150
+ if (existing?.status === "superseded" && next.status !== "superseded")
151
+ throw new Error("cannot rewrite a superseded build run as active");
152
+ if (!existing)
153
+ workflow.createRun({
154
+ runId: next.runId, kind: "build", checkpoint: next.checkpoint,
155
+ originalWork: { tickets: next.tickets, deliveryUnit: next.deliveryUnit, branchMode: next.branchMode },
156
+ remainingWork: { tickets: remainingTickets(next) }, state: next,
157
+ }, now);
158
+ else
159
+ workflow.transition(next.runId, {
160
+ status: workflowStatus(next.status), checkpoint: next.checkpoint,
161
+ remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
162
+ }, now);
163
+ if (next.builder?.sessionId)
164
+ workflow.recordSession(next.runId, "builder", "builder", latestSessionBinding(next.sessionBindings, "builder", next.builder.sessionId) ?? next.builder.sessionId, "checkpoint", next.builder.settings, now);
165
+ if (next.qa?.sessionId)
166
+ workflow.recordSession(next.runId, "qa", "qa", latestSessionBinding(next.sessionBindings, "qa", next.qa.sessionId) ?? next.qa.sessionId, "checkpoint", next.qa.settings, now);
167
+ publicationId = workflow.beginPublication(next.runId, { operation: "build-run-projection", target,
168
+ revision: createHash("sha256").update(JSON.stringify(next)).digest("hex"), updatedAt: next.updatedAt }, {}, now).transactionId;
169
+ });
170
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
171
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
172
+ renameSync(temp, target);
173
+ workflow.atomic(() => {
174
+ workflow.updatePublication(publicationId, "committed", now);
175
+ // Publishing the latest authoritative revision also reconciles any older
176
+ // interrupted publication of this same projection; never write old bytes.
177
+ for (const pending of workflow.incompletePublications()) {
178
+ const intent = pending.intent;
179
+ if (pending.runId === next.runId && intent.operation === "build-run-projection" && intent.target === target)
180
+ workflow.updatePublication(pending.transactionId, "committed", now);
181
+ }
182
+ });
162
183
  }
163
184
  finally {
164
185
  workflow.close();
165
186
  }
166
- const target = join(directory, `${run.runId}.json`);
167
- const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
168
- writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
169
- renameSync(temp, target);
170
187
  return next;
171
188
  }
172
189
  export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
@@ -251,16 +268,37 @@ export function completeBuildRun(projectDir, run, now = new Date()) {
251
268
  }
252
269
  export function readBuildRuns(projectDir) {
253
270
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
254
- if (!existsSync(directory))
255
- return [];
256
- const workflow = existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3")) ? new WorkflowDb(projectDir) : undefined;
257
- const runs = readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
271
+ const workflow = new WorkflowReader(projectDir);
272
+ const candidates = new Map();
273
+ if (existsSync(directory))
274
+ for (const name of readdirSync(directory).filter(name => name.endsWith(".json"))) {
275
+ try {
276
+ const value = JSON.parse(readFileSync(join(directory, name), "utf8"));
277
+ candidates.set(value.runId, value);
278
+ }
279
+ catch { /* authoritative DB state can repair an absent or partial projection */ }
280
+ }
281
+ for (const stored of workflow.buildRuns()) {
282
+ const value = stored.state;
283
+ if (value?.runId)
284
+ candidates.set(value.runId, value);
285
+ }
286
+ const runs = [...candidates.values()].flatMap((projection) => {
258
287
  try {
259
- const projection = JSON.parse(readFileSync(join(directory, name), "utf8"));
260
288
  validateBuildRun(projection);
261
289
  const authoritative = workflow?.getRun(projection.runId)?.state;
262
290
  const run = authoritative?.runId ? authoritative : projection;
263
- const upgraded = upgradeBuildRun(run, projectDir);
291
+ let upgraded = upgradeBuildRun(run, projectDir);
292
+ if (workflow?.getRun(run.runId)?.status === "superseded")
293
+ upgraded = { ...upgraded, status: "superseded" };
294
+ if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
295
+ for (const role of ["builder", "qa"]) {
296
+ const lease = workflow?.roleMutationLease(run.runId, role);
297
+ const ref = lease?.sessionRef;
298
+ if (ref && lease.generation > 0 && upgraded[role])
299
+ upgraded = { ...upgraded, [role]: { ...upgraded[role], sessionId: ref.sessionId }, sessionBindings: upsertSessionBinding(upgraded.sessionBindings, ref) };
300
+ }
301
+ }
264
302
  return [{ ...upgraded, frozenPolicy: workflow?.autonomyPolicy(run.runId) ?? upgraded.frozenPolicy, recoveryAttempts: workflow?.recoveryAttempts(run.runId) ?? upgraded.recoveryAttempts, pendingDecisions: workflow?.pendingHumanDecisions(run.runId) ?? upgraded.pendingDecisions, supervisor: workflow?.supervisorState(run.runId) ?? upgraded.supervisor }];
265
303
  }
266
304
  catch {
@@ -1,3 +1,4 @@
1
+ import { isLiveProcessIdentity } from "../processIdentity.js";
1
2
  import { Command } from "commander";
2
3
  import { resolve } from "node:path";
3
4
  import { readBuildRuns } from "../buildRuns.js";
@@ -35,10 +36,11 @@ export function buildDecideCommand() {
35
36
  .requiredOption("--run <id>", "build run ID")
36
37
  .requiredOption("--decision <id>", "decision ID")
37
38
  .requiredOption("--choice <id>", "stable choice ID")
39
+ .option("--answer <text>", "actual answer to a scoped QA handback question")
38
40
  .action((project, opts) => {
39
41
  const db = new WorkflowDb(resolve(project));
40
42
  try {
41
- const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice);
43
+ const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice, new Date(), opts.answer);
42
44
  console.log(`rafi: recorded ${decision.decisionId}=${opts.choice}`);
43
45
  }
44
46
  finally {
@@ -57,20 +59,14 @@ export function buildStopCommand() {
57
59
  const state = db.supervisorState(opts.run);
58
60
  if (!state)
59
61
  throw new Error(`supervisor state not found for run ${opts.run}`);
62
+ if (!state.pid || !state.processStart || !isLiveProcessIdentity(state.pid, state.processStart))
63
+ throw new Error("supervisor process identity cannot be verified; refusing to signal a potentially reused PID");
60
64
  const next = { ...state, status: "stopping", stopRequestedAt: new Date().toISOString() };
61
65
  db.putSupervisorState(opts.run, next);
62
- if (state.workerPid) {
63
- try {
64
- process.kill(state.workerPid, "SIGTERM");
65
- }
66
- catch { /* stale worker */ }
67
- }
68
- if (state.pid && state.pid !== process.pid) {
69
- try {
70
- process.kill(state.pid, "SIGTERM");
71
- }
72
- catch { /* stale supervisor */ }
73
- }
66
+ // The verified parent owns child cleanup; raw persisted worker PIDs may
67
+ // have been reused and must never be signalled independently.
68
+ if (state.pid !== process.pid)
69
+ process.kill(state.pid, "SIGTERM");
74
70
  console.log(`rafi: stop requested for ${opts.run}`);
75
71
  }
76
72
  finally {
@@ -7,6 +7,13 @@ export interface RuntimeCommandRecoveryContext {
7
7
  }
8
8
  export interface RuntimeReadyForCommandOptions {
9
9
  label: string;
10
+ timeoutMs?: number;
11
+ durable?: {
12
+ projectDir: string;
13
+ runId: string;
14
+ scopeRevision: string;
15
+ };
16
+ onTrace?: import("../runtimeReadiness.js").ProbeRuntimeOptions["onTrace"];
10
17
  yes?: boolean;
11
18
  allowSwitch?: boolean;
12
19
  model?: string | undefined;
@@ -4,11 +4,14 @@ import { checkRuntimeReady, RuntimeAuthError, runtimeCommandLabel, } from "../ru
4
4
  import { resolveExecutablePath } from "../runtimeReadiness.js";
5
5
  import { otherRuntime, runtimeDisplayName } from "./runtimeSelection.js";
6
6
  import { currentActivity } from "../activity.js";
7
+ import { durableHumanDecision } from "../humanDecision.js";
8
+ import { WorkflowDb } from "../workflowDb.js";
9
+ import { createHash } from "node:crypto";
7
10
  export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrOptions) {
8
11
  const opts = typeof labelOrOptions === "string"
9
12
  ? { label: labelOrOptions }
10
13
  : labelOrOptions;
11
- const check = opts.check ?? checkRuntimeReady;
14
+ const check = opts.check ?? ((projectDir, runtime) => checkRuntimeReady(projectDir, runtime, { onTrace: opts.onTrace, timeoutMs: opts.timeoutMs }));
12
15
  const checkClaudeSdk = opts.checkClaudeSdk ?? requireClaudeSDK;
13
16
  const allowSwitch = opts.allowSwitch !== false;
14
17
  const nonInteractive = !opts.choose && (Boolean(opts.yes) || !process.stdin.isTTY || !process.stdout.isTTY);
@@ -31,13 +34,15 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
31
34
  stderr: err instanceof Error ? err.message : String(err),
32
35
  cause: err,
33
36
  });
34
- if (nonInteractive) {
37
+ if (nonInteractive && !opts.durable) {
35
38
  throw failure;
36
39
  }
37
40
  const fallbackRuntime = otherRuntime(runtime);
38
- const choice = opts.choose
39
- ? await opts.choose(failure, { otherRuntime: fallbackRuntime, allowSwitch })
40
- : await promptRuntimeRecovery(failure, opts.label, fallbackRuntime, allowSwitch);
41
+ const choice = opts.durable && !opts.choose
42
+ ? await durableRuntimeRecovery(opts.durable, failure, opts.label, fallbackRuntime, allowSwitch)
43
+ : opts.choose
44
+ ? await opts.choose(failure, { otherRuntime: fallbackRuntime, allowSwitch })
45
+ : await promptRuntimeRecovery(failure, opts.label, fallbackRuntime, allowSwitch);
41
46
  if (choice === "retry") {
42
47
  currentActivity()?.note(`rafi: retrying ${runtime} readiness check`);
43
48
  continue;
@@ -72,6 +77,8 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
72
77
  }
73
78
  }
74
79
  if (choice === "cancel") {
80
+ if (opts.durable)
81
+ throw failure;
75
82
  console.log("foreman: cancelled");
76
83
  process.exit(0);
77
84
  }
@@ -79,6 +86,34 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
79
86
  }
80
87
  }
81
88
  }
89
+ async function durableRuntimeRecovery(scope, failure, label, fallbackRuntime, allowSwitch) {
90
+ const db = new WorkflowDb(scope.projectDir);
91
+ try {
92
+ db.ensureRun(scope.runId);
93
+ const identity = createHash("sha256").update(JSON.stringify([scope.scopeRevision, failure.runtime, label, allowSwitch])).digest("hex");
94
+ const prefix = `runtime-recovery:${scope.runId}:${identity}:`;
95
+ const attempt = db.operations(scope.runId).filter(item => item.kind === "runtime-recovery-decision" && item.idempotencyKey.startsWith(prefix)).length;
96
+ const key = `${prefix}${attempt}`;
97
+ console.error(failure.message);
98
+ const choice = await durableHumanDecision({
99
+ projectDir: scope.projectDir, runId: scope.runId, key,
100
+ prompt: `${runtimeCommandLabel(failure.runtime)} is not ready for ${label}. Choose how to recover.`,
101
+ choices: [{ id: "retry", label: "Fix manually and retry" }, ...(allowSwitch ? [{ id: "switch", label: `Use ${runtimeDisplayName(fallbackRuntime)}` }] : []), { id: "cancel", label: "Stop and preserve work" }],
102
+ operation: () => promptRuntimeRecovery(failure, label, fallbackRuntime, allowSwitch),
103
+ });
104
+ // Spend this authorization before executing it. A later failure must never
105
+ // reuse an old retry answer and turn a human decision into an infinite loop.
106
+ db.atomic(() => {
107
+ db.planOperation({ runId: scope.runId, idempotencyKey: key, kind: "runtime-recovery-decision", intent: { choice } });
108
+ db.updateOperation(key, "in_progress");
109
+ db.updateOperation(key, "confirmed", { result: { choice } });
110
+ });
111
+ return choice;
112
+ }
113
+ finally {
114
+ db.close();
115
+ }
116
+ }
82
117
  async function promptRuntimeRecovery(err, label, fallbackRuntime, allowSwitch) {
83
118
  log.error(err.message);
84
119
  log.info("Cancel stops this command and keeps project files in place. It does not uninstall packages, delete generated files, or change configuration.");