ai-foreman 1.7.6 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +16 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +940 -42
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +86 -0
  32. package/dist/continuity.js +365 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +326 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +20 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +102 -3
  47. package/dist/sessionLifecycle.js +578 -20
  48. package/dist/statusReporter.d.ts +58 -8
  49. package/dist/statusReporter.js +210 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +99 -2
  68. package/dist/workflowDb.js +434 -8
  69. package/package.json +31 -3
package/dist/agentRun.js CHANGED
@@ -6,12 +6,15 @@ import { PermissionPolicy } from "./permissions/policy.js";
6
6
  import { ClaudeAdapter } from "./adapters/claude.js";
7
7
  import { CodexAdapter } from "./adapters/codex.js";
8
8
  import { RecoveringAdapter } from "./adapters/recovering.js";
9
+ import { SessionUnavailableError } from "./adapters/sessionFailure.js";
9
10
  import { Foreman, createPermissionHandler } from "./foreman.js";
10
11
  import { printEvents } from "./cli/events.js";
11
12
  import { loadRoleBundle } from "./roles.js";
12
13
  import { ensureRuntimeReadyForCommand } from "./cli/runtimeAuthPrompt.js";
13
14
  import { resolveAgentForProject } from "./cli/runtimeSelection.js";
14
15
  import { parse as parseYaml } from "yaml";
16
+ import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "./sessionIdentity.js";
17
+ import { WorkflowDb } from "./workflowDb.js";
15
18
  export const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
