ai-foreman 1.7.6 → 1.7.8

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 (69) hide show
  1. package/README.md +26 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +31 -6
  4. package/dist/adapters/claude.d.ts +61 -6
  5. package/dist/adapters/claude.js +544 -36
  6. package/dist/adapters/codex.d.ts +28 -1
  7. package/dist/adapters/codex.js +398 -27
  8. package/dist/adapters/recovering.d.ts +13 -2
  9. package/dist/adapters/recovering.js +32 -4
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +117 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +38 -0
  16. package/dist/branch/currentGuard.js +71 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +1079 -43
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +92 -0
  32. package/dist/continuity.js +415 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +546 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +24 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +198 -5
  47. package/dist/sessionLifecycle.js +1373 -20
  48. package/dist/statusReporter.d.ts +84 -8
  49. package/dist/statusReporter.js +270 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +145 -3
  68. package/dist/workflowDb.js +571 -10
  69. package/package.json +32 -4
package/dist/cli/start.js CHANGED
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { resolve, join, relative } from "node:path";
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { execFileSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
5
6
  import { select, text, isCancel } from "@clack/prompts";
6
7
  import { loadConfig } from "../config.js";
7
8
  import { Log } from "../log.js";
@@ -23,16 +24,24 @@ import { formatGitHubFailure, preflightGh } from "../branch/github.js";
23
24
  import { preflightGlab } from "../branch/gitlab.js";
24
25
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
25
26
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
26
- import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
27
+ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
27
28
  import { parse as parseYaml } from "yaml";
28
29
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
29
30
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
30
31
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
31
- import { AgentStatusReporter } from "../statusReporter.js";
32
+ import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
32
33
  import { currentActivity, withActivityPhase } from "../activity.js";
33
34
  import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
34
35
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
35
36
  import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
37
+ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
38
+ import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
39
+ import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
40
+ import { ContextCapabilityError, RoleSessionController } from "../sessionLifecycle.js";
41
+ import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
+ import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
43
+ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
44
+ import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
36
45
  function fail(message) {
37
46
  console.error(`foreman: ${message}`);
38
47
  process.exit(1);
@@ -60,6 +69,14 @@ function collectTicket(value, previous) {
60
69
  function unique(values) {
61
70
  return [...new Set(values)];
62
71
  }
72
+ function legacyBranchSessionRef(session, configRoot, fallbackProvider) {
73
+ const provider = session.agent === "claude" || session.agent === "codex" ? session.agent : fallbackProvider;
74
+ return createProviderSessionRef({
75
+ provider, sessionId: session.sessionId, role: "builder", stream: "builder", generation: 0,
76
+ cwd: session.worktreePath, configRoot, workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
77
+ ticketId: session.ticket, deliveryUnitId: session.deliveryUnitId, source: "legacy-inferred", createdAt: session.ts,
78
+ });
79
+ }
63
80
  export function formatStartResumeCommand(executable, projectDir, steps, sessionId) {
64
81
  return [
65
82
  executable,
@@ -105,12 +122,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
105
122
  ?? deliveryCompletion
106
123
  ?? savedBuild?.completion
107
124
  ?? "none";
125
+ const explicitIsolation = branchFlagOn || createPrFlagOn || optionWasProvided(command, "completion") || Boolean(opts.stacks);
126
+ if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
127
+ completionMode = "none";
108
128
  if (createPrFlagProvided && opts.createPr === false) {
109
129
  completionMode = "none";
110
130
  }
111
131
  const savedBranchMode = savedBuild?.branch_strategy === "branch-per-ticket";
112
132
  let branchMode = Boolean(savedBranchMode || branchFlagOn || createPrFlagOn || completionMode !== "none");
113
- if (!branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
133
+ if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
134
+ branchMode = false;
135
+ if (savedBuild?.branch_strategy !== "current" && !branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
114
136
  branchMode = delivery.unit.branch_mode !== "current";
115
137
  }
116
138
  if (branchFlagProvided && opts.branchPerTicket === false) {
@@ -138,6 +160,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
138
160
  mergeMethod: typeof opts.mergeMethod === "string" ? parseMergeMethod(opts.mergeMethod) : delivery?.unit.merge_method ?? savedBuild?.merge_method ?? "squash",
139
161
  };
140
162
  }
163
+ function resolveStartBranchPrefix(cwd, opts, recovery) {
164
+ const resumed = recovery?.runDecisions?.branchPrefix;
165
+ if (resumed)
166
+ return { value: validateBranchPrefix(resumed), source: "resume" };
167
+ if (typeof opts.branchPrefix === "string")
168
+ return { value: validateBranchPrefix(opts.branchPrefix), source: "cli" };
169
+ const saved = loadTicketSetupConfig(cwd)?.build.branch_prefix;
170
+ if (saved)
171
+ return { value: validateBranchPrefix(saved), source: "project" };
172
+ return { value: BUILTIN_BRANCH_PREFIX, source: "builtin" };
173
+ }
141
174
  function parseMergeMethod(value) {
142
175
  if (["squash", "merge", "rebase"].includes(value))
143
176
  return value;
@@ -268,6 +301,9 @@ export function buildStartCommand() {
268
301
  .option("-m, --model <model>", "override the builder's model")
269
302
  .option("-r, --resume <sessionId>", "resume a prior builder session")
270
303
  .option("--recover-run <id>", "continue an existing master recovery run ID")
304
+ .option("--recovery-mode <mode>", "frozen build:resume mode (internal recovery receipt)")
305
+ .option("--accept-handoff <generation>", "accept a pre-staged cumulative handoff generation")
306
+ .option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
271
307
  .option("--continue", "resume the most recent logged session for this project")
272
308
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
273
309
  .option("-y, --yes", "skip pre-flight confirmation prompt")
@@ -285,7 +321,10 @@ export function buildStartCommand() {
285
321
  .option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
286
322
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
287
323
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
288
- .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "feature")
324
+ .option("--branch-prefix <prefix>", "branch name prefix for ticket branches")
325
+ .option("--show-session-cost", "show authoritative cost or trustworthy cumulative session tokens for Builder and QA")
326
+ .option("--hide-session-cost", "hide session cost/token usage for Builder and QA")
327
+ .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
289
328
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
290
329
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
291
330
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
@@ -335,6 +374,21 @@ export function buildStartCommand() {
335
374
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
336
375
  if (opts.recoverRun && !recoveryRecord)
337
376
  fail(`recoverable build run not found: ${opts.recoverRun}`);
377
+ if (opts.recoveryMode) {
378
+ const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
379
+ if (!allowed.includes(String(opts.recoveryMode)))
380
+ fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
381
+ if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
382
+ fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
383
+ }
384
+ if (opts.acceptHandoff && !recoveryRecord)
385
+ fail("--accept-handoff requires --recover-run");
386
+ let pendingHandoffGeneration = opts.acceptHandoff === undefined ? undefined : Number(opts.acceptHandoff);
387
+ if (pendingHandoffGeneration !== undefined && (!Number.isSafeInteger(pendingHandoffGeneration) || pendingHandoffGeneration < 1))
388
+ fail("--accept-handoff must be a positive generation");
389
+ const pendingHandoffRole = String(opts.acceptHandoffRole ?? "builder");
390
+ if (pendingHandoffRole !== "builder" && pendingHandoffRole !== "qa")
391
+ fail("--accept-handoff-role must be builder or qa");
338
392
  const roleDefaults = readAgentDefaults(cwd);
339
393
  let deliveryRun;
340
394
  let deliveryConfig;
@@ -380,6 +434,14 @@ export function buildStartCommand() {
380
434
  if (stackCount !== undefined && selectedStacks.length !== stackCount)
381
435
  fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
382
436
  const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
437
+ const resolvedPrefix = resolveStartBranchPrefix(cwd, opts, recoveryRecord);
438
+ const branchPrefix = resolvedPrefix.value;
439
+ if (opts.showSessionCost && opts.hideSessionCost)
440
+ fail("choose either --show-session-cost or --hide-session-cost");
441
+ const sessionCostOverride = opts.showSessionCost ? true : opts.hideSessionCost ? false : undefined;
442
+ const thresholdOverride = opts.autoCompactThreshold === undefined ? undefined : Number(opts.autoCompactThreshold);
443
+ if (thresholdOverride !== undefined && (!Number.isInteger(thresholdOverride) || thresholdOverride < 1 || thresholdOverride > 99))
444
+ fail("--auto-compact-threshold must be an integer from 1 to 99");
383
445
  let agent;
384
446
  try {
385
447
  agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
@@ -428,7 +490,18 @@ export function buildStartCommand() {
428
490
  fail("choose either --resume <sessionId> or --continue, not both");
429
491
  }
430
492
  const branchMode = branchDefaults.branchMode;
431
- const continueTickets = recoveryIntent ? requestedTicketIds : [];
493
+ const savedWorkMode = loadTicketSetupConfig(cwd)?.build.branch_strategy ?? "current";
494
+ const workModeSource = recoveryRecord?.runDecisions ? "resume" : branchMode && savedWorkMode === "current" ? "cli" : "project";
495
+ if (!recoveryRecord && branchMode && savedWorkMode === "current") {
496
+ console.log(`foreman: run override — project uses Current branch — Rafi works here; you manage Git; this run explicitly uses an isolated workflow`);
497
+ }
498
+ const continueTickets = recoveryIntent
499
+ ? requestedTicketIds.length > 0
500
+ ? requestedTicketIds
501
+ : branchDefaults.branchMode && recoveryRecord
502
+ ? [...recoveryRecord.tickets]
503
+ : []
504
+ : [];
432
505
  let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
433
506
  const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
434
507
  if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
@@ -457,6 +530,21 @@ export function buildStartCommand() {
457
530
  if (!branchMode && opts.continue && !resumeSessionId) {
458
531
  fail(`no previous session id found under ${join(cwd, ".foreman")}`);
459
532
  }
533
+ let resumeSessionRef;
534
+ if (!branchMode && resumeSessionId) {
535
+ if (recoveryRecord)
536
+ resumeSessionRef = buildRunSessionBinding(recoveryRecord, "builder", resumeSessionId);
537
+ if (!resumeSessionRef) {
538
+ try {
539
+ resumeSessionRef = resolveUniqueSessionBinding(readBuildRuns(cwd).flatMap((run) => run.sessionBindings ?? []), resumeSessionId);
540
+ }
541
+ catch (error) {
542
+ fail(error instanceof Error ? error.message : String(error));
543
+ }
544
+ }
545
+ if (!resumeSessionRef)
546
+ fail(`session ${resumeSessionId} has no stored location-scoped binding; choose fresh recovery with a validated handoff instead of an unverifiable exact resume`);
547
+ }
460
548
  if (selectedNewTicket) {
461
549
  if (!isTicketsInitialized(cwd))
462
550
  fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
@@ -516,34 +604,213 @@ export function buildStartCommand() {
516
604
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
517
605
  const log = new Log(logPath);
518
606
  const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
519
- const createRawBuilder = async (builderCwd, sessionId) => {
520
- const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
607
+ const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
608
+ const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
521
609
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
522
610
  const adapterOpts = {
523
611
  cwd: builderCwd,
524
612
  runtimeExecutable: agentExecutable,
525
613
  runtimePhase: "builder",
526
614
  model,
527
- resumeSessionId: sessionId,
615
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : sessionId ? { resumeSessionId: sessionId } : {}),
616
+ configRoot: cwd,
617
+ sessionRole: "builder",
618
+ sessionStream: sessionRef?.stream ?? "builder",
619
+ sessionGeneration: sessionRef?.generation ?? 0,
620
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
621
+ ticketId: sessionRef?.ticketId,
622
+ deliveryUnitId: sessionRef?.deliveryUnitId,
528
623
  permission: createPermissionHandler(builderPolicy, log),
529
624
  effort: builderEffort,
530
625
  fast: builderFast,
531
626
  systemPromptAppend: roleBundle.system || undefined,
532
627
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
628
+ contextManagementPolicy: {
629
+ role: "builder",
630
+ configuredThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
631
+ compactMaximum: roleDefaults.builder?.compact_maximum ?? 10,
632
+ settingsRevision,
633
+ model: model ?? "default",
634
+ },
533
635
  };
534
- return agent === "codex"
636
+ const adapter = agent === "codex"
535
637
  ? new CodexAdapter(adapterOpts)
536
638
  : await ClaudeAdapter.create(adapterOpts);
639
+ if (sessionRef && adapter.agent === "codex") {
640
+ const availability = await adapter.validateSession();
641
+ if (availability.status !== "available") {
642
+ await adapter.close().catch(() => { });
643
+ 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 });
644
+ }
645
+ }
646
+ return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
647
+ };
648
+ let activeContinuityRunId;
649
+ let liveStatusReporter;
650
+ let activeBuilderForStatus;
651
+ let activeBuilderContextSnapshot;
652
+ const resolvedContinuitySettings = (role) => role === "builder" ? {
653
+ role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
654
+ reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
655
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
656
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
657
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
658
+ } : {
659
+ role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
660
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
661
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
662
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
663
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
664
+ };
665
+ const liveRoleFields = [
666
+ "make", "model", "reasoning", "fast", "session_strategy", "display_session_cost",
667
+ "auto_compact_threshold_percent", "compact_maximum",
668
+ ];
669
+ const observedRoleDefaults = {
670
+ builder: { ...(roleDefaults.builder ?? {}) },
671
+ qa: { ...(roleDefaults.qa ?? {}) },
672
+ };
673
+ const observedProjectRevision = { builder: settingsRevision, qa: settingsRevision };
674
+ const effectiveRoleRevision = { builder: settingsRevision, qa: settingsRevision };
675
+ const liveRoleOverrides = {
676
+ builder: new Map(),
677
+ qa: new Map(),
678
+ };
679
+ const liveRoleSettings = (role, base) => {
680
+ const live = readAgentDefaults(cwd);
681
+ const revision = live.revision ?? 0;
682
+ const defaults = role === "builder" ? live.builder : live.qa;
683
+ if (revision > observedProjectRevision[role]) {
684
+ const prior = observedRoleDefaults[role];
685
+ let roleChanged = false;
686
+ for (const field of liveRoleFields) {
687
+ const value = defaults?.[field];
688
+ if (!Object.is(value, prior[field])) {
689
+ liveRoleOverrides[role].set(field, value);
690
+ roleChanged = true;
691
+ }
692
+ }
693
+ const revisionDb = new WorkflowDb(cwd);
694
+ let targetedFields;
695
+ try {
696
+ targetedFields = revisionDb.projectSettingsRevisionTargets(revision)[role] ?? [];
697
+ }
698
+ finally {
699
+ revisionDb.close();
700
+ }
701
+ for (const field of targetedFields) {
702
+ if (liveRoleFields.includes(field)) {
703
+ const typedField = field;
704
+ liveRoleOverrides[role].set(typedField, defaults?.[typedField]);
705
+ }
706
+ }
707
+ observedRoleDefaults[role] = { ...(defaults ?? {}) };
708
+ observedProjectRevision[role] = revision;
709
+ if (roleChanged || targetedFields.length)
710
+ effectiveRoleRevision[role] = revision;
711
+ }
712
+ if (effectiveRoleRevision[role] <= base.settings_revision)
713
+ return base;
714
+ const merged = {
715
+ ...base,
716
+ source: defaults ? "project" : base.source,
717
+ settings_revision: effectiveRoleRevision[role],
718
+ };
719
+ const mutable = merged;
720
+ for (const [field, value] of liveRoleOverrides[role]) {
721
+ if (value === undefined)
722
+ continue;
723
+ mutable[field] = value;
724
+ }
725
+ return merged;
726
+ };
727
+ const createBuilderForSettings = async (builderCwd, settings) => {
728
+ const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
729
+ label: "live Builder settings",
730
+ yes: true,
731
+ allowSwitch: false,
732
+ model: settings.model === "default" ? undefined : settings.model,
733
+ });
734
+ const policy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
735
+ const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
736
+ const adapterOptions = {
737
+ cwd: builderCwd,
738
+ configRoot: cwd,
739
+ sessionRole: "builder",
740
+ sessionStream: "builder",
741
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
742
+ runtimeExecutable: ready.executable,
743
+ runtimePhase: "builder",
744
+ model: settings.model === "default" ? undefined : settings.model,
745
+ permission: createPermissionHandler(policy, log),
746
+ effort: explicitEffort(settings.reasoning),
747
+ fast: settings.fast,
748
+ systemPromptAppend: roleBundle.system || undefined,
749
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
750
+ contextManagementPolicy: {
751
+ role: "builder", configuredThresholdPercent: settings.auto_compact_threshold_percent,
752
+ compactMaximum: settings.compact_maximum, settingsRevision: settings.settings_revision, model: settings.model,
753
+ },
754
+ };
755
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
756
+ return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
757
+ };
758
+ const createQaForSettings = async (qaCwd, settings) => {
759
+ const ready = await ensureRuntimeReadyForCommand(qaCwd, settings.make, {
760
+ label: "live QA settings",
761
+ yes: true,
762
+ allowSwitch: false,
763
+ model: settings.model === "default" ? undefined : settings.model,
764
+ });
765
+ const policy = new PermissionPolicy(config.permissions, qaCwd);
766
+ const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
767
+ const adapterOptions = {
768
+ cwd: qaCwd,
769
+ configRoot: cwd,
770
+ sessionRole: "qa",
771
+ sessionStream: "qa",
772
+ runtimeExecutable: ready.executable,
773
+ runtimePhase: "qa",
774
+ model: settings.model === "default" ? undefined : settings.model,
775
+ permission: createPermissionHandler(policy, log),
776
+ effort: explicitEffort(settings.reasoning),
777
+ fast: settings.fast,
778
+ systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
779
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
780
+ contextManagementPolicy: {
781
+ role: "qa", configuredThresholdPercent: settings.auto_compact_threshold_percent,
782
+ compactMaximum: settings.compact_maximum, settingsRevision: settings.settings_revision, model: settings.model,
783
+ },
784
+ };
785
+ return settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
537
786
  };
538
- const createBuilder = async (builderCwd, sessionId) => {
539
- const initial = await createRawBuilder(builderCwd, sessionId);
787
+ const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
788
+ if (sessionId && !sessionRef) {
789
+ const availability = {
790
+ version: 1,
791
+ status: "unknown",
792
+ checkedAt: new Date().toISOString(),
793
+ reason: "legacy-unscoped",
794
+ detail: "Builder recovery cannot authorize an exact provider resume from a raw session ID",
795
+ };
796
+ throw new SessionUnavailableError({
797
+ runtime: agent,
798
+ phase: "preflight",
799
+ dispatchState: "not-sent",
800
+ executable: agentExecutable ?? agent,
801
+ cwd: builderCwd,
802
+ diagnostics: availability.detail,
803
+ availability,
804
+ });
805
+ }
806
+ const initial = await createRawBuilder(builderCwd, sessionId, sessionRef);
540
807
  return new RecoveringAdapter({
541
808
  initial,
542
809
  runtime: agent,
543
810
  label: "builder turn",
544
811
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
545
812
  allowSwitch: !(opts.resume || opts.continue),
546
- recreate: async (nextRuntime, resumeSessionId) => {
813
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
547
814
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
548
815
  label: "builder recovery",
549
816
  allowSwitch: false,
@@ -552,38 +819,195 @@ export function buildStartCommand() {
552
819
  agent = nextReady.runtime;
553
820
  model = nextReady.model;
554
821
  agentExecutable = nextReady.executable;
555
- return createRawBuilder(builderCwd, resumeSessionId);
822
+ return createRawBuilder(builderCwd, resumeSessionId, resumeRef);
823
+ },
824
+ });
825
+ };
826
+ const continuousBuilder = (adapter, builderCwd, runId, settings = resolvedContinuitySettings("builder"), replaceRecoveryLeaseAfterCheckpoint = false) => new ContinuityAdapter({
827
+ adapter,
828
+ projectDir: cwd,
829
+ runId,
830
+ role: "builder",
831
+ settings,
832
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settings.settings_revision,
833
+ createSuccessor: () => createRecoveringBuilder(builderCwd),
834
+ replaceRecoveryLeaseAfterCheckpoint,
835
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
836
+ const effectiveSettings = liveRoleSettings("builder", settings);
837
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
838
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
839
+ const sessionId = predecessor.sessionId();
840
+ const recoveryDb = new WorkflowDb(cwd);
841
+ let compactionCount = 0;
842
+ try {
843
+ compactionCount = recoveryDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
844
+ }
845
+ finally {
846
+ recoveryDb.close();
847
+ }
848
+ const transfer = await new HandoffService(cwd).transfer({
849
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: predecessor.sessionRef?.(),
850
+ allowNonCurrentContinuity: true,
851
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
852
+ ...(nativeUsage ? { sessionUsage: {
853
+ version: 1, runId, role: "builder", provider: predecessor.agent,
854
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
855
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
856
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
857
+ } } : {}),
858
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
859
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
860
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
861
+ log.write("handoff-transfer", {
862
+ role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
863
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
864
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
865
+ });
866
+ return transfer.successor;
867
+ },
868
+ handleHandoffRequest: async (output, predecessor, frozenAction) => {
869
+ const request = parseBuilderHandoffRequest(output);
870
+ if (!request)
871
+ return undefined;
872
+ const effectiveSettings = liveRoleSettings("builder", settings);
873
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
874
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
875
+ const sessionId = predecessor.sessionId();
876
+ const countDb = new WorkflowDb(cwd);
877
+ let compactionCount = 0;
878
+ try {
879
+ const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
880
+ countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
881
+ countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
882
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
883
+ }
884
+ finally {
885
+ countDb.close();
886
+ }
887
+ const transfer = await new HandoffService(cwd).transfer({
888
+ runId,
889
+ role: "builder",
890
+ reason: request.reason,
891
+ predecessorSessionId: sessionId,
892
+ predecessorSessionRef: predecessor.sessionRef?.(),
893
+ requestedByBuilder: true,
894
+ roleState: {
895
+ ...request.roleState,
896
+ frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
897
+ ...(context ? { occupancy: context } : { occupancy: "unavailable" }),
898
+ },
899
+ ...(nativeUsage ? { sessionUsage: {
900
+ version: 1, runId, role: "builder", provider: predecessor.agent,
901
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
902
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
903
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
904
+ } } : {}),
905
+ compactionCount,
906
+ compactMaximum: effectiveSettings.compact_maximum,
907
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
908
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
909
+ log.write("handoff-transfer", {
910
+ requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
911
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
912
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
913
+ });
914
+ return transfer.successor;
915
+ },
916
+ });
917
+ const createBuilder = async (builderCwd, sessionId, sessionRef) => {
918
+ const recoveryMode = String(opts.recoveryMode ?? "");
919
+ const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
920
+ let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
921
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
922
+ const service = new HandoffService(cwd);
923
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
924
+ adapter = accepted.successor;
925
+ pendingHandoffGeneration = undefined;
926
+ log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
927
+ }
928
+ return activeContinuityRunId ? continuousBuilder(adapter, builderCwd, activeContinuityRunId, undefined, String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role !== "qa") : adapter;
929
+ };
930
+ const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
931
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
932
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
933
+ const sessionId = input.adapter.sessionId();
934
+ const countDb = new WorkflowDb(cwd);
935
+ let compactionCount = 0;
936
+ try {
937
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", input.adapter.sessionRef?.() ?? sessionId);
938
+ }
939
+ finally {
940
+ countDb.close();
941
+ }
942
+ 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}`;
943
+ const transfer = await new HandoffService(cwd).transfer({
944
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
945
+ roleState: {
946
+ settingsFrom: input.current, settingsTo: input.next,
947
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
948
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
556
949
  },
950
+ ...(nativeUsage ? { sessionUsage: {
951
+ version: 1, runId, role: "builder", provider: input.adapter.agent,
952
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
953
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
954
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
955
+ } } : {}),
956
+ compactionCount, compactMaximum: input.next.compact_maximum,
957
+ resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
958
+ }, () => createBuilderForSettings(builderCwd, input.next));
959
+ log.write("handoff-transfer", {
960
+ role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
961
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
962
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
557
963
  });
964
+ if (input.adapter instanceof ContinuityAdapter) {
965
+ await input.adapter.adoptValidatedSuccessor(transfer.successor);
966
+ return input.adapter;
967
+ }
968
+ await input.adapter.close().catch(() => { });
969
+ return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
558
970
  };
559
- const createRawQa = async (qaCwd, sessionId) => {
971
+ const createRawQa = async (qaCwd, sessionRef) => {
560
972
  const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
561
973
  const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
562
974
  const adapterOpts = {
563
975
  cwd: qaCwd,
976
+ configRoot: cwd,
977
+ sessionRole: "qa",
978
+ sessionStream: "qa",
564
979
  runtimeExecutable: qaExecutable,
565
980
  runtimePhase: "qa",
566
981
  model: qaModel,
567
- resumeSessionId: sessionId,
982
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
983
+ sessionGeneration: sessionRef?.generation ?? 0,
984
+ workspaceIdentity: captureWorkspaceIdentity(qaCwd),
568
985
  permission: createPermissionHandler(qaPolicy, log),
569
986
  effort: qaEffort,
570
987
  fast: qaFast,
571
988
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
572
989
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
990
+ contextManagementPolicy: {
991
+ role: "qa",
992
+ configuredThresholdPercent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
993
+ compactMaximum: roleDefaults.qa?.compact_maximum ?? 10,
994
+ settingsRevision,
995
+ model: qaModel ?? "default",
996
+ },
573
997
  };
574
998
  return qaAgent === "codex"
575
999
  ? new CodexAdapter(adapterOpts)
576
1000
  : await ClaudeAdapter.create(adapterOpts);
577
1001
  };
578
- const createQa = async (qaCwd, sessionId) => {
579
- const initial = await createRawQa(qaCwd, sessionId);
1002
+ const createRecoveringQa = async (qaCwd, sessionRef) => {
1003
+ const initial = await createRawQa(qaCwd, sessionRef);
580
1004
  return new RecoveringAdapter({
581
1005
  initial,
582
1006
  runtime: qaAgent,
583
1007
  label: "QA turn",
584
1008
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
585
- allowSwitch: !sessionId,
586
- recreate: async (nextRuntime, resumeSessionId) => {
1009
+ allowSwitch: !sessionRef,
1010
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
587
1011
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
588
1012
  label: "QA recovery",
589
1013
  allowSwitch: false,
@@ -592,9 +1016,244 @@ export function buildStartCommand() {
592
1016
  qaAgent = nextReady.runtime;
593
1017
  qaModel = nextReady.model;
594
1018
  qaExecutable = nextReady.executable;
595
- return createRawQa(qaCwd, resumeSessionId);
1019
+ if (resumeSessionId && !resumeRef) {
1020
+ const availability = {
1021
+ version: 1,
1022
+ status: "unknown",
1023
+ checkedAt: new Date().toISOString(),
1024
+ reason: "legacy-unscoped",
1025
+ detail: "QA retry cannot authorize an exact provider resume from a raw session ID",
1026
+ };
1027
+ throw new SessionUnavailableError({
1028
+ runtime: nextRuntime,
1029
+ phase: "preflight",
1030
+ dispatchState: "not-sent",
1031
+ executable: nextReady.executable,
1032
+ cwd: qaCwd,
1033
+ diagnostics: availability.detail,
1034
+ availability,
1035
+ });
1036
+ }
1037
+ return createRawQa(qaCwd, resumeRef);
1038
+ },
1039
+ });
1040
+ };
1041
+ const decorateQa = (adapter, qaCwd, sessionId, settingsOverride) => {
1042
+ const qaSettings = settingsOverride ?? resolvedContinuitySettings("qa");
1043
+ const continuous = activeContinuityRunId ? new ContinuityAdapter({
1044
+ adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
1045
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
1046
+ createSuccessor: () => createRecoveringQa(qaCwd),
1047
+ replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
1048
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
1049
+ const effectiveSettings = liveRoleSettings("qa", qaSettings);
1050
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
1051
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1052
+ const predecessorSessionId = predecessor.sessionId();
1053
+ const recoveryDb = new WorkflowDb(cwd);
1054
+ let compactionCount = 0;
1055
+ try {
1056
+ compactionCount = recoveryDb.successfulCompactionCount(activeContinuityRunId, "qa", predecessor.sessionRef?.() ?? predecessorSessionId);
1057
+ }
1058
+ finally {
1059
+ recoveryDb.close();
1060
+ }
1061
+ const transfer = await new HandoffService(cwd).transfer({
1062
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId, predecessorSessionRef: predecessor.sessionRef?.(),
1063
+ allowNonCurrentContinuity: true,
1064
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
1065
+ ...(nativeUsage ? { sessionUsage: {
1066
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1067
+ providerSessionId: predecessorSessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1068
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1069
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1070
+ } } : {}),
1071
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1072
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1073
+ }, () => createQaForSettings(qaCwd, effectiveSettings));
1074
+ log.write("handoff-transfer", {
1075
+ role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1076
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1077
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1078
+ });
1079
+ return transfer.successor;
1080
+ },
1081
+ }) : adapter;
1082
+ return new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1083
+ role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1084
+ phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1085
+ settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
1086
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1087
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "qa", active.sessionRef?.() ?? active.sessionId()) : 0;
1088
+ }
1089
+ finally {
1090
+ db.close();
1091
+ } },
1092
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1093
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1094
+ }
1095
+ finally {
1096
+ db.close();
1097
+ } },
1098
+ }), () => {
1099
+ const active = activeBuilderForStatus?.();
1100
+ if (active)
1101
+ liveStatusReporter?.updateState({
1102
+ role: "builder", provider: active.agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1103
+ phase: "builder work session", sessionTransition: "builder session", adapter: active,
1104
+ settingsRevision, displaySessionCost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1105
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1106
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "builder", active.sessionRef?.() ?? active.sessionId()) : 0;
1107
+ }
1108
+ finally {
1109
+ db.close();
1110
+ } },
1111
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1112
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1113
+ }
1114
+ finally {
1115
+ db.close();
1116
+ } },
1117
+ contextSnapshot: activeBuilderContextSnapshot,
1118
+ });
1119
+ });
1120
+ };
1121
+ const createQa = async (qaCwd, sessionId) => {
1122
+ const recoveryMode = String(opts.recoveryMode ?? "");
1123
+ // A disposable QA cwd is never eligible for exact reuse, even when a
1124
+ // compatibility caller still supplies the previous raw ID.
1125
+ const effectiveSessionId = undefined;
1126
+ void sessionId;
1127
+ let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1128
+ let explicitRecoveryAccepted = false;
1129
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1130
+ const service = new HandoffService(cwd);
1131
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1132
+ adapter = accepted.successor;
1133
+ pendingHandoffGeneration = undefined;
1134
+ explicitRecoveryAccepted = true;
1135
+ log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1136
+ }
1137
+ // A new disposable QA worktree always means a new provider session.
1138
+ // Transfer cumulative QA state by accepting a durable handoff in that
1139
+ // fresh session; never attach the old session in the new /tmp cwd.
1140
+ if (!explicitRecoveryAccepted && activeContinuityRunId && recoveryMode !== "fresh-recovery-only") {
1141
+ const stateDb = new WorkflowDb(cwd);
1142
+ const priorLease = stateDb.roleMutationLease(activeContinuityRunId, "qa");
1143
+ const head = stateDb.continuityHead(activeContinuityRunId, "qa");
1144
+ const checkpoint = stateDb.latestContinuityCheckpoint(activeContinuityRunId, "qa");
1145
+ const compactionCount = priorLease
1146
+ ? stateDb.successfulCompactionCount(activeContinuityRunId, "qa", priorLease.sessionRef ?? priorLease.providerSessionId)
1147
+ : 0;
1148
+ stateDb.close();
1149
+ if (priorLease) {
1150
+ if (!head || head.state !== "current" || !checkpoint) {
1151
+ await adapter.close().catch(() => { });
1152
+ throw new ContinuityRecoveryRequiredError(activeContinuityRunId, "qa", `cannot transfer cumulative QA state from a ${head?.state ?? "missing"} checkpoint`);
1153
+ }
1154
+ const service = new HandoffService(cwd);
1155
+ const staged = service.stage({
1156
+ runId: activeContinuityRunId,
1157
+ role: "qa",
1158
+ reason: "new disposable QA snapshot requires a fresh location-scoped provider session",
1159
+ predecessorSessionId: priorLease.providerSessionId,
1160
+ predecessorSessionRef: priorLease.sessionRef,
1161
+ roleState: { predecessorGeneration: priorLease.generation, successorSnapshotCwd: qaCwd },
1162
+ compactionCount,
1163
+ compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1164
+ });
1165
+ const accepted = await service.acceptStaged(staged, adapter);
1166
+ adapter = accepted.successor;
1167
+ log.write("handoff-transfer", {
1168
+ role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1169
+ predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
1170
+ acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest,
1171
+ });
1172
+ }
1173
+ }
1174
+ return decorateQa(adapter, qaCwd, effectiveSessionId);
1175
+ };
1176
+ const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1177
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
1178
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1179
+ const sessionId = input.adapter.sessionId();
1180
+ const countDb = new WorkflowDb(cwd);
1181
+ let compactionCount = 0;
1182
+ try {
1183
+ compactionCount = countDb.successfulCompactionCount(runId, "qa", input.adapter.sessionRef?.() ?? sessionId);
1184
+ }
1185
+ finally {
1186
+ countDb.close();
1187
+ }
1188
+ 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}`;
1189
+ const transfer = await new HandoffService(cwd).transfer({
1190
+ runId, role: "qa", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1191
+ roleState: {
1192
+ settingsFrom: input.current, settingsTo: input.next,
1193
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1194
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1195
+ },
1196
+ ...(nativeUsage ? { sessionUsage: {
1197
+ version: 1, runId, role: "qa", provider: input.adapter.agent,
1198
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1199
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1200
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1201
+ } } : {}),
1202
+ compactionCount, compactMaximum: input.next.compact_maximum,
1203
+ resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1204
+ }, () => createQaForSettings(qaCwd, input.next));
1205
+ log.write("handoff-transfer", {
1206
+ role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1207
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1208
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1209
+ });
1210
+ await input.adapter.close().catch(() => { });
1211
+ return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1212
+ };
1213
+ const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
1214
+ if (!activeContinuityRunId)
1215
+ throw new Error("QA session boundary requires an active durable run");
1216
+ const qaSettings = resolvedContinuitySettings("qa");
1217
+ const controller = RoleSessionController.managed({
1218
+ projectDir: cwd,
1219
+ runId: activeContinuityRunId,
1220
+ role: "qa",
1221
+ requireNativeContextManagement: true,
1222
+ initialSettings: qaSettings,
1223
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1224
+ readSettings: () => liveRoleSettings("qa", qaSettings),
1225
+ settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1226
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1227
+ role: "qa", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1228
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1229
+ sessionTransition: "adopted live settings",
1230
+ }),
1231
+ report: (event) => {
1232
+ currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1233
+ log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1234
+ void liveStatusReporter?.tick();
1235
+ },
1236
+ handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1237
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1238
+ const transfer = await new HandoffService(cwd).transfer({
1239
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1240
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1241
+ ...(nativeUsage ? { sessionUsage: {
1242
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1243
+ providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1244
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1245
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1246
+ } } : {}),
1247
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1248
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1249
+ }, () => createQaForSettings(qaCwd, settings));
1250
+ log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1251
+ await predecessor.close().catch(() => { });
1252
+ return decorateQa(transfer.successor, qaCwd, undefined, settings);
596
1253
  },
597
1254
  });
1255
+ liveStatusReporter?.updateState({ contextSnapshot: () => controller.contextSnapshot() });
1256
+ return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
598
1257
  };
599
1258
  const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
600
1259
  if (branchMode) {
@@ -658,7 +1317,16 @@ export function buildStartCommand() {
658
1317
  if (!ticket)
659
1318
  fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
660
1319
  const sessionId = opts.resume ?? session.sessionId;
661
- resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId });
1320
+ let sessionRef = session.sessionRef;
1321
+ if (sessionRef && sessionRef.sessionId !== sessionId)
1322
+ sessionRef = undefined;
1323
+ if (!sessionRef && recoveryRecord)
1324
+ sessionRef = buildRunSessionBinding(recoveryRecord, "builder", sessionId);
1325
+ if (!sessionRef && sessionId === session.sessionId)
1326
+ sessionRef = legacyBranchSessionRef(session, cwd, agent);
1327
+ if (!sessionRef)
1328
+ fail(`session ${sessionId} for ${ticketId} has no unique location-scoped binding; use fresh-with-handoff recovery`);
1329
+ resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId, sessionRef });
662
1330
  nodes.push({
663
1331
  ticket,
664
1332
  branch: session.branch,
@@ -681,55 +1349,131 @@ export function buildStartCommand() {
681
1349
  plan = buildBranchPlan(tickets, states, {
682
1350
  steps,
683
1351
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
684
- branchPrefix: opts.branchPrefix,
1352
+ branchPrefix,
685
1353
  maxBranchDepth,
686
1354
  rootBaseBranches: branchDefaults.rootBaseBranches,
687
1355
  ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
688
1356
  });
689
1357
  let auditBuilder;
690
1358
  let auditViewer;
1359
+ let auditCompleted = false;
1360
+ const auditRunId = `audit-${stamp}`;
1361
+ activeContinuityRunId = auditRunId;
691
1362
  try {
1363
+ const auditSettings = resolvedContinuitySettings("builder");
1364
+ const auditInstruction = buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket));
1365
+ const auditDb = new WorkflowDb(cwd);
1366
+ try {
1367
+ auditDb.ensureRun(auditRunId);
1368
+ auditDb.appendContinuityEvent({
1369
+ runId: auditRunId, role: "host", kind: "branch_audit_initialized",
1370
+ payload: { frozenActionDigest: createHash("sha256").update(auditInstruction).digest("hex") },
1371
+ authoritativeStateRevision: auditSettings.settings_revision,
1372
+ });
1373
+ auditDb.publishContinuityCheckpoint({
1374
+ runId: auditRunId, role: "builder", authoritativeStateRevision: auditSettings.settings_revision,
1375
+ delta: {
1376
+ version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [],
1377
+ openWork: ["complete the selected-ticket dependency audit"], nextAction: "complete the selected-ticket dependency audit",
1378
+ },
1379
+ });
1380
+ }
1381
+ finally {
1382
+ auditDb.close();
1383
+ }
692
1384
  auditBuilder = await createBuilder(cwd);
1385
+ const auditController = RoleSessionController.managed({
1386
+ projectDir: cwd, runId: auditRunId, role: "builder", initialSettings: auditSettings,
1387
+ requireNativeContextManagement: true,
1388
+ readSettings: () => liveRoleSettings("builder", auditSettings),
1389
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(auditRunId, cwd, input),
1390
+ report: (event) => log.write("context-lifecycle", { phase: "branch-audit", ...event, sample: event.sample }),
1391
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1392
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1393
+ const transfer = await new HandoffService(cwd).transfer({
1394
+ runId: auditRunId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1395
+ roleState: { phase: "branch-audit", frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1396
+ ...(nativeUsage ? { sessionUsage: {
1397
+ version: 1, runId: auditRunId, role: "builder", provider: adapter.agent,
1398
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1399
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1400
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1401
+ } } : {}),
1402
+ compactionCount, compactMaximum: settings.compact_maximum,
1403
+ resources: [{ label: "frozen-branch-audit", content: frozenAction, authoritative: true }],
1404
+ }, () => createBuilderForSettings(cwd, settings));
1405
+ log.write("handoff-transfer", {
1406
+ phase: "branch-audit", reason, generation: transfer.manifest.generation,
1407
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1408
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1409
+ });
1410
+ if (adapter instanceof ContinuityAdapter) {
1411
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1412
+ return adapter;
1413
+ }
1414
+ await adapter.close().catch(() => { });
1415
+ return continuousBuilder(transfer.successor, cwd, auditRunId, settings);
1416
+ },
1417
+ });
1418
+ auditBuilder = (await auditController.atSafeBoundary(auditBuilder, auditInstruction)).adapter;
693
1419
  auditViewer = printEvents(auditBuilder.events());
694
- const audit = await auditBuilder.sendTurn(buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)));
1420
+ const audit = await auditBuilder.sendTurn(auditInstruction);
695
1421
  if (audit.isError)
696
1422
  throw new Error(audit.text);
697
1423
  const auditDependencies = parseAuditDependencies(audit.text);
698
1424
  plan = buildBranchPlan(tickets, states, {
699
1425
  steps,
700
1426
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
701
- branchPrefix: opts.branchPrefix,
1427
+ branchPrefix,
702
1428
  maxBranchDepth,
703
1429
  auditDependencies,
704
1430
  rootBaseBranches: branchDefaults.rootBaseBranches,
705
1431
  ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
706
1432
  });
707
1433
  auditDependencyCount = auditDependencies.length;
1434
+ auditCompleted = true;
708
1435
  }
709
1436
  finally {
710
1437
  await auditBuilder?.close().catch(() => { });
711
1438
  await auditViewer?.catch(() => { });
1439
+ const auditDb = new WorkflowDb(cwd);
1440
+ try {
1441
+ if (auditDb.getRun(auditRunId))
1442
+ auditDb.transition(auditRunId, auditCompleted
1443
+ ? { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" }
1444
+ : { status: "paused", checkpoint: "branch-audit-interrupted", remainingWork: { action: "complete the selected-ticket dependency audit" }, event: "audit_interrupted" });
1445
+ }
1446
+ finally {
1447
+ auditDb.close();
1448
+ }
1449
+ activeContinuityRunId = undefined;
712
1450
  }
713
1451
  if (deliveryRun?.unit.branch_mode === "shared") {
714
- plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, opts.branchPrefix);
1452
+ plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
715
1453
  const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
716
1454
  const first = plan.nodes[0];
717
1455
  if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
718
- resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
1456
+ 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" });
1457
+ resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId, sessionRef });
719
1458
  }
