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.
- package/README.md +2 -1
- package/dist/adapters/claude.d.ts +15 -0
- package/dist/adapters/claude.js +149 -28
- package/dist/adapters/codex.d.ts +9 -0
- package/dist/adapters/codex.js +77 -8
- package/dist/adapters/fastMode.d.ts +4 -0
- package/dist/adapters/fastMode.js +9 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/agentRun.js +1 -0
- package/dist/branch/runner.js +46 -44
- package/dist/buildAssignment.d.ts +21 -1
- package/dist/buildAssignment.js +215 -2
- package/dist/buildRuns.d.ts +6 -0
- package/dist/buildRuns.js +73 -7
- package/dist/cli/start.d.ts +8 -1
- package/dist/cli/start.js +132 -38
- package/dist/cli/tickets.d.ts +1 -0
- package/dist/cli/tickets.js +20 -2
- package/dist/continuity.js +11 -10
- package/dist/foreman.d.ts +8 -0
- package/dist/foreman.js +69 -10
- package/dist/handoffProtocol.d.ts +4 -0
- package/dist/handoffProtocol.js +19 -0
- package/dist/handoffs.d.ts +1 -1
- package/dist/handoffs.js +23 -4
- package/dist/permissions/policy.js +18 -11
- package/dist/permissions/shellForms.d.ts +3 -0
- package/dist/permissions/shellForms.js +203 -0
- package/dist/qaBuilderActivation.d.ts +19 -0
- package/dist/qaBuilderActivation.js +112 -0
- package/dist/qaFailureDelivery.js +1 -1
- package/dist/qaRecovery.js +2 -1
- package/dist/qaReview.d.ts +4 -0
- package/dist/qaReview.js +37 -7
- package/dist/sessionLifecycle.js +1 -1
- package/dist/supervisedStart.js +23 -3
- package/dist/supervisor.js +33 -0
- package/dist/tickets/groupReset.js +100 -96
- package/dist/tickets/reset.js +80 -69
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +276 -8
- package/package.json +2 -2
package/dist/workflowDb.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2277
|
-
&& (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted")) {
|
|
2278
|
-
|
|
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
|
|
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.
|
|
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.
|
|
311
|
+
"special-agents": "0.8.15",
|
|
312
312
|
"yaml": "^2.5.1"
|
|
313
313
|
},
|
|
314
314
|
"devDependencies": {
|