ai-foreman 1.7.13 → 1.7.14

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 (47) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +122 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +30 -8
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +6 -0
  8. package/dist/adapters/types.d.ts +22 -0
  9. package/dist/agentRun.js +21 -3
  10. package/dist/branch/currentGuard.d.ts +2 -0
  11. package/dist/branch/currentGuard.js +9 -0
  12. package/dist/branch/finalization.d.ts +22 -0
  13. package/dist/branch/finalization.js +120 -0
  14. package/dist/branch/git.js +7 -2
  15. package/dist/branch/runner.d.ts +3 -2
  16. package/dist/branch/runner.js +277 -75
  17. package/dist/buildRuns.d.ts +1 -0
  18. package/dist/buildRuns.js +33 -3
  19. package/dist/cli/start.js +314 -86
  20. package/dist/cli/state.d.ts +4 -0
  21. package/dist/cli/state.js +69 -0
  22. package/dist/continuity.d.ts +4 -0
  23. package/dist/continuity.js +26 -3
  24. package/dist/foreman.d.ts +22 -4
  25. package/dist/foreman.js +105 -19
  26. package/dist/handoffs.d.ts +5 -2
  27. package/dist/handoffs.js +93 -12
  28. package/dist/index.js +2 -0
  29. package/dist/qaFailureDelivery.d.ts +100 -0
  30. package/dist/qaFailureDelivery.js +570 -0
  31. package/dist/qaProtocolV2.d.ts +233 -0
  32. package/dist/qaProtocolV2.js +114 -0
  33. package/dist/qaRecovery.d.ts +15 -3
  34. package/dist/qaRecovery.js +477 -83
  35. package/dist/qaReview.d.ts +42 -17
  36. package/dist/qaReview.js +1216 -457
  37. package/dist/qaRuntime.d.ts +14 -0
  38. package/dist/qaRuntime.js +44 -0
  39. package/dist/qaSnapshot.d.ts +34 -0
  40. package/dist/qaSnapshot.js +302 -34
  41. package/dist/stateTransfer.d.ts +57 -0
  42. package/dist/stateTransfer.js +644 -0
  43. package/dist/statusReporter.d.ts +2 -0
  44. package/dist/statusReporter.js +6 -0
  45. package/dist/workflowDb.d.ts +245 -2
  46. package/dist/workflowDb.js +647 -6
  47. package/package.json +15 -3
package/dist/cli/start.js CHANGED
@@ -45,25 +45,47 @@ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
45
45
  import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
46
46
  import { ObservabilityStore, RunObserver } from "../observability.js";
47
47
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
48
- import { loadQaRecoveryPacket } from "../qaRecovery.js";
48
+ import { loadQaRecoveryPacket, recoverPendingQaRecoveryPublications } from "../qaRecovery.js";
49
+ import { qaDigest } from "../qaProtocolV2.js";
49
50
  import { loadSkill } from "special-agents";
51
+ import { acceptQaRuntimeHandoff, describeQaRuntimeHandle, frozenQaRuntimeSettings } from "../qaRuntime.js";
50
52
  const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
51
53
  function fail(message) {
52
54
  console.error(`foreman: ${message}`);
53
55
  process.exit(1);
54
56
  }