720
1459
  }
721
1460
  if (selectedStacks.length && deliveryConfig) {
722
- plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
1461
+ plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
723
1462
  }
724
1463
  else if (!deliveryRun) {
725
1464
  const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
726
1465
  if (branchPolicy)
727
- plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, opts.branchPrefix);
1466
+ plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
728
1467
  }
729
1468
  if (recoveryRecord && opts.resume && plan.nodes[0]) {
730
1469
  const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
731
- if (prior)
732
- resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: opts.resume });
1470
+ if (prior) {
1471
+ const requested = String(opts.resume);
1472
+ const sessionRef = prior.sessionRef?.sessionId === requested ? prior.sessionRef : buildRunSessionBinding(recoveryRecord, "builder", requested) ?? (prior.sessionId === requested ? legacyBranchSessionRef(prior, cwd, agent) : undefined);
1473
+ if (!sessionRef)
1474
+ fail(`session ${requested} has no stored scope for ${plan.nodes[0].ticket.id}`);
1475
+ resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: requested, sessionRef });
1476
+ }
733
1477
  }
734
1478
  }
735
1479
  const branchPresentation = presentBranchPlan(plan, {
@@ -772,21 +1516,86 @@ export function buildStartCommand() {
772
1516
  process.exit(0);
773
1517
  }
774
1518
  }
1519
+ for (const [ticketId, session] of resumeSessionByTicket) {
1520
+ const ref = session.sessionRef;
1521
+ if (!ref)
1522
+ fail(`session ${session.sessionId} for ${ticketId} has no location-scoped binding`);
1523
+ if (ref.provider !== agent)
1524
+ fail(`session ${ref.sessionId} belongs to ${ref.provider}, but this recovery selected ${agent}`);
1525
+ const availability = await resolveProviderSessionAvailability(ref, {
1526
+ cwd: session.worktreePath,
1527
+ configRoot: cwd,
1528
+ workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
1529
+ runtimeExecutable: agentExecutable,
1530
+ });
1531
+ if (availability.status !== "available" || !availability.sessionRef) {
1532
+ 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.`);
1533
+ }
1534
+ resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1535
+ }
775
1536
  const capturedBranchBuilder = {
776
1537
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
777
1538
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
778
1539
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1540
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1541
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1542
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
779
1543
  };
780
1544
  const capturedBranchQa = {
781
1545
  role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
782
1546
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1547
+ display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1548
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
1549
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
783
1550
  };
784
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
1551
+ const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1552
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
785
1553
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
786
1554
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1555
+ runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
787
1556
  });
1557
+ activeContinuityRunId = masterRun.runId;
1558
+ const branchContextControllers = new Map();
1559
+ const branchContextController = (worktreePath) => {
1560
+ const existing = branchContextControllers.get(worktreePath);
1561
+ if (existing)
1562
+ return existing;
1563
+ const controller = RoleSessionController.managed({
1564
+ projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1565
+ requireNativeContextManagement: true,
1566
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1567
+ readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1568
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1569
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1570
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1571
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1572
+ sessionTransition: "adopted live settings",
1573
+ }),
1574
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); void liveStatusReporter?.tick(); },
1575
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1576
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1577
+ const transfer = await new HandoffService(cwd).transfer({
1578
+ runId: masterRun.runId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1579
+ roleState: { worktreePath, frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1580
+ ...(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 } } : {}),
1581
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1582
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1583
+ }, () => createBuilderForSettings(worktreePath, settings));
1584
+ log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1585
+ if (adapter instanceof ContinuityAdapter) {
1586
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1587
+ return adapter;
1588
+ }
1589
+ await adapter.close().catch(() => { });
1590
+ return continuousBuilder(transfer.successor, worktreePath, masterRun.runId, settings);
1591
+ },
1592
+ });
1593
+ branchContextControllers.set(worktreePath, controller);
1594
+ return controller;
1595
+ };
788
1596
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
789
1597
  branchHeartbeat.unref();
1598
+ let branchSessionFailure;
790
1599
  const summaries = await runBranchPlan({
791
1600
  projectDir: cwd,
792
1601
  runId: masterRun.runId,
@@ -814,13 +1623,85 @@ export function buildStartCommand() {
814
1623
  archiveDoc: ticketsConfig.paths.archiveDoc,
815
1624
  },
816
1625
  resumeSessions: resumeSessionByTicket,
817
- createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
1626
+ createBuilder: async (builderCwd, sessionId, sessionRef) => branchContextController(builderCwd).manage(await createBuilder(builderCwd, sessionId, sessionRef)),
1627
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1628
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1629
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
818
1630
  createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
819
1631
  builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
820
1632
  qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
821
- observeBuilder: (builder) => printEvents(builder.events()),
1633
+ observeBuilder: async (builder) => {
1634
+ liveStatusReporter?.stop();
1635
+ activeBuilderForStatus = () => builder;
1636
+ activeBuilderContextSnapshot = () => [...branchContextControllers.values()]
1637
+ .map((controller) => controller.contextSnapshot())
1638
+ .find((snapshot) => snapshot?.contextSample.providerSessionId === builder.sessionId());
1639
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1640
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1641
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1642
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1643
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1644
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1645
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1646
+ }
1647
+ finally {
1648
+ db.close();
1649
+ } },
1650
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1651
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1652
+ }
1653
+ finally {
1654
+ db.close();
1655
+ } },
1656
+ contextSnapshot: activeBuilderContextSnapshot,
1657
+ }, (line, snapshot) => {
1658
+ const activity = currentActivity();
1659
+ if (activity && process.stdout.isTTY)
1660
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1661
+ else
1662
+ console.log(line);
1663
+ log.write("agent-status", { ...snapshot });
1664
+ const db = new WorkflowDb(cwd);
1665
+ try {
1666
+ db.recordTelemetry(masterRun.runId, snapshot);
1667
+ if (snapshot.sessionUsage)
1668
+ db.recordSessionUsage(snapshot.sessionUsage);
1669
+ }
1670
+ finally {
1671
+ db.close();
1672
+ }
1673
+ });
1674
+ reporter.start();
1675
+ try {
1676
+ await printEvents(builder.events());
1677
+ }
1678
+ finally {
1679
+ reporter.stop();
1680
+ }
1681
+ },
822
1682
  qaNonconvergence,
1683
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1684
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1685
+ qaSessionBoundary,
823
1686
  });
1687
+ if (branchSessionFailure) {
1688
+ const failure = branchSessionFailure;
1689
+ const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
1690
+ const summary = failure.message.slice(0, 1000);
1691
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
1692
+ status: "recoverable",
1693
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1694
+ }), "recoverable");
1695
+ clearInterval(branchHeartbeat);
1696
+ log.write("session-unavailable", { runId: masterRun.runId, guided, message: summary });
1697
+ console.error(`foreman: exact provider session unavailable; preserved branch work remains recoverable: ${summary}`);
1698
+ console.error(guided
1699
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --guided-recovery`
1700
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --inspect`);
1701
+ process.exit(2);
1702
+ }
1703
+ if (pendingHandoffGeneration !== undefined)
1704
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
824
1705
  clearInterval(branchHeartbeat);
825
1706
  if (selectedStacks.length) {
826
1707
  let allComplete = true;
@@ -864,6 +1745,8 @@ export function buildStartCommand() {
864
1745
  console.log(` ${command}`);
865
1746
  }
866
1747
  }
1748
+ if (masterRun.status === "completed")
1749
+ printHandoffPruneCommand(masterRun.runId);
867
1750
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
868
1751
  process.exit(failed ? 2 : 0);
869
1752
  }
@@ -876,6 +1759,20 @@ export function buildStartCommand() {
876
1759
  agent = ready.runtime;
877
1760
  model = ready.model;
878
1761
  agentExecutable = ready.executable;
1762
+ if (resumeSessionRef) {
1763
+ if (resumeSessionRef.provider !== agent)
1764
+ fail(`session ${resumeSessionRef.sessionId} belongs to ${resumeSessionRef.provider}, but this recovery selected ${agent}`);
1765
+ const availability = await resolveProviderSessionAvailability(resumeSessionRef, {
1766
+ cwd,
1767
+ configRoot: cwd,
1768
+ workspaceIdentity: captureCurrentWorkflowSessionIdentity(cwd),
1769
+ runtimeExecutable: agentExecutable,
1770
+ });
1771
+ if (availability.status !== "available" || !availability.sessionRef) {
1772
+ 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.`);
1773
+ }
1774
+ resumeSessionRef = availability.sessionRef;
1775
+ }
879
1776
  if (qaEnabled) {
880
1777
  const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
881
1778
  qaAgent = qaReady.runtime;
@@ -886,11 +1783,16 @@ export function buildStartCommand() {
886
1783
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
887
1784
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
888
1785
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1786
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1787
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1788
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
889
1789
  };
890
- 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", settings_revision: settingsRevision };
891
- let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
1790
+ 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 };
1791
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
892
1792
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1793
+ runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
893
1794
  });
