ai-foreman 1.7.19 → 1.7.21

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 (205) hide show
  1. package/README.md +29 -5
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +92 -5
  20. package/dist/buildAdmission.d.ts +61 -0
  21. package/dist/buildAdmission.js +286 -0
  22. package/dist/buildApproval.d.ts +2 -0
  23. package/dist/buildApproval.js +17 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildLaunchGate.d.ts +1 -0
  29. package/dist/buildLaunchGate.js +36 -0
  30. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  31. package/dist/buildOwnershipReconciliation.js +41 -0
  32. package/dist/buildOwnershipRepair.d.ts +33 -0
  33. package/dist/buildOwnershipRepair.js +111 -0
  34. package/dist/buildRuns.d.ts +24 -1
  35. package/dist/buildRuns.js +216 -70
  36. package/dist/buildSyntheticWork.d.ts +2 -0
  37. package/dist/buildSyntheticWork.js +30 -0
  38. package/dist/buildWorkAdmission.d.ts +17 -0
  39. package/dist/buildWorkAdmission.js +57 -0
  40. package/dist/buildWorkAuthorization.d.ts +2 -0
  41. package/dist/buildWorkAuthorization.js +23 -0
  42. package/dist/buildWorkContext.d.ts +3 -0
  43. package/dist/buildWorkContext.js +6 -0
  44. package/dist/buildWorkMigration.d.ts +9 -0
  45. package/dist/buildWorkMigration.js +206 -0
  46. package/dist/buildWorkReconciliation.d.ts +12 -0
  47. package/dist/buildWorkReconciliation.js +70 -0
  48. package/dist/builderGuidanceFollowup.d.ts +15 -0
  49. package/dist/builderGuidanceFollowup.js +34 -0
  50. package/dist/cli/doctor.js +3 -2
  51. package/dist/cli/graph.d.ts +2 -0
  52. package/dist/cli/graph.js +75 -0
  53. package/dist/cli/manager.d.ts +8 -0
  54. package/dist/cli/manager.js +234 -12
  55. package/dist/cli/recovery.js +3 -2
  56. package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
  57. package/dist/cli/runtimeAuthPrompt.js +41 -7
  58. package/dist/cli/start.d.ts +8 -0
  59. package/dist/cli/start.js +2091 -1849
  60. package/dist/cli/status.js +20 -5
  61. package/dist/cli/tickets.js +9 -7
  62. package/dist/continuity.d.ts +16 -1
  63. package/dist/continuity.js +68 -12
  64. package/dist/foreman.d.ts +12 -4
  65. package/dist/foreman.js +397 -60
  66. package/dist/graph/boundary.d.ts +15 -0
  67. package/dist/graph/boundary.js +50 -0
  68. package/dist/graph/bridge.d.ts +9 -0
  69. package/dist/graph/bridge.js +146 -0
  70. package/dist/graph/bridge.py +205 -0
  71. package/dist/graph/config.d.ts +11 -0
  72. package/dist/graph/config.js +40 -0
  73. package/dist/graph/corpus.d.ts +16 -0
  74. package/dist/graph/corpus.js +188 -0
  75. package/dist/graph/derived.d.ts +17 -0
  76. package/dist/graph/derived.js +73 -0
  77. package/dist/graph/host.d.ts +4 -0
  78. package/dist/graph/host.js +160 -0
  79. package/dist/graph/install.d.ts +3 -0
  80. package/dist/graph/install.js +50 -0
  81. package/dist/graph/lifecycle.d.ts +11 -0
  82. package/dist/graph/lifecycle.js +124 -0
  83. package/dist/graph/maintenance.d.ts +43 -0
  84. package/dist/graph/maintenance.js +301 -0
  85. package/dist/graph/manager.d.ts +9 -0
  86. package/dist/graph/manager.js +92 -0
  87. package/dist/graph/preparation.d.ts +41 -0
  88. package/dist/graph/preparation.js +43 -0
  89. package/dist/graph/read.d.ts +27 -0
  90. package/dist/graph/read.js +152 -0
  91. package/dist/graph/semantic.d.ts +17 -0
  92. package/dist/graph/semantic.js +27 -0
  93. package/dist/graph/session.d.ts +8 -0
  94. package/dist/graph/session.js +92 -0
  95. package/dist/graph/sources.d.ts +4 -0
  96. package/dist/graph/sources.js +11 -0
  97. package/dist/graph/storage.d.ts +38 -0
  98. package/dist/graph/storage.js +159 -0
  99. package/dist/graph/turn.d.ts +17 -0
  100. package/dist/graph/turn.js +360 -0
  101. package/dist/graph/util.d.ts +5 -0
  102. package/dist/graph/util.js +51 -0
  103. package/dist/handoffs.d.ts +1 -0
  104. package/dist/handoffs.js +5 -1
  105. package/dist/humanDecision.d.ts +21 -2
  106. package/dist/humanDecision.js +169 -10
  107. package/dist/index.js +4 -1
  108. package/dist/installOwnership.d.ts +46 -0
  109. package/dist/installOwnership.js +440 -0
  110. package/dist/linuxProbeInventory.d.ts +12 -0
  111. package/dist/linuxProbeInventory.js +48 -0
  112. package/dist/log.d.ts +1 -1
  113. package/dist/log.js +7 -1
  114. package/dist/managerEvidence.d.ts +25 -0
  115. package/dist/managerEvidence.js +326 -0
  116. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  117. package/dist/managerEvidenceArtifacts.js +118 -0
  118. package/dist/managerPacket.d.ts +2 -0
  119. package/dist/managerPacket.js +23 -0
  120. package/dist/markers.d.ts +1 -1
  121. package/dist/markers.js +2 -2
  122. package/dist/observability.js +1 -1
  123. package/dist/processIdentity.d.ts +21 -1
  124. package/dist/processIdentity.js +87 -5
  125. package/dist/providerPhase.d.ts +13 -0
  126. package/dist/providerPhase.js +26 -0
  127. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  128. package/dist/qaAuthorizedDisposition.js +26 -0
  129. package/dist/qaBuildGate.d.ts +4 -0
  130. package/dist/qaBuildGate.js +216 -0
  131. package/dist/qaBuilderCoverage.d.ts +5 -0
  132. package/dist/qaBuilderCoverage.js +78 -0
  133. package/dist/qaContractCoverage.d.ts +10 -0
  134. package/dist/qaContractCoverage.js +55 -0
  135. package/dist/qaContractDelivery.d.ts +23 -0
  136. package/dist/qaContractDelivery.js +145 -0
  137. package/dist/qaContractFreshness.d.ts +15 -0
  138. package/dist/qaContractFreshness.js +36 -0
  139. package/dist/qaDeliveryJournal.d.ts +10 -0
  140. package/dist/qaDeliveryJournal.js +16 -1
  141. package/dist/qaEffectiveConfig.d.ts +20 -0
  142. package/dist/qaEffectiveConfig.js +40 -0
  143. package/dist/qaEvidenceReader.d.ts +14 -0
  144. package/dist/qaEvidenceReader.js +176 -0
  145. package/dist/qaFailureDelivery.d.ts +2 -0
  146. package/dist/qaFailureDelivery.js +107 -10
  147. package/dist/qaHandbackMigration.d.ts +1 -1
  148. package/dist/qaHandbackMigration.js +3 -3
  149. package/dist/qaPreparation.d.ts +26 -0
  150. package/dist/qaPreparation.js +150 -0
  151. package/dist/qaPreparationInputs.d.ts +8 -0
  152. package/dist/qaPreparationInputs.js +47 -0
  153. package/dist/qaPreparationMetrics.d.ts +145 -0
  154. package/dist/qaPreparationMetrics.js +120 -0
  155. package/dist/qaPreparationPolicy.d.ts +5 -0
  156. package/dist/qaPreparationPolicy.js +51 -0
  157. package/dist/qaPreparationStore.d.ts +157 -0
  158. package/dist/qaPreparationStore.js +412 -0
  159. package/dist/qaProtocolV2.d.ts +28 -0
  160. package/dist/qaProtocolV2.js +12 -0
  161. package/dist/qaReview.d.ts +6 -0
  162. package/dist/qaReview.js +293 -41
  163. package/dist/qaTimeline.d.ts +18 -0
  164. package/dist/qaTimeline.js +123 -0
  165. package/dist/qaVerificationContract.d.ts +17 -0
  166. package/dist/qaVerificationContract.js +236 -0
  167. package/dist/readinessCleanup.d.ts +19 -0
  168. package/dist/readinessCleanup.js +122 -0
  169. package/dist/readinessGate.d.ts +1 -0
  170. package/dist/readinessGate.js +82 -0
  171. package/dist/recoveryGuidance.d.ts +17 -0
  172. package/dist/recoveryGuidance.js +38 -0
  173. package/dist/runtimeAuth.d.ts +1 -0
  174. package/dist/runtimeAuth.js +5 -1
  175. package/dist/runtimeReadiness.d.ts +13 -0
  176. package/dist/runtimeReadiness.js +206 -10
  177. package/dist/sessionIdentity.d.ts +2 -0
  178. package/dist/sessionIdentity.js +14 -0
  179. package/dist/sessionLifecycle.js +3 -3
  180. package/dist/sources/sourceRegistry.js +7 -0
  181. package/dist/stateTransfer.js +85 -8
  182. package/dist/statusReporter.d.ts +1 -0
  183. package/dist/statusReporter.js +1 -0
  184. package/dist/supervisedStart.d.ts +6 -0
  185. package/dist/supervisedStart.js +178 -62
  186. package/dist/supervisor.d.ts +2 -0
  187. package/dist/supervisor.js +26 -2
  188. package/dist/terminalStyle.d.ts +21 -0
  189. package/dist/terminalStyle.js +50 -0
  190. package/dist/ticketPopulation.d.ts +1 -0
  191. package/dist/ticketPopulation.js +5 -1
  192. package/dist/tickets/commands.js +2 -2
  193. package/dist/tickets/queue.js +1 -1
  194. package/dist/tickets/ticketSchema.d.ts +198 -0
  195. package/dist/tickets/ticketSchema.js +2 -0
  196. package/dist/windowsProbeJob.d.ts +12 -0
  197. package/dist/windowsProbeJob.js +144 -0
  198. package/dist/workflowDb.d.ts +121 -6
  199. package/dist/workflowDb.js +874 -30
  200. package/dist/workflowReader.d.ts +116 -2
  201. package/dist/workflowReader.js +131 -4
  202. package/package.json +89 -8
  203. package/scripts/copy-graph-assets.mjs +3 -0
  204. package/scripts/graph-benchmark.mjs +20 -0
  205. package/scripts/graph-evaluation.mjs +85 -0
package/dist/cli/start.js CHANGED
@@ -1,9 +1,18 @@
1
+ import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "../qaEffectiveConfig.js";
2
+ import { graphContext, sendGraphTurn } from "../graph/turn.js";
3
+ import { initializeSyntheticWork } from "../buildSyntheticWork.js";
4
+ import { admitApprovedTicket } from "../buildWorkAuthorization.js";
5
+ import { WorkflowReader } from "../workflowReader.js";
6
+ import { localBuildAuthority, canonicalProject, withBuildInvocation } from "../buildAdmission.js";
7
+ import { formatRecoveryCommand, formatDecisionCommands, formatExactRunRecovery } from "../recoveryGuidance.js";
1
8
  import { durableReadOnlyProposal } from "../readOnlyProposal.js";
2
9
  import { captureFrozenQaSource } from "../qaSnapshot.js";
3
- import { superviseStart, supervisedRunId } from "../supervisedStart.js";
4
- import { durableHumanDecision, HumanDecisionRequired } from "../humanDecision.js";
5
- import { requiresBuildApproval, buildScopeRevision } from "../buildApproval.js";
6
- import { Command } from "commander";
10
+ import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
11
+ import { assertBuildAssignmentReconciled } from "../buildAssignment.js";
12
+ import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
13
+ import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
14
+ import { Command, Option } from "commander";
15
+ import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
7
16
  import { resolve, join, relative } from "node:path";
8
17
  import { existsSync, readFileSync, readdirSync } from "node:fs";
9
18
  import { execFileSync } from "node:child_process";
@@ -18,7 +27,7 @@ import { RecoveringAdapter } from "../adapters/recovering.js";
18
27
  import { Foreman, createPermissionHandler } from "../foreman.js";
19
28
  import { printEvents } from "./events.js";
20
29
  import { loadRoleBundle } from "../roles.js";