55
- function pendingQaRecoveryPacket(projectDir, runId) {
57
+ function pendingQaRecoveryPacket(projectDir, runId, ticketId) {
58
+ recoverPendingQaRecoveryPublications(projectDir, runId);
56
59
  const db = new WorkflowDb(projectDir);
57
60
  try {
58
- const pending = db.getRun(runId)?.state.qaReportRecovery;
59
- if (!pending || typeof pending !== "object")
61
+ const pending = db.pendingQaRecoveryHeads(runId);
62
+ if (pending.length > 1 && !ticketId)
63
+ throw new Error(`run ${runId} has multiple pending QA recovery packets; select one with --ticket`);
64
+ const selected = ticketId ? pending.find((item) => item.ticketId === ticketId) : pending[0];
65
+ if (!selected || selected.pendingAction === "resolved")
60
66
  return undefined;
61
- const state = pending;
62
- if (state.pendingAction !== "operator-menu" || typeof state.packetPath !== "string" || typeof state.packetDigest !== "string")
63
- return undefined;
64
- const packet = loadQaRecoveryPacket(state.packetPath);
65
- if (packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId)
66
- throw new Error("saved QA recovery packet does not match its durable pending-decision state");
67
+ const packet = loadQaRecoveryPacket(selected.packetPath);
68
+ const state = db.qaRecoveryHead(runId, packet.manifest.ticketId);
69
+ if (!state || packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId
70
+ || packet.manifest.ticketId !== state.ticketId || packet.manifest.packetId !== state.packetId
71
+ || packet.manifest.revision !== state.revision || packet.manifest.reviewedStateDigest !== state.reviewedStateDigest
72
+ || packet.manifest.correctionTurns !== state.correctionTurns || packet.manifest.pendingAction !== state.pendingAction) {
73
+ throw new Error("saved QA recovery packet does not match its durable recovery head");
74
+ }
75
+ const qaHead = db.qaTicketHead(runId, packet.manifest.ticketId);
76
+ const attempts = db.qaReviewAttempts(runId, packet.manifest.ticketId);
77
+ const attempt = attempts.find((item) => item.attemptId === packet.manifest.reviewAttemptId);
78
+ const packetMayLeadSource = qaHead.sourceStateDigest !== packet.manifest.reviewedStateDigest
79
+ && ["automatic-recovery", "successor-acknowledgement", "qa-correction", "qa-full-review", "operator-menu"].includes(packet.manifest.pendingAction);
80
+ const exactBinding = qaHead.reviewNumber === packet.manifest.reviewAttempt && attempt?.reviewNumber === packet.manifest.reviewAttempt
81
+ && attempt.sourceDigest === qaHead.sourceStateDigest && (qaHead.sourceStateDigest === packet.manifest.reviewedStateDigest || packetMayLeadSource);
82
+ const undispatchedSuccessor = attempts.find((item) => item.reviewNumber === qaHead.reviewNumber);
83
+ const packetMayLagOneUndispatchedReview = qaHead.state === "review-ready" && qaHead.reviewNumber === packet.manifest.reviewAttempt + 1
84
+ && attempt?.reviewNumber === packet.manifest.reviewAttempt && attempt.status === "interrupted" && undispatchedSuccessor?.status === "started"
85
+ && undispatchedSuccessor.sourceDigest === qaHead.sourceStateDigest && qaHead.sourceStateDigest === packet.manifest.reviewedStateDigest;
86
+ if (!exactBinding && !packetMayLagOneUndispatchedReview) {
87
+ throw new Error("saved QA recovery packet does not match its durable review/source binding");
88
+ }
67
89
  return packet;
68
90
  }
69
91
  finally {
@@ -326,6 +348,8 @@ export function buildStartCommand() {
326
348
  .option("-r, --resume <sessionId>", "resume a prior builder session")
327
349
  .option("--recover-run <id>", "continue an existing master recovery run ID")
328
350
  .option("--recovery-mode <mode>", "frozen build:resume mode (internal recovery receipt)")
351
+ .option("--recovery-decision-digest <digest>", "exact frozen build:resume decision digest (internal)")
352
+ .option("--qa-revision <number>", "exact durable QA protocol revision (internal recovery receipt)")
329
353
  .option("--accept-handoff <generation>", "accept a pre-staged cumulative handoff generation")
330
354
  .option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
331
355
  .option("--continue", "resume the most recent logged session for this project")
@@ -416,6 +440,8 @@ export function buildStartCommand() {
416
440
  fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
417
441
  if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
418
442
  fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
443
+ if (opts.recoveryDecisionDigest && createHash("sha256").update(JSON.stringify(recoveryRecord.recoveryDecision)).digest("hex") !== opts.recoveryDecisionDigest)
444
+ fail("recovery decision digest no longer matches the persisted decision receipt");
419
445
  }
420
446
  if (opts.acceptHandoff && !recoveryRecord)
421
447
  fail("--accept-handoff requires --recover-run");
@@ -777,6 +803,25 @@ export function buildStartCommand() {
777
803
  ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
778
804
  ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
779
805
  });
806
+ const qaRuntimeMetadata = new WeakMap();
807
+ const resolveQaRuntimeMetadata = (qaCwd, settings) => {
808
+ const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
809
+ const effectiveRoleInstructions = `${bundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`;
810
+ const skills = bundle.skills.map((name) => {
811
+ const runtimeRoot = settings.make === "codex" ? ".codex" : ".claude";
812
+ const path = [join(qaCwd, runtimeRoot, "skills", name, "SKILL.md"), join(qaCwd, ".agents", "skills", name, "SKILL.md")].find(existsSync);
813
+ if (path) {
814
+ const content = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
815
+ return { name, path, content, digest: createHash("sha256").update(content).digest("hex") };
816
+ }
817
+ const bundled = loadSkill(name);
818
+ if (!bundled.body?.trim())
819
+ throw new Error(`QA runtime ${settings.make} skill ${name} has no exact dispatchable content`);
820
+ const content = `## ${bundled.name}\n${bundled.body.trim()}`;
821
+ return { name, path: `special-agents:${name}`, content, digest: createHash("sha256").update(content).digest("hex") };
822
+ });
823
+ return { settings: structuredClone(settings), effectiveRoleInstructions, skills };
824
+ };
780
825
  const createBuilderForSettings = async (builderCwd, settings) => {
781
826
  const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
782
827
  label: "live Builder settings",
@@ -814,8 +859,8 @@ export function buildStartCommand() {
814
859
  allowSwitch: false,
815
860
  model: settings.model === "default" ? undefined : settings.model,
816
861
  });
817
- const policy = new PermissionPolicy(config.permissions, qaCwd);
818
- const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
862
+ const policy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
863
+ const metadata = resolveQaRuntimeMetadata(qaCwd, settings);
819
864
  const adapterOptions = {
820
865
  cwd: qaCwd,
821
866
  configRoot: cwd,
@@ -827,13 +872,19 @@ export function buildStartCommand() {
827
872
  permission: createPermissionHandler(policy, log),
828
873
  effort: explicitEffort(settings.reasoning),
829
874
  fast: settings.fast,
830
- systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
831
- skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
875
+ sandboxMode: "read-only",
876
+ systemPromptAppend: metadata.effectiveRoleInstructions,
877
+ preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
832
878
  autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
879
+ allowAutoCompactionSetupTurn: false,
833
880
  observer: activeObserver,
834
881
  };
835
882
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
883
+ if (!created.prepareSession)
884
+ throw new Error(`QA runtime ${settings.make} cannot establish a durable session identity before review`);
885
+ await created.prepareSession();
836
886
  await prepareNativeAutoCompaction(created);
887
+ qaRuntimeMetadata.set(created, metadata);
837
888
  return created;
838
889
  };
839
890
  const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
@@ -1033,8 +1084,8 @@ export function buildStartCommand() {
1033
1084
  allowSwitch: false,
1034
1085
  model: qaSettings.model === "default" ? undefined : qaSettings.model,
1035
1086
  });
1036
- const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
1037
- const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
1087
+ const qaPolicy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
1088
+ const metadata = resolveQaRuntimeMetadata(qaCwd, qaSettings);
1038
1089
  const adapterOpts = {
1039
1090
  cwd: qaCwd,
1040
1091
  configRoot: cwd,
@@ -1049,26 +1100,35 @@ export function buildStartCommand() {
1049
1100
  permission: createPermissionHandler(qaPolicy, log),
1050
1101
  effort: explicitEffort(qaSettings.reasoning),
1051
1102
  fast: qaSettings.fast,
1052
- systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
1053
- skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
1103
+ sandboxMode: "read-only",
1104
+ systemPromptAppend: metadata.effectiveRoleInstructions,
1105
+ preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
1054
1106
  autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
1107
+ allowAutoCompactionSetupTurn: false,
1055
1108
  observer: activeObserver,
1056
1109
  };
1057
1110
  const created = qaSettings.make === "codex"
1058
1111
  ? new CodexAdapter(adapterOpts)
1059
1112
  : await ClaudeAdapter.create(adapterOpts);
1113
+ if (!created.prepareSession)
1114
+ throw new Error(`QA runtime ${qaSettings.make} cannot establish a durable session identity before review`);
1115
+ await created.prepareSession();
1060
1116
  await prepareNativeAutoCompaction(created);
1117
+ qaRuntimeMetadata.set(created, metadata);
1061
1118
  return created;
1062
1119
  };
1063
1120
  const createRecoveringQa = async (qaCwd, sessionRef) => {
1064
1121
  const initial = await createRawQa(qaCwd, sessionRef);
1065
- return new RecoveringAdapter({
1122
+ const recovering = new RecoveringAdapter({
1066
1123
  initial,
1067
1124
  runtime: qaAgent,
1068
1125
  label: "QA turn",
1069
1126
  observer: activeObserver,
1070
- enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
1071
- allowSwitch: !sessionRef,
1127
+ // QA retries and provider switches must cross the explicit durable
1128
+ // QA recovery boundary; an adapter-local replay would reuse one turn
1129
+ // intent for two different provider executions.
1130
+ enabled: false,
1131
+ allowSwitch: false,
1072
1132
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
1073
1133
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
1074
1134
  label: "QA recovery",
@@ -1099,11 +1159,17 @@ export function buildStartCommand() {
1099
1159
  return createRawQa(qaCwd, resumeRef);
1100
1160
  },
1101
1161
  });
1162
+ const metadata = qaRuntimeMetadata.get(initial);
1163
+ if (metadata)
1164
+ qaRuntimeMetadata.set(recovering, metadata);
1165
+ return recovering;
1102
1166
  };
1103
- const decorateQa = (adapter, qaCwd, sessionId, settingsOverride) => {
1104
- const qaSettings = settingsOverride ?? resolvedContinuitySettings("qa");
1167
+ const decorateQa = (adapter, qaCwd, sessionId) => {
1168
+ const metadata = qaRuntimeMetadata.get(adapter);
1169
+ const qaSettings = frozenQaRuntimeSettings(adapter, metadata);
1105
1170
  const continuous = activeContinuityRunId ? new ContinuityAdapter({
1106
1171
  adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
1172
+ durableSingleTurn: true,
1107
1173
  authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
1108
1174
  createSuccessor: () => createRecoveringQa(qaCwd),
1109
1175
  replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
@@ -1141,7 +1207,7 @@ export function buildStartCommand() {
1141
1207
  return transfer.successor;
1142
1208
  },
1143
1209
  }) : adapter;
1144
- return new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1210
+ const decorated = new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1145
1211
  role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1146
1212
  phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1147
1213
  settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
@@ -1178,6 +1244,52 @@ export function buildStartCommand() {
1178
1244
  } },
1179
1245
  });
1180
1246
  });
1247
+ qaRuntimeMetadata.set(continuous, metadata);
1248
+ qaRuntimeMetadata.set(decorated, metadata);
1249
+ return decorated;
1250
+ };
1251
+ const describeQaHandle = (adapter, _qaCwd, handoffReceipt) => describeQaRuntimeHandle(adapter, qaRuntimeMetadata.get(adapter), handoffReceipt);
1252
+ const qaV2SessionRef = (ref) => {
1253
+ if (ref.role !== "qa" || ref.stream !== "qa" || !ref.sessionId || !ref.cwd || !ref.configRoot
1254
+ || /^(?:unavailable|unknown)$/i.test(ref.sessionId.trim()))
1255
+ throw new Error("QA handoff receipt has an invalid scoped session identity");
1256
+ return {
1257
+ version: 2, provider: ref.provider, sessionId: ref.sessionId, role: "qa", stream: "qa",
1258
+ generation: ref.generation, cwd: ref.cwd, configRoot: ref.configRoot,
1259
+ createdAt: ref.createdAt, validatedAt: ref.validatedAt ?? new Date().toISOString(),
1260
+ };
1261
+ };
1262
+ const qaV2HandoffReceipt = (receipt, recovery, confinementDigest) => {
1263
+ if (!receipt.predecessorSessionRef)
1264
+ throw new Error("QA recovery handoff has no scoped predecessor identity");
1265
+ const receiptDb = new WorkflowDb(cwd);
1266
+ try {
1267
+ const head = receiptDb.qaTicketHead(recovery.runId, recovery.ticketId);
1268
+ if (!head.reviewBasisDigest || !head.sourceStateDigest)
1269
+ throw new Error("QA recovery handoff has no durable predecessor review basis/source state");
1270
+ const resources = receipt.resources.map((resource) => {
1271
+ if (!resource.path || !resource.purpose || resource.bytes === undefined || !resource.mediaType)
1272
+ throw new Error(`QA recovery handoff resource ${resource.label} lacks complete inventory metadata`);
1273
+ return { label: resource.label, digest: resource.digest, authoritative: resource.authoritative,
1274
+ requiredForRecovery: resource.requiredForRecovery ?? false, mediaType: resource.mediaType,
1275
+ path: resource.path, purpose: resource.purpose, bytes: resource.bytes };
1276
+ });
1277
+ const base = {
1278
+ version: 2, runId: recovery.runId, ticketId: recovery.ticketId, qaRevision: head.revision,
1279
+ sourceStateDigest: recovery.reviewedStateDigest, predecessorSourceStateDigest: head.sourceStateDigest,
1280
+ reviewBasisDigest: head.reviewBasisDigest, requiresFullReview: true, confinementDigest,
1281
+ predecessor: qaV2SessionRef(receipt.predecessorSessionRef), successor: qaV2SessionRef(receipt.successorSessionRef),
1282
+ manifestDigest: receipt.manifestDigest, continuityCheckpointDigest: receipt.continuityCheckpointDigest,
1283
+ acceptanceCheckpointDigest: receipt.acceptanceCheckpointDigest, packetDigest: recovery.packetDigest,
1284
+ inventoryDigest: qaDigest("handoff-inventory", resources), resources, acceptedAt: receipt.acceptedAt,
1285
+ };
1286
+ const completed = { ...base, operationId: qaDigest("qa-handoff-operation", base) };
1287
+ receiptDb.recordQaHandoffReceipt(completed);
1288
+ return completed;
1289
+ }
1290
+ finally {
1291
+ receiptDb.close();
1292
+ }
1181
1293
  };
