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.
Files changed (42) 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 +21 -1
  12. package/dist/buildAssignment.js +215 -2
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +8 -1
  16. package/dist/cli/start.js +132 -38
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +20 -2
  19. package/dist/continuity.js +11 -10
  20. package/dist/foreman.d.ts +8 -0
  21. package/dist/foreman.js +69 -10
  22. package/dist/handoffProtocol.d.ts +4 -0
  23. package/dist/handoffProtocol.js +19 -0
  24. package/dist/handoffs.d.ts +1 -1
  25. package/dist/handoffs.js +23 -4
  26. package/dist/permissions/policy.js +18 -11
  27. package/dist/permissions/shellForms.d.ts +3 -0
  28. package/dist/permissions/shellForms.js +203 -0
  29. package/dist/qaBuilderActivation.d.ts +19 -0
  30. package/dist/qaBuilderActivation.js +112 -0
  31. package/dist/qaFailureDelivery.js +1 -1
  32. package/dist/qaRecovery.js +2 -1
  33. package/dist/qaReview.d.ts +4 -0
  34. package/dist/qaReview.js +37 -7
  35. package/dist/sessionLifecycle.js +1 -1
  36. package/dist/supervisedStart.js +23 -3
  37. package/dist/supervisor.js +33 -0
  38. package/dist/tickets/groupReset.js +100 -96
  39. package/dist/tickets/reset.js +80 -69
  40. package/dist/workflowDb.d.ts +16 -1
  41. package/dist/workflowDb.js +276 -8
  42. package/package.json +2 -2
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,
@@ -24,8 +24,15 @@ export declare function validateStartOptions(opts: Record<string, any>, requireC
24
24
  export declare function validateRecoveryArguments(args: readonly string[], projectDir: string): void;
25
25
  /** Validate persisted preparation argv without executing a command or provider. */
26
26
  export declare function validatePreparationArguments(args: readonly string[], projectDir: string): string[];
27
- export declare function buildStartCommand(): Command;
27
+ export declare function buildStartCommand(options?: {
28
+ beforeFreshStart?: (projectDir: string) => Promise<boolean>;
29
+ }): Command;
28
30
  export declare function createQaNonconvergenceHandler(projectDir: string, noninteractive: boolean, planner?: AgentRoleDefaultsV1, observer?: () => RunObserver | undefined, activeRunId?: () => string): (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
29
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;
30
37
  /** Make the executable branch plan exactly match each explicit root-to-tip stack. */
31
38
  export declare function applyExplicitStackTopology(plan: BranchPlan, stacks: DeliveryStack[], delivery: DeliveryConfig, branchPrefix: string): BranchPlan;
package/dist/cli/start.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "../qaEffectiveConfig.js";
2
+ import { activateQaRecoveryBuilder } from "../qaBuilderActivation.js";
2
3
  import { graphContext, sendGraphTurn } from "../graph/turn.js";
3
4
  import { initializeSyntheticWork } from "../buildSyntheticWork.js";
4
5
  import { admitApprovedTicket } from "../buildWorkAuthorization.js";
@@ -8,7 +9,7 @@ import { formatRecoveryCommand, formatDecisionCommands, formatExactRunRecovery }
8
9
  import { durableReadOnlyProposal } from "../readOnlyProposal.js";
9
10
  import { captureFrozenQaSource } from "../qaSnapshot.js";
10
11
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
11
- import { assertBuildAssignmentReconciled } from "../buildAssignment.js";
12
+ import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
12
13
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
13
14
  import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
14
15
  import { Command, Option } from "commander";
@@ -24,7 +25,7 @@ import { PermissionPolicy } from "../permissions/policy.js";
24
25
  import { ClaudeAdapter } from "../adapters/claude.js";
25
26
  import { CodexAdapter } from "../adapters/codex.js";
26
27
  import { RecoveringAdapter } from "../adapters/recovering.js";
27
- import { Foreman, createPermissionHandler } from "../foreman.js";
28
+ import { Foreman, BuildInterruptedError, createPermissionHandler } from "../foreman.js";
28
29
  import { printEvents } from "./events.js";
29
30
  import { loadRoleBundle } from "../roles.js";
30
31
  import { ensureBuildRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
@@ -39,7 +40,7 @@ import { formatGitHubFailure, preflightGh } from "../branch/github.js";
39
40
  import { preflightGlab } from "../branch/gitlab.js";
40
41
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
41
42
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
42
- import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
43
+ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, persistCurrentBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
43
44
  import { parse as parseYaml } from "yaml";
44
45
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
45
46
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
@@ -450,7 +451,7 @@ export function validatePreparationArguments(args, projectDir) {
450
451
  throw new Error("Saved preparation contains stale internal recovery authority");
451
452
  return savedStartArguments(command, projectDir, options);
452
453
  }
453
- export function buildStartCommand() {
454
+ export function buildStartCommand(options = {}) {
454
455
  return new Command("start")
455
456
  .description("Enlist a builder and drive it through a batch of N steps.")
456
457
  .argument("<project>", "path to the project directory the builder works in")
@@ -471,7 +472,7 @@ export function buildStartCommand() {
471
472
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
472
473
  .option("-y, --yes", "skip pre-flight confirmation prompt")
473
474
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
474
- .option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
475
+ .option("--fast", "use native fast mode when available; keep the model and reasoning settings")
475
476
  .option("--no-qa", "disable per-ticket QA review (enabled by default)")
476
477
  .option("--autonomy <profile>", "autonomy profile (supervised | balanced | unattended)")
477
478
  .option("--detach", "return after launching the durable supervisor")
@@ -557,6 +558,10 @@ export function buildStartCommand() {
557
558
  const cwd = resolve(project);
558
559
  if (!existsSync(cwd))
559
560
  fail(`project directory not found: ${cwd}`);
561
+ // Recovery commands launch a frozen continuation and must not recursively
562
+ // offer a different recovery path. Fresh launches may be redirected first.
563
+ if (!recoveryIntent && options.beforeFreshStart && !await options.beforeFreshStart(cwd))
564
+ return;
560
565
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
561
566
  let dirtyWork = recoveryRecord?.runDecisions?.dirtyWork;
562
567
  if (opts.recoverRun && !recoveryRecord)
@@ -956,7 +961,10 @@ export function buildStartCommand() {
956
961
  let activeContinuityRunId;
957
962
  let liveStatusReporter;
958
963
  let activeBuilderForStatus;
959
- const resolvedContinuitySettings = (role) => role === "builder" ? {
964
+ const qaBuilderRecoverySelected = recoveryRecord?.recoveryDecision?.role === "qa";
965
+ const savedQaBuilderSettings = qaBuilderRecoverySelected ? recoveryRecord.builder?.settings : undefined;
966
+ let publishActivatedBuilderSession;
967
+ const resolvedContinuitySettings = (role) => role === "builder" && savedQaBuilderSettings ? { ...savedQaBuilderSettings } : role === "builder" ? {
960
968
  role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
961
969
  reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
962
970
  settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
@@ -1254,6 +1262,35 @@ export function buildStartCommand() {
1254
1262
  await continuous.validateFreshRecovery();
1255
1263
  return continuous;
1256
1264
  };
1265
+ // QA-only recovery leaves Builder ownership untouched until a real,
1266
+ // approved remediation/guidance boundary needs Builder work. The unused
1267
+ // adapter cannot bypass the old role owner or use QA's CLI model settings.
1268
+ const activateBuilderForQaRecovery = async (adapter, worktree, frozenAction) => {
1269
+ if (!qaBuilderRecoverySelected || !recoveryRecord)
1270
+ return adapter;
1271
+ const currentRun = readBuildRuns(cwd).find(run => run.runId === recoveryRecord.runId);
1272
+ // The saved recovery ticket is only the initial boundary. Later batch
1273
+ // tickets must bind their own durable selection before Builder work.
1274
+ const ticketId = currentRun?.currentTicket ?? recoveryRecord.recoveryDecision?.executionTickets?.[0] ?? requestedTicketIds[0];
1275
+ if (!ticketId)
1276
+ throw new Error("QA recovery has no approved Builder follow-up ticket; inspect this build before dispatch");
1277
+ const activated = await activateQaRecoveryBuilder({ projectDir: cwd, runId: recoveryRecord.runId, ticketId, worktree, adapter,
1278
+ settings: savedQaBuilderSettings ?? resolvedContinuitySettings("builder"), expectedCapabilities: builderCapabilities, frozenAction,
1279
+ createSuccessor: () => createBuilderForSettings(worktree, savedQaBuilderSettings ?? resolvedContinuitySettings("builder")),
1280
+ wrap: successor => continuousBuilder(successor, worktree, recoveryRecord.runId, savedQaBuilderSettings ?? resolvedContinuitySettings("builder")),
1281
+ recovery: { ...handoffRecoveryOptions("builder"), allowProviderSwitch: false },
1282
+ });
1283
+ if (activated !== adapter) {
1284
+ const current = readBuildRuns(cwd).find(run => run.runId === recoveryRecord.runId);
1285
+ const ref = activated.sessionRef?.();
1286
+ if (!current || !ref?.validatedAt)
1287
+ throw new Error("Accepted Builder activation lacks a durable scoped session; recovery is paused before implementation");
1288
+ if (!publishActivatedBuilderSession)
1289
+ throw new Error("Accepted Builder activation has no original execution authority; recovery is paused before implementation");
1290
+ publishActivatedBuilderSession(ref);
1291
+ }
1292
+ return activated;
1293
+ };
1257
1294
  const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
1258
1295
  const context = await input.adapter.contextUsage?.().catch(() => undefined);
1259
1296
  const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
@@ -1450,7 +1487,7 @@ export function buildStartCommand() {
1450
1487
  db.close();
1451
1488
  } },
1452
1489
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1453
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1490
+ return activeContinuityRunId ? db.roleMutationLease(activeContinuityRunId, "qa")?.generation ?? 0 : 0;
1454
1491
  }
1455
1492
  finally {
1456
1493
  db.close();
@@ -1469,7 +1506,7 @@ export function buildStartCommand() {
1469
1506
  db.close();
1470
1507
  } },
1471
1508
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1472
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1509
+ return activeContinuityRunId ? db.roleMutationLease(activeContinuityRunId, "builder")?.generation ?? 0 : 0;
1473
1510
  }
1474
1511
  finally {
1475
1512
  db.close();
@@ -1544,8 +1581,16 @@ export function buildStartCommand() {
1544
1581
  const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0]);
1545
1582
  if (!packet)
1546
1583
  throw new Error("accepted QA recovery handoff has no current durable packet");
1584
+ const boundaryDb = new WorkflowDb(cwd);
1585
+ let afterOwnedRemediation = false;
1586
+ try {
1587
+ afterOwnedRemediation = boundaryDb.hasConfirmedQaRemediationForRecheck(packet.manifest.runId, packet.manifest.ticketId);
1588
+ }
1589
+ finally {
1590
+ boundaryDb.close();
1591
+ }
1547
1592
  const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
1548
- handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1593
+ handoffReceipt = afterOwnedRemediation ? { kind: "initial" } : { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1549
1594
  runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
1550
1595
  reviewedStateDigest: packet.manifest.reviewedStateDigest,
1551
1596
  }, confinement.digest) };
@@ -1754,7 +1799,8 @@ export function buildStartCommand() {
1754
1799
  if (branchDefaults.createReview && branchDefaults.reviewProvider) {
1755
1800
  await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
1756
1801
  }
1757
- console.log(`foreman: builder capability request: workspace writes, shell network ${grantedNetwork ? "approved for this build" : "off"}, approvals ${opts.builderApprovals ? "per operation" : "never"}; effective policy requires provider confirmation. Use a provenance-verified local source/dependency bundle for offline acquisition.`);
1802
+ if (grantedNetwork || opts.builderApprovals)
1803
+ console.log(`foreman: requested Builder shell capabilities: network ${grantedNetwork ? "approved for this build" : "off"}; approvals ${opts.builderApprovals ? "per operation" : "never"}. Effective permissions will be verified with the provider.`);
1758
1804
  const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1759
1805
  label: "start",
1760
1806
  yes: Boolean(opts.yes),
@@ -1933,7 +1979,7 @@ export function buildStartCommand() {
1933
1979
  }
1934
1980
  resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1935
1981
  }
1936
- const capturedBranchBuilder = {
1982
+ const capturedBranchBuilder = savedQaBuilderSettings ? { ...savedQaBuilderSettings } : {
1937
1983
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
1938
1984
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1939
1985
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
@@ -1949,7 +1995,9 @@ export function buildStartCommand() {
1949
1995
  compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1950
1996
  };
1951
1997
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1952
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1998
+ const branchQaOnlyRecovery = Boolean(recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord)
1999
+ && (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
2000
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(branchQaOnlyRecovery ? {} : { builder: capturedBranchBuilder, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }), qa: capturedBranchQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1953
2001
  runId: invocationRunId,
1954
2002
  builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1955
2003
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
@@ -1957,6 +2005,7 @@ export function buildStartCommand() {
1957
2005
  runDecisions: { dirtyWork, workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1958
2006
  });
1959
2007
  activeContinuityRunId = masterRun.runId;
2008
+ publishActivatedBuilderSession = ref => { masterRun = persistCurrentBuildSession(cwd, masterRun, "builder", ref); };
1960
2009
  if (requiresBuildApproval(cwd, plan.nodes.map(node => node.ticket.id), autoApprovePlanUpdates, approvalConsequences) && plan.issues.every((issue) => !issue.blocking)) {
1961
2010
  const action = await durableHumanDecision({ projectDir: cwd, runId: invocationRunId, key: `branch-plan:${buildScopeRevision(cwd, approvalConsequences)}`, prompt: branchPresentation.prompt, choices: [{ id: "proceed", label: "Proceed" }, { id: "cancel", label: "Cancel" }], operation: () => select({
1962
2011
  message: branchPresentation.prompt,
@@ -1985,9 +2034,9 @@ export function buildStartCommand() {
1985
2034
  for (const node of plan.nodes)
1986
2035
  admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
1987
2036
  masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
1988
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
2037
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1989
2038
  let qaProtocolResumeTicket;
1990
- if (recoveryRecord && !qaResumedRecovery) {
2039
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
1991
2040
  const protocolDb = new WorkflowDb(cwd);
1992
2041
  try {
1993
2042
  const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
@@ -2121,7 +2170,7 @@ export function buildStartCommand() {
2121
2170
  db.close();
2122
2171
  } },
2123
2172
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2124
- return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
2173
+ return db.roleMutationLease(masterRun.runId, "builder")?.generation ?? 0;
2125
2174
  }
2126
2175
  finally {
2127
2176
  db.close();
@@ -2145,9 +2194,12 @@ export function buildStartCommand() {
2145
2194
  }
2146
2195
  },
2147
2196
  qaNonconvergence,
2148
- beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
2197
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(await activateBuilderForQaRecovery(adapter, worktreePath, frozenAction), frozenAction)).adapter,
2149
2198
  observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
2150
- builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
2199
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => {
2200
+ const activated = await activateBuilderForQaRecovery(adapter, worktreePath, frozenAction);
2201
+ return activated !== adapter ? activated : (await branchContextController(worktreePath).atWorkSessionBoundary(activated, frozenAction, strategy)).adapter;
2202
+ },
2151
2203
  qaSessionBoundary,
2152
2204
  observeQaNativeCompactions,
2153
2205
  });
@@ -2252,7 +2304,8 @@ export function buildStartCommand() {
2252
2304
  state.close();
2253
2305
  }
2254
2306
  }
2255
- console.log(`foreman: builder capability request: workspace writes, shell network ${grantedNetwork ? "approved for this build" : "off"}, approvals ${opts.builderApprovals ? "per operation" : "never"}; effective policy requires provider confirmation. Use a provenance-verified local source/dependency bundle for offline acquisition.`);
2307
+ if (grantedNetwork || opts.builderApprovals)
2308
+ console.log(`foreman: requested Builder shell capabilities: network ${grantedNetwork ? "approved for this build" : "off"}; approvals ${opts.builderApprovals ? "per operation" : "never"}. Effective permissions will be verified with the provider.`);
2256
2309
  const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
2257
2310
  label: "start",
2258
2311
  yes: Boolean(opts.yes),
@@ -2282,7 +2335,7 @@ export function buildStartCommand() {
2282
2335
  qaModel = qaReady.model;
2283
2336
  qaExecutable = qaReady.executable;
2284
2337
  }
2285
- const capturedBuilder = {
2338
+ const capturedBuilder = savedQaBuilderSettings ? { ...savedQaBuilderSettings } : {
2286
2339
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
2287
2340
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
2288
2341
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
@@ -2291,7 +2344,11 @@ export function buildStartCommand() {
2291
2344
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
2292
2345
  };
2293
2346
  const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 65, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
2294
- let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2347
+ // QA recovery owns the QA session only. Its CLI model overrides and unused
2348
+ // Builder adapter must not replace the saved Builder that continues later.
2349
+ const qaOnlyRecovery = Boolean(recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord)
2350
+ && (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
2351
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(qaOnlyRecovery ? {} : { builder: capturedBuilder, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }), qa: capturedQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2295
2352
  runId: invocationRunId,
2296
2353
  tickets: [...(executionTickets ?? [])], builderCapabilities, authorizedBatch: { tickets: [...(executionTickets ?? [])], requestedSteps: steps, scopeRevision: buildScopeRevision(cwd, approvalConsequences), startedTickets: [] }, repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
2297
2354
  frozenPolicy, autonomyProfile, qaEnabled,
@@ -2300,10 +2357,11 @@ export function buildStartCommand() {
2300
2357
  buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2301
2358
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2302
2359
  activeContinuityRunId = buildRun.runId;
2303
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2360
+ publishActivatedBuilderSession = ref => { buildRun = persistCurrentBuildSession(cwd, buildRun, "builder", ref); };
2361
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2304
2362
  let qaFinalizationTicket;
2305
2363
  let qaProtocolResumeTicket;
2306
- if (recoveryRecord && !qaResumedRecovery) {
2364
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
2307
2365
  const finalizationDb = new WorkflowDb(cwd);
2308
2366
  try {
2309
2367
  const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
@@ -2343,19 +2401,38 @@ export function buildStartCommand() {
2343
2401
  }
2344
2402
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
2345
2403
  heartbeat.unref();
2404
+ let interruptionRequested = false;
2405
+ let stopActiveBuild;
2346
2406
  const checkpointSignal = () => {
2347
- try {
2348
- buildRun = releaseBuildLease(cwd, buildRun, "interrupted");
2349
- }
2350
- catch { /* best effort */ }
2407
+ if (["completed", "cancelled", "superseded"].includes(buildRun.status))
2408
+ return;
2409
+ if (interruptionRequested)
2410
+ return;
2411
+ interruptionRequested = true;
2412
+ clearInterval(heartbeat);
2413
+ // Hold the lease until the cancelled provider turn has published its
2414
+ // outcome. Releasing here races with assignment and completion writes.
2415
+ void stopActiveBuild?.().catch(error => log.write("error", { phase: "interruption-cleanup", message: String(error) }));
2351
2416
  };
2352
2417
  process.once("SIGINT", checkpointSignal);
2353
2418
  process.once("SIGTERM", checkpointSignal);
2354
2419
  let builder;
2355
2420
  try {
2421
+ if (recoveryRecord)
2422
+ assertInterruptedSourceSnapshot(cwd, recoveryRecord);
2356
2423
  builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
2424
+ stopActiveBuild = () => builder.close();
2425
+ if (interruptionRequested) {
2426
+ await builder.close();
2427
+ throw new BuildInterruptedError();
2428
+ }
2357
2429
  }
2358
2430
  catch (error) {
2431
+ if (error instanceof BuildInterruptedError) {
2432
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "interrupted", { status: "recoverable" }), "recoverable");
2433
+ console.error(`rafi: Build interrupted; current work is preserved. Continue with ${formatRecoveryCommand(cwd)}`);
2434
+ process.exit(2);
2435
+ }
2359
2436
  if (!(error instanceof SessionUnavailableError))
2360
2437
  throw error;
2361
2438
  const summary = error.message.slice(0, 1000);
@@ -2413,7 +2490,13 @@ export function buildStartCommand() {
2413
2490
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
2414
2491
  },
2415
2492
  });
2416
- const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, frozenPolicy.limits.builderQaFixesPerTicket, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); }, activeObserver, capturedQa, true, qaReportRecovery, qaResumedRecovery, buildRun.runId, frozenPolicy.continueIndependentTickets);
2493
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, frozenPolicy.limits.builderQaFixesPerTicket, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(await activateBuilderForQaRecovery(adapter, cwd, frozenAction), frozenAction)).adapter, async (adapter, frozenAction, strategy) => {
2494
+ const activated = await activateBuilderForQaRecovery(adapter, cwd, frozenAction);
2495
+ return activated !== adapter ? activated : (await contextController.atWorkSessionBoundary(activated, frozenAction, strategy)).adapter;
2496
+ }, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); }, activeObserver, capturedQa, true, qaReportRecovery, qaResumedRecovery, buildRun.runId, frozenPolicy.continueIndependentTickets);
2497
+ stopActiveBuild = () => foreman.requestStop();
2498
+ if (interruptionRequested)
2499
+ await foreman.requestStop();
2417
2500
  activeBuilderForStatus = () => foreman.builderAdapter();
2418
2501
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
2419
2502
  runId: buildRun.runId,
@@ -2429,7 +2512,7 @@ export function buildStartCommand() {
2429
2512
  db.close();
2430
2513
  } },
2431
2514
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2432
- return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
2515
+ return db.roleMutationLease(buildRun.runId, "builder")?.generation ?? 0;
2433
2516
  }
2434
2517
  finally {
2435
2518
  db.close();
@@ -2451,7 +2534,7 @@ export function buildStartCommand() {
2451
2534
  builderFast ? "fast" : null,
2452
2535
  qaEnabled ? null : "qa=off",
2453
2536
  ].filter(Boolean).join(" ");
2454
- console.log(`${accent("foreman:")} driving a ${sanitizeTerminalText(agent)} builder through ${steps} step(s)${modifiers ? ` [${sanitizeTerminalText(modifiers)}]` : ""}`);
2537
+ console.log(`${accent("foreman:")} requested batch budget ${steps} step(s); current admitted scope ${buildRun.tickets.join(", ") || "awaiting a validated work plan"}; ${sanitizeTerminalText(agent)} Builder${modifiers ? ` [${sanitizeTerminalText(modifiers)}]` : ""}`);
2455
2538
  console.log(`${accent("foreman:")} project ${sanitizeTerminalText(cwd)}`);
2456
2539
  if (trackerRelPath)
2457
2540
  console.log(`${accent("foreman:")} tracker ${sanitizeTerminalText(trackerRelPath)}`);
@@ -2497,8 +2580,6 @@ export function buildStartCommand() {
2497
2580
  : foreman.completePendingQaRecovery(ticketId));
2498
2581
  if (pendingHandoffGeneration !== undefined)
2499
2582
  throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2500
- if (foreman.builderSessionId())
2501
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2502
2583
  if (foreman.qaSessionId())
2503
2584
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2504
2585
  buildRun = qa.outcome === "passed" || qa.outcome === "waived"
@@ -2650,7 +2731,16 @@ export function buildStartCommand() {
2650
2731
  }
2651
2732
  catch (err) {
2652
2733
  clearInterval(heartbeat);
2734
+ process.off("SIGINT", checkpointSignal);
2735
+ process.off("SIGTERM", checkpointSignal);
2736
+ await foreman.close().catch(() => { });
2653
2737
  statusReporter.stop("interrupted");
2738
+ if (interruptionRequested || err instanceof BuildInterruptedError) {
2739
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "interrupted", { status: "recoverable" }), "recoverable");
2740
+ log.write("safe-boundary-paused", { runId: buildRun.runId, checkpoint: "interrupted", intentional: true });
2741
+ console.error(`rafi: Build interrupted; current work is preserved. Continue with ${formatRecoveryCommand(cwd)}`);
2742
+ process.exit(2);
2743
+ }
2654
2744
  if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
2655
2745
  const summary = err.message.slice(0, 1000);
2656
2746
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
@@ -2717,7 +2807,7 @@ export function buildStartCommand() {
2717
2807
  }
2718
2808
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "failed", { status: "failed", failure: { category: "unknown", summary: err instanceof Error ? err.message.slice(0, 1000) : String(err).slice(0, 1000), at: new Date().toISOString() } }), "failed");
2719
2809
  await foreman.close().catch(() => { });
2720
- log.write("error", { message: String(err) });
2810
+ log.write("error", { message: String(err), ...(err instanceof Error ? { stack: err.stack } : {}) });
2721
2811
  fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
2722
2812
  }
2723
2813
  }
