ai-foreman 2.0.0 → 2.0.1

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 (50) hide show
  1. package/dist/adapters/claude.js +4 -3
  2. package/dist/adapters/codex.d.ts +2 -0
  3. package/dist/adapters/codex.js +7 -2
  4. package/dist/branch/git.js +3 -8
  5. package/dist/buildApproval.js +3 -9
  6. package/dist/buildRuns.d.ts +1 -0
  7. package/dist/buildRuns.js +6 -1
  8. package/dist/cli/doctor.js +3 -1
  9. package/dist/cli/start.js +63 -13
  10. package/dist/cli/tickets.d.ts +0 -1
  11. package/dist/cli/tickets.js +11 -22
  12. package/dist/continuity.d.ts +0 -2
  13. package/dist/continuity.js +30 -34
  14. package/dist/dispatchNotSent.d.ts +4 -0
  15. package/dist/dispatchNotSent.js +10 -0
  16. package/dist/foreman.d.ts +5 -12
  17. package/dist/foreman.js +19 -85
  18. package/dist/gitExclude.d.ts +16 -0
  19. package/dist/gitExclude.js +46 -0
  20. package/dist/graph/turn.d.ts +1 -1
  21. package/dist/graph/turn.js +1 -1
  22. package/dist/handoffs.d.ts +3 -3
  23. package/dist/handoffs.js +5 -32
  24. package/dist/humanDecision.d.ts +7 -0
  25. package/dist/humanDecision.js +11 -0
  26. package/dist/observability.js +3 -8
  27. package/dist/qaBuildGate.js +13 -1
  28. package/dist/qaPreparation.js +21 -7
  29. package/dist/qaPreparationStore.d.ts +11 -0
  30. package/dist/qaPreparationStore.js +44 -4
  31. package/dist/qaRecovery.d.ts +0 -3
  32. package/dist/qaRecovery.js +1 -24
  33. package/dist/qaReview.js +32 -22
  34. package/dist/qaSnapshot.js +19 -14
  35. package/dist/qaStructuredResponse.d.ts +8 -0
  36. package/dist/qaStructuredResponse.js +24 -0
  37. package/dist/qaTimeline.js +3 -5
  38. package/dist/recoveryOwners.d.ts +32 -0
  39. package/dist/recoveryOwners.js +87 -0
  40. package/dist/runtimePreflight.d.ts +11 -0
  41. package/dist/runtimePreflight.js +77 -0
  42. package/dist/sources/sourceRegistry.d.ts +0 -3
  43. package/dist/sources/sourceRegistry.js +0 -42
  44. package/dist/structuredOperation.js +26 -4
  45. package/dist/ticketPlanning.d.ts +0 -3
  46. package/dist/ticketPlanning.js +0 -23
  47. package/dist/ticketPopulationPaging.js +23 -1
  48. package/dist/workflowDb.d.ts +16 -1
  49. package/dist/workflowDb.js +89 -22
  50. package/package.json +11 -3
@@ -1,4 +1,5 @@
1
1
  import { sealTicketPopulationPage } from "./ticketPopulationPageAuthority.js";
2
+ import { dispatchWasNotSent } from "./dispatchNotSent.js";
2
3
  import { boundedUtf8 } from "./boundedTurnEvidence.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { assertIntermediateResultV2, historicProviderWireContract, providerWireContract, renderProviderWireInstruction, selectedDispatchTupleV2, structuredDigest, validateStructuredCandidate } from "rafi-spec";
