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.
Files changed (40) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/claude.d.ts +15 -0
  3. package/dist/adapters/claude.js +149 -28
  4. package/dist/adapters/codex.d.ts +9 -0
  5. package/dist/adapters/codex.js +77 -8
  6. package/dist/adapters/fastMode.d.ts +4 -0
  7. package/dist/adapters/fastMode.js +9 -0
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/agentRun.js +1 -0
  10. package/dist/branch/runner.js +46 -44
  11. package/dist/buildAssignment.d.ts +15 -0
  12. package/dist/buildAssignment.js +142 -5
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +5 -0
  16. package/dist/cli/start.js +123 -33
  17. package/dist/continuity.js +11 -10
  18. package/dist/foreman.d.ts +8 -0
  19. package/dist/foreman.js +69 -10
  20. package/dist/handoffProtocol.d.ts +4 -0
  21. package/dist/handoffProtocol.js +19 -0
  22. package/dist/handoffs.d.ts +1 -1
  23. package/dist/handoffs.js +23 -4
  24. package/dist/permissions/policy.js +18 -11
  25. package/dist/permissions/shellForms.d.ts +3 -0
  26. package/dist/permissions/shellForms.js +203 -0
  27. package/dist/qaBuilderActivation.d.ts +19 -0
  28. package/dist/qaBuilderActivation.js +112 -0
  29. package/dist/qaFailureDelivery.js +1 -1
  30. package/dist/qaRecovery.js +2 -1
  31. package/dist/qaReview.d.ts +4 -0
  32. package/dist/qaReview.js +37 -7
  33. package/dist/sessionLifecycle.js +1 -1
  34. package/dist/supervisedStart.js +23 -3
  35. package/dist/supervisor.js +33 -0
  36. package/dist/tickets/groupReset.js +100 -96
  37. package/dist/tickets/reset.js +80 -69
  38. package/dist/workflowDb.d.ts +16 -1
  39. package/dist/workflowDb.js +276 -8
  40. 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
+ }
@@ -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
@@ -54,6 +54,7 @@ export function readOnlyPermissionConfig() {
54
54
  "npm run typecheck",
55
55
  "npm run lint",
56
56
  "npm run check",
57
+ "node --test",
57
58
  "pnpm test",
58
59
  "pnpm run test",
59
60
  "pnpm typecheck",
@@ -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
- workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
395
- const sessionId = builder.sessionId();
396
- const sessionRef = builder.sessionRef?.();
397
- if (sessionId)
398
- builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
399
- if (sessionId) {
400
- opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
401
- workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
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 (node.deliveryUnitId) {
435
- const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
436
- mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
437
- 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");
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
@@ -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 === 1
37
- && run.tickets.includes(decision.executionTickets[0])
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
- guidance = db.reserveGuidance(runId, ticketId, "builder", operationId, before.digest, instruction);
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
  });
@@ -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 dbStatus = workflow.getRun(run.runId)?.status;
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([...(progress?.completed ?? run.progress.completedTickets), ...run.progress.completedOperations, ...operationNames])];
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 ?? run.checkpoint,
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 = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
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
- return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
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: legacy.checkpoint,
821
+ ...(legacy.status === "completed" ? { lastSuccessfulAction: "build completed" } : {}),
756
822
  nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
757
823
  },
758
824
  frozenPolicy: policy,
@@ -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;