@@ -2801,6 +2891,7 @@ function pruneRecoveryTelemetryAtCompletion(projectDir, retentionDays) {
2801
2891
  console.error(`rafi: recovery telemetry maintenance skipped: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
2802
2892
  }
2803
2893
  }
2894
+ const reportedCompactionPolicies = new WeakMap();
2804
2895
  async function prepareNativeAutoCompaction(adapter) {
2805
2896
  if (!adapter.prepareAutoCompaction) {
2806
2897
  await adapter.close().catch(() => { });
@@ -2808,11 +2899,14 @@ async function prepareNativeAutoCompaction(adapter) {
2808
2899
  }
2809
2900
  try {
2810
2901
  const policy = await adapter.prepareAutoCompaction();
2811
- if (policy && policy.effectiveThresholdPercent !== policy.requestedThresholdPercent) {
2812
- console.log(`foreman: ${adapter.agent} applied automatic compaction at ${policy.effectiveThresholdPercent}% `
2813
- + `(configured ${policy.requestedThresholdPercent}%; provider minimum or clamp). `
2814
- + "Use `rafi agents .` to update the Builder or QA saved threshold interactively.");
2902
+ const noticeKey = policy ? `${policy.requestedThresholdPercent}:${policy.effectiveThresholdPercent}` : undefined;
2903
+ if (policy && policy.effectiveThresholdPercent !== policy.requestedThresholdPercent && reportedCompactionPolicies.get(adapter) !== noticeKey) {
2904
+ console.log(`foreman: ${adapter.agent} reports a native automatic-compaction trigger of ${policy.effectiveThresholdPercent}% `
2905
+ + `(requested ${policy.requestedThresholdPercent}%). Configuration does not mean that compaction occurred. `
2906
+ + "The provider's effective trigger may differ from the saved preference.");
2815
2907
  }
2908
+ if (noticeKey)
2909
+ reportedCompactionPolicies.set(adapter, noticeKey);
2816
2910
  }
2817
2911
  catch (error) {
2818
2912
  await adapter.close().catch(() => { });
@@ -2975,10 +3069,10 @@ function readAgentDefaults(projectDir) {
2975
3069
  function explicitDefaultValue(value) {
2976
3070
  return value && value !== "default" ? value : undefined;
2977
3071
  }
2978
- function recoveryCompactionHistoryUncertain(projectDir, recovery, runId, role) {
3072
+ export function recoveryCompactionHistoryUncertain(projectDir, recovery, runId, role) {
2979
3073
  if (!recovery)
2980
3074
  return false;
2981
- if (recovery.legacy || !recovery.runDecisions || !runId)
3075
+ if (recovery.legacy || (!recovery.runDecisions && recovery.version !== 3) || !runId)
2982
3076
  return true;
2983
3077
  const db = new WorkflowDb(projectDir);
2984
3078
  try {
@@ -61,5 +61,6 @@ export declare function ticketOwnershipReceipts(projectDir: string, commandName:
61
61
  export declare function buildTicketsCommand(options?: {
62
62
  prepareOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
63
63
  recordOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
64
+ restartBuild?: (projectDir: string, runId: string, tickets: string[]) => Promise<void>;
64
65
  }): Command;
65
66
  export {};