ai-foreman 1.7.25 → 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 +15 -0
- package/dist/buildAssignment.js +142 -5
- package/dist/buildRuns.d.ts +6 -0
- package/dist/buildRuns.js +73 -7
- package/dist/cli/start.d.ts +5 -0
- package/dist/cli/start.js +123 -33
- 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 +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Only explicit speed-option rejection permits a same-session retry. Auth,
|
|
2
|
+
* generic model failures, timeouts, and uncertain transport failures do not. */
|
|
3
|
+
export function isFastModeUnavailable(detail) {
|
|
4
|
+
return /(?:fast(?:[ _-]mode)?|service[ _-]tier|speed\s*[=:]\s*["']?fast)[\s\S]{0,160}(?:not supported|unsupported|not available|unavailable|not enabled|disabled|not allowed|requires|invalid|unrecognized|unknown)|(?:not supported|unsupported|not available|unavailable|invalid|unrecognized|unknown)[\s\S]{0,100}(?:--fast|fast[ _-]mode|service[ _-]tier)/i.test(detail);
|
|
5
|
+
}
|
|
6
|
+
export function reportFastModeUnavailable(model) {
|
|
7
|
+
const label = (model ?? "the selected model").replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 200);
|
|
8
|
+
console.error(`rafi: Fast mode not available with ${label}; continuing at standard speed.`);
|
|
9
|
+
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { ConfigurableAgentRole, ProviderSessionRefV1, RuntimeProbeCategory,
|
|
|
6
6
|
import type { RunObserver } from "../observability.js";
|
|
7
7
|
/** A permission request surfaced by a builder before it runs a tool. */
|
|
8
8
|
export interface PermissionRequest {
|
|
9
|
+
/** Provider-observed tool cwd, distinct from immutable session launch scope. */
|
|
10
|
+
executionCwd?: string;
|
|
9
11
|
toolName: string;
|
|
10
12
|
input: Record<string, unknown>;
|
|
11
13
|
/** Human-readable prompt from the agent, when available. */
|
package/dist/agentRun.js
CHANGED
package/dist/branch/runner.js
CHANGED
|
@@ -304,7 +304,7 @@ export async function runBranchPlan(opts) {
|
|
|
304
304
|
base: node.baseBranch,
|
|
305
305
|
dependencies: node.dependencies,
|
|
306
306
|
});
|
|
307
|
-
workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
|
|
307
|
+
workflowCheckpoint(opts.projectDir, opts.runId, qaOnlyRecovery ? "qa-recovery-before" : "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
|
|
308
308
|
const continuationDb = new WorkflowDb(opts.projectDir);
|
|
309
309
|
const continuationSession = continuations.length ? continuationDb.branchResumeSession(opts.runId, node.ticket.id) : undefined;
|
|
310
310
|
continuationDb.close();
|
|
@@ -391,50 +391,52 @@ export async function runBranchPlan(opts) {
|
|
|
391
391
|
? await opts.observer.withContext({ role: "builder", stream: "builder", ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, turn)
|
|
392
392
|
: await turn();
|
|
393
393
|
builder = foreman.builderAdapter();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (sessionId) {
|
|
404
|
-
const resumeSession = {
|
|
405
|
-
ticket: node.ticket.id,
|
|
406
|
-
branch: node.branch,
|
|
407
|
-
base: node.baseBranch,
|
|
408
|
-
worktreePath,
|
|
409
|
-
sessionId,
|
|
410
|
-
sessionRef: builder.sessionRef?.(),
|
|
411
|
-
agent: opts.agent,
|
|
412
|
-
model: opts.model,
|
|
413
|
-
effort: opts.effort,
|
|
414
|
-
fast: opts.fast,
|
|
415
|
-
qaEnabled: opts.qaEnabled,
|
|
416
|
-
createPr: createsReview,
|
|
417
|
-
completionMode,
|
|
418
|
-
reviewProvider,
|
|
419
|
-
prReady: opts.prReady,
|
|
420
|
-
keepWorktrees: opts.keepWorktrees,
|
|
421
|
-
deliveryUnitId: node.deliveryUnitId,
|
|
422
|
-
deliveryUnitFinal: node.deliveryUnitFinal,
|
|
423
|
-
logPath: "structured-recovery",
|
|
424
|
-
ts: new Date().toISOString(),
|
|
425
|
-
};
|
|
426
|
-
opts.log.write("branch-session", resumeSession);
|
|
427
|
-
const resumeDb = new WorkflowDb(opts.projectDir);
|
|
428
|
-
try {
|
|
429
|
-
resumeDb.recordBranchResumeSession(opts.runId, resumeSession);
|
|
430
|
-
}
|
|
431
|
-
finally {
|
|
432
|
-
resumeDb.close();
|
|
394
|
+
if (!qaOnlyRecovery) {
|
|
395
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
|
|
396
|
+
const sessionId = builder.sessionId();
|
|
397
|
+
const sessionRef = builder.sessionRef?.();
|
|
398
|
+
if (sessionId)
|
|
399
|
+
builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
|
|
400
|
+
if (sessionId) {
|
|
401
|
+
opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
|
|
402
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
|
|
433
403
|
}
|
|
434
|
-
if (
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
404
|
+
if (sessionId) {
|
|
405
|
+
const resumeSession = {
|
|
406
|
+
ticket: node.ticket.id,
|
|
407
|
+
branch: node.branch,
|
|
408
|
+
base: node.baseBranch,
|
|
409
|
+
worktreePath,
|
|
410
|
+
sessionId,
|
|
411
|
+
sessionRef: builder.sessionRef?.(),
|
|
412
|
+
agent: opts.agent,
|
|
413
|
+
model: opts.model,
|
|
414
|
+
effort: opts.effort,
|
|
415
|
+
fast: opts.fast,
|
|
416
|
+
qaEnabled: opts.qaEnabled,
|
|
417
|
+
createPr: createsReview,
|
|
418
|
+
completionMode,
|
|
419
|
+
reviewProvider,
|
|
420
|
+
prReady: opts.prReady,
|
|
421
|
+
keepWorktrees: opts.keepWorktrees,
|
|
422
|
+
deliveryUnitId: node.deliveryUnitId,
|
|
423
|
+
deliveryUnitFinal: node.deliveryUnitFinal,
|
|
424
|
+
logPath: "structured-recovery",
|
|
425
|
+
ts: new Date().toISOString(),
|
|
426
|
+
};
|
|
427
|
+
opts.log.write("branch-session", resumeSession);
|
|
428
|
+
const resumeDb = new WorkflowDb(opts.projectDir);
|
|
429
|
+
try {
|
|
430
|
+
resumeDb.recordBranchResumeSession(opts.runId, resumeSession);
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
resumeDb.close();
|
|
434
|
+
}
|
|
435
|
+
if (node.deliveryUnitId) {
|
|
436
|
+
const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
|
|
437
|
+
mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
|
|
438
|
+
writeFileSync(path, `${JSON.stringify({ runId: opts.runId, unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
|
|
439
|
+
}
|
|
438
440
|
}
|
|
439
441
|
}
|
|
440
442
|
opts.log.write("step", {
|
|
@@ -22,6 +22,21 @@ export declare class BuildAssignmentRejected extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
/** A frozen exact-session Builder decision may defer unrelated pending QA. */
|
|
24
24
|
export declare function isInterruptedBuilderRecovery(run: BuildRunRecordV2 | undefined): boolean;
|
|
25
|
+
/** Recovery requires current source, assignment and admission proofs.
|
|
26
|
+
* A V3 or migrated record alone never grants continuation authority;
|
|
27
|
+
* full eligibility is verified before any unknown receipt is reconciled. */
|
|
28
|
+
export declare function canReconcileInterruptedBuilderSource(projectDir: string, runId: string): boolean;
|
|
29
|
+
/** Resume the existing approved build from observed local source, never replay
|
|
30
|
+
* the interrupted instruction or pretend its unknown outcome was successful.
|
|
31
|
+
* The caller must own recovery admission and the original run's workflow lease. */
|
|
32
|
+
export declare function prepareInterruptedBuilderSourceContinuation(projectDir: string, runId: string): {
|
|
33
|
+
ticketIds: string[];
|
|
34
|
+
reconciliationId: string;
|
|
35
|
+
sourceDigest: string;
|
|
36
|
+
contentDigest: string;
|
|
37
|
+
};
|
|
38
|
+
/** A launch may not consume a recovery snapshot after product source changes. */
|
|
39
|
+
export declare function assertInterruptedSourceSnapshot(projectDir: string, run: BuildRunRecordV2): void;
|
|
25
40
|
/**
|
|
26
41
|
* Retire only the host-side records for a Builder turn that was interrupted
|
|
27
42
|
* before Rafi received a durable response. The next turn is sent to the
|
package/dist/buildAssignment.js
CHANGED
|
@@ -10,6 +10,11 @@ import { spawnSync } from "node:child_process";
|
|
|
10
10
|
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
11
11
|
import { WorkflowDb } from "./workflowDb.js";
|
|
12
12
|
import { WorkflowReader } from "./workflowReader.js";
|
|
13
|
+
import { classifyProcess, processGroupQuiescent } from "./processIdentity.js";
|
|
14
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
15
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
16
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
17
|
+
import { providerSessionKey } from "./sessionIdentity.js";
|
|
13
18
|
/** Work responses confirm an assignment; they never select the next ticket. */
|
|
14
19
|
export function validateBuildAssignment(ticketId, status, responseText = "") {
|
|
15
20
|
// An unmarked response may receive the existing bounded response-only repair.
|
|
@@ -32,12 +37,134 @@ export function isInterruptedBuilderRecovery(run) {
|
|
|
32
37
|
return false;
|
|
33
38
|
const decision = run.recoveryDecision;
|
|
34
39
|
const continuation = run.interruptedBuilderContinuation;
|
|
40
|
+
if (continuation?.recovery === "source-reconciled" && continuation.reconciliationId) {
|
|
41
|
+
return decision?.runId === run.runId && decision.role === "builder" && decision.mode === "fresh-with-handoff"
|
|
42
|
+
&& Boolean(continuation.ticketIds?.length === 1 && decision.executionTickets?.includes(continuation.ticketIds[0])
|
|
43
|
+
&& run.tickets.includes(continuation.ticketIds[0]));
|
|
44
|
+
}
|
|
35
45
|
return decision?.runId === run.runId && decision.role === "builder" && decision.mode === "exact-session"
|
|
36
|
-
&& decision.executionTickets?.length
|
|
37
|
-
&&
|
|
38
|
-
&& Boolean(continuation?.ticketIds?.includes(decision.executionTickets[0]))
|
|
46
|
+
&& Boolean(decision.executionTickets?.length && decision.executionTickets.every(id => run.tickets.includes(id)))
|
|
47
|
+
&& Boolean(continuation?.ticketIds?.length === 1 && decision.executionTickets?.includes(continuation.ticketIds[0]))
|
|
39
48
|
&& Boolean(continuation?.sessionRef && decision.predecessorSessionRef && sameSession(continuation.sessionRef, decision.predecessorSessionRef));
|
|
40
49
|
}
|
|
50
|
+
/** Recovery requires current source, assignment and admission proofs.
|
|
51
|
+
* A V3 or migrated record alone never grants continuation authority;
|
|
52
|
+
* full eligibility is verified before any unknown receipt is reconciled. */
|
|
53
|
+
export function canReconcileInterruptedBuilderSource(projectDir, runId) {
|
|
54
|
+
const db = new WorkflowReader(projectDir);
|
|
55
|
+
try {
|
|
56
|
+
const state = db.getRun(runId)?.state;
|
|
57
|
+
const assignments = db.operations(runId, true).filter(op => op.kind === "build-assignment" && ["in_progress", "uncertain"].includes(op.status));
|
|
58
|
+
if (state?.version !== 3 || state.branchMode !== "current" || assignments.length !== 1)
|
|
59
|
+
return false;
|
|
60
|
+
const intent = assignments[0].intent;
|
|
61
|
+
return Boolean(intent.ticketId && state.tickets.includes(intent.ticketId) && intent.before?.digest && intent.requirementsDigest && intent.instructionDigest
|
|
62
|
+
&& db.events(runId).some(event => event.type === "worker_started"));
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Resume the existing approved build from observed local source, never replay
|
|
69
|
+
* the interrupted instruction or pretend its unknown outcome was successful.
|
|
70
|
+
* The caller must own recovery admission and the original run's workflow lease. */
|
|
71
|
+
export function prepareInterruptedBuilderSourceContinuation(projectDir, runId) {
|
|
72
|
+
const db = new WorkflowDb(projectDir);
|
|
73
|
+
try {
|
|
74
|
+
const lease = db.currentLease();
|
|
75
|
+
if (lease?.runId !== runId || lease.pid !== process.pid)
|
|
76
|
+
throw new BuildAssignmentRejected("Source recovery requires the original run's current recovery lease");
|
|
77
|
+
if (!canReconcileInterruptedBuilderSource(projectDir, runId))
|
|
78
|
+
throw new BuildAssignmentRejected("Interrupted work lacks current source-bound assignment authority; use guided recovery or preserve files and restart");
|
|
79
|
+
const run = db.getRun(runId).state;
|
|
80
|
+
if (run.builderCapabilities?.grantedNetwork)
|
|
81
|
+
throw new BuildAssignmentRejected("Interrupted network-enabled work requires explicit side-effect reconciliation; source inspection cannot establish remote outcomes");
|
|
82
|
+
if (db.readinessProcesses().length || db.unresolvedRoleDispatches(runId, "qa").length)
|
|
83
|
+
throw new BuildAssignmentRejected("Owned processes or a separate QA dispatch must be reconciled before Builder source recovery");
|
|
84
|
+
if (db.incompletePublications().some(item => item.runId === runId && item.intent.operation !== "build-run-projection"))
|
|
85
|
+
throw new BuildAssignmentRejected("A pending source/tracker publication must be reconciled before Builder source recovery");
|
|
86
|
+
const worker = db.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
|
|
87
|
+
if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)) {
|
|
88
|
+
throw new BuildAssignmentRejected("The interrupted worker and its owned process group must be verified stopped before source recovery");
|
|
89
|
+
}
|
|
90
|
+
const assignment = db.operations(runId).find(op => op.kind === "build-assignment" && ["in_progress", "uncertain"].includes(op.status));
|
|
91
|
+
const intent = assignment.intent;
|
|
92
|
+
if (realpathSync(intent.worktree) !== realpathSync(projectDir) || realpathSync(run.repository.worktree) !== realpathSync(projectDir))
|
|
93
|
+
throw new BuildAssignmentRejected("Interrupted source belongs to another worktree");
|
|
94
|
+
if (run.progress.completedTickets.includes(intent.ticketId) || ["completed", "waived"].includes(db.qaTicketHead(runId, intent.ticketId).state))
|
|
95
|
+
throw new BuildAssignmentRejected("A completed ticket cannot be reopened by interrupted source recovery");
|
|
96
|
+
const admission = db.assertAdmittedWork(runId, intent.ticketId);
|
|
97
|
+
const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(projectDir), projectDir).tickets).find(ticket => ticket.id === intent.ticketId);
|
|
98
|
+
if (!definition || admission.requirementsDigest !== intent.requirementsDigest || qaDigest("admitted-requirements", definition) !== intent.requirementsDigest)
|
|
99
|
+
throw new BuildAssignmentRejected("Interrupted ticket requirements changed; current scope needs approval before recovery");
|
|
100
|
+
const dispatches = db.unresolvedRoleDispatches(runId, "builder");
|
|
101
|
+
const owner = db.roleMutationLease(runId, "builder");
|
|
102
|
+
if (!dispatches.length || !owner?.sessionRef || !owner.sessionKey || owner.sessionRef.source !== "observed" || !owner.sessionRef.validatedAt
|
|
103
|
+
|| Number.isNaN(Date.parse(owner.sessionRef.validatedAt)) || dispatches.some(op => {
|
|
104
|
+
const ref = op.intent.sessionRef;
|
|
105
|
+
return !sameSession(ref, owner.sessionRef) || providerSessionKey(ref) !== owner.sessionKey;
|
|
106
|
+
}))
|
|
107
|
+
throw new BuildAssignmentRejected("Interrupted dispatch is not bound to the recorded Builder owner");
|
|
108
|
+
const source = captureBuildSource(projectDir);
|
|
109
|
+
if (run.repository.branch && sourceBranch(projectDir) !== run.repository.branch)
|
|
110
|
+
throw new BuildAssignmentRejected("The current branch changed; return to the interrupted build's owning branch before source recovery");
|
|
111
|
+
const reconciliationId = `source-resume:${runId}:${randomUUID()}`;
|
|
112
|
+
db.atomic(() => {
|
|
113
|
+
if (captureBuildSource(projectDir).digest !== source.digest)
|
|
114
|
+
throw new BuildAssignmentRejected("Preserved source changed during recovery inspection; retry from the current files");
|
|
115
|
+
const retained = retainSource(db, source);
|
|
116
|
+
const receipt = { recovery: "source-reconciled", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, source: retained, ticketId: intent.ticketId, requirementsDigest: intent.requirementsDigest, worker, at: new Date().toISOString() };
|
|
117
|
+
for (const dispatch of dispatches)
|
|
118
|
+
db.updateOperation(dispatch.idempotencyKey, "failed", { result: { ...receipt, priorResult: dispatch.result }, error: "Interrupted provider outcome remains unknown; verified stopped local execution is replaced by source inspection and fresh QA, without replay" });
|
|
119
|
+
db.updateOperation(assignment.idempotencyKey, "failed", { result: { ...receipt, priorResult: assignment.result }, error: "Interrupted assignment preserved for source-bound continuation; completion is not established" });
|
|
120
|
+
const event = db.appendContinuityEvent({ runId, role: "host", kind: "interrupted_source_reconciled", payload: { ...receipt, dispatchIds: dispatches.map(op => op.idempotencyKey), assignmentId: assignment.idempotencyKey }, authoritativeStateRevision: db.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
|
|
121
|
+
db.publishContinuityCheckpoint({ runId, role: "builder", state: "current", authoritativeStateRevision: event.authoritativeStateRevision, delta: {
|
|
122
|
+
version: 1, decisions: [`Continue already-approved ticket ${intent.ticketId} in the SAME build from preserved source; this host reconciliation is not a successful provider turn.`],
|
|
123
|
+
constraints: ["Never replay the interrupted provider instruction or assume its side effects did not happen. Inspect current files first, implement only missing approved work, and require independent QA. Preserve all completed tickets and accepted QA.", "The predecessor session's compaction history remains unknown; use a validated fresh handoff before any new implementation."],
|
|
124
|
+
discoveries: [], completedActions: [], evidence: [`Host-observed source digest ${source.digest}; interrupted assignment ${assignment.idempotencyKey}; reconciliation ${reconciliationId}`],
|
|
125
|
+
failures: ["The interrupted provider outcome is unknown and remains preserved in the historical receipt."], blockers: [], openWork: [`Inspect preserved implementation of ${intent.ticketId}; complete remaining criteria and obtain fresh QA.`], nextAction: `Accept a fresh source-bound handoff, inspect ${intent.ticketId}, then continue all remaining approved tickets.`,
|
|
126
|
+
} });
|
|
127
|
+
const current = db.getRun(runId);
|
|
128
|
+
db.transition(runId, { checkpoint: "builder-source-reconciled", state: { ...current.state, interruptedBuilderContinuation: { recovery: "source-reconciled", ticketIds: [intent.ticketId], reconciliationId, sourceDigest: source.digest, contentDigest: source.contentDigest, productDigest: interruptedProductDigest(projectDir, source) } }, event: "builder_source_continuation_authorized", payload: receipt });
|
|
129
|
+
});
|
|
130
|
+
return { ticketIds: [intent.ticketId], reconciliationId, sourceDigest: source.digest, contentDigest: source.contentDigest };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A launch may not consume a recovery snapshot after product source changes. */
|
|
137
|
+
export function assertInterruptedSourceSnapshot(projectDir, run) {
|
|
138
|
+
const continuation = run.interruptedBuilderContinuation;
|
|
139
|
+
if (continuation?.recovery !== "source-reconciled" || continuation.firstAssignmentId || !continuation.ticketIds?.some(id => !run.progress.completedTickets.includes(id)))
|
|
140
|
+
return;
|
|
141
|
+
const reader = new WorkflowReader(projectDir);
|
|
142
|
+
try {
|
|
143
|
+
// A stale in-memory snapshot can lag the atomic consumption marker. The
|
|
144
|
+
// source-bound assignment journal is durable and cannot be overwritten by
|
|
145
|
+
// the next ordinary run projection.
|
|
146
|
+
const reconciliationId = continuation.reconciliationId;
|
|
147
|
+
if (reconciliationId && reader.operations(run.runId, true).some(op => op.kind === "build-assignment" && op.intent.sourceRecoveryId === reconciliationId))
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
reader.close();
|
|
152
|
+
}
|
|
153
|
+
const source = captureBuildSource(projectDir);
|
|
154
|
+
if (continuation.productDigest ? interruptedProductDigest(projectDir, source) !== continuation.productDigest : source.contentDigest !== continuation.contentDigest)
|
|
155
|
+
throw new BuildAssignmentRejected("Preserved implementation changed after recovery preparation; inspect the current files with rafi resume before launching new work");
|
|
156
|
+
}
|
|
157
|
+
function interruptedProductDigest(projectDir, source) {
|
|
158
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
159
|
+
// Ticket status/rendering is host bookkeeping, not product mutation. Ticket
|
|
160
|
+
// definitions and other documents remain bound, including unrelated docs.
|
|
161
|
+
const bookkeeping = new Set([paths.stateDb, `${paths.stateDb}-wal`, `${paths.stateDb}-shm`, paths.progressDoc, paths.archiveDoc].map(path => relative(projectDir, path).replace(/\\/g, "/")));
|
|
162
|
+
return createHash("sha256").update(JSON.stringify({ head: source.head, branch: sourceBranch(projectDir), paths: source.pathInventory.filter(path => !bookkeeping.has(path.path)) })).digest("hex");
|
|
163
|
+
}
|
|
164
|
+
function sourceBranch(projectDir) {
|
|
165
|
+
const result = spawnSync("git", ["branch", "--show-current"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
166
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
167
|
+
}
|
|
41
168
|
/**
|
|
42
169
|
* Retire only the host-side records for a Builder turn that was interrupted
|
|
43
170
|
* before Rafi received a durable response. The next turn is sent to the
|
|
@@ -210,6 +337,12 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
210
337
|
db.atomic(() => {
|
|
211
338
|
db.ensureRun(runId);
|
|
212
339
|
const admission = db.assertAdmittedWork(runId, ticketId);
|
|
340
|
+
const continuationState = db.getRun(runId);
|
|
341
|
+
const sourceContinuation = continuationState.state.interruptedBuilderContinuation;
|
|
342
|
+
if (sourceContinuation?.recovery === "source-reconciled" && sourceContinuation.ticketIds?.includes(ticketId) && !sourceContinuation.firstAssignmentId) {
|
|
343
|
+
assertInterruptedSourceSnapshot(projectDir, continuationState.state);
|
|
344
|
+
db.transition(runId, { checkpoint: continuationState.checkpoint, state: { ...continuationState.state, interruptedBuilderContinuation: { ...sourceContinuation, firstAssignmentId: operationId } }, event: "builder_source_continuation_consumed", payload: { ticketId, operationId, sourceDigest: before.digest } });
|
|
345
|
+
}
|
|
213
346
|
let contractBinding;
|
|
214
347
|
if (db.qaPreparationStore().policy(runId)?.mode === "enforce") {
|
|
215
348
|
const contract = assertContractReceipt(db.qaPreparationStore(), runId, ticketId, admission.requirementsDigest, adapter ? actualContractSession(adapter, worktree, projectDir) : undefined, adapter);
|
|
@@ -221,7 +354,11 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
221
354
|
if (contract.inputs.find(input => input.kind === "rules")?.digest !== current.digest || contract.inputs.find(input => input.kind === "checklist")?.digest !== contractDigest("input-checklist", current.checklist))
|
|
222
355
|
throw new BuildAssignmentRejected("Canonical QA rules, skills or checklist changed after delivery; reconcile the contract before implementation");
|
|
223
356
|
}
|
|
224
|
-
|
|
357
|
+
const continuation = db.getRun(runId)?.state.interruptedBuilderContinuation;
|
|
358
|
+
const recoveredInstruction = continuation?.recovery === "source-reconciled" && continuation.ticketIds?.includes(ticketId)
|
|
359
|
+
? `${instruction}\n\nHost recovery ${continuation.reconciliationId}: an earlier assignment was interrupted with an unknown result. Inspect the preserved files before acting. Do not replay the old instruction, repeat completed side effects, or redo completed tickets. Implement only missing acceptance criteria for this ticket, validate the current implementation and return truthful evidence for independent fresh QA.`
|
|
360
|
+
: instruction;
|
|
361
|
+
guidance = db.reserveGuidance(runId, ticketId, "builder", operationId, before.digest, recoveredInstruction);
|
|
225
362
|
if (followup) {
|
|
226
363
|
if (admission.requirementsDigest !== followup.requirementsDigest)
|
|
227
364
|
throw new BuildAssignmentRejected("Ticket requirements changed; renewed scope approval is required before the Builder follow-up");
|
|
@@ -236,7 +373,7 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
236
373
|
db.transitionQa(runId, ticketId, head.revision, { type: "builder-guidance-followup-intended" });
|
|
237
374
|
}
|
|
238
375
|
const instructionDigest = db.putEvidence("qa", Buffer.from(guidance.text));
|
|
239
|
-
db.planOperation({ runId, idempotencyKey: operationId, kind: "build-assignment", intent: { ticketId, worktree, admissionId: admission.assignmentId, requirementsDigest: admission.requirementsDigest, instructionDigest, ...(contractBinding ? { contractBinding } : {}), before: retainSource(db, before), ...(followup ? { managerFollowup: followup } : {}) } });
|
|
376
|
+
db.planOperation({ runId, idempotencyKey: operationId, kind: "build-assignment", intent: { ticketId, worktree, admissionId: admission.assignmentId, requirementsDigest: admission.requirementsDigest, instructionDigest, ...(continuation?.recovery === "source-reconciled" && continuation.ticketIds?.includes(ticketId) ? { sourceRecoveryId: continuation.reconciliationId } : {}), ...(contractBinding ? { contractBinding } : {}), before: retainSource(db, before), ...(followup ? { managerFollowup: followup } : {}) } });
|
|
240
377
|
db.recordWorkAssignment(runId, ticketId, operationId, { requirementsDigest: admission.requirementsDigest, worktree, sourceDigest: before.digest, ownerGeneration: db.currentLease()?.generation, instructionDigest });
|
|
241
378
|
db.updateOperation(operationId, "in_progress");
|
|
242
379
|
});
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -39,6 +39,9 @@ export declare function resumeBuildRun(projectDir: string, runId: string, patch:
|
|
|
39
39
|
type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision" | "display_session_cost" | "auto_compact_threshold_percent" | "compact_maximum"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision" | "display_session_cost" | "auto_compact_threshold_percent" | "compact_maximum">>;
|
|
40
40
|
export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
41
41
|
export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV2, checkpoint: string, patch?: Partial<BuildRunRecordV2>): BuildRunRecordV2;
|
|
42
|
+
/** Refresh a publication from durable state while retaining only the caller's
|
|
43
|
+
* original execution authority. A read-only projection never grants authority. */
|
|
44
|
+
export declare function persistCurrentBuildSession(projectDir: string, authorizedRun: BuildRunRecordV2, role: "builder" | "qa", session: ProviderSessionRefV1): BuildRunRecordV2;
|
|
42
45
|
export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV2, role: "builder" | "qa", session: string | ProviderSessionRefV1): BuildRunRecordV2;
|
|
43
46
|
/** Latest scoped binding, with the narrow legacy Builder inference allowed by the recovery protocol. */
|
|
44
47
|
export declare function buildRunSessionBinding(run: BuildRunRecordV2, role: "builder" | "qa", sessionId?: string): ProviderSessionRefV1 | undefined;
|
|
@@ -51,6 +54,8 @@ export declare function releaseBuildLease(projectDir: string, run: BuildRunRecor
|
|
|
51
54
|
export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
52
55
|
/** A recovered ticket boundary is complete only when the saved run scope is done. */
|
|
53
56
|
export declare function finishRecoveredTicketScope(projectDir: string, run: BuildRunRecordV2, checkpoint: string): BuildRunRecordV2;
|
|
57
|
+
/** A recovered boundary cannot authorize work on a still-blocked remainder. */
|
|
58
|
+
export declare function hasRunnableBuildRemainder(projectDir: string, run: BuildRunRecordV2): boolean;
|
|
54
59
|
export declare function readBuildRuns(projectDir: string): BuildRunRecordV2[];
|
|
55
60
|
export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV2 & {
|
|
56
61
|
active: boolean;
|
|
@@ -71,6 +76,7 @@ export interface BuildRecoveryProjection {
|
|
|
71
76
|
compactLabel: string;
|
|
72
77
|
compactHint: string;
|
|
73
78
|
completed: string[];
|
|
79
|
+
completedOperations?: string[];
|
|
74
80
|
remaining: string[];
|
|
75
81
|
lastSuccess: string;
|
|
76
82
|
interruption: string;
|
package/dist/buildRuns.js
CHANGED
|
@@ -16,6 +16,7 @@ import { loadTickets } from "./tickets/ticketLoader.js";
|
|
|
16
16
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
17
17
|
import { classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
18
18
|
import { assertBuildAssignmentReconciled } from "./buildAssignment.js";
|
|
19
|
+
import { computeDisplayStatus, resolveBlockers } from "./tickets/blockers.js";
|
|
19
20
|
export const BUILD_RUN_VERSION = 3;
|
|
20
21
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
21
22
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
@@ -176,6 +177,11 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
176
177
|
const existing = workflow.getRun(next.runId);
|
|
177
178
|
const priorProgress = existing?.state.progress;
|
|
178
179
|
next = { ...next, progress: { ...next.progress, completedTickets: [...new Set([...next.progress.completedTickets, ...(priorProgress?.completedTickets ?? [])])] } };
|
|
180
|
+
const newlyCompleted = next.progress.completedTickets.filter(ticket => !priorProgress?.completedTickets?.includes(ticket));
|
|
181
|
+
if (newlyCompleted.length || next.status === "completed")
|
|
182
|
+
next = { ...next, progress: { ...next.progress,
|
|
183
|
+
lastSuccessfulAction: next.status === "completed" ? "Build completed" : `Recorded completion of ${newlyCompleted.join(", ")}`,
|
|
184
|
+
} };
|
|
179
185
|
if (existing && ["completed", "cancelled", "superseded"].includes(existing.status) && workflowStatus(next.status) !== existing.status)
|
|
180
186
|
throw new Error(`cannot rewrite a ${existing.status} build run`);
|
|
181
187
|
if (!existing)
|
|
@@ -228,6 +234,21 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
228
234
|
export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
|
|
229
235
|
return saveBuildRun(projectDir, { ...run, ...patch, checkpoint, receipts: patch.receipts ?? run.receipts });
|
|
230
236
|
}
|
|
237
|
+
/** Refresh a publication from durable state while retaining only the caller's
|
|
238
|
+
* original execution authority. A read-only projection never grants authority. */
|
|
239
|
+
export function persistCurrentBuildSession(projectDir, authorizedRun, role, session) {
|
|
240
|
+
const db = new WorkflowDb(projectDir);
|
|
241
|
+
try {
|
|
242
|
+
assertBuildAuthority(db, authorizedRun);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
const latest = readBuildRuns(projectDir).find(run => run.runId === authorizedRun.runId);
|
|
248
|
+
if (!latest)
|
|
249
|
+
throw new Error("The authorized build no longer exists; inspect recovery before publishing a session");
|
|
250
|
+
return persistBuildSession(projectDir, { ...authorizedRun, ...latest }, role, session);
|
|
251
|
+
}
|
|
231
252
|
export function persistBuildSession(projectDir, run, role, session) {
|
|
232
253
|
const current = run[role];
|
|
233
254
|
if (!current)
|
|
@@ -329,6 +350,28 @@ export function finishRecoveredTicketScope(projectDir, run, checkpoint) {
|
|
|
329
350
|
progress: { ...run.progress, completedTickets: completed, remainingTickets: remaining, nextAction: `Resume ${remaining[0]}` },
|
|
330
351
|
}), "recoverable");
|
|
331
352
|
}
|
|
353
|
+
/** A recovered boundary cannot authorize work on a still-blocked remainder. */
|
|
354
|
+
export function hasRunnableBuildRemainder(projectDir, run) {
|
|
355
|
+
if (run.authorizedBatch && run.progress.completedTickets.length >= run.authorizedBatch.requestedSteps)
|
|
356
|
+
return false;
|
|
357
|
+
if (!isTicketsInitialized(projectDir))
|
|
358
|
+
return false;
|
|
359
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
360
|
+
const db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
361
|
+
try {
|
|
362
|
+
const states = new Map(db.prepare("SELECT * FROM ticket_state").all().map(state => [state.ticket_id, state]));
|
|
363
|
+
return loadTickets(paths.tickets).some(ticket => {
|
|
364
|
+
if (!run.progress.remainingTickets.includes(ticket.id))
|
|
365
|
+
return false;
|
|
366
|
+
const blockedBy = resolveBlockers(ticket, states);
|
|
367
|
+
const status = computeDisplayStatus(states.get(ticket.id)?.status ?? "planned", blockedBy);
|
|
368
|
+
return blockedBy.length === 0 && ["next", "in_progress"].includes(status);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
db.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
332
375
|
export function readBuildRuns(projectDir) {
|
|
333
376
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
334
377
|
const workflow = new WorkflowReader(projectDir);
|
|
@@ -363,9 +406,13 @@ export function readBuildRuns(projectDir) {
|
|
|
363
406
|
const authoritative = workflow?.getRun(projection.runId)?.state;
|
|
364
407
|
const run = authoritative?.runId ? authoritative : projection;
|
|
365
408
|
let upgraded = upgradeBuildRun(run, projectDir);
|
|
366
|
-
const
|
|
409
|
+
const storedRun = workflow.getRun(run.runId);
|
|
410
|
+
const dbStatus = storedRun?.status;
|
|
367
411
|
if (dbStatus === "superseded" || dbStatus === "completed" || dbStatus === "cancelled")
|
|
368
412
|
upgraded = { ...upgraded, status: dbStatus };
|
|
413
|
+
else if (storedRun && (dbStatus === "failed" || dbStatus === "paused" || dbStatus === "blocked")) {
|
|
414
|
+
upgraded = { ...upgraded, status: dbStatus === "failed" ? "failed" : "recoverable", checkpoint: storedRun.checkpoint, updatedAt: storedRun.updatedAt };
|
|
415
|
+
}
|
|
369
416
|
if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
|
|
370
417
|
for (const role of ["builder", "qa"]) {
|
|
371
418
|
const lease = workflow?.roleMutationLease(run.runId, role);
|
|
@@ -563,7 +610,8 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
|
|
|
563
610
|
unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
|
|
564
611
|
if (branchChanged)
|
|
565
612
|
unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
|
|
566
|
-
const completed = [...new Set(
|
|
613
|
+
const completed = [...new Set(progress?.completed ?? run.progress.completedTickets)];
|
|
614
|
+
const completedOperations = [...new Set([...run.progress.completedOperations, ...operationNames])];
|
|
567
615
|
const remaining = progress?.remaining ?? (run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run));
|
|
568
616
|
const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
|
|
569
617
|
const validation = run.progress.validation
|
|
@@ -580,8 +628,8 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
|
|
|
580
628
|
run, ticketId, ticketTitle,
|
|
581
629
|
compactLabel: `${primary} — ${run.status}`,
|
|
582
630
|
compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
|
|
583
|
-
completed, remaining,
|
|
584
|
-
lastSuccess: run.progress.lastSuccessfulAction ??
|
|
631
|
+
completed, completedOperations, remaining,
|
|
632
|
+
lastSuccess: run.progress.lastSuccessfulAction ?? "none recorded",
|
|
585
633
|
interruption,
|
|
586
634
|
validation,
|
|
587
635
|
expectedChanges,
|
|
@@ -623,6 +671,7 @@ export function formatBuildRecoveryProjection(projection) {
|
|
|
623
671
|
`ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
|
|
624
672
|
`status ${run.status}; checkpoint ${run.checkpoint}`,
|
|
625
673
|
`completed ${projection.completed.join(", ") || "none recorded"}`,
|
|
674
|
+
...(projection.completedOperations?.length ? [`verified operation receipts ${projection.completedOperations.join(", ")}`] : []),
|
|
626
675
|
`current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
|
|
627
676
|
`remaining ${projection.remaining.join(", ") || "none"}`,
|
|
628
677
|
`last successful action ${projection.lastSuccess}`,
|
|
@@ -680,7 +729,7 @@ function captureGitSnapshot(worktree, input) {
|
|
|
680
729
|
return undefined;
|
|
681
730
|
}
|
|
682
731
|
};
|
|
683
|
-
const statusPaths = (
|
|
732
|
+
const statusPaths = gitStatusPaths(worktree);
|
|
684
733
|
const head = input.startHead ?? git(["rev-parse", "HEAD"]);
|
|
685
734
|
const branch = input.branch ?? git(["branch", "--show-current"]);
|
|
686
735
|
return {
|
|
@@ -710,7 +759,24 @@ function gitValue(cwd, args) {
|
|
|
710
759
|
}
|
|
711
760
|
}
|
|
712
761
|
function gitStatusPaths(cwd) {
|
|
713
|
-
|
|
762
|
+
try {
|
|
763
|
+
// Porcelain status columns and pathname whitespace are significant. In -z
|
|
764
|
+
// mode a rename/copy has a second raw pathname, without status columns.
|
|
765
|
+
const records = execFileSync("git", ["status", "--porcelain=v1", "-z"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\0");
|
|
766
|
+
const paths = [];
|
|
767
|
+
for (let i = 0; i < records.length; i++) {
|
|
768
|
+
const record = records[i];
|
|
769
|
+
if (!record)
|
|
770
|
+
continue;
|
|
771
|
+
paths.push(record.slice(3));
|
|
772
|
+
if (/[RC]/.test(record.slice(0, 2)) && records[i + 1])
|
|
773
|
+
paths.push(records[++i]);
|
|
774
|
+
}
|
|
775
|
+
return [...new Set(paths.filter(Boolean))].sort();
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
714
780
|
}
|
|
715
781
|
function relativeTime(value, now) {
|
|
716
782
|
const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
|
|
@@ -752,7 +818,7 @@ function upgradeBuildRun(run, projectDir = run.repository.root, now = new Date()
|
|
|
752
818
|
completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
|
|
753
819
|
completedOperations: Object.keys(legacy.receipts).sort(),
|
|
754
820
|
remainingTickets: remaining,
|
|
755
|
-
lastSuccessfulAction:
|
|
821
|
+
...(legacy.status === "completed" ? { lastSuccessfulAction: "build completed" } : {}),
|
|
756
822
|
nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
|
|
757
823
|
},
|
|
758
824
|
frozenPolicy: policy,
|
package/dist/cli/start.d.ts
CHANGED
|
@@ -29,5 +29,10 @@ export declare function buildStartCommand(options?: {
|
|
|
29
29
|
}): Command;
|
|
30
30
|
export declare function createQaNonconvergenceHandler(projectDir: string, noninteractive: boolean, planner?: AgentRoleDefaultsV1, observer?: () => RunObserver | undefined, activeRunId?: () => string): (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
|
|
31
31
|
export declare function createQaReportRecoveryHandler(projectDir: string, noninteractive: boolean, observer?: () => RunObserver | undefined): QaReportRecoveryHandler;
|
|
32
|
+
export declare function recoveryCompactionHistoryUncertain(projectDir: string, recovery: {
|
|
33
|
+
legacy?: boolean;
|
|
34
|
+
version?: number;
|
|
35
|
+
runDecisions?: unknown;
|
|
36
|
+
} | undefined, runId: string | undefined, role: "builder" | "qa"): boolean;
|
|
32
37
|
/** Make the executable branch plan exactly match each explicit root-to-tip stack. */
|
|
33
38
|
export declare function applyExplicitStackTopology(plan: BranchPlan, stacks: DeliveryStack[], delivery: DeliveryConfig, branchPrefix: string): BranchPlan;
|