1182
1294
  const createQa = async (qaCwd, sessionId) => {
1183
1295
  const recoveryMode = String(opts.recoveryMode ?? "");
@@ -1187,15 +1299,24 @@ export function buildStartCommand() {
1187
1299
  void sessionId;
1188
1300
  let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1189
1301
  let explicitRecoveryAccepted = false;
1190
- let handoffReceipt = { unavailable: "no predecessor handoff was required" };
1302
+ let handoffReceipt = { kind: "initial" };
1191
1303
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1192
1304
  const service = new HandoffService(cwd);
1193
1305
  const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1194
1306
  const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1307
+ if (!accepted.acceptanceReceipt)
1308
+ throw new Error("QA recovery handoff did not produce a scoped acceptance receipt");
1195
1309
  adapter = accepted.successor;
1196
1310
  pendingHandoffGeneration = undefined;
1197
1311
  explicitRecoveryAccepted = true;
1198
- handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1312
+ const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, typeof opts.ticket === "string" ? opts.ticket : undefined);
1313
+ if (!packet)
1314
+ throw new Error("accepted QA recovery handoff has no current durable packet");
1315
+ const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
1316
+ handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1317
+ runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
1318
+ reviewedStateDigest: packet.manifest.reviewedStateDigest,
1319
+ }, confinement.digest) };
1199
1320
  log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1200
