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