ai-foreman 1.7.6 → 1.7.7

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 +16 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -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 +940 -42
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +86 -0
  32. package/dist/continuity.js +365 -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 +326 -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 +20 -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 +102 -3
  47. package/dist/sessionLifecycle.js +578 -20
  48. package/dist/statusReporter.d.ts +58 -8
  49. package/dist/statusReporter.js +210 -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 +99 -2
  68. package/dist/workflowDb.js +434 -8
  69. package/package.json +31 -3
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,154 @@ 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,
533
628
  };
534
- return agent === "codex"
629
+ const adapter = agent === "codex"
535
630
  ? new CodexAdapter(adapterOpts)
536
631
  : await ClaudeAdapter.create(adapterOpts);
632
+ if (sessionRef && adapter.agent === "codex") {
633
+ const availability = await adapter.validateSession();
634
+ if (availability.status !== "available") {
635
+ await adapter.close().catch(() => { });
636
+ 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 });
637
+ }
638
+ }
639
+ return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
537
640
  };
538
- const createBuilder = async (builderCwd, sessionId) => {
539
- const initial = await createRawBuilder(builderCwd, sessionId);
641
+ let activeContinuityRunId;
642
+ let liveStatusReporter;
643
+ let activeBuilderForStatus;
644
+ const resolvedContinuitySettings = (role) => role === "builder" ? {
645
+ role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
646
+ reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
647
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
648
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
649
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
650
+ } : {
651
+ role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
652
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
653
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
654
+ auto_compact_threshold_percent: 50, compact_maximum: 10,
655
+ };
656
+ const liveRoleSettings = (role, base) => {
657
+ const live = readAgentDefaults(cwd);
658
+ const revision = live.revision ?? 0;
659
+ if (revision <= base.settings_revision)
660
+ return base;
661
+ const defaults = role === "builder" ? live.builder : live.qa;
662
+ return {
663
+ ...base,
664
+ source: defaults ? "project" : base.source,
665
+ make: defaults?.make ?? base.make,
666
+ model: defaults?.model ?? base.model,
667
+ reasoning: defaults?.reasoning ?? base.reasoning,
668
+ fast: defaults?.fast ?? base.fast,
669
+ session_strategy: defaults?.session_strategy ?? base.session_strategy,
670
+ display_session_cost: defaults?.display_session_cost ?? base.display_session_cost,
671
+ auto_compact_threshold_percent: defaults?.auto_compact_threshold_percent ?? base.auto_compact_threshold_percent,
672
+ compact_maximum: defaults?.compact_maximum ?? base.compact_maximum,
673
+ settings_revision: revision,
674
+ };
675
+ };
676
+ const createBuilderForSettings = async (builderCwd, settings) => {
677
+ const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
678
+ label: "live Builder settings",
679
+ yes: true,
680
+ allowSwitch: false,
681
+ model: settings.model === "default" ? undefined : settings.model,
682
+ });
683
+ const policy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
684
+ const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
685
+ const adapterOptions = {
686
+ cwd: builderCwd,
687
+ configRoot: cwd,
688
+ sessionRole: "builder",
689
+ sessionStream: "builder",
690
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
691
+ runtimeExecutable: ready.executable,
692
+ runtimePhase: "builder",
693
+ model: settings.model === "default" ? undefined : settings.model,
694
+ permission: createPermissionHandler(policy, log),
695
+ effort: explicitEffort(settings.reasoning),
696
+ fast: settings.fast,
697
+ systemPromptAppend: roleBundle.system || undefined,
698
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
699
+ };
700
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
701
+ return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
702
+ };
703
+ const createQaForSettings = async (qaCwd, settings) => {
704
+ const ready = await ensureRuntimeReadyForCommand(qaCwd, settings.make, {
705
+ label: "live QA settings",
706
+ yes: true,
707
+ allowSwitch: false,
708
+ model: settings.model === "default" ? undefined : settings.model,
709
+ });
710
+ const policy = new PermissionPolicy(config.permissions, qaCwd);
711
+ const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
712
+ const adapterOptions = {
713
+ cwd: qaCwd,
714
+ configRoot: cwd,
715
+ sessionRole: "qa",
716
+ sessionStream: "qa",
717
+ runtimeExecutable: ready.executable,
718
+ runtimePhase: "qa",
719
+ model: settings.model === "default" ? undefined : settings.model,
720
+ permission: createPermissionHandler(policy, log),
721
+ effort: explicitEffort(settings.reasoning),
722
+ fast: settings.fast,
723
+ 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.`,
724
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
725
+ };
726
+ return settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
727
+ };
728
+ const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
729
+ if (sessionId && !sessionRef) {
730
+ const availability = {
731
+ version: 1,
732
+ status: "unknown",
733
+ checkedAt: new Date().toISOString(),
734
+ reason: "legacy-unscoped",
735
+ detail: "Builder recovery cannot authorize an exact provider resume from a raw session ID",
736
+ };
737
+ throw new SessionUnavailableError({
738
+ runtime: agent,
739
+ phase: "preflight",
740
+ dispatchState: "not-sent",
741
+ executable: agentExecutable ?? agent,
742
+ cwd: builderCwd,
743
+ diagnostics: availability.detail,
744
+ availability,
745
+ });
746
+ }
747
+ const initial = await createRawBuilder(builderCwd, sessionId, sessionRef);
540
748
  return new RecoveringAdapter({
541
749
  initial,
542
750
  runtime: agent,
543
751
  label: "builder turn",
544
752
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
545
753
  allowSwitch: !(opts.resume || opts.continue),
546
- recreate: async (nextRuntime, resumeSessionId) => {
754
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
547
755
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
548
756
  label: "builder recovery",
549
757
  allowSwitch: false,
@@ -552,19 +760,169 @@ export function buildStartCommand() {
552
760
  agent = nextReady.runtime;
553
761
  model = nextReady.model;
554
762
  agentExecutable = nextReady.executable;
555
- return createRawBuilder(builderCwd, resumeSessionId);
763
+ return createRawBuilder(builderCwd, resumeSessionId, resumeRef);
764
+ },
765
+ });
766
+ };
767
+ const continuousBuilder = (adapter, builderCwd, runId, settings = resolvedContinuitySettings("builder"), replaceRecoveryLeaseAfterCheckpoint = false) => new ContinuityAdapter({
768
+ adapter,
769
+ projectDir: cwd,
770
+ runId,
771
+ role: "builder",
772
+ settings,
773
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settings.settings_revision,
774
+ createSuccessor: () => createRecoveringBuilder(builderCwd),
775
+ replaceRecoveryLeaseAfterCheckpoint,
776
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
777
+ const effectiveSettings = liveRoleSettings("builder", settings);
778
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
779
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
780
+ const sessionId = predecessor.sessionId();
781
+ const recoveryDb = new WorkflowDb(cwd);
782
+ let compactionCount = 0;
783
+ try {
784
+ compactionCount = recoveryDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
785
+ }
786
+ finally {
787
+ recoveryDb.close();
788
+ }
789
+ const transfer = await new HandoffService(cwd).transfer({
790
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: predecessor.sessionRef?.(),
791
+ allowNonCurrentContinuity: true,
792
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
793
+ ...(nativeUsage ? { sessionUsage: {
794
+ version: 1, runId, role: "builder", provider: predecessor.agent,
795
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
796
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
797
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
798
+ } } : {}),
799
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
800
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
801
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
802
+ log.write("handoff-transfer", {
803
+ role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
804
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
805
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
806
+ });
807
+ return transfer.successor;
808
+ },
809
+ handleHandoffRequest: async (output, predecessor, frozenAction) => {
810
+ const request = parseBuilderHandoffRequest(output);
811
+ if (!request)
812
+ return undefined;
813
+ const effectiveSettings = liveRoleSettings("builder", settings);
814
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
815
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
816
+ const sessionId = predecessor.sessionId();
817
+ const countDb = new WorkflowDb(cwd);
818
+ let compactionCount = 0;
819
+ try {
820
+ const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
821
+ countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
822
+ countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
823
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
824
+ }
825
+ finally {
826
+ countDb.close();
827
+ }
828
+ const transfer = await new HandoffService(cwd).transfer({
829
+ runId,
830
+ role: "builder",
831
+ reason: request.reason,
832
+ predecessorSessionId: sessionId,
833
+ predecessorSessionRef: predecessor.sessionRef?.(),
834
+ requestedByBuilder: true,
835
+ roleState: {
836
+ ...request.roleState,
837
+ frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
838
+ ...(context ? { occupancy: context } : { occupancy: "unavailable" }),
839
+ },
840
+ ...(nativeUsage ? { sessionUsage: {
841
+ version: 1, runId, role: "builder", provider: predecessor.agent,
842
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
843
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
844
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
845
+ } } : {}),
846
+ compactionCount,
847
+ compactMaximum: effectiveSettings.compact_maximum,
848
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
849
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
850
+ log.write("handoff-transfer", {
851
+ requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
852
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
853
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
854
+ });
855
+ return transfer.successor;
856
+ },
857
+ });
858
+ const createBuilder = async (builderCwd, sessionId, sessionRef) => {
859
+ const recoveryMode = String(opts.recoveryMode ?? "");
860
+ const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
861
+ let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
862
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
863
+ const service = new HandoffService(cwd);
864
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
865
+ adapter = accepted.successor;
866
+ pendingHandoffGeneration = undefined;
867
+ log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
868
+ }
869
+ return activeContinuityRunId ? continuousBuilder(adapter, builderCwd, activeContinuityRunId, undefined, String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role !== "qa") : adapter;
870
+ };
871
+ const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
872
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
873
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
874
+ const sessionId = input.adapter.sessionId();
875
+ const countDb = new WorkflowDb(cwd);
876
+ let compactionCount = 0;
877
+ try {
878
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", input.adapter.sessionRef?.() ?? sessionId);
879
+ }
880
+ finally {
881
+ countDb.close();
882
+ }
883
+ 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}`;
884
+ const transfer = await new HandoffService(cwd).transfer({
885
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
886
+ roleState: {
887
+ settingsFrom: input.current, settingsTo: input.next,
888
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
889
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
556
890
  },
891
+ ...(nativeUsage ? { sessionUsage: {
892
+ version: 1, runId, role: "builder", provider: input.adapter.agent,
893
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
894
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
895
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
896
+ } } : {}),
897
+ compactionCount, compactMaximum: input.next.compact_maximum,
898
+ resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
899
+ }, () => createBuilderForSettings(builderCwd, input.next));
900
+ log.write("handoff-transfer", {
901
+ role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
902
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
903
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
557
904
  });
905
+ if (input.adapter instanceof ContinuityAdapter) {
906
+ await input.adapter.adoptValidatedSuccessor(transfer.successor);
907
+ return input.adapter;
908
+ }
909
+ await input.adapter.close().catch(() => { });
910
+ return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
558
911
  };
559
- const createRawQa = async (qaCwd, sessionId) => {
912
+ const createRawQa = async (qaCwd, sessionRef) => {
560
913
  const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
561
914
  const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
562
915
  const adapterOpts = {
563
916
  cwd: qaCwd,
917
+ configRoot: cwd,
918
+ sessionRole: "qa",
919
+ sessionStream: "qa",
564
920
  runtimeExecutable: qaExecutable,
565
921
  runtimePhase: "qa",
566
922
  model: qaModel,
567
- resumeSessionId: sessionId,
923
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
924
+ sessionGeneration: sessionRef?.generation ?? 0,
925
+ workspaceIdentity: captureWorkspaceIdentity(qaCwd),
568
926
  permission: createPermissionHandler(qaPolicy, log),
569
927
  effort: qaEffort,
570
928
  fast: qaFast,
@@ -575,15 +933,15 @@ export function buildStartCommand() {
575
933
  ? new CodexAdapter(adapterOpts)
576
934
  : await ClaudeAdapter.create(adapterOpts);
577
935
  };
578
- const createQa = async (qaCwd, sessionId) => {
579
- const initial = await createRawQa(qaCwd, sessionId);
936
+ const createRecoveringQa = async (qaCwd, sessionRef) => {
937
+ const initial = await createRawQa(qaCwd, sessionRef);
580
938
  return new RecoveringAdapter({
581
939
  initial,
582
940
  runtime: qaAgent,
583
941
  label: "QA turn",
584
942
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
585
- allowSwitch: !sessionId,
586
- recreate: async (nextRuntime, resumeSessionId) => {
943
+ allowSwitch: !sessionRef,
944
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
587
945
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
588
946
  label: "QA recovery",
589
947
  allowSwitch: false,
@@ -592,10 +950,241 @@ export function buildStartCommand() {
592
950
  qaAgent = nextReady.runtime;
593
951
  qaModel = nextReady.model;
594
952
  qaExecutable = nextReady.executable;
595
- return createRawQa(qaCwd, resumeSessionId);
953
+ if (resumeSessionId && !resumeRef) {
954
+ const availability = {
955
+ version: 1,
956
+ status: "unknown",
957
+ checkedAt: new Date().toISOString(),
958
+ reason: "legacy-unscoped",
959
+ detail: "QA retry cannot authorize an exact provider resume from a raw session ID",
960
+ };
961
+ throw new SessionUnavailableError({
962
+ runtime: nextRuntime,
963
+ phase: "preflight",
964
+ dispatchState: "not-sent",
965
+ executable: nextReady.executable,
966
+ cwd: qaCwd,
967
+ diagnostics: availability.detail,
968
+ availability,
969
+ });
970
+ }
971
+ return createRawQa(qaCwd, resumeRef);
596
972
  },
597
973
  });
598
974
  };
975
+ const decorateQa = (adapter, qaCwd, sessionId, settingsOverride) => {
976
+ const qaSettings = settingsOverride ?? resolvedContinuitySettings("qa");
977
+ const continuous = activeContinuityRunId ? new ContinuityAdapter({
978
+ adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
979
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
980
+ createSuccessor: () => createRecoveringQa(qaCwd),
981
+ replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
982
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
983
+ const effectiveSettings = liveRoleSettings("qa", qaSettings);
984
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
985
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
986
+ const predecessorSessionId = predecessor.sessionId();
987
+ const recoveryDb = new WorkflowDb(cwd);
988
+ let compactionCount = 0;
989
+ try {
990
+ compactionCount = recoveryDb.successfulCompactionCount(activeContinuityRunId, "qa", predecessor.sessionRef?.() ?? predecessorSessionId);
991
+ }
992
+ finally {
993
+ recoveryDb.close();
994
+ }
995
+ const transfer = await new HandoffService(cwd).transfer({
996
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId, predecessorSessionRef: predecessor.sessionRef?.(),
997
+ allowNonCurrentContinuity: true,
998
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
999
+ ...(nativeUsage ? { sessionUsage: {
1000
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1001
+ providerSessionId: predecessorSessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1002
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1003
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1004
+ } } : {}),
1005
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1006
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1007
+ }, () => createQaForSettings(qaCwd, effectiveSettings));
1008
+ log.write("handoff-transfer", {
1009
+ role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1010
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1011
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1012
+ });
1013
+ return transfer.successor;
1014
+ },
1015
+ }) : adapter;
1016
+ return new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1017
+ role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1018
+ phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1019
+ settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
1020
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1021
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "qa", active.sessionRef?.() ?? active.sessionId()) : 0;
1022
+ }
1023
+ finally {
1024
+ db.close();
1025
+ } },
1026
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1027
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1028
+ }
1029
+ finally {
1030
+ db.close();
1031
+ } },
1032
+ }), () => {
1033
+ const active = activeBuilderForStatus?.();
1034
+ if (active)
1035
+ liveStatusReporter?.updateState({
1036
+ role: "builder", provider: active.agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1037
+ phase: "builder work session", sessionTransition: "builder session", adapter: active,
1038
+ settingsRevision, displaySessionCost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1039
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1040
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "builder", active.sessionRef?.() ?? active.sessionId()) : 0;
1041
+ }
1042
+ finally {
1043
+ db.close();
1044
+ } },
1045
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1046
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1047
+ }
1048
+ finally {
1049
+ db.close();
1050
+ } },
1051
+ });
1052
+ });
1053
+ };
1054
+ const createQa = async (qaCwd, sessionId) => {
1055
+ const recoveryMode = String(opts.recoveryMode ?? "");
1056
+ // A disposable QA cwd is never eligible for exact reuse, even when a
1057
+ // compatibility caller still supplies the previous raw ID.
1058
+ const effectiveSessionId = undefined;
1059
+ void sessionId;
1060
+ let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1061
+ let explicitRecoveryAccepted = false;
1062
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1063
+ const service = new HandoffService(cwd);
1064
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1065
+ adapter = accepted.successor;
1066
+ pendingHandoffGeneration = undefined;
1067
+ explicitRecoveryAccepted = true;
1068
+ log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1069
+ }
1070
+ // A new disposable QA worktree always means a new provider session.
1071
+ // Transfer cumulative QA state by accepting a durable handoff in that
1072
+ // fresh session; never attach the old session in the new /tmp cwd.
1073
+ if (!explicitRecoveryAccepted && activeContinuityRunId && recoveryMode !== "fresh-recovery-only") {
1074
+ const stateDb = new WorkflowDb(cwd);
1075
+ const priorLease = stateDb.roleMutationLease(activeContinuityRunId, "qa");
1076
+ const head = stateDb.continuityHead(activeContinuityRunId, "qa");
1077
+ const checkpoint = stateDb.latestContinuityCheckpoint(activeContinuityRunId, "qa");
1078
+ const compactionCount = priorLease
1079
+ ? stateDb.successfulCompactionCount(activeContinuityRunId, "qa", priorLease.sessionRef ?? priorLease.providerSessionId)
1080
+ : 0;
1081
+ stateDb.close();
1082
+ if (priorLease) {
1083
+ if (!head || head.state !== "current" || !checkpoint) {
1084
+ await adapter.close().catch(() => { });
1085
+ throw new ContinuityRecoveryRequiredError(activeContinuityRunId, "qa", `cannot transfer cumulative QA state from a ${head?.state ?? "missing"} checkpoint`);
1086
+ }
1087
+ const service = new HandoffService(cwd);
1088
+ const staged = service.stage({
1089
+ runId: activeContinuityRunId,
1090
+ role: "qa",
1091
+ reason: "new disposable QA snapshot requires a fresh location-scoped provider session",
1092
+ predecessorSessionId: priorLease.providerSessionId,
1093
+ predecessorSessionRef: priorLease.sessionRef,
1094
+ roleState: { predecessorGeneration: priorLease.generation, successorSnapshotCwd: qaCwd },
1095
+ compactionCount,
1096
+ compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1097
+ });
1098
+ const accepted = await service.acceptStaged(staged, adapter);
1099
+ adapter = accepted.successor;
1100
+ log.write("handoff-transfer", {
1101
+ role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1102
+ predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
1103
+ acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest,
1104
+ });
1105
+ }
1106
+ }
1107
+ return decorateQa(adapter, qaCwd, effectiveSessionId);
1108
+ };
1109
+ const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1110
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
1111
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1112
+ const sessionId = input.adapter.sessionId();
1113
+ const countDb = new WorkflowDb(cwd);
1114
+ let compactionCount = 0;
1115
+ try {
1116
+ compactionCount = countDb.successfulCompactionCount(runId, "qa", input.adapter.sessionRef?.() ?? sessionId);
1117
+ }
1118
+ finally {
1119
+ countDb.close();
1120
+ }
1121
+ 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}`;
1122
+ const transfer = await new HandoffService(cwd).transfer({
1123
+ runId, role: "qa", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1124
+ roleState: {
1125
+ settingsFrom: input.current, settingsTo: input.next,
1126
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1127
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1128
+ },
1129
+ ...(nativeUsage ? { sessionUsage: {
1130
+ version: 1, runId, role: "qa", provider: input.adapter.agent,
1131
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1132
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1133
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1134
+ } } : {}),
1135
+ compactionCount, compactMaximum: input.next.compact_maximum,
1136
+ resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1137
+ }, () => createQaForSettings(qaCwd, input.next));
1138
+ log.write("handoff-transfer", {
1139
+ role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1140
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1141
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1142
+ });
1143
+ await input.adapter.close().catch(() => { });
1144
+ return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1145
+ };
1146
+ const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
1147
+ if (!activeContinuityRunId)
1148
+ throw new Error("QA session boundary requires an active durable run");
1149
+ const qaSettings = resolvedContinuitySettings("qa");
1150
+ const controller = RoleSessionController.managed({
1151
+ projectDir: cwd,
1152
+ runId: activeContinuityRunId,
1153
+ role: "qa",
1154
+ initialSettings: qaSettings,
1155
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1156
+ readSettings: () => liveRoleSettings("qa", qaSettings),
1157
+ settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1158
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1159
+ role: "qa", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1160
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1161
+ sessionTransition: "adopted live settings",
1162
+ }),
1163
+ report: (event) => {
1164
+ currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1165
+ log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1166
+ },
1167
+ handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1168
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1169
+ const transfer = await new HandoffService(cwd).transfer({
1170
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1171
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1172
+ ...(nativeUsage ? { sessionUsage: {
1173
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1174
+ providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1175
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1176
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1177
+ } } : {}),
1178
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1179
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1180
+ }, () => createQaForSettings(qaCwd, settings));
1181
+ log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1182
+ await predecessor.close().catch(() => { });
1183
+ return decorateQa(transfer.successor, qaCwd, undefined, settings);
1184
+ },
1185
+ });
1186
+ return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1187
+ };
599
1188
  const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
600
1189
  if (branchMode) {
601
1190
  const ticketsConfig = loadTicketsConfig(cwd);
@@ -658,7 +1247,16 @@ export function buildStartCommand() {
658
1247
  if (!ticket)
659
1248
  fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
660
1249
  const sessionId = opts.resume ?? session.sessionId;
661
- resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId });
1250
+ let sessionRef = session.sessionRef;
1251
+ if (sessionRef && sessionRef.sessionId !== sessionId)
1252
+ sessionRef = undefined;
1253
+ if (!sessionRef && recoveryRecord)
1254
+ sessionRef = buildRunSessionBinding(recoveryRecord, "builder", sessionId);
1255
+ if (!sessionRef && sessionId === session.sessionId)
1256
+ sessionRef = legacyBranchSessionRef(session, cwd, agent);
1257
+ if (!sessionRef)
1258
+ fail(`session ${sessionId} for ${ticketId} has no unique location-scoped binding; use fresh-with-handoff recovery`);
1259
+ resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId, sessionRef });
662
1260
  nodes.push({
663
1261
  ticket,
664
1262
  branch: session.branch,
@@ -681,13 +1279,15 @@ export function buildStartCommand() {
681
1279
  plan = buildBranchPlan(tickets, states, {
682
1280
  steps,
683
1281
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
684
- branchPrefix: opts.branchPrefix,
1282
+ branchPrefix,
685
1283
  maxBranchDepth,
686
1284
  rootBaseBranches: branchDefaults.rootBaseBranches,
687
1285
  ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
688
1286
  });
689
1287
  let auditBuilder;
690
1288
  let auditViewer;
1289
+ const auditRunId = `audit-${stamp}`;
1290
+ activeContinuityRunId = auditRunId;
691
1291
  try {
692
1292
  auditBuilder = await createBuilder(cwd);
693
1293
  auditViewer = printEvents(auditBuilder.events());
@@ -698,7 +1298,7 @@ export function buildStartCommand() {
698
1298
  plan = buildBranchPlan(tickets, states, {
699
1299
  steps,
700
1300
  baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
701
- branchPrefix: opts.branchPrefix,
1301
+ branchPrefix,
702
1302
  maxBranchDepth,
703
1303
  auditDependencies,
704
1304
  rootBaseBranches: branchDefaults.rootBaseBranches,
@@ -709,27 +1309,42 @@ export function buildStartCommand() {
709
1309
  finally {
710
1310
  await auditBuilder?.close().catch(() => { });
711
1311
  await auditViewer?.catch(() => { });
1312
+ const auditDb = new WorkflowDb(cwd);
1313
+ try {
1314
+ if (auditDb.getRun(auditRunId))
1315
+ auditDb.transition(auditRunId, { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" });
1316
+ }
1317
+ finally {
1318
+ auditDb.close();
1319
+ }
1320
+ activeContinuityRunId = undefined;
712
1321
  }
713
1322
  if (deliveryRun?.unit.branch_mode === "shared") {
714
- plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, opts.branchPrefix);
1323
+ plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
715
1324
  const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
716
1325
  const first = plan.nodes[0];
717
1326
  if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
718
- resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
1327
+ 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" });
1328
+ resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId, sessionRef });
719
1329
  }
720
1330
  }
721
1331
  if (selectedStacks.length && deliveryConfig) {
722
- plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
1332
+ plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
723
1333
  }
724
1334
  else if (!deliveryRun) {
725
1335
  const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
726
1336
  if (branchPolicy)
727
- plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, opts.branchPrefix);
1337
+ plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
728
1338
  }
729
1339
  if (recoveryRecord && opts.resume && plan.nodes[0]) {
730
1340
  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 });
1341
+ if (prior) {
1342
+ const requested = String(opts.resume);
1343
+ const sessionRef = prior.sessionRef?.sessionId === requested ? prior.sessionRef : buildRunSessionBinding(recoveryRecord, "builder", requested) ?? (prior.sessionId === requested ? legacyBranchSessionRef(prior, cwd, agent) : undefined);
1344
+ if (!sessionRef)
1345
+ fail(`session ${requested} has no stored scope for ${plan.nodes[0].ticket.id}`);
1346
+ resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: requested, sessionRef });
1347
+ }
733
1348
  }
734
1349
  }
735
1350
  const branchPresentation = presentBranchPlan(plan, {
@@ -772,21 +1387,84 @@ export function buildStartCommand() {
772
1387
  process.exit(0);
773
1388
  }
774
1389
  }
1390
+ for (const [ticketId, session] of resumeSessionByTicket) {
1391
+ const ref = session.sessionRef;
1392
+ if (!ref)
1393
+ fail(`session ${session.sessionId} for ${ticketId} has no location-scoped binding`);
1394
+ if (ref.provider !== agent)
1395
+ fail(`session ${ref.sessionId} belongs to ${ref.provider}, but this recovery selected ${agent}`);
1396
+ const availability = await resolveProviderSessionAvailability(ref, {
1397
+ cwd: session.worktreePath,
1398
+ configRoot: cwd,
1399
+ workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
1400
+ runtimeExecutable: agentExecutable,
1401
+ });
1402
+ if (availability.status !== "available" || !availability.sessionRef) {
1403
+ 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.`);
1404
+ }
1405
+ resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1406
+ }
775
1407
  const capturedBranchBuilder = {
776
1408
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
777
1409
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
778
1410
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1411
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1412
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1413
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
779
1414
  };