1321
  }
1201
1322
  // A new disposable QA worktree always means a new provider session.
@@ -1227,8 +1348,12 @@ export function buildStartCommand() {
1227
1348
  compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1228
1349
  });
1229
1350
  const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1351
+ if (!accepted.acceptanceReceipt)
1352
+ throw new Error("QA snapshot handoff did not produce a scoped acceptance receipt");
1230
1353
  adapter = accepted.successor;
1231
- handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1354
+ // This continuity transfer precedes construction of a ticket/source
1355
+ // review basis, so it is not a QA-report recovery receipt.
1356
+ handoffReceipt = { kind: "initial" };
1232
1357
  log.write("handoff-transfer", {
1233
1358
  role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1234
1359
  predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
@@ -1237,34 +1362,7 @@ export function buildStartCommand() {
1237
1362
  }
1238
1363
  }
1239
1364
  const decorated = decorateQa(adapter, qaCwd, effectiveSessionId);
1240
- const settings = resolvedContinuitySettings("qa");
1241
- const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
1242
- const effectiveRoleInstructions = `${bundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`;
1243
- return {
1244
- adapter: decorated,
1245
- sessionIdentity: decorated.sessionRef?.() ?? decorated.sessionId(),
1246
- effectiveRoleInstructions,
1247
- runtimeContext: settings,
1248
- skills: bundle.skills.map((name) => {
1249
- const path = [join(qaCwd, ".agents", "skills", name, "SKILL.md"), join(qaCwd, ".claude", "skills", name, "SKILL.md"), join(qaCwd, ".codex", "skills", name, "SKILL.md")].find(existsSync);
1250
- if (path) {
1251
- const dispatchedContent = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
1252
- return { name, path, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
1253
- }
1254
- if (settings.make === "codex") {
1255
- try {
1256
- const bundled = loadSkill(name);
1257
- if (bundled.body?.trim()) {
1258
- const dispatchedContent = `## ${bundled.name}\n${bundled.body.trim()}`;
1259
- return { name, path: `special-agents:${name}`, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
1260
- }
1261
- }
1262
- catch { /* explicit unavailable result below */ }
1263
- }
1264
- return { name, digest: "unavailable", reason: "runtime skill name had no resolvable loaded skill content" };
1265
- }),
1266
- handoffReceipt,
1267
- };
1365
+ return describeQaHandle(decorated, qaCwd, handoffReceipt);
1268
1366
  };
1269
1367
  const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1270
1368
  const context = await input.adapter.contextUsage?.().catch(() => undefined);
@@ -1301,12 +1399,14 @@ export function buildStartCommand() {
1301
1399
  acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1302
1400
  });
1303
1401
  await input.adapter.close().catch(() => { });
1304
- return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1402
+ return decorateQa(transfer.successor, qaCwd);
1305
1403
  };
