ai-foreman 1.7.28 → 1.7.29

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.
@@ -1,9 +1,40 @@
1
1
  import { processStartIdentity } from "./processIdentity.js";
2
+ import { stopCause } from "rafi-spec";
2
3
  import { WorkflowDb } from "./workflowDb.js";
4
+ /**
5
+ * Retry only the short synchronous persistence operation. The caller owns
6
+ * the retry budget and remains responsible for deciding whether ownership is
7
+ * safe after the budget is exhausted.
8
+ */
9
+ export async function persistWithBoundedRetry(persist, sleep, attempts = 3) {
10
+ let error;
11
+ let completedAttempts = 0;
12
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
13
+ completedAttempts = attempt;
14
+ try {
15
+ persist();
16
+ return { ok: true, attempts: attempt };
17
+ }
18
+ catch (failure) {
19
+ error = failure;
20
+ if (attempt < attempts && retryablePersistenceError(failure))
21
+ await sleep(100 * attempt);
22
+ else
23
+ break;
24
+ }
25
+ }
26
+ return { ok: false, attempts: completedAttempts, error };
27
+ }
28
+ function retryablePersistenceError(error) {
29
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database is (?:busy|locked)/i.test(message);
32
+ }
3
33
  /** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
4
34
  export class DurableSupervisor {
5
35
  options;
6
36
  stopRequested = false;
37
+ requestedStopCause;
7
38
  active;
8
39
  sleep;
9
40
  now;
@@ -12,8 +43,12 @@ export class DurableSupervisor {
12
43
  this.sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
13
44
  this.now = options.now ?? (() => new Date());
14
45
  }
15
- requestStop() {
46
+ requestStop(cause = "unknown") {
16
47
  this.stopRequested = true;
48
+ const source = cause === "user-sigint" || cause === "parent-sigterm" ? "signal" : cause === "heartbeat-timeout" ? "heartbeat" : cause === "heartbeat-persistence-failure" ? "persistence" : cause === "human-decision" ? "human-decision" : "host";
49
+ const actor = cause === "user-sigint" ? "user" : cause === "parent-sigterm" ? "parent" : cause === "heartbeat-persistence-failure" ? "database" : cause === "unknown" ? "unknown" : "supervisor";
50
+ this.requestedStopCause = stopCause({ cause, source, actor, runId: this.options.runId, checkpoint: this.options.checkpoint(), at: this.now().toISOString(), requested: true });
51
+ this.options.onStopRequested?.(this.requestedStopCause);
17
52
  void this.active?.stop();
18
53
  }
19
54
  async run() {
@@ -64,24 +99,35 @@ export class DurableSupervisor {
64
99
  db.putSupervisorState(this.options.runId, state);
65
100
  });
66
101
  let lastCheckpoint = this.options.checkpoint();
67
- const heartbeat = setInterval(() => {
68
- state = { ...state, heartbeatAt: this.now().toISOString() };
102
+ let heartbeatInFlight = false;
103
+ const persistHeartbeat = async () => {
104
+ if (heartbeatInFlight || this.stopRequested)
105
+ return;
106
+ heartbeatInFlight = true;
69
107
  try {
70
- saveState();
108
+ state = { ...state, heartbeatAt: this.now().toISOString() };
109
+ const persisted = await persistWithBoundedRetry(saveState, this.sleep);
110
+ if (persisted.ok)
111
+ return;
112
+ this.requestStop("heartbeat-persistence-failure");
113
+ // Ownership failure remains fail-closed; the explicit cause is
114
+ // delivered to the worker-exit evidence by supervisedStart.
71
115
  }
72
- catch {
73
- this.requestStop();
116
+ finally {
117
+ heartbeatInFlight = false;
74
118
  }
75
- }, 10_000);
119
+ };
120
+ let heartbeatTask;
121
+ const heartbeat = setInterval(() => { heartbeatTask = persistHeartbeat(); }, this.options.heartbeatIntervalMs ?? 10_000);
76
122
  heartbeat.unref();
77
123
  try {
78
124
  while (!this.stopRequested) {
79
125
  const pending = db.pendingHumanDecisions(this.options.runId);
80
126
  if (pending.some(decision => !this.options.policy.continueIndependentTickets || !decision.interruptionId.startsWith("ticket:"))) {
81
- state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString() };
127
+ state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString(), stopCause: stopCause({ cause: "human-decision", source: "human-decision", actor: "supervisor", runId: this.options.runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, checkpoint: "waiting-for-human", at: this.now().toISOString(), requested: true }) };
82
128
  saveState();
83
129
  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" });
130
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "waiting-for-human", event: "worker_waiting_for_human", payload: { cause: state.stopCause } });
85
131
  return { kind: "waiting_for_human" };
86
132
  }
87
133
  const checkpoint = this.options.checkpoint();
@@ -92,22 +138,27 @@ export class DurableSupervisor {
92
138
  this.active = await this.options.spawnWorker(state.workerGeneration);
93
139
  state = { ...state, workerPid: this.active.pid };
94
140
  saveState();
141
+ const workerPid = state.workerPid;
95
142
  const outcome = await this.active.result;
96
143
  this.active = undefined;
97
144
  state = { ...state, workerPid: undefined, heartbeatAt: this.now().toISOString() };
145
+ const cause = this.requestedStopCause
146
+ ? stopCause({ ...this.requestedStopCause, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, pid: workerPid, processStart: workerPid ? processStartIdentity(workerPid) : undefined, checkpoint: this.options.checkpoint(), at: this.now().toISOString() })
147
+ : stopCause({ cause: "worker-exit", source: "worker", actor: "worker", runId: this.options.runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, pid: workerPid, checkpoint: this.options.checkpoint(), at: this.now().toISOString(), requested: false, detail: outcome.kind === "failed" ? outcome.detail : `worker outcome: ${outcome.kind}` });
148
+ state = { ...state, stopCause: cause };
98
149
  if (outcome.kind !== "crashed") {
99
150
  if (outcome.kind === "failed" && db.getRun(this.options.runId)?.status === "running") {
100
151
  const eligibility = db.preparationEligibility(this.options.runId);
101
152
  db.transitionSupervisor(this.options.runId, state.workerGeneration, {
102
153
  status: eligibility.eligible ? "failed" : "paused",
103
154
  checkpoint: eligibility.eligible ? "worker-failed" : "worker-reconciliation-required",
104
- event: "worker_exit_recorded", payload: { detail: outcome.detail, reasons: eligibility.reasons },
155
+ event: "worker_exit_recorded", payload: { detail: outcome.detail, reasons: eligibility.reasons, cause },
105
156
  });
106
157
  }
107
158
  else if (db.getRun(this.options.runId)?.status === "running")
108
159
  db.transitionSupervisor(this.options.runId, state.workerGeneration, {
109
160
  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 },
161
+ event: "worker_exit_recorded", payload: { outcome: outcome.kind, cause },
111
162
  });
112
163
  const status = outcome.kind === "completed" ? "stopped" : outcome.kind === "waiting_for_human" ? "waiting_for_human" : outcome.kind === "failed" ? "failed" : "stopped";
113
164
  state = { ...state, status };
@@ -118,7 +169,10 @@ export class DurableSupervisor {
118
169
  if (!eligibility.eligible) {
119
170
  state = { ...state, status: "waiting_for_human" };
120
171
  saveState();
121
- db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-reconciliation-required", event: "worker_restart_withheld", payload: { reasons: eligibility.reasons } });
172
+ const cause = stopCause({ ...state.stopCause, cause: "worker-reconciliation-required", source: "reconciliation", actor: "supervisor", checkpoint: "worker-reconciliation-required", at: this.now().toISOString(), requested: false, detail: eligibility.reasons.join("; ") });
173
+ state = { ...state, stopCause: cause };
174
+ saveState();
175
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-reconciliation-required", event: "worker_restart_withheld", payload: { reasons: eligibility.reasons, cause } });
122
176
  return { kind: "waiting_for_human" };
123
177
  }
124
178
  const checkpointLimit = this.options.policy.limits.workerRestartsPerCheckpoint;
@@ -128,7 +182,7 @@ export class DurableSupervisor {
128
182
  saveState();
129
183
  if (db.getRun(this.options.runId)?.status === "running")
130
184
  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 },
185
+ status: "failed", checkpoint: "worker-restart-budget-exhausted", event: "worker_restart_budget_exhausted", payload: { checkpoint, detail: outcome.detail, cause: state.stopCause },
132
186
  });
133
187
  return { kind: "failed", detail: `worker restart budget exhausted at ${checkpoint}: ${outcome.detail}` };
134
188
  }
@@ -136,26 +190,29 @@ export class DurableSupervisor {
136
190
  saveState();
137
191
  await this.sleep(Math.min(10_000, 250 * (2 ** Math.min(5, state.checkpointRestarts - 1))));
138
192
  }
139
- state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined };
193
+ state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined, stopCause: state.stopCause ?? this.requestedStopCause ?? stopCause({ cause: "unknown", source: "host", actor: "unknown", runId: this.options.runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, checkpoint: "worker-stopped", at: this.now().toISOString(), requested: true, detail: "Supervisor stopped without a more specific durable cause" }) };
140
194
  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" });
195
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-stopped", event: "supervisor_stop_recorded", payload: { cause: state.stopCause } });
142
196
  saveState();
143
197
  return { kind: "stopped" };
144
198
  }
145
199
  catch (error) {
146
- state = { ...state, status: "failed", workerPid: undefined, heartbeatAt: this.now().toISOString() };
200
+ state = { ...state, status: "failed", workerPid: undefined, heartbeatAt: this.now().toISOString(), stopCause: stopCause({ cause: "launch-failure", source: "launch", actor: "supervisor", runId: this.options.runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, checkpoint: "worker-launch-failed", at: this.now().toISOString(), requested: false, detail: String(error) }) };
147
201
  saveState();
148
202
  if (db.getRun(this.options.runId)?.status === "running") {
149
203
  const eligibility = db.preparationEligibility(this.options.runId);
204
+ const cause = stopCause({ cause: "launch-failure", source: "launch", actor: "supervisor", runId: this.options.runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, checkpoint: eligibility.eligible ? "worker-launch-failed" : "worker-reconciliation-required", at: new Date().toISOString(), requested: false, detail: String(error) });
150
205
  db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: eligibility.eligible ? "failed" : "paused",
151
206
  checkpoint: eligibility.eligible ? "worker-launch-failed" : "worker-reconciliation-required", event: "worker_launch_failed",
152
- payload: { detail: String(error), reasons: eligibility.reasons },
207
+ payload: { detail: String(error), reasons: eligibility.reasons, cause },
153
208
  });
154
209
  }
155
210
  throw error;
156
211
  }
157
212
  finally {
158
213
  clearInterval(heartbeat);
214
+ if (heartbeatTask)
215
+ await heartbeatTask;
159
216
  try {
160
217
  if (ownedAdmission && db.buildAdmission()?.token === ownedAdmission.token)
161
218
  db.releaseBuildAdmission(ownedAdmission);
@@ -3,7 +3,9 @@ import { canonicalJson } from "./qaProtocolV2.js";
3
3
  import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
4
  import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
5
5
  import { bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
6
- import { applyHostQaComparison, findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
6
+ import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
7
+ import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
8
+ import { applyHostQaAugmentations } from "./hostQaAugmentation.js";
7
9
  import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
8
10
  import { GraphStore, migrateGraphStore } from "./graph/storage.js";
9
11
  import { reconcileWork } from "./buildWorkReconciliation.js";
@@ -54,6 +56,7 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
54
56
  throw new Error("workflow recovery database not found");
55
57
  const db = new Database(path, { fileMustExist: true });
56
58
  db.function("rafi_protocol_v3", () => 1);
59
+ db.pragma("busy_timeout = 5000");
57
60
  registerHandbackWriter(db);
58
61
  try {
59
62
  const at = now.toISOString();
@@ -144,6 +147,10 @@ export class WorkflowDb {
144
147
  registerQaGraphJournalWriter(this.db);
145
148
  if (!readinessAccess)
146
149
  this.db.pragma("journal_mode = WAL");
150
+ // Short-lived coordinator/worker writes can overlap with a heartbeat or
151
+ // another durable transition. Let SQLite wait for the owning transaction
152
+ // instead of immediately surfacing SQLITE_BUSY to the supervisor.
153
+ this.db.pragma("busy_timeout = 5000");
147
154
  this.db.pragma("synchronous = FULL");
148
155
  this.db.pragma("foreign_keys = ON");
149
156
  try {
@@ -2086,9 +2093,6 @@ export class WorkflowDb {
2086
2093
  for (const method of check.verification)
2087
2094
  if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
2088
2095
  throw new Error("Equivalent verification authority is stale or foreign");
2089
- const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
2090
- if (errors.length)
2091
- throw new Error(errors.join("; "));
2092
2096
  const initialTurns = this.db.prepare("SELECT intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND source_state_digest=? AND review_basis_digest=?").all(input.runId, input.ticketId, attempt.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest);
2093
2097
  const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
2094
2098
  if (!initial || initial.providerSession.sessionId !== binding.sessionId)
@@ -2110,16 +2114,34 @@ export class WorkflowDb {
2110
2114
  })
2111
2115
  : undefined;
2112
2116
  let retainedCoverage = retainedProviderCoverage;
2117
+ let retainedHostComparison;
2113
2118
  const hostCheck = findHostQaCheck(contract);
2114
2119
  if (hostCheck) {
2115
2120
  const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
2116
2121
  if (!hostDigest)
2117
2122
  throw new Error("Coverage is missing the host-owned comparison artifact reference");
2118
2123
  const hostResult = hostQaComparisonV1Schema.parse(store.artifact(hostDigest, "host-qa-comparison"));
2119
- retainedCoverage = retainedProviderCoverage
2120
- ? applyHostQaComparison(retainedProviderCoverage, contract, hostResult, hostDigest, this.projectDir)
2124
+ retainedHostComparison = { result: hostResult, resultDigest: hostDigest };
2125
+ }
2126
+ const graphCheck = findGraphQaChecks(contract)[0];
2127
+ if (graphCheck) {
2128
+ const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
2129
+ if (!graphDigest)
2130
+ throw new Error("Coverage is missing the host-owned Graphify applicability artifact reference");
2131
+ const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(graphDigest, "host-graph-applicability"));
2132
+ const retainedHostGraph = { result: graphResult, resultDigest: graphDigest };
2133
+ retainedCoverage = retainedCoverage
2134
+ ? applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison, hostGraph: retainedHostGraph })
2121
2135
  : undefined;
2122
2136
  }
2137
+ else if (retainedCoverage && retainedHostComparison) {
2138
+ retainedCoverage = applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison });
2139
+ }
2140
+ if (!retainedCoverage)
2141
+ throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
2142
+ const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
2143
+ if (errors.length)
2144
+ throw new Error(errors.join("; "));
2123
2145
  if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
2124
2146
  throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
2125
2147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.28",
3
+ "version": "1.7.29",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -311,8 +311,8 @@
311
311
  "luxon": "^3.7.2",
312
312
  "parse5": "7.2.1",
313
313
  "pdfjs-dist": "4.10.38",
314
- "rafi-spec": "0.8.17",
315
- "special-agents": "0.8.17",
314
+ "rafi-spec": "0.8.18",
315
+ "special-agents": "0.8.18",
316
316
  "yaml": "^2.5.1",
317
317
  "zod": "4.4.3"
318
318
  },