ai-foreman 1.7.5 → 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 +17 -2
- 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/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +37 -3
- 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 +51 -18
- package/dist/buildRuns.js +318 -31
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1040 -62
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +615 -80
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +9 -5
- package/dist/foreman.js +76 -20
- 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 +7 -6
- package/dist/qaReview.js +31 -20
- 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/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- 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 +28 -0
- package/dist/tickets/reset.js +214 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +23 -0
- package/dist/tickets/setupConfig.js +115 -3
- package/dist/tickets/stateDb.d.ts +42 -1
- package/dist/tickets/stateDb.js +261 -23
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +102 -2
- package/dist/workflowDb.js +460 -8
- package/package.json +35 -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";
|
|
@@ -17,21 +18,30 @@ import { resolveAgentForProject } from "./runtimeSelection.js";
|
|
|
17
18
|
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
|
|
18
19
|
import { loadTickets } from "../tickets/ticketLoader.js";
|
|
19
20
|
import { StateDb } from "../tickets/stateDb.js";
|
|
20
|
-
import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
|
|
21
|
-
import {
|
|
21
|
+
import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
|
|
22
|
+
import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
|
|
22
23
|
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, 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
|
-
import { WorkflowDb } from "../workflowDb.js";
|
|
34
|
+
import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
|
|
34
35
|
import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
|
|
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";
|
|
35
45
|
function fail(message) {
|
|
36
46
|
console.error(`foreman: ${message}`);
|
|
37
47
|
process.exit(1);
|
|
@@ -59,6 +69,14 @@ function collectTicket(value, previous) {
|
|
|
59
69
|
function unique(values) {
|
|
60
70
|
return [...new Set(values)];
|
|
61
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
|
+
}
|
|
62
80
|
export function formatStartResumeCommand(executable, projectDir, steps, sessionId) {
|
|
63
81
|
return [
|
|
64
82
|
executable,
|
|
@@ -104,12 +122,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
|
|
|
104
122
|
?? deliveryCompletion
|
|
105
123
|
?? savedBuild?.completion
|
|
106
124
|
?? "none";
|
|
125
|
+
const explicitIsolation = branchFlagOn || createPrFlagOn || optionWasProvided(command, "completion") || Boolean(opts.stacks);
|
|
126
|
+
if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
|
|
127
|
+
completionMode = "none";
|
|
107
128
|
if (createPrFlagProvided && opts.createPr === false) {
|
|
108
129
|
completionMode = "none";
|
|
109
130
|
}
|
|
110
131
|
const savedBranchMode = savedBuild?.branch_strategy === "branch-per-ticket";
|
|
111
132
|
let branchMode = Boolean(savedBranchMode || branchFlagOn || createPrFlagOn || completionMode !== "none");
|
|
112
|
-
if (
|
|
133
|
+
if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
|
|
134
|
+
branchMode = false;
|
|
135
|
+
if (savedBuild?.branch_strategy !== "current" && !branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
|
|
113
136
|
branchMode = delivery.unit.branch_mode !== "current";
|
|
114
137
|
}
|
|
115
138
|
if (branchFlagProvided && opts.branchPerTicket === false) {
|
|
@@ -137,6 +160,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
|
|
|
137
160
|
mergeMethod: typeof opts.mergeMethod === "string" ? parseMergeMethod(opts.mergeMethod) : delivery?.unit.merge_method ?? savedBuild?.merge_method ?? "squash",
|
|
138
161
|
};
|
|
139
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
|
+
}
|
|
140
174
|
function parseMergeMethod(value) {
|
|
141
175
|
if (["squash", "merge", "rebase"].includes(value))
|
|
142
176
|
return value;
|
|
@@ -267,6 +301,9 @@ export function buildStartCommand() {
|
|
|
267
301
|
.option("-m, --model <model>", "override the builder's model")
|
|
268
302
|
.option("-r, --resume <sessionId>", "resume a prior builder session")
|
|
269
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")
|
|
270
307
|
.option("--continue", "resume the most recent logged session for this project")
|
|
271
308
|
.option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
|
|
272
309
|
.option("-y, --yes", "skip pre-flight confirmation prompt")
|
|
@@ -284,13 +321,24 @@ export function buildStartCommand() {
|
|
|
284
321
|
.option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
|
|
285
322
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
286
323
|
.option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
|
|
287
|
-
.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)")
|
|
288
328
|
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
|
|
289
329
|
.option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
|
|
290
330
|
.option("--keep-worktrees", "keep successful ticket worktrees for inspection")
|
|
291
|
-
.option("--ticket <id>", "
|
|
331
|
+
.option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
|
|
292
332
|
.option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
|
|
293
333
|
.action(async (project, opts, command) => {
|
|
334
|
+
const requestedTicketIds = opts.ticket ?? [];
|
|
335
|
+
const recoveryIntent = Boolean(opts.resume || opts.continue || opts.recoverRun);
|
|
336
|
+
if (!recoveryIntent && requestedTicketIds.length > 1)
|
|
337
|
+
fail("new-ticket selection supports exactly one --ticket");
|
|
338
|
+
if (!recoveryIntent && requestedTicketIds.length === 1 && opts.steps === undefined && opts.stacks === undefined)
|
|
339
|
+
opts.steps = "1";
|
|
340
|
+
if (!recoveryIntent && requestedTicketIds.length && opts.stacks !== undefined)
|
|
341
|
+
fail("new-ticket selection cannot be combined with --stacks");
|
|
294
342
|
if (opts.steps !== undefined && opts.stacks !== undefined)
|
|
295
343
|
fail("--steps and --stacks are mutually exclusive");
|
|
296
344
|
if (opts.steps === undefined && opts.stacks === undefined) {
|
|
@@ -326,6 +374,21 @@ export function buildStartCommand() {
|
|
|
326
374
|
const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
|
|
327
375
|
if (opts.recoverRun && !recoveryRecord)
|
|
328
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");
|
|
329
392
|
const roleDefaults = readAgentDefaults(cwd);
|
|
330
393
|
let deliveryRun;
|
|
331
394
|
let deliveryConfig;
|
|
@@ -371,6 +434,14 @@ export function buildStartCommand() {
|
|
|
371
434
|
if (stackCount !== undefined && selectedStacks.length !== stackCount)
|
|
372
435
|
fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
|
|
373
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");
|
|
374
445
|
let agent;
|
|
375
446
|
try {
|
|
376
447
|
agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
|
|
@@ -419,13 +490,25 @@ export function buildStartCommand() {
|
|
|
419
490
|
fail("choose either --resume <sessionId> or --continue, not both");
|
|
420
491
|
}
|
|
421
492
|
const branchMode = branchDefaults.branchMode;
|
|
422
|
-
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
|
+
: [];
|
|
505
|
+
let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
|
|
423
506
|
const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
|
|
424
507
|
if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
|
|
425
508
|
fail("--max-branch-depth must be a positive integer");
|
|
426
509
|
}
|
|
427
|
-
if (!branchMode && continueTickets.length >
|
|
428
|
-
fail("
|
|
510
|
+
if (!branchMode && continueTickets.length > 1) {
|
|
511
|
+
fail("current-branch build recovery supports exactly one --ticket");
|
|
429
512
|
}
|
|
430
513
|
if (branchMode) {
|
|
431
514
|
if (opts.resume && continueTickets.length > 1) {
|
|
@@ -434,9 +517,6 @@ export function buildStartCommand() {
|
|
|
434
517
|
if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
|
|
435
518
|
fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
|
|
436
519
|
}
|
|
437
|
-
if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
|
|
438
|
-
fail("--ticket in branch mode requires --continue or --resume <sessionId>");
|
|
439
|
-
}
|
|
440
520
|
if (opts.tickets)
|
|
441
521
|
fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
|
|
442
522
|
if (!isTicketsInitialized(cwd))
|
|
@@ -450,39 +530,228 @@ export function buildStartCommand() {
|
|
|
450
530
|
if (!branchMode && opts.continue && !resumeSessionId) {
|
|
451
531
|
fail(`no previous session id found under ${join(cwd, ".foreman")}`);
|
|
452
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
|
+
}
|
|
548
|
+
if (selectedNewTicket) {
|
|
549
|
+
if (!isTicketsInitialized(cwd))
|
|
550
|
+
fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
|
|
551
|
+
const eligibilityConfig = loadTicketsConfig(cwd);
|
|
552
|
+
const eligibilityPaths = resolveTicketPaths(eligibilityConfig, cwd);
|
|
553
|
+
const eligibilityDefs = loadTickets(eligibilityPaths.tickets);
|
|
554
|
+
const eligibilityDb = new StateDb(eligibilityPaths.stateDb);
|
|
555
|
+
const eligibilityStates = eligibilityDb.getAllStates();
|
|
556
|
+
eligibilityDb.close();
|
|
557
|
+
const activeLease = readCurrentWorkflowLease(cwd);
|
|
558
|
+
let assessment = evaluateTicketEligibility({
|
|
559
|
+
tickets: eligibilityDefs,
|
|
560
|
+
states: eligibilityStates,
|
|
561
|
+
implementationLimit: eligibilityConfig.implementationLimit,
|
|
562
|
+
delivery: deliveryConfig,
|
|
563
|
+
activeLease: activeLease ? { runId: activeLease.runId } : undefined,
|
|
564
|
+
}, selectedNewTicket);
|
|
565
|
+
if (!assessment.eligible) {
|
|
566
|
+
console.error(`foreman: ${selectedNewTicket} is not eligible:`);
|
|
567
|
+
for (const blocker of assessment.blockers)
|
|
568
|
+
console.error(` - ${blocker.detail}`);
|
|
569
|
+
const dependency = assessment.eligibleDependencies[0];
|
|
570
|
+
if (!dependency || opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
571
|
+
fail(`cannot start ${selectedNewTicket}`);
|
|
572
|
+
const answer = await select({ message: `Start eligible dependency ${dependency.id}: ${dependency.title} instead?`, options: [
|
|
573
|
+
{ value: "dependency", label: `Start ${dependency.id} (Recommended)` },
|
|
574
|
+
{ value: "cancel", label: "Cancel" },
|
|
575
|
+
] });
|
|
576
|
+
if (isCancel(answer) || answer === "cancel") {
|
|
577
|
+
console.log("ai-foreman: cancelled; nothing changed");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
selectedNewTicket = dependency.id;
|
|
581
|
+
assessment = evaluateTicketEligibility({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig }, selectedNewTicket);
|
|
582
|
+
}
|
|
583
|
+
const recommended = eligibleTickets({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig })[0];
|
|
584
|
+
if (assessment.eligible && recommended && recommended.id !== selectedNewTicket) {
|
|
585
|
+
console.log(`foreman: recommended ${recommended.id}: ${recommended.title}`);
|
|
586
|
+
console.log(`foreman: requested ${assessment.ticket.id}: ${assessment.ticket.title}`);
|
|
587
|
+
if (!opts.yes) {
|
|
588
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
589
|
+
fail(`starting non-recommended ${selectedNewTicket} requires --yes`);
|
|
590
|
+
const answer = await select({ message: `Start ${selectedNewTicket} anyway?`, options: [
|
|
591
|
+
{ value: "start", label: `Start ${selectedNewTicket}` }, { value: "cancel", label: "Cancel" },
|
|
592
|
+
] });
|
|
593
|
+
if (isCancel(answer) || answer === "cancel") {
|
|
594
|
+
console.log("ai-foreman: cancelled; nothing changed");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const recoveryTicket = !branchMode && recoveryRecord ? continueTickets[0] : undefined;
|
|
601
|
+
const preferredTicket = selectedNewTicket ?? recoveryTicket;
|
|
453
602
|
const config = loadConfig(join(cwd, "foreman.yaml"));
|
|
454
603
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
455
604
|
const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
|
|
456
605
|
const log = new Log(logPath);
|
|
457
606
|
const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
|
|
458
|
-
const createRawBuilder = async (builderCwd, sessionId) => {
|
|
459
|
-
const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
|
|
607
|
+
const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
|
|
608
|
+
const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
|
|
460
609
|
const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
|
|
461
610
|
const adapterOpts = {
|
|
462
611
|
cwd: builderCwd,
|
|
463
612
|
runtimeExecutable: agentExecutable,
|
|
464
613
|
runtimePhase: "builder",
|
|
465
614
|
model,
|
|
466
|
-
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,
|
|
467
623
|
permission: createPermissionHandler(builderPolicy, log),
|
|
468
624
|
effort: builderEffort,
|
|
469
625
|
fast: builderFast,
|
|
470
626
|
systemPromptAppend: roleBundle.system || undefined,
|
|
471
627
|
skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
|
|
472
628
|
};
|
|
473
|
-
|
|
629
|
+
const adapter = agent === "codex"
|
|
474
630
|
? new CodexAdapter(adapterOpts)
|
|
475
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;
|
|
640
|
+
};
|
|
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);
|
|
476
727
|
};
|
|
477
|
-
const
|
|
478
|
-
|
|
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);
|
|
479
748
|
return new RecoveringAdapter({
|
|
480
749
|
initial,
|
|
481
750
|
runtime: agent,
|
|
482
751
|
label: "builder turn",
|
|
483
752
|
enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
484
753
|
allowSwitch: !(opts.resume || opts.continue),
|
|
485
|
-
recreate: async (nextRuntime, resumeSessionId) => {
|
|
754
|
+
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
486
755
|
const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
|
|
487
756
|
label: "builder recovery",
|
|
488
757
|
allowSwitch: false,
|
|
@@ -491,19 +760,169 @@ export function buildStartCommand() {
|
|
|
491
760
|
agent = nextReady.runtime;
|
|
492
761
|
model = nextReady.model;
|
|
493
762
|
agentExecutable = nextReady.executable;
|
|
494
|
-
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" }),
|
|
495
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,
|
|
496
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);
|
|
497
911
|
};
|
|
498
|
-
const createRawQa = async (qaCwd,
|
|
912
|
+
const createRawQa = async (qaCwd, sessionRef) => {
|
|
499
913
|
const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
|
|
500
914
|
const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
|
|
501
915
|
const adapterOpts = {
|
|
502
916
|
cwd: qaCwd,
|
|
917
|
+
configRoot: cwd,
|
|
918
|
+
sessionRole: "qa",
|
|
919
|
+
sessionStream: "qa",
|
|
503
920
|
runtimeExecutable: qaExecutable,
|
|
504
921
|
runtimePhase: "qa",
|
|
505
922
|
model: qaModel,
|
|
506
|
-
|
|
923
|
+
...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
|
|
924
|
+
sessionGeneration: sessionRef?.generation ?? 0,
|
|
925
|
+
workspaceIdentity: captureWorkspaceIdentity(qaCwd),
|
|
507
926
|
permission: createPermissionHandler(qaPolicy, log),
|
|
508
927
|
effort: qaEffort,
|
|
509
928
|
fast: qaFast,
|
|
@@ -514,15 +933,15 @@ export function buildStartCommand() {
|
|
|
514
933
|
? new CodexAdapter(adapterOpts)
|
|
515
934
|
: await ClaudeAdapter.create(adapterOpts);
|
|
516
935
|
};
|
|
517
|
-
const
|
|
518
|
-
const initial = await createRawQa(qaCwd,
|
|
936
|
+
const createRecoveringQa = async (qaCwd, sessionRef) => {
|
|
937
|
+
const initial = await createRawQa(qaCwd, sessionRef);
|
|
519
938
|
return new RecoveringAdapter({
|
|
520
939
|
initial,
|
|
521
940
|
runtime: qaAgent,
|
|
522
941
|
label: "QA turn",
|
|
523
942
|
enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
524
|
-
allowSwitch: !
|
|
525
|
-
recreate: async (nextRuntime, resumeSessionId) => {
|
|
943
|
+
allowSwitch: !sessionRef,
|
|
944
|
+
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
526
945
|
const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
|
|
527
946
|
label: "QA recovery",
|
|
528
947
|
allowSwitch: false,
|
|
@@ -531,19 +950,256 @@ export function buildStartCommand() {
|
|
|
531
950
|
qaAgent = nextReady.runtime;
|
|
532
951
|
qaModel = nextReady.model;
|
|
533
952
|
qaExecutable = nextReady.executable;
|
|
534
|
-
|
|
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);
|
|
535
972
|
},
|
|
536
973
|
});
|
|
537
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
|
+
};
|
|
538
1188
|
const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
|
|
539
1189
|
if (branchMode) {
|
|
540
1190
|
const ticketsConfig = loadTicketsConfig(cwd);
|
|
541
1191
|
const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
|
|
542
1192
|
const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
|
|
543
1193
|
if (baseWorktreePolicy === "skip") {
|
|
544
|
-
const
|
|
545
|
-
if (
|
|
546
|
-
console.
|
|
1194
|
+
const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
|
|
1195
|
+
if (projection?.expectedChanges.length) {
|
|
1196
|
+
console.log(`foreman: preserved expected in-progress work: ${projection.expectedChanges.join(", ")}`);
|
|
1197
|
+
}
|
|
1198
|
+
if (projection?.unexpectedChanges.length) {
|
|
1199
|
+
console.warn("foreman: warning: unexpected changes may conflict with recovery; recovery is still allowed:");
|
|
1200
|
+
for (const item of projection.unexpectedChanges)
|
|
1201
|
+
console.warn(` ${item.path}: ${item.risk}`);
|
|
1202
|
+
console.warn("foreman: recommended action: preserve or move overlapping changes before continuing");
|
|
547
1203
|
}
|
|
548
1204
|
}
|
|
549
1205
|
else {
|
|
@@ -591,7 +1247,16 @@ export function buildStartCommand() {
|
|
|
591
1247
|
if (!ticket)
|
|
592
1248
|
fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
|
|
593
1249
|
const sessionId = opts.resume ?? session.sessionId;
|
|
594
|
-
|
|
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 });
|
|
595
1260
|
nodes.push({
|
|
596
1261
|
ticket,
|
|
597
1262
|
branch: session.branch,
|
|
@@ -613,14 +1278,16 @@ export function buildStartCommand() {
|
|
|
613
1278
|
else {
|
|
614
1279
|
plan = buildBranchPlan(tickets, states, {
|
|
615
1280
|
steps,
|
|
616
|
-
baseRef: opts.base ?? currentGitRef(cwd),
|
|
617
|
-
branchPrefix
|
|
1281
|
+
baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
|
|
1282
|
+
branchPrefix,
|
|
618
1283
|
maxBranchDepth,
|
|
619
1284
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
620
|
-
ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1285
|
+
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
621
1286
|
});
|
|
622
1287
|
let auditBuilder;
|
|
623
1288
|
let auditViewer;
|
|
1289
|
+
const auditRunId = `audit-${stamp}`;
|
|
1290
|
+
activeContinuityRunId = auditRunId;
|
|
624
1291
|
try {
|
|
625
1292
|
auditBuilder = await createBuilder(cwd);
|
|
626
1293
|
auditViewer = printEvents(auditBuilder.events());
|
|
@@ -630,34 +1297,54 @@ export function buildStartCommand() {
|
|
|
630
1297
|
const auditDependencies = parseAuditDependencies(audit.text);
|
|
631
1298
|
plan = buildBranchPlan(tickets, states, {
|
|
632
1299
|
steps,
|
|
633
|
-
baseRef: opts.base ?? currentGitRef(cwd),
|
|
634
|
-
branchPrefix
|
|
1300
|
+
baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
|
|
1301
|
+
branchPrefix,
|
|
635
1302
|
maxBranchDepth,
|
|
636
1303
|
auditDependencies,
|
|
637
1304
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
638
|
-
ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1305
|
+
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
639
1306
|
});
|
|
640
1307
|
auditDependencyCount = auditDependencies.length;
|
|
641
1308
|
}
|
|
642
1309
|
finally {
|
|
643
1310
|
await auditBuilder?.close().catch(() => { });
|
|
644
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;
|
|
645
1321
|
}
|
|
646
1322
|
if (deliveryRun?.unit.branch_mode === "shared") {
|
|
647
|
-
plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining,
|
|
1323
|
+
plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
|
|
648
1324
|
const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
|
|
649
1325
|
const first = plan.nodes[0];
|
|
650
1326
|
if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
|
|
651
|
-
|
|
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 });
|
|
652
1329
|
}
|
|
653
1330
|
}
|
|
654
1331
|
if (selectedStacks.length && deliveryConfig) {
|
|
655
|
-
plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig,
|
|
1332
|
+
plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
|
|
1333
|
+
}
|
|
1334
|
+
else if (!deliveryRun) {
|
|
1335
|
+
const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
|
|
1336
|
+
if (branchPolicy)
|
|
1337
|
+
plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
|
|
656
1338
|
}
|
|
657
1339
|
if (recoveryRecord && opts.resume && plan.nodes[0]) {
|
|
658
1340
|
const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
|
|
659
|
-
if (prior)
|
|
660
|
-
|
|
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
|
+
}
|
|
661
1348
|
}
|
|
662
1349
|
}
|
|
663
1350
|
const branchPresentation = presentBranchPlan(plan, {
|
|
@@ -700,21 +1387,84 @@ export function buildStartCommand() {
|
|
|
700
1387
|
process.exit(0);
|
|
701
1388
|
}
|
|
702
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
|
+
}
|
|
703
1407
|
const capturedBranchBuilder = {
|
|
704
1408
|
role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
|
|
705
1409
|
make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
|
|
706
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,
|
|
707
1414
|
};
|
|
708
1415
|
const capturedBranchQa = {
|
|
709
1416
|
role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
|
|
710
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,
|
|
711
1420
|
};
|
|
712
|
-
|
|
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({
|
|
713
1423
|
tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
714
|
-
repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, builder: capturedBranchBuilder, qa: capturedBranchQa,
|
|
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" },
|
|
715
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
|
+
};
|
|
716
1465
|
const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
|
|
717
1466
|
branchHeartbeat.unref();
|
|
1467
|
+
let branchSessionFailure;
|
|
718
1468
|
const summaries = await runBranchPlan({
|
|
719
1469
|
projectDir: cwd,
|
|
720
1470
|
runId: masterRun.runId,
|
|
@@ -742,13 +1492,82 @@ export function buildStartCommand() {
|
|
|
742
1492
|
archiveDoc: ticketsConfig.paths.archiveDoc,
|
|
743
1493
|
},
|
|
744
1494
|
resumeSessions: resumeSessionByTicket,
|
|
745
|
-
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; },
|
|
746
1499
|
createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
|
|
747
1500
|
builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
|
|
748
1501
|
qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
|
|
749
|
-
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
|
+
},
|
|
750
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,
|
|
751
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`);
|
|
752
1571
|
clearInterval(branchHeartbeat);
|
|
753
1572
|
if (selectedStacks.length) {
|
|
754
1573
|
let allComplete = true;
|
|
@@ -792,6 +1611,8 @@ export function buildStartCommand() {
|
|
|
792
1611
|
console.log(` ${command}`);
|
|
793
1612
|
}
|
|
794
1613
|
}
|
|
1614
|
+
if (masterRun.status === "completed")
|
|
1615
|
+
printHandoffPruneCommand(masterRun.runId);
|
|
795
1616
|
const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
|
|
796
1617
|
process.exit(failed ? 2 : 0);
|
|
797
1618
|
}
|
|
@@ -804,6 +1625,20 @@ export function buildStartCommand() {
|
|
|
804
1625
|
agent = ready.runtime;
|
|
805
1626
|
model = ready.model;
|
|
806
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
|
+
}
|
|
807
1642
|
if (qaEnabled) {
|
|
808
1643
|
const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
|
|
809
1644
|
qaAgent = qaReady.runtime;
|
|
@@ -814,11 +1649,16 @@ export function buildStartCommand() {
|
|
|
814
1649
|
role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
|
|
815
1650
|
make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
|
|
816
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,
|
|
817
1655
|
};
|
|
818
|
-
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 };
|
|
819
|
-
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId:
|
|
820
|
-
tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
|
|
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({
|
|
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" },
|
|
821
1660
|
});
|
|
1661
|
+
activeContinuityRunId = buildRun.runId;
|
|
822
1662
|
const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
|
|
823
1663
|
heartbeat.unref();
|
|
824
1664
|
const checkpointSignal = () => {
|
|
@@ -829,22 +1669,103 @@ export function buildStartCommand() {
|
|
|
829
1669
|
};
|
|
830
1670
|
process.once("SIGINT", checkpointSignal);
|
|
831
1671
|
process.once("SIGTERM", checkpointSignal);
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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,
|
|
835
1739
|
role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
|
|
836
1740
|
reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
|
|
837
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
|
+
} },
|
|
838
1756
|
}, (line, snapshot) => {
|
|
839
1757
|
const activity = currentActivity();
|
|
840
|
-
if (activity)
|
|
841
|
-
activity.
|
|
1758
|
+
if (activity && process.stdout.isTTY)
|
|
1759
|
+
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
842
1760
|
else
|
|
843
1761
|
console.log(line);
|
|
844
1762
|
log.write("agent-status", { ...snapshot });
|
|
845
1763
|
const db = new WorkflowDb(cwd);
|
|
846
1764
|
try {
|
|
847
1765
|
db.recordTelemetry(buildRun.runId, snapshot);
|
|
1766
|
+
db.recordContextSample(snapshot.contextSample);
|
|
1767
|
+
if (snapshot.sessionUsage)
|
|
1768
|
+
db.recordSessionUsage(snapshot.sessionUsage);
|
|
848
1769
|
}
|
|
849
1770
|
finally {
|
|
850
1771
|
db.close();
|
|
@@ -866,11 +1787,11 @@ export function buildStartCommand() {
|
|
|
866
1787
|
try {
|
|
867
1788
|
console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
|
|
868
1789
|
buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
|
|
869
|
-
await foreman.runPreflight(steps, ticketsContent);
|
|
1790
|
+
await foreman.runPreflight(steps, ticketsContent, preferredTicket);
|
|
870
1791
|
if (foreman.builderSessionId())
|
|
871
|
-
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
|
|
1792
|
+
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
|
|
872
1793
|
if (foreman.qaSessionId())
|
|
873
|
-
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
|
|
1794
|
+
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
|
|
874
1795
|
buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
|
|
875
1796
|
if (!opts.yes) {
|
|
876
1797
|
while (true) {
|
|
@@ -909,9 +1830,21 @@ export function buildStartCommand() {
|
|
|
909
1830
|
await foreman.sendPreflightFeedback(String(fb));
|
|
910
1831
|
}
|
|
911
1832
|
}
|
|
912
|
-
const result = await foreman.runBatch(steps, trackerRelPath)
|
|
1833
|
+
const result = await foreman.runBatch(steps, trackerRelPath, (ticketId) => {
|
|
1834
|
+
const tickets = buildRun.tickets.includes(ticketId)
|
|
1835
|
+
? buildRun.tickets
|
|
1836
|
+
: [...buildRun.tickets, ticketId];
|
|
1837
|
+
buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", {
|
|
1838
|
+
tickets,
|
|
1839
|
+
currentTicket: ticketId,
|
|
1840
|
+
});
|
|
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());
|
|
913
1846
|
if (foreman.qaSessionId())
|
|
914
|
-
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
|
|
1847
|
+
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
|
|
915
1848
|
buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
|
|
916
1849
|
? completeBuildRun(cwd, buildRun)
|
|
917
1850
|
: releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
|
|
@@ -923,6 +1856,8 @@ export function buildStartCommand() {
|
|
|
923
1856
|
console.log(`foreman: outcome — ${result.outcome}`);
|
|
924
1857
|
if (result.detail)
|
|
925
1858
|
console.log(`foreman: ${result.detail}`);
|
|
1859
|
+
if (buildRun.status === "completed")
|
|
1860
|
+
printHandoffPruneCommand(buildRun.runId);
|
|
926
1861
|
if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
|
|
927
1862
|
const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
|
|
928
1863
|
const remainingSteps = Math.max(1, result.requested - result.completed);
|
|
@@ -935,6 +1870,45 @@ export function buildStartCommand() {
|
|
|
935
1870
|
catch (err) {
|
|
936
1871
|
clearInterval(heartbeat);
|
|
937
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
|
+
}
|
|
938
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");
|
|
939
1913
|
await foreman.close().catch(() => { });
|
|
940
1914
|
log.write("error", { message: String(err) });
|
|
@@ -1058,7 +2032,7 @@ export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix)
|
|
|
1058
2032
|
const unit = unitById.get(unitId);
|
|
1059
2033
|
if (!unit)
|
|
1060
2034
|
throw new Error(`stack ${stack.id} references missing delivery unit ${unitId}`);
|
|
1061
|
-
const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix
|
|
2035
|
+
const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix || "feature"}/${slug(unitId)}` : undefined;
|
|
1062
2036
|
const unitBaseBranch = predecessorBranch;
|
|
1063
2037
|
for (let ticketIndex = 0; ticketIndex < unit.tickets.length; ticketIndex++) {
|
|
1064
2038
|
const ticketId = unit.tickets[ticketIndex];
|
|
@@ -1099,3 +2073,7 @@ function shellQuote(value) {
|
|
|
1099
2073
|
? value
|
|
1100
2074
|
: `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1101
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
|
+
}
|