ai-foreman 1.7.24 → 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 (42) 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 +21 -1
  12. package/dist/buildAssignment.js +215 -2
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +8 -1
  16. package/dist/cli/start.js +132 -38
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +20 -2
  19. package/dist/continuity.js +11 -10
  20. package/dist/foreman.d.ts +8 -0
  21. package/dist/foreman.js +69 -10
  22. package/dist/handoffProtocol.d.ts +4 -0
  23. package/dist/handoffProtocol.js +19 -0
  24. package/dist/handoffs.d.ts +1 -1
  25. package/dist/handoffs.js +23 -4
  26. package/dist/permissions/policy.js +18 -11
  27. package/dist/permissions/shellForms.d.ts +3 -0
  28. package/dist/permissions/shellForms.js +203 -0
  29. package/dist/qaBuilderActivation.d.ts +19 -0
  30. package/dist/qaBuilderActivation.js +112 -0
  31. package/dist/qaFailureDelivery.js +1 -1
  32. package/dist/qaRecovery.js +2 -1
  33. package/dist/qaReview.d.ts +4 -0
  34. package/dist/qaReview.js +37 -7
  35. package/dist/sessionLifecycle.js +1 -1
  36. package/dist/supervisedStart.js +23 -3
  37. package/dist/supervisor.js +33 -0
  38. package/dist/tickets/groupReset.js +100 -96
  39. package/dist/tickets/reset.js +80 -69
  40. package/dist/workflowDb.d.ts +16 -1
  41. package/dist/workflowDb.js +276 -8
  42. package/package.json +2 -2
@@ -15,11 +15,15 @@ import { createHash, randomUUID } from "node:crypto";
15
15
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
16
16
  import { hostname } from "node:os";
17
17
  import { dirname, join, resolve } from "node:path";
18
+ import { execFileSync } from "node:child_process";
18
19
  import Database from "better-sqlite3";
19
20
  import { validateQaFailureReport } from "rafi-spec";
20
21
  import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
21
22
  import { providerSessionKey } from "./sessionIdentity.js";
22
- import { taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
23
+ import { captureFrozenQaSource } from "./qaSnapshot.js";
24
+ import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
25
+ import { loadTickets } from "./tickets/ticketLoader.js";
26
+ import { processGroupQuiescent, taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
23
27
  import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
24
28
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
25
29
  /** Read the current project lease without creating or migrating recovery state. */
@@ -63,6 +67,41 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
63
67
  }
64
68
  }
65
69
  /** Authoritative persistence for every resumable workflow in a project. */