780
1415
  const capturedBranchQa = {
781
1416
  role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
782
1417
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1418
+ display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1419
+ auto_compact_threshold_percent: 50, compact_maximum: 10,
783
1420
  };
784
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
1421
+ const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1422
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
785
1423
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
786
1424
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1425
+ runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
787
1426
  });
1427
+ activeContinuityRunId = masterRun.runId;
1428
+ const branchContextControllers = new Map();
1429
+ const branchContextController = (worktreePath) => {
1430
+ const existing = branchContextControllers.get(worktreePath);
1431
+ if (existing)
1432
+ return existing;
1433
+ const controller = RoleSessionController.managed({
1434
+ projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1435
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1436
+ readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1437
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1438
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1439
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1440
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1441
+ sessionTransition: "adopted live settings",
1442
+ }),
1443
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); },
1444
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1445
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1446
+ const transfer = await new HandoffService(cwd).transfer({
1447
+ runId: masterRun.runId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1448
+ roleState: { worktreePath, frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1449
+ ...(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 } } : {}),
1450
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1451
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1452
+ }, () => createBuilderForSettings(worktreePath, settings));
1453
+ log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1454
+ if (adapter instanceof ContinuityAdapter) {
1455
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1456
+ return adapter;
1457
+ }
1458
+ await adapter.close().catch(() => { });
1459
+ return continuousBuilder(transfer.successor, worktreePath, masterRun.runId, settings);
1460
+ },
1461
+ });
1462
+ branchContextControllers.set(worktreePath, controller);
1463
+ return controller;
1464
+ };
788
1465
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
789
1466
  branchHeartbeat.unref();
