ai-foreman 1.7.25 → 1.7.26

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 (40) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/claude.d.ts +15 -0
  3. package/dist/adapters/claude.js +149 -28
  4. package/dist/adapters/codex.d.ts +9 -0
  5. package/dist/adapters/codex.js +77 -8
  6. package/dist/adapters/fastMode.d.ts +4 -0
  7. package/dist/adapters/fastMode.js +9 -0
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/agentRun.js +1 -0
  10. package/dist/branch/runner.js +46 -44
  11. package/dist/buildAssignment.d.ts +15 -0
  12. package/dist/buildAssignment.js +142 -5
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +5 -0
  16. package/dist/cli/start.js +123 -33
  17. package/dist/continuity.js +11 -10
  18. package/dist/foreman.d.ts +8 -0
  19. package/dist/foreman.js +69 -10
  20. package/dist/handoffProtocol.d.ts +4 -0
  21. package/dist/handoffProtocol.js +19 -0
  22. package/dist/handoffs.d.ts +1 -1
  23. package/dist/handoffs.js +23 -4
  24. package/dist/permissions/policy.js +18 -11
  25. package/dist/permissions/shellForms.d.ts +3 -0
  26. package/dist/permissions/shellForms.js +203 -0
  27. package/dist/qaBuilderActivation.d.ts +19 -0
  28. package/dist/qaBuilderActivation.js +112 -0
  29. package/dist/qaFailureDelivery.js +1 -1
  30. package/dist/qaRecovery.js +2 -1
  31. package/dist/qaReview.d.ts +4 -0
  32. package/dist/qaReview.js +37 -7
  33. package/dist/sessionLifecycle.js +1 -1
  34. package/dist/supervisedStart.js +23 -3
  35. package/dist/supervisor.js +33 -0
  36. package/dist/tickets/groupReset.js +100 -96
  37. package/dist/tickets/reset.js +80 -69
  38. package/dist/workflowDb.d.ts +16 -1
  39. package/dist/workflowDb.js +276 -8
  40. package/package.json +1 -1
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { assertBuildAssignmentReconciled, captureBuildSource } from "./buildAssignment.js";
4
+ import { HandoffService, HandoffRecoveryPausedError } from "./handoffs.js";
5
+ import { qaDigest } from "./qaProtocolV2.js";
6
+ import { providerSessionKey } from "./sessionIdentity.js";
7
+ import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
8
+ import { loadTickets } from "./tickets/ticketLoader.js";
9
+ import { WorkflowDb } from "./workflowDb.js";
10
+ /** QA-only startup must not replace Builder ownership. Activate a Builder only
11
+ * at an authorized work boundary, through the ordinary validated handoff. */
12
+ export async function activateQaRecoveryBuilder(input) {
13
+ const inspect = () => {
14
+ const db = new WorkflowDb(input.projectDir);
15
+ try {
16
+ const lease = db.currentLease();
17
+ if (lease?.runId !== input.runId || lease.pid !== process.pid)
18
+ throw new Error("Builder activation requires the current build execution lease");
19
+ const run = db.getRun(input.runId)?.state;
20
+ if (!run?.tickets.includes(input.ticketId) || run.progress.completedTickets.includes(input.ticketId))
21
+ throw new Error("QA recovery cannot activate Builder work outside unfinished approved scope");
22
+ const admission = db.assertAdmittedWork(input.runId, input.ticketId);
23
+ const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(input.projectDir), input.projectDir).tickets).find(ticket => ticket.id === input.ticketId);
24
+ if (!definition || qaDigest("admitted-requirements", definition) !== admission.requirementsDigest)
25
+ throw new Error("Builder recovery requirements changed; renew scope approval before implementation");
26
+ if (!run.builder || qaDigest("builder-settings", run.builder.settings) !== qaDigest("builder-settings", input.settings))
27
+ throw new Error("QA recovery cannot replace the saved Builder settings");
28
+ if (input.expectedCapabilities && qaDigest("builder-capabilities", run.builderCapabilities) !== qaDigest("builder-capabilities", input.expectedCapabilities))
29
+ throw new Error("Builder capability approval changed before recovery activation");
30
+ if (db.unresolvedRoleDispatches(input.runId, "builder").length)
31
+ throw new Error("Reconcile unknown Builder dispatch before activating another session");
32
+ assertBuildAssignmentReconciled(input.projectDir, input.runId, input.worktree);
33
+ const owner = db.roleMutationLease(input.runId, "builder");
34
+ const ref = owner?.sessionRef;
35
+ if (!owner || !Number.isSafeInteger(owner.generation) || owner.generation < 0 || !ref || ref.version !== 1 || ref.role !== "builder" || ref.stream !== "builder"
36
+ || !Number.isSafeInteger(ref.generation) || ref.generation < 0 || ref.sessionId !== owner.providerSessionId
37
+ || ref.source !== "observed" || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt)) || providerSessionKey(ref) !== owner.sessionKey
38
+ || realpathSync(ref.cwd) !== realpathSync(input.worktree) || realpathSync(ref.configRoot) !== realpathSync(input.projectDir))
39
+ throw new Error("QA recovery has no validated Builder owner for this worktree");
40
+ const branchSession = db.branchResumeSessions(false).find(session => session.ticket === input.ticketId && realpathSync(session.worktreePath) === realpathSync(input.worktree));
41
+ const branch = branchSession?.branch ?? (realpathSync(run.repository.worktree) === realpathSync(input.worktree) ? run.repository.branch : undefined);
42
+ if (!branch || execFileSync("git", ["branch", "--show-current"], { cwd: input.worktree, encoding: "utf8" }).trim() !== branch)
43
+ throw new Error("Return to the approved Builder worktree branch before recovery activation");
44
+ const head = db.continuityHead(input.runId, "builder");
45
+ if (!head || head.state !== "current" || !db.latestContinuityCheckpoint(input.runId, "builder"))
46
+ throw new Error("Builder continuity needs reconstruction before QA can request implementation");
47
+ return { owner, ref, head, source: captureBuildSource(input.worktree), requirementsDigest: admission.requirementsDigest,
48
+ capabilitiesDigest: qaDigest("builder-capabilities", run.builderCapabilities), compactionCount: db.successfulCompactionCount(input.runId, "builder", ref) };
49
+ }
50
+ finally {
51
+ db.close();
52
+ }
53
+ };
54
+ const before = inspect();
55
+ const currentRef = input.adapter.sessionRef?.();
56
+ if (currentRef && providerSessionKey(currentRef) === before.owner.sessionKey && currentRef.generation === before.ref.generation
57
+ && currentRef.role === "builder" && currentRef.stream === "builder" && realpathSync(currentRef.configRoot) === realpathSync(input.projectDir)
58
+ && currentRef.source === "observed" && currentRef.validatedAt)
59
+ return input.adapter;
60
+ const assertPreparedState = (current) => {
61
+ if (current.owner.generation !== before.owner.generation || current.owner.sessionKey !== before.owner.sessionKey
62
+ || current.head.digest !== before.head.digest || current.source.digest !== before.source.digest || current.capabilitiesDigest !== before.capabilitiesDigest)
63
+ throw new Error("Builder recovery state changed while preparing its handoff; inspect and retry");
64
+ };
65
+ const transfer = await new HandoffService(input.projectDir).transfer({
66
+ runId: input.runId, role: "builder", reason: "Authorized Builder work after QA-only recovery requires a validated Builder owner",
67
+ predecessorSessionId: before.ref.sessionId, predecessorSessionRef: before.ref,
68
+ compactionCount: before.compactionCount, compactMaximum: input.settings.compact_maximum ?? 10,
69
+ roleState: { ticketId: input.ticketId, requirementsDigest: before.requirementsDigest, sourceDigest: before.source.digest,
70
+ capabilitiesDigest: before.capabilitiesDigest, recovery: "qa-builder-activation", providerReplayAuthorized: false },
71
+ resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true, purpose: "Approved QA remediation or guidance action; no replay of historical work" }],
72
+ }, async (handoff, runtime) => {
73
+ assertPreparedState(inspect());
74
+ const successor = await input.createSuccessor(handoff, runtime);
75
+ try {
76
+ assertPreparedState(inspect());
77
+ }
78
+ catch (error) {
79
+ await successor.close().catch(() => { });
80
+ throw error;
81
+ }
82
+ return successor;
83
+ }, input.recovery);
84
+ let owned;
85
+ try {
86
+ owned = await input.wrap(transfer.successor);
87
+ }
88
+ catch (error) {
89
+ let drainError;
90
+ try {
91
+ await transfer.successor.close();
92
+ }
93
+ catch (failure) {
94
+ drainError = failure;
95
+ }
96
+ throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted but could not install its execution wrapper: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} The recorded accepted owner is preserved; inspect this build with rafi resume before continuing.`);
97
+ }
98
+ try {
99
+ await input.adapter.close();
100
+ }
101
+ catch (error) {
102
+ let drainError;
103
+ try {
104
+ await owned.close();
105
+ }
106
+ catch (failure) {
107
+ drainError = failure;
108
+ }
109
+ throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted, but the unused startup session could not close: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} Ownership remains with the recorded accepted session; reconcile cleanup with rafi resume before continuing.`);
110
+ }
111
+ return owned;
112
+ }
@@ -372,7 +372,7 @@ export class QaFailureDeliveryService {
372
372
  Object.assign(record, { providerInstructionDigest: db.putEvidence("handoff", Buffer.from(turn.providerInstruction)), providerInstructionBytes: Buffer.byteLength(turn.providerInstruction), providerInstructionAvailability: "captured" });
373
373
  }
