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
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, isInterruptedBuilderRecovery } 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";
@@ -471,7 +472,7 @@ export function buildStartCommand(options = {}) {
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")
@@ -960,7 +961,10 @@ export function buildStartCommand(options = {}) {
960
961
  let activeContinuityRunId;
961
962
  let liveStatusReporter;
962
963
  let activeBuilderForStatus;
963
- 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" ? {
964
968
  role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
965
969
  reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
966
970
  settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
@@ -1258,6 +1262,35 @@ export function buildStartCommand(options = {}) {
1258
1262
  await continuous.validateFreshRecovery();
1259
1263
  return continuous;
1260
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
+ };
1261
1294
  const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
1262
1295
  const context = await input.adapter.contextUsage?.().catch(() => undefined);
1263
1296
  const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
@@ -1454,7 +1487,7 @@ export function buildStartCommand(options = {}) {
1454
1487
  db.close();
1455
1488
  } },
1456
1489
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1457
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1490
+ return activeContinuityRunId ? db.roleMutationLease(activeContinuityRunId, "qa")?.generation ?? 0 : 0;
1458
1491
  }
1459
1492
  finally {
1460
1493
  db.close();
@@ -1473,7 +1506,7 @@ export function buildStartCommand(options = {}) {
1473
1506
  db.close();
1474
1507
  } },
1475
1508
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1476
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1509
+ return activeContinuityRunId ? db.roleMutationLease(activeContinuityRunId, "builder")?.generation ?? 0 : 0;
1477
1510
  }
1478
1511
  finally {
1479
1512
  db.close();
@@ -1548,8 +1581,16 @@ export function buildStartCommand(options = {}) {
1548
1581
  const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0]);
1549
1582
  if (!packet)
1550
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
+ }
1551
1592
  const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
1552
- handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1593
+ handoffReceipt = afterOwnedRemediation ? { kind: "initial" } : { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1553
1594
  runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
1554
1595
  reviewedStateDigest: packet.manifest.reviewedStateDigest,
1555
1596
  }, confinement.digest) };
@@ -1758,7 +1799,8 @@ export function buildStartCommand(options = {}) {
1758
1799
  if (branchDefaults.createReview && branchDefaults.reviewProvider) {
1759
1800
  await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
1760
1801
  }
1761
- 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.`);
1762
1804
  const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1763
1805
  label: "start",
1764
1806
  yes: Boolean(opts.yes),
@@ -1937,7 +1979,7 @@ export function buildStartCommand(options = {}) {
1937
1979
  }
1938
1980
  resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1939
1981
  }
1940
- const capturedBranchBuilder = {
1982
+ const capturedBranchBuilder = savedQaBuilderSettings ? { ...savedQaBuilderSettings } : {
1941
1983
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
1942
1984
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1943
1985
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
@@ -1953,7 +1995,9 @@ export function buildStartCommand(options = {}) {
1953
1995
  compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1954
1996
  };
1955
1997
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1956
- 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({
1957
2001
  runId: invocationRunId,
1958
2002
  builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1959
2003
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
@@ -1961,6 +2005,7 @@ export function buildStartCommand(options = {}) {
1961
2005
  runDecisions: { dirtyWork, workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1962
2006
  });
1963
2007
  activeContinuityRunId = masterRun.runId;
2008
+ publishActivatedBuilderSession = ref => { masterRun = persistCurrentBuildSession(cwd, masterRun, "builder", ref); };
1964
2009
  if (requiresBuildApproval(cwd, plan.nodes.map(node => node.ticket.id), autoApprovePlanUpdates, approvalConsequences) && plan.issues.every((issue) => !issue.blocking)) {
1965
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({
1966
2011
  message: branchPresentation.prompt,
@@ -2125,7 +2170,7 @@ export function buildStartCommand(options = {}) {
2125
2170
  db.close();
2126
2171
  } },
2127
2172
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2128
- return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
2173
+ return db.roleMutationLease(masterRun.runId, "builder")?.generation ?? 0;
2129
2174
  }
2130
2175
  finally {
2131
2176
  db.close();
@@ -2149,9 +2194,12 @@ export function buildStartCommand(options = {}) {
2149
2194
  }
2150
2195
  },
2151
2196
  qaNonconvergence,
2152
- 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,
2153
2198
  observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
2154
- 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
+ },
2155
2203
  qaSessionBoundary,
2156
2204
  observeQaNativeCompactions,
2157
2205
  });
@@ -2256,7 +2304,8 @@ export function buildStartCommand(options = {}) {
2256
2304
  state.close();
2257
2305
  }
2258
2306
  }
2259
- 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.`);
2260
2309
  const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