70
+ function partialQaRecoveryProcessProof(db, runId, ticketId, launchToken) {
71
+ const run = db.getRun(runId);
72
+ const launch = db.buildLaunch(launchToken);
73
+ const supervisor = db.supervisorState(runId);
74
+ if (!run || run.status !== "paused" || run.state.status !== "recoverable" || run.checkpoint !== "qa-recovery-complete"
75
+ || !launch || launch.runId !== runId || launch.state !== "claimed" || !launch.claimant
76
+ || !supervisor || supervisor.status !== "waiting_for_human" || supervisor.workerPid
77
+ || launch.role !== "coordinator" || launch.claimant.pid !== supervisor.pid || launch.claimant.processStart !== supervisor.processStart
78
+ || !["completed", "waived"].includes(db.qaTicketHead(runId, ticketId).state)
79
+ || !(run.state.progress?.completedTickets?.includes(ticketId))
80
+ || db.pendingHumanDecisions(runId).length || db.readinessProcesses(runId).length
81
+ || db.unresolvedRoleDispatches(runId, "builder").length || db.unresolvedRoleDispatches(runId, "qa").length)
82
+ return undefined;
83
+ const events = db.events(runId);
84
+ const exit = events.filter(event => event.type === "worker_exit").at(-1);
85
+ const started = events.filter(event => event.type === "worker_started").at(-1);
86
+ const exitProof = exit?.payload;
87
+ const startProof = started?.payload;
88
+ if (!exit || !started || exit.at < launch.createdAt || started.at < launch.createdAt
89
+ || !exitProof || !startProof || exitProof.code !== 0 || exitProof.signal != null || exitProof.stopRequested !== false
90
+ || exitProof.generation !== supervisor.workerGeneration || startProof.generation !== exitProof.generation)
91
+ return undefined;
92
+ const worker = started.payload;
93
+ if (!worker.workerPid || !worker.processStart || !worker.host)
94
+ return undefined;
95
+ return { worker: { pid: worker.workerPid, processStart: worker.processStart, host: worker.host }, claimant: launch.claimant,
96
+ launchToken, checkpoint: run.checkpoint, supervisorGeneration: supervisor.generation,
97
+ workerGeneration: supervisor.workerGeneration, startedAt: started.at, exitedAt: exit.at,
98
+ startedSequence: started.sequence, exitedSequence: exit.sequence };
99
+ }
100
+ function partialQaProcessesSettled(proof) {
101
+ return classifyProcess(proof.worker.pid, proof.worker.processStart, proof.worker.host).state === "dead"
102
+ && processGroupQuiescent(proof.worker.pid)
103
+ && classifyProcess(proof.claimant.pid, proof.claimant.processStart, proof.claimant.host).state === "dead";
104
+ }
66
105
  export class WorkflowDb {
67
106
  projectDir;
68
107
  readinessAccess;
@@ -73,6 +112,7 @@ export class WorkflowDb {
73
112
  coordinatorTransition = 0;
74
113
  lineageTransition = 0;
75
114
  workAuthority = 0;
115
+ createdRecoveryIntents = new Set();
76
116
  constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE), readinessAccess) {
77
117
  this.projectDir = projectDir;
78
118
  this.readinessAccess = readinessAccess;
@@ -290,6 +330,70 @@ export class WorkflowDb {
290
330
  /** Atomic local changes only; never await provider or filesystem work here. */
291
331
  atomic(work) { return this.db.transaction(work).immediate(); }
292
332
  buildAdmission() { return readAdmission(this.db); }
333
+ /** Short synchronous tracker mutations share the build fence without retiring
334
+ * provider receipts or granting authority to resume implementation. */
335
+ withTrackerMutationAuthority(work) {
336
+ if (this.writerAuthority) {
337
+ this.assertBuildAdmission(this.writerAuthority);
338
+ return work();
339
+ }
340
+ let authority;
341
+ let lease;
342
+ let intent;
343
+ try {
344
+ const prior = this.buildAdmission();
345
+ if (prior) {
346
+ authority = this.acquireBuildRecoveryAdmission(prior.runId);
347
+ lease = this.acquireLease(prior.runId);
348
+ }
349
+ else {
350
+ this.atomic(() => {
351
+ this.assertNoLegacyBuildOwner();
352
+ for (const owner of this.runningSupervisors()) {
353
+ if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
354
+ throw new Error("A live or unverified supervisor must stop before tracker reset");
355
+ }
356
+ for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
357
+ if (this.unresolvedPreparationProcesses(row.run_id).length)
358
+ throw new Error("Owned preparation descendants require reconciliation before tracker reset");
359
+ }
360
+ intent = this.createRun({ kind: "recovery", checkpoint: "tracker-mutation-intent", state: { operation: "tracker-mutation" } });
361
+ lease = this.acquireLease(intent.runId);
362
+ authority = this.admitLeasedBuild(lease);
363
+ });
364
+ }
365
+ const result = work();
366
+ if (intent)
367
+ this.transition(intent.runId, { status: "completed", checkpoint: "tracker-mutation-completed" });
368
+ return result;
369
+ }
370
+ catch (error) {
371
+ if (intent && this.getRun(intent.runId) && authority && this.buildAdmission()?.token === authority.token) {
372
+ this.transition(intent.runId, { status: "failed", checkpoint: "tracker-mutation-failed", payload: { error: error instanceof Error ? error.message : String(error) } });
373
+ }
374
+ throw error;
375
+ }
376
+ finally {
377
+ try {
378
+ if (lease) {
379
+ this.releaseLease(lease);
380
+ // Acquisition can have been rolled back by the outer transaction.
381
+ // Drop only this invocation's cached token, never a replacement owner.
382
+ if (this.currentLease()?.owner !== lease.owner)
383
+ forgetOriginalLease(this.projectDir, lease);
384
+ }
385
+ }
386
+ finally {
387
+ if (authority && this.buildAdmission()?.token === authority.token)
388
+ this.releaseBuildAdmission(authority);
389
+ else if (authority) {
390
+ forgetBuildAuthority(authority);
391
+ if (this.writerAuthority?.token === authority.token)
392
+ this.writerAuthority = undefined;
393
+ }
394
+ }
395
+ }
396
+ }
293
397
  assertNoLegacyBuildOwner() {
294
398
  for (const row of this.db.prepare("SELECT run_id,state_json FROM workflow_runs WHERE kind='build' AND legacy=1").all()) {
295
399
  const state = parseJson(row.state_json);
@@ -419,6 +523,29 @@ export class WorkflowDb {
419
523
  });
420
524
  }
421
525
  buildLaunch(token) { return readLaunch(this.db, token); }
526
+ /** A supervisor's waiting exit may represent a successful partial QA boundary.
527
+ * Require this launch's clean worker exit and verified cleanup, never just QA. */
528
+ successfulPartialQaRecovery(runId, ticketId, launchToken) {
529
+ const proof = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
530
+ return Boolean(proof && partialQaProcessesSettled(proof));
531
+ }
532
+ async waitForSuccessfulPartialQaRecovery(runId, ticketId, launchToken) {
533
+ // Clean exits can precede native descendant/inventory settlement. Recheck
534
+ // only an already validated exact boundary; never kill or relax ownership.
535
+ const initial = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
536
+ if (!initial || process.platform === "win32")
537
+ return false;
538
+ const deadline = Date.now() + 3000;
539
+ do {
540
+ const proof = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
541
+ if (!proof || JSON.stringify(proof) !== JSON.stringify(initial))
542
+ return false;
543
+ if (partialQaProcessesSettled(proof))
544
+ return true;
545
+ await new Promise(resolve => setTimeout(resolve, 100));
546
+ } while (Date.now() < deadline);
547
+ return false;
548
+ }
422
549
  preparationSuccessor(runId) {
423
550
  const seen = new Set();
424
551
  let current = runId;
@@ -714,8 +841,26 @@ export class WorkflowDb {
714
841
  }
715
842
  this.insertEvent(run.runId, "run_created", run.checkpoint, { kind: run.kind }, at);
716
843
  })();