1306
- const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd, recovery) => {
1404
+ const qaSessionBoundary = async (handle, frozenAction, strategy, qaCwd, recovery) => {
1405
+ const adapter = handle.adapter;
1307
1406
  if (!activeContinuityRunId)
1308
1407
  throw new Error("QA session boundary requires an active durable run");
1309
- const qaSettings = resolvedContinuitySettings("qa");
1408
+ const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1409
+ let acceptedBoundary;
1310
1410
  const controller = RoleSessionController.managed({
1311
1411
  projectDir: cwd,
1312
1412
  runId: activeContinuityRunId,
@@ -1315,11 +1415,14 @@ export function buildStartCommand() {
1315
1415
  historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
1316
1416
  readSettings: () => liveRoleSettings("qa", qaSettings),
1317
1417
  settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1318
- settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1319
- role: "qa", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1320
- adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1321
- sessionTransition: "adopted live settings",
1322
- }),
1418
+ settingsAdopted: (_settings, active) => {
1419
+ const actual = frozenQaRuntimeSettings(active, qaRuntimeMetadata.get(active));
1420
+ liveStatusReporter?.updateState({
1421
+ role: "qa", provider: active.agent, model: actual.model, reasoning: actual.reasoning, fast: actual.fast,
1422
+ adapter: active, settingsRevision: actual.settings_revision, displaySessionCost: actual.display_session_cost,
1423
+ sessionTransition: "adopted live settings",
1424
+ });
1425
+ },
1323
1426
  report: (event) => {
1324
1427
  currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1325
1428
  },
@@ -1336,22 +1439,29 @@ export function buildStartCommand() {
1336
1439
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1337
1440
  } } : {}),
1338
1441
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1339
- resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }, ...(recovery ? [
1340
- { label: "qa-recovery-packet", digest: recovery.packetDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.qa-recovery+json", path: recovery.packetPath },
1341
- { label: "qa-reviewed-state", digest: recovery.reviewedStateDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.reviewed-state", path: "reviewed-state/" },
1342
- ] : [])],
1442
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.map((resource) => ({
1443
+ label: resource.label, digest: resource.digest, authoritative: true, requiredForRecovery: resource.requiredForRecovery,
1444
+ mediaType: resource.mediaType, path: resource.path, purpose: resource.purpose, bytes: resource.bytes,
1445
+ })) : [])],
1343
1446
  }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1447