374
374
  record.eventEvidenceDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(events)));
375
- record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
375
+ record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["text", "agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
376
376
  record.terminalCount = events.filter(e => e.kind === "turn-complete").length;
377
377
  db.recordQaDeliveryTurn(record);
378
378
  this.faults.afterResponseStored?.();
@@ -426,9 +426,10 @@ export function materializeQaRecoveryContext(packet, qaWorktree) {
426
426
  } };
427
427
  }
428
428
  export function renderQaRecoveryAcknowledgementInstruction(packet, relativePath) {
429
- const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
429
+ const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- read_path=${join(relativePath, resource.path)} packet_path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
430
430
  return [
431
431
  "Read and verify every required recovery resource in this exact sealed packet revision before replying.",
432
+ "Use each exact read_path below within the current QA working directory. The normal read-only QA permissions apply; preserve the sealed packet and source.",
432
433
  `Recovery context directory: ${relativePath}`,
433
434
  `Packet ID: ${packet.manifest.packetId}`,
434
435
  `Packet revision: ${packet.manifest.revision}`,
@@ -208,6 +208,10 @@ export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<Isolated
208
208
  export declare function buildQaReviewHandoff(ticket: TicketDef, builderSummary: string, diffDigest: string, validationChecklist: string[], changeSummary?: unknown, history?: QaReportHistoryEntry[]): string;
209
209
  export declare function compactWithRetry(adapter: BuilderAdapter): Promise<CompactResult>;
210
210
  export declare const QA_REPORT_RECOVERY_MENU: readonly ["Try a complete fresh QA review.", "Manually fix saved JSON for human inspection.", "Give QA specific instructions for a fresh review.", "Pause."];
211
+ /** Shared invariant for host staging and the actual QA boundary receipt. */
212
+ export declare function assertQaRecoveryHandoffInventory(resources: ReadonlyArray<{
213
+ label: string;
214
+ }>): void;
211
215
  export declare function beginQaFinalization(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, certificateId: string, expectedSourceStateDigest: string, consumer: string, allowedProjectionPaths?: string[]): Promise<void>;
212
216
  export declare function verifyPendingQaFinalizationSource(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, allowProjection?: boolean, projectionOperationId?: string): Promise<void>;
213
217
  export declare function completeQaFinalization(projectDir: string, runId: string, ticketId: string): void;
package/dist/qaReview.js CHANGED
@@ -200,8 +200,10 @@ async function runIsolatedQaOwned(opts) {
200
200
  if (protocolHead.state === "source-frozen" || protocolHead.state === "review-ready") {
201
201
  protocolHead = scopeDb.transitionQa(opts.recovery.runId, opts.ticket.id, protocolHead.revision, { type: "operator-menu" });
202
202
  }
203
- if (opts.resumedRecovery && protocolHead.state === "recheck-required" && opts.resumedRecovery.manifest.pendingAction !== "qa-full-review") {
203
+ if (opts.resumedRecovery && protocolHead.state === "recheck-required") {
204
204
  opts.resumedRecovery = updateQaRecoveryPosition(opts.resumedRecovery, "qa-full-review", opts.resumedRecovery.manifest.correctionTurns, "qa-full-review");
205
+ if (scopeDb.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
206
+ opts.resumedRecovery = undefined;
205
207
  }
206
208
  durableReviews = scopeDb.qaReviewAttempts(opts.recovery.runId, opts.ticket.id);
207
209
  for (const interrupted of durableReviews.filter((item) => item.status === "started"))
@@ -324,6 +326,8 @@ async function runIsolatedQaOwned(opts) {
324
326
  opts.qaRemediationAuthorization = undefined;
325
327
  if (!fix.ok)
326
328
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
329
+ if (!consumeRecoveryAfterOwnedFix(opts))
330
+ return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
327
331
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
328
332
  opts.state.builderResponseHistory.push({ ticketId: opts.ticket.id, cycle: durableReport.reviewNumber, kind: "remediation", response: fix.response, summary: fixSummary });
329
333
  opts.builderSummary = fix.response;
@@ -387,6 +391,8 @@ async function runIsolatedQaOwned(opts) {
387
391
  opts.qaRemediationAuthorization = undefined;
388
392
  if (!fix.ok)
389
393
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
394
+ if (!consumeRecoveryAfterOwnedFix(opts))
395
+ return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
390
396
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
391
397
  opts.state.builderResponseHistory.push({
392
398
  ticketId: opts.ticket.id,
@@ -1367,6 +1373,22 @@ function markQaRecoveryResolved(opts) {
1367
1373
  db.close();
1368
1374
  }
1369
1375
  }
1376
+ function consumeRecoveryAfterOwnedFix(opts) {
1377
+ if (!opts.resumedRecovery)
1378
+ return true;
1379
+ const db = new WorkflowDb(opts.recovery.projectDir);
1380
+ try {
1381
+ if (!db.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
1382
+ return false;
1383
+ // The validated report led to confirmed source-changing implementation.
1384
+ // Its recovery packet remains durable history, not the next review basis.
1385
+ opts.resumedRecovery = undefined;
1386
+ return true;
1387
+ }
1388
+ finally {
1389
+ db.close();
1390
+ }
1391
+ }
1370
1392
  function markQaRecoveryPendingBuilder(opts) {
1371
1393
  markQaRecoveryAction(opts, "builder-remediation-pending");
1372
1394
  }
@@ -1542,10 +1564,7 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
1542
1564
  || resource.authoritative !== true || typeof resource.requiredForRecovery !== "boolean")) {
1543
1565
  throw new Error("QA recovery acceptance receipt contains invalid resource metadata");
1544
1566
  }
1545
- for (const required of ["continuity-checkpoint", "authoritative-run-state", "frozen-qa-action"]) {
1546
- if (!receipt.resources.some((resource) => resource.label === required))
1547
- throw new Error(`QA recovery acceptance receipt is missing ${required}`);
1548
- }
1567
+ assertQaRecoveryHandoffInventory(receipt.resources);
1549
1568
  if (receipt.inventoryDigest !== qaDigest("handoff-inventory", receipt.resources))
1550
1569
  throw new Error("QA recovery acceptance receipt inventory digest mismatch");
1551
1570
  const receiptDb = new WorkflowDb(packet.projectDir);
@@ -1566,6 +1585,13 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
1566
1585
  }
1567
1586
  }
1568
1587
  }
1588
+ /** Shared invariant for host staging and the actual QA boundary receipt. */
1589
+ export function assertQaRecoveryHandoffInventory(resources) {
1590
+ for (const required of ["continuity-checkpoint", "authoritative-run-state", "frozen-qa-action"]) {
1591
+ if (!resources.some(resource => resource.label === required))
1592
+ throw new Error(`QA recovery acceptance receipt is missing ${required}`);
1593
+ }
1594
+ }
1569
1595
  function effectiveTurnText(turn) { return turn.cleanedResponse ?? turn.text; }
1570
1596
  const qaTurnBindings = new WeakMap();
1571
1597
  function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
@@ -1769,8 +1795,12 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
1769
1795
  }
1770
1796
  let result;
1771
1797
  const providerStartedAt = Date.now();
1798
+ // Reading the sealed QA packet is ordinary confined read-only QA work.
1799
+ // Only formatting repair and ownership acceptance are response-only; the
1800
+ // first resource-inspection turn cannot truthfully read with tools disabled.
1801
+ const responseRepair = slot?.includes("correction") || (slot?.includes("acknowledgement") && slot.includes("repair"));
1772
1802
  try {
1773
- result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : slot?.includes("acknowledgement") || slot?.includes("correction") ? { purpose: "response-repair", responseOnly: true } : undefined);
1803
+ result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : responseRepair ? { purpose: "response-repair", responseOnly: true } : undefined);
1774
1804
  }
1775
1805
  catch (error) {
1776
1806
  const failedDb = new WorkflowDb(binding.projectDir);
@@ -1827,7 +1857,7 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
1827
1857
  throw new Error(detail);
1828
1858
  }
1829
1859
  const completedAt = new Date().toISOString();
1830
- const terminalEventObserved = Boolean(result.turnId
1860
+ const terminalEventObserved = Boolean(result.turnId && result.failure?.dispatchState !== "unknown"
1831
1861
  && events.some((event) => event.kind === "turn-complete" && (event.turnId ?? event.result.turnId) === result.turnId));
1832
1862
  const receiptDb = new WorkflowDb(binding.projectDir);
1833
1863
  try {
@@ -768,7 +768,7 @@ export class ThresholdCompactionController {
768
768
  handoffGeneration() {
769
769
  const db = new WorkflowDb(this.options.projectDir);
770
770
  try {
771
- return db.handoffs(this.options.runId).at(-1)?.generation ?? 0;
771
+ return db.roleMutationLease(this.options.runId, this.options.role)?.generation ?? 0;
772
772
  }
773
773
  finally {
774
774
  db.close();
@@ -3,8 +3,10 @@ import { fileURLToPath } from "node:url";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { closeSync, mkdirSync, openSync } from "node:fs";
5
5
  import { join } from "node:path";
6
+ import { hostname } from "node:os";
6
7
  import { DurableSupervisor } from "./supervisor.js";
7
8
  import { WorkflowDb } from "./workflowDb.js";
9
+ import { processStartIdentity } from "./processIdentity.js";
8
10
  import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
9
11
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
10
12
  export function supervisedRunId() {
@@ -174,6 +176,12 @@ export async function superviseStart(projectDir, runId, policy, selection) {
174
176
  return;
175
177
  try {
176
178
  db.acknowledgeBuildLaunchChild(authority, launch.token, child.pid);
179
+ // Record the incarnation before acknowledging the blocked launch gate.
180
+ // Any persistence failure follows the existing owned-child cleanup.
181
+ db.transitionSupervisor(runId, generation, {
182
+ checkpoint: db.getRun(runId)?.checkpoint ?? "preparing", event: "worker_started",
183
+ payload: { workerPid: child.pid, processStart: processStartIdentity(child.pid), host: hostname(), generation },
184
+ });
177
185
  child.send("rafi-launch-ack", () => { });
178
186
  clearTimeout(startup);
179
187
  }
@@ -228,12 +236,18 @@ export async function superviseStart(projectDir, runId, policy, selection) {
228
236
  const result = new Promise(resolve => {
229
237
  child.once("error", error => {
230
238
  clearInterval(watchdog);
239
+ exitCode = 1;
231
240
  if (child.pid) {
232
241
  stop();
233
242
  return;
234
243
  } // exit handler owns cleanup and settlement
235
- db.failBuildLaunch(authority, launch.token);
236
- resolve({ kind: "failed", detail: error.message });
244
+ try {
245
+ db.failBuildLaunch(authority, launch.token);
246
+ resolve({ kind: "failed", detail: error.message });
247
+ }
248
+ catch (failure) {
249
+ resolve({ kind: "failed", detail: `${error.message}; launch reconciliation failed: ${String(failure)}` });
250
+ }
237
251
  });
238
252
  child.once("exit", async (code, signal) => {
239
253
  clearInterval(watchdog);
@@ -309,7 +323,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
309
323
  process.once("SIGINT", stopInt);
310
324
  process.once("SIGTERM", stopTerm);
311
325
  try {
312
- await supervisor.run();
326
+ const outcome = await supervisor.run();
327
+ if (outcome.kind === "failed") {
328
+ exitCode ||= 1;
329
+ console.error(`rafi: ${outcome.detail}`);
330
+ }
331
+ else if (outcome.kind === "waiting_for_human")
332
+ exitCode ||= 2;
313
333
  }
314
334
  finally {
315
335
  process.off("SIGINT", stopInt);
@@ -80,6 +80,8 @@ export class DurableSupervisor {
80
80
  if (pending.some(decision => !this.options.policy.continueIndependentTickets || !decision.interruptionId.startsWith("ticket:"))) {
81
81
  state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString() };
82
82
  saveState();
83
+ if (db.getRun(this.options.runId)?.status === "running")
84
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "waiting-for-human", event: "worker_waiting_for_human" });
83
85
  return { kind: "waiting_for_human" };
84
86
  }
85
87
  const checkpoint = this.options.checkpoint();
@@ -94,6 +96,19 @@ export class DurableSupervisor {
94
96
  this.active = undefined;
95
97
  state = { ...state, workerPid: undefined, heartbeatAt: this.now().toISOString() };
96
98
  if (outcome.kind !== "crashed") {
99
+ if (outcome.kind === "failed" && db.getRun(this.options.runId)?.status === "running") {
100
+ const eligibility = db.preparationEligibility(this.options.runId);
101
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, {
102
+ status: eligibility.eligible ? "failed" : "paused",
103
+ checkpoint: eligibility.eligible ? "worker-failed" : "worker-reconciliation-required",
104
+ event: "worker_exit_recorded", payload: { detail: outcome.detail, reasons: eligibility.reasons },
105
+ });
106
+ }
107
+ else if (db.getRun(this.options.runId)?.status === "running")
108
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, {
109
+ status: "paused", checkpoint: outcome.kind === "waiting_for_human" && db.pendingHumanDecisions(this.options.runId).length ? "waiting-for-human" : "worker-exited-before-checkpoint",
110
+ event: "worker_exit_recorded", payload: { outcome: outcome.kind },
111
+ });
97
112
  const status = outcome.kind === "completed" ? "stopped" : outcome.kind === "waiting_for_human" ? "waiting_for_human" : outcome.kind === "failed" ? "failed" : "stopped";
98
113
  state = { ...state, status };
99
114
  saveState();
@@ -111,6 +126,10 @@ export class DurableSupervisor {
111
126
  if (state.checkpointRestarts >= checkpointLimit || state.runRestarts >= runLimit) {
112
127
  state = { ...state, status: "failed" };
113
128
  saveState();
129
+ if (db.getRun(this.options.runId)?.status === "running")
130
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, {
131
+ status: "failed", checkpoint: "worker-restart-budget-exhausted", event: "worker_restart_budget_exhausted", payload: { checkpoint, detail: outcome.detail },
132
+ });
114
133
  return { kind: "failed", detail: `worker restart budget exhausted at ${checkpoint}: ${outcome.detail}` };
115
134
  }
116
135
  state = { ...state, checkpointRestarts: state.checkpointRestarts + 1, runRestarts: state.runRestarts + 1 };
@@ -118,9 +137,23 @@ export class DurableSupervisor {
118
137
  await this.sleep(Math.min(10_000, 250 * (2 ** Math.min(5, state.checkpointRestarts - 1))));
119
138
  }
120
139
  state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined };
140
+ if (db.getRun(this.options.runId)?.status === "running")
141
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-stopped", event: "supervisor_stop_recorded" });
121
142
  saveState();
122
143
  return { kind: "stopped" };
123
144
  }
145
+ catch (error) {
146
+ state = { ...state, status: "failed", workerPid: undefined, heartbeatAt: this.now().toISOString() };
147
+ saveState();
148
+ if (db.getRun(this.options.runId)?.status === "running") {
149
+ const eligibility = db.preparationEligibility(this.options.runId);
150
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: eligibility.eligible ? "failed" : "paused",
151
+ checkpoint: eligibility.eligible ? "worker-launch-failed" : "worker-reconciliation-required", event: "worker_launch_failed",
152
+ payload: { detail: String(error), reasons: eligibility.reasons },
153
+ });
154
+ }
155
+ throw error;
156
+ }
124
157
  finally {
125
158
  clearInterval(heartbeat);
126
159
  try {
@@ -94,107 +94,111 @@ export function resolveTicketResetSelection(projectDir, requested, options = {},
94
94
  }
95
95
  export function applyResolvedTicketReset(projectDir, selection, actor = "user", now = new Date(), hooks = {}) {
96
96
  const opened = open(projectDir);
97
- const { config, paths, definitions, db } = opened;
98
- const workflow = new WorkflowDb(projectDir);
99
- const resetId = hooks.resetId ?? randomUUID();
100
- const current = new Map(definitions.map((definition) => [definition.id, definition]));
101
- const groupCatalog = db.listTicketGroups();
102
- const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
103
- const states = db.getAllStates();
104
- const selectedIds = new Set(selection.ticketIds);
105
- const currentRelatedRuns = recoverableBuildRuns(projectDir, now)
106
- .filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
107
- .map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
108
- const comparable = { ...selection, relatedRuns: currentRelatedRuns };
109
- delete comparable.inputFingerprint;
110
- const actualFingerprint = selectionFingerprint(comparable, current, states, snapshots, groupCatalog);
111
- if (actualFingerprint !== selection.inputFingerprint) {
112
- db.close();
113
- workflow.close();
114
- throw new Error("ticket/group/reset inputs changed after preview; no reset was applied—preview the exact selection again");
115
- }
116
- const restore = selection.definitionRestorations.filter((item) => item.restore);
117
- const restored = restore.map((item) => item.ticketId);
118
- const restoredSet = new Set(restored);
119
- const restoredDefinitions = restore.map((item) => snapshots.get(item.ticketId)?.definition);
120
- if (restoredDefinitions.some((item) => !item)) {
121
- db.close();
122
- workflow.close();
123
- throw new Error("a selected definition snapshot became unavailable");
124
- }
125
- const nextDefinitions = [...definitions, ...restoredDefinitions.filter((definition) => !current.has(definition.id))].sort((a, b) => a.order - b.order);
126
- const definitionIssues = validateTicketDefs(nextDefinitions);
127
- if (definitionIssues.length) {
128
- db.close();
129
- workflow.close();
130
- throw new Error(`restored ticket definitions are invalid: ${definitionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
131
- }
132
- const resettable = selection.ticketIds.filter((ticketId) => current.has(ticketId) || restoredSet.has(ticketId));
133
- const ignoredMissing = selection.ticketIds.filter((ticketId) => !current.has(ticketId) && !restoredSet.has(ticketId));
134
- if (resettable.length === 0 && restored.length === 0) {
135
- db.close();
136
- workflow.close();
137
- return { resetId, tickets: [], restored: [], ignoredMissing };
138
- }
139
- const stageDir = join(projectDir, ".rafi", "reset-stage", resetId);
140
- const stagedDefinitions = join(stageDir, "tickets.yaml");
141
- const stagedProgress = join(stageDir, "progress.md");
142
- const definitionsBefore = existsSync(paths.tickets) ? readFileSync(paths.tickets) : undefined;
143
- const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
144
- const oldStates = new Map([...new Set([...resettable, ...restored])].map((ticketId) => [ticketId, db.getState(ticketId)]));
145
- const oldRecent = db.getRecentCompleted().filter((row) => resettable.includes(row.ticket_id));
146
- const eventIds = [];
147
- const run = workflow.createRun({ kind: "recovery", checkpoint: "group-reset-intent", originalWork: selection, remainingWork: { tickets: resettable }, state: { resetId } }, now);
148
- const publication = workflow.beginPublication(run.runId, { operation: "group-ticket-reset", resetId, selection, stagedDefinitions, stagedProgress, definitionsTarget: paths.tickets, progressTarget: paths.progressDoc }, { definitions: digest(definitionsBefore), progress: digest(progressBefore) }, now);
97
+ const { config, paths, db } = opened;
98
+ let workflowHandle;
149
99
  try {
150
- const stamp = now.toISOString();
151
- db.transaction(() => {
152
- for (const definition of restoredDefinitions)
153
- if (!db.getState(definition.id))
154
- db.upsertState(definition.id, pristineTicketState(definition.id, stamp, actor), stamp);
155
- for (const row of selection.previewRows) {
156
- if (!resettable.includes(row.ticketId)) {
157
- eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset_skipped_missing", old_status: row.status, new_status: row.status, summary: `Skipped missing definition ${row.ticketId}`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, input_fingerprint: selection.inputFingerprint }) }));
158
- continue;
159
- }
160
- const old = db.getState(row.ticketId)?.status ?? row.status;
161
- db.upsertState(row.ticketId, pristineTicketState(row.ticketId, stamp, actor), stamp);
162
- db.deleteRecentCompleted(row.ticketId);
163
- eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset", old_status: old, new_status: "planned", summary: `Reset ${row.ticketId} from approved immutable selection`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, groups: selection.groups.map((group) => group.id), restored_definition: restoredSet.has(row.ticketId), input_fingerprint: selection.inputFingerprint }) }));
100
+ const workflow = workflowHandle = new WorkflowDb(projectDir);
101
+ const resetId = hooks.resetId ?? randomUUID();
102
+ return workflow.withTrackerMutationAuthority(() => {
103
+ const definitions = loadTickets(paths.tickets);
104
+ const current = new Map(definitions.map((definition) => [definition.id, definition]));
105
+ const groupCatalog = db.listTicketGroups();
106
+ const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
107
+ const states = db.getAllStates();
108
+ const selectedIds = new Set(selection.ticketIds);
109
+ const currentRelatedRuns = recoverableBuildRuns(projectDir, now)
110
+ .filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
111
+ .map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
112
+ const comparable = { ...selection, relatedRuns: currentRelatedRuns };
113
+ delete comparable.inputFingerprint;
114
+ const actualFingerprint = selectionFingerprint(comparable, current, states, snapshots, groupCatalog);
115
+ if (actualFingerprint !== selection.inputFingerprint) {
116
+ throw new Error("ticket/group/reset inputs changed after preview; no reset was applied—preview the exact selection again");
117
+ }
118
+ const restore = selection.definitionRestorations.filter((item) => item.restore);
119
+ const restored = restore.map((item) => item.ticketId);
120
+ const restoredSet = new Set(restored);
121
+ const restoredDefinitions = restore.map((item) => snapshots.get(item.ticketId)?.definition);
122
+ if (restoredDefinitions.some((item) => !item)) {
123
+ throw new Error("a selected definition snapshot became unavailable");
124
+ }
125
+ const nextDefinitions = [...definitions, ...restoredDefinitions.filter((definition) => !current.has(definition.id))].sort((a, b) => a.order - b.order);
126
+ const definitionIssues = validateTicketDefs(nextDefinitions);
127
+ if (definitionIssues.length) {
128
+ throw new Error(`restored ticket definitions are invalid: ${definitionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
129
+ }
130
+ const resettable = selection.ticketIds.filter((ticketId) => current.has(ticketId) || restoredSet.has(ticketId));
131
+ const ignoredMissing = selection.ticketIds.filter((ticketId) => !current.has(ticketId) && !restoredSet.has(ticketId));
132
+ if (resettable.length === 0 && restored.length === 0) {
133
+ return { resetId, tickets: [], restored: [], ignoredMissing };
134
+ }
135
+ const stageDir = join(projectDir, ".rafi", "reset-stage", resetId);
136
+ const stagedDefinitions = join(stageDir, "tickets.yaml");
137
+ const stagedProgress = join(stageDir, "progress.md");
138
+ const definitionsBefore = existsSync(paths.tickets) ? readFileSync(paths.tickets) : undefined;
139
+ const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
140
+ const oldStates = new Map([...new Set([...resettable, ...restored])].map((ticketId) => [ticketId, db.getState(ticketId)]));
141
+ const oldRecent = db.getRecentCompleted().filter((row) => resettable.includes(row.ticket_id));
142
+ const eventIds = [];
143
+ const run = workflow.createRun({ kind: "recovery", checkpoint: "group-reset-intent", originalWork: selection, remainingWork: { tickets: resettable }, state: { resetId } }, now);
144
+ let publication;
145
+ try {
146
+ publication = workflow.beginPublication(run.runId, { operation: "group-ticket-reset", resetId, selection, stagedDefinitions, stagedProgress, definitionsTarget: paths.tickets, progressTarget: paths.progressDoc }, { definitions: digest(definitionsBefore), progress: digest(progressBefore) }, now);
147
+ const stamp = now.toISOString();
148
+ db.transaction(() => {
149
+ for (const definition of restoredDefinitions)
150
+ if (!db.getState(definition.id))
151
+ db.upsertState(definition.id, pristineTicketState(definition.id, stamp, actor), stamp);
152
+ for (const row of selection.previewRows) {
153
+ if (!resettable.includes(row.ticketId)) {
154
+ eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset_skipped_missing", old_status: row.status, new_status: row.status, summary: `Skipped missing definition ${row.ticketId}`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, input_fingerprint: selection.inputFingerprint }) }));
155
+ continue;
156
+ }
157
+ const old = db.getState(row.ticketId)?.status ?? row.status;
158
+ db.upsertState(row.ticketId, pristineTicketState(row.ticketId, stamp, actor), stamp);
159
+ db.deleteRecentCompleted(row.ticketId);
160
+ eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset", old_status: old, new_status: "planned", summary: `Reset ${row.ticketId} from approved immutable selection`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, groups: selection.groups.map((group) => group.id), restored_definition: restoredSet.has(row.ticketId), input_fingerprint: selection.inputFingerprint }) }));
161
+ }
162
+ });
163
+ mkdirSync(stageDir, { recursive: true });
164
+ writeFileSync(stagedDefinitions, stringify({ tickets: nextDefinitions }, { lineWidth: 120 }), "utf8");
165
+ writeFileSync(stagedProgress, renderProgressDoc({ config, projectDir, ticketDefs: nextDefinitions, states: db.getAllStates(), db, now: stamp }), "utf8");
166
+ workflow.updatePublication(publication.transactionId, "staged", now);
167
+ workflow.updatePublication(publication.transactionId, "tracker_committed", now);
168
+ hooks.beforePublish?.();
169
+ atomicCopy(stagedDefinitions, paths.tickets);
170
+ writeProgressDoc(paths.progressDoc, readFileSync(stagedProgress, "utf8"), true);
171
+ workflow.updatePublication(publication.transactionId, "published", now);
172
+ workflow.updatePublication(publication.transactionId, "committed", now);
173
+ workflow.transition(run.runId, { status: "completed", checkpoint: "group-reset-committed", remainingWork: {}, state: { resetId, tickets: resettable, restored, ignoredMissing } }, now);
174
+ rmSync(stageDir, { recursive: true, force: true });
175
+ return { resetId, tickets: resettable, restored, ignoredMissing };
176
+ }
177
+ catch (error) {
178
+ db.transaction(() => {
179
+ db.deleteEvents(eventIds);
180
+ for (const [ticketId, state] of oldStates)
181
+ state ? db.upsertState(ticketId, state, state.updated_at) : db.deleteState(ticketId);
182
+ for (const row of oldRecent)
183
+ db.upsertRecentCompleted(row);
184
+ });
185
+ restoreFile(paths.tickets, definitionsBefore);
186
+ restoreFile(paths.progressDoc, progressBefore);
187
+ if (publication)
188
+ workflow.updatePublication(publication.transactionId, "rolled_back", now);
189
+ workflow.transition(run.runId, { status: "failed", checkpoint: "group-reset-rolled-back", state: { resetId, error: error instanceof Error ? error.message : String(error) } }, now);
190
+ rmSync(stageDir, { recursive: true, force: true });
191
+ throw error;
164
192
  }
165
193
  });
166
- mkdirSync(stageDir, { recursive: true });
167
- writeFileSync(stagedDefinitions, stringify({ tickets: nextDefinitions }, { lineWidth: 120 }), "utf8");
168
- writeFileSync(stagedProgress, renderProgressDoc({ config, projectDir, ticketDefs: nextDefinitions, states: db.getAllStates(), db, now: stamp }), "utf8");
169
- workflow.updatePublication(publication.transactionId, "staged", now);
170
- workflow.updatePublication(publication.transactionId, "tracker_committed", now);
171
- hooks.beforePublish?.();
172
- atomicCopy(stagedDefinitions, paths.tickets);
173
- writeProgressDoc(paths.progressDoc, readFileSync(stagedProgress, "utf8"), true);
174
- workflow.updatePublication(publication.transactionId, "published", now);
175
- workflow.updatePublication(publication.transactionId, "committed", now);
176
- workflow.transition(run.runId, { status: "completed", checkpoint: "group-reset-committed", remainingWork: {}, state: { resetId, tickets: resettable, restored, ignoredMissing } }, now);
177
- rmSync(stageDir, { recursive: true, force: true });
178
- return { resetId, tickets: resettable, restored, ignoredMissing };
179
- }
180
- catch (error) {
181
- db.transaction(() => {
182
- db.deleteEvents(eventIds);
183
- for (const [ticketId, state] of oldStates)
184
- state ? db.upsertState(ticketId, state, state.updated_at) : db.deleteState(ticketId);
185
- for (const row of oldRecent)
186
- db.upsertRecentCompleted(row);
187
- });
188
- restoreFile(paths.tickets, definitionsBefore);
189
- restoreFile(paths.progressDoc, progressBefore);
190
- workflow.updatePublication(publication.transactionId, "rolled_back", now);
191
- workflow.transition(run.runId, { status: "failed", checkpoint: "group-reset-rolled-back", state: { resetId, error: error instanceof Error ? error.message : String(error) } }, now);
192
- rmSync(stageDir, { recursive: true, force: true });
193
- throw error;
194
194
  }
195
195
  finally {
196
- db.close();
197
- workflow.close();
196
+ try {
197
+ workflowHandle?.close();
198
+ }
199
+ finally {
200
+ db.close();
201
+ }
198
202
  }
199
203
  }
200
204
  function selectGroups(groups, requested) {