21
- import { ensureRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
30
+ import { ensureBuildRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
22
31
  import { resolveAgentForProject } from "./runtimeSelection.js";
23
32
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
24
33
  import { loadTickets } from "../tickets/ticketLoader.js";
@@ -29,7 +38,7 @@ import { formatGitHubFailure, preflightGh } from "../branch/github.js";
29
38
  import { preflightGlab } from "../branch/gitlab.js";
30
39
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
31
40
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
32
- import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
41
+ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, currentBranchRecoveryTicket, currentBranchTicketProgress, recoveryExecutionTickets, finishRecoveredTicketScope, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
33
42
  import { parse as parseYaml } from "yaml";
34
43
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
35
44
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
@@ -52,7 +61,6 @@ import { ObservabilityStore, RunObserver } from "../observability.js";
52
61
  import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
53
62
  import { loadQaRecoveryPacket, recoverPendingQaRecoveryPublications } from "../qaRecovery.js";
54
63
  import { qaDigest } from "../qaProtocolV2.js";
55
- import { loadSkill } from "special-agents";
56
64
  import { acceptQaRuntimeHandoff, describeQaRuntimeHandle, frozenQaRuntimeSettings } from "../qaRuntime.js";
57
65
  const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
58
66
  function fail(message) {
@@ -144,16 +152,15 @@ export function formatResumeGuidance(executable, projectDir, steps, sessionId, r
144
152
  return [
145
153
  ...run.decisions.flatMap(decision => [
146
154
  `foreman: input required: ${decision.prompt}`,
147
- `foreman: choices: ${decision.choices.map(choice => `${choice.id} (${choice.label})`).join(", ")}`,
148
- ` rafi build:decide ${shellQuote(projectDir)} --run ${shellQuote(run.runId)} --decision ${shellQuote(decision.decisionId)} --choice <choice-id>`,
155
+ ...formatDecisionCommands(projectDir, run.runId, decision, executable),
149
156
  ]),
150
- "foreman: resume this run with:",
151
- ` rafi build:resume ${shellQuote(projectDir)} --run ${shellQuote(run.runId)}`,
157
+ executable === "rafi" ? "foreman: resume this run with:" : "foreman: review recovery for this run with:",
158
+ ` ${formatExactRunRecovery(projectDir, run.runId, executable)}`,
152
159
  ];
153
160
  if (executable === "rafi") {
154
161
  return [
155
162
  "foreman: resume this run with:",
156
- ` rafi build:resume ${shellQuote(projectDir)}`,
163
+ ` ${formatRecoveryCommand(projectDir, executable, process.cwd(), sessionId ? { id: sessionId, steps } : undefined)}`,
157
164
  ];
158
165
  }
159
166
  if (!sessionId)
@@ -352,6 +359,94 @@ async function ensureReviewProviderReady(cwd, provider, log, yes) {
352
359
  currentActivity()?.note("rafi: retrying GitLab readiness check");
353
360
  }
354
361
  }
362
+ /** Save parsed CLI values as argv, never as executable shell text. */
363
+ export function savedStartArguments(command, projectDir, opts) {
364
+ const args = ["start", projectDir];
365
+ for (const option of command.options) {
366
+ const name = option.attributeName();
367
+ if (["preparationRun", "launchToken"].includes(name))
368
+ continue;
369
+ const value = opts[name];
370
+ if (value === undefined || (!["steps", "stacks"].includes(name) && command.getOptionValueSource(name) !== "cli"))
371
+ continue;
372
+ if (option.negate) {
373
+ if (value === false)
374
+ args.push(option.long);
375
+ continue;
376
+ }
377
+ if (value === false)
378
+ continue;
379
+ if (value === true)
380
+ args.push(option.long);
381
+ else
382
+ for (const item of Array.isArray(value) ? value : [value])
383
+ args.push(option.long, name === "tickets" ? resolve(String(item)) : String(item));
384
+ }
385
+ return args;
386
+ }
387
+ /** Pure validation shared by interactive start and persisted preparation. */
388
+ export function validateStartOptions(opts, requireCount) {
389
+ const recovery = Boolean(opts.resume || opts.continue || opts.recoverRun);
390
+ const tickets = opts.ticket ?? [];
391
+ if (!recovery && tickets.length > 1)
392
+ throw new Error("new-ticket selection supports exactly one --ticket");
393
+ if (!recovery && tickets.length && opts.stacks !== undefined)
394
+ throw new Error("new-ticket selection cannot be combined with --stacks");
395
+ if (!recovery && tickets.length === 1 && opts.steps === undefined && opts.stacks === undefined)
396
+ opts.steps = "1";
397
+ if (opts.steps !== undefined && opts.stacks !== undefined)
398
+ throw new Error("--steps and --stacks are mutually exclusive");
399
+ if (requireCount && opts.steps === undefined && opts.stacks === undefined)
400
+ throw new Error("exactly one of --steps or --stacks is required");
401
+ for (const name of ["steps", "stacks"])
402
+ if (opts[name] !== undefined && (!/^\d+$/.test(String(opts[name])) || !Number.isSafeInteger(Number(opts[name])) || Number(opts[name]) < 1))
403
+ throw new Error(`--${name} must be a positive integer`);
404
+ for (const [name, allowed] of Object.entries({ agent: ["claude", "codex"], effort: ["low", "medium", "high", "xhigh"], autonomy: ["supervised", "balanced", "unattended"], completion: ["pr", "auto-merge", "direct-merge", "none"], mergeMethod: ["squash", "merge", "rebase"], provider: ["auto", "github", "gitlab"], acceptHandoffRole: ["builder", "qa"] })) {
405
+ if (opts[name] !== undefined && !allowed.includes(String(opts[name])))
406
+ throw new Error(`--${name} must be ${allowed.join(" | ")}`);
407
+ }
408
+ for (const name of ["maxBranchDepth", "autoMergeTimeoutMinutes", "acceptHandoff"])
409
+ if (opts[name] !== undefined && (!/^\d+$/.test(String(opts[name])) || !Number.isSafeInteger(Number(opts[name])) || Number(opts[name]) < 1))
410
+ throw new Error(`${name} must be a positive integer`);
411
+ if (opts.autoCompactThreshold !== undefined && (!/^\d+$/.test(String(opts.autoCompactThreshold)) || Number(opts.autoCompactThreshold) < 1 || Number(opts.autoCompactThreshold) > 99))
412
+ throw new Error("--auto-compact-threshold must be an integer from 1 to 99");
413
+ if (opts.resume && opts.continue)
414
+ throw new Error("--resume and --continue are mutually exclusive");
415
+ if (opts.preparationRun && opts.recoverRun)
416
+ throw new Error("Preparation and established recovery run modes are mutually exclusive");
417
+ if (opts.preparationRun && !opts.launchToken)
418
+ throw new Error("Preparation launch requires both run and token");
419
+ if (opts.launchToken && !opts.preparationRun && !opts.recoverRun)
420
+ throw new Error("Launch token requires a preparation or recovery run");
421
+ }
422
+ /** Validate generated established recovery before reserving execution authority. */
423
+ export function validateRecoveryArguments(args, projectDir) {
424
+ if (args[0] !== "start" || canonicalProject(args[1] ?? "") !== canonicalProject(projectDir))
425
+ throw new Error("Recovery arguments belong to another project");
426
+ const command = buildStartCommand().exitOverride().configureOutput({ writeErr: () => { } });
427
+ const parsed = command.parseOptions([...args.slice(2)]);
428
+ if (parsed.unknown.length || parsed.operands.length)
429
+ throw new Error("Unsupported recovery arguments");
430
+ const opts = command.opts();
431
+ validateStartOptions(opts, true);
432
+ if (!opts.recoverRun || opts.preparationRun || opts.launchToken)
433
+ throw new Error("Invalid generated recovery context");
434
+ }
435
+ /** Validate persisted preparation argv without executing a command or provider. */
436
+ export function validatePreparationArguments(args, projectDir) {
437
+ if (args[0] !== "start" || !args[1] || canonicalProject(args[1]) !== canonicalProject(projectDir))
438
+ throw new Error("Saved preparation arguments belong to another project");
439
+ const command = buildStartCommand().exitOverride().configureOutput({ writeErr: () => { } });
440
+ const parsed = command.parseOptions([...args.slice(2)]);
441
+ if (parsed.unknown.length || parsed.operands.length)
442
+ throw new Error("Saved preparation arguments contain unsupported options or operands");
443
+ const options = command.opts();
444
+ validateStartOptions(options, true);
445
+ for (const name of ["recoverRun", "recoveryMode", "recoveryDecisionDigest", "acceptHandoff", "acceptHandoffRole", "qaRevision", "launchToken", "preparationRun"])
446
+ if (command.getOptionValueSource(name) === "cli")
447
+ throw new Error("Saved preparation contains stale internal recovery authority");
448
+ return savedStartArguments(command, projectDir, options);
449
+ }
355
450
  export function buildStartCommand() {
356
451
  return new Command("start")
357
452
  .description("Enlist a builder and drive it through a batch of N steps.")
@@ -361,6 +456,8 @@ export function buildStartCommand() {
361
456
  .option("-a, --agent <agent>", "builder agent (claude | codex)")
362
457
  .option("-m, --model <model>", "override the builder's model")
363
458
  .option("-r, --resume <sessionId>", "resume a prior builder session")
459
+ .addOption(new Option("--preparation-run <id>", "authorized preparation successor (internal)").hideHelp())
460
+ .addOption(new Option("--launch-token <token>", "single-use preparation launch authorization (internal)").hideHelp())
364
461
  .option("--recover-run <id>", "continue an existing master recovery run ID")
365
462
  .option("--recovery-mode <mode>", "frozen build:resume mode (internal recovery receipt)")
366
463
  .option("--recovery-decision-digest <digest>", "exact frozen build:resume decision digest (internal)")
@@ -393,10 +490,18 @@ export function buildStartCommand() {
393
490
  .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
394
491
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
395
492
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
493
+ .option("--builder-network", "request build-scoped builder internet access with separate explicit approval; rechecked on resume")
494
+ .option("--builder-approvals", "allow the builder to request explicit per-operation sandbox approvals; QA stays restricted")
396
495
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
397
496
  .option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
398
497
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
399
- .action(async (project, opts, command) => {
498
+ .action(async (project, opts, command) => withBuildInvocation(async () => {
499
+ try {
500
+ validateStartOptions(opts, false);
501
+ }
502
+ catch (error) {
503
+ fail(error.message);
504
+ }
400
505
  const requestedTicketIds = opts.ticket ?? [];
401
506
  const recoveryIntent = Boolean(opts.resume || opts.continue || opts.recoverRun);
402
507
  if (!recoveryIntent && requestedTicketIds.length > 1)
@@ -420,6 +525,12 @@ export function buildStartCommand() {
420
525
  process.exit(0);
421
526
  opts[mode] = String(count);
422
527
  }
528
+ try {
529
+ validateStartOptions(opts, true);
530
+ }
531
+ catch (error) {
532
+ fail(error.message);
533
+ }
423
534
  let steps = Number.parseInt(opts.steps ?? "0", 10);
424
535
  const stackCount = opts.stacks === undefined ? undefined : Number.parseInt(opts.stacks, 10);
425
536
  if (stackCount !== undefined && (!Number.isInteger(stackCount) || stackCount < 1))
@@ -443,1830 +554,2015 @@ export function buildStartCommand() {
443
554
  const autonomyProfile = opts.autonomy;
444
555
  if (autonomyProfile && !["supervised", "balanced", "unattended"].includes(autonomyProfile))
445
556
  fail("--autonomy must be supervised, balanced, or unattended");
446
- const frozenPolicy = recoveryRecord && "frozenPolicy" in recoveryRecord
557
+ const supervisedInvocationId = recoveryRecord?.runId ?? opts.preparationRun ?? supervisedRunId() ?? (process.env.RAFI_DETACHED_SUPERVISOR_RUN || undefined) ?? randomUUID();
558
+ const policyReader = new WorkflowReader(cwd);
559
+ const persistedPolicy = policyReader.autonomyPolicy(supervisedInvocationId);
560
+ policyReader.close();
561
+ const frozenPolicy = persistedPolicy ?? (recoveryRecord && "frozenPolicy" in recoveryRecord
447
562
  ? recoveryRecord.frozenPolicy
448
- : resolveAutonomyPolicy(loadProjectAutonomyConfig(cwd), autonomyProfile);
449
- const supervisedInvocationId = recoveryRecord?.runId ?? supervisedRunId() ?? (process.env.RAFI_DETACHED_SUPERVISOR_RUN || undefined) ?? randomUUID();
450
- if (await superviseStart(cwd, supervisedInvocationId, opts.supervisor === false ? { ...frozenPolicy, supervisorEnabled: false } : frozenPolicy, { steps: opts.steps, stacks: opts.stacks, detach: opts.detach }))
451
- return;
452
- const autoApprovePlanUpdates = recoveryRecord
453
- ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
454
- : Boolean(opts.yes);
455
- if (opts.recoveryMode) {
456
- const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
457
- if (!allowed.includes(String(opts.recoveryMode)))
458
- fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
459
- if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
460
- fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
461
- if (opts.recoveryDecisionDigest && createHash("sha256").update(JSON.stringify(recoveryRecord.recoveryDecision)).digest("hex") !== opts.recoveryDecisionDigest)
462
- fail("recovery decision digest no longer matches the persisted decision receipt");
463
- }
464
- if (opts.acceptHandoff && !recoveryRecord)
465
- fail("--accept-handoff requires --recover-run");
466
- let pendingHandoffGeneration = opts.acceptHandoff === undefined ? undefined : Number(opts.acceptHandoff);
467
- if (pendingHandoffGeneration !== undefined && (!Number.isSafeInteger(pendingHandoffGeneration) || pendingHandoffGeneration < 1))
468
- fail("--accept-handoff must be a positive generation");
469
- const pendingHandoffRole = String(opts.acceptHandoffRole ?? "builder");
470
- if (pendingHandoffRole !== "builder" && pendingHandoffRole !== "qa")
471
- fail("--accept-handoff-role must be builder or qa");
472
- const roleDefaults = readAgentDefaults(cwd);
473
- let deliveryRun;
474
- let deliveryConfig;
475
- let selectedStacks = [];
476
- let selectedStackTickets;
477
- if (isTicketsInitialized(cwd)) {
478
- const delivery = loadDeliveryConfig(cwd);
479
- deliveryConfig = delivery;
480
- if (delivery) {
481
- const earlyConfig = loadTicketsConfig(cwd);
482
- const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
483
- const earlyDb = new StateDb(earlyPaths.stateDb);
484
- const earlyStates = earlyDb.getAllStates();
485
- earlyDb.close();
486
- if (stackCount !== undefined) {
487
- const earlyTickets = loadTickets(earlyPaths.tickets);
488
- const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
489
- if (selection.error)
490
- fail(selection.error);
491
- selectedStacks = selection.stacks;
492
- selectedStackTickets = selection.tickets;
493
- steps = selection.tickets.length;
494
- opts.branchPerTicket = true;
495
- opts.completion = "pr";
496
- const providers = new Set(selection.stacks.flatMap((stack) => stack.units.map((id) => delivery.units.find((unit) => unit.id === id)?.provider)).filter((provider) => provider === "github" || provider === "gitlab"));
497
- if (providers.size !== 1)
498
- fail("selected stacks must use one explicit GitHub or GitLab provider per run");
499
- opts.provider = [...providers][0];
563
+ : resolveAutonomyPolicy(loadProjectAutonomyConfig(cwd), autonomyProfile));
564
+ try {
565
+ if (await superviseStart(cwd, supervisedInvocationId, opts.supervisor === false ? { ...frozenPolicy, supervisorEnabled: false } : frozenPolicy, { steps: opts.steps, stacks: opts.stacks, detach: opts.detach, launchToken: opts.launchToken, startArgs: savedStartArguments(command, cwd, opts) }))
566
+ return;
567
+ if (recoveryRecord || opts.preparationRun) {
568
+ assertBuildAssignmentReconciled(cwd, supervisedInvocationId);
569
+ if (!await servicePendingHumanDecisions({ projectDir: cwd, runId: supervisedInvocationId, tickets: requestedTicketIds.length ? requestedTicketIds : recoveryRecord?.tickets.length ? recoveryRecord.tickets : undefined, scopeRevision: isTicketsInitialized(cwd) ? buildScopeRevision(cwd) : undefined }))
570
+ return;
571
+ }
572
+ const requestedNetwork = Boolean(opts.builderNetwork || recoveryRecord?.builderCapabilities?.requestedNetwork);
573
+ let grantedNetwork = false;
574
+ if (requestedNetwork) {
575
+ const permissionDb = new WorkflowDb(cwd);
576
+ let networkKey;
577
+ try {
578
+ networkKey = permissionDb.pendingHumanDecisions(supervisedInvocationId).find(decision => decision.interruptionId.startsWith("builder-network:"))?.interruptionId ?? `builder-network:${process.pid}:${randomUUID()}`;
500
579
  }
501
- else
502
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
503
- if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
504
- const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
505
- { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
506
- ] });
507
- if (isCancel(answer))
508
- process.exit(0);
509
- if (answer === "skip")
510
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, [deliveryRun.unit.id, ...(opts.skipDeliveryUnit ?? [])]);
580
+ finally {
581
+ permissionDb.close();
511
582
  }
583
+ const networkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: networkKey,
584
+ prompt: "Allow the builder shell to access the internet for this build? This permits network use by any builder command, not only downloads. Workspace write confinement remains enforced. QA stays offline. Recovery requires approval again; --yes does not approve this permission.",
585
+ choices: [{ id: "allow-network", label: "Allow builder internet for this build" }, { id: "stay-offline", label: "Stay offline and use a verified local bundle" }],
586
+ operation: async () => {
587
+ const action = await select({ message: "Allow builder internet for this build?", options: [{ value: "stay-offline", label: "Stay offline" }, { value: "allow-network", label: "Allow builder internet for this build" }] });
588
+ return isCancel(action) ? undefined : action;
589
+ } });
590
+ if (networkDecision === undefined || isCancel(networkDecision))
591
+ return;
592
+ grantedNetwork = networkDecision === "allow-network";
512
593
  }
513
- }
514
- if (stackCount !== undefined && selectedStacks.length !== stackCount)
515
- fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
516
- const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
517
- const resolvedPrefix = resolveStartBranchPrefix(cwd, opts, recoveryRecord);
518
- const branchPrefix = resolvedPrefix.value;
519
- const approvalConsequences = {
520
- branch_strategy: branchDefaults.branchMode ? "branch-per-ticket" : "current",
521
- branch_prefix: branchPrefix,
522
- ...(optionWasProvided(command, "completion") || optionWasProvided(command, "createPr") ? { completion: branchDefaults.completionMode } : {}),
523
- ...(optionWasProvided(command, "mergeMethod") ? { merge_method: branchDefaults.mergeMethod } : {}),
524
- ...(optionWasProvided(command, "prReady") ? { pr_ready: branchDefaults.prReady } : {}),
525
- ...(optionWasProvided(command, "provider") ? { provider: branchDefaults.reviewProvider } : {}),
526
- };
527
- if (opts.showSessionCost && opts.hideSessionCost)
528
- fail("choose either --show-session-cost or --hide-session-cost");
529
- const sessionCostOverride = opts.showSessionCost ? true : opts.hideSessionCost ? false : undefined;
530
- const thresholdOverride = opts.autoCompactThreshold === undefined ? undefined : Number(opts.autoCompactThreshold);
531
- if (thresholdOverride !== undefined && (!Number.isInteger(thresholdOverride) || thresholdOverride < 1 || thresholdOverride > 99))
532
- fail("--auto-compact-threshold must be an integer from 1 to 99");
533
- let agent;
534
- try {
535
- agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
536
- }
537
- catch (err) {
538
- fail(err instanceof Error ? err.message : String(err));
539
- }
540
- let model = opts.model ?? explicitDefaultValue(roleDefaults.builder?.model);
541
- let agentExecutable;
542
- const builderEffort = opts.effort ?? explicitEffort(roleDefaults.builder?.reasoning);
543
- const builderFast = opts.fast ?? roleDefaults.builder?.fast;
544
- let qaAgent = resolveAgentForProject(cwd, roleDefaults.qa?.make);
545
- let qaModel = explicitDefaultValue(roleDefaults.qa?.model);
546
- let qaExecutable;
547
- const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
548
- const qaFast = roleDefaults.qa?.fast ?? false;
549
- const settingsRevision = roleDefaults.revision ?? 0;
550
- const configuredTrackerPath = isTicketsInitialized(cwd)
551
- ? loadTicketsConfig(cwd).paths.progressDoc
552
- : undefined;
553
- const TRACKER_SEARCH_PATHS = unique([
554
- configuredTrackerPath,
555
- "docs/ticket-progress.md",
556
- "ticket-progress.md",
557
- ].filter(Boolean));
558
- let ticketsContent;
559
- let trackerRelPath;
560
- if (opts.tickets) {
561
- const ticketPath = resolve(opts.tickets);
562
- if (!existsSync(ticketPath))
563
- fail(`ticket file not found: ${ticketPath}`);
564
- ticketsContent = readFileSync(ticketPath, "utf8");
565
- trackerRelPath = relative(cwd, ticketPath);
566
- }
567
- else {
568
- for (const rel of TRACKER_SEARCH_PATHS) {
569
- const abs = join(cwd, rel);
570
- if (existsSync(abs)) {
571
- ticketsContent = readFileSync(abs, "utf8");
572
- trackerRelPath = rel;
573
- break;
594
+ const builderCapabilities = { requestedNetwork, grantedNetwork, ...(grantedNetwork ? { approvedAt: new Date().toISOString(), grantWorkerPid: process.pid } : {}) };
595
+ const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
596
+ const autoApprovePlanUpdates = recoveryRecord
597
+ ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
598
+ : Boolean(opts.yes);
599
+ if (opts.recoveryMode) {
600
+ const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
601
+ if (!allowed.includes(String(opts.recoveryMode)))
602
+ fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
603
+ if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
604
+ fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
605
+ if (opts.recoveryDecisionDigest && createHash("sha256").update(JSON.stringify(recoveryRecord.recoveryDecision)).digest("hex") !== opts.recoveryDecisionDigest)
606
+ fail("recovery decision digest no longer matches the persisted decision receipt");
607
+ }
608
+ if (opts.acceptHandoff && !recoveryRecord)
609
+ fail("--accept-handoff requires --recover-run");
610
+ let pendingHandoffGeneration = opts.acceptHandoff === undefined ? undefined : Number(opts.acceptHandoff);
611
+ if (pendingHandoffGeneration !== undefined && (!Number.isSafeInteger(pendingHandoffGeneration) || pendingHandoffGeneration < 1))
612
+ fail("--accept-handoff must be a positive generation");
613
+ const pendingHandoffRole = String(opts.acceptHandoffRole ?? "builder");
614
+ if (pendingHandoffRole !== "builder" && pendingHandoffRole !== "qa")
615
+ fail("--accept-handoff-role must be builder or qa");
616
+ const roleDefaults = readAgentDefaults(cwd);
617
+ let deliveryRun;
618
+ let deliveryConfig;
619
+ let selectedStacks = [];
620
+ let selectedStackTickets;
621
+ if (isTicketsInitialized(cwd)) {
622
+ const delivery = loadDeliveryConfig(cwd);
623
+ deliveryConfig = delivery;
624
+ if (delivery) {
625
+ const earlyConfig = loadTicketsConfig(cwd);
626
+ const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
627
+ const earlyDb = new StateDb(earlyPaths.stateDb);
628
+ const earlyStates = earlyDb.getAllStates();
629
+ earlyDb.close();
630
+ if (stackCount !== undefined) {
631
+ const earlyTickets = loadTickets(earlyPaths.tickets);
632
+ const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
633
+ if (selection.error)
634
+ fail(selection.error);
635
+ selectedStacks = selection.stacks;
636
+ selectedStackTickets = selection.tickets;
637
+ steps = selection.tickets.length;
638
+ opts.branchPerTicket = true;
639
+ opts.completion = "pr";
640
+ const providers = new Set(selection.stacks.flatMap((stack) => stack.units.map((id) => delivery.units.find((unit) => unit.id === id)?.provider)).filter((provider) => provider === "github" || provider === "gitlab"));
641
+ if (providers.size !== 1)
642
+ fail("selected stacks must use one explicit GitHub or GitLab provider per run");
643
+ opts.provider = [...providers][0];
644
+ }
645
+ else
646
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
647
+ if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
648
+ const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
649
+ { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
650
+ ] });
651
+ if (isCancel(answer))
652
+ process.exit(0);
653
+ if (answer === "skip")
654
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, [deliveryRun.unit.id, ...(opts.skipDeliveryUnit ?? [])]);
655
+ }
574
656
  }
575
657
  }
576
- }
577
- if (opts.resume && opts.continue) {
578
- fail("choose either --resume <sessionId> or --continue, not both");
579
- }
580
- const branchMode = branchDefaults.branchMode;
581
- const savedWorkMode = loadTicketSetupConfig(cwd)?.build.branch_strategy ?? "current";
582
- const workModeSource = recoveryRecord?.runDecisions ? "resume" : branchMode && savedWorkMode === "current" ? "cli" : "project";
583
- if (!recoveryRecord && branchMode && savedWorkMode === "current") {
584
- console.log(`foreman: run override — project uses Current branch — Rafi works here; you manage Git; this run explicitly uses an isolated workflow`);
585
- }
586
- const continueTickets = recoveryIntent
587
- ? requestedTicketIds.length > 0
588
- ? requestedTicketIds
589
- : branchDefaults.branchMode && recoveryRecord
590
- ? [...recoveryRecord.tickets]
591
- : []
592
- : [];
593
- let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
594
- const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
595
- if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
596
- fail("--max-branch-depth must be a positive integer");
597
- }
598
- if (!branchMode && continueTickets.length > 1) {
599
- fail("current-branch build recovery supports exactly one --ticket");
600
- }
601
- if (branchMode) {
602
- if (opts.resume && continueTickets.length > 1) {
603
- fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
658
+ if (stackCount !== undefined && selectedStacks.length !== stackCount)
659
+ fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
660
+ const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
661
+ const resolvedPrefix = resolveStartBranchPrefix(cwd, opts, recoveryRecord);
662
+ const branchPrefix = resolvedPrefix.value;
663
+ const approvalConsequences = {
664
+ branch_strategy: branchDefaults.branchMode ? "branch-per-ticket" : "current",
665
+ branch_prefix: branchPrefix,
666
+ ...(optionWasProvided(command, "completion") || optionWasProvided(command, "createPr") ? { completion: branchDefaults.completionMode } : {}),
667
+ ...(optionWasProvided(command, "mergeMethod") ? { merge_method: branchDefaults.mergeMethod } : {}),
668
+ ...(optionWasProvided(command, "prReady") ? { pr_ready: branchDefaults.prReady } : {}),
669
+ ...(optionWasProvided(command, "provider") ? { provider: branchDefaults.reviewProvider } : {}),
670
+ };
671
+ if (opts.showSessionCost && opts.hideSessionCost)
672
+ fail("choose either --show-session-cost or --hide-session-cost");
673
+ const sessionCostOverride = opts.showSessionCost ? true : opts.hideSessionCost ? false : undefined;
674
+ const thresholdOverride = opts.autoCompactThreshold === undefined ? undefined : Number(opts.autoCompactThreshold);
675
+ if (thresholdOverride !== undefined && (!Number.isInteger(thresholdOverride) || thresholdOverride < 1 || thresholdOverride > 99))
676
+ fail("--auto-compact-threshold must be an integer from 1 to 99");
677
+ let agent;
678
+ try {
679
+ agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
604
680
  }
605
- if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
606
- fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
681
+ catch (err) {
682
+ fail(err instanceof Error ? err.message : String(err));
607
683
  }
608
- if (opts.tickets)
609
- fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
610
- if (!isTicketsInitialized(cwd))
611
- fail("isolated branch runs require initialized .tickets/ (run ai-foreman tickets init)");
612
- if (branchDefaults.createReview && !branchDefaults.reviewProvider) {
613
- fail("PR/MR completion is enabled but no GitHub or GitLab origin remote was detected; pass --provider github|gitlab or --completion none");
684
+ let model = opts.model ?? explicitDefaultValue(roleDefaults.builder?.model);
685
+ let agentExecutable;
686
+ const builderEffort = opts.effort ?? explicitEffort(roleDefaults.builder?.reasoning);
687
+ const builderFast = opts.fast ?? roleDefaults.builder?.fast;
688
+ let qaAgent = resolveAgentForProject(cwd, roleDefaults.qa?.make);
689
+ let qaModel = explicitDefaultValue(roleDefaults.qa?.model);
690
+ let qaExecutable;
691
+ const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
692
+ const qaFast = roleDefaults.qa?.fast ?? false;
693
+ const settingsRevision = roleDefaults.revision ?? 0;
694
+ const configuredTrackerPath = isTicketsInitialized(cwd)
695
+ ? loadTicketsConfig(cwd).paths.progressDoc
696
+ : undefined;
697
+ const TRACKER_SEARCH_PATHS = unique([
698
+ configuredTrackerPath,
699
+ "docs/ticket-progress.md",
700
+ "ticket-progress.md",
701
+ ].filter(Boolean));
702
+ let ticketsContent;
703
+ let trackerRelPath;
704
+ if (opts.tickets) {
705
+ const ticketPath = resolve(opts.tickets);
706
+ if (!existsSync(ticketPath))
707
+ fail(`ticket file not found: ${ticketPath}`);
708
+ ticketsContent = readFileSync(ticketPath, "utf8");
709
+ trackerRelPath = relative(cwd, ticketPath);
614
710
  }
615
- }
616
- const resumeSessionId = opts.resume ??
617
- (!branchMode && opts.continue ? findLastSessionId(join(cwd, ".foreman")) : undefined);
618
- if (!branchMode && opts.continue && !resumeSessionId) {
619
- fail(`no previous session id found under ${join(cwd, ".foreman")}`);
620
- }
621
- let resumeSessionRef;
622
- if (!branchMode && resumeSessionId) {
623
- if (recoveryRecord)
624
- resumeSessionRef = buildRunSessionBinding(recoveryRecord, "builder", resumeSessionId);
625
- if (!resumeSessionRef) {
626
- try {
627
- resumeSessionRef = resolveUniqueSessionBinding(readBuildRuns(cwd).flatMap((run) => run.sessionBindings ?? []), resumeSessionId);
711
+ else {
712
+ for (const rel of TRACKER_SEARCH_PATHS) {
713
+ const abs = join(cwd, rel);
714
+ if (existsSync(abs)) {
715
+ ticketsContent = readFileSync(abs, "utf8");
716
+ trackerRelPath = rel;
717
+ break;
718
+ }
628
719
  }
629
- catch (error) {
630
- fail(error instanceof Error ? error.message : String(error));
720
+ }
721
+ if (opts.resume && opts.continue) {
722
+ fail("choose either --resume <sessionId> or --continue, not both");
723
+ }
724
+ const branchMode = branchDefaults.branchMode;
725
+ const savedWorkMode = loadTicketSetupConfig(cwd)?.build.branch_strategy ?? "current";
726
+ const workModeSource = recoveryRecord?.runDecisions ? "resume" : branchMode && savedWorkMode === "current" ? "cli" : "project";
727
+ if (!recoveryRecord && branchMode && savedWorkMode === "current") {
728
+ console.log(`foreman: run override — project uses Current branch — Rafi works here; you manage Git; this run explicitly uses an isolated workflow`);
729
+ }
730
+ const continueTickets = recoveryIntent
731
+ ? requestedTicketIds.length > 0
732
+ ? requestedTicketIds
733
+ : branchDefaults.branchMode && recoveryRecord
734
+ ? [...recoveryRecord.tickets]
735
+ : recoveryRecord ? [currentBranchRecoveryTicket(cwd, recoveryRecord)].filter((ticket) => ticket !== undefined) : []
736
+ : [];
737
+ if (recoveryRecord && continueTickets.some(ticket => !recoveryRecord.tickets.includes(ticket)))
738
+ fail("Recovery ticket is outside the saved run scope");
739
+ let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
740
+ const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
741
+ if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
742
+ fail("--max-branch-depth must be a positive integer");
743
+ }
744
+ if (!branchMode && continueTickets.length > 1) {
745
+ fail("current-branch build recovery supports exactly one --ticket");
746
+ }
747
+ if (branchMode) {
748
+ if (opts.resume && continueTickets.length > 1) {
749
+ fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
750
+ }
751
+ if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
752
+ fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
753
+ }
754
+ if (opts.tickets)
755
+ fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
756
+ if (!isTicketsInitialized(cwd))
757
+ fail("isolated branch runs require initialized .tickets/ (run ai-foreman tickets init)");
758
+ if (branchDefaults.createReview && !branchDefaults.reviewProvider) {
759
+ fail("PR/MR completion is enabled but no GitHub or GitLab origin remote was detected; pass --provider github|gitlab or --completion none");
631
760
  }
632
761
  }
633
- if (!resumeSessionRef)
634
- fail(`session ${resumeSessionId} has no stored location-scoped binding; choose fresh recovery with a validated handoff instead of an unverifiable exact resume`);
635
- }
636
- if (selectedNewTicket) {
637
- if (!isTicketsInitialized(cwd))
638
- fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
639
- const eligibilityConfig = loadTicketsConfig(cwd);
640
- const eligibilityPaths = resolveTicketPaths(eligibilityConfig, cwd);
641
- const eligibilityDefs = loadTickets(eligibilityPaths.tickets);
642
- const eligibilityDb = new StateDb(eligibilityPaths.stateDb);
643
- const eligibilityStates = eligibilityDb.getAllStates();
644
- eligibilityDb.close();
645
- const activeLease = readCurrentWorkflowLease(cwd);
646
- let assessment = evaluateTicketEligibility({
647
- tickets: eligibilityDefs,
648
- states: eligibilityStates,
649
- implementationLimit: eligibilityConfig.implementationLimit,
650
- delivery: deliveryConfig,
651
- activeLease: activeLease ? { runId: activeLease.runId } : undefined,
652
- }, selectedNewTicket);
653
- if (!assessment.eligible) {
654
- console.error(`foreman: ${selectedNewTicket} is not eligible:`);
655
- for (const blocker of assessment.blockers)
656
- console.error(` - ${blocker.detail}`);
657
- const dependency = assessment.eligibleDependencies[0];
658
- if (!dependency || opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
659
- fail(`cannot start ${selectedNewTicket}`);
660
- const answer = await select({ message: `Start eligible dependency ${dependency.id}: ${dependency.title} instead?`, options: [
661
- { value: "dependency", label: `Start ${dependency.id} (Recommended)` },
662
- { value: "cancel", label: "Cancel" },
663
- ] });
664
- if (isCancel(answer) || answer === "cancel") {
665
- console.log("ai-foreman: cancelled; nothing changed");
666
- return;
762
+ const resumeSessionId = opts.resume ??
763
+ (!branchMode && opts.continue ? findLastSessionId(join(cwd, ".foreman")) : undefined);
764
+ if (!branchMode && opts.continue && !resumeSessionId) {
765
+ fail(`no previous session id found under ${join(cwd, ".foreman")}`);
766
+ }
767
+ let resumeSessionRef;
768
+ if (!branchMode && resumeSessionId) {
769
+ if (recoveryRecord)
770
+ resumeSessionRef = buildRunSessionBinding(recoveryRecord, "builder", resumeSessionId);
771
+ if (!resumeSessionRef) {
772
+ try {
773
+ resumeSessionRef = resolveUniqueSessionBinding(readBuildRuns(cwd).flatMap((run) => run.sessionBindings ?? []), resumeSessionId);
774
+ }
775
+ catch (error) {
776
+ fail(error instanceof Error ? error.message : String(error));
777
+ }
667
778
  }
668
- selectedNewTicket = dependency.id;
669
- assessment = evaluateTicketEligibility({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig }, selectedNewTicket);
779
+ if (!resumeSessionRef)
780
+ fail(`session ${resumeSessionId} has no stored location-scoped binding; choose fresh recovery with a validated handoff instead of an unverifiable exact resume`);
670
781
  }
671
- const recommended = eligibleTickets({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig })[0];
672
- if (assessment.eligible && recommended && recommended.id !== selectedNewTicket) {
673
- console.log(`foreman: recommended ${recommended.id}: ${recommended.title}`);
674
- console.log(`foreman: requested ${assessment.ticket.id}: ${assessment.ticket.title}`);
675
- if (!opts.yes) {
676
- if (!process.stdin.isTTY || !process.stdout.isTTY)
677
- fail(`starting non-recommended ${selectedNewTicket} requires --yes`);
678
- const answer = await select({ message: `Start ${selectedNewTicket} anyway?`, options: [
679
- { value: "start", label: `Start ${selectedNewTicket}` }, { value: "cancel", label: "Cancel" },
782
+ if (selectedNewTicket) {
783
+ if (!isTicketsInitialized(cwd))
784
+ fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
785
+ const eligibilityConfig = loadTicketsConfig(cwd);
786
+ const eligibilityPaths = resolveTicketPaths(eligibilityConfig, cwd);
787
+ const eligibilityDefs = loadTickets(eligibilityPaths.tickets);
788
+ const eligibilityDb = new StateDb(eligibilityPaths.stateDb);
789
+ const eligibilityStates = eligibilityDb.getAllStates();
790
+ eligibilityDb.close();
791
+ const activeLease = readCurrentWorkflowLease(cwd);
792
+ let assessment = evaluateTicketEligibility({
793
+ tickets: eligibilityDefs,
794
+ states: eligibilityStates,
795
+ implementationLimit: eligibilityConfig.implementationLimit,
796
+ delivery: deliveryConfig,
797
+ activeLease: activeLease ? { runId: activeLease.runId } : undefined,
798
+ }, selectedNewTicket);
799
+ if (!assessment.eligible) {
800
+ console.error(`foreman: ${selectedNewTicket} is not eligible:`);
801
+ for (const blocker of assessment.blockers)
802
+ console.error(` - ${blocker.detail}`);
803
+ const dependency = assessment.eligibleDependencies[0];
804
+ if (!dependency || opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
805
+ fail(`cannot start ${selectedNewTicket}`);
806
+ const answer = await select({ message: `Start eligible dependency ${dependency.id}: ${dependency.title} instead?`, options: [
807
+ { value: "dependency", label: `Start ${dependency.id} (Recommended)` },
808
+ { value: "cancel", label: "Cancel" },
680
809
  ] });
681
810
  if (isCancel(answer) || answer === "cancel") {
682
811
  console.log("ai-foreman: cancelled; nothing changed");
683
812
  return;
684
813
  }
814
+ selectedNewTicket = dependency.id;
815
+ assessment = evaluateTicketEligibility({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig }, selectedNewTicket);
816
+ }
817
+ const recommended = eligibleTickets({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig })[0];
818
+ if (assessment.eligible && recommended && recommended.id !== selectedNewTicket) {
819
+ console.log(`foreman: recommended ${recommended.id}: ${recommended.title}`);
820
+ console.log(`foreman: requested ${assessment.ticket.id}: ${assessment.ticket.title}`);
821
+ if (!opts.yes) {
822
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
823
+ fail(`starting non-recommended ${selectedNewTicket} requires --yes`);
824
+ const answer = await select({ message: `Start ${selectedNewTicket} anyway?`, options: [
825
+ { value: "start", label: `Start ${selectedNewTicket}` }, { value: "cancel", label: "Cancel" },
826
+ ] });
827
+ if (isCancel(answer) || answer === "cancel") {
828
+ console.log("ai-foreman: cancelled; nothing changed");
829
+ return;
830
+ }
831
+ }
685
832
  }
686
833
  }
687
- }
688
- const recoveryTicket = !branchMode && recoveryRecord ? continueTickets[0] : undefined;
689
- const preferredTicket = selectedNewTicket ?? recoveryTicket;
690
- const config = loadConfig(join(cwd, "foreman.yaml"));
691
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
692
- const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
693
- const log = new Log(logPath);
694
- const qaResolution = resolveQaEnablement({
695
- cli: command.getOptionValueSource("qa") === "cli" ? Boolean(opts.qa) : undefined,
696
- frozen: recoveryRecord && "qaEnabled" in recoveryRecord ? Boolean(recoveryRecord.qaEnabled) : undefined,
697
- projectDir: cwd,
698
- legacyForeman: config.qa.enabled,
699
- });
700
- const qaEnabled = qaResolution.enabled;
701
- if (qaResolution.deprecationWarning)
702
- console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
703
- const invocationRunId = supervisedInvocationId;
704
- let observabilityStore;
705
- if (config.observability.enabled) {
706
- try {
707
- observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
708
- }
709
- catch (error) {
710
- console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
834
+ const recoveryTicket = !branchMode && recoveryRecord ? continueTickets[0] : undefined;
835
+ const preferredTicket = selectedNewTicket ?? recoveryTicket;
836
+ let executionTickets = !branchMode && recoveryRecord && recoveryRecord.tickets.length
837
+ ? recoveryExecutionTickets(recoveryRecord, requestedTicketIds, Boolean(opts.recoveryMode))
838
+ : selectedNewTicket ? [selectedNewTicket] : undefined;
839
+ const config = loadConfig(join(cwd, "foreman.yaml"));
840
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
841
+ const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
842
+ const log = new Log(logPath);
843
+ const qaResolution = resolveQaEnablement({
844
+ cli: command.getOptionValueSource("qa") === "cli" ? Boolean(opts.qa) : undefined,
845
+ frozen: recoveryRecord && "qaEnabled" in recoveryRecord ? Boolean(recoveryRecord.qaEnabled) : undefined,
846
+ projectDir: cwd,
847
+ legacyForeman: config.qa.enabled,
848
+ });
849
+ const qaEnabled = qaResolution.enabled;
850
+ if (qaResolution.deprecationWarning)
851
+ console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
852
+ const invocationRunId = supervisedInvocationId;
853
+ let observabilityStore;
854
+ if (config.observability.enabled) {
855
+ try {
856
+ observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
857
+ }
858
+ catch (error) {
859
+ console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
860
+ }
711
861
  }
712
- }
713
- let activeObserver = observabilityStore ? new RunObserver(observabilityStore, invocationRunId) : undefined;
714
- const finishObservationOnExit = () => {
715
- if (!activeObserver)
716
- return;
717
- activeObserver.finish("interrupted");
718
- observabilityStore?.closeLogFile(logPath, "interrupted");
719
- observabilityStore?.close();
720
- activeObserver = undefined;
721
- };
722
- process.once("exit", finishObservationOnExit);
723
- activeObserver?.startResourceSampling();
724
- observabilityStore?.recordCapabilities(invocationRunId, {
725
- provider_turns: { source: "provider_turns", state: "available", observedAt: new Date().toISOString() },
726
- tools: { source: "tools", state: "available", observedAt: new Date().toISOString() },
727
- process_resources: { source: "process_resources", state: "available", observedAt: new Date().toISOString() },
728
- child_process_resources: { source: "child_process_resources", state: "unavailable", observedAt: new Date().toISOString(), detail: "provider child-process attribution is unsupported" },
729
- }, FOREMAN_VERSION);
730
- observabilityStore?.indexLogFile(logPath, invocationRunId);
731
- const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
732
- const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
733
- const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
734
- const adapterOpts = {
735
- cwd: builderCwd,
736
- runtimeExecutable: agentExecutable,
737
- runtimePhase: "builder",
738
- preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
739
- rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
740
- compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
741
- shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
742
- turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
743
- model,
744
- ...(sessionRef ? { resumeSessionRef: sessionRef } : sessionId ? { resumeSessionId: sessionId } : {}),
745
- configRoot: cwd,
746
- sessionRole: "builder",
747
- sessionStream: sessionRef?.stream ?? "builder",
748
- sessionGeneration: sessionRef?.generation ?? 0,
749
- workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
750
- ticketId: sessionRef?.ticketId,
751
- deliveryUnitId: sessionRef?.deliveryUnitId,
752
- permission: createPermissionHandler(builderPolicy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
753
- onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
754
- onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
755
- effort: builderEffort,
756
- fast: builderFast,
757
- systemPromptAppend: roleBundle.system || undefined,
758
- skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
759
- autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
760
- observer: activeObserver,
862
+ let activeObserver = observabilityStore ? new RunObserver(observabilityStore, invocationRunId) : undefined;
863
+ const finishObservationOnExit = (exitCode) => {
864
+ if (!activeObserver)
865
+ return;
866
+ const reader = new WorkflowReader(cwd);
867
+ const durable = reader.getRun(invocationRunId);
868
+ reader.close();
869
+ const outcome = durable?.status === "cancelled" ? "cancelled" : durable?.checkpoint === "waiting-for-human" ? "waiting-for-human" : durable?.status === "failed" ? "failed" : "interrupted";
870
+ activeObserver.finish(outcome, { checkpoint: durable?.checkpoint, failureCategory: `process-exit:${exitCode}` });
871
+ observabilityStore?.closeLogFile(logPath, outcome);
872
+ observabilityStore?.close();
873
+ activeObserver = undefined;
761
874
  };
762
- const adapter = agent === "codex"
763
- ? new CodexAdapter(adapterOpts)
764
- : await ClaudeAdapter.create(adapterOpts);
765
- if (sessionRef && adapter.agent === "codex") {
766
- const availability = await adapter.validateSession();
767
- if (availability.status !== "available") {
768
- await adapter.close().catch(() => { });
769
- throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: agentExecutable ?? "codex", cwd: builderCwd, diagnostics: availability.detail ?? `Codex session ${sessionRef.sessionId} is ${availability.status}`, availability });
875
+ process.once("exit", finishObservationOnExit);
876
+ activeObserver?.startResourceSampling();
877
+ observabilityStore?.recordCapabilities(invocationRunId, {
878
+ provider_turns: { source: "provider_turns", state: "available", observedAt: new Date().toISOString() },
879
+ tools: { source: "tools", state: "available", observedAt: new Date().toISOString() },
880
+ process_resources: { source: "process_resources", state: "available", observedAt: new Date().toISOString() },
881
+ child_process_resources: { source: "child_process_resources", state: "unavailable", observedAt: new Date().toISOString(), detail: "provider child-process attribution is unsupported" },
882
+ }, FOREMAN_VERSION);
883
+ observabilityStore?.indexLogFile(logPath, invocationRunId);
884
+ const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
885
+ const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
886
+ const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
887
+ const adapterOpts = {
888
+ cwd: builderCwd,
889
+ runtimeExecutable: agentExecutable,
890
+ runtimePhase: "builder",
891
+ preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
892
+ rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
893
+ compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
894
+ shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
895
+ turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
896
+ model,
897
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : sessionId ? { resumeSessionId: sessionId } : {}),
898
+ configRoot: cwd,
899
+ sessionRole: "builder",
900
+ sessionStream: sessionRef?.stream ?? "builder",
901
+ sessionGeneration: sessionRef?.generation ?? 0,
902
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
903
+ ticketId: sessionRef?.ticketId,
904
+ deliveryUnitId: sessionRef?.deliveryUnitId,
905
+ networkAccess: grantedNetwork,
906
+ approvalPolicy: opts.builderApprovals ? "on-request" : "never",
907
+ permission: createPermissionHandler(builderPolicy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
908
+ onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
909
+ onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
910
+ effort: builderEffort,
911
+ fast: builderFast,
912
+ systemPromptAppend: roleBundle.system || undefined,
913
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
914
+ autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 65,
915
+ observer: activeObserver,
916
+ };
917
+ const adapter = agent === "codex"
918
+ ? new CodexAdapter(adapterOpts)
919
+ : await ClaudeAdapter.create(adapterOpts);
920
+ if (sessionRef && adapter.agent === "codex") {
921
+ const availability = await adapter.validateSession();
922
+ if (availability.status !== "available") {
923
+ await adapter.close().catch(() => { });
924
+ throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: agentExecutable ?? "codex", cwd: builderCwd, diagnostics: availability.detail ?? `Codex session ${sessionRef.sessionId} is ${availability.status}`, availability });
925
+ }
770
926
  }
771
- }
772
- await prepareNativeAutoCompaction(adapter);
773
- return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
774
- };
775
- let activeContinuityRunId;
776
- let liveStatusReporter;
777
- let activeBuilderForStatus;
778
- const resolvedContinuitySettings = (role) => role === "builder" ? {
779
- role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
780
- reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
781
- settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
782
- auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
783
- compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
784
- } : {
785
- role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
786
- reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
787
- settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
788
- auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
789
- compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
790
- };
791
- const liveRoleSettings = (role, base) => {
792
- const live = readAgentDefaults(cwd);
793
- const revision = live.revision ?? 0;
794
- if (revision <= base.settings_revision)
795
- return base;
796
- const defaults = role === "builder" ? live.builder : live.qa;
797
- return {
798
- ...base,
799
- source: defaults ? "project" : base.source,
800
- make: defaults?.make ?? base.make,
801
- model: defaults?.model ?? base.model,
802
- reasoning: defaults?.reasoning ?? base.reasoning,
803
- fast: defaults?.fast ?? base.fast,
804
- session_strategy: defaults?.session_strategy ?? base.session_strategy,
805
- display_session_cost: defaults?.display_session_cost ?? base.display_session_cost,
806
- auto_compact_threshold_percent: defaults?.auto_compact_threshold_percent ?? base.auto_compact_threshold_percent,
807
- compact_maximum: defaults?.compact_maximum ?? base.compact_maximum,
808
- settings_revision: revision,
927
+ await prepareNativeAutoCompaction(adapter);
928
+ return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
809
929
  };
810
- };
811
- const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
812
- ? { ...base, make: requestedRuntime, model: "default", source: "cli" }
813
- : base;
814
- const handoffRecoveryOptions = (role) => ({
815
- allowProviderSwitch: true,
816
- desktopNotifications: config.notifications.enabled,
817
- terminalBell: config.notifications.terminal_bell,
818
- onAccepted: (accepted) => {
819
- if (role === "builder" && accepted.successor.agent !== agent) {
820
- agent = accepted.successor.agent;
821
- model = undefined;
822
- agentExecutable = undefined;
823
- }
824
- if (role === "qa" && accepted.successor.agent !== qaAgent) {
825
- qaAgent = accepted.successor.agent;
826
- qaModel = undefined;
827
- qaExecutable = undefined;
828
- }
829
- },
830
- });
831
- const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
832
- ? current
833
- : { ...current, make, model: acceptedModel ?? "default", source: "cli" };
834
- const withRunLocalAcceptedRuntimes = (run) => ({
835
- ...run,
836
- ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
837
- ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
838
- });
839
- const qaRuntimeMetadata = new WeakMap();
840
- const resolveQaRuntimeMetadata = (qaCwd, settings) => {
841
- const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
842
- 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.`;
843
- const skills = bundle.skills.map((name) => {
844
- const runtimeRoot = settings.make === "codex" ? ".codex" : ".claude";
845
- const path = [join(qaCwd, runtimeRoot, "skills", name, "SKILL.md"), join(qaCwd, ".agents", "skills", name, "SKILL.md")].find(existsSync);
846
- if (path) {
847
- const content = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
848
- return { name, path, content, digest: createHash("sha256").update(content).digest("hex") };
849
- }
850
- const bundled = loadSkill(name);
851
- if (!bundled.body?.trim())
852
- throw new Error(`QA runtime ${settings.make} skill ${name} has no exact dispatchable content`);
853
- const content = `## ${bundled.name}\n${bundled.body.trim()}`;
854
- return { name, path: `special-agents:${name}`, content, digest: createHash("sha256").update(content).digest("hex") };
855
- });
856
- return { settings: structuredClone(settings), effectiveRoleInstructions, skills };
857
- };
858
- const createBuilderForSettings = async (builderCwd, settings) => {
859
- const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
860
- label: "live Builder settings",
861
- onTrace: trace => log.write("runtime-readiness", { ...trace, role: "builder" }),
862
- yes: true,
863
- allowSwitch: false,
864
- model: settings.model === "default" ? undefined : settings.model,
865
- });
866
- const policy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
867
- const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
868
- const adapterOptions = {
869
- cwd: builderCwd,
870
- configRoot: cwd,
871
- sessionRole: "builder",
872
- sessionStream: "builder",
873
- workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
874
- runtimeExecutable: ready.executable,
875
- runtimePhase: "builder",
876
- preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
877
- rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
878
- compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
879
- shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
880
- turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
881
- model: settings.model === "default" ? undefined : settings.model,
882
- permission: createPermissionHandler(policy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
883
- onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
884
- onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
885
- effort: explicitEffort(settings.reasoning),
886
- fast: settings.fast,
887
- systemPromptAppend: roleBundle.system || undefined,
888
- skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
889
- autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
890
- observer: activeObserver,
930
+ let activeContinuityRunId;
931
+ let liveStatusReporter;
932
+ let activeBuilderForStatus;
933
+ const resolvedContinuitySettings = (role) => role === "builder" ? {
934
+ role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
935
+ reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
936
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
937
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 65,
938
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
939
+ } : {
940
+ role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
941
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
942
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
943
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 65,
944
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
945
+ };
946
+ const liveRoleSettings = (role, base) => {
947
+ const live = readAgentDefaults(cwd);
948
+ const revision = live.revision ?? 0;
949
+ if (revision <= base.settings_revision)
950
+ return base;
951
+ const defaults = role === "builder" ? live.builder : live.qa;
952
+ return {
953
+ ...base,
954
+ source: defaults ? "project" : base.source,
955
+ make: defaults?.make ?? base.make,
956
+ model: defaults?.model ?? base.model,
957
+ reasoning: defaults?.reasoning ?? base.reasoning,
958
+ fast: defaults?.fast ?? base.fast,
959
+ session_strategy: defaults?.session_strategy ?? base.session_strategy,
960
+ display_session_cost: defaults?.display_session_cost ?? base.display_session_cost,
961
+ auto_compact_threshold_percent: defaults?.auto_compact_threshold_percent ?? base.auto_compact_threshold_percent,
962
+ compact_maximum: defaults?.compact_maximum ?? base.compact_maximum,
963
+ settings_revision: revision,
964
+ };
891
965
  };
892
- const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
893
- await prepareNativeAutoCompaction(created);
894
- return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
895
- };
896
- const createQaForSettings = async (qaCwd, settings) => {
897
- const ready = await ensureRuntimeReadyForCommand(qaCwd, settings.make, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
898
- label: "live QA settings",
899
- onTrace: trace => log.write("runtime-readiness", { ...trace, role: "qa" }),
900
- yes: true,
901
- allowSwitch: false,
902
- model: settings.model === "default" ? undefined : settings.model,
966
+ const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
967
+ ? { ...base, make: requestedRuntime, model: "default", source: "cli" }
968
+ : base;
969
+ const handoffRecoveryOptions = (role) => ({
970
+ allowProviderSwitch: true,
971
+ desktopNotifications: config.notifications.enabled,
972
+ terminalBell: config.notifications.terminal_bell,
973
+ onAccepted: (accepted) => {
974
+ if (role === "builder" && accepted.successor.agent !== agent) {
975
+ agent = accepted.successor.agent;
976
+ model = undefined;
977
+ agentExecutable = undefined;
978
+ }
979
+ if (role === "qa" && accepted.successor.agent !== qaAgent) {
980
+ qaAgent = accepted.successor.agent;
981
+ qaModel = undefined;
982
+ qaExecutable = undefined;
983
+ }
984
+ },
903
985
  });
904
- const policy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
905
- const metadata = resolveQaRuntimeMetadata(qaCwd, settings);
906
- const adapterOptions = {
907
- cwd: qaCwd,
908
- configRoot: cwd,
909
- sessionRole: "qa",
910
- sessionStream: "qa",
911
- runtimeExecutable: ready.executable,
912
- runtimePhase: "qa",
913
- preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
914
- rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
915
- compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
916
- shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
917
- turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
918
- model: settings.model === "default" ? undefined : settings.model,
919
- permission: createPermissionHandler(policy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
920
- onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
921
- onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
922
- effort: explicitEffort(settings.reasoning),
923
- fast: settings.fast,
924
- sandboxMode: "read-only",
925
- systemPromptAppend: metadata.effectiveRoleInstructions,
926
- preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
927
- autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
928
- allowAutoCompactionSetupTurn: false,
929
- observer: activeObserver,
986
+ const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
987
+ ? current
988
+ : { ...current, make, model: acceptedModel ?? "default", source: "cli" };
989
+ const withRunLocalAcceptedRuntimes = (run) => ({
990
+ ...run,
991
+ ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
992
+ ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
993
+ });
994
+ const qaRuntimeMetadata = new WeakMap();
995
+ const resolveQaRuntimeMetadata = (qaCwd, settings) => {
996
+ const effective = resolveEffectiveQaConfiguration(cwd, settings);
997
+ const effectiveRoleInstructions = finalQaRoleInstructions(effective.qaRules);
998
+ return { settings: structuredClone(settings), effectiveRoleInstructions, skills: effective.skills.map(skill => ({ ...skill, digest: createHash("sha256").update(skill.content).digest("hex") })) };
930
999
  };
931
- const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
932
- if (!created.prepareSession)
933
- throw new Error(`QA runtime ${settings.make} cannot establish a durable session identity before review`);
934
- await created.prepareSession();
935
- await prepareNativeAutoCompaction(created);
936
- qaRuntimeMetadata.set(created, metadata);
937
- return created;
938
- };
939
- const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
940
- if (sessionId && !sessionRef) {
941
- const availability = {
942
- version: 1,
943
- status: "unknown",
944
- checkedAt: new Date().toISOString(),
945
- reason: "legacy-unscoped",
946
- detail: "Builder recovery cannot authorize an exact provider resume from a raw session ID",
947
- };
948
- throw new SessionUnavailableError({
949
- runtime: agent,
950
- phase: "preflight",
951
- dispatchState: "not-sent",
952
- executable: agentExecutable ?? agent,
1000
+ const createBuilderForSettings = async (builderCwd, settings) => {
1001
+ const ready = await ensureBuildRuntimeReadyForCommand(builderCwd, settings.make, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1002
+ label: "live Builder settings",
1003
+ onTrace: trace => log.write("runtime-readiness", { ...trace, role: "builder" }),
1004
+ yes: true,
1005
+ allowSwitch: false,
1006
+ model: settings.model === "default" ? undefined : settings.model,
1007
+ });
1008
+ const policy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
1009
+ const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
1010
+ const adapterOptions = {
953
1011
  cwd: builderCwd,
954
- diagnostics: availability.detail,
955
- availability,
1012
+ configRoot: cwd,
1013
+ sessionRole: "builder",
1014
+ sessionStream: "builder",
1015
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
1016
+ runtimeExecutable: ready.executable,
1017
+ runtimePhase: "builder",
1018
+ preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1019
+ rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
1020
+ compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
1021
+ shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
1022
+ turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
1023
+ model: settings.model === "default" ? undefined : settings.model,
1024
+ networkAccess: grantedNetwork,
1025
+ approvalPolicy: opts.builderApprovals ? "on-request" : "never",
1026
+ permission: createPermissionHandler(policy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
1027
+ onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
1028
+ onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
1029
+ effort: explicitEffort(settings.reasoning),
1030
+ fast: settings.fast,
1031
+ systemPromptAppend: roleBundle.system || undefined,
1032
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
1033
+ autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
1034
+ observer: activeObserver,
1035
+ };
1036
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
1037
+ await prepareNativeAutoCompaction(created);
1038
+ return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
1039
+ };
1040
+ const createQaForSettings = async (qaCwd, settings) => {
1041
+ const ready = await ensureBuildRuntimeReadyForCommand(qaCwd, settings.make, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1042
+ label: "live QA settings",
1043
+ onTrace: trace => log.write("runtime-readiness", { ...trace, role: "qa" }),
1044
+ yes: true,
1045
+ allowSwitch: false,
1046
+ model: settings.model === "default" ? undefined : settings.model,
956
1047
  });
957
- }
958
- const initial = await createRawBuilder(builderCwd, sessionId, sessionRef);
959
- return new RecoveringAdapter({
960
- initial,
961
- runtime: agent,
962
- label: "builder turn",
963
- observer: activeObserver,
964
- enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
965
- allowSwitch: !(opts.resume || opts.continue),
966
- recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
967
- const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
968
- label: "builder recovery",
969
- allowSwitch: false,
970
- model: nextRuntime === agent ? model : undefined,
1048
+ const policy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
1049
+ const metadata = resolveQaRuntimeMetadata(qaCwd, settings);
1050
+ const adapterOptions = {
1051
+ cwd: qaCwd,
1052
+ configRoot: cwd,
1053
+ sessionRole: "qa",
1054
+ sessionStream: "qa",
1055
+ runtimeExecutable: ready.executable,
1056
+ runtimePhase: "qa",
1057
+ preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1058
+ rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
1059
+ compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
1060
+ shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
1061
+ turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
1062
+ model: settings.model === "default" ? undefined : settings.model,
1063
+ permission: createPermissionHandler(policy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
1064
+ onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
1065
+ onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
1066
+ effort: explicitEffort(settings.reasoning),
1067
+ fast: settings.fast,
1068
+ sandboxMode: "read-only",
1069
+ systemPromptAppend: metadata.effectiveRoleInstructions,
1070
+ preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
1071
+ autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
1072
+ allowAutoCompactionSetupTurn: false,
1073
+ observer: activeObserver,
1074
+ };
1075
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
1076
+ if (!created.prepareSession)
1077
+ throw new Error(`QA runtime ${settings.make} cannot establish a durable session identity before review`);
1078
+ await created.prepareSession();
1079
+ await prepareNativeAutoCompaction(created);
1080
+ qaRuntimeMetadata.set(created, metadata);
1081
+ return created;
1082
+ };
1083
+ const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
1084
+ if (sessionId && !sessionRef) {
1085
+ const availability = {
1086
+ version: 1,
1087
+ status: "unknown",
1088
+ checkedAt: new Date().toISOString(),
1089
+ reason: "legacy-unscoped",
1090
+ detail: "Builder recovery cannot authorize an exact provider resume from a raw session ID",
1091
+ };
1092
+ throw new SessionUnavailableError({
1093
+ runtime: agent,
1094
+ phase: "preflight",
1095
+ dispatchState: "not-sent",
1096
+ executable: agentExecutable ?? agent,
1097
+ cwd: builderCwd,
1098
+ diagnostics: availability.detail,
1099
+ availability,
971
1100
  });
972
- agent = nextReady.runtime;
973
- model = nextReady.model;
974
- agentExecutable = nextReady.executable;
975
- return createRawBuilder(builderCwd, resumeSessionId, resumeRef);
976
- },
977
- });
978
- };
979
- const continuousBuilder = (adapter, builderCwd, runId, settings = resolvedContinuitySettings("builder"), replaceRecoveryLeaseAfterCheckpoint = false) => new ContinuityAdapter({
980
- adapter,
981
- projectDir: cwd,
982
- runId,
983
- role: "builder",
984
- settings,
985
- authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settings.settings_revision,
986
- createSuccessor: () => createRecoveringBuilder(builderCwd),
987
- replaceRecoveryLeaseAfterCheckpoint,
988
- recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
989
- const effectiveSettings = liveRoleSettings("builder", settings);
990
- const context = await predecessor.contextUsage?.().catch(() => undefined);
991
- const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
992
- const sessionId = predecessor.sessionId();
993
- const recoveryDb = new WorkflowDb(cwd);
994
- let compactionCount = 0;
995
- try {
996
- compactionCount = recoveryDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
997
- }
998
- finally {
999
- recoveryDb.close();
1000
1101
  }
1001
- const transfer = await new HandoffService(cwd).transfer({
1002
- runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: predecessor.sessionRef?.(),
1003
- allowNonCurrentContinuity: true,
1004
- roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
1005
- ...(nativeUsage ? { sessionUsage: {
1006
- version: 1, runId, role: "builder", provider: predecessor.agent,
1007
- providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1008
- cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1009
- cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1010
- } } : {}),
1011
- compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1012
- resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1013
- }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
1014
- log.write("handoff-transfer", {
1015
- role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1016
- predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1017
- acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1018
- });
1019
- return transfer.successor;
1020
- },
1021
- handleHandoffRequest: async (output, predecessor, frozenAction) => {
1022
- const request = parseBuilderHandoffRequest(output);
1023
- if (!request)
1024
- return undefined;
1025
- const effectiveSettings = liveRoleSettings("builder", settings);
1026
- const context = await predecessor.contextUsage?.().catch(() => undefined);
1027
- const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1028
- const sessionId = predecessor.sessionId();
1029
- const countDb = new WorkflowDb(cwd);
1030
- let compactionCount = 0;
1031
- try {
1032
- const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
1033
- countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
1034
- countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
1035
- compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
1036
- }
1037
- finally {
1038
- countDb.close();
1039
- }
1040
- const transfer = await new HandoffService(cwd).transfer({
1041
- runId,
1042
- role: "builder",
1043
- reason: request.reason,
1044
- predecessorSessionId: sessionId,
1045
- predecessorSessionRef: predecessor.sessionRef?.(),
1046
- requestedByBuilder: true,
1047
- roleState: {
1048
- ...request.roleState,
1049
- frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
1050
- ...(context ? { occupancy: context } : { occupancy: "unavailable" }),
1102
+ const initial = await createRawBuilder(builderCwd, sessionId, sessionRef);
1103
+ return new RecoveringAdapter({
1104
+ initial,
1105
+ runtime: agent,
1106
+ label: "builder turn",
1107
+ observer: activeObserver,
1108
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
1109
+ allowSwitch: !(opts.resume || opts.continue),
1110
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
1111
+ const nextReady = await ensureBuildRuntimeReadyForCommand(builderCwd, nextRuntime, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1112
+ label: "builder recovery",
1113
+ allowSwitch: false,
1114
+ model: nextRuntime === agent ? model : undefined,
1115
+ });
1116
+ agent = nextReady.runtime;
1117
+ model = nextReady.model;
1118
+ agentExecutable = nextReady.executable;
1119
+ return createRawBuilder(builderCwd, resumeSessionId, resumeRef);
1051
1120
  },
1052
- ...(nativeUsage ? { sessionUsage: {
1053
- version: 1, runId, role: "builder", provider: predecessor.agent,
1054
- providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1055
- cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1056
- cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1057
- } } : {}),
1058
- compactionCount,
1059
- compactMaximum: effectiveSettings.compact_maximum,
1060
- resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1061
- }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
1062
- log.write("handoff-transfer", {
1063
- requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
1064
- predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1065
- acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1066
1121
  });
1067
- return transfer.successor;
1068
- },
1069
- });
1070
- const createBuilder = async (builderCwd, sessionId, sessionRef) => {
1071
- const recoveryMode = String(opts.recoveryMode ?? "");
1072
- const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
1073
- let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
1074
- if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
1075
- const service = new HandoffService(cwd);
1076
- const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1077
- const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
1078
- adapter = accepted.successor;
1079
- pendingHandoffGeneration = undefined;
1080
- log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1081
- }
1082
- return activeContinuityRunId ? continuousBuilder(adapter, builderCwd, activeContinuityRunId, undefined, String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role !== "qa") : adapter;
1083
- };
1084
- const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
1085
- const context = await input.adapter.contextUsage?.().catch(() => undefined);
1086
- const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1087
- const sessionId = input.adapter.sessionId();
1088
- const countDb = new WorkflowDb(cwd);
1089
- let compactionCount = 0;
1090
- try {
1091
- compactionCount = countDb.successfulCompactionCount(runId, "builder", input.adapter.sessionRef?.() ?? sessionId);
1092
- }
1093
- finally {
1094
- countDb.close();
1095
- }
1096
- const reason = `live settings revision ${input.next.settings_revision} changed Builder provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
1097
- const transfer = await new HandoffService(cwd).transfer({
1098
- runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1099
- roleState: {
1100
- settingsFrom: input.current, settingsTo: input.next,
1101
- frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1102
- ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1103
- },
1104
- ...(nativeUsage ? { sessionUsage: {
1105
- version: 1, runId, role: "builder", provider: input.adapter.agent,
1106
- providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1107
- cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1108
- cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1109
- } } : {}),
1110
- compactionCount, compactMaximum: input.next.compact_maximum,
1111
- resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
1112
- }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
1113
- log.write("handoff-transfer", {
1114
- role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1115
- predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1116
- acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1117
- });
1118
- if (input.adapter instanceof ContinuityAdapter) {
1119
- await input.adapter.adoptValidatedSuccessor(transfer.successor);
1120
- return input.adapter;
1121
- }
1122
- await input.adapter.close().catch(() => { });
1123
- return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
1124
- };
1125
- const createRawQa = async (qaCwd, sessionRef) => {
1126
- // Isolated QA conversations are intentionally disposable. Resolve live
1127
- // QA settings for each one so a threshold-only update applies to the
1128
- // very next review instead of waiting for a nonexistent session reuse.
1129
- const qaSettings = liveRoleSettings("qa", resolvedContinuitySettings("qa"));
1130
- const ready = await ensureRuntimeReadyForCommand(qaCwd, qaSettings.make, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1131
- label: "live QA settings",
1132
- onTrace: trace => log.write("runtime-readiness", { ...trace, role: "qa" }),
1133
- yes: true,
1134
- allowSwitch: false,
1135
- model: qaSettings.model === "default" ? undefined : qaSettings.model,
1136
- });
1137
- const qaPolicy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
1138
- const metadata = resolveQaRuntimeMetadata(qaCwd, qaSettings);
1139
- const adapterOpts = {
1140
- cwd: qaCwd,
1141
- configRoot: cwd,
1142
- sessionRole: "qa",
1143
- sessionStream: "qa",
1144
- runtimeExecutable: ready.executable,
1145
- runtimePhase: "qa",
1146
- preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1147
- rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
1148
- compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
1149
- shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
1150
- turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
1151
- model: qaSettings.model === "default" ? undefined : qaSettings.model,
1152
- ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
1153
- sessionGeneration: sessionRef?.generation ?? 0,
1154
- workspaceIdentity: captureWorkspaceIdentity(qaCwd),
1155
- permission: createPermissionHandler(qaPolicy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
1156
- onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
1157
- onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
1158
- effort: explicitEffort(qaSettings.reasoning),
1159
- fast: qaSettings.fast,
1160
- sandboxMode: "read-only",
1161
- systemPromptAppend: metadata.effectiveRoleInstructions,
1162
- preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
1163
- autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
1164
- allowAutoCompactionSetupTurn: false,
1165
- observer: activeObserver,
1166
1122
  };
1167
- const created = qaSettings.make === "codex"
1168
- ? new CodexAdapter(adapterOpts)
1169
- : await ClaudeAdapter.create(adapterOpts);
1170
- if (!created.prepareSession)
1171
- throw new Error(`QA runtime ${qaSettings.make} cannot establish a durable session identity before review`);
1172
- await created.prepareSession();
1173
- await prepareNativeAutoCompaction(created);
1174
- qaRuntimeMetadata.set(created, metadata);
1175
- return created;
1176
- };
1177
- const createRecoveringQa = async (qaCwd, sessionRef) => {
1178
- const initial = await createRawQa(qaCwd, sessionRef);
1179
- const recovering = new RecoveringAdapter({
1180
- initial,
1181
- runtime: qaAgent,
1182
- label: "QA turn",
1183
- observer: activeObserver,
1184
- // QA retries and provider switches must cross the explicit durable
1185
- // QA recovery boundary; an adapter-local replay would reuse one turn
1186
- // intent for two different provider executions.
1187
- enabled: false,
1188
- allowSwitch: false,
1189
- recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
1190
- const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1191
- label: "QA recovery",
1192
- allowSwitch: false,
1193
- model: nextRuntime === qaAgent ? qaModel : undefined,
1194
- });
1195
- qaAgent = nextReady.runtime;
1196
- qaModel = nextReady.model;
1197
- qaExecutable = nextReady.executable;
1198
- if (resumeSessionId && !resumeRef) {
1199
- const availability = {
1200
- version: 1,
1201
- status: "unknown",
1202
- checkedAt: new Date().toISOString(),
1203
- reason: "legacy-unscoped",
1204
- detail: "QA retry cannot authorize an exact provider resume from a raw session ID",
1205
- };
1206
- throw new SessionUnavailableError({
1207
- runtime: nextRuntime,
1208
- phase: "preflight",
1209
- dispatchState: "not-sent",
1210
- executable: nextReady.executable,
1211
- cwd: qaCwd,
1212
- diagnostics: availability.detail,
1213
- availability,
1214
- });
1215
- }
1216
- return createRawQa(qaCwd, resumeRef);
1217
- },
1218
- });
1219
- const metadata = qaRuntimeMetadata.get(initial);
1220
- if (metadata)
1221
- qaRuntimeMetadata.set(recovering, metadata);
1222
- return recovering;
1223
- };
1224
- const decorateQa = (adapter, qaCwd, sessionId) => {
1225
- const metadata = qaRuntimeMetadata.get(adapter);
1226
- const qaSettings = frozenQaRuntimeSettings(adapter, metadata);
1227
- const continuous = activeContinuityRunId ? new ContinuityAdapter({
1228
- adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
1229
- durableSingleTurn: true,
1230
- authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
1231
- createSuccessor: () => createRecoveringQa(qaCwd),
1232
- replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
1123
+ const continuousBuilder = (adapter, builderCwd, runId, settings = resolvedContinuitySettings("builder"), replaceRecoveryLeaseAfterCheckpoint = false) => new ContinuityAdapter({
1124
+ adapter,
1125
+ projectDir: cwd,
1126
+ runId,
1127
+ role: "builder",
1128
+ settings,
1129
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settings.settings_revision,
1130
+ createSuccessor: () => createRecoveringBuilder(builderCwd),
1131
+ replaceRecoveryLeaseAfterCheckpoint,
1233
1132
  recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
1234
- const effectiveSettings = liveRoleSettings("qa", qaSettings);
1133
+ const effectiveSettings = liveRoleSettings("builder", settings);
1235
1134
  const context = await predecessor.contextUsage?.().catch(() => undefined);
1236
1135
  const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1237
- const predecessorSessionId = predecessor.sessionId();
1136
+ const sessionId = predecessor.sessionId();
1238
1137
  const recoveryDb = new WorkflowDb(cwd);
1239
1138
  let compactionCount = 0;
1240
1139
  try {
1241
- compactionCount = recoveryDb.successfulCompactionCount(activeContinuityRunId, "qa", predecessor.sessionRef?.() ?? predecessorSessionId);
1140
+ compactionCount = recoveryDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
1242
1141
  }
1243
1142
  finally {
1244
1143
  recoveryDb.close();
1245
1144
  }
1246
1145
  const transfer = await new HandoffService(cwd).transfer({
1247
- runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId, predecessorSessionRef: predecessor.sessionRef?.(),
1146
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: predecessor.sessionRef?.(),
1248
1147
  allowNonCurrentContinuity: true,
1249
1148
  roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
1250
1149
  ...(nativeUsage ? { sessionUsage: {
1251
- version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1252
- providerSessionId: predecessorSessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1150
+ version: 1, runId, role: "builder", provider: predecessor.agent,
1151
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1253
1152
  cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1254
1153
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1255
1154
  } } : {}),
1256
1155
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1257
1156
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1258
- }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
1157
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
1259
1158
  log.write("handoff-transfer", {
1260
- role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1159
+ role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1261
1160
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1262
1161
  acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1263
1162
  });
1264
1163
  return transfer.successor;
1265
1164
  },
1266
- }) : adapter;
1267
- const decorated = new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1268
- role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1269
- phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1270
- settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
1271
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
1272
- return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "qa", active.sessionRef?.() ?? active.sessionId()) : 0;
1273
- }
1274
- finally {
1275
- db.close();
1276
- } },
1277
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1278
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1279
- }
1280
- finally {
1281
- db.close();
1282
- } },
1283
- }), () => {
1284
- const active = activeBuilderForStatus?.();
1285
- if (active)
1286
- liveStatusReporter?.updateState({
1287
- role: "builder", provider: active.agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1288
- phase: "builder work session", sessionTransition: "builder session", adapter: active,
1289
- settingsRevision, displaySessionCost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1290
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
1291
- return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "builder", active.sessionRef?.() ?? active.sessionId()) : 0;
1292
- }
1293
- finally {
1294
- db.close();
1295
- } },
1296
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1297
- return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1298
- }
1299
- finally {
1300
- db.close();
1301
- } },
1302
- });
1303
- });
1304
- qaRuntimeMetadata.set(continuous, metadata);
1305
- qaRuntimeMetadata.set(decorated, metadata);
1306
- return decorated;
1307
- };
1308
- const describeQaHandle = (adapter, _qaCwd, handoffReceipt) => describeQaRuntimeHandle(adapter, qaRuntimeMetadata.get(adapter), handoffReceipt);
1309
- const qaV2SessionRef = (ref) => {
1310
- if (ref.role !== "qa" || ref.stream !== "qa" || !ref.sessionId || !ref.cwd || !ref.configRoot
1311
- || /^(?:unavailable|unknown)$/i.test(ref.sessionId.trim()))
1312
- throw new Error("QA handoff receipt has an invalid scoped session identity");
1313
- return {
1314
- version: 2, provider: ref.provider, sessionId: ref.sessionId, role: "qa", stream: "qa",
1315
- generation: ref.generation, cwd: ref.cwd, configRoot: ref.configRoot,
1316
- createdAt: ref.createdAt, validatedAt: ref.validatedAt ?? new Date().toISOString(),
1317
- };
1318
- };
1319
- const qaV2HandoffReceipt = (receipt, recovery, confinementDigest) => {
1320
- if (!receipt.predecessorSessionRef)
1321
- throw new Error("QA recovery handoff has no scoped predecessor identity");
1322
- const receiptDb = new WorkflowDb(cwd);
1323
- try {
1324
- const head = receiptDb.qaTicketHead(recovery.runId, recovery.ticketId);
1325
- if (!head.reviewBasisDigest || !head.sourceStateDigest)
1326
- throw new Error("QA recovery handoff has no durable predecessor review basis/source state");
1327
- const resources = receipt.resources.map((resource) => {
1328
- if (!resource.path || !resource.purpose || resource.bytes === undefined || !resource.mediaType)
1329
- throw new Error(`QA recovery handoff resource ${resource.label} lacks complete inventory metadata`);
1330
- return { label: resource.label, digest: resource.digest, authoritative: resource.authoritative,
1331
- requiredForRecovery: resource.requiredForRecovery ?? false, mediaType: resource.mediaType,
1332
- path: resource.path, purpose: resource.purpose, bytes: resource.bytes };
1333
- });
1334
- const base = {
1335
- version: 2, runId: recovery.runId, ticketId: recovery.ticketId, qaRevision: head.revision,
1336
- sourceStateDigest: recovery.reviewedStateDigest, predecessorSourceStateDigest: head.sourceStateDigest,
1337
- reviewBasisDigest: head.reviewBasisDigest, requiresFullReview: true, confinementDigest,
1338
- predecessor: qaV2SessionRef(receipt.predecessorSessionRef), successor: qaV2SessionRef(receipt.successorSessionRef),
1339
- manifestDigest: receipt.manifestDigest, continuityCheckpointDigest: receipt.continuityCheckpointDigest,
1340
- acceptanceCheckpointDigest: receipt.acceptanceCheckpointDigest, packetDigest: recovery.packetDigest,
1341
- inventoryDigest: qaDigest("handoff-inventory", resources), resources, acceptedAt: receipt.acceptedAt,
1342
- };
1343
- const completed = { ...base, operationId: qaDigest("qa-handoff-operation", base) };
1344
- receiptDb.recordQaHandoffReceipt(completed);
1345
- return completed;
1346
- }
1347
- finally {
1348
- receiptDb.close();
1349
- }
1350
- };
1351
- const createQa = async (qaCwd, sessionId) => {
1352
- const recoveryMode = String(opts.recoveryMode ?? "");
1353
- // A disposable QA cwd is never eligible for exact reuse, even when a
1354
- // compatibility caller still supplies the previous raw ID.
1355
- const effectiveSessionId = undefined;
1356
- void sessionId;
1357
- let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1358
- let explicitRecoveryAccepted = false;
1359
- let handoffReceipt = { kind: "initial" };
1360
- if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1361
- const service = new HandoffService(cwd);
1362
- const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1363
- const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1364
- if (!accepted.acceptanceReceipt)
1365
- throw new Error("QA recovery handoff did not produce a scoped acceptance receipt");
1366
- adapter = accepted.successor;
1367
- pendingHandoffGeneration = undefined;
1368
- explicitRecoveryAccepted = true;
1369
- const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, typeof opts.ticket === "string" ? opts.ticket : undefined);
1370
- if (!packet)
1371
- throw new Error("accepted QA recovery handoff has no current durable packet");
1372
- const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
1373
- handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1374
- runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
1375
- reviewedStateDigest: packet.manifest.reviewedStateDigest,
1376
- }, confinement.digest) };
1377
- log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1378
- }
1379
- // A new disposable QA worktree always means a new provider session.
1380
- // Transfer cumulative QA state by accepting a durable handoff in that
1381
- // fresh session; never attach the old session in the new /tmp cwd.
1382
- if (!explicitRecoveryAccepted && activeContinuityRunId && recoveryMode !== "fresh-recovery-only") {
1383
- const stateDb = new WorkflowDb(cwd);
1384
- const priorLease = stateDb.roleMutationLease(activeContinuityRunId, "qa");
1385
- const head = stateDb.continuityHead(activeContinuityRunId, "qa");
1386
- const checkpoint = stateDb.latestContinuityCheckpoint(activeContinuityRunId, "qa");
1387
- const compactionCount = priorLease
1388
- ? stateDb.successfulCompactionCount(activeContinuityRunId, "qa", priorLease.sessionRef ?? priorLease.providerSessionId)
1389
- : 0;
1390
- stateDb.close();
1391
- if (priorLease) {
1392
- if (!head || head.state !== "current" || !checkpoint) {
1393
- await adapter.close().catch(() => { });
1394
- throw new ContinuityRecoveryRequiredError(activeContinuityRunId, "qa", `cannot transfer cumulative QA state from a ${head?.state ?? "missing"} checkpoint`);
1395
- }
1396
- const service = new HandoffService(cwd);
1397
- const staged = service.stage({
1398
- runId: activeContinuityRunId,
1399
- role: "qa",
1400
- reason: "new disposable QA snapshot requires a fresh location-scoped provider session",
1401
- predecessorSessionId: priorLease.providerSessionId,
1402
- predecessorSessionRef: priorLease.sessionRef,
1403
- roleState: { predecessorGeneration: priorLease.generation, successorSnapshotCwd: qaCwd },
1404
- compactionCount,
1405
- compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1406
- });
1407
- const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1408
- if (!accepted.acceptanceReceipt)
1409
- throw new Error("QA snapshot handoff did not produce a scoped acceptance receipt");
1410
- adapter = accepted.successor;
1411
- // This continuity transfer precedes construction of a ticket/source
1412
- // review basis, so it is not a QA-report recovery receipt.
1413
- handoffReceipt = { kind: "initial" };
1414
- log.write("handoff-transfer", {
1415
- role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1416
- predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
1417
- acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest,
1418
- });
1419
- }
1420
- }
1421
- const decorated = decorateQa(adapter, qaCwd, effectiveSessionId);
1422
- return describeQaHandle(decorated, qaCwd, handoffReceipt);
1423
- };
1424
- const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1425
- const context = await input.adapter.contextUsage?.().catch(() => undefined);
1426
- const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1427
- const sessionId = input.adapter.sessionId();
1428
- const countDb = new WorkflowDb(cwd);
1429
- let compactionCount = 0;
1430
- try {
1431
- compactionCount = countDb.successfulCompactionCount(runId, "qa", input.adapter.sessionRef?.() ?? sessionId);
1432
- }
1433
- finally {
1434
- countDb.close();
1435
- }
1436
- const reason = `live settings revision ${input.next.settings_revision} changed QA provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
1437
- const transfer = await new HandoffService(cwd).transfer({
1438
- runId, role: "qa", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1439
- roleState: {
1440
- settingsFrom: input.current, settingsTo: input.next,
1441
- frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1442
- ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1443
- },
1444
- ...(nativeUsage ? { sessionUsage: {
1445
- version: 1, runId, role: "qa", provider: input.adapter.agent,
1446
- providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1447
- cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1448
- cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1449
- } } : {}),
1450
- compactionCount, compactMaximum: input.next.compact_maximum,
1451
- resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1452
- }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
1453
- log.write("handoff-transfer", {
1454
- role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1455
- predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1456
- acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1457
- });
1458
- await input.adapter.close().catch(() => { });
1459
- return decorateQa(transfer.successor, qaCwd);
1460
- };
1461
- const qaSessionBoundary = async (handle, frozenAction, strategy, qaCwd, recovery) => {
1462
- const adapter = handle.adapter;
1463
- if (!activeContinuityRunId)
1464
- throw new Error("QA session boundary requires an active durable run");
1465
- const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1466
- let acceptedBoundary;
1467
- const controller = RoleSessionController.managed({
1468
- projectDir: cwd,
1469
- runId: activeContinuityRunId,
1470
- role: "qa",
1471
- initialSettings: qaSettings,
1472
- historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
1473
- readSettings: () => liveRoleSettings("qa", qaSettings),
1474
- settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1475
- settingsAdopted: (_settings, active) => {
1476
- const actual = frozenQaRuntimeSettings(active, qaRuntimeMetadata.get(active));
1477
- liveStatusReporter?.updateState({
1478
- role: "qa", provider: active.agent, model: actual.model, reasoning: actual.reasoning, fast: actual.fast,
1479
- adapter: active, settingsRevision: actual.settings_revision, displaySessionCost: actual.display_session_cost,
1480
- sessionTransition: "adopted live settings",
1481
- });
1482
- },
1483
- report: (event) => {
1484
- currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1485
- },
1486
- handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1165
+ handleHandoffRequest: async (output, predecessor, frozenAction) => {
1166
+ const request = parseBuilderHandoffRequest(output);
1167
+ if (!request)
1168
+ return undefined;
1169
+ const effectiveSettings = liveRoleSettings("builder", settings);
1170
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
1487
1171
  const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1172
+ const sessionId = predecessor.sessionId();
1173
+ const countDb = new WorkflowDb(cwd);
1174
+ let compactionCount = 0;
1175
+ try {
1176
+ const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
1177
+ countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
1178
+ countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
1179
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
1180
+ }
1181
+ finally {
1182
+ countDb.close();
1183
+ }
1488
1184
  const transfer = await new HandoffService(cwd).transfer({
1489
- runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1490
- roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample,
1491
- ...(recovery ? { recoveryPacketDigest: recovery.packetDigest, reviewedStateDigest: recovery.reviewedStateDigest } : {}) },
1185
+ runId,
1186
+ role: "builder",
1187
+ reason: request.reason,
1188
+ predecessorSessionId: sessionId,
1189
+ predecessorSessionRef: predecessor.sessionRef?.(),
1190
+ requestedByBuilder: true,
1191
+ roleState: {
1192
+ ...request.roleState,
1193
+ frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
1194
+ ...(context ? { occupancy: context } : { occupancy: "unavailable" }),
1195
+ },
1492
1196
  ...(nativeUsage ? { sessionUsage: {
1493
- version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1494
- providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1197
+ version: 1, runId, role: "builder", provider: predecessor.agent,
1198
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1495
1199
  cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1496
1200
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1497
1201
  } } : {}),
1498
- compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1499
- resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.map((resource) => ({
1500
- label: resource.label, digest: resource.digest, authoritative: true, requiredForRecovery: resource.requiredForRecovery,
1501
- mediaType: resource.mediaType, path: resource.path, purpose: resource.purpose, bytes: resource.bytes,
1502
- })) : [])],
1503
- }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1504
- if (!transfer.acceptanceReceipt || !recovery)
1505
- throw new Error("QA recovery boundary did not produce a complete receipt context");
1506
- const successor = decorateQa(transfer.successor, qaCwd);
1507
- acceptedBoundary = acceptQaRuntimeHandoff(describeQaHandle(successor, qaCwd, { kind: "initial" }), (confinementDigest) => qaV2HandoffReceipt(transfer.acceptanceReceipt, recovery, confinementDigest));
1508
- log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1509
- await predecessor.close().catch(() => { });
1510
- return successor;
1202
+ compactionCount,
1203
+ compactMaximum: effectiveSettings.compact_maximum,
1204
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1205
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
1206
+ log.write("handoff-transfer", {
1207
+ requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
1208
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1209
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1210
+ });
1211
+ return transfer.successor;
1511
1212
  },
1512
1213
  });
1513
- const next = (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1514
- if (!acceptedBoundary || acceptedBoundary.handle.adapter !== next)
1515
- throw new Error("QA recovery boundary did not produce a validated acceptance receipt for its active successor");
1516
- return acceptedBoundary;
1517
- };
1518
- const observeQaNativeCompactions = async (adapter) => {
1519
- if (!activeContinuityRunId)
1520
- return;
1521
- const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1522
- const controller = new ThresholdCompactionController({
1523
- projectDir: cwd,
1524
- runId: activeContinuityRunId,
1525
- role: "qa",
1526
- initialSettings: qaSettings,
1527
- historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
1528
- });
1529
- await controller.observeNativeCompactions(adapter);
1530
- };
1531
- const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner, () => activeObserver, () => activeContinuityRunId ?? invocationRunId);
1532
- const qaReportRecovery = createQaReportRecoveryHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, () => activeObserver);
1533
- if (branchMode) {
1534
- const ticketsConfig = loadTicketsConfig(cwd);
1535
- const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
1536
- const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
1537
- if (baseWorktreePolicy === "skip") {
1538
- const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
1539
- if (projection?.expectedChanges.length) {
1540
- console.log(`foreman: preserved expected in-progress work: ${projection.expectedChanges.join(", ")}`);
1541
- }
1542
- if (projection?.unexpectedChanges.length) {
1543
- console.warn("foreman: warning: unexpected changes may conflict with recovery; recovery is still allowed:");
1544
- for (const item of projection.unexpectedChanges)
1545
- console.warn(` ${item.path}: ${item.risk}`);
1546
- console.warn("foreman: recommended action: preserve or move overlapping changes before continuing");
1214
+ const createBuilder = async (builderCwd, sessionId, sessionRef) => {
1215
+ const recoveryMode = String(opts.recoveryMode ?? "");
1216
+ const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
1217
+ let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
1218
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
1219
+ const service = new HandoffService(cwd);
1220
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1221
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
1222
+ adapter = accepted.successor;
1223
+ pendingHandoffGeneration = undefined;
1224
+ log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1547
1225
  }
1548
- }
1549
- else {
1226
+ const continuous = activeContinuityRunId ? continuousBuilder(adapter, builderCwd, activeContinuityRunId, undefined, String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role !== "qa") : adapter;
1227
+ if (continuous instanceof ContinuityAdapter)
1228
+ await continuous.validateFreshRecovery();
1229
+ return continuous;
1230
+ };
1231
+ const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
1232
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
1233
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1234
+ const sessionId = input.adapter.sessionId();
1235
+ const countDb = new WorkflowDb(cwd);
1236
+ let compactionCount = 0;
1550
1237
  try {
1551
- ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
1238
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", input.adapter.sessionRef?.() ?? sessionId);
1552
1239
  }
1553
- catch (err) {
1554
- fail(err instanceof Error ? err.message : String(err));
1555
- }
1556
- }
1557
- if (branchDefaults.createReview && branchDefaults.reviewProvider) {
1558
- await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
1559
- }
1560
- const ready = await ensureRuntimeReadyForCommand(cwd, agent, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1561
- label: "start",
1562
- yes: Boolean(opts.yes),
1563
- allowSwitch: !(opts.resume || opts.continue),
1564
- model,
1565
- });
1566
- agent = ready.runtime;
1567
- model = ready.model;
1568
- agentExecutable = ready.executable;
1569
- const ticketPaths = resolveTicketPaths(ticketsConfig, cwd);
1570
- const tickets = loadTickets(ticketPaths.tickets);
1571
- const db = new StateDb(ticketPaths.stateDb);
1572
- const states = db.getAllStates();
1573
- db.close();
1574
- const ticketById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
1575
- const resumeSessionByTicket = new Map();
1576
- let auditDependencyCount;
1577
- let plan;
1578
- if (continueTickets.length > 0) {
1579
- const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
1580
- const sessionByTicket = new Map(sessions.map((session) => [session.ticket, session]));
1581
- const nodes = [];
1582
- for (const ticketId of continueTickets) {
1583
- const session = sessionByTicket.get(ticketId);
1584
- if (!session) {
1585
- fail([
1586
- `no resumable branch session found for ticket ${ticketId}`,
1587
- branchContinueTicketHelp(cwd),
1588
- ].join("\n"));
1589
- }
1590
- const ticket = ticketById.get(ticketId);
1591
- if (!ticket)
1592
- fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
1593
- const sessionId = opts.resume ?? session.sessionId;
1594
- let sessionRef = session.sessionRef;
1595
- if (sessionRef && sessionRef.sessionId !== sessionId)
1596
- sessionRef = undefined;
1597
- if (!sessionRef && recoveryRecord)
1598
- sessionRef = buildRunSessionBinding(recoveryRecord, "builder", sessionId);
1599
- if (!sessionRef && sessionId === session.sessionId)
1600
- sessionRef = legacyBranchSessionRef(session, cwd, agent);
1601
- if (!sessionRef)
1602
- fail(`session ${sessionId} for ${ticketId} has no unique location-scoped binding; use fresh-with-handoff recovery`);
1603
- resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId, sessionRef });
1604
- nodes.push({
1605
- ticket,
1606
- branch: session.branch,
1607
- baseRef: session.base,
1608
- baseBranch: session.base,
1609
- dependencies: [],
1610
- depth: 1,
1611
- worktreePath: session.worktreePath,
1612
- deliveryUnitId: session.deliveryUnitId,
1613
- deliveryUnitFinal: session.deliveryUnitFinal,
1614
- });
1240
+ finally {
1241
+ countDb.close();
1615
1242
  }
1616
- plan = {
1617
- baseRef: nodes[0]?.baseRef ?? (opts.base ?? currentGitRef(cwd)),
1618
- nodes,
1619
- issues: [],
1243
+ const reason = `live settings revision ${input.next.settings_revision} changed Builder provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
1244
+ const transfer = await new HandoffService(cwd).transfer({
1245
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1246
+ roleState: {
1247
+ settingsFrom: input.current, settingsTo: input.next,
1248
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1249
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1250
+ },
1251
+ ...(nativeUsage ? { sessionUsage: {
1252
+ version: 1, runId, role: "builder", provider: input.adapter.agent,
1253
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1254
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1255
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1256
+ } } : {}),
1257
+ compactionCount, compactMaximum: input.next.compact_maximum,
1258
+ resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
1259
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
1260
+ log.write("handoff-transfer", {
1261
+ role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1262
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1263
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1264
+ });
1265
+ if (input.adapter instanceof ContinuityAdapter) {
1266
+ await input.adapter.adoptValidatedSuccessor(transfer.successor);
1267
+ return input.adapter;
1268
+ }
1269
+ await input.adapter.close().catch(() => { });
1270
+ return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
1271
+ };
1272
+ const createRawQa = async (qaCwd, sessionRef) => {
1273
+ // Isolated QA conversations are intentionally disposable. Resolve live
1274
+ // QA settings for each one so a threshold-only update applies to the
1275
+ // very next review instead of waiting for a nonexistent session reuse.
1276
+ const qaSettings = liveRoleSettings("qa", resolvedContinuitySettings("qa"));
1277
+ const ready = await ensureBuildRuntimeReadyForCommand(qaCwd, qaSettings.make, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1278
+ label: "live QA settings",
1279
+ onTrace: trace => log.write("runtime-readiness", { ...trace, role: "qa" }),
1280
+ yes: true,
1281
+ allowSwitch: false,
1282
+ model: qaSettings.model === "default" ? undefined : qaSettings.model,
1283
+ });
1284
+ const qaPolicy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
1285
+ const metadata = resolveQaRuntimeMetadata(qaCwd, qaSettings);
1286
+ const adapterOpts = {
1287
+ cwd: qaCwd,
1288
+ configRoot: cwd,
1289
+ sessionRole: "qa",
1290
+ sessionStream: "qa",
1291
+ runtimeExecutable: ready.executable,
1292
+ runtimePhase: "qa",
1293
+ preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1294
+ rpcTimeoutMs: frozenPolicy.runtimeDeadlines?.rpc_ms,
1295
+ compactionTimeoutMs: frozenPolicy.runtimeDeadlines?.compaction_ms,
1296
+ shutdownTimeoutMs: frozenPolicy.runtimeDeadlines?.shutdown_ms,
1297
+ turnDeadlineMs: frozenPolicy.runtimeDeadlines?.turn_ms,
1298
+ model: qaSettings.model === "default" ? undefined : qaSettings.model,
1299
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
1300
+ sessionGeneration: sessionRef?.generation ?? 0,
1301
+ workspaceIdentity: captureWorkspaceIdentity(qaCwd),
1302
+ permission: createPermissionHandler(qaPolicy, log, { durable: () => ({ projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, ticketId: activeObserver?.context().ticketId }) }),
1303
+ onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
1304
+ onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace }),
1305
+ effort: explicitEffort(qaSettings.reasoning),
1306
+ fast: qaSettings.fast,
1307
+ sandboxMode: "read-only",
1308
+ systemPromptAppend: metadata.effectiveRoleInstructions,
1309
+ preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
1310
+ autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
1311
+ allowAutoCompactionSetupTurn: false,
1312
+ observer: activeObserver,
1620
1313
  };
1621
- }
1622
- else {
1623
- plan = buildBranchPlan(tickets, states, {
1624
- steps,
1625
- baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1626
- branchPrefix,
1627
- maxBranchDepth,
1628
- rootBaseBranches: branchDefaults.rootBaseBranches,
1629
- ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1314
+ const created = qaSettings.make === "codex"
1315
+ ? new CodexAdapter(adapterOpts)
1316
+ : await ClaudeAdapter.create(adapterOpts);
1317
+ if (!created.prepareSession)
1318
+ throw new Error(`QA runtime ${qaSettings.make} cannot establish a durable session identity before review`);
1319
+ await created.prepareSession();
1320
+ await prepareNativeAutoCompaction(created);
1321
+ qaRuntimeMetadata.set(created, metadata);
1322
+ return created;
1323
+ };
1324
+ const createRecoveringQa = async (qaCwd, sessionRef) => {
1325
+ const initial = await createRawQa(qaCwd, sessionRef);
1326
+ const recovering = new RecoveringAdapter({
1327
+ initial,
1328
+ runtime: qaAgent,
1329
+ label: "QA turn",
1330
+ observer: activeObserver,
1331
+ // QA retries and provider switches must cross the explicit durable
1332
+ // QA recovery boundary; an adapter-local replay would reuse one turn
1333
+ // intent for two different provider executions.
1334
+ enabled: false,
1335
+ allowSwitch: false,
1336
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
1337
+ const nextReady = await ensureBuildRuntimeReadyForCommand(qaCwd, nextRuntime, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1338
+ label: "QA recovery",
1339
+ allowSwitch: false,
1340
+ model: nextRuntime === qaAgent ? qaModel : undefined,
1341
+ });
1342
+ qaAgent = nextReady.runtime;
1343
+ qaModel = nextReady.model;
1344
+ qaExecutable = nextReady.executable;
1345
+ if (resumeSessionId && !resumeRef) {
1346
+ const availability = {
1347
+ version: 1,
1348
+ status: "unknown",
1349
+ checkedAt: new Date().toISOString(),
1350
+ reason: "legacy-unscoped",
1351
+ detail: "QA retry cannot authorize an exact provider resume from a raw session ID",
1352
+ };
1353
+ throw new SessionUnavailableError({
1354
+ runtime: nextRuntime,
1355
+ phase: "preflight",
1356
+ dispatchState: "not-sent",
1357
+ executable: nextReady.executable,
1358
+ cwd: qaCwd,
1359
+ diagnostics: availability.detail,
1360
+ availability,
1361
+ });
1362
+ }
1363
+ return createRawQa(qaCwd, resumeRef);
1364
+ },
1365
+ });
1366
+ const metadata = qaRuntimeMetadata.get(initial);
1367
+ if (metadata)
1368
+ qaRuntimeMetadata.set(recovering, metadata);
1369
+ return recovering;
1370
+ };
1371
+ const decorateQa = async (adapter, qaCwd, sessionId) => {
1372
+ const metadata = qaRuntimeMetadata.get(adapter);
1373
+ const qaSettings = frozenQaRuntimeSettings(adapter, metadata);
1374
+ const continuous = activeContinuityRunId ? new ContinuityAdapter({
1375
+ adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
1376
+ durableSingleTurn: true,
1377
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
1378
+ createSuccessor: () => createRecoveringQa(qaCwd),
1379
+ replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
1380
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
1381
+ const effectiveSettings = liveRoleSettings("qa", qaSettings);
1382
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
1383
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1384
+ const predecessorSessionId = predecessor.sessionId();
1385
+ const recoveryDb = new WorkflowDb(cwd);
1386
+ let compactionCount = 0;
1387
+ try {
1388
+ compactionCount = recoveryDb.successfulCompactionCount(activeContinuityRunId, "qa", predecessor.sessionRef?.() ?? predecessorSessionId);
1389
+ }
1390
+ finally {
1391
+ recoveryDb.close();
1392
+ }
1393
+ const transfer = await new HandoffService(cwd).transfer({
1394
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId, predecessorSessionRef: predecessor.sessionRef?.(),
1395
+ allowNonCurrentContinuity: true,
1396
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
1397
+ ...(nativeUsage ? { sessionUsage: {
1398
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1399
+ providerSessionId: predecessorSessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1400
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1401
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1402
+ } } : {}),
1403
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1404
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1405
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
1406
+ log.write("handoff-transfer", {
1407
+ role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1408
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1409
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1410
+ });
1411
+ return transfer.successor;
1412
+ },
1413
+ }) : adapter;
1414
+ if (continuous instanceof ContinuityAdapter)
1415
+ await continuous.validateFreshRecovery();
1416
+ const decorated = new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1417
+ role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1418
+ phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1419
+ settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
1420
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1421
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "qa", active.sessionRef?.() ?? active.sessionId()) : 0;
1422
+ }
1423
+ finally {
1424
+ db.close();
1425
+ } },
1426
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1427
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1428
+ }
1429
+ finally {
1430
+ db.close();
1431
+ } },
1432
+ }), () => {
1433
+ const active = activeBuilderForStatus?.();
1434
+ if (active)
1435
+ liveStatusReporter?.updateState({
1436
+ role: "builder", provider: active.agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1437
+ phase: "builder work session", sessionTransition: "builder session", adapter: active,
1438
+ settingsRevision, displaySessionCost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1439
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1440
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "builder", active.sessionRef?.() ?? active.sessionId()) : 0;
1441
+ }
1442
+ finally {
1443
+ db.close();
1444
+ } },
1445
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1446
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1447
+ }
1448
+ finally {
1449
+ db.close();
1450
+ } },
1451
+ });
1630
1452
  });
1631
- let auditBuilder;
1632
- let auditViewer;
1633
- const auditRunId = `audit-${stamp}`;
1634
- activeContinuityRunId = auditRunId;
1453
+ qaRuntimeMetadata.set(continuous, metadata);
1454
+ qaRuntimeMetadata.set(decorated, metadata);
1455
+ return decorated;
1456
+ };
1457
+ const describeQaHandle = (adapter, _qaCwd, handoffReceipt) => describeQaRuntimeHandle(adapter, qaRuntimeMetadata.get(adapter), handoffReceipt);
1458
+ const qaV2SessionRef = (ref) => {
1459
+ if (ref.role !== "qa" || ref.stream !== "qa" || !ref.sessionId || !ref.cwd || !ref.configRoot
1460
+ || /^(?:unavailable|unknown)$/i.test(ref.sessionId.trim()))
1461
+ throw new Error("QA handoff receipt has an invalid scoped session identity");
1462
+ return {
1463
+ version: 2, provider: ref.provider, sessionId: ref.sessionId, role: "qa", stream: "qa",
1464
+ generation: ref.generation, cwd: ref.cwd, configRoot: ref.configRoot,
1465
+ createdAt: ref.createdAt, validatedAt: ref.validatedAt ?? new Date().toISOString(),
1466
+ };
1467
+ };
1468
+ const qaV2HandoffReceipt = (receipt, recovery, confinementDigest) => {
1469
+ if (!receipt.predecessorSessionRef)
1470
+ throw new Error("QA recovery handoff has no scoped predecessor identity");
1471
+ const receiptDb = new WorkflowDb(cwd);
1635
1472
  try {
1636
- auditBuilder = await createBuilder(cwd);
1637
- auditViewer = printEvents(auditBuilder.events());
1638
- const audit = await auditBuilder.sendTurn(buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)));
1639
- if (audit.isError)
1640
- throw new Error(audit.text);
1641
- const auditDependencies = parseAuditDependencies(audit.text);
1473
+ const head = receiptDb.qaTicketHead(recovery.runId, recovery.ticketId);
1474
+ if (!head.reviewBasisDigest || !head.sourceStateDigest)
1475
+ throw new Error("QA recovery handoff has no durable predecessor review basis/source state");
1476
+ const resources = receipt.resources.map((resource) => {
1477
+ if (!resource.path || !resource.purpose || resource.bytes === undefined || !resource.mediaType)
1478
+ throw new Error(`QA recovery handoff resource ${resource.label} lacks complete inventory metadata`);
1479
+ return { label: resource.label, digest: resource.digest, authoritative: resource.authoritative,
1480
+ requiredForRecovery: resource.requiredForRecovery ?? false, mediaType: resource.mediaType,
1481
+ path: resource.path, purpose: resource.purpose, bytes: resource.bytes };
1482
+ });
1483
+ const base = {
1484
+ version: 2, runId: recovery.runId, ticketId: recovery.ticketId, qaRevision: head.revision,
1485
+ sourceStateDigest: recovery.reviewedStateDigest, predecessorSourceStateDigest: head.sourceStateDigest,
1486
+ reviewBasisDigest: head.reviewBasisDigest, requiresFullReview: true, confinementDigest,
1487
+ predecessor: qaV2SessionRef(receipt.predecessorSessionRef), successor: qaV2SessionRef(receipt.successorSessionRef),
1488
+ manifestDigest: receipt.manifestDigest, continuityCheckpointDigest: receipt.continuityCheckpointDigest,
1489
+ acceptanceCheckpointDigest: receipt.acceptanceCheckpointDigest, packetDigest: recovery.packetDigest,
1490
+ inventoryDigest: qaDigest("handoff-inventory", resources), resources, acceptedAt: receipt.acceptedAt,
1491
+ };
1492
+ const completed = { ...base, operationId: qaDigest("qa-handoff-operation", base) };
1493
+ receiptDb.recordQaHandoffReceipt(completed);
1494
+ return completed;
1495
+ }
1496
+ finally {
1497
+ receiptDb.close();
1498
+ }
1499
+ };
1500
+ const createQa = async (qaCwd, sessionId) => {
1501
+ const recoveryMode = String(opts.recoveryMode ?? "");
1502
+ // A disposable QA cwd is never eligible for exact reuse, even when a
1503
+ // compatibility caller still supplies the previous raw ID.
1504
+ const effectiveSessionId = undefined;
1505
+ void sessionId;
1506
+ let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1507
+ let explicitRecoveryAccepted = false;
1508
+ let handoffReceipt = { kind: "initial" };
1509
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1510
+ const service = new HandoffService(cwd);
1511
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1512
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1513
+ if (!accepted.acceptanceReceipt)
1514
+ throw new Error("QA recovery handoff did not produce a scoped acceptance receipt");
1515
+ adapter = accepted.successor;
1516
+ pendingHandoffGeneration = undefined;
1517
+ explicitRecoveryAccepted = true;
1518
+ const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0]);
1519
+ if (!packet)
1520
+ throw new Error("accepted QA recovery handoff has no current durable packet");
1521
+ const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
1522
+ handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
1523
+ runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
1524
+ reviewedStateDigest: packet.manifest.reviewedStateDigest,
1525
+ }, confinement.digest) };
1526
+ log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1527
+ }
1528
+ // A new disposable QA worktree always means a new provider session.
1529
+ // Transfer cumulative QA state by accepting a durable handoff in that
1530
+ // fresh session; never attach the old session in the new /tmp cwd.
1531
+ if (!explicitRecoveryAccepted && activeContinuityRunId && recoveryMode !== "fresh-recovery-only") {
1532
+ const stateDb = new WorkflowDb(cwd);
1533
+ const priorLease = stateDb.roleMutationLease(activeContinuityRunId, "qa");
1534
+ const head = stateDb.continuityHead(activeContinuityRunId, "qa");
1535
+ const checkpoint = stateDb.latestContinuityCheckpoint(activeContinuityRunId, "qa");
1536
+ const compactionCount = priorLease
1537
+ ? stateDb.successfulCompactionCount(activeContinuityRunId, "qa", priorLease.sessionRef ?? priorLease.providerSessionId)
1538
+ : 0;
1539
+ stateDb.close();
1540
+ if (priorLease) {
1541
+ if (!head || head.state !== "current" || !checkpoint) {
1542
+ await adapter.close().catch(() => { });
1543
+ throw new ContinuityRecoveryRequiredError(activeContinuityRunId, "qa", `cannot transfer cumulative QA state from a ${head?.state ?? "missing"} checkpoint`);
1544
+ }
1545
+ const service = new HandoffService(cwd);
1546
+ const staged = service.stage({
1547
+ runId: activeContinuityRunId,
1548
+ role: "qa",
1549
+ reason: "new disposable QA snapshot requires a fresh location-scoped provider session",
1550
+ predecessorSessionId: priorLease.providerSessionId,
1551
+ predecessorSessionRef: priorLease.sessionRef,
1552
+ roleState: { predecessorGeneration: priorLease.generation, successorSnapshotCwd: qaCwd },
1553
+ compactionCount,
1554
+ compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1555
+ });
1556
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1557
+ if (!accepted.acceptanceReceipt)
1558
+ throw new Error("QA snapshot handoff did not produce a scoped acceptance receipt");
1559
+ adapter = accepted.successor;
1560
+ // This continuity transfer precedes construction of a ticket/source
1561
+ // review basis, so it is not a QA-report recovery receipt.
1562
+ handoffReceipt = { kind: "initial" };
1563
+ log.write("handoff-transfer", {
1564
+ role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1565
+ predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
1566
+ acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest,
1567
+ });
1568
+ }
1569
+ }
1570
+ const decorated = await decorateQa(adapter, qaCwd, effectiveSessionId);
1571
+ return describeQaHandle(decorated, qaCwd, handoffReceipt);
1572
+ };
1573
+ const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1574
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
1575
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1576
+ const sessionId = input.adapter.sessionId();
1577
+ const countDb = new WorkflowDb(cwd);
1578
+ let compactionCount = 0;
1579
+ try {
1580
+ compactionCount = countDb.successfulCompactionCount(runId, "qa", input.adapter.sessionRef?.() ?? sessionId);
1581
+ }
1582
+ finally {
1583
+ countDb.close();
1584
+ }
1585
+ const reason = `live settings revision ${input.next.settings_revision} changed QA provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
1586
+ const transfer = await new HandoffService(cwd).transfer({
1587
+ runId, role: "qa", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1588
+ roleState: {
1589
+ settingsFrom: input.current, settingsTo: input.next,
1590
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1591
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1592
+ },
1593
+ ...(nativeUsage ? { sessionUsage: {
1594
+ version: 1, runId, role: "qa", provider: input.adapter.agent,
1595
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1596
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1597
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1598
+ } } : {}),
1599
+ compactionCount, compactMaximum: input.next.compact_maximum,
1600
+ resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1601
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
1602
+ log.write("handoff-transfer", {
1603
+ role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1604
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1605
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1606
+ });
1607
+ await input.adapter.close().catch(() => { });
1608
+ return decorateQa(transfer.successor, qaCwd);
1609
+ };
1610
+ const qaSessionBoundary = async (handle, frozenAction, strategy, qaCwd, recovery) => {
1611
+ const adapter = handle.adapter;
1612
+ if (!activeContinuityRunId)
1613
+ throw new Error("QA session boundary requires an active durable run");
1614
+ const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1615
+ let acceptedBoundary;
1616
+ const controller = RoleSessionController.managed({
1617
+ projectDir: cwd,
1618
+ runId: activeContinuityRunId,
1619
+ role: "qa",
1620
+ initialSettings: qaSettings,
1621
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, opts.recoveryMode === "fresh-recovery-only" ? undefined : recoveryRecord, activeContinuityRunId, "qa"),
1622
+ readSettings: () => liveRoleSettings("qa", qaSettings),
1623
+ settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1624
+ settingsAdopted: (_settings, active) => {
1625
+ const actual = frozenQaRuntimeSettings(active, qaRuntimeMetadata.get(active));
1626
+ liveStatusReporter?.updateState({
1627
+ role: "qa", provider: active.agent, model: actual.model, reasoning: actual.reasoning, fast: actual.fast,
1628
+ adapter: active, settingsRevision: actual.settings_revision, displaySessionCost: actual.display_session_cost,
1629
+ sessionTransition: "adopted live settings",
1630
+ });
1631
+ },
1632
+ report: (event) => {
1633
+ currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1634
+ },
1635
+ handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1636
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1637
+ const transfer = await new HandoffService(cwd).transfer({
1638
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1639
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample,
1640
+ ...(recovery ? { recoveryPacketDigest: recovery.packetDigest, reviewedStateDigest: recovery.reviewedStateDigest } : {}) },
1641
+ ...(nativeUsage ? { sessionUsage: {
1642
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1643
+ providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1644
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1645
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1646
+ } } : {}),
1647
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1648
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.map((resource) => ({
1649
+ label: resource.label, digest: resource.digest, authoritative: true, requiredForRecovery: resource.requiredForRecovery,
1650
+ mediaType: resource.mediaType, path: resource.path, purpose: resource.purpose, bytes: resource.bytes,
1651
+ })) : [])],
1652
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1653
+ if (!transfer.acceptanceReceipt || !recovery)
1654
+ throw new Error("QA recovery boundary did not produce a complete receipt context");
1655
+ const successor = await decorateQa(transfer.successor, qaCwd);
1656
+ acceptedBoundary = acceptQaRuntimeHandoff(describeQaHandle(successor, qaCwd, { kind: "initial" }), (confinementDigest) => qaV2HandoffReceipt(transfer.acceptanceReceipt, recovery, confinementDigest));
1657
+ log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1658
+ await predecessor.close().catch(() => { });
1659
+ return successor;
1660
+ },
1661
+ });
1662
+ const next = (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1663
+ if (!acceptedBoundary || acceptedBoundary.handle.adapter !== next)
1664
+ throw new Error("QA recovery boundary did not produce a validated acceptance receipt for its active successor");
1665
+ return acceptedBoundary;
1666
+ };
1667
+ const observeQaNativeCompactions = async (adapter) => {
1668
+ if (!activeContinuityRunId)
1669
+ return;
1670
+ const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
1671
+ const controller = new ThresholdCompactionController({
1672
+ projectDir: cwd,
1673
+ runId: activeContinuityRunId,
1674
+ role: "qa",
1675
+ initialSettings: qaSettings,
1676
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, opts.recoveryMode === "fresh-recovery-only" ? undefined : recoveryRecord, activeContinuityRunId, "qa"),
1677
+ });
1678
+ await controller.observeNativeCompactions(adapter);
1679
+ };
1680
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner, () => activeObserver, () => activeContinuityRunId ?? invocationRunId);
1681
+ const qaReportRecovery = createQaReportRecoveryHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, () => activeObserver);
1682
+ if (branchMode) {
1683
+ const ticketsConfig = loadTicketsConfig(cwd);
1684
+ const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
1685
+ const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
1686
+ if (baseWorktreePolicy === "skip") {
1687
+ const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
1688
+ if (projection?.expectedChanges.length) {
1689
+ console.log(`foreman: preserved expected in-progress work: ${projection.expectedChanges.join(", ")}`);
1690
+ }
1691
+ if (projection?.unexpectedChanges.length) {
1692
+ console.warn("foreman: warning: unexpected changes may conflict with recovery; recovery is still allowed:");
1693
+ for (const item of projection.unexpectedChanges)
1694
+ console.warn(` ${item.path}: ${item.risk}`);
1695
+ console.warn("foreman: recommended action: preserve or move overlapping changes before continuing");
1696
+ }
1697
+ }
1698
+ else {
1699
+ try {
1700
+ ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
1701
+ }
1702
+ catch (err) {
1703
+ fail(err instanceof Error ? err.message : String(err));
1704
+ }
1705
+ }
1706
+ if (branchDefaults.createReview && branchDefaults.reviewProvider) {
1707
+ await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
1708
+ }
1709
+ 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.`);
1710
+ const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
1711
+ label: "start",
1712
+ yes: Boolean(opts.yes),
1713
+ allowSwitch: !(opts.resume || opts.continue),
1714
+ model,
1715
+ });
1716
+ agent = ready.runtime;
1717
+ model = ready.model;
1718
+ agentExecutable = ready.executable;
1719
+ const ticketPaths = resolveTicketPaths(ticketsConfig, cwd);
1720
+ const tickets = loadTickets(ticketPaths.tickets);
1721
+ const db = new StateDb(ticketPaths.stateDb);
1722
+ const states = db.getAllStates();
1723
+ db.close();
1724
+ const ticketById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
1725
+ const resumeSessionByTicket = new Map();
1726
+ let auditDependencyCount;
1727
+ let plan;
1728
+ if (continueTickets.length > 0) {
1729
+ const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
1730
+ const sessionByTicket = new Map(sessions.map((session) => [session.ticket, session]));
1731
+ const nodes = [];
1732
+ for (const ticketId of continueTickets) {
1733
+ const session = sessionByTicket.get(ticketId);
1734
+ if (!session) {
1735
+ fail([
1736
+ `no resumable branch session found for ticket ${ticketId}`,
1737
+ branchContinueTicketHelp(cwd),
1738
+ ].join("\n"));
1739
+ }
1740
+ const ticket = ticketById.get(ticketId);
1741
+ if (!ticket)
1742
+ fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
1743
+ const sessionId = opts.resume ?? session.sessionId;
1744
+ let sessionRef = session.sessionRef;
1745
+ if (sessionRef && sessionRef.sessionId !== sessionId)
1746
+ sessionRef = undefined;
1747
+ if (!sessionRef && recoveryRecord)
1748
+ sessionRef = buildRunSessionBinding(recoveryRecord, "builder", sessionId);
1749
+ if (!sessionRef && sessionId === session.sessionId)
1750
+ sessionRef = legacyBranchSessionRef(session, cwd, agent);
1751
+ if (!sessionRef)
1752
+ fail(`session ${sessionId} for ${ticketId} has no unique location-scoped binding; use fresh-with-handoff recovery`);
1753
+ resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId, sessionRef });
1754
+ nodes.push({
1755
+ ticket,
1756
+ branch: session.branch,
1757
+ baseRef: session.base,
1758
+ baseBranch: session.base,
1759
+ dependencies: [],
1760
+ depth: 1,
1761
+ worktreePath: session.worktreePath,
1762
+ deliveryUnitId: session.deliveryUnitId,
1763
+ deliveryUnitFinal: session.deliveryUnitFinal,
1764
+ });
1765
+ }
1766
+ plan = {
1767
+ baseRef: nodes[0]?.baseRef ?? (opts.base ?? currentGitRef(cwd)),
1768
+ nodes,
1769
+ issues: [],
1770
+ };
1771
+ }
1772
+ else {
1642
1773
  plan = buildBranchPlan(tickets, states, {
1643
1774
  steps,
1644
1775
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1645
1776
  branchPrefix,
1646
1777
  maxBranchDepth,
1647
- auditDependencies,
1648
1778
  rootBaseBranches: branchDefaults.rootBaseBranches,
1649
1779
  ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1650
1780
  });
1651
- auditDependencyCount = auditDependencies.length;
1781
+ let auditBuilder;
1782
+ let auditViewer;
1783
+ const auditRunId = `audit-${stamp}`;
1784
+ activeContinuityRunId = auditRunId;
1785
+ try {
1786
+ auditBuilder = await createBuilder(cwd);
1787
+ auditViewer = printEvents(auditBuilder.events());
1788
+ const audit = await sendGraphTurn(auditBuilder, buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)), graphContext(cwd, auditBuilder.sessionRef?.()?.cwd ?? cwd, "branch-dependency-audit", plan.nodes.map(node => node.ticket.id).join(" ")), { purpose: "planning" });
1789
+ if (audit.isError)
1790
+ throw new Error(audit.text);
1791
+ const auditDependencies = parseAuditDependencies(audit.text);
1792
+ plan = buildBranchPlan(tickets, states, {
1793
+ steps,
1794
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1795
+ branchPrefix,
1796
+ maxBranchDepth,
1797
+ auditDependencies,
1798
+ rootBaseBranches: branchDefaults.rootBaseBranches,
1799
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1800
+ });
1801
+ auditDependencyCount = auditDependencies.length;
1802
+ }
1803
+ finally {
1804
+ await auditBuilder?.close().catch(() => { });
1805
+ await auditViewer?.catch(() => { });
1806
+ const auditDb = new WorkflowDb(cwd);
1807
+ try {
1808
+ if (auditDb.getRun(auditRunId))
1809
+ auditDb.transition(auditRunId, { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" });
1810
+ }
1811
+ finally {
1812
+ auditDb.close();
1813
+ }
1814
+ activeContinuityRunId = undefined;
1815
+ }
1816
+ if (deliveryRun?.unit.branch_mode === "shared") {
1817
+ plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
1818
+ const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
1819
+ const first = plan.nodes[0];
1820
+ if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
1821
+ const sessionRef = saved.sessionRef ?? createProviderSessionRef({ provider: agent, sessionId: saved.sessionId, role: "builder", stream: "builder", generation: 0, cwd: saved.worktreePath, configRoot: cwd, workspaceIdentity: captureWorkspaceIdentity(saved.worktreePath), ticketId: saved.ticket, deliveryUnitId: saved.unitId, source: "legacy-inferred" });
1822
+ resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId, sessionRef });
1823
+ }
1824
+ }
1825
+ if (selectedStacks.length && deliveryConfig) {
1826
+ plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
1827
+ }
1828
+ else if (!deliveryRun) {
1829
+ const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
1830
+ if (branchPolicy)
1831
+ plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
1832
+ }
1833
+ if (recoveryRecord && opts.resume && plan.nodes[0]) {
1834
+ const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
1835
+ if (prior) {
1836
+ const requested = String(opts.resume);
1837
+ const sessionRef = prior.sessionRef?.sessionId === requested ? prior.sessionRef : buildRunSessionBinding(recoveryRecord, "builder", requested) ?? (prior.sessionId === requested ? legacyBranchSessionRef(prior, cwd, agent) : undefined);
1838
+ if (!sessionRef)
1839
+ fail(`session ${requested} has no stored scope for ${plan.nodes[0].ticket.id}`);
1840
+ resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: requested, sessionRef });
1841
+ }
1842
+ }
1843
+ }
1844
+ const branchPresentation = presentBranchPlan(plan, {
1845
+ stacked: selectedStacks.length > 0,
1846
+ resumed: continueTickets.length > 0 || Boolean(recoveryRecord),
1847
+ });
1848
+ log.write("branch-plan", {
1849
+ ...branchPlanLogMetadata(plan, branchPresentation),
1850
+ ...(auditDependencyCount === undefined ? {} : { auditDependencyCount }),
1851
+ ...(continueTickets.length > 0 || recoveryRecord ? { resume: true } : {}),
1852
+ });
1853
+ console.log(branchPresentation.banner);
1854
+ console.log(`foreman: project ${cwd}`);
1855
+ console.log(`foreman: base ${plan.baseRef}`);
1856
+ console.log(`foreman: log ${logPath}\n`);
1857
+ if (deliveryRun) {
1858
+ console.log(`foreman: delivery unit ${deliveryRun.unit.id} — ${deliveryRun.completed}/${deliveryRun.unit.tickets.length} complete, ${deliveryRun.remaining.length} remaining`);
1859
+ if (deliveryRun.unit.branch_mode === "shared" && steps < deliveryRun.remaining.length)
1860
+ console.log(`foreman: --steps ${steps} completes ${steps} ticket(s) in this ${deliveryRun.remaining.length}-ticket remainder; branch/session is preserved and PR completion is deferred`);
1861
+ if (deliveryRun.unit.dependency_mode === "stack")
1862
+ console.log("foreman: stacked delivery — dependent branches must be rebased or retargeted after prerequisite merges");
1863
+ }
1864
+ for (const node of plan.nodes) {
1865
+ console.log(` ${node.ticket.id} ${node.branch} base=${node.baseBranch}`);
1866
+ }
1867
+ for (const issue of plan.issues) {
1868
+ console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
1869
+ }
1870
+ console.log();
1871
+ for (const [ticketId, session] of resumeSessionByTicket) {
1872
+ const ref = session.sessionRef;
1873
+ if (!ref)
1874
+ fail(`session ${session.sessionId} for ${ticketId} has no location-scoped binding`);
1875
+ if (ref.provider !== agent)
1876
+ fail(`session ${ref.sessionId} belongs to ${ref.provider}, but this recovery selected ${agent}`);
1877
+ const availability = await resolveProviderSessionAvailability(ref, {
1878
+ cwd: session.worktreePath,
1879
+ configRoot: cwd,
1880
+ workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
1881
+ runtimeExecutable: agentExecutable,
1882
+ });
1883
+ if (availability.status !== "available" || !availability.sessionRef) {
1884
+ fail(`exact session ${ref.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The preserved worktree remains recoverable; choose fresh-with-handoff recovery.`);
1885
+ }
1886
+ resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1652
1887
  }
1653
- finally {
1654
- await auditBuilder?.close().catch(() => { });
1655
- await auditViewer?.catch(() => { });
1656
- const auditDb = new WorkflowDb(cwd);
1888
+ const capturedBranchBuilder = {
1889
+ role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
1890
+ make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1891
+ session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1892
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1893
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 65,
1894
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
1895
+ };
1896
+ const capturedBranchQa = {
1897
+ role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
1898
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1899
+ display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1900
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 65,
1901
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1902
+ };
1903
+ const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1904
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1905
+ runId: invocationRunId,
1906
+ builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1907
+ repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1908
+ frozenPolicy, autonomyProfile, qaEnabled,
1909
+ runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1910
+ });
1911
+ activeContinuityRunId = masterRun.runId;
1912
+ if (requiresBuildApproval(cwd, plan.nodes.map(node => node.ticket.id), autoApprovePlanUpdates, approvalConsequences) && plan.issues.every((issue) => !issue.blocking)) {
1913
+ 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({
1914
+ message: branchPresentation.prompt,
1915
+ options: [
1916
+ { value: "proceed", label: "Proceed" },
1917
+ { value: "cancel", label: "Cancel" },
1918
+ ],
1919
+ }) });
1920
+ if (isCancel(action) || action === "cancel") {
1921
+ const cancelled = action === "cancel";
1922
+ if (cancelled) {
1923
+ const decisions = new WorkflowDb(cwd);
1924
+ try {
1925
+ decisions.cancelPendingHumanDecisions(invocationRunId, "User cancelled build; pending decisions superseded");
1926
+ }
1927
+ finally {
1928
+ decisions.close();
1929
+ }
1930
+ }
1931
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, cancelled ? "cancelled" : "input-paused", { status: cancelled ? "cancelled" : "recoverable" }), cancelled ? "cancelled" : "recoverable");
1932
+ activeObserver?.finish(cancelled ? "cancelled" : "paused", { checkpoint: cancelled ? "user-cancel" : "prompt-eof-or-cancel" });
1933
+ console.log("ai-foreman: cancelled");
1934
+ process.exit(0);
1935
+ }
1936
+ }
1937
+ for (const node of plan.nodes)
1938
+ admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
1939
+ masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
1940
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1941
+ let qaProtocolResumeTicket;
1942
+ if (recoveryRecord && !qaResumedRecovery) {
1943
+ const protocolDb = new WorkflowDb(cwd);
1657
1944
  try {
1658
- if (auditDb.getRun(auditRunId))
1659
- auditDb.transition(auditRunId, { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" });
1945
+ const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
1946
+ if (pendingHeads.length > 1 && !requestedTicketIds.length)
1947
+ fail(`run ${masterRun.runId} has multiple pending QA tickets; select one with --ticket`);
1948
+ const requested = requestedTicketIds[0];
1949
+ const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
1950
+ if (pendingHeads.length && requested && !head)
1951
+ fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
1952
+ if (head && !["completed", "waived"].includes(head.state)) {
1953
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)
1954
+ fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
1955
+ qaProtocolResumeTicket = head.ticketId;
1956
+ }
1660
1957
  }
1661
1958
  finally {
1662
- auditDb.close();
1959
+ protocolDb.close();
1663
1960
  }
1664
- activeContinuityRunId = undefined;
1665
- }
1666
- if (deliveryRun?.unit.branch_mode === "shared") {
1667
- plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
1668
- const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
1669
- const first = plan.nodes[0];
1670
- if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
1671
- const sessionRef = saved.sessionRef ?? createProviderSessionRef({ provider: agent, sessionId: saved.sessionId, role: "builder", stream: "builder", generation: 0, cwd: saved.worktreePath, configRoot: cwd, workspaceIdentity: captureWorkspaceIdentity(saved.worktreePath), ticketId: saved.ticket, deliveryUnitId: saved.unitId, source: "legacy-inferred" });
1672
- resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId, sessionRef });
1961
+ }
1962
+ if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
1963
+ fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
1964
+ if (qaResumedRecovery) {
1965
+ const protocolDb = new WorkflowDb(cwd);
1966
+ try {
1967
+ const revision = protocolDb.qaTicketHead(masterRun.runId, qaResumedRecovery.manifest.ticketId).revision;
1968
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== revision)
1969
+ fail(`pending QA protocol requires exact --qa-revision ${revision}`);
1970
+ }
1971
+ finally {
1972
+ protocolDb.close();
1673
1973
  }
1674
1974
  }
1675
- if (selectedStacks.length && deliveryConfig) {
1676
- plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
1975
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1976
+ const branchContextControllers = new Map();
1977
+ const branchContextController = (worktreePath) => {
1978
+ const existing = branchContextControllers.get(worktreePath);
1979
+ if (existing)
1980
+ return existing;
1981
+ const controller = RoleSessionController.managed({
1982
+ projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1983
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, opts.recoveryMode === "fresh-recovery-only" ? undefined : recoveryRecord, masterRun.runId, "builder"),
1984
+ readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1985
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1986
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1987
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1988
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1989
+ sessionTransition: "adopted live settings",
1990
+ }),
1991
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); },
1992
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1993
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1994
+ const transfer = await new HandoffService(cwd).transfer({
1995
+ runId: masterRun.runId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1996
+ roleState: { worktreePath, frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1997
+ ...(nativeUsage ? { sessionUsage: { version: 1, runId: masterRun.runId, role: "builder", provider: adapter.agent, providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source, cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens, cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd } } : {}),
1998
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1999
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
2000
+ }, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
2001
+ log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
2002
+ if (adapter instanceof ContinuityAdapter) {
2003
+ await adapter.adoptValidatedSuccessor(transfer.successor);
2004
+ return adapter;
2005
+ }
2006
+ await adapter.close().catch(() => { });
2007
+ return continuousBuilder(transfer.successor, worktreePath, masterRun.runId, settings);
2008
+ },
2009
+ });
2010
+ branchContextControllers.set(worktreePath, controller);
2011
+ return controller;
2012
+ };
2013
+ const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
2014
+ branchHeartbeat.unref();
2015
+ let branchSessionFailure;
2016
+ let summaries;
2017
+ try {
2018
+ summaries = await runBranchPlan({
2019
+ projectDir: cwd,
2020
+ runId: masterRun.runId,
2021
+ plan,
2022
+ continueIndependentTickets: frozenPolicy.continueIndependentTickets,
2023
+ log,
2024
+ agent,
2025
+ model,
2026
+ effort: builderEffort,
2027
+ fast: builderFast,
2028
+ notificationsEnabled: config.notifications.enabled,
2029
+ terminalBellEnabled: config.notifications.terminal_bell,
2030
+ qaEnabled,
2031
+ qaRuntimeContext: capturedBranchQa,
2032
+ qaContinuityManaged: true,
2033
+ qaReportRecovery,
2034
+ qaResumedRecovery,
2035
+ qaProtocolResumeTicket,
2036
+ qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
2037
+ observer: activeObserver,
2038
+ createPr: branchDefaults.createReview,
2039
+ completionMode: branchDefaults.completionMode,
2040
+ reviewProvider: branchDefaults.reviewProvider,
2041
+ prReady: branchDefaults.prReady,
2042
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
2043
+ cleanupBranches: branchDefaults.cleanupBranches,
2044
+ autoMergeWait: branchDefaults.autoMergeWait,
2045
+ autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
2046
+ mergeMethod: branchDefaults.mergeMethod,
2047
+ allowedBaseDirtyPaths,
2048
+ baseWorktreePolicy,
2049
+ trackerPaths: {
2050
+ progressDoc: ticketsConfig.paths.progressDoc,
2051
+ archiveDoc: ticketsConfig.paths.archiveDoc,
2052
+ },
2053
+ resumeSessions: resumeSessionByTicket,
2054
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
2055
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
2056
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
2057
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
2058
+ createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
2059
+ builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
2060
+ qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
2061
+ observeBuilder: async (builder) => {
2062
+ liveStatusReporter?.stop();
2063
+ activeBuilderForStatus = () => builder;
2064
+ const reporter = liveStatusReporter = new AgentStatusReporter({
2065
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
2066
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
2067
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
2068
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
2069
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
2070
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
2071
+ }
2072
+ finally {
2073
+ db.close();
2074
+ } },
2075
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2076
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
2077
+ }
2078
+ finally {
2079
+ db.close();
2080
+ } },
2081
+ }, (line, snapshot) => {
2082
+ const activity = currentActivity();
2083
+ if (activity)
2084
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
2085
+ else
2086
+ console.log(line);
2087
+ activeObserver?.store.updateCurrentState({ runId: masterRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: masterRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: snapshot.phase, lastSignalAt: snapshot.contextSample.observedAt });
2088
+ if (snapshot.contextSample.used !== undefined)
2089
+ activeObserver?.store.recordMetric({ runId: masterRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
2090
+ });
2091
+ reporter.start();
2092
+ try {
2093
+ await printEvents(builder.events());
2094
+ }
2095
+ finally {
2096
+ reporter.stop();
2097
+ }
2098
+ },
2099
+ qaNonconvergence,
2100
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
2101
+ observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
2102
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
2103
+ qaSessionBoundary,
2104
+ observeQaNativeCompactions,
2105
+ });
1677
2106
  }
1678
- else if (!deliveryRun) {
1679
- const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
1680
- if (branchPolicy)
1681
- plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
2107
+ catch (error) {
2108
+ if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
2109
+ throw error;
2110
+ const summary = error.message.slice(0, 1000);
2111
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
2112
+ status: "recoverable",
2113
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2114
+ }), "recoverable");
2115
+ clearInterval(branchHeartbeat);
2116
+ log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
2117
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2118
+ console.error(`foreman: resume this run with: ${formatRecoveryCommand(cwd)}`);
2119
+ process.exit(2);
1682
2120
  }
1683
- if (recoveryRecord && opts.resume && plan.nodes[0]) {
1684
- const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
1685
- if (prior) {
1686
- const requested = String(opts.resume);
1687
- const sessionRef = prior.sessionRef?.sessionId === requested ? prior.sessionRef : buildRunSessionBinding(recoveryRecord, "builder", requested) ?? (prior.sessionId === requested ? legacyBranchSessionRef(prior, cwd, agent) : undefined);
1688
- if (!sessionRef)
1689
- fail(`session ${requested} has no stored scope for ${plan.nodes[0].ticket.id}`);
1690
- resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: requested, sessionRef });
2121
+ if (branchSessionFailure) {
2122
+ const failure = branchSessionFailure;
2123
+ const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
2124
+ const summary = failure.message.slice(0, 1000);
2125
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
2126
+ status: "recoverable",
2127
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
2128
+ }), "recoverable");
2129
+ clearInterval(branchHeartbeat);
2130
+ log.write("session-unavailable", { runId: masterRun.runId, guided, message: summary });
2131
+ console.error(`foreman: exact provider session unavailable; preserved branch work remains recoverable: ${summary}`);
2132
+ console.error(guided
2133
+ ? `foreman: continue with: ${formatRecoveryCommand(cwd)}`
2134
+ : `foreman: inspect recovery with: ${formatRecoveryCommand(cwd)}`);
2135
+ process.exit(2);
2136
+ }
2137
+ if (pendingHandoffGeneration !== undefined)
2138
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
2139
+ clearInterval(branchHeartbeat);
2140
+ if (selectedStacks.length) {
2141
+ let allComplete = true;
2142
+ for (const stack of selectedStacks) {
2143
+ const stackTickets = stack.units.flatMap((id) => deliveryConfig?.units.find((unit) => unit.id === id)?.tickets ?? []);
2144
+ const rows = summaries.filter((row) => stackTickets.includes(row.ticket));
2145
+ const complete = rows.length > 0 && rows.every((row) => row.buildStatus === "done" && (!row.pr || ["created", "existing"].includes(row.pr.status)));
2146
+ allComplete &&= complete;
2147
+ const successful = rows.filter((row) => row.buildStatus === "done");
2148
+ updateStackDeliveryState(cwd, [stack.id], complete ? "awaiting_review" : "partial", {
2149
+ review_links: [...(stack.review_links ?? []), ...successful.flatMap((row) => row.pr?.url ? [row.pr.url] : [])],
2150
+ published_branches: { ...(stack.published_branches ?? {}), ...Object.fromEntries(successful.map((row) => [row.ticket, row.branch])) },
2151
+ completed_prefix: [...(stack.completed_prefix ?? []), ...successful.map((row) => row.ticket)],
2152
+ next_ticket: complete ? undefined : stackTickets.find((ticket) => !successful.some((row) => row.ticket === ticket)),
2153
+ remote_checked_at: stack.remote_checked_at, remote_stale: true,
2154
+ });
1691
2155
  }
2156
+ console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
1692
2157
  }
2158
+ masterRun = withRunLocalAcceptedRuntimes(masterRun);
2159
+ masterRun = summaries.every((row) => row.buildStatus === "done")
2160
+ ? completeBuildRun(cwd, masterRun)
2161
+ : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
2162
+ activeObserver?.finish(masterRun.status, observationSummaryMetadata(cwd, masterRun, qaEnabled, capturedBranchBuilder.make, summaries.find(row => row.pr?.url)));
2163
+ observabilityStore?.closeLogFile(logPath, masterRun.status);
2164
+ observabilityStore?.enforceLimits();
2165
+ observabilityStore?.maintainLogs(cwd);
2166
+ observabilityStore?.close();
2167
+ pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2168
+ activeObserver = undefined;
2169
+ process.off("exit", finishObservationOnExit);
2170
+ console.log("foreman: branch run summary");
2171
+ console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
2172
+ for (const row of summaries) {
2173
+ console.log([
2174
+ row.ticket,
2175
+ row.branch || "-",
2176
+ row.base,
2177
+ row.buildStatus,
2178
+ row.commit ?? "-",
2179
+ row.pushStatus ?? "-",
2180
+ row.pr?.url ?? row.pr?.error ?? row.detail ?? "-",
2181
+ ].join("\t"));
2182
+ }
2183
+ const followupCommands = formatBranchSummaryFollowupCommands(cwd, join(cwd, ".foreman"), summaries);
2184
+ if (followupCommands.length > 0) {
2185
+ console.log();
2186
+ console.log("foreman: continue blocked branch ticket(s) with:");
2187
+ for (const command of followupCommands) {
2188
+ console.log(` ${command}`);
2189
+ }
2190
+ }
2191
+ if (masterRun.status === "completed")
2192
+ printHandoffPruneCommand(masterRun.runId);
2193
+ const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
2194
+ fireTerminalBell(config.notifications.terminal_bell);
2195
+ process.exit(failed ? 2 : 0);
1693
2196
  }
1694
- const branchPresentation = presentBranchPlan(plan, {
1695
- stacked: selectedStacks.length > 0,
1696
- resumed: continueTickets.length > 0 || Boolean(recoveryRecord),
1697
- });
1698
- log.write("branch-plan", {
1699
- ...branchPlanLogMetadata(plan, branchPresentation),
1700
- ...(auditDependencyCount === undefined ? {} : { auditDependencyCount }),
1701
- ...(continueTickets.length > 0 || recoveryRecord ? { resume: true } : {}),
1702
- });
1703
- console.log(branchPresentation.banner);
1704
- console.log(`foreman: project ${cwd}`);
1705
- console.log(`foreman: base ${plan.baseRef}`);
1706
- console.log(`foreman: log ${logPath}\n`);
1707
- if (deliveryRun) {
1708
- console.log(`foreman: delivery unit ${deliveryRun.unit.id} — ${deliveryRun.completed}/${deliveryRun.unit.tickets.length} complete, ${deliveryRun.remaining.length} remaining`);
1709
- if (deliveryRun.unit.branch_mode === "shared" && steps < deliveryRun.remaining.length)
1710
- console.log(`foreman: --steps ${steps} completes ${steps} ticket(s) in this ${deliveryRun.remaining.length}-ticket remainder; branch/session is preserved and PR completion is deferred`);
1711
- if (deliveryRun.unit.dependency_mode === "stack")
1712
- console.log("foreman: stacked delivery — dependent branches must be rebased or retargeted after prerequisite merges");
1713
- }
1714
- for (const node of plan.nodes) {
1715
- console.log(` ${node.ticket.id} ${node.branch} base=${node.baseBranch}`);
1716
- }
1717
- for (const issue of plan.issues) {
1718
- console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
2197
+ if (!recoveryRecord && isTicketsInitialized(cwd)) {
2198
+ const paths = resolveTicketPaths(loadTicketsConfig(cwd), cwd);
2199
+ const state = new StateDb(paths.stateDb);
2200
+ try {
2201
+ executionTickets = executionTickets ?? loadTickets(paths.tickets).filter(ticket => !["done", "canceled", "cancelled", "obsolete"].includes(state.getState(ticket.id)?.status ?? "planned")).sort((a, b) => a.order - b.order).slice(0, steps).map(ticket => ticket.id);
2202
+ }
2203
+ finally {
2204
+ state.close();
2205
+ }
1719
2206
  }
1720
- console.log();
1721
- for (const [ticketId, session] of resumeSessionByTicket) {
1722
- const ref = session.sessionRef;
1723
- if (!ref)
1724
- fail(`session ${session.sessionId} for ${ticketId} has no location-scoped binding`);
1725
- if (ref.provider !== agent)
1726
- fail(`session ${ref.sessionId} belongs to ${ref.provider}, but this recovery selected ${agent}`);
1727
- const availability = await resolveProviderSessionAvailability(ref, {
1728
- cwd: session.worktreePath,
2207
+ 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.`);
2208
+ const ready = await ensureBuildRuntimeReadyForCommand(cwd, agent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
2209
+ label: "start",
2210
+ yes: Boolean(opts.yes),
2211
+ allowSwitch: !(opts.resume || opts.continue),
2212
+ model,
2213
+ });
2214
+ agent = ready.runtime;
2215
+ model = ready.model;
2216
+ agentExecutable = ready.executable;
2217
+ if (resumeSessionRef) {
2218
+ if (resumeSessionRef.provider !== agent)
2219
+ fail(`session ${resumeSessionRef.sessionId} belongs to ${resumeSessionRef.provider}, but this recovery selected ${agent}`);
2220
+ const availability = await resolveProviderSessionAvailability(resumeSessionRef, {
2221
+ cwd,
1729
2222
  configRoot: cwd,
1730
- workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
2223
+ workspaceIdentity: captureCurrentWorkflowSessionIdentity(cwd),
1731
2224
  runtimeExecutable: agentExecutable,
1732
2225
  });
1733
2226
  if (availability.status !== "available" || !availability.sessionRef) {
1734
- fail(`exact session ${ref.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The preserved worktree remains recoverable; choose fresh-with-handoff recovery.`);
2227
+ fail(`exact session ${resumeSessionRef.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The current worktree and recovery checkpoints were preserved; choose fresh-with-handoff recovery.`);
1735
2228
  }
1736
- resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
2229
+ resumeSessionRef = availability.sessionRef;
1737
2230
  }
1738
- const capturedBranchBuilder = {
2231
+ if (qaEnabled) {
2232
+ const qaReady = await ensureBuildRuntimeReadyForCommand(cwd, qaAgent, { build: readinessOwner, durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms, label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
2233
+ qaAgent = qaReady.runtime;
2234
+ qaModel = qaReady.model;
2235
+ qaExecutable = qaReady.executable;
2236
+ }
2237
+ const capturedBuilder = {
1739
2238
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
1740
2239
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1741
2240
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1742
2241
  display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1743
- auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
2242
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 65,
1744
2243
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
1745
2244
  };
1746
- const capturedBranchQa = {
1747
- role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
1748
- reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1749
- display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1750
- auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
1751
- compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1752
- };
1753
- const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1754
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2245
+ 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 };
2246
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
1755
2247
  runId: invocationRunId,
1756
- tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1757
- repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
2248
+ 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,
1758
2249
  frozenPolicy, autonomyProfile, qaEnabled,
1759
- runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
2250
+ runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1760
2251
  });
1761
- activeContinuityRunId = masterRun.runId;
1762
- if (requiresBuildApproval(cwd, plan.nodes.map(node => node.ticket.id), autoApprovePlanUpdates, approvalConsequences) && plan.issues.every((issue) => !issue.blocking)) {
1763
- 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({
1764
- message: branchPresentation.prompt,
1765
- options: [
1766
- { value: "proceed", label: "Proceed" },
1767
- { value: "cancel", label: "Cancel" },
1768
- ],
1769
- }) });
1770
- if (isCancel(action) || action === "cancel") {
1771
- console.log("ai-foreman: cancelled");
1772
- process.exit(0);
1773
- }
1774
- }
1775
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
2252
+ buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2253
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2254
+ activeContinuityRunId = buildRun.runId;
2255
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2256
+ let qaFinalizationTicket;
1776
2257
  let qaProtocolResumeTicket;
1777
2258
  if (recoveryRecord && !qaResumedRecovery) {
1778
- const protocolDb = new WorkflowDb(cwd);
2259
+ const finalizationDb = new WorkflowDb(cwd);
1779
2260
  try {
1780
- const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
1781
- if (pendingHeads.length > 1 && !opts.ticket)
1782
- fail(`run ${masterRun.runId} has multiple pending QA tickets; select one with --ticket`);
1783
- const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
2261
+ const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
2262
+ if (pendingHeads.length > 1 && !requestedTicketIds.length)
2263
+ fail(`run ${buildRun.runId} has multiple pending QA tickets; select one with --ticket`);
2264
+ const requested = requestedTicketIds[0];
1784
2265
  const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
1785
2266
  if (pendingHeads.length && requested && !head)
1786
2267
  fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
1787
- if (head && !["completed", "waived"].includes(head.state)) {
1788
- if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)
2268
+ if (head) {
2269
+ if (head.state === "finalizing")
2270
+ qaFinalizationTicket = head.ticketId;
2271
+ else if (!["idle", "completed", "waived"].includes(head.state))
2272
+ qaProtocolResumeTicket = head.ticketId;
2273
+ if ((qaFinalizationTicket || qaProtocolResumeTicket)
2274
+ && (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)) {
1789
2275
  fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
1790
- qaProtocolResumeTicket = head.ticketId;
2276
+ }
1791
2277
  }
1792
2278
  }
1793
2279
  finally {
1794
- protocolDb.close();
2280
+ finalizationDb.close();
1795
2281
  }
1796
2282
  }
1797
2283
  if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
1798
2284
  fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
1799
2285
  if (qaResumedRecovery) {
1800
- const protocolDb = new WorkflowDb(cwd);
2286
+ const qaDb = new WorkflowDb(cwd);
1801
2287
  try {
1802
- const revision = protocolDb.qaTicketHead(masterRun.runId, qaResumedRecovery.manifest.ticketId).revision;
1803
- if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== revision)
1804
- fail(`pending QA protocol requires exact --qa-revision ${revision}`);
2288
+ const currentRevision = qaDb.qaTicketHead(buildRun.runId, qaResumedRecovery.manifest.ticketId).revision;
2289
+ if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== currentRevision)
2290
+ fail(`pending QA recovery requires exact --qa-revision ${currentRevision}`);
1805
2291
  }
1806
2292
  finally {
1807
- protocolDb.close();
2293
+ qaDb.close();
1808
2294
  }
1809
2295
  }
1810
- activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1811
- const branchContextControllers = new Map();
1812
- const branchContextController = (worktreePath) => {
1813
- const existing = branchContextControllers.get(worktreePath);
1814
- if (existing)
1815
- return existing;
1816
- const controller = RoleSessionController.managed({
1817
- projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1818
- historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, masterRun.runId, "builder"),
1819
- readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1820
- settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1821
- settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1822
- role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1823
- adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1824
- sessionTransition: "adopted live settings",
1825
- }),
1826
- report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); },
1827
- handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1828
- const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1829
- const transfer = await new HandoffService(cwd).transfer({
1830
- runId: masterRun.runId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1831
- roleState: { worktreePath, frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1832
- ...(nativeUsage ? { sessionUsage: { version: 1, runId: masterRun.runId, role: "builder", provider: adapter.agent, providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source, cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens, cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd } } : {}),
1833
- compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1834
- resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1835
- }, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1836
- log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1837
- if (adapter instanceof ContinuityAdapter) {
1838
- await adapter.adoptValidatedSuccessor(transfer.successor);
1839
- return adapter;
1840
- }
1841
- await adapter.close().catch(() => { });
1842
- return continuousBuilder(transfer.successor, worktreePath, masterRun.runId, settings);
1843
- },
1844
- });
1845
- branchContextControllers.set(worktreePath, controller);
1846
- return controller;
2296
+ const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
2297
+ heartbeat.unref();
2298
+ const checkpointSignal = () => {
2299
+ try {
2300
+ buildRun = releaseBuildLease(cwd, buildRun, "interrupted");
2301
+ }
2302
+ catch { /* best effort */ }
1847
2303
  };
1848
- const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
1849
- branchHeartbeat.unref();
1850
- let branchSessionFailure;
1851
- let summaries;
2304
+ process.once("SIGINT", checkpointSignal);
2305
+ process.once("SIGTERM", checkpointSignal);
2306
+ let builder;
1852
2307
  try {
1853
- summaries = await runBranchPlan({
1854
- projectDir: cwd,
1855
- runId: masterRun.runId,
1856
- plan,
1857
- continueIndependentTickets: frozenPolicy.continueIndependentTickets,
1858
- log,
1859
- agent,
1860
- model,
1861
- effort: builderEffort,
1862
- fast: builderFast,
1863
- notificationsEnabled: config.notifications.enabled,
1864
- terminalBellEnabled: config.notifications.terminal_bell,
1865
- qaEnabled,
1866
- qaRuntimeContext: capturedBranchQa,
1867
- qaContinuityManaged: true,
1868
- qaReportRecovery,
1869
- qaResumedRecovery,
1870
- qaProtocolResumeTicket,
1871
- qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
1872
- observer: activeObserver,
1873
- createPr: branchDefaults.createReview,
1874
- completionMode: branchDefaults.completionMode,
1875
- reviewProvider: branchDefaults.reviewProvider,
1876
- prReady: branchDefaults.prReady,
1877
- keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1878
- cleanupBranches: branchDefaults.cleanupBranches,
1879
- autoMergeWait: branchDefaults.autoMergeWait,
1880
- autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1881
- mergeMethod: branchDefaults.mergeMethod,
1882
- allowedBaseDirtyPaths,
1883
- baseWorktreePolicy,
1884
- trackerPaths: {
1885
- progressDoc: ticketsConfig.paths.progressDoc,
1886
- archiveDoc: ticketsConfig.paths.archiveDoc,
1887
- },
1888
- resumeSessions: resumeSessionByTicket,
1889
- createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1890
- recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1891
- recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1892
- onSessionUnavailable: (error) => { branchSessionFailure = error; },
1893
- createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1894
- builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1895
- qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1896
- observeBuilder: async (builder) => {
1897
- liveStatusReporter?.stop();
1898
- activeBuilderForStatus = () => builder;
1899
- const reporter = liveStatusReporter = new AgentStatusReporter({
1900
- runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1901
- reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1902
- phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1903
- settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1904
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
1905
- return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1906
- }
1907
- finally {
1908
- db.close();
1909
- } },
1910
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1911
- return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1912
- }
1913
- finally {
1914
- db.close();
1915
- } },
1916
- }, (line, snapshot) => {
1917
- const activity = currentActivity();
1918
- if (activity)
1919
- activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1920
- else
1921
- console.log(line);
1922
- activeObserver?.store.updateCurrentState({ runId: masterRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: masterRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: snapshot.phase, lastSignalAt: snapshot.contextSample.observedAt });
1923
- if (snapshot.contextSample.used !== undefined)
1924
- activeObserver?.store.recordMetric({ runId: masterRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
1925
- });
1926
- reporter.start();
1927
- try {
1928
- await printEvents(builder.events());
1929
- }
1930
- finally {
1931
- reporter.stop();
1932
- }
1933
- },
1934
- qaNonconvergence,
1935
- beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1936
- observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1937
- builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1938
- qaSessionBoundary,
1939
- observeQaNativeCompactions,
1940
- });
2308
+ builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
1941
2309
  }
1942
2310
  catch (error) {
1943
- if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
2311
+ if (!(error instanceof SessionUnavailableError))
1944
2312
  throw error;
1945
2313
  const summary = error.message.slice(0, 1000);
1946
- masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
1947
- status: "recoverable",
1948
- failure: { category: "unknown", summary, at: new Date().toISOString() },
1949
- }), "recoverable");
1950
- clearInterval(branchHeartbeat);
1951
- log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
1952
- console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
1953
- console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)}`);
1954
- process.exit(2);
1955
- }
1956
- if (branchSessionFailure) {
1957
- const failure = branchSessionFailure;
1958
- const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
1959
- const summary = failure.message.slice(0, 1000);
1960
- masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
2314
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "session-unavailable-before-dispatch", {
1961
2315
  status: "recoverable",
1962
2316
  failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1963
2317
  }), "recoverable");
1964
- clearInterval(branchHeartbeat);
1965
- log.write("session-unavailable", { runId: masterRun.runId, guided, message: summary });
1966
- console.error(`foreman: exact provider session unavailable; preserved branch work remains recoverable: ${summary}`);
1967
- console.error(guided
1968
- ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --guided-recovery`
1969
- : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --inspect`);
2318
+ clearInterval(heartbeat);
2319
+ log.write("session-unavailable", { runId: buildRun.runId, phase: error.failure.phase, dispatchState: error.failure.dispatchState, message: summary });
2320
+ console.error(`foreman: exact provider session unavailable before dispatch; current worktree state was preserved: ${summary}`);
2321
+ console.error(`foreman: inspect recovery with: ${formatRecoveryCommand(cwd)}`);
1970
2322
  process.exit(2);
1971
2323
  }
1972
- if (pendingHandoffGeneration !== undefined)
1973
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1974
- clearInterval(branchHeartbeat);
1975
- if (selectedStacks.length) {
1976
- let allComplete = true;
1977
- for (const stack of selectedStacks) {
1978
- const stackTickets = stack.units.flatMap((id) => deliveryConfig?.units.find((unit) => unit.id === id)?.tickets ?? []);
1979
- const rows = summaries.filter((row) => stackTickets.includes(row.ticket));
1980
- const complete = rows.length > 0 && rows.every((row) => row.buildStatus === "done" && (!row.pr || ["created", "existing"].includes(row.pr.status)));
1981
- allComplete &&= complete;
1982
- const successful = rows.filter((row) => row.buildStatus === "done");
1983
- updateStackDeliveryState(cwd, [stack.id], complete ? "awaiting_review" : "partial", {
1984
- review_links: [...(stack.review_links ?? []), ...successful.flatMap((row) => row.pr?.url ? [row.pr.url] : [])],
1985
- published_branches: { ...(stack.published_branches ?? {}), ...Object.fromEntries(successful.map((row) => [row.ticket, row.branch])) },
1986
- completed_prefix: [...(stack.completed_prefix ?? []), ...successful.map((row) => row.ticket)],
1987
- next_ticket: complete ? undefined : stackTickets.find((ticket) => !successful.some((row) => row.ticket === ticket)),
1988
- remote_checked_at: stack.remote_checked_at, remote_stale: true,
1989
- });
2324
+ const contextController = RoleSessionController.managed({
2325
+ projectDir: cwd,
2326
+ runId: buildRun.runId,
2327
+ role: "builder",
2328
+ initialSettings: capturedBuilder,
2329
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, opts.recoveryMode === "fresh-recovery-only" ? undefined : recoveryRecord, buildRun.runId, "builder"),
2330
+ readSettings: () => liveRoleSettings("builder", capturedBuilder),
2331
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
2332
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
2333
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
2334
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
2335
+ sessionTransition: "adopted live settings",
2336
+ }),
2337
+ report: (event) => {
2338
+ currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
2339
+ },
2340
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
2341
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
2342
+ const transfer = await new HandoffService(cwd).transfer({
2343
+ runId: buildRun.runId,
2344
+ role: "builder",
2345
+ reason,
2346
+ predecessorSessionId: adapter.sessionId(),
2347
+ predecessorSessionRef: adapter.sessionRef?.(),
2348
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
2349
+ ...(nativeUsage ? { sessionUsage: {
2350
+ version: 1, runId: buildRun.runId, role: "builder", provider: adapter.agent,
2351
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
2352
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
2353
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
2354
+ } } : {}),
2355
+ compactionCount,
2356
+ compactMaximum: settings.compact_maximum ?? 10,
2357
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
2358
+ }, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
2359
+ log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
2360
+ if (adapter instanceof ContinuityAdapter) {
2361
+ await adapter.adoptValidatedSuccessor(transfer.successor);
2362
+ return adapter;
2363
+ }
2364
+ await adapter.close();
2365
+ return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
2366
+ },
2367
+ });
2368
+ 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);
2369
+ activeBuilderForStatus = () => foreman.builderAdapter();
2370
+ const statusReporter = liveStatusReporter = new AgentStatusReporter({
2371
+ runId: buildRun.runId,
2372
+ role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
2373
+ reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
2374
+ phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
2375
+ settingsRevision: capturedBuilder.settings_revision,
2376
+ displaySessionCost: capturedBuilder.display_session_cost ?? false,
2377
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
2378
+ return db.successfulCompactionCount(buildRun.runId, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1990
2379
  }
1991
- console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
1992
- }
1993
- masterRun = withRunLocalAcceptedRuntimes(masterRun);
1994
- masterRun = summaries.every((row) => row.buildStatus === "done")
1995
- ? completeBuildRun(cwd, masterRun)
1996
- : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
1997
- activeObserver?.finish(masterRun.status, observationSummaryMetadata(cwd, masterRun, qaEnabled, capturedBranchBuilder.make, summaries.find(row => row.pr?.url)));
1998
- observabilityStore?.closeLogFile(logPath, masterRun.status);
1999
- observabilityStore?.enforceLimits();
2000
- observabilityStore?.maintainLogs(cwd);
2001
- observabilityStore?.close();
2002
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2003
- activeObserver = undefined;
2004
- process.off("exit", finishObservationOnExit);
2005
- console.log("foreman: branch run summary");
2006
- console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
2007
- for (const row of summaries) {
2008
- console.log([
2009
- row.ticket,
2010
- row.branch || "-",
2011
- row.base,
2012
- row.buildStatus,
2013
- row.commit ?? "-",
2014
- row.pushStatus ?? "-",
2015
- row.pr?.url ?? row.pr?.error ?? row.detail ?? "-",
2016
- ].join("\t"));
2017
- }
2018
- const followupCommands = formatBranchSummaryFollowupCommands(cwd, join(cwd, ".foreman"), summaries);
2019
- if (followupCommands.length > 0) {
2020
- console.log();
2021
- console.log("foreman: continue blocked branch ticket(s) with:");
2022
- for (const command of followupCommands) {
2023
- console.log(` ${command}`);
2380
+ finally {
2381
+ db.close();
2382
+ } },
2383
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2384
+ return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
2024
2385
  }
2025
- }
2026
- if (masterRun.status === "completed")
2027
- printHandoffPruneCommand(masterRun.runId);
2028
- const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
2029
- fireTerminalBell(config.notifications.terminal_bell);
2030
- process.exit(failed ? 2 : 0);
2031
- }
2032
- const ready = await ensureRuntimeReadyForCommand(cwd, agent, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
2033
- label: "start",
2034
- yes: Boolean(opts.yes),
2035
- allowSwitch: !(opts.resume || opts.continue),
2036
- model,
2037
- });
2038
- agent = ready.runtime;
2039
- model = ready.model;
2040
- agentExecutable = ready.executable;
2041
- if (resumeSessionRef) {
2042
- if (resumeSessionRef.provider !== agent)
2043
- fail(`session ${resumeSessionRef.sessionId} belongs to ${resumeSessionRef.provider}, but this recovery selected ${agent}`);
2044
- const availability = await resolveProviderSessionAvailability(resumeSessionRef, {
2045
- cwd,
2046
- configRoot: cwd,
2047
- workspaceIdentity: captureCurrentWorkflowSessionIdentity(cwd),
2048
- runtimeExecutable: agentExecutable,
2386
+ finally {
2387
+ db.close();
2388
+ } },
2389
+ }, (line, snapshot) => {
2390
+ const activity = currentActivity();
2391
+ if (activity)
2392
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
2393
+ else
2394
+ console.log(line);
2395
+ activeObserver?.store.updateCurrentState({ runId: buildRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: buildRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: snapshot.phase, lastSignalAt: snapshot.contextSample.observedAt });
2396
+ if (snapshot.contextSample.used !== undefined)
2397
+ activeObserver?.store.recordMetric({ runId: buildRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
2049
2398
  });
2050
- if (availability.status !== "available" || !availability.sessionRef) {
2051
- fail(`exact session ${resumeSessionRef.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The current worktree and recovery checkpoints were preserved; choose fresh-with-handoff recovery.`);
2052
- }
2053
- resumeSessionRef = availability.sessionRef;
2054
- }
2055
- if (qaEnabled) {
2056
- const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { durable: { projectDir: cwd, runId: activeContinuityRunId ?? invocationRunId, scopeRevision: buildScopeRevision(cwd, approvalConsequences) }, timeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms, label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
2057
- qaAgent = qaReady.runtime;
2058
- qaModel = qaReady.model;
2059
- qaExecutable = qaReady.executable;
2060
- }
2061
- const capturedBuilder = {
2062
- role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
2063
- make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
2064
- session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
2065
- display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
2066
- auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
2067
- compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
2068
- };
2069
- 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 };
2070
- let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
2071
- runId: invocationRunId,
2072
- tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
2073
- frozenPolicy, autonomyProfile, qaEnabled,
2074
- runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
2075
- });
2076
- activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2077
- activeContinuityRunId = buildRun.runId;
2078
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
2079
- let qaFinalizationTicket;
2080
- let qaProtocolResumeTicket;
2081
- if (recoveryRecord && !qaResumedRecovery) {
2082
- const finalizationDb = new WorkflowDb(cwd);
2399
+ statusReporter.start();
2400
+ const modifiers = [
2401
+ model ? `model=${model}` : null,
2402
+ builderEffort ? `effort=${builderEffort}` : null,
2403
+ builderFast ? "fast" : null,
2404
+ qaEnabled ? null : "qa=off",
2405
+ ].filter(Boolean).join(" ");
2406
+ console.log(`${accent("foreman:")} driving a ${sanitizeTerminalText(agent)} builder through ${steps} step(s)${modifiers ? ` [${sanitizeTerminalText(modifiers)}]` : ""}`);
2407
+ console.log(`${accent("foreman:")} project ${sanitizeTerminalText(cwd)}`);
2408
+ if (trackerRelPath)
2409
+ console.log(`${accent("foreman:")} tracker ${sanitizeTerminalText(trackerRelPath)}`);
2410
+ console.log(`${accent("foreman:")} log ${sanitizeTerminalText(logPath)}\n`);
2411
+ const viewer = printEvents(builder.events());
2083
2412
  try {
2084
- const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
2085
- if (pendingHeads.length > 1 && !opts.ticket)
2086
- fail(`run ${buildRun.runId} has multiple pending QA tickets; select one with --ticket`);
2087
- const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
2088
- const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
2089
- if (pendingHeads.length && requested && !head)
2090
- fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
2091
- if (head) {
2092
- if (head.state === "finalizing")
2093
- qaFinalizationTicket = head.ticketId;
2094
- else if (!["idle", "completed", "waived"].includes(head.state))
2095
- qaProtocolResumeTicket = head.ticketId;
2096
- if ((qaFinalizationTicket || qaProtocolResumeTicket)
2097
- && (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)) {
2098
- fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
2413
+ if (recoveryRecord && currentBranchTicketProgress(cwd, buildRun)?.remaining.length === 0) {
2414
+ buildRun = finishRecoveredTicketScope(cwd, buildRun, "reconciled-completed-tickets");
2415
+ clearInterval(heartbeat);
2416
+ statusReporter.stop("completed");
2417
+ await foreman.close();
2418
+ await viewer;
2419
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2420
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2421
+ observabilityStore?.close();
2422
+ activeObserver = undefined;
2423
+ process.off("exit", finishObservationOnExit);
2424
+ console.log("foreman: recovered completed ticket scope");
2425
+ process.exit(0);
2426
+ }
2427
+ if (qaFinalizationTicket) {
2428
+ console.log(`ai-foreman: reconciling the interrupted QA finalization for ${qaFinalizationTicket}; Builder dispatch is disabled.\n`);
2429
+ await foreman.completePendingQaFinalization(qaFinalizationTicket);
2430
+ buildRun = finishRecoveredTicketScope(cwd, buildRun, "qa-finalization-complete");
2431
+ clearInterval(heartbeat);
2432
+ statusReporter.stop("QA finalization complete");
2433
+ await foreman.close();
2434
+ await viewer;
2435
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2436
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2437
+ observabilityStore?.close();
2438
+ activeObserver = undefined;
2439
+ process.off("exit", finishObservationOnExit);
2440
+ console.log(`foreman: reconciled QA finalization for ${qaFinalizationTicket}`);
2441
+ process.exit(0);
2442
+ }
2443
+ if (qaResumedRecovery || qaProtocolResumeTicket) {
2444
+ const ticketId = qaResumedRecovery?.manifest.ticketId ?? qaProtocolResumeTicket;
2445
+ console.log(`ai-foreman: resuming the exact QA boundary for ${ticketId}; Builder preflight and work dispatch are disabled.\n`);
2446
+ buildRun = checkpointBuildRun(cwd, buildRun, "qa-recovery-before-dispatch", { currentTicket: ticketId });
2447
+ const qa = await (activeObserver
2448
+ ? activeObserver.span("qa_recovery", "Exact QA-only recovery", () => foreman.completePendingQaRecovery(ticketId))
2449
+ : foreman.completePendingQaRecovery(ticketId));
2450
+ if (pendingHandoffGeneration !== undefined)
2451
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2452
+ if (foreman.builderSessionId())
2453
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2454
+ if (foreman.qaSessionId())
2455
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2456
+ buildRun = qa.outcome === "passed" || qa.outcome === "waived"
2457
+ ? finishRecoveredTicketScope(cwd, buildRun, "qa-recovery-complete")
2458
+ : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "qa-recovery-paused", {
2459
+ status: "recoverable", currentTicket: ticketId,
2460
+ }), "recoverable");
2461
+ clearInterval(heartbeat);
2462
+ statusReporter.stop(qa.outcome === "passed" || qa.outcome === "waived" ? "QA recovery complete" : "paused");
2463
+ await foreman.close();
2464
+ await viewer;
2465
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2466
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2467
+ observabilityStore?.enforceLimits();
2468
+ observabilityStore?.maintainLogs(cwd);
2469
+ observabilityStore?.close();
2470
+ activeObserver = undefined;
2471
+ process.off("exit", finishObservationOnExit);
2472
+ console.log(`foreman: exact QA recovery for ${ticketId} — ${qa.outcome}`);
2473
+ if (qa.detail)
2474
+ console.log(`foreman: ${qa.detail}`);
2475
+ process.exit(qa.outcome === "passed" || qa.outcome === "waived" ? 0 : 2);
2476
+ }
2477
+ console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
2478
+ buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
2479
+ await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket, executionTickets)) : foreman.runPreflight(steps, ticketsContent, preferredTicket, executionTickets));
2480
+ buildRun = withRunLocalAcceptedRuntimes(buildRun);
2481
+ if (foreman.builderSessionId())
2482
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2483
+ if (foreman.qaSessionId())
2484
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2485
+ buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
2486
+ let planDecisionRevision = 0;
2487
+ const ensureApproved = createBuildApprovalGate(cwd, executionTickets, autoApprovePlanUpdates, approvalConsequences, async () => {
2488
+ while (true) {
2489
+ console.log();
2490
+ const action = await durableHumanDecision({ projectDir: cwd, runId: invocationRunId, key: `build-plan:${buildScopeRevision(cwd, approvalConsequences)}:${planDecisionRevision}`, observer: activeObserver, prompt: "How does this plan look?", choices: [{ id: "proceed", label: "Proceed" }, { id: "feedback", label: "Feedback" }, { id: "cancel", label: "Cancel" }], operation: () => select({
2491
+ message: "How does this plan look?",
2492
+ options: [
2493
+ { value: "proceed", label: "Proceed — start implementing" },
2494
+ { value: "feedback", label: "Give feedback — revise the plan" },
2495
+ { value: "cancel", label: "Cancel" },
2496
+ ],
2497
+ }) });
2498
+ if (isCancel(action) || action === "cancel") {
2499
+ const cancelled = action === "cancel";
2500
+ if (cancelled) {
2501
+ const decisions = new WorkflowDb(cwd);
2502
+ try {
2503
+ decisions.cancelPendingHumanDecisions(invocationRunId, "User cancelled build; pending decisions superseded");
2504
+ }
2505
+ finally {
2506
+ decisions.close();
2507
+ }
2508
+ }
2509
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, cancelled ? "cancelled" : "input-paused", { status: cancelled ? "cancelled" : "recoverable" }), cancelled ? "cancelled" : "recoverable");
2510
+ activeObserver?.finish(cancelled ? "cancelled" : "paused", { checkpoint: cancelled ? "user-cancel" : "prompt-eof-or-cancel" });
2511
+ console.log("ai-foreman: cancelled");
2512
+ statusReporter.stop("cancelled");
2513
+ await foreman.close();
2514
+ await viewer;
2515
+ process.exit(0);
2516
+ }
2517
+ if (action === "proceed") {
2518
+ console.log();
2519
+ break;
2520
+ }
2521
+ const fb = await durableHumanDecision({ projectDir: cwd, runId: invocationRunId, key: `build-plan-feedback:${buildScopeRevision(cwd, approvalConsequences)}:${planDecisionRevision}`, observer: activeObserver, prompt: "Your build plan feedback:", choices: [{ id: "custom", label: "Provide feedback" }], operation: () => text({
2522
+ message: "Your feedback:",
2523
+ validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
2524
+ }) });
2525
+ if (isCancel(fb) || fb === undefined) {
2526
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "input-paused", { status: "recoverable" }), "recoverable");
2527
+ activeObserver?.finish("paused", { checkpoint: "feedback-eof-or-cancel" });
2528
+ console.log("ai-foreman: cancelled");
2529
+ statusReporter.stop("cancelled");
2530
+ await foreman.close();
2531
+ await viewer;
2532
+ process.exit(0);
2533
+ }
2534
+ console.log();
2535
+ await foreman.sendPreflightFeedback(String(fb));
2536
+ planDecisionRevision++;
2099
2537
  }
2538
+ });
2539
+ await ensureApproved();
2540
+ if (!isTicketsInitialized(cwd)) {
2541
+ const synthetic = initializeSyntheticWork(cwd, buildRun.runId, steps, `Explicit unticketed build invocation ${invocationRunId}`, foreman.approvedPlanText());
2542
+ buildRun = checkpointBuildRun(cwd, buildRun, "synthetic-work-admitted", { tickets: synthetic });
2100
2543
  }
2101
- }
2102
- finally {
2103
- finalizationDb.close();
2104
- }
2105
- }
2106
- if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
2107
- fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
2108
- if (qaResumedRecovery) {
2109
- const qaDb = new WorkflowDb(cwd);
2110
- try {
2111
- const currentRevision = qaDb.qaTicketHead(buildRun.runId, qaResumedRecovery.manifest.ticketId).revision;
2112
- if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== currentRevision)
2113
- fail(`pending QA recovery requires exact --qa-revision ${currentRevision}`);
2114
- }
2115
- finally {
2116
- qaDb.close();
2117
- }
2118
- }
2119
- const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
2120
- heartbeat.unref();
2121
- const checkpointSignal = () => {
2122
- try {
2123
- buildRun = releaseBuildLease(cwd, buildRun, "interrupted");
2124
- }
2125
- catch { /* best effort */ }
2126
- };
2127
- process.once("SIGINT", checkpointSignal);
2128
- process.once("SIGTERM", checkpointSignal);
2129
- let builder;
2130
- try {
2131
- builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
2132
- }
2133
- catch (error) {
2134
- if (!(error instanceof SessionUnavailableError))
2135
- throw error;
2136
- const summary = error.message.slice(0, 1000);
2137
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "session-unavailable-before-dispatch", {
2138
- status: "recoverable",
2139
- failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
2140
- }), "recoverable");
2141
- clearInterval(heartbeat);
2142
- log.write("session-unavailable", { runId: buildRun.runId, phase: error.failure.phase, dispatchState: error.failure.dispatchState, message: summary });
2143
- console.error(`foreman: exact provider session unavailable before dispatch; current worktree state was preserved: ${summary}`);
2144
- console.error(`foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
2145
- process.exit(2);
2146
- }
2147
- const contextController = RoleSessionController.managed({
2148
- projectDir: cwd,
2149
- runId: buildRun.runId,
2150
- role: "builder",
2151
- initialSettings: capturedBuilder,
2152
- historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, buildRun.runId, "builder"),
2153
- readSettings: () => liveRoleSettings("builder", capturedBuilder),
2154
- settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
2155
- settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
2156
- role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
2157
- adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
2158
- sessionTransition: "adopted live settings",
2159
- }),
2160
- report: (event) => {
2161
- currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
2162
- },
2163
- handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
2164
- const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
2165
- const transfer = await new HandoffService(cwd).transfer({
2166
- runId: buildRun.runId,
2167
- role: "builder",
2168
- reason,
2169
- predecessorSessionId: adapter.sessionId(),
2170
- predecessorSessionRef: adapter.sessionRef?.(),
2171
- roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
2172
- ...(nativeUsage ? { sessionUsage: {
2173
- version: 1, runId: buildRun.runId, role: "builder", provider: adapter.agent,
2174
- providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
2175
- cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
2176
- cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
2177
- } } : {}),
2178
- compactionCount,
2179
- compactMaximum: settings.compact_maximum ?? 10,
2180
- resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
2181
- }, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
2182
- log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
2183
- if (adapter instanceof ContinuityAdapter) {
2184
- await adapter.adoptValidatedSuccessor(transfer.successor);
2185
- return adapter;
2186
- }
2187
- await adapter.close();
2188
- return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
2189
- },
2190
- });
2191
- 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);
2192
- activeBuilderForStatus = () => foreman.builderAdapter();
2193
- const statusReporter = liveStatusReporter = new AgentStatusReporter({
2194
- runId: buildRun.runId,
2195
- role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
2196
- reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
2197
- phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
2198
- settingsRevision: capturedBuilder.settings_revision,
2199
- displaySessionCost: capturedBuilder.display_session_cost ?? false,
2200
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
2201
- return db.successfulCompactionCount(buildRun.runId, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2202
- }
2203
- finally {
2204
- db.close();
2205
- } },
2206
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
2207
- return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
2208
- }
2209
- finally {
2210
- db.close();
2211
- } },
2212
- }, (line, snapshot) => {
2213
- const activity = currentActivity();
2214
- if (activity)
2215
- activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
2216
- else
2217
- console.log(line);
2218
- activeObserver?.store.updateCurrentState({ runId: buildRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: buildRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: snapshot.phase, lastSignalAt: snapshot.contextSample.observedAt });
2219
- if (snapshot.contextSample.used !== undefined)
2220
- activeObserver?.store.recordMetric({ runId: buildRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
2221
- });
2222
- statusReporter.start();
2223
- const modifiers = [
2224
- model ? `model=${model}` : null,
2225
- builderEffort ? `effort=${builderEffort}` : null,
2226
- builderFast ? "fast" : null,
2227
- qaEnabled ? null : "qa=off",
2228
- ].filter(Boolean).join(" ");
2229
- console.log(`foreman: driving a ${agent} builder through ${steps} step(s)${modifiers ? ` [${modifiers}]` : ""}`);
2230
- console.log(`foreman: project ${cwd}`);
2231
- if (trackerRelPath)
2232
- console.log(`foreman: tracker ${trackerRelPath}`);
2233
- console.log(`foreman: log ${logPath}\n`);
2234
- const viewer = printEvents(builder.events());
2235
- try {
2236
- if (qaFinalizationTicket) {
2237
- console.log(`ai-foreman: reconciling the interrupted QA finalization for ${qaFinalizationTicket}; Builder dispatch is disabled.\n`);
2238
- await foreman.completePendingQaFinalization(qaFinalizationTicket);
2239
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "qa-finalization-complete", { status: "recoverable", currentTicket: qaFinalizationTicket }), "recoverable");
2240
- clearInterval(heartbeat);
2241
- statusReporter.stop("QA finalization complete");
2242
- await foreman.close();
2243
- await viewer;
2244
- activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2245
- observabilityStore?.closeLogFile(logPath, buildRun.status);
2246
- observabilityStore?.close();
2247
- activeObserver = undefined;
2248
- process.off("exit", finishObservationOnExit);
2249
- console.log(`foreman: reconciled QA finalization for ${qaFinalizationTicket}`);
2250
- process.exit(0);
2251
- }
2252
- if (qaResumedRecovery || qaProtocolResumeTicket) {
2253
- const ticketId = qaResumedRecovery?.manifest.ticketId ?? qaProtocolResumeTicket;
2254
- console.log(`ai-foreman: resuming the exact QA boundary for ${ticketId}; Builder preflight and work dispatch are disabled.\n`);
2255
- buildRun = checkpointBuildRun(cwd, buildRun, "qa-recovery-before-dispatch", { currentTicket: ticketId });
2256
- const qa = await (activeObserver
2257
- ? activeObserver.span("qa_recovery", "Exact QA-only recovery", () => foreman.completePendingQaRecovery(ticketId))
2258
- : foreman.completePendingQaRecovery(ticketId));
2544
+ const onTicketStart = async (ticketId) => {
2545
+ await ensureApproved();
2546
+ admitApprovedTicket(cwd, buildRun.runId, ticketId, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved ticket selection`);
2547
+ const tickets = buildRun.tickets.includes(ticketId) ? buildRun.tickets : [...buildRun.tickets, ticketId];
2548
+ buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", { tickets, currentTicket: ticketId,
2549
+ ...(buildRun.authorizedBatch ? { authorizedBatch: { ...buildRun.authorizedBatch, approvalRevisions: [...new Set([...(buildRun.authorizedBatch.approvalRevisions ?? [buildRun.authorizedBatch.scopeRevision]), buildScopeRevision(cwd, approvalConsequences)])], startedTickets: [...new Set([...buildRun.authorizedBatch.startedTickets, ticketId])] } } : {}) });
2550
+ };
2551
+ const batch = () => foreman.runBatch(steps, trackerRelPath, onTicketStart, preferredTicket, executionTickets);
2552
+ const result = await (activeObserver ? activeObserver.span("builder_work", "Builder and QA batch", batch) : batch());
2259
2553
  if (pendingHandoffGeneration !== undefined)
2260
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
2554
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
2261
2555
  if (foreman.builderSessionId())
2262
2556
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2263
2557
  if (foreman.qaSessionId())
2264
2558
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2265
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, qa.outcome === "passed" || qa.outcome === "waived" ? "qa-recovery-complete" : "qa-recovery-paused", {
2266
- status: "recoverable", currentTicket: ticketId,
2267
- }), "recoverable");
2559
+ buildRun = buildRun.tickets.length && isTicketsInitialized(cwd)
2560
+ ? finishRecoveredTicketScope(cwd, buildRun, "ticket-recovery-boundary")
2561
+ : result.outcome === "all-done" || result.outcome === "plan-complete"
2562
+ ? completeBuildRun(cwd, buildRun)
2563
+ : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
2268
2564
  clearInterval(heartbeat);
2269
- statusReporter.stop(qa.outcome === "passed" || qa.outcome === "waived" ? "QA recovery complete" : "paused");
2565
+ statusReporter.stop(buildRun.status === "completed" ? "completed" : "paused");
2270
2566
  await foreman.close();
2271
2567
  await viewer;
2272
2568
  activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
@@ -2274,183 +2570,123 @@ export function buildStartCommand() {
2274
2570
  observabilityStore?.enforceLimits();
2275
2571
  observabilityStore?.maintainLogs(cwd);
2276
2572
  observabilityStore?.close();
2573
+ pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2277
2574
  activeObserver = undefined;
2278
2575
  process.off("exit", finishObservationOnExit);
2279
- console.log(`foreman: exact QA recovery for ${ticketId} — ${qa.outcome}`);
2280
- if (qa.detail)
2281
- console.log(`foreman: ${qa.detail}`);
2282
- process.exit(qa.outcome === "passed" || qa.outcome === "waived" ? 0 : 2);
2283
- }
2284
- console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
2285
- buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
2286
- await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
2287
- buildRun = withRunLocalAcceptedRuntimes(buildRun);
2288
- if (foreman.builderSessionId())
2289
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2290
- if (foreman.qaSessionId())
2291
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2292
- buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
2293
- let planDecisionRevision = 0;
2294
- if (requiresBuildApproval(cwd, preferredTicket ? [preferredTicket] : undefined, autoApprovePlanUpdates, approvalConsequences)) {
2295
- while (true) {
2296
- console.log();
2297
- const action = await durableHumanDecision({ projectDir: cwd, runId: invocationRunId, key: `build-plan:${buildScopeRevision(cwd, approvalConsequences)}:${planDecisionRevision}`, observer: activeObserver, prompt: "How does this plan look?", choices: [{ id: "proceed", label: "Proceed" }, { id: "feedback", label: "Feedback" }, { id: "cancel", label: "Cancel" }], operation: () => select({
2298
- message: "How does this plan look?",
2299
- options: [
2300
- { value: "proceed", label: "Proceed — start implementing" },
2301
- { value: "feedback", label: "Give feedback — revise the plan" },
2302
- { value: "cancel", label: "Cancel" },
2303
- ],
2304
- }) });
2305
- if (isCancel(action) || action === "cancel") {
2306
- console.log("ai-foreman: cancelled");
2307
- statusReporter.stop("cancelled");
2308
- await foreman.close();
2309
- await viewer;
2310
- process.exit(0);
2311
- }
2312
- if (action === "proceed") {
2313
- console.log();
2314
- break;
2576
+ console.log(`\n${accent("foreman:")} ${result.completed}/${result.requested} step(s) completed`);
2577
+ const safeOutcome = sanitizeTerminalText(result.outcome);
2578
+ const resultOutcome = /success|complete|pass/i.test(safeOutcome) ? success(safeOutcome)
2579
+ : /fail|error/i.test(safeOutcome) ? error(safeOutcome)
2580
+ : /warn|partial|blocked/i.test(safeOutcome) ? warning(safeOutcome) : safeOutcome;
2581
+ console.log(`${accent("foreman:")} outcome — ${resultOutcome}`);
2582
+ if (result.detail)
2583
+ console.log(`foreman: ${result.detail}`);
2584
+ if (buildRun.status === "completed")
2585
+ printHandoffPruneCommand(buildRun.runId);
2586
+ if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
2587
+ const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
2588
+ const remainingSteps = Math.max(1, result.requested - result.completed);
2589
+ const decisionsDb = new WorkflowDb(cwd);
2590
+ try {
2591
+ for (const line of formatResumeGuidance(executable, cwd, remainingSteps, foreman.builderSessionId(), {
2592
+ runId: buildRun.runId, decisions: decisionsDb.pendingHumanDecisions(buildRun.runId),
2593
+ }))
2594
+ console.log(line);
2315
2595
  }
2316
- const fb = await durableHumanDecision({ projectDir: cwd, runId: invocationRunId, key: `build-plan-feedback:${buildScopeRevision(cwd, approvalConsequences)}:${planDecisionRevision}`, observer: activeObserver, prompt: "Your build plan feedback:", choices: [{ id: "custom", label: "Provide feedback" }], operation: () => text({
2317
- message: "Your feedback:",
2318
- validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
2319
- }) });
2320
- if (isCancel(fb)) {
2321
- console.log("ai-foreman: cancelled");
2322
- statusReporter.stop("cancelled");
2323
- await foreman.close();
2324
- await viewer;
2325
- process.exit(0);
2596
+ finally {
2597
+ decisionsDb.close();
2326
2598
  }
2327
- console.log();
2328
- await foreman.sendPreflightFeedback(String(fb));
2329
- planDecisionRevision++;
2330
2599
  }
2600
+ fireTerminalBell(config.notifications.terminal_bell);
2601
+ process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
2331
2602
  }
2332
- const result = await (activeObserver ? activeObserver.span("builder_work", "Builder and QA batch", () => foreman.runBatch(steps, trackerRelPath, (ticketId) => {
2333
- const tickets = buildRun.tickets.includes(ticketId)
2334
- ? buildRun.tickets
2335
- : [...buildRun.tickets, ticketId];
2336
- buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", {
2337
- tickets,
2338
- currentTicket: ticketId,
2339
- });
2340
- }, preferredTicket)) : foreman.runBatch(steps, trackerRelPath, (ticketId) => {
2341
- const tickets = buildRun.tickets.includes(ticketId) ? buildRun.tickets : [...buildRun.tickets, ticketId];
2342
- buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", { tickets, currentTicket: ticketId });
2343
- }, preferredTicket));
2344
- if (pendingHandoffGeneration !== undefined)
2345
- throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
2346
- if (foreman.builderSessionId())
2347
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
2348
- if (foreman.qaSessionId())
2349
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
2350
- buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
2351
- ? completeBuildRun(cwd, buildRun)
2352
- : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
2353
- clearInterval(heartbeat);
2354
- statusReporter.stop(buildRun.status === "completed" ? "completed" : "paused");
2355
- await foreman.close();
2356
- await viewer;
2357
- activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2358
- observabilityStore?.closeLogFile(logPath, buildRun.status);
2359
- observabilityStore?.enforceLimits();
2360
- observabilityStore?.maintainLogs(cwd);
2361
- observabilityStore?.close();
2362
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2363
- activeObserver = undefined;
2364
- process.off("exit", finishObservationOnExit);
2365
- console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
2366
- console.log(`foreman: outcome — ${result.outcome}`);
2367
- if (result.detail)
2368
- console.log(`foreman: ${result.detail}`);
2369
- if (buildRun.status === "completed")
2370
- printHandoffPruneCommand(buildRun.runId);
2371
- if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
2372
- const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
2373
- const remainingSteps = Math.max(1, result.requested - result.completed);
2374
- const decisionsDb = new WorkflowDb(cwd);
2375
- try {
2376
- for (const line of formatResumeGuidance(executable, cwd, remainingSteps, foreman.builderSessionId(), {
2377
- runId: buildRun.runId, decisions: decisionsDb.pendingHumanDecisions(buildRun.runId),
2378
- }))
2379
- console.log(line);
2603
+ catch (err) {
2604
+ clearInterval(heartbeat);
2605
+ statusReporter.stop("interrupted");
2606
+ if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
2607
+ const summary = err.message.slice(0, 1000);
2608
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
2609
+ status: "recoverable",
2610
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2611
+ }), "recoverable");
2612
+ await foreman.close().catch(() => { });
2613
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary });
2614
+ console.error(`foreman: run paused safely: ${summary}`);
2615
+ console.error(`foreman: after resolving the condition, inspect recovery with: ${formatRecoveryCommand(cwd)}`);
2616
+ process.exit(2);
2380
2617
  }
2381
- finally {
2382
- decisionsDb.close();
2618
+ if (err instanceof SessionUnavailableContinuityError || err instanceof SessionUnavailableError) {
2619
+ const guided = err instanceof SessionUnavailableContinuityError ? err.guidedRecovery : err.failure.dispatchState === "unknown";
2620
+ const summary = err.message.slice(0, 1000);
2621
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
2622
+ status: "recoverable",
2623
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
2624
+ }), "recoverable");
2625
+ await foreman.close().catch(() => { });
2626
+ log.write("session-unavailable", { runId: buildRun.runId, guided, message: summary });
2627
+ console.error(`foreman: exact provider session unavailable; current worktree state was preserved: ${summary}`);
2628
+ console.error(guided
2629
+ ? `foreman: continue with: ${formatRecoveryCommand(cwd)}`
2630
+ : `foreman: inspect recovery with: ${formatRecoveryCommand(cwd)}`);
2631
+ process.exit(2);
2383
2632
  }
2384
- }
2385
- fireTerminalBell(config.notifications.terminal_bell);
2386
- process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
2387
- }
2388
- catch (err) {
2389
- clearInterval(heartbeat);
2390
- statusReporter.stop("interrupted");
2391
- if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
2392
- const summary = err.message.slice(0, 1000);
2393
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
2394
- status: "recoverable",
2395
- failure: { category: "unknown", summary, at: new Date().toISOString() },
2396
- }), "recoverable");
2397
- await foreman.close().catch(() => { });
2398
- log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary });
2399
- console.error(`foreman: run paused safely: ${summary}`);
2400
- console.error(`foreman: after resolving the condition, inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
2401
- process.exit(2);
2402
- }
2403
- if (err instanceof SessionUnavailableContinuityError || err instanceof SessionUnavailableError) {
2404
- const guided = err instanceof SessionUnavailableContinuityError ? err.guidedRecovery : err.failure.dispatchState === "unknown";
2405
- const summary = err.message.slice(0, 1000);
2406
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
2407
- status: "recoverable",
2408
- failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
2409
- }), "recoverable");
2410
- await foreman.close().catch(() => { });
2411
- log.write("session-unavailable", { runId: buildRun.runId, guided, message: summary });
2412
- console.error(`foreman: exact provider session unavailable; current worktree state was preserved: ${summary}`);
2413
- console.error(guided
2414
- ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`
2415
- : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
2416
- process.exit(2);
2417
- }
2418
- if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
2419
- const summary = err.message.slice(0, 1000);
2420
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
2421
- status: "recoverable",
2422
- failure: { category: "unknown", summary, at: new Date().toISOString() },
2423
- }), "recoverable");
2424
- await foreman.close().catch(() => { });
2425
- log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
2426
- console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2427
- console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)}`);
2428
- process.exit(2);
2429
- }
2430
- if (err instanceof HumanDecisionRequired) {
2431
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "waiting-for-human", { status: "recoverable" }), "recoverable");
2633
+ if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
2634
+ const summary = err.message.slice(0, 1000);
2635
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
2636
+ status: "recoverable",
2637
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2638
+ }), "recoverable");
2639
+ await foreman.close().catch(() => { });
2640
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
2641
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2642
+ console.error(`foreman: resume this run with: ${formatRecoveryCommand(cwd)}`);
2643
+ process.exit(2);
2644
+ }
2645
+ if (err instanceof HumanDecisionCancelled) {
2646
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "cancelled", { status: "cancelled" }), "cancelled");
2647
+ activeObserver?.finish("cancelled", { checkpoint: "question-menu" });
2648
+ await foreman.close().catch(() => { });
2649
+ console.log("ai-foreman: cancelled");
2650
+ process.exit(0);
2651
+ }
2652
+ if (err instanceof HumanDecisionRequired) {
2653
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "waiting-for-human", { status: "recoverable" }), "recoverable");
2654
+ await foreman.close().catch(() => { });
2655
+ console.error(err.message);
2656
+ process.exit(2);
2657
+ }
2658
+ if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
2659
+ const summary = err.message.slice(0, 1000);
2660
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
2661
+ status: "recoverable",
2662
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2663
+ }), "recoverable");
2664
+ await foreman.close().catch(() => { });
2665
+ log.write("guided-recovery-required", { runId: buildRun.runId, message: summary });
2666
+ console.error(`foreman: run paused at a recoverable continuity checkpoint: ${summary}`);
2667
+ console.error(`foreman: continue with: ${formatRecoveryCommand(cwd)}`);
2668
+ process.exit(2);
2669
+ }
2670
+ 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");
2432
2671
  await foreman.close().catch(() => { });
