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
|
@@ -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", {
|
|
@@ -2,7 +2,7 @@ import type { BuilderAdapter } from "./adapters/types.js";
|
|
|
2
2
|
import type { StepStatus } from "./foreman.js";
|
|
3
3
|
import type { TurnResult } from "./adapters/types.js";
|
|
4
4
|
import { type FrozenQaSourceState } from "./qaSnapshot.js";
|
|
5
|
-
import type { ProviderSessionRefV1 } from "rafi-spec";
|
|
5
|
+
import type { BuildRunRecordV2, ProviderSessionRefV1 } from "rafi-spec";
|
|
6
6
|
/** Work responses confirm an assignment; they never select the next ticket. */
|
|
7
7
|
export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
|
|
8
8
|
export interface BuildAssignmentCapture {
|
|
@@ -20,6 +20,23 @@ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDi
|
|
|
20
20
|
};
|
|
21
21
|
export declare class BuildAssignmentRejected extends Error {
|
|
22
22
|
}
|
|
23
|
+
/** A frozen exact-session Builder decision may defer unrelated pending QA. */
|
|
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;
|
|
23
40
|
/**
|
|
24
41
|
* Retire only the host-side records for a Builder turn that was interrupted
|
|
25
42
|
* before Rafi received a durable response. The next turn is sent to the
|
|
@@ -32,6 +49,9 @@ export declare function prepareExactSessionBuilderContinuation(projectDir: strin
|
|
|
32
49
|
dispatchIds: string[];
|
|
33
50
|
assignmentIds: string[];
|
|
34
51
|
};
|
|
52
|
+
/** Retire uncertain execution for an explicitly selected start-over, never claim
|
|
53
|
+
* that a provider did not run or that its preserved implementation passed QA. */
|
|
54
|
+
export declare function retireBuildExecutionForStartOver(projectDir: string, runId: string, operationId: string): void;
|
|
35
55
|
export interface BuilderGuidanceFollowup {
|
|
36
56
|
reviewAttemptId: string;
|
|
37
57
|
requirementsDigest: string;
|
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.
|
|
@@ -26,6 +31,140 @@ export function validateBuildAssignment(ticketId, status, responseText = "") {
|
|
|
26
31
|
}
|
|
27
32
|
export class BuildAssignmentRejected extends Error {
|
|
28
33
|
}
|
|
34
|
+
/** A frozen exact-session Builder decision may defer unrelated pending QA. */
|
|
35
|
+
export function isInterruptedBuilderRecovery(run) {
|
|
36
|
+
if (!run)
|
|
37
|
+
return false;
|
|
38
|
+
const decision = run.recoveryDecision;
|
|
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
|
+
}
|
|
45
|
+
return decision?.runId === run.runId && decision.role === "builder" && decision.mode === "exact-session"
|
|
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]))
|
|
48
|
+
&& Boolean(continuation?.sessionRef && decision.predecessorSessionRef && sameSession(continuation.sessionRef, decision.predecessorSessionRef));
|
|
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
|
+
}
|
|
29
168
|
/**
|
|
30
169
|
* Retire only the host-side records for a Builder turn that was interrupted
|
|
31
170
|
* before Rafi received a durable response. The next turn is sent to the
|
|
@@ -78,12 +217,62 @@ export function prepareExactSessionBuilderContinuation(projectDir, runId, sessio
|
|
|
78
217
|
db.close();
|
|
79
218
|
}
|
|
80
219
|
}
|
|
220
|
+
/** Retire uncertain execution for an explicitly selected start-over, never claim
|
|
221
|
+
* that a provider did not run or that its preserved implementation passed QA. */
|
|
222
|
+
export function retireBuildExecutionForStartOver(projectDir, runId, operationId) {
|
|
223
|
+
const db = new WorkflowDb(projectDir);
|
|
224
|
+
let admission;
|
|
225
|
+
let lease;
|
|
226
|
+
try {
|
|
227
|
+
const operation = db.getRun(operationId);
|
|
228
|
+
const original = operation?.originalWork;
|
|
229
|
+
if (operation?.kind !== "recovery" || operation.state.operation !== "build-start-over" || original?.run?.runId !== runId)
|
|
230
|
+
throw new Error("Execution retirement requires a recorded start-over intent for this run");
|
|
231
|
+
admission = db.acquireBuildRecoveryAdmission(runId);
|
|
232
|
+
lease = db.acquireLease(runId);
|
|
233
|
+
if (db.readinessProcesses().length)
|
|
234
|
+
throw new Error("Owned process cleanup must be verified before starting over");
|
|
235
|
+
if (db.incompletePublications().some(item => {
|
|
236
|
+
if (item.runId !== runId)
|
|
237
|
+
return false;
|
|
238
|
+
const intent = item.intent;
|
|
239
|
+
return intent.operation !== "build-run-projection" || intent.target !== join(resolve(projectDir), ".foreman", "runs", `${runId}.json`);
|
|
240
|
+
}))
|
|
241
|
+
throw new Error("Finish pending publication recovery before starting over; preserved files may still be awaiting publication");
|
|
242
|
+
db.atomic(() => {
|
|
243
|
+
for (const dispatch of [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")]) {
|
|
244
|
+
db.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "start-over", operationId, outcomeUnknown: true, providerReplayAuthorized: false }, error: "Operator chose a fresh run from preserved source; historical execution outcome remains unknown" });
|
|
245
|
+
}
|
|
246
|
+
const run = db.getRun(runId);
|
|
247
|
+
if (!run)
|
|
248
|
+
throw new Error("Original run disappeared before start-over");
|
|
249
|
+
db.transition(runId, { checkpoint: "execution-retired-for-start-over", state: { ...run.state, executionRetiredForStartOver: operationId }, event: "execution_retired_for_start_over", payload: { operationId, providerReplayAuthorized: false } });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
try {
|
|
254
|
+
if (lease)
|
|
255
|
+
db.releaseLease(lease);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
try {
|
|
259
|
+
if (admission)
|
|
260
|
+
db.releaseBuildAdmission(admission);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
db.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
81
268
|
function sameSession(value, session) {
|
|
82
269
|
if (!value || typeof value !== "object")
|
|
83
270
|
return false;
|
|
84
271
|
const candidate = value;
|
|
85
272
|
return candidate.provider === session.provider
|
|
86
273
|
&& candidate.sessionId === session.sessionId
|
|
274
|
+
&& candidate.generation === session.generation
|
|
275
|
+
&& session.role === "builder" && session.stream === "builder"
|
|
87
276
|
&& candidate.role === "builder"
|
|
88
277
|
&& candidate.stream === "builder"
|
|
89
278
|
&& candidate.cwd === session.cwd
|
|
@@ -93,9 +282,23 @@ export function assertBuildAssignmentReconciled(projectDir, runId, worktree = pr
|
|
|
93
282
|
const reader = new WorkflowReader(projectDir);
|
|
94
283
|
try {
|
|
95
284
|
const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
|
|
285
|
+
const retiredRuns = new Set();
|
|
96
286
|
for (const operation of runs.flatMap(id => reader.operations(id, true))) {
|
|
97
287
|
if (operation.kind !== "build-assignment")
|
|
98
288
|
continue;
|
|
289
|
+
if (retiredRuns.has(operation.runId))
|
|
290
|
+
continue;
|
|
291
|
+
const owner = reader.getRun(operation.runId);
|
|
292
|
+
// An explicit start-over retires execution, while retaining assignments
|
|
293
|
+
// as historical evidence. It never authorizes their provider replay.
|
|
294
|
+
const retirement = typeof owner?.state.supersededBy === "string" ? reader.getRun(owner.state.supersededBy) : undefined;
|
|
295
|
+
if (owner?.status === "superseded" && retirement?.kind === "recovery" && retirement.state.operation === "build-start-over"
|
|
296
|
+
&& retirement.state.gitPreserved === true && typeof retirement.state.resetId === "string"
|
|
297
|
+
&& retirement.originalWork.run?.runId === operation.runId
|
|
298
|
+
&& reader.events(operation.runId).some(event => event.type === "execution_retired_for_start_over" && event.payload.operationId === retirement.runId)) {
|
|
299
|
+
retiredRuns.add(operation.runId);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
99
302
|
const result = operation.result;
|
|
100
303
|
if (result?.reconciliationId)
|
|
101
304
|
continue;
|
|
@@ -134,6 +337,12 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
134
337
|
db.atomic(() => {
|
|
135
338
|
db.ensureRun(runId);
|
|
136
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
|
+
}
|
|
137
346
|
let contractBinding;
|
|
138
347
|
if (db.qaPreparationStore().policy(runId)?.mode === "enforce") {
|
|
139
348
|
const contract = assertContractReceipt(db.qaPreparationStore(), runId, ticketId, admission.requirementsDigest, adapter ? actualContractSession(adapter, worktree, projectDir) : undefined, adapter);
|
|
@@ -145,7 +354,11 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
145
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))
|
|
146
355
|
throw new BuildAssignmentRejected("Canonical QA rules, skills or checklist changed after delivery; reconcile the contract before implementation");
|
|
147
356
|
}
|
|
148
|
-
|
|
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);
|
|
149
362
|
if (followup) {
|
|
150
363
|
if (admission.requirementsDigest !== followup.requirementsDigest)
|
|
151
364
|
throw new BuildAssignmentRejected("Ticket requirements changed; renewed scope approval is required before the Builder follow-up");
|
|
@@ -160,7 +373,7 @@ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, inst
|
|
|
160
373
|
db.transitionQa(runId, ticketId, head.revision, { type: "builder-guidance-followup-intended" });
|
|
161
374
|
}
|
|
162
375
|
const instructionDigest = db.putEvidence("qa", Buffer.from(guidance.text));
|
|
163
|
-
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 } : {}) } });
|
|
164
377
|
db.recordWorkAssignment(runId, ticketId, operationId, { requirementsDigest: admission.requirementsDigest, worktree, sourceDigest: before.digest, ownerGeneration: db.currentLease()?.generation, instructionDigest });
|
|
165
378
|
db.updateOperation(operationId, "in_progress");
|
|
166
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;
|