1467
+ let branchSessionFailure;
790
1468
  const summaries = await runBranchPlan({
791
1469
  projectDir: cwd,
792
1470
  runId: masterRun.runId,
@@ -814,13 +1492,82 @@ export function buildStartCommand() {
814
1492
  archiveDoc: ticketsConfig.paths.archiveDoc,
815
1493
  },
816
1494
  resumeSessions: resumeSessionByTicket,
817
- createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
1495
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1496
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1497
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1498
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
818
1499
  createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
819
1500
  builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
820
1501
  qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
821
- observeBuilder: (builder) => printEvents(builder.events()),
1502
+ observeBuilder: async (builder) => {
1503
+ liveStatusReporter?.stop();
1504
+ activeBuilderForStatus = () => builder;
1505
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1506
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1507
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1508
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1509
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1510
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1511
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1512
+ }
1513
+ finally {
1514
+ db.close();
1515
+ } },
1516
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1517
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1518
+ }
1519
+ finally {
1520
+ db.close();
1521
+ } },
1522
+ }, (line, snapshot) => {
1523
+ const activity = currentActivity();
1524
+ if (activity && process.stdout.isTTY)
1525
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1526
+ else
1527
+ console.log(line);
1528
+ log.write("agent-status", { ...snapshot });
1529
+ const db = new WorkflowDb(cwd);
1530
+ try {
1531
+ db.recordTelemetry(masterRun.runId, snapshot);
1532
+ db.recordContextSample(snapshot.contextSample);
1533
+ if (snapshot.sessionUsage)
1534
+ db.recordSessionUsage(snapshot.sessionUsage);
1535
+ }
1536
+ finally {
1537
+ db.close();
1538
+ }
1539
+ });
1540
+ reporter.start();
1541
+ try {
1542
+ await printEvents(builder.events());
1543
+ }
1544
+ finally {
1545
+ reporter.stop();
1546
+ }
1547
+ },
822
1548
  qaNonconvergence,