844
+ if (run.kind === "recovery")
845
+ this.createdRecoveryIntents.add(run.runId);
717
846
  return run;
718
847
  }
848
+ /** Record failure of an intent this connection just created, before recovery
849
+ * authority was acquired. This cannot write build execution or publications. */
850
+ pauseOwnStartOverIntent(runId, error) {
851
+ const run = this.getRun(runId);
852
+ if (!this.createdRecoveryIntents.has(runId) || run?.kind !== "recovery" || run.state.operation !== "build-start-over"
853
+ || run.checkpoint !== "start-over-inventory" || run.state.gitPreserved || this.operations(runId).length
854
+ || this.incompletePublications().some(publication => publication.runId === runId))
855
+ throw new Error("Recovery intent is outside this connection's unstarted setup scope");
856
+ this.coordinatorTransition++;
857
+ try {
858
+ this.transition(runId, { status: "paused", checkpoint: "start-over-setup-paused", state: { ...run.state, error: error instanceof Error ? error.message : String(error) } });
859
+ }
860
+ finally {
861
+ this.coordinatorTransition--;
862
+ }
863
+ }
719
864
  getRun(runId) {
720
865
  const row = this.db.prepare("SELECT * FROM workflow_runs WHERE run_id=?").get(runId);
721
866
  return row ? rowToRun(row) : undefined;
@@ -849,6 +994,21 @@ export class WorkflowDb {
849
994
  const rows = this.db.prepare("SELECT * FROM live_settings_acknowledgments WHERE revision=? ORDER BY acknowledged_at").all(revision);
850
995
  return rows.map((row) => ({ runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), revision: row.revision, acknowledgedAt: row.acknowledged_at }));
851
996
  }