+ if (!transfer.acceptanceReceipt || !recovery)
1448
+ throw new Error("QA recovery boundary did not produce a complete receipt context");
1449
+ const successor = decorateQa(transfer.successor, qaCwd);
1450
+ acceptedBoundary = acceptQaRuntimeHandoff(describeQaHandle(successor, qaCwd, { kind: "initial" }), (confinementDigest) => qaV2HandoffReceipt(transfer.acceptanceReceipt, recovery, confinementDigest));
1344
1451
  log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1345
1452
  await predecessor.close().catch(() => { });
1346
- return decorateQa(transfer.successor, qaCwd, undefined, settings);
1453
+ return successor;
1347
1454
  },
1348
1455
  });
1349
- return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1456
+ const next = (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1457
+ if (!acceptedBoundary || acceptedBoundary.handle.adapter !== next)
1458
+ throw new Error("QA recovery boundary did not produce a validated acceptance receipt for its active successor");
1459
+ return acceptedBoundary;
1350
1460
  };
1351
1461
  const observeQaNativeCompactions = async (adapter) => {
1352
1462
  if (!activeContinuityRunId)
1353
1463
  return;
1354
- const qaSettings = resolvedContinuitySettings("qa");
1464
+ const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1355
1465
  const controller = new ThresholdCompactionController({
1356
1466
  projectDir: cwd,
1357
1467
  runId: activeContinuityRunId,
@@ -1597,7 +1707,7 @@ export function buildStartCommand() {
1597
1707
  compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1598
1708
  };
1599
1709
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1600
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
1710
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1601
1711
  runId: invocationRunId,
1602
1712
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1603
1713
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
@@ -1605,7 +1715,41 @@ export function buildStartCommand() {
1605
1715
  runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1606
1716
  });
1607
1717
  activeContinuityRunId = masterRun.runId;
1608
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId) : undefined;
1718
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
1719
+ let qaProtocolResumeTicket;
1720
+ if (recoveryRecord && !qaResumedRecovery) {
1721
+ const protocolDb = new WorkflowDb(cwd);
1722
+ try {
1723
+ const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
1724
+ if (pendingHeads.length > 1 && !opts.ticket)
1725
+ fail(`run ${masterRun.runId} has multiple pending QA tickets; select one with --ticket`);
1726
+ const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
1727
+ const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
1728
+ if (pendingHeads.length && requested && !head)
1729
+ fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
1730
+ if (head && !["completed", "waived"].includes(head.state)) {
1731
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)
1732
+ fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
1733
+ qaProtocolResumeTicket = head.ticketId;
1734
+ }
1735
+ }
1736
+ finally {
1737
+ protocolDb.close();
1738
+ }
1739
+ }
1740
+ if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
1741
+ fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
1742
+ if (qaResumedRecovery) {
1743
+ const protocolDb = new WorkflowDb(cwd);
1744
+ try {
1745
+ const revision = protocolDb.qaTicketHead(masterRun.runId, qaResumedRecovery.manifest.ticketId).revision;
1746
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== revision)
1747
+ fail(`pending QA protocol requires exact --qa-revision ${revision}`);
1748
+ }
1749
+ finally {
1750
+ protocolDb.close();
1751
+ }
1752
+ }
1609
1753
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1610
1754
  const branchContextControllers = new Map();