1549
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1550
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1551
+ qaSessionBoundary,
823
1552
  });
1553
+ if (branchSessionFailure) {
1554
+ const failure = branchSessionFailure;
1555
+ const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
1556
+ const summary = failure.message.slice(0, 1000);
1557
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
1558
+ status: "recoverable",
1559
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1560
+ }), "recoverable");
1561
+ clearInterval(branchHeartbeat);
1562
+ log.write("session-unavailable", { runId: masterRun.runId, guided, message: summary });
1563
+ console.error(`foreman: exact provider session unavailable; preserved branch work remains recoverable: ${summary}`);
1564
+ console.error(guided
1565
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --guided-recovery`
1566
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --inspect`);
1567
+ process.exit(2);
1568
+ }
1569
+ if (pendingHandoffGeneration !== undefined)
1570
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
824
1571
  clearInterval(branchHeartbeat);
825
1572
  if (selectedStacks.length) {
826
1573
  let allComplete = true;
@@ -864,6 +1611,8 @@ export function buildStartCommand() {
864
1611
  console.log(` ${command}`);
865
1612
  }
866
1613
  }
1614
+ if (masterRun.status === "completed")
1615
+ printHandoffPruneCommand(masterRun.runId);
867
1616
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
868
1617
  process.exit(failed ? 2 : 0);