16
19
  export function assertEffortLevel(effort) {
17
20
  if (effort && !VALID_EFFORT.includes(effort)) {
@@ -87,10 +90,29 @@ export async function createRoleBuilder(opts) {
87
90
  throw new Error(`project directory not found: ${opts.projectDir}`);
88
91
  }
89
92
  const saved = readRoleDefaultsForExecution(opts.projectDir, opts.role);
93
+ if (opts.resumeSessionRef && opts.resumeSessionId && opts.resumeSessionRef.sessionId !== opts.resumeSessionId) {
94
+ throw new Error("resume session ID does not match the supplied location-scoped reference");
95
+ }
96
+ let requestedResumeRef = opts.resumeSessionRef;
97
+ if (!requestedResumeRef && opts.resumeSessionId) {
98
+ const bindingDb = new WorkflowDb(opts.projectDir);
99
+ try {
100
+ requestedResumeRef = resolveUniqueSessionBinding(bindingDb.providerSessionBindings(opts.resumeSessionId), opts.resumeSessionId);
101
+ }
102
+ finally {
103
+ bindingDb.close();
104
+ }
105
+ }
90
106
  const configuredEffort = saved?.reasoning && saved.reasoning !== "default" ? saved.reasoning : undefined;
91
107
  const effectiveEffort = opts.effort ?? (VALID_EFFORT.includes(configuredEffort) ? configuredEffort : undefined);
92
108
  assertEffortLevel(effectiveEffort);
93
- let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? saved?.make);
109
+ let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? requestedResumeRef?.provider ?? saved?.make);
110
+ if (requestedResumeRef && requestedResumeRef.provider !== runtime) {
111
+ throw new Error(`session ${requestedResumeRef.sessionId} belongs to ${requestedResumeRef.provider}, but ${runtime} was selected`);
112
+ }
113
+ if (requestedResumeRef && requestedResumeRef.role !== configurableRole(opts.role)) {
114
+ throw new Error(`session ${requestedResumeRef.sessionId} belongs to role ${requestedResumeRef.role}, not ${configurableRole(opts.role)}`);
115
+ }
94
116
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
95
117
  const log = opts.log ?? new Log(makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-")));
96
118
  const roleBundle = loadRoleBundle(opts.role, { projectDir: opts.projectDir });
@@ -99,7 +121,7 @@ export async function createRoleBuilder(opts) {
99
121
  const ready = await ensureRuntimeReadyForCommand(opts.projectDir, runtime, {
100
122
  label: opts.label,
101
123
  yes: Boolean(opts.yes),
102
- allowSwitch: opts.allowSwitch,
124
+ allowSwitch: requestedResumeRef || opts.resumeSessionId ? false : opts.allowSwitch,
103
125
  model: initialModel,
104
126
  });
105
127
  runtime = ready.runtime;
@@ -108,13 +130,42 @@ export async function createRoleBuilder(opts) {
108
130
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
109
131
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
110
132
  const interactive = !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
111
- const makeAdapter = async (nextRuntime, resumeSessionId) => {
133
+ const makeAdapter = async (nextRuntime, resumeSessionId, resumeSessionRef) => {
134
+ let scopedRef = resumeSessionRef;
135
+ if (!scopedRef && resumeSessionId) {
136
+ const bindingDb = new WorkflowDb(opts.projectDir);
137
+ try {
138
+ scopedRef = resolveUniqueSessionBinding(bindingDb.providerSessionBindings(resumeSessionId), resumeSessionId);
139
+ }
140
+ finally {
141
+ bindingDb.close();
142
+ }
143
+ scopedRef ??= createProviderSessionRef({
144
+ provider: nextRuntime,
145
+ sessionId: resumeSessionId,
146
+ role: configurableRole(opts.role),
147
+ stream: opts.role,
148
+ generation: 0,
149
+ cwd: opts.projectDir,
150
+ configRoot: opts.projectDir,
151
+ workspaceIdentity: captureWorkspaceIdentity(opts.projectDir),
152
+ source: "legacy-inferred",
153
+ });
154
+ }
155
+ if (scopedRef && scopedRef.provider !== nextRuntime) {
156
+ throw new Error(`session ${scopedRef.sessionId} belongs to ${scopedRef.provider}, but ${nextRuntime} was selected`);
157
+ }
112
158
  const adapterOpts = {
113
159
  cwd: opts.projectDir,
160
+ configRoot: opts.projectDir,
114
161
  runtimeExecutable,
115
162
  runtimePhase: phaseForRole(opts.role),
116
163
  model,
117
- resumeSessionId,
164
+ ...(scopedRef ? { resumeSessionRef: scopedRef } : {}),
165
+ sessionRole: configurableRole(opts.role),
166
+ sessionStream: opts.role,
167
+ sessionGeneration: scopedRef?.generation ?? 0,
168
+ workspaceIdentity: captureWorkspaceIdentity(opts.projectDir),
118
169
  permission: createPermissionHandler(policy, log, {
119
170
  interactive,
120
171
  onAnsweredQuestion: opts.onAnsweredQuestion,
@@ -126,18 +177,46 @@ export async function createRoleBuilder(opts) {
126
177
  systemPromptAppend: roleBundle.system || undefined,
127
178
  skills: skills.length > 0 ? skills : undefined,
128
179
  };
129
- return nextRuntime === "codex"
180
+ const adapter = nextRuntime === "codex"
130
181
  ? new CodexAdapter(adapterOpts)
131
182
  : await ClaudeAdapter.create(adapterOpts);
183
+ if (scopedRef && adapter.agent === "codex") {
184
+ const availability = await adapter.validateSession();
185
+ if (availability.status !== "available") {
186
+ await adapter.close().catch(() => { });
187
+ throw new SessionUnavailableError({
188
+ runtime: "codex",
189
+ phase: "preflight",
190
+ dispatchState: "not-sent",
191
+ executable: runtimeExecutable,
192
+ cwd: opts.projectDir,
193
+ diagnostics: availability.detail ?? `Codex session ${scopedRef.sessionId} is ${availability.status}`,
194
+ availability,
195
+ });
196
+ }
197
+ }
198
+ return adapter;
132
199
  };
133
- const initial = await makeAdapter(runtime, opts.resumeSessionId);
200
+ const initial = await makeAdapter(runtime, opts.resumeSessionId, requestedResumeRef);
201
+ const persistSessionRef = (ref) => {
202
+ const bindingDb = new WorkflowDb(opts.projectDir);
203
+ try {
204
+ bindingDb.recordProviderSessionBinding(ref);
205
+ }
206
+ finally {
207
+ bindingDb.close();
208
+ }
209
+ };
210
+ const initialRef = initial.sessionRef?.();
211
+ if (initialRef)
212
+ persistSessionRef(initialRef);
134
213
  const builder = new RecoveringAdapter({
135
214
  initial,
136
215
  runtime,
137
216
  label: opts.label,
138
217
  enabled: interactive,
139
- allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId,
140
- recreate: async (nextRuntime, resumeSessionId) => {
218
+ allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId && !requestedResumeRef,
219
+ recreate: async (nextRuntime, resumeSessionId, resumeSessionRef) => {
141
220
  const nextReady = await ensureRuntimeReadyForCommand(opts.projectDir, nextRuntime, {
142
221
  label: opts.label,
143
222
  allowSwitch: false,
@@ -146,8 +225,9 @@ export async function createRoleBuilder(opts) {
146
225
  runtime = nextReady.runtime;
147
226
  model = nextReady.model;
148
227
  runtimeExecutable = nextReady.executable;
149
- return makeAdapter(runtime, resumeSessionId);
228
+ return makeAdapter(runtime, resumeSessionId, resumeSessionRef);
150
229
  },
230
+ onSessionRef: persistSessionRef,
151
231
  });
152
232
  return { builder, runtime, model, effort, roleBundle, skills, log };
153
233
  }
@@ -225,6 +305,7 @@ export async function runRoleInstruction(opts) {
225
305
  model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
226
306
  effort,
227
307
  sessionId: builder.sessionId(),
308
+ sessionRef: builder.sessionRef?.(),
228
309
  statusKind: turn.status.kind,
229
310
  summary: turn.status.summary,
230
311
  reason: turn.status.reason,
@@ -238,6 +319,7 @@ export async function runRoleInstruction(opts) {
238
319
  model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
239
320
  effort,
240
321
  sessionId: builder.sessionId(),
322
+ sessionRef: builder.sessionRef?.(),
241
323
  logPath,
242
324
  roleBundle,
243
325
  skills,
@@ -250,3 +332,8 @@ export async function runRoleInstruction(opts) {
250
332
  throw err;
251
333
  }
252
334
  }
335
+ function configurableRole(role) {
336
+ return ["builder", "qa", "planner", "ticket-maker", "uninstaller"].includes(role)
337
+ ? role
338
+ : "builder";
339
+ }
@@ -0,0 +1,34 @@
1
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
2
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
+ export interface CurrentWorkflowIdentity {
4
+ worktree: string;
5
+ ref: string;
6
+ }
7
+ export declare class CurrentWorkflowChangedError extends Error {
8
+ readonly expected: CurrentWorkflowIdentity;
9
+ readonly actual: CurrentWorkflowIdentity;
10
+ constructor(expected: CurrentWorkflowIdentity, actual: CurrentWorkflowIdentity);
11
+ }
12
+ export declare function captureCurrentWorkflowIdentity(cwd: string): CurrentWorkflowIdentity;
13
+ export declare function assertCurrentWorkflowIdentity(cwd: string, expected: CurrentWorkflowIdentity): void;
14
+ export declare function currentWorkflowIdentityKey(identity: CurrentWorkflowIdentity): string;
15
+ export declare function captureCurrentWorkflowSessionIdentity(cwd: string): string;
16
+ /** Fence every provider boundary against an unexpected user/provider ref switch. */
17
+ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
18
+ private readonly adapter;
19
+ private readonly cwd;
20
+ private readonly expected;
21
+ readonly agent: BuilderAdapter["agent"];
22
+ constructor(adapter: BuilderAdapter, cwd: string, expected?: CurrentWorkflowIdentity);
23
+ sendTurn(text: string): Promise<TurnResult>;
24
+ sessionId(): string | undefined;
25
+ sessionRef(): ProviderSessionRefV1 | undefined;
26
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
27
+ validateSession(): Promise<SessionAvailabilityV1>;
28
+ compact(): Promise<CompactResult>;
29
+ contextUsage(): Promise<ContextUsage | undefined>;
30
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
31
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
32
+ events(): AsyncIterable<BuilderEvent>;
33
+ close(): Promise<void>;
34
+ }
@@ -0,0 +1,65 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { realpathSync } from "node:fs";
4
+ export class CurrentWorkflowChangedError extends Error {
5
+ expected;
6
+ actual;
7
+ constructor(expected, actual) {
8
+ super(`current-branch workflow paused: active worktree/ref changed (expected ${expected.worktree} @ ${expected.ref}; found ${actual.worktree} @ ${actual.ref})`);
9
+ this.expected = expected;
10
+ this.actual = actual;
11
+ }
12
+ }
13
+ export function captureCurrentWorkflowIdentity(cwd) {
14
+ return { worktree: realpathSync(git(cwd, ["rev-parse", "--show-toplevel"])), ref: currentRef(cwd) };
15
+ }
16
+ export function assertCurrentWorkflowIdentity(cwd, expected) {
17
+ const actual = captureCurrentWorkflowIdentity(cwd);
18
+ if (actual.worktree !== expected.worktree || actual.ref !== expected.ref) {
19
+ throw new CurrentWorkflowChangedError(expected, actual);
20
+ }
21
+ }
22
+ export function currentWorkflowIdentityKey(identity) {
23
+ return `current_${createHash("sha256").update(`${identity.worktree}\0${identity.ref}`).digest("hex")}`;
24
+ }
25
+ export function captureCurrentWorkflowSessionIdentity(cwd) {
26
+ return currentWorkflowIdentityKey(captureCurrentWorkflowIdentity(cwd));
27
+ }
28
+ /** Fence every provider boundary against an unexpected user/provider ref switch. */
29
+ export class CurrentWorkflowGuardAdapter {
30
+ adapter;
31
+ cwd;
32
+ expected;
33
+ agent;
34
+ constructor(adapter, cwd, expected = captureCurrentWorkflowIdentity(cwd)) {
35
+ this.adapter = adapter;
36
+ this.cwd = cwd;
37
+ this.expected = expected;
38
+ this.agent = adapter.agent;
39
+ }
40
+ async sendTurn(text) {
41
+ assertCurrentWorkflowIdentity(this.cwd, this.expected);
42
+ const result = await this.adapter.sendTurn(text);
43
+ assertCurrentWorkflowIdentity(this.cwd, this.expected);
44
+ return result;
45
+ }
46
+ sessionId() { return this.adapter.sessionId(); }
47
+ sessionRef() { return this.adapter.sessionRef?.(); }
48
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
49
+ validateSession() { assertCurrentWorkflowIdentity(this.cwd, this.expected); return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
50
+ async compact() { assertCurrentWorkflowIdentity(this.cwd, this.expected); const result = await (this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" })); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
51
+ contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
52
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
53
+ switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
54
+ events() { return this.adapter.events(); }
55
+ close() { return this.adapter.close(); }
56
+ }
57
+ function currentRef(cwd) {
58
+ try {
59
+ return `branch:${git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"])}`;
60
+ }
61
+ catch {
62
+ return `detached:${git(cwd, ["rev-parse", "HEAD"])}`;
63
+ }
64
+ }
65
+ function git(cwd, args) { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
@@ -24,4 +24,4 @@ export declare function applySharedDeliveryBranch(plan: BranchPlan, unitId: stri
24
24
  export declare function applySizeBranchPolicy(plan: BranchPlan, policy: TicketBuildSetupConfig["branch_policy"], delivery?: DeliveryConfig, branchPrefix?: string): BranchPlan;
25
25
  export declare function parseAuditDependencies(text: string): AuditDependency[];
26
26
  export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
27
- export declare function normalizeBranchPrefix(prefix: string | undefined): string;
27
+ export { normalizeBranchPrefix } from "./prefix.js";
@@ -1,3 +1,4 @@
1
+ import { normalizeBranchPrefix } from "./prefix.js";
1
2
  export function buildBranchPlan(tickets, states, opts) {
2
3
  const selected = selectTicketsForBranchRun(tickets, states, opts.steps, opts.ticketIds);
3
4
  const selectedIds = new Set(selected.map((ticket) => ticket.id));
@@ -192,13 +193,7 @@ function uniqueBranchName(prefix, ticket, used) {
192
193
  used.add(branch);
193
194
  return branch;
194
195
  }
195
- export function normalizeBranchPrefix(prefix) {
196
- const value = prefix === undefined || prefix === "" ? "feature" : prefix;
197
- if (value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//") || /[~^:?*\[\\\s]/.test(value)) {
198
- throw new Error(`invalid Git branch prefix: ${value}`);
199
- }
200
- return value;
201
- }
196
+ export { normalizeBranchPrefix } from "./prefix.js";
202
197
  function detectSelectedCycles(tickets, deps) {
203
198
  const selected = new Set(tickets.map((ticket) => ticket.id));
204
199
  const visited = new Set();
@@ -0,0 +1,8 @@
1
+ /** Built-in prefix used only when neither a run nor project value exists. */
2
+ export declare const BUILTIN_BRANCH_PREFIX = "feature";
3
+ /**
4
+ * Validate a prefix as the leading components of a Git branch ref. Internal
5
+ * slashes are intentional (for example `team/feature`).
6
+ */
7
+ export declare function validateBranchPrefix(value: string): string;
8
+ export declare function normalizeBranchPrefix(prefix: string | undefined): string;
@@ -0,0 +1,26 @@
1
+ /** Built-in prefix used only when neither a run nor project value exists. */
2
+ export const BUILTIN_BRANCH_PREFIX = "feature";
3
+ /**
4
+ * Validate a prefix as the leading components of a Git branch ref. Internal
5
+ * slashes are intentional (for example `team/feature`).
6
+ */
7
+ export function validateBranchPrefix(value) {
8
+ const prefix = value.trim();
9
+ if (!prefix)
10
+ throw new Error("branch prefix must not be empty");
11
+ if (prefix === "@" || prefix.startsWith("-") || prefix.startsWith("/") || prefix.endsWith("/")) {
12
+ throw new Error(`invalid Git branch prefix: ${value}`);
13
+ }
14
+ if (prefix.includes("//") || prefix.includes("..") || prefix.includes("@{") || /[\x00-\x20\x7f~^:?*\[\\]/.test(prefix)) {
15
+ throw new Error(`invalid Git branch prefix: ${value}`);
16
+ }
17
+ for (const segment of prefix.split("/")) {
18
+ if (!segment || segment === "." || segment === ".." || segment.startsWith(".") || segment.endsWith(".") || segment.endsWith(".lock")) {
19
+ throw new Error(`invalid Git branch prefix: ${value}`);
20
+ }
21
+ }
22
+ return prefix;
23
+ }
24
+ export function normalizeBranchPrefix(prefix) {
25
+ return validateBranchPrefix(prefix === undefined ? BUILTIN_BRANCH_PREFIX : prefix);
26
+ }
@@ -1,10 +1,12 @@
1
1
  import type { BranchRunSummary } from "./types.js";
2
+ import type { ProviderSessionRefV1 } from "rafi-spec";
2
3
  export interface BranchResumeSession {
3
4
  ticket: string;
4
5
  branch: string;
5
6
  base: string;
6
7
  worktreePath: string;
7
8
  sessionId: string;
9
+ sessionRef?: ProviderSessionRefV1;
8
10
  logPath: string;
9
11
  agent?: string;
10
12
  model?: string;
@@ -32,6 +32,7 @@ export function findResumableBranchSessions(foremanDir) {
32
32
  base,
33
33
  worktreePath,
34
34
  sessionId,
35
+ ...(isProviderSessionRef(record.sessionRef) ? { sessionRef: record.sessionRef } : {}),
35
36
  logPath,
36
37
  agent: stringField(record.agent),
37
38
  model: stringField(record.model),
@@ -116,6 +117,13 @@ function stringField(value) {
116
117
  function booleanField(value) {
117
118
  return typeof value === "boolean" ? value : undefined;
118
119
  }
120
+ function isProviderSessionRef(value) {
121
+ if (!value || typeof value !== "object")
122
+ return false;
123
+ const ref = value;
124
+ return ref.version === 1 && (ref.provider === "claude" || ref.provider === "codex") && typeof ref.sessionId === "string"
125
+ && typeof ref.cwd === "string" && typeof ref.configRoot === "string" && typeof ref.workspaceIdentity === "string";
126
+ }
119
127
  function shellQuote(value) {
120
128
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
121
129
  return value;
@@ -1,13 +1,16 @@
1
1
  import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
2
- import type { SessionStrategy } from "rafi-spec";
2
+ import type { ProviderSessionRefV1, SessionStrategy } from "rafi-spec";
3
3
  import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "../qaReview.js";
4
4
  import type { Log } from "../log.js";
5
5
  import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, MergeMethod, ReviewProvider } from "./types.js";
6
+ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
7
+ import { SessionUnavailableContinuityError } from "../continuity.js";
6
8
  export interface DeliveryUnitSession {
7
9
  unitId: string;
8
10
  branch: string;
9
11
  worktreePath: string;
10
12
  sessionId: string;
13
+ sessionRef?: ProviderSessionRefV1;
11
14
  ticket: string;
12
15
  }
13
16
  export type BaseWorktreePolicy = "enforce" | "warn" | "skip";
@@ -41,13 +44,22 @@ export interface BranchRunnerOptions {
41
44
  resumeSessions?: Map<string, {
42
45
  worktreePath: string;
43
46
  sessionId: string;
47
+ sessionRef?: ProviderSessionRefV1;
44
48
  }>;
45
- createBuilder: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
49
+ createBuilder: (cwd: string, sessionId?: string, sessionRef?: ProviderSessionRefV1) => Promise<BuilderAdapter>;
50
+ /** Persist every observed Builder binding before later work can supersede it. */
51
+ recordBuilderSession?: (session: string | ProviderSessionRefV1, ticketId: string, worktreePath: string) => void;
52
+ recordQaSession?: (session: string | ProviderSessionRefV1, ticketId: string, worktreePath: string) => void;
53
+ /** Notify the host and stop the branch plan without discarding its worktree. */
54
+ onSessionUnavailable?: (error: SessionUnavailableError | SessionUnavailableContinuityError) => void;
46
55
  createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
47
56
  builderSessionStrategy?: SessionStrategy;
48
57
  qaSessionStrategy?: SessionStrategy;
49
58
  observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
50
59
  qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
60
+ beforeBuilderTurn?: (adapter: BuilderAdapter, frozenAction: string, cwd: string) => Promise<BuilderAdapter>;
61
+ builderSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
62
+ qaSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
51
63
  }
52
64
  export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
53
65
  export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
@@ -6,10 +6,12 @@ import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
6
6
  import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
7
7
  import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
8
8
  import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
9
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
- import { join } from "node:path";
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
11
  import { WorkflowDb } from "../workflowDb.js";
12
12
  import { currentActivity, withActivityPhase } from "../activity.js";
13
+ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
14
+ import { SessionUnavailableContinuityError } from "../continuity.js";
13
15
  export function readDeliveryUnitSession(projectDir, unitId) {
14
16
  const path = deliverySessionPath(projectDir, unitId);
15
17
  if (!existsSync(path))
@@ -46,7 +48,7 @@ export async function runBranchPlan(opts) {
46
48
  const summaries = [];
47
49
  const successfulBranches = new Set();
48
50
  const pushedBranches = new Set();
49
- let builderStreamSession;
51
+ let builderStream;
50
52
  let builderWorkSessions = 0;
51
53
  const qaStream = { reviews: 0, modificationViolations: 0 };
52
54
  for (const issue of opts.plan.issues) {
@@ -138,28 +140,51 @@ export async function runBranchPlan(opts) {
138
140
  base: node.baseBranch,
139
141
  worktreePath,
140
142
  sessionId: resumeSession.sessionId,
143
+ sessionRef: resumeSession.sessionRef,
141
144
  });
142
145
  }
143
146
  journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => cmdUpdate(opts.projectDir, node.ticket.id, {
144
147
  status: "in_progress", actor: "foreman",
145
148
  summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
146
149
  }));
147
- const continuedBuilderSession = resumeSession?.sessionId ?? (opts.builderSessionStrategy === "compact" ? builderStreamSession : undefined);
148
- builder = await opts.createBuilder(worktreePath, continuedBuilderSession);
149
- if (opts.builderSessionStrategy === "compact" && builderWorkSessions > 0 && continuedBuilderSession) {
150
- const compacted = await compactWithRetry(builder);
151
- if (!compacted.ok) {
150
+ const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
151
+ // Reattach the predecessor even for a `fresh` strategy so the host can
152
+ // publish and validate a cumulative handoff before creating its successor.
153
+ const sameWorktreeStream = builderStream && canonical(builderStream.worktreePath) === canonical(worktreePath) ? builderStream : undefined;
154
+ const continuedBuilderSession = resumeSession?.sessionId ?? sameWorktreeStream?.sessionId;
155
+ const continuedBuilderRef = resumeSession?.sessionRef ?? sameWorktreeStream?.sessionRef;
156
+ builder = await opts.createBuilder(worktreePath, continuedBuilderSession, continuedBuilderRef);
157
+ if (builderWorkSessions > 0 && continuedBuilderSession) {
158
+ const strategy = opts.builderSessionStrategy ?? "compact";
159
+ if (opts.builderSessionBoundary) {
160
+ builder = await opts.builderSessionBoundary(builder, ticketInstruction, strategy, worktreePath);
161
+ }
162
+ else if (strategy === "compact") {
163
+ const compacted = await compactWithRetry(builder);
164
+ if (!compacted.ok) {
165
+ await builder.close();
166
+ builder = await opts.createBuilder(worktreePath);
167
+ }
168
+ }
169
+ else {
152
170
  await builder.close();
153
171
  builder = await opts.createBuilder(worktreePath);
154
172
  }
155
173
  }
156
174
  builderWorkSessions += 1;
157
175
  viewer = opts.observeBuilder?.(builder);
158
- const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined);
159
- const { result, status } = await foreman.runInstruction(resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths));
176
+ const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined);
177
+ const { result, status } = await foreman.runInstruction(ticketInstruction);
178
+ builder = foreman.builderAdapter();
160
179
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
161
180
  const sessionId = builder.sessionId();
162
- builderStreamSession = opts.builderSessionStrategy === "compact" ? sessionId : undefined;
181
+ const sessionRef = builder.sessionRef?.();
182
+ if (sessionId)
183
+ builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
184
+ if (sessionId) {
185
+ opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
186
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
187
+ }
163
188
  if (sessionId) {
164
189
  opts.log.write("branch-session", {
165
190
  ticket: node.ticket.id,
@@ -167,6 +192,7 @@ export async function runBranchPlan(opts) {
167
192
  base: node.baseBranch,
168
193
  worktreePath,
169
194
  sessionId,
195
+ sessionRef: builder.sessionRef?.(),
170
196
  agent: opts.agent,
171
197
  model: opts.model,
172
198
  effort: opts.effort,
@@ -183,7 +209,7 @@ export async function runBranchPlan(opts) {
183
209
  if (node.deliveryUnitId) {
184
210
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
185
211
  mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
186
- writeFileSync(path, `${JSON.stringify({ unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, ticket: node.ticket.id }, null, 2)}\n`, "utf8");
212
+ writeFileSync(path, `${JSON.stringify({ unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
187
213
  }
188
214
  }
189
215
  opts.log.write("step", {
@@ -215,30 +241,47 @@ export async function runBranchPlan(opts) {
215
241
  const qa = await runIsolatedQa({
216
242
  ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
217
243
  qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
244
+ sessionBoundary: opts.qaSessionBoundary,
218
245
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
219
246
  fix: async (issues) => {
220
247
  if (!builder)
221
248
  return { ok: false, detail: "Builder session unavailable" };
222
249
  builderWorkSessions += 1;
223
- if (opts.builderSessionStrategy === "compact" && builder.sessionId()) {
250
+ const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
251
+ const fixInstruction = buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest);
252
+ const strategy = opts.builderSessionStrategy ?? "compact";
253
+ if (opts.builderSessionBoundary) {
254
+ builder = await opts.builderSessionBoundary(builder, fixInstruction, strategy, worktreePath);
255
+ }
256
+ else if (strategy === "compact" && builder.sessionId()) {
224
257
  const compacted = await compactWithRetry(builder);
225
258
  if (!compacted.ok) {
226
259
  await builder.close();
227
260
  builder = await opts.createBuilder(worktreePath);
228
261
  }
229
262
  }
230
- else if (opts.builderSessionStrategy === "fresh") {
263
+ else if (strategy === "fresh") {
231
264
  await builder.close();
232
265
  builder = await opts.createBuilder(worktreePath);
233
266
  }
234
- const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
235
- const fixed = await builder.sendTurn(buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest));
267
+ if (opts.beforeBuilderTurn)
268
+ builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
269
+ const fixed = await builder.sendTurn(fixInstruction);
236
270
  const fixedStatus = parseStepStatus(fixed.text);
237
- builderStreamSession = opts.builderSessionStrategy === "compact" ? builder.sessionId() : undefined;
271
+ const fixedSessionId = builder.sessionId();
272
+ const fixedSessionRef = builder.sessionRef?.();
273
+ if (fixedSessionId)
274
+ builderStream = { sessionId: fixedSessionId, ...(fixedSessionRef ? { sessionRef: fixedSessionRef } : {}), worktreePath };
275
+ if (fixedSessionId) {
276
+ opts.recordBuilderSession?.(fixedSessionRef ?? fixedSessionId, node.ticket.id, worktreePath);
277
+ workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId: fixedSessionId, sessionRef: fixedSessionRef, worktree: worktreePath, branch: node.branch });
278
+ }
238
279
  return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error ?? fixed.text.slice(0, 500) };
239
280
  },
240
281
  onNonconvergence: opts.qaNonconvergence,
241
282
  });
283
+ if (qaStream.sessionId)
284
+ opts.recordQaSession?.(qaStream.sessionRef ?? qaStream.sessionId, node.ticket.id, worktreePath);
242
285
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
243
286
  if (qa.outcome !== "passed" && qa.outcome !== "waived") {
244
287
  const detail = qa.detail ?? "QA did not pass";
@@ -444,6 +487,14 @@ export async function runBranchPlan(opts) {
444
487
  removeTicketWorktree(opts.projectDir, worktreePath);
445
488
  }
446
489
  catch (err) {
490
+ if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
491
+ const message = err.message;
492
+ opts.log.write("branch-issue", { ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
493
+ notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
494
+ summaries.push(summaryFor(node, "blocked", message));
495
+ opts.onSessionUnavailable?.(err);
496
+ return summaries;
497
+ }
447
498
  const message = err instanceof Error ? err.message : String(err);
448
499
  const code = message.includes("switched branch")
449
500
  ? "branch_switch"
@@ -510,6 +561,7 @@ function workflowCheckpoint(projectDir, runId, checkpoint, ticket, payload) {
510
561
  if (!run)
511
562
  return;
512
563
  db.transition(runId, { checkpoint, state: { ...run.state, currentTicket: ticket, ...payload }, event: checkpoint, payload: { ticket, ...payload } });
564
+ db.appendContinuityEvent({ runId, role: "host", kind: checkpoint, payload: { ticket, ...payload }, authoritativeStateRevision: db.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
513
565
  }
514
566
  finally {
515
567
  db.close();
@@ -583,6 +635,14 @@ function autoMergePollMs() {
583
635
  function sleep(ms) {
584
636
  return new Promise((resolve) => setTimeout(resolve, ms));
585
637
  }
638
+ function canonical(path) {
639
+ try {
640
+ return realpathSync.native(path);
641
+ }
642
+ catch {
643
+ return resolve(path);
644
+ }
645
+ }
586
646
  function notifyIssue(enabled, message) {
587
647
  if (enabled)
588
648
  fireNotification("Foreman branch issue", message);