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.
- package/dist/adapters/claude.js +4 -3
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +7 -2
- package/dist/branch/git.js +3 -8
- package/dist/buildApproval.js +3 -9
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +6 -1
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/start.js +63 -13
- package/dist/cli/tickets.d.ts +0 -1
- package/dist/cli/tickets.js +11 -22
- package/dist/continuity.d.ts +0 -2
- package/dist/continuity.js +30 -34
- package/dist/dispatchNotSent.d.ts +4 -0
- package/dist/dispatchNotSent.js +10 -0
- package/dist/foreman.d.ts +5 -12
- package/dist/foreman.js +19 -85
- package/dist/gitExclude.d.ts +16 -0
- package/dist/gitExclude.js +46 -0
- package/dist/graph/turn.d.ts +1 -1
- package/dist/graph/turn.js +1 -1
- package/dist/handoffs.d.ts +3 -3
- package/dist/handoffs.js +5 -32
- package/dist/humanDecision.d.ts +7 -0
- package/dist/humanDecision.js +11 -0
- package/dist/observability.js +3 -8
- package/dist/qaBuildGate.js +13 -1
- package/dist/qaPreparation.js +21 -7
- package/dist/qaPreparationStore.d.ts +11 -0
- package/dist/qaPreparationStore.js +44 -4
- package/dist/qaRecovery.d.ts +0 -3
- package/dist/qaRecovery.js +1 -24
- package/dist/qaReview.js +32 -22
- package/dist/qaSnapshot.js +19 -14
- package/dist/qaStructuredResponse.d.ts +8 -0
- package/dist/qaStructuredResponse.js +24 -0
- package/dist/qaTimeline.js +3 -5
- package/dist/recoveryOwners.d.ts +32 -0
- package/dist/recoveryOwners.js +87 -0
- package/dist/runtimePreflight.d.ts +11 -0
- package/dist/runtimePreflight.js +77 -0
- package/dist/sources/sourceRegistry.d.ts +0 -3
- package/dist/sources/sourceRegistry.js +0 -42
- package/dist/structuredOperation.js +26 -4
- package/dist/ticketPlanning.d.ts +0 -3
- package/dist/ticketPlanning.js +0 -23
- package/dist/ticketPopulationPaging.js +23 -1
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +89 -22
- 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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/ticketPlanning.d.ts
CHANGED
|
@@ -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[];
|
package/dist/ticketPlanning.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/workflowDb.d.ts
CHANGED
|
@@ -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;
|
package/dist/workflowDb.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2232
|
-
|
|
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
|
|
2247
|
-
|
|
2248
|
-
&&
|
|
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
|
-
|
|
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.
|
|
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.
|
|
323
|
-
"special-agents": "0.9.
|
|
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
|
},
|