2433
- console.error(err.message);
2434
- process.exit(2);
2672
+ log.write("error", { message: String(err) });
2673
+ fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
2435
2674
  }
2436
- if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
2437
- const summary = err.message.slice(0, 1000);
2438
- buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
2439
- status: "recoverable",
2440
- failure: { category: "unknown", summary, at: new Date().toISOString() },
2441
- }), "recoverable");
2442
- await foreman.close().catch(() => { });
2443
- log.write("guided-recovery-required", { runId: buildRun.runId, message: summary });
2444
- console.error(`foreman: run paused at a recoverable continuity checkpoint: ${summary}`);
2445
- console.error(`foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`);
2446
- process.exit(2);
2675
+ }
2676
+ catch (error) {
2677
+ if (error instanceof HumanDecisionCancelled) {
2678
+ console.log("ai-foreman: cancelled");
2679
+ return;
2447
2680
  }
2448
- 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");
2449
- await foreman.close().catch(() => { });
2450
- log.write("error", { message: String(err) });
2451
- fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
2681
+ if (!(error instanceof HumanDecisionRequired))
2682
+ throw error;
2683
+ console.error(error.message);
2684
+ process.exitCode = 2;
2452
2685
  }
2453
- });
2686
+ finally {
2687
+ finishStartAdmission(cwd, supervisedInvocationId);
2688
+ }
2689
+ }));
2454
2690
  }
