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.
- package/README.md +16 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +940 -42
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +6 -6
- package/dist/qaReview.js +20 -14
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +99 -2
- package/dist/workflowDb.js +434 -8
- 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 (
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
579
|
-
const initial = await createRawQa(qaCwd,
|
|
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: !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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.
|
|
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
|
+
}
|