2261
2310
  label: "start",
2262
2311
  yes: Boolean(opts.yes),
@@ -2286,7 +2335,7 @@ export function buildStartCommand(options = {}) {
2286
2335
  qaModel = qaReady.model;
2287
2336
  qaExecutable = qaReady.executable;
2288
2337
  }
2289
- const capturedBuilder = {
2338
+ const capturedBuilder = savedQaBuilderSettings ? { ...savedQaBuilderSettings } : {
2290
2339
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
2291
2340
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
2292
2341
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
@@ -2295,7 +2344,11 @@ export function buildStartCommand(options = {}) {
2295
2344
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
2296
2345
  };
2297
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 };
2298
- 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({
2299
2352
  runId: invocationRunId,
2300
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,
2301
2354
  frozenPolicy, autonomyProfile, qaEnabled,
@@ -2304,6 +2357,7 @@ export function buildStartCommand(options = {}) {
2304
2357
  buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2305
2358
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2306
2359
  activeContinuityRunId = buildRun.runId;
2360
+ publishActivatedBuilderSession = ref => { buildRun = persistCurrentBuildSession(cwd, buildRun, "builder", ref); };
2307
2361
  const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2308
2362
  let qaFinalizationTicket;
2309
2363
  let qaProtocolResumeTicket;
@@ -2347,19 +2401,38 @@ export function buildStartCommand(options = {}) {
2347
2401
  }
2348
2402
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
2349
2403
  heartbeat.unref();
2404
+ let interruptionRequested = false;
2405
+ let stopActiveBuild;
2350
2406
  const checkpointSignal = () => {
2351
- try {
2352
- buildRun = releaseBuildLease(cwd, buildRun, "interrupted");
2353
- }
2354
- 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) }));
2355
2416
  };
2356
2417
  process.once("SIGINT", checkpointSignal);
2357
2418
  process.once("SIGTERM", checkpointSignal);
2358
2419
  let builder;
2359
2420
  try {
2421
+ if (recoveryRecord)
2422
+ assertInterruptedSourceSnapshot(cwd, recoveryRecord);
2360
2423
  builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
2424
+ stopActiveBuild = () => builder.close();
2425
+ if (interruptionRequested) {
2426
+ await builder.close();
2427
+ throw new BuildInterruptedError();
2428
+ }
2361
2429
  }
2362
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
+ }
2363
2436
  if (!(error instanceof SessionUnavailableError))
2364
2437
  throw error;
2365
2438
  const summary = error.message.slice(0, 1000);
@@ -2417,7 +2490,13 @@ export function buildStartCommand(options = {}) {
2417
2490
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
2418
2491
  },
2419
2492
  });
2420
- 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();
2421
2500
  activeBuilderForStatus = () => foreman.builderAdapter();
2422
2501
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
2423
2502
  runId: buildRun.runId,
@@ -2433,7 +2512,7 @@ export function buildStartCommand(options = {}) {
2433
2512
  db.close();
2434
2513
  } },
2435
2514
  handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2436
- return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
2515
+ return db.roleMutationLease(buildRun.runId, "builder")?.generation ?? 0;
2437
2516
  }
2438
2517
  finally {
2439
2518
  db.close();
@@ -2455,7 +2534,7 @@ export function buildStartCommand(options = {}) {
2455
2534
  builderFast ? "fast" : null,
2456
2535
  qaEnabled ? null : "qa=off",
2457
2536
  ].filter(Boolean).join(" ");
2458
- 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)}]` : ""}`);
2459
2538
  console.log(`${accent("foreman:")} project ${sanitizeTerminalText(cwd)}`);
2460
2539
  if (trackerRelPath)
2461
2540
  console.log(`${accent("foreman:")} tracker ${sanitizeTerminalText(trackerRelPath)}`);
@@ -2501,8 +2580,6 @@ export function buildStartCommand(options = {}) {
2501
2580
  : foreman.completePendingQaRecovery(ticketId));
2502
2581
  if (pendingHandoffGeneration !== undefined)
2503
2582
  throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2504
- if (foreman.builderSessionId())
2505
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2506
2583
  if (foreman.qaSessionId())
2507
2584
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2508
2585
  buildRun = qa.outcome === "passed" || qa.outcome === "waived"
@@ -2654,7 +2731,16 @@ export function buildStartCommand(options = {}) {
2654
2731
  }