1611
1755
  const branchContextController = (worktreePath) => {
@@ -1665,6 +1809,7 @@ export function buildStartCommand() {
1665
1809
  qaContinuityManaged: true,
1666
1810
  qaReportRecovery,
1667
1811
  qaResumedRecovery,
1812
+ qaProtocolResumeTicket,
1668
1813
  qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
1669
1814
  observer: activeObserver,
1670
1815
  createPr: branchDefaults.createReview,
@@ -1864,7 +2009,7 @@ export function buildStartCommand() {
1864
2009
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
1865
2010
  };
1866
2011
  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 ?? 50, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
1867
- let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
2012
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1868
2013
  runId: invocationRunId,
1869
2014
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1870
2015
  frozenPolicy, autonomyProfile, qaEnabled,
@@ -1872,7 +2017,47 @@ export function buildStartCommand() {
1872
2017
  });
1873
2018
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1874
2019
  activeContinuityRunId = buildRun.runId;
1875
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId) : undefined;
2020
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
2021
+ let qaFinalizationTicket;
2022
+ let qaProtocolResumeTicket;
2023
+ if (recoveryRecord && !qaResumedRecovery) {
2024
+ const finalizationDb = new WorkflowDb(cwd);
2025
+ try {
2026
+ const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
2027
+ if (pendingHeads.length > 1 && !opts.ticket)
2028
+ fail(`run ${buildRun.runId} has multiple pending QA tickets; select one with --ticket`);
2029
+ const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
2030
+ const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
2031
+ if (pendingHeads.length && requested && !head)
2032
+ fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
2033
+ if (head) {
2034
+ if (head.state === "finalizing")
2035
+ qaFinalizationTicket = head.ticketId;
2036
+ else if (!["idle", "completed", "waived"].includes(head.state))
2037
+ qaProtocolResumeTicket = head.ticketId;
2038
+ if ((qaFinalizationTicket || qaProtocolResumeTicket)
2039
+ && (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)) {
2040
+ fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
2041
+ }
2042
+ }
2043
+ }
2044
+ finally {
2045
+ finalizationDb.close();
2046
+ }
2047
+ }
2048
+ if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
2049
+ fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
2050
+ if (qaResumedRecovery) {
2051
+ const qaDb = new WorkflowDb(cwd);
2052
+ try {
2053
+ const currentRevision = qaDb.qaTicketHead(buildRun.runId, qaResumedRecovery.manifest.ticketId).revision;
2054
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== currentRevision)
2055
+ fail(`pending QA recovery requires exact --qa-revision ${currentRevision}`);
2056
+ }
2057
+ finally {
2058
+ qaDb.close();
2059
+ }
2060
+ }
1876
2061
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
1877
2062
  heartbeat.unref();
1878
2063
  const checkpointSignal = () => {
@@ -1945,7 +2130,7 @@ export function buildStartCommand() {
1945
2130
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1946
2131
  },
1947
2132
  });
1948
- 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);
2133
+ 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);
1949
2134
  activeBuilderForStatus = () => foreman.builderAdapter();
1950
2135
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1951
2136
  runId: buildRun.runId,
@@ -1990,6 +2175,54 @@ export function buildStartCommand() {
1990
2175
  console.log(`foreman: log ${logPath}\n`);
1991
2176
  const viewer = printEvents(builder.events());
1992
2177
  try {
2178
+ if (qaFinalizationTicket) {
2179
+ console.log(`ai-foreman: reconciling the interrupted QA finalization for ${qaFinalizationTicket}; Builder dispatch is disabled.\n`);
2180
+ await foreman.completePendingQaFinalization(qaFinalizationTicket);
2181
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "qa-finalization-complete", { status: "recoverable", currentTicket: qaFinalizationTicket }), "recoverable");
2182
+ clearInterval(heartbeat);
2183
+ statusReporter.stop();
2184
+ await foreman.close();
2185
+ await viewer;
2186
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2187
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2188
+ observabilityStore?.close();
2189
+ activeObserver = undefined;
2190
+ process.off("exit", finishObservationOnExit);
2191
+ console.log(`foreman: reconciled QA finalization for ${qaFinalizationTicket}`);
2192
+ process.exit(0);
2193
+ }
2194
+ if (qaResumedRecovery || qaProtocolResumeTicket) {
2195
+ const ticketId = qaResumedRecovery?.manifest.ticketId ?? qaProtocolResumeTicket;
2196
+ console.log(`ai-foreman: resuming the exact QA boundary for ${ticketId}; Builder preflight and work dispatch are disabled.\n`);
2197
+ buildRun = checkpointBuildRun(cwd, buildRun, "qa-recovery-before-dispatch", { currentTicket: ticketId });
2198
+ const qa = await (activeObserver
2199
+ ? activeObserver.span("qa_recovery", "Exact QA-only recovery", () => foreman.completePendingQaRecovery(ticketId))
2200
+ : foreman.completePendingQaRecovery(ticketId));
2201
+ if (pendingHandoffGeneration !== undefined)
2202
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2203
+ if (foreman.builderSessionId())
2204
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2205
+ if (foreman.qaSessionId())
2206
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2207
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, qa.outcome === "passed" || qa.outcome === "waived" ? "qa-recovery-complete" : "qa-recovery-paused", {
2208
+ status: "recoverable", currentTicket: ticketId,
2209
+ }), "recoverable");
2210
+ clearInterval(heartbeat);
2211
+ statusReporter.stop();
2212
+ await foreman.close();
2213
+ await viewer;
2214
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2215
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2216
+ observabilityStore?.enforceLimits();
2217
+ observabilityStore?.maintainLogs(cwd);
2218
+ observabilityStore?.close();
2219
+ activeObserver = undefined;
2220
+ process.off("exit", finishObservationOnExit);
2221
+ console.log(`foreman: exact QA recovery for ${ticketId} — ${qa.outcome}`);
2222
+ if (qa.detail)
2223
+ console.log(`foreman: ${qa.detail}`);
2224
+ process.exit(qa.outcome === "passed" || qa.outcome === "waived" ? 0 : 2);
2225
+ }
1993
2226
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
1994
2227
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
1995
2228
  await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