1795
+ activeContinuityRunId = buildRun.runId;
894
1796
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
895
1797
  heartbeat.unref();
896
1798
  const checkpointSignal = () => {
@@ -901,22 +1803,107 @@ export function buildStartCommand() {
901
1803
  };
902
1804
  process.once("SIGINT", checkpointSignal);
903
1805
  process.once("SIGTERM", checkpointSignal);
904
- const builder = await createBuilder(cwd, resumeSessionId);
905
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence);
906
- const statusReporter = new AgentStatusReporter({
1806
+ let builder;
1807
+ try {
1808
+ builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
1809
+ }
1810
+ catch (error) {
1811
+ if (!(error instanceof SessionUnavailableError))
1812
+ throw error;
1813
+ const summary = error.message.slice(0, 1000);
1814
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "session-unavailable-before-dispatch", {
1815
+ status: "recoverable",
1816
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1817
+ }), "recoverable");
1818
+ clearInterval(heartbeat);
1819
+ log.write("session-unavailable", { runId: buildRun.runId, phase: error.failure.phase, dispatchState: error.failure.dispatchState, message: summary });
1820
+ console.error(`foreman: exact provider session unavailable before dispatch; current worktree state was preserved: ${summary}`);
1821
+ console.error(`foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1822
+ process.exit(2);
1823
+ }
1824
+ const contextController = RoleSessionController.managed({
1825
+ projectDir: cwd,
1826
+ runId: buildRun.runId,
1827
+ role: "builder",
1828
+ requireNativeContextManagement: true,
1829
+ initialSettings: capturedBuilder,
1830
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1831
+ readSettings: () => liveRoleSettings("builder", capturedBuilder),
1832
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
1833
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1834
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1835
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1836
+ sessionTransition: "adopted live settings",
1837
+ }),
1838
+ report: (event) => {
1839
+ currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1840
+ log.write("context-lifecycle", { ...event, sample: event.sample });
1841
+ void liveStatusReporter?.tick();
1842
+ },
1843
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1844
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1845
+ const transfer = await new HandoffService(cwd).transfer({
1846
+ runId: buildRun.runId,
1847
+ role: "builder",
1848
+ reason,
1849
+ predecessorSessionId: adapter.sessionId(),
1850
+ predecessorSessionRef: adapter.sessionRef?.(),
1851
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1852
+ ...(nativeUsage ? { sessionUsage: {
1853
+ version: 1, runId: buildRun.runId, role: "builder", provider: adapter.agent,
1854
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1855
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1856
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1857
+ } } : {}),
1858
+ compactionCount,
1859
+ compactMaximum: settings.compact_maximum ?? 10,
1860
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1861
+ }, () => createBuilderForSettings(cwd, settings));
1862
+ log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1863
+ if (adapter instanceof ContinuityAdapter) {
1864
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1865
+ return adapter;
1866
+ }
1867
+ await adapter.close().catch(() => { });
1868
+ return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1869
+ },
1870
+ });
1871
+ builder = contextController.manage(builder);
1872
+ activeBuilderContextSnapshot = () => contextController.contextSnapshot();
1873
+ const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, 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);
1874
+ activeBuilderForStatus = () => foreman.builderAdapter();
1875
+ const statusReporter = liveStatusReporter = new AgentStatusReporter({
1876
+ runId: buildRun.runId,
907
1877
  role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
908
1878
  reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
909
1879
  phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
1880
+ settingsRevision: capturedBuilder.settings_revision,
1881
+ displaySessionCost: capturedBuilder.display_session_cost ?? false,
1882
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1883
+ return db.successfulCompactionCount(buildRun.runId, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1884
+ }
1885
+ finally {
1886
+ db.close();
1887
+ } },
1888
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1889
+ return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
1890
+ }
1891
+ finally {
1892
+ db.close();
1893
+ } },
1894
+ contextSnapshot: () => contextController.contextSnapshot(),
910
1895
  }, (line, snapshot) => {
911
1896
  const activity = currentActivity();
912
- if (activity)
913
- activity.writePersistent(line);
1897
+ if (activity && process.stdout.isTTY)
1898
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
914
1899
  else
915
1900
  console.log(line);
916
1901
  log.write("agent-status", { ...snapshot });
917
1902
  const db = new WorkflowDb(cwd);
918
1903
  try {
919
1904
  db.recordTelemetry(buildRun.runId, snapshot);
1905
+ if (snapshot.sessionUsage)
1906
+ db.recordSessionUsage(snapshot.sessionUsage);
920
1907
  }
921
1908
  finally {
922
1909
  db.close();
@@ -940,9 +1927,9 @@ export function buildStartCommand() {
940
1927
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
941
1928
  await foreman.runPreflight(steps, ticketsContent, preferredTicket);
942
1929
  if (foreman.builderSessionId())
943
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
1930
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
944
1931
  if (foreman.qaSessionId())
945
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1932
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
946
1933
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
947
1934
  if (!opts.yes) {
948
1935
  while (true) {
@@ -990,8 +1977,12 @@ export function buildStartCommand() {
990
1977
  currentTicket: ticketId,
991
1978
  });
992
1979
  }, preferredTicket);
1980
+ if (pendingHandoffGeneration !== undefined)
1981
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1982
+ if (foreman.builderSessionId())
1983
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
993
1984
  if (foreman.qaSessionId())
994
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1985
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
995
1986
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
996
1987
  ? completeBuildRun(cwd, buildRun)
997
1988
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
@@ -1003,6 +1994,8 @@ export function buildStartCommand() {
1003
1994
  console.log(`foreman: outcome — ${result.outcome}`);
1004
1995
  if (result.detail)
1005
1996
  console.log(`foreman: ${result.detail}`);
1997
+ if (buildRun.status === "completed")
1998
+ printHandoffPruneCommand(buildRun.runId);
1006
1999
  if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
1007
2000
  const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
1008
2001
  const remainingSteps = Math.max(1, result.requested - result.completed);
@@ -1015,6 +2008,45 @@ export function buildStartCommand() {
1015
2008
  catch (err) {
1016
2009
  clearInterval(heartbeat);
1017
2010
  statusReporter.stop();
2011
+ if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
2012
+ const summary = err.message.slice(0, 1000);
2013
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
2014
+ status: "recoverable",
2015
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2016
+ }), "recoverable");
2017
+ await foreman.close().catch(() => { });
2018
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary });
2019
+ console.error(`foreman: run paused safely: ${summary}`);
2020
+ console.error(`foreman: after resolving the condition, inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
2021
+ process.exit(2);
2022
+ }
2023
+ if (err instanceof SessionUnavailableContinuityError || err instanceof SessionUnavailableError) {
2024
+ const guided = err instanceof SessionUnavailableContinuityError ? err.guidedRecovery : err.failure.dispatchState === "unknown";
2025
+ const summary = err.message.slice(0, 1000);
2026
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
2027
+ status: "recoverable",
2028
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
2029
+ }), "recoverable");
2030
+ await foreman.close().catch(() => { });
2031
+ log.write("session-unavailable", { runId: buildRun.runId, guided, message: summary });
2032
+ console.error(`foreman: exact provider session unavailable; current worktree state was preserved: ${summary}`);
2033
+ console.error(guided
2034
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`
2035
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
2036
+ process.exit(2);
2037
+ }
2038
+ if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
2039
+ const summary = err.message.slice(0, 1000);
2040
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
2041
+ status: "recoverable",
2042
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2043
+ }), "recoverable");
2044
+ await foreman.close().catch(() => { });
2045
+ log.write("guided-recovery-required", { runId: buildRun.runId, message: summary });
2046
+ console.error(`foreman: run paused at a recoverable continuity checkpoint: ${summary}`);
2047
+ console.error(`foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`);
2048
+ process.exit(2);
2049
+ }
1018
2050
  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");
1019
2051
  await foreman.close().catch(() => { });
1020
2052
  log.write("error", { message: String(err) });
@@ -1179,3 +2211,7 @@ function shellQuote(value) {
1179
2211
  ? value
1180
2212
  : `'${value.replace(/'/g, `'"'"'`)}'`;
1181
2213
  }
2214
+ function printHandoffPruneCommand(runId) {
2215
+ console.log("foreman: disposable handoff cache can be pruned with:");
2216
+ console.log(` rafi handoffs prune-cache --run ${shellQuote(runId)} --keep-latest 1`);
2217
+ }