2655
2732
  catch (err) {
2656
2733
  clearInterval(heartbeat);
2734
+ process.off("SIGINT", checkpointSignal);
2735
+ process.off("SIGTERM", checkpointSignal);
2736
+ await foreman.close().catch(() => { });
2657
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
+ }
2658
2744
  if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
2659
2745
  const summary = err.message.slice(0, 1000);
2660
2746
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
@@ -2721,7 +2807,7 @@ export function buildStartCommand(options = {}) {
2721
2807
  }
2722
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");
2723
2809
  await foreman.close().catch(() => { });
2724
- log.write("error", { message: String(err) });
2810
+ log.write("error", { message: String(err), ...(err instanceof Error ? { stack: err.stack } : {}) });
2725
2811
  fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
2726
2812
  }
2727
2813
  }
@@ -2805,6 +2891,7 @@ function pruneRecoveryTelemetryAtCompletion(projectDir, retentionDays) {
2805
2891
  console.error(`rafi: recovery telemetry maintenance skipped: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
2806
2892
  }
2807
2893
  }
2894
+ const reportedCompactionPolicies = new WeakMap();
2808
2895
  async function prepareNativeAutoCompaction(adapter) {
2809
2896
  if (!adapter.prepareAutoCompaction) {
2810
2897
  await adapter.close().catch(() => { });
@@ -2812,11 +2899,14 @@ async function prepareNativeAutoCompaction(adapter) {
2812
2899
  }
2813
2900
  try {
2814
2901
  const policy = await adapter.prepareAutoCompaction();
2815
- if (policy && policy.effectiveThresholdPercent !== policy.requestedThresholdPercent) {
2816
- console.log(`foreman: ${adapter.agent} applied automatic compaction at ${policy.effectiveThresholdPercent}% `
2817
- + `(configured ${policy.requestedThresholdPercent}%; provider minimum or clamp). `
2818
- + "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.");
2819
2907
  }
2908
+ if (noticeKey)
2909
+ reportedCompactionPolicies.set(adapter, noticeKey);
2820
2910
  }
2821
2911
  catch (error) {
2822
2912
  await adapter.close().catch(() => { });
@@ -2979,10 +3069,10 @@ function readAgentDefaults(projectDir) {
2979
3069
  function explicitDefaultValue(value) {
2980
3070
  return value && value !== "default" ? value : undefined;
2981
3071
  }
2982
- function recoveryCompactionHistoryUncertain(projectDir, recovery, runId, role) {
3072
+ export function recoveryCompactionHistoryUncertain(projectDir, recovery, runId, role) {
2983
3073
  if (!recovery)
2984
3074
  return false;
2985
- if (recovery.legacy || !recovery.runDecisions || !runId)
3075
+ if (recovery.legacy || (!recovery.runDecisions && recovery.version !== 3) || !runId)
2986
3076
  return true;
2987
3077
  const db = new WorkflowDb(projectDir);
2988
3078
  try {
@@ -102,6 +102,7 @@ export function continuityInstruction(role) {
102
102
  return [
103
103
  "Before your final STEP_STATUS line (or as the final line when STEP_STATUS is not requested), emit exactly one single-line continuity record:",
104
104
  `${CONTINUITY_MARKER} {"version":1,"decisions":[],"constraints":[],"discoveries":[],"completedActions":[],"evidence":[],"failures":[],"blockers":[],"openWork":[],"nextAction":"specific next action"}`,
105
+ "The continuity record is JSON. A requested STEP_STATUS line is separate plain text: never add JSON braces, a trailing comma, or a code fence to that status line.",
105
106
  ...(role === "qa" ? ["QA protocol: this continuity record is required even when the review requests only a report or status. Put it before RAFI_QA_FAILURE_REPORT_START when returning a failure envelope, outside the report JSON. Rafi strips the record before validating the report, so keep the report envelope immediately followed by STEP_STATUS."] : []),
106
107
  "Keep it concise and cumulative for facts learned this turn. Do not include credentials, hidden reasoning, or raw transcripts.",
107
108
  ].join("\n");
@@ -177,13 +178,6 @@ export class ContinuityAdapter {
177
178
  return withGraphSessionAccess(this.options.projectDir, this.adapter, () => this.sendTurnOwned(instruction, policy));
178
179
  }
179
180
  async sendTurnOwned(instruction, policy) {
180
- const db = new WorkflowDb(this.options.projectDir);
181
- try {
182
- db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
183
- }
184
- finally {
185
- db.close();
186
- }
187
181
  const providerInstruction = `${instruction}\n\n${continuityInstruction(this.options.role)}`;
188
182
  const journal = new WorkflowDb(this.options.projectDir);
189
183
  const dispatchId = `dispatch:${this.options.runId}:${this.options.role}:${randomUUID()}`;
@@ -209,6 +203,7 @@ export class ContinuityAdapter {
209
203
  throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "a previous dispatched turn has no durable completion; reconcile it before sending more work");
210
204
  journal.planOperation({ runId: this.options.runId, idempotencyKey: dispatchId, kind: "provider-dispatch", intent: { role: this.options.role, sessionRef: this.adapter.sessionRef?.(), instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) } });
211
205
  journal.updateOperation(dispatchId, "in_progress");
206
+ journal.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, dispatchId, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
212
207
  });
213
208
  try {
214
209
  original = await dispatchWithGraphAccess(this.options.projectDir, this.adapter, providerInstruction, policy, (text, p) => this.adapter.sendTurn(text, p));
@@ -218,7 +213,11 @@ export class ContinuityAdapter {
218
213
  throw error;
219
214
  }
220
215
  const responseDigest = journal.putEvidence("handoff", original.rawResponse ?? original.text);
221
- journal.updateOperation(dispatchId, original.failure?.dispatchState === "unknown" ? "uncertain" : "confirmed", { result: { responseDigest, isError: original.isError, failure: original.failure, turnId: original.turnId, sessionRef: this.adapter.sessionRef?.() } });
216
+ journal.atomic(() => {
217
+ journal.updateOperation(dispatchId, original.failure?.dispatchState === "unknown" ? "uncertain" : original.failure?.dispatchState === "not-sent" ? "failed" : "confirmed", { result: { responseDigest, isError: original.isError, failure: original.failure, turnId: original.turnId, sessionRef: this.adapter.sessionRef?.() } });
218
+ if (original.failure?.dispatchState === "not-sent")
219
+ journal.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind: "turn_not_sent", payload: { dispatchId, role: this.options.role }, authoritativeStateRevision: this.revision() });
220
+ });
222
221
  }
223
222
  finally {
224
223
  journal.close();
@@ -418,8 +417,10 @@ export class ContinuityAdapter {
418
417
  const db = new WorkflowDb(this.options.projectDir);
419
418
  try {
420
419
  const sessionRef = this.adapter.sessionRef?.();
421
- db.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind, payload: { isError: result.isError, numTurns: result.numTurns, sessionId: this.adapter.sessionId(), sessionRef, delta }, authoritativeStateRevision: this.revision(), sessionRef });
422
- return db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef });
420
+ return db.atomic(() => {
421
+ db.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind, payload: { isError: result.isError, numTurns: result.numTurns, sessionId: this.adapter.sessionId(), sessionRef, delta }, authoritativeStateRevision: this.revision(), sessionRef });
422
+ return db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef });
423
+ });
423
424
  }
424
425
  finally {
425
426
  db.close();
package/dist/foreman.d.ts CHANGED
@@ -61,6 +61,9 @@ export interface ForemanNotificationOptions {
61
61
  terminalBell: boolean;
62
62
  }
63
63
  /** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
64
+ export declare class BuildInterruptedError extends Error {
65
+ constructor();
66
+ }
64
67
  export declare class Foreman {
65
68
  private builder;
66
69
  private readonly log;
@@ -88,6 +91,10 @@ export declare class Foreman {
88
91
  private readonly continueIndependentTickets;
89
92
  private readonly builderWorktree?;
90
93
  private readonly decisionPrompt?;
94
+ private interruptionRequested;
95
+ private activeQaAdapter?;
96
+ private assertNotInterrupted;
97
+ private createOwnedQa;
91
98
  private graphPurpose;
92
99
  setGraphPurpose(purpose: GraphPurpose): void;
93
100
  private readonly ticketsEnabled;
@@ -160,6 +167,7 @@ export declare class Foreman {
160
167
  builderSessionId(): string | undefined;
161
168
  builderAdapter(): BuilderAdapter;
162
169
  close(): Promise<void>;
170
+ requestStop(): Promise<void>;
163
171
  private ticketForQa;
164
172
  runBatch(n: number, trackerPath?: string, onTicketStart?: (ticketId: string) => void | Promise<void>, preferredTicketId?: string, recoveryTickets?: readonly string[]): Promise<BatchResult>;
165
173
  }