@@ -2326,7 +2559,7 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner, obse
2326
2559
  };
2327
2560
  }
2328
2561
  function createQaReportRecoveryHandler(noninteractive, observer) {
2329
- return async ({ packet, originalIssues, liveSession, contextUsage }) => {
2562
+ return async ({ packet, liveSession, contextUsage }) => {
2330
2563
  if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY) {
2331
2564
  console.error(`foreman: QA report recovery requires input; saved packet ${packet.directory}`);
2332
2565
  console.error(`foreman: resume with the run ID recorded in ${join(packet.directory, "manifest.json")}`);
@@ -2335,10 +2568,9 @@ function createQaReportRecoveryHandler(noninteractive, observer) {
2335
2568
  const choice = await withObservedUserWait(observer?.(), "QA report recovery decision", () => select({
2336
2569
  message: "QA report recovery:",
2337
2570
  options: [
2338
- { value: "fresh", label: "Try another fresh QA." },
2339
- { value: "plain", label: "Use plain issues fallback.", hint: originalIssues ? originalIssues.slice(0, 120) : "Unavailable: original issues synopsis was empty" },
2340
- { value: "manual", label: "Manually fix saved JSON.", hint: join(packet.directory, "report.json") },
2341
- { value: "guidance", label: "Give QA specific instructions." },
2571
+ { value: "fresh", label: "Run complete fresh QA." },
2572
+ { value: "manual", label: "Inspect saved JSON.", hint: join(packet.directory, "report.json") },
2573
+ { value: "guidance", label: "Give QA fresh-review instructions." },
2342
2574
  { value: "pause", label: "Pause." },
2343
2575
  ],
2344
2576
  }));
@@ -2346,18 +2578,14 @@ function createQaReportRecoveryHandler(noninteractive, observer) {
2346
2578
  return { action: "pause" };
2347
2579
  if (choice === "fresh")
2348
2580
  return { action: "fresh" };
2349
- if (choice === "plain")
2350
- return { action: "plain" };
2351
2581
  if (choice === "manual")
2352
2582
  return { action: "manual" };
2353
2583
  console.log(`foreman: current QA context usage: ${JSON.stringify(contextUsage).slice(0, 500)}`);
2354
2584
  const guidance = await withObservedUserWait(observer?.(), "QA recovery guidance", () => text({ message: "Specific QA report-recovery instructions:" }));
2355
2585
  if (isCancel(guidance) || !String(guidance).trim())
2356
2586
  return { action: "pause" };
2357
- const route = await withObservedUserWait(observer?.(), "QA recovery session choice", () => select({ message: "Use which QA session route?", options: liveSession ? [
2358
- { value: "current", label: "Current session" }, { value: "compact", label: "Compact current session" }, { value: "fresh", label: "Fresh validated handoff" },
2359
- ] : [{ value: "fresh", label: "Fresh validated handoff (resumed recovery)" }] }));
2360
- return isCancel(route) ? { action: "pause" } : { action: "guidance", instructions: String(guidance), route };
2587
+ void liveSession;
2588
+ return { action: "guidance", instructions: String(guidance), route: "fresh" };
2361
2589
  };
2362
2590
  }
2363
2591
  function readAgentDefaults(projectDir) {