997
+ settingsAdoptionState(runId, role) {
998
+ const admission = this.buildAdmission();
999
+ const lease = this.currentLease();
1000
+ const owner = admission?.runId === runId ? admission : lease?.runId === runId ? lease : undefined;
1001
+ if (!owner)
1002
+ return "deferred";
1003
+ const liveness = classifyProcess(owner.pid, owner.processStart, owner.host).state;
1004
+ if (liveness === "unknown")
1005
+ return "unknown";
1006
+ if (liveness !== "live" || this.getRun(runId)?.status !== "running" || this.pendingHumanDecisions(runId).length)
1007
+ return "deferred";
1008
+ // Historical role/session records are not proof of an active role. A
1009
+ // submitted turn can adopt saved settings at its next safe boundary.
1010
+ return this.unresolvedRoleDispatches(runId, role).some(operation => operation.status === "in_progress") ? "active" : "deferred";
1011
+ }
852
1012
  appendContinuityEvent(input, now = new Date()) {
853
1013
  this.ensureRun(input.runId, "build", now);
854
1014
  const at = now.toISOString();
@@ -893,7 +1053,7 @@ export class WorkflowDb {
893
1053
  this.refreshRunContinuityHead(input.runId, input.authoritativeStateRevision, at);
894
1054
  this.insertEvent(input.runId, "continuity_checkpoint", `continuity:${input.role}`, { checkpointId: checkpoint.checkpointId, digest, state: checkpoint.state }, at);
895
1055
  return checkpoint;
896
- })();
1056
+ }).immediate();
897
1057
  }
898
1058
  continuityCheckpoints(runId, role) {
899
1059
  const rows = role
@@ -1578,6 +1738,90 @@ export class WorkflowDb {
1578
1738
  .run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
1579
1739
  })();
1580
1740
  }