2455
2691
  function ticketSizeBucket(projectDir, ticketIds) {
2456
2692
  if (!ticketIds.length || !isTicketsInitialized(projectDir))
@@ -2542,8 +2778,11 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
2542
2778
  throw new Error("QA nonconvergence requires its owning build run");
2543
2779
  const scope = createHash("sha256").update(JSON.stringify([buildScopeRevision(projectDir), context.ticket, context.history])).digest("hex");
2544
2780
  let round = 0;
2781
+ let waiverDecisionId;
2545
2782
  const ask = (phase, choices, operation) => durableHumanDecision({
2546
2783
  projectDir, runId, ticketId: context.ticket.id, observer: observer?.(), defer: noninteractive,
2784
+ onAnswered: decision => { if (phase.startsWith("QA waiver confirmation") && decision.selectedChoiceId === "yes")
2785
+ waiverDecisionId = decision.decisionId; },
2547
2786
  key: `qa-nonconvergence:${scope}:${round}:${phase}`, prompt: `${context.ticket.id}: ${phase}`, choices, operation,
2548
2787
  });
2549
2788
  while (true) {
@@ -2564,7 +2803,10 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
2564
2803
  ] }));
2565
2804
  if (approved === "yes") {
2566
2805
  const db = new WorkflowDb(projectDir);
2806
+ let authorizationRef;
2567
2807
  try {
2808
+ if (db.qaPreparationStore().policy(runId)?.mode === "enforce")
2809
+ authorizationRef = db.authorizeContractQaWaiver(runId, context.ticket.id, waiverDecisionId);
2568
2810
  const lease = db.currentLease();
2569
2811
  if (lease) {
2570
2812
  const detail = JSON.stringify({ decision: "waive", ticket: context.ticket.id, unresolved: context.history, at: new Date().toISOString() });
@@ -2576,7 +2818,7 @@ export function createQaNonconvergenceHandler(projectDir, noninteractive, planne
2576
2818
  db.close();
2577
2819
  }
2578
2820
  console.log(`foreman: QA WAIVER recorded for ${context.ticket.id}; validation_result will be failed`);
2579
- return { action: "waive" };
2821
+ return { action: "waive", ...(authorizationRef ? { authorizationRef } : {}) };
2580
2822
  }
2581
2823
  continue;
2582
2824
  }