@@ -76,6 +77,8 @@ export async function journalStructuredDispatch(root, adapter, instruction, poli
76
77
  const db = new WorkflowDb(root);
77
78
  try {
78
79
  db.enableStructuredCommunication();
80
+ // A single agent call outside a build journals under its own run, which is
81
+ // never listed or resumed as a build.
79
82
  db.ensureRun(runId);
80
83
  if (adapter.postConfirmedDispatch)
81
84
  for (const pendingId of db.pendingStructuredPostprocessing(runId)) {
@@ -153,25 +156,31 @@ export async function journalStructuredDispatch(root, adapter, instruction, poli
153
156
  turn = await withSealedDispatch(root, tuple, dispatchCapability => dispatch(renderedInstruction, { ...sealedPolicy, journalRunId: runId, dispatchCapability }), before);
154
157
  }
155
158
  catch (error) {
159
+ // A host refusal before transport is a known unsent turn, not an unknown outcome.
156
160
  if (db.operation(key)?.status === "in_progress")
157
- db.updateOperation(key, "uncertain", { error: boundedUtf8(String(error), 4000).toString() });
161
+ db.updateOperation(key, dispatchWasNotSent(error) ? "failed" : "uncertain", { error: boundedUtf8(String(error), 4000).toString(), ...(dispatchWasNotSent(error) ? { result: { dispatchState: "not-sent" } } : {}) });
158
162
  throw error;
159
163
  }
160
164
  const after = adapter.sessionRef?.();
161
165
  const sameScope = before && after && before.provider === after.provider && before.role === after.role &&
162
166
  before.stream === after.stream && before.cwd === after.cwd && before.configRoot === after.configRoot &&
163
167
  before.workspaceIdentity === after.workspaceIdentity && before.ticketId === after.ticketId && before.deliveryUnitId === after.deliveryUnitId;
168
+ // A receipt from another dispatch, session or generation leaves this
169
+ // dispatch's own outcome unknown.
170
+ let receiptMismatch = false;
164
171
  const observedCompaction = sameScope && (adapter.contractCompactionSequence?.() ?? 0) > compactionBefore && after.generation >= before.generation;
165
172
  if (turn.structured?.state === "candidate" && (turn.structured.profile !== wireContract.profile || turn.structured.wireContractDigest !== wireContract.descriptorDigest ||
166
173
  turn.structured.wireSchemaDigest !== wireContract.wireSchemaDigest || turn.structured.wireVariant !== wireContract.variant || turn.structured.dispatchId !== key || turn.structured.sessionId !== adapter.sessionId() ||
167
174
  before && (!sameScope || !observedCompaction && (before.sessionId !== after.sessionId || before.generation !== after.generation)))) {
168
175
  turn = { ...turn, selectedDispatch: tuple, isError: true, structured: { state: "rejected", reason: "invalid-contract", detail: "Dispatch/session/generation receipt mismatch", dispatchId: key } };
176
+ receiptMismatch = true;
169
177
  }
170
178
  // Exact raw provider material is retained once in the evidence store. The
171
179
  // journal only carries the digest/reference and validation fact, so it
172
180
  // cannot become a second, differently-redacted copy of the provider turn.
173
181
  turn = { ...turn, selectedDispatch: tuple };
174
182
  let receiptValidated = false;
183
+ let requirementRejected = false;
175
184
  if (turn.structured?.state === "rejected")
176
185
  turn = { ...turn, isError: true };
177
186
  else if (!turn.isError && !turn.failure) {
@@ -179,7 +188,13 @@ export async function journalStructuredDispatch(root, adapter, instruction, poli
179
188
  const response = verifyPersistedDispatch(policy.output, db.structuredOwnerDispatch(key), turn.structured, tuple);
180
189
  // A response that cannot satisfy host requirements is rejected before
181
190
  // confirmation instead of failing in postprocessing after it.
182
- requirement?.validate(response);
191
+ try {
192
+ requirement?.validate(response);
193
+ }
194
+ catch (error) {
195
+ requirementRejected = true;
196
+ throw error;
197
+ }
183
198
  receiptValidated = true;
184
199
  }
185
200
  catch (error) {
@@ -210,13 +225,20 @@ export async function journalStructuredDispatch(root, adapter, instruction, poli
210
225
  const rawDigest = db.putEvidence("handoff", retainedRaw);
211
226
  const responseDigest = db.putEvidence("handoff", Buffer.from(JSON.stringify(retained)));
212
227
  db.recordCommunicationEvidenceRefs(key, [responseDigest, rawDigest, ...(retained.valueDigest ? [retained.valueDigest] : []), ...(retained.rejectedValue ? [retained.rejectedValue.evidenceDigest] : [])]);
213
- const status = turn.failure?.dispatchState === "not-sent" ? "failed" : turn.failure?.dispatchState === "unknown" || !receiptValidated ? "uncertain" : "confirmed";
228
+ // A provider that finished its turn with a response that fails validation
229
+ // has a known outcome: the answer was rejected. Only a missing or
230
+ // interrupted turn leaves the outcome unknown.
231
+ // Only the answer's own content counts: a wire-schema violation or a failed
232
+ // host requirement. Binding and receipt failures stay unknown.
233
+ const answeredInvalid = !receiptValidated && !turn.failure && !receiptMismatch && turn.structured?.state === "rejected" && turn.structured.reason === "invalid-contract"
234
+ && (requirementRejected || turn.structured.detail.includes("Structured result violates wire schema"));
235
+ const status = turn.failure?.dispatchState === "not-sent" || answeredInvalid ? "failed" : turn.failure?.dispatchState === "unknown" || !receiptValidated ? "uncertain" : "confirmed";
214
236
  turn = { ...turn, transportEvidence: { rawDigest, responseDigest, rawBytes: raw.length, retainedRawBytes: retainedRaw.length,
215
237
  valueDigest: retained.valueDigest, rejectedValueDigest: retained.rejectedValue?.evidenceDigest } };
216
238
  db.atomic(() => {
217
239
  if (status === "confirmed" && adapter.postConfirmedDispatch)
218
240
  db.requireStructuredPostprocessing(key, { instruction, policy: continuationPolicy(selected, key) });
219
- db.updateOperation(key, status, { result: { responseDigest, rawDigest, rawBytes: raw.length, retainedRawBytes: retainedRaw.length, validation: receiptValidated ? "accepted" : turn.structured?.state ?? "missing-receipt", wireContractDigest: wireContract.descriptorDigest } });
241
+ db.updateOperation(key, status, { result: { responseDigest, rawDigest, rawBytes: raw.length, retainedRawBytes: retainedRaw.length, validation: receiptValidated ? "accepted" : turn.structured?.state ?? "missing-receipt", wireContractDigest: wireContract.descriptorDigest, ...(answeredInvalid ? { dispatchState: "answered-invalid" } : {}) } });
220
242
  });
221
243
  if (status === "confirmed" && adapter.postConfirmedDispatch) {
222
244
  try {
@@ -2,8 +2,6 @@ import type { TicketDef } from "./tickets/ticketSchema.js";
2
2
  import { type TicketStatus } from "./tickets/stateDb.js";
3
3
  import { type DeliveryConfig } from "./tickets/delivery.js";
4
4
  import { type TicketBuildSetupConfig } from "./tickets/setupConfig.js";
5
- export declare const PROPOSAL_START = "RAFI_PROPOSAL_START";
6
- export declare const PROPOSAL_END = "RAFI_PROPOSAL_END";
7
5
  export interface TicketEdit {
8
6
  id: string;
9
7
  patch: Partial<Omit<TicketDef, "id">>;
@@ -72,7 +70,6 @@ export { ticketPlanProposalV1Schema } from "rafi-spec";
72
70
  export declare function readTicketPlanningContext(projectDir: string): TicketPlanningContext;
73
71
  export declare function ticketPlanningFingerprint(projectDir: string): PlanningFingerprint[];
74
72
  export declare function planningFingerprintChanges(projectDir: string, expected: PlanningFingerprint[]): string[];
75
- export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): TicketPlanProposal;
76
73
  export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): string[];
77
74
  export interface ApplyTicketPlanOptions {
78
75
  expectedFingerprint: PlanningFingerprint[];
@@ -11,8 +11,6 @@ import { runAllValidation } from "./tickets/validate.js";
11
11
  import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/delivery.js";
12
12
  import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
13
13
  import { ticketPlanProposalV1Schema, zodIssues } from "rafi-spec";
14
- export const PROPOSAL_START = "RAFI_PROPOSAL_START";
15
- export const PROPOSAL_END = "RAFI_PROPOSAL_END";
16
14
  export { ticketPlanProposalV1Schema } from "rafi-spec";
17
15
  const TICKET_STATUSES = new Set([
18
16
  "planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete",
@@ -46,27 +44,6 @@ export function ticketPlanningFingerprint(projectDir) {
46
44
  export function planningFingerprintChanges(projectDir, expected) {
47
45
  return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
48
46
  }
49
- export function extractTicketPlanProposal(output, existingTickets = [], context = {}) {
50
- const start = output.lastIndexOf(PROPOSAL_START);
51
- const end = output.lastIndexOf(PROPOSAL_END);
52
- if (start < 0 || end <= start)
53
- throw new Error(`planner output is missing ${PROPOSAL_START}/${PROPOSAL_END}`);
54
- const raw = output.slice(start + PROPOSAL_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(raw);
58
- }
59
- catch (err) {
60
- throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
61
- }
62
- const contract = ticketPlanProposalV1Schema.safeParse(parsed);
63
- if (!contract.success)
64
- throw new Error(`planner proposal contract validation failed:\n${zodIssues(contract.error).map((issue) => `- ${issue}`).join("\n")}`);
65
- const issues = validateTicketPlanProposal(contract.data, existingTickets, context);
66
- if (issues.length)
67
- throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
68
- return contract.data;
69
- }
70
47
  export function validateTicketPlanProposal(value, existingTickets = [], context = {}) {
71
48
  const issues = [];
72
49
  const contract = ticketPlanProposalV1Schema.safeParse(value);
@@ -6,10 +6,10 @@ import { assertTicketPopulationPageBinding, withTicketPopulationPage } from "./t
6
6
  /** Each provider page is sealed and durably appended before aggregate interpretation. */
7
7
  export async function streamTicketPopulation(input) {
8
8
  const runId = `ticket-population:${input.interviewId}`;
9
- const artifactId = `${runId}:artifact:${input.correction}`;
10
9
  const db = new WorkflowDb(input.projectDir);
11
10
  try {
12
11
  db.enableStructuredCommunication();
12
+ const artifactId = restartableArtifactId(db, runId, `${runId}:artifact:${input.correction}`);
13
13
  const prior = db.operation(artifactId);
14
14
  if (prior) {
15
15
  const retained = prior.intent;
@@ -103,3 +103,25 @@ export async function streamTicketPopulation(input) {
103
103
  db.close();
104
104
  }
105
105
  }
106
+ /**
107
+ * An earlier population attempt that never reached its terminal page (a bad
108
+ * answer, a provider crash or a stalled turn, or Rafi itself stopping) is
109
+ * retired with its unanswered page calls, and population restarts from its
110
+ * first page under a new lineage with the same instruction. A terminal
111
+ * lineage is kept so its retained pages can be replayed.
112
+ */
113
+ function restartableArtifactId(db, runId, baseId) {
114
+ for (let restart = 0;; restart++) {
115
+ const artifactId = restart ? `${baseId}:restart-${restart}` : baseId;
116
+ const prior = db.operation(artifactId);
117
+ if (!prior || db.populationPageLedger(artifactId)?.state === "terminal")
118
+ return artifactId;
119
+ if (prior.status === "failed")
120
+ continue;
121
+ db.atomic(() => {
122
+ for (const dispatch of db.operations(runId).filter(op => op.kind === "provider-dispatch" && ["in_progress", "uncertain"].includes(op.status)))
123
+ db.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "population-restart", outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result }, error: "Unfinished ticket population page; population restarts from its first page" });
124
+ db.updateOperation(artifactId, "failed", { result: { recovery: "population-restart", priorResult: prior.result }, error: "Unfinished ticket population lineage retired; population restarts from its first page" });
125
+ });
126
+ }
127
+ }
@@ -13,7 +13,10 @@ import type { QaDeliveryTurn, QaDeliveryOutcome, QaDeliveryInvocationV3 } from "
13
13
  import type { BranchResumeSession } from "./branch/resume.js";
14
14
  import { type BuilderRemediationReceiptV2, type BuilderRemediationReceiptV3, type FrozenQaSourceStateV2, type HandoffAcceptanceReceiptV2, type QaPassCertificateV2, type QaReducerEventV2, type QaReducerStateV2, type QaReportDisposition, type QaReviewBasisV2, type QaTurnIntentV2, type QaTurnReceiptV2 } from "./qaProtocolV2.js";
15
15
  export declare const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
16
- export type WorkflowKind = "plan" | "ticket-plan" | "ticket-populate" | "uninstall" | "build" | "qa-remediation" | "recovery" | "legacy";
16
+ export type WorkflowKind = "plan" | "ticket-plan" | "ticket-populate" | "uninstall" | "build" | "qa-remediation" | "recovery" | "legacy" | "operation";
17
+ /** Journal runs for agent calls outside a build use these run-id prefixes. */
18
+ export declare const NON_BUILD_RUN_PREFIXES: readonly ["operation:", "ticket-population:", "qa-"];
19
+ export declare function isNonBuildRunId(runId: string): boolean;
17
20
  export type WorkflowRunStatus = "running" | "paused" | "blocked" | "completed" | "failed" | "cancelled" | "superseded";
18
21
  export interface WorkflowRunSnapshot {
19
22
  runId: string;
@@ -419,6 +422,8 @@ export declare class WorkflowDb {
419
422
  humanDecisionKey(decisionId: string): string | undefined;
420
423
  humanDecision(decisionId: string): PendingHumanDecision | undefined;
421
424
  pendingHumanDecisions(runId: string): PendingHumanDecision[];
425
+ /** Answered decisions for a run, oldest first (e.g. answered with build:decide while no worker waited). */
426
+ answeredHumanDecisions(runId: string): PendingHumanDecision[];
422
427
  decisionContinuationAvailable(runId: string, decisionId: string): boolean;
423
428
  nextDecisionContinuationKey(runId: string, decisionId: string): string;
424
429
  answeredTicketDecisions(runId: string, scopeRevision: string): PendingHumanDecision[];
@@ -588,6 +593,16 @@ export declare class WorkflowDb {
588
593
  */
589
594
  enableStructuredCommunication(): void;
590
595
  beginQaTurn(intent: QaTurnIntentV2): void;
596
+ /** Retire this run's unresolved dispatches on a provider session the host
597
+ * has just closed after a failed call. The closed provider can no longer
598
+ * act, so the call's outcome is final: unknown, never replayed. */
599
+ retireClosedSessionDispatches(runId: string, sessionId: string | undefined, reason: string): string[];
600
+ /** Retire interrupted role turns that are not implementation or bound QA
601
+ * review work (for example build preflight, coverage, or handoff
602
+ * acceptance) so the step restarts from its beginning. Implementation and
603
+ * review turns keep their own recovery. The interrupted outcome stays unknown and is
604
+ * never replayed or treated as a result. Returns the retired dispatch IDs. */
605
+ reconcileInterruptedSteps(runId: string, role: "builder" | "qa"): string[];
591
606
  /** Retire an interrupted, proven read-only review transport, never a QA pass
592
607
  * or Builder remediation. A fresh immutable review must still run afterward. */
593
608
  reconcileInterruptedQaDispatch(runId: string, ticketId: string): boolean;
@@ -1,3 +1,4 @@
1
+ import { ensureLocalGitExcludes } from "./gitExclude.js";
1
2
  import { persistedProviderWireContract } from "rafi-spec";
2
3
  import { verifyCheckpointOrigin, verifyContinuityHeads } from "./continuityOrigin.js";
3
4
  import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
@@ -22,7 +23,7 @@ import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmissio
22
23
  import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
23
24
  import { originalBuildLease, rememberOriginalLease, forgetOriginalLease, registerLaunchChild, acknowledgeLaunchChild, reconcileLaunch, checkBuildOwnershipSchema, canonicalProject, launchDigest, localBuildAuthority, rememberBuildAuthority, forgetBuildAuthority, migrateBuildAdmission, acquireAdmission, readAdmission, assertAdmission, releaseAdmission, reserveLaunch, setLaunchState, claimLaunch, readLaunch } from "./buildAdmission.js";
24
25
  import { createHash, randomUUID } from "node:crypto";
25
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
26
27
  import { hostname } from "node:os";
27
28
  import { dirname, join, resolve } from "node:path";
28
29
  import { execFileSync } from "node:child_process";
@@ -36,6 +37,22 @@ import { loadTickets } from "./tickets/ticketLoader.js";
36
37
  import { processGroupQuiescent, taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
37
38
  import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
38
39
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
40
+ /** Role steps that change no project files, so an interrupted one can run
41
+ * again from its beginning with its original inputs. */
42
+ // QA preparation runs in a disposable snapshot, so its steps are restartable too.
43
+ const QA_PREPARATION_STEPS = ["qa-preparation-depth", "qa-preparation-draft", "qa-preparation-assessment", "qa-preparation-challenge"];
44
+ const RESTARTABLE_STEPS = {
45
+ builder: new Set(["build-preflight", "session-initialization", "handoff-acceptance", "builder-coverage", "branch-dependency-audit", "qa-contract-acceptance", "qa-contract-segment", "status", "escalation", ...QA_PREPARATION_STEPS]),
46
+ qa: new Set(["session-initialization", "handoff-acceptance", "qa-recovery-acceptance", "qa-contract-acceptance", "qa-contract-segment", ...QA_PREPARATION_STEPS]),
47
+ };
48
+ /** Journal runs for agent calls outside a build use these run-id prefixes. */
49
+ export const NON_BUILD_RUN_PREFIXES = ["operation:", "ticket-population:", "qa-"];
50
+ export function isNonBuildRunId(runId) {
51
+ return NON_BUILD_RUN_PREFIXES.some(prefix => runId.startsWith(prefix));
52
+ }
53
+ function defaultRunKind(runId) {
54
+ return isNonBuildRunId(runId) ? "operation" : "build";
55
+ }
39
56
  /** Read the current project lease without creating or migrating recovery state. */
40
57
  export function readCurrentWorkflowLease(projectDir) {
41
58
  const path = join(resolve(projectDir), WORKFLOW_DB_FILE);
@@ -440,7 +457,10 @@ export class WorkflowDb {
440
457
  if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
441
458
  throw new Error(`supervisor already active for project (run ${owner.runId})`);
442
459
  }
443
- const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get();
460
+ // Single agent calls outside builds ("operation:<id>" runs: planning,
461
+ // ticket population, uninstall proposals) are read-only proposals; an
462
+ // unknown outcome there cannot conflict with a build.
463
+ const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get();
444
464
  if (uncertain)
445
465
  throw new Error(`Build ${uncertain.run_id} has unresolved provider dispatch; reconcile before another launch`);
446
466
  for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
@@ -465,7 +485,7 @@ export class WorkflowDb {
465
485
  return this.atomic(() => {
466
486
  this.assertExecutionProtocol();
467
487
  this.assertNoLegacyBuildOwner();
468
- const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get(runId);
488
+ const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get(runId);
469
489
  if (otherDispatch)
470
490
  throw new Error(`Build ${otherDispatch.run_id} has unresolved provider dispatch; reconcile before starting another recovery workflow`);
471
491
  const current = localBuildAuthority(this.projectDir);
@@ -827,7 +847,7 @@ export class WorkflowDb {
827
847
  return { eligible: reasons.length === 0, reasons };
828
848
  }
829
849
  /** Ensure non-workflow build records can use the same durable event store. */
830
- ensureRun(runId, kind = "build", now = new Date()) {
850
+ ensureRun(runId, kind = defaultRunKind(runId), now = new Date()) {
831
851
  const existing = this.getRun(runId);
832
852
  if (existing)
833
853
  return existing;
@@ -1284,6 +1304,11 @@ export class WorkflowDb {
1284
1304
  return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
1285
1305
  .map((row) => parseJson(row.decision_json));
1286
1306
  }
1307
+ /** Answered decisions for a run, oldest first (e.g. answered with build:decide while no worker waited). */
1308
+ answeredHumanDecisions(runId) {
1309
+ return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='answered' ORDER BY created_at").all(runId)
1310
+ .map((row) => parseJson(row.decision_json));
1311
+ }
1287
1312
  decisionContinuationAvailable(runId, decisionId) {
1288
1313
  const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
1289
1314
  return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
@@ -2199,6 +2224,50 @@ export class WorkflowDb {
2199
2224
  .run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
2200
2225
  })();
2201
2226
  }
2227
+ /** Retire this run's unresolved dispatches on a provider session the host
2228
+ * has just closed after a failed call. The closed provider can no longer
2229
+ * act, so the call's outcome is final: unknown, never replayed. */
2230
+ retireClosedSessionDispatches(runId, sessionId, reason) {
2231
+ if (!sessionId)
2232
+ return [];
2233
+ const dispatches = [...this.unresolvedRoleDispatches(runId, "builder"), ...this.unresolvedRoleDispatches(runId, "qa")]
2234
+ .filter(dispatch => dispatch.intent.sessionRef?.sessionId === sessionId);
2235
+ this.atomic(() => {
2236
+ for (const dispatch of dispatches)
2237
+ this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "provider-closed", outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result }, error: reason });
2238
+ });
2239
+ return dispatches.map(dispatch => dispatch.idempotencyKey);
2240
+ }
2241
+ /** Retire interrupted role turns that are not implementation or bound QA
2242
+ * review work (for example build preflight, coverage, or handoff
2243
+ * acceptance) so the step restarts from its beginning. Implementation and
2244
+ * review turns keep their own recovery. The interrupted outcome stays unknown and is
2245
+ * never replayed or treated as a result. Returns the retired dispatch IDs. */
2246
+ reconcileInterruptedSteps(runId, role) {
2247
+ // Only steps that are safe to run again from their beginning. Implementation
2248
+ // turns, bound QA review turns and turns of unknown kind keep their own recovery.
2249
+ const reviewTurn = this.db.prepare("SELECT 1 FROM qa_turns WHERE operation_id=?");
2250
+ const dispatches = this.unresolvedRoleDispatches(runId, role).filter(dispatch => {
2251
+ const operation = String(dispatch.intent.operation ?? "");
2252
+ return RESTARTABLE_STEPS[role].has(operation) && (role === "builder" || !reviewTurn.get(dispatch.idempotencyKey));
2253
+ });
2254
+ if (!dispatches.length)
2255
+ return [];
2256
+ const lease = this.currentLease();
2257
+ if (lease?.runId !== runId || lease.pid !== process.pid)
2258
+ throw new Error("Step recovery requires the original build recovery lease");
2259
+ const worker = this.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
2260
+ if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)
2261
+ || this.readinessProcesses().length)
2262
+ throw new Error("The interrupted step and all owned provider processes must be verified stopped before it restarts");
2263
+ const reconciliationId = `${role}-step-restart:${runId}:${randomUUID()}`;
2264
+ this.atomic(() => {
2265
+ for (const dispatch of dispatches)
2266
+ this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "step-restart", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, operation: dispatch.intent.operation ?? null, priorResult: dispatch.result }, error: "Interrupted step outcome remains unknown; the step restarts from its beginning with its original inputs" });
2267
+ this.appendContinuityEvent({ runId, role: "host", kind: "interrupted_step_restarted", payload: { role, reconciliationId, dispatchIds: dispatches.map(dispatch => dispatch.idempotencyKey), outcomeUnknown: true, providerReplayAuthorized: false }, authoritativeStateRevision: this.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
2268
+ });
2269
+ return dispatches.map(dispatch => dispatch.idempotencyKey);
2270
+ }
2202
2271
  /** Retire an interrupted, proven read-only review transport, never a QA pass
2203
2272
  * or Builder remediation. A fresh immutable review must still run afterward. */
2204
2273
  reconcileInterruptedQaDispatch(runId, ticketId) {
@@ -2225,17 +2294,20 @@ export class WorkflowDb {
2225
2294
  // A cancellation can have a durable host terminal receipt while the
2226
2295
  // provider dispatch is still unknown. Exact frozen-turn bindings remain
2227
2296
  // required; the old receipt is never promoted to an accepted QA result.
2297
+ // A QA turn and its provider dispatch share one ID: the turn's operation ID
2298
+ // is the dispatch's idempotency key. Instruction digests are not comparable
2299
+ // across the two records (the dispatch hashes the full rendered provider
2300
+ // instruction), so binding is by that shared ID.
2301
+ // A host-completed turn is recoverable only when its dispatch recorded a
2302
+ // typed unknown provider outcome.
2228
2303
  const rows = this.db.prepare("SELECT status,intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND status IN ('intended','uncertain','unknown','completed')").all(runId, ticketId);
2229
- const turns = rows.flatMap(row => {
2304
+ const turns = new Map(rows.flatMap(row => {
2230
2305
  const turn = parseJson(row.intent_json);
2231
- if (row.status === "completed" && !dispatches.some(dispatch => {
2232
- const result = dispatch.result;
2233
- return result?.isError === true && result.failure?.dispatchState === "unknown"
2234
- && dispatch.intent.instructionDigest === turn.instructionDigest;
2235
- }))
2306
+ const result = dispatches.find(dispatch => dispatch.idempotencyKey === turn.operationId)?.result;
2307
+ if (row.status === "completed" && !(result?.isError === true && result.failure?.dispatchState === "unknown"))
2236
2308
  return [];
2237
- return [turn];
2238
- });
2309
+ return [[turn.operationId, turn]];
2310
+ }));
2239
2311
  const bindings = dispatches.map(dispatch => {
2240
2312
  const intent = dispatch.intent;
2241
2313
  const ref = intent.sessionRef;
@@ -2243,9 +2315,10 @@ export class WorkflowDb {
2243
2315
  || !ref.configRoot || canonicalProject(ref.configRoot) !== canonicalProject(this.projectDir) || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt))
2244
2316
  || ref.source !== "observed" || providerSessionKey(ref) !== owner.sessionKey)
2245
2317
  throw new Error("QA dispatch has no validated owner binding");
2246
- const turn = turns.find(turn => turn.reviewNumber === head.reviewNumber && turn.instructionDigest === intent.instructionDigest
2247
- && turn.providerSession.sessionId === ref.sessionId && turn.providerSession.provider === ref.provider && turn.providerSession.generation === ref.generation
2248
- && turn.providerSession.cwd === ref.cwd && turn.providerSession.configRoot === ref.configRoot);
2318
+ const candidate = turns.get(dispatch.idempotencyKey);
2319
+ const turn = candidate && candidate.reviewNumber === head.reviewNumber
2320
+ && candidate.providerSession.sessionId === ref.sessionId && candidate.providerSession.provider === ref.provider && candidate.providerSession.generation === ref.generation
2321
+ && candidate.providerSession.cwd === ref.cwd && candidate.providerSession.configRoot === ref.configRoot ? candidate : undefined;
2249
2322
  if (!turn || turn.sourceStateDigest !== head.sourceStateDigest || turn.reviewBasisDigest !== head.reviewBasisDigest)
2250
2323
  throw new Error("QA dispatch does not match the frozen review turn");
2251
2324
  const sessions = this.db.prepare("SELECT session_json FROM qa_sessions WHERE run_id=? AND ticket_id=? AND generation=? AND source_state_digest=? AND review_basis_digest=?").all(runId, ticketId, ref.generation, turn.sourceStateDigest, turn.reviewBasisDigest);
@@ -3636,11 +3709,5 @@ function leaseVerifiedLive(lease, now, staleMs) {
3636
3709
  return classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead";
3637
3710
  }
3638
3711
  function ensureRecoveryGitignore(projectDir) {
3639
- const localExclude = join(projectDir, ".git", "info", "exclude");
3640
- const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
3641
- const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
3642
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
3643
- const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
3644
- if (missing.length)
3645
- appendFileSync(path, `${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
3712
+ ensureLocalGitExcludes(projectDir, [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"], { fallbackToGitignore: true });
3646
3713
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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",
@@ -93,6 +93,14 @@
93
93
  "types": "./dist/workflowReader.d.ts",
94
94
  "default": "./dist/workflowReader.js"
95
95
  },
96
+ "./recovery-owners.js": {
97
+ "types": "./dist/recoveryOwners.d.ts",
98
+ "default": "./dist/recoveryOwners.js"
99
+ },
100
+ "./runtime-preflight.js": {
101
+ "types": "./dist/runtimePreflight.d.ts",
102
+ "default": "./dist/runtimePreflight.js"
103
+ },
96
104
  "./process-identity.js": {
97
105
  "types": "./dist/processIdentity.d.ts",
98
106
  "default": "./dist/processIdentity.js"
@@ -319,8 +327,8 @@
319
327
  "luxon": "^3.7.2",
320
328
  "parse5": "7.2.1",
321
329
  "pdfjs-dist": "4.10.38",
322
- "rafi-spec": "0.9.0",
323
- "special-agents": "0.9.0",
330
+ "rafi-spec": "0.9.1",
331
+ "special-agents": "0.9.1",
324
332
  "yaml": "^2.5.1",
325
333
  "zod": "4.4.3"
326
334
  },