869
1618
  }
@@ -876,6 +1625,20 @@ export function buildStartCommand() {
876
1625
  agent = ready.runtime;
877
1626
  model = ready.model;
878
1627
  agentExecutable = ready.executable;
1628
+ if (resumeSessionRef) {
1629
+ if (resumeSessionRef.provider !== agent)
1630
+ fail(`session ${resumeSessionRef.sessionId} belongs to ${resumeSessionRef.provider}, but this recovery selected ${agent}`);
1631
+ const availability = await resolveProviderSessionAvailability(resumeSessionRef, {
1632
+ cwd,
1633
+ configRoot: cwd,
1634
+ workspaceIdentity: captureCurrentWorkflowSessionIdentity(cwd),
1635
+ runtimeExecutable: agentExecutable,
1636
+ });
1637
+ if (availability.status !== "available" || !availability.sessionRef) {
1638
+ 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.`);
1639
+ }
1640
+ resumeSessionRef = availability.sessionRef;
1641
+ }
879
1642
  if (qaEnabled) {
880
1643
  const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
881
1644
  qaAgent = qaReady.runtime;
@@ -886,11 +1649,16 @@ export function buildStartCommand() {
886
1649
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
887
1650
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
888
1651
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1652
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1653
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1654
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
889
1655
  };
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({
1656
+ 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: 50, compact_maximum: 10, settings_revision: settingsRevision };
1657
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
892
1658
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1659
+ runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
893
1660
  });
1661
+ activeContinuityRunId = buildRun.runId;
894
1662
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
895
1663
  heartbeat.unref();
896
1664
  const checkpointSignal = () => {
@@ -901,22 +1669,103 @@ export function buildStartCommand() {
901
1669
  };
902
1670
  process.once("SIGINT", checkpointSignal);
903
1671
  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({
1672
+ let builder;
1673
+ try {
1674
+ builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
1675
+ }
1676
+ catch (error) {
1677
+ if (!(error instanceof SessionUnavailableError))
1678
+ throw error;
1679
+ const summary = error.message.slice(0, 1000);
1680
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "session-unavailable-before-dispatch", {
1681
+ status: "recoverable",
1682
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1683
+ }), "recoverable");
1684
+ clearInterval(heartbeat);
1685
+ log.write("session-unavailable", { runId: buildRun.runId, phase: error.failure.phase, dispatchState: error.failure.dispatchState, message: summary });
1686
+ console.error(`foreman: exact provider session unavailable before dispatch; current worktree state was preserved: ${summary}`);
1687
+ console.error(`foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1688
+ process.exit(2);
1689
+ }
1690
+ const contextController = RoleSessionController.managed({
1691
+ projectDir: cwd,
1692
+ runId: buildRun.runId,
1693
+ role: "builder",
1694
+ initialSettings: capturedBuilder,
1695
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1696
+ readSettings: () => liveRoleSettings("builder", capturedBuilder),
1697
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
1698
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1699
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1700
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1701
+ sessionTransition: "adopted live settings",
1702
+ }),
1703
+ report: (event) => {
1704
+ currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1705
+ log.write("context-lifecycle", { ...event, sample: event.sample });
1706
+ },
1707
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1708
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1709
+ const transfer = await new HandoffService(cwd).transfer({
1710
+ runId: buildRun.runId,
1711
+ role: "builder",
1712
+ reason,
1713
+ predecessorSessionId: adapter.sessionId(),
1714
+ predecessorSessionRef: adapter.sessionRef?.(),
1715
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1716
+ ...(nativeUsage ? { sessionUsage: {
1717
+ version: 1, runId: buildRun.runId, role: "builder", provider: adapter.agent,
1718
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1719
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1720
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1721
+ } } : {}),
1722
+ compactionCount,
1723
+ compactMaximum: settings.compact_maximum ?? 10,
1724
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1725
+ }, () => createBuilderForSettings(cwd, settings));
1726
+ log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1727
+ if (adapter instanceof ContinuityAdapter) {
1728
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1729
+ return adapter;
1730
+ }
1731
+ await adapter.close().catch(() => { });
1732
+ return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1733
+ },
1734
+ });
1735
+ 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);
1736
+ activeBuilderForStatus = () => foreman.builderAdapter();
1737
+ const statusReporter = liveStatusReporter = new AgentStatusReporter({
1738
+ runId: buildRun.runId,
907
1739
  role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
908
1740
  reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
909
1741
  phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
1742
+ settingsRevision: capturedBuilder.settings_revision,
1743
+ displaySessionCost: capturedBuilder.display_session_cost ?? false,
1744
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1745
+ return db.successfulCompactionCount(buildRun.runId, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1746
+ }
1747
+ finally {
1748
+ db.close();
1749
+ } },
1750
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1751
+ return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
1752
+ }
1753
+ finally {
1754
+ db.close();
1755
+ } },
910
1756
  }, (line, snapshot) => {
911
1757
  const activity = currentActivity();
912
- if (activity)
913
- activity.writePersistent(line);
1758
+ if (activity && process.stdout.isTTY)
1759
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
914
1760
  else
915
1761
  console.log(line);
916
1762
  log.write("agent-status", { ...snapshot });
917
1763
  const db = new WorkflowDb(cwd);
918
1764
  try {
919
1765
  db.recordTelemetry(buildRun.runId, snapshot);
1766
+ db.recordContextSample(snapshot.contextSample);
1767
+ if (snapshot.sessionUsage)
1768
+ db.recordSessionUsage(snapshot.sessionUsage);
920
1769
  }
921
1770
  finally {
922
1771
  db.close();
@@ -940,9 +1789,9 @@ export function buildStartCommand() {
940
1789
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
941
1790
  await foreman.runPreflight(steps, ticketsContent, preferredTicket);
942
1791
  if (foreman.builderSessionId())
943
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
1792
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
944
1793
  if (foreman.qaSessionId())
945
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1794
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
946
1795
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
947
1796
  if (!opts.yes) {
948
1797
  while (true) {
@@ -990,8 +1839,12 @@ export function buildStartCommand() {
990
1839
  currentTicket: ticketId,
991
1840
  });
992
1841
  }, preferredTicket);
1842
+ if (pendingHandoffGeneration !== undefined)
1843
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1844
+ if (foreman.builderSessionId())
1845
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
993
1846
  if (foreman.qaSessionId())
994
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1847
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
995
1848
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
996
1849
  ? completeBuildRun(cwd, buildRun)
997
1850
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
@@ -1003,6 +1856,8 @@ export function buildStartCommand() {
1003
1856
  console.log(`foreman: outcome — ${result.outcome}`);
1004
1857
  if (result.detail)
1005
1858
  console.log(`foreman: ${result.detail}`);
1859
+ if (buildRun.status === "completed")
1860
+ printHandoffPruneCommand(buildRun.runId);
1006
1861
  if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
1007
1862
  const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
1008
1863
  const remainingSteps = Math.max(1, result.requested - result.completed);
@@ -1015,6 +1870,45 @@ export function buildStartCommand() {
1015
1870
  catch (err) {
1016
1871
  clearInterval(heartbeat);
1017
1872
  statusReporter.stop();
1873
+ if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
1874
+ const summary = err.message.slice(0, 1000);
1875
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
1876
+ status: "recoverable",
1877
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1878
+ }), "recoverable");
1879
+ await foreman.close().catch(() => { });
1880
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary });
1881
+ console.error(`foreman: run paused safely: ${summary}`);
1882
+ console.error(`foreman: after resolving the condition, inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1883
+ process.exit(2);
1884
+ }
1885
+ if (err instanceof SessionUnavailableContinuityError || err instanceof SessionUnavailableError) {
1886
+ const guided = err instanceof SessionUnavailableContinuityError ? err.guidedRecovery : err.failure.dispatchState === "unknown";
1887
+ const summary = err.message.slice(0, 1000);
1888
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
1889
+ status: "recoverable",
1890
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1891
+ }), "recoverable");
1892
+ await foreman.close().catch(() => { });
1893
+ log.write("session-unavailable", { runId: buildRun.runId, guided, message: summary });
1894
+ console.error(`foreman: exact provider session unavailable; current worktree state was preserved: ${summary}`);
1895
+ console.error(guided
1896
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`
1897
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1898
+ process.exit(2);
1899
+ }
1900
+ if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
1901
+ const summary = err.message.slice(0, 1000);
1902
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
1903
+ status: "recoverable",
1904
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1905
+ }), "recoverable");
1906
+ await foreman.close().catch(() => { });
1907
+ log.write("guided-recovery-required", { runId: buildRun.runId, message: summary });
1908
+ console.error(`foreman: run paused at a recoverable continuity checkpoint: ${summary}`);
1909
+ console.error(`foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`);
1910
+ process.exit(2);
1911
+ }
1018
1912
  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
1913
  await foreman.close().catch(() => { });
1020
1914
  log.write("error", { message: String(err) });
@@ -1179,3 +2073,7 @@ function shellQuote(value) {
1179
2073
  ? value
1180
2074
  : `'${value.replace(/'/g, `'"'"'`)}'`;
1181
2075
  }
2076
+ function printHandoffPruneCommand(runId) {
2077
+ console.log("foreman: disposable handoff cache can be pruned with:");
2078
+ console.log(` rafi handoffs prune-cache --run ${shellQuote(runId)} --keep-latest 1`);
2079
+ }