1741
+ /** Retire an interrupted, proven read-only review transport, never a QA pass
1742
+ * or Builder remediation. A fresh immutable review must still run afterward. */
1743
+ reconcileInterruptedQaDispatch(runId, ticketId) {
1744
+ const dispatches = this.unresolvedRoleDispatches(runId, "qa");
1745
+ if (!dispatches.length)
1746
+ return false;
1747
+ const lease = this.currentLease();
1748
+ if (lease?.runId !== runId || lease.pid !== process.pid)
1749
+ throw new Error("QA reconciliation requires the original build recovery lease");
1750
+ const worker = this.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
1751
+ if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)
1752
+ || this.readinessProcesses().length || this.unresolvedRoleDispatches(runId, "builder").length)
1753
+ throw new Error("Interrupted QA and all owned provider processes must be verified stopped before a fresh review");
1754
+ const head = this.qaTicketHead(runId, ticketId);
1755
+ if (!["turn-intended", "turn-uncertain", "operator-menu"].includes(head.state))
1756
+ throw new Error("QA dispatch is not an interrupted review boundary");
1757
+ const run = this.getRun(runId)?.state;
1758
+ if (!run?.tickets?.includes(ticketId) || run.progress?.completedTickets?.includes(ticketId))
1759
+ throw new Error("QA recovery cannot reopen completed or unapproved tickets");
1760
+ const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(this.projectDir), this.projectDir).tickets).find(ticket => ticket.id === ticketId);
1761
+ if (!definition || qaDigest("admitted-requirements", definition) !== this.assertAdmittedWork(runId, ticketId).requirementsDigest)
1762
+ throw new Error("QA recovery requirements changed; approval is required");
1763
+ const owner = this.roleMutationLease(runId, "qa");
1764
+ // A cancellation can have a durable host terminal receipt while the
1765
+ // provider dispatch is still unknown. Exact frozen-turn bindings remain
1766
+ // required; the old receipt is never promoted to an accepted QA result.
1767
+ 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);
1768
+ const turns = rows.flatMap(row => {
1769
+ const turn = parseJson(row.intent_json);
1770
+ if (row.status === "completed" && !dispatches.some(dispatch => {
1771
+ const result = dispatch.result;
1772
+ return result?.isError === true && result.failure?.dispatchState === "unknown"
1773
+ && dispatch.intent.instructionDigest === turn.instructionDigest;
1774
+ }))
1775
+ return [];
1776
+ return [turn];
1777
+ });
1778
+ const bindings = dispatches.map(dispatch => {
1779
+ const intent = dispatch.intent;
1780
+ const ref = intent.sessionRef;
1781
+ if (!ref || !owner?.sessionKey || ref.role !== "qa" || ref.stream !== "qa" || !Number.isSafeInteger(ref.generation) || ref.generation < 0
1782
+ || !ref.configRoot || canonicalProject(ref.configRoot) !== canonicalProject(this.projectDir) || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt))
1783
+ || ref.source !== "observed" || providerSessionKey(ref) !== owner.sessionKey)
1784
+ throw new Error("QA dispatch has no validated owner binding");
1785
+ const turn = turns.find(turn => turn.reviewNumber === head.reviewNumber && turn.instructionDigest === intent.instructionDigest
1786
+ && turn.providerSession.sessionId === ref.sessionId && turn.providerSession.provider === ref.provider && turn.providerSession.generation === ref.generation
1787
+ && turn.providerSession.cwd === ref.cwd && turn.providerSession.configRoot === ref.configRoot);
1788
+ if (!turn || turn.sourceStateDigest !== head.sourceStateDigest || turn.reviewBasisDigest !== head.reviewBasisDigest)
1789
+ throw new Error("QA dispatch does not match the frozen review turn");
1790
+ 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);
1791
+ const session = sessions.map(row => parseJson(row.session_json)).find(session => providerSessionKey(session.session) === owner.sessionKey && session.confinement);
1792
+ const confinement = session?.confinement;
1793
+ if (!confinement)
1794
+ throw new Error("Interrupted QA lacks a retained read-only confinement proof");
1795
+ const { digest, ...fields } = confinement;
1796
+ if (fields.version !== 2 || fields.sourceMode !== "read-only" || fields.scratchMode !== "isolated" || !["none", "user"].includes(String(fields.settingsSources))
1797
+ || !["disabled", "provider-required"].includes(String(fields.networkMode)) || !/^[a-f0-9]{64}$/.test(String(fields.environmentDigest)) || !/^[a-f0-9]{64}$/.test(String(fields.policyDigest)) || digest !== qaDigest("qa-confinement", fields))
1798
+ throw new Error("Interrupted QA confinement is invalid");
1799
+ const sourceRow = this.db.prepare("SELECT state_json FROM qa_source_states WHERE run_id=? AND ticket_id=? AND digest=?").get(runId, ticketId, turn.sourceStateDigest);
1800
+ const frozen = sourceRow ? parseJson(sourceRow.state_json) : undefined;
1801
+ if (!frozen || frozen.version !== 2 || frozen.runId !== runId || frozen.ticketId !== ticketId || frozen.digest !== turn.sourceStateDigest || !/^[a-f0-9]{64}$/.test(frozen.contentDigest) || !/^[a-f0-9]{64}$/.test(frozen.originDigest) || !Array.isArray(frozen.paths))
1802
+ throw new Error("Interrupted QA frozen source binding is missing or invalid");
1803
+ const basisRow = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE run_id=? AND ticket_id=? AND digest=?").get(runId, ticketId, turn.reviewBasisDigest);
1804
+ if (!basisRow)
1805
+ throw new Error("Interrupted QA review basis is missing");
1806
+ const basis = parseJson(basisRow.basis_json);
1807
+ const { digest: basisDigest, ...basisFields } = basis;
1808
+ if (basisDigest !== qaDigest("review-basis-fields", basisFields) || basis.confinementDigest !== digest || basis.ticketDigest !== qaDigest("ticket", definition))
1809
+ throw new Error("Interrupted QA review basis is invalid");
1810
+ return { dispatch, turn, confinementDigest: digest };
1811
+ });
1812
+ const source = captureFrozenQaSource(this.projectDir);
1813
+ if (run.repository?.branch && execFileSync("git", ["branch", "--show-current"], { cwd: this.projectDir, encoding: "utf8" }).trim() !== run.repository.branch)
1814
+ throw new Error("Return to the build's owning branch before QA recovery");
1815
+ this.atomic(() => {
1816
+ if (captureFrozenQaSource(this.projectDir).digest !== source.digest)
1817
+ throw new Error("QA source changed during recovery inspection; retry");
1818
+ const reconciliationId = `qa-review-resume:${runId}:${randomUUID()}`;
1819
+ for (const { dispatch, turn, confinementDigest } of bindings)
1820
+ this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "interrupted-read-only-qa", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result, qaTurnId: turn.operationId, reviewedSourceDigest: turn.sourceStateDigest, currentSourceDigest: source.digest, confinementDigest }, error: "Interrupted read-only QA outcome remains unknown; replaced by a fresh immutable full review, never replayed or treated as a pass" });
1821
+ this.appendContinuityEvent({ runId, role: "host", kind: "interrupted_qa_reconciled", payload: { reconciliationId, ticketId, dispatchIds: dispatches.map(dispatch => dispatch.idempotencyKey), currentSourceDigest: source.digest, outcomeUnknown: true, providerReplayAuthorized: false }, authoritativeStateRevision: this.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
1822
+ });
1823
+ return true;
1824
+ }
1581
1825
  commitQaTurnIntent(intent, expectedRevision) {
1582
1826
  return this.db.transaction(() => {
1583
1827
  const next = this.transitionQa(intent.runId, intent.ticketId, expectedRevision, { type: "turn-intended", slot: intent.slot }, new Date(intent.intendedAt));
@@ -1748,6 +1992,21 @@ export class WorkflowDb {
1748
1992
  const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
1749
1993
  return row ? parseJson(row.receipt_json) : undefined;
1750
1994
  }
1995
+ hasConfirmedQaRemediationForRecheck(runId, ticketId) {
1996
+ const head = this.qaTicketHead(runId, ticketId);
1997
+ const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
1998
+ if (head.state !== "recheck-required" || !attempt || attempt.status !== "succeeded"
1999
+ || attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
2000
+ return false;
2001
+ const receipt = this.builderRemediationReceipt(attempt.attemptId);
2002
+ const review = this.qaReviewAttempt(attempt.reviewAttemptId);
2003
+ const transition = this.db.prepare("SELECT to_revision,event_json FROM qa_transitions WHERE run_id=? AND ticket_id=? ORDER BY to_revision DESC LIMIT 1").get(runId, ticketId);
2004
+ return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
2005
+ && receipt.operationId === attempt.attemptId && receipt.requestDigest === attempt.requestDigest
2006
+ && receipt.responseDigest === attempt.responseDigest && receipt.summaryDigest === attempt.summaryDigest
2007
+ && receipt.sourceStateDigest === review.sourceDigest && receipt.reportDigest === review.reportDigest && receipt.providerTurnId
2008
+ && transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
2009
+ }
1751
2010
  recordBuilderRemediationReceipt(receipt) {
1752
2011
  const text = json(receipt);
1753
2012
  const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
@@ -2268,17 +2527,26 @@ export class WorkflowDb {
2268
2527
  */
2269
2528
  hasUncheckpointedRoleTurn(runId, role) {
2270
2529
  const events = this.continuityEvents(runId);
2271
- let startSequence = 0;
2530
+ const pending = new Map();
2272
2531
  for (const event of events) {
2273
2532
  if (event.kind === "turn_started" && event.role === "host" && event.payload.role === role) {
2274
- startSequence = event.sequence;
2533
+ const dispatchId = event.payload.dispatchId;
2534
+ pending.set(event.sequence, typeof dispatchId === "string" ? dispatchId : undefined);
2535
+ }
2536
+ else if (event.role === role && event.kind === "turn_not_sent") {
2537
+ const dispatchId = event.payload.dispatchId;
2538
+ if (typeof dispatchId === "string")
2539
+ for (const [sequence, id] of pending) {
2540
+ if (id === dispatchId)
2541
+ pending.delete(sequence);
2542
+ }
2275
2543
  }
2276
- else if (startSequence && event.sequence > startSequence && event.role === role
2277
- && (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted")) {
2278
- startSequence = 0;
2544
+ else if (event.role === role
2545
+ && (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted" || event.kind === "handoff_successor_accepted")) {
2546
+ pending.clear();
2279
2547
  }
2280
2548
  }
2281
- return startSequence > 0;
2549
+ return pending.size > 0;
2282
2550
  }
2283
2551
  beginPublication(runId, intent, previousDigests, now = new Date()) {
2284
2552
  const transactionId = randomUUID();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.24",
3
+ "version": "1.7.26",
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",
@@ -308,7 +308,7 @@
308
308
  "parse5": "7.2.1",
309
309
  "pdfjs-dist": "4.10.38",
310
310
  "rafi-spec": "0.8.15",
311
- "special-agents": "0.8.14",
311
+ "special-agents": "0.8.15",
312
312
  "yaml": "^2.5.1"
313
313
  },
314
314
  "devDependencies": {