ai-foreman 1.7.11 → 1.7.13

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 (75) hide show
  1. package/README.md +15 -0
  2. package/dist/activity.d.ts +5 -1
  3. package/dist/activity.js +1 -1
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +133 -7
  6. package/dist/adapters/codex.d.ts +5 -0
  7. package/dist/adapters/codex.js +121 -11
  8. package/dist/adapters/recovering.d.ts +2 -0
  9. package/dist/adapters/recovering.js +11 -1
  10. package/dist/adapters/types.d.ts +47 -1
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +8 -2
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +12 -3
  15. package/dist/branch/runner.js +131 -67
  16. package/dist/buildRuns.d.ts +6 -2
  17. package/dist/buildRuns.js +76 -32
  18. package/dist/cli/doctor.d.ts +7 -0
  19. package/dist/cli/doctor.js +84 -1
  20. package/dist/cli/manager.d.ts +17 -0
  21. package/dist/cli/manager.js +135 -0
  22. package/dist/cli/recovery.d.ts +4 -0
  23. package/dist/cli/recovery.js +80 -0
  24. package/dist/cli/start.js +446 -142
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +27 -9
  27. package/dist/config.d.ts +4 -0
  28. package/dist/config.js +28 -1
  29. package/dist/continuity.js +14 -9
  30. package/dist/diagnostics.d.ts +25 -0
  31. package/dist/diagnostics.js +428 -0
  32. package/dist/foreman.d.ts +26 -6
  33. package/dist/foreman.js +147 -104
  34. package/dist/handoffs.d.ts +35 -3
  35. package/dist/handoffs.js +155 -17
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +3 -3
  38. package/dist/log.js +4 -1
  39. package/dist/managerPacket.d.ts +25 -0
  40. package/dist/managerPacket.js +83 -0
  41. package/dist/markers.d.ts +1 -1
  42. package/dist/markers.js +4 -1
  43. package/dist/notify.d.ts +4 -0
  44. package/dist/notify.js +15 -0
  45. package/dist/observability.d.ts +248 -0
  46. package/dist/observability.js +1018 -0
  47. package/dist/planChanges.d.ts +4 -0
  48. package/dist/planChanges.js +16 -0
  49. package/dist/projectDiagnostics.d.ts +27 -0
  50. package/dist/projectDiagnostics.js +407 -0
  51. package/dist/qaRecovery.d.ts +140 -0
  52. package/dist/qaRecovery.js +595 -0
  53. package/dist/qaReview.d.ts +133 -14
  54. package/dist/qaReview.js +984 -59
  55. package/dist/qaSnapshot.d.ts +31 -5
  56. package/dist/qaSnapshot.js +209 -134
  57. package/dist/recoveryPolicy.d.ts +37 -0
  58. package/dist/recoveryPolicy.js +251 -0
  59. package/dist/roles.js +1 -0
  60. package/dist/supervisor.d.ts +40 -0
  61. package/dist/supervisor.js +102 -0
  62. package/dist/ticketHistoryReader.d.ts +19 -0
  63. package/dist/ticketHistoryReader.js +58 -0
  64. package/dist/tickets/details.d.ts +8 -0
  65. package/dist/tickets/details.js +59 -11
  66. package/dist/tickets/output.d.ts +5 -0
  67. package/dist/tickets/output.js +73 -0
  68. package/dist/tickets/stateDb.d.ts +1 -0
  69. package/dist/tickets/stateDb.js +5 -0
  70. package/dist/workflowDb.d.ts +31 -2
  71. package/dist/workflowDb.js +192 -23
  72. package/dist/workflowReader.d.ts +42 -0
  73. package/dist/workflowReader.js +171 -0
  74. package/foreman.yaml +13 -0
  75. package/package.json +51 -3
@@ -2,6 +2,7 @@
2
2
  * Agent-agnostic interface over a coding agent ("builder").
3
3
  */
4
4
  import type { ConfigurableAgentRole, ProviderSessionRefV1, RuntimeProbeCategory, RuntimeProbePhase, SessionAvailabilityV1 } from "rafi-spec";
5
+ import type { RunObserver } from "../observability.js";
5
6
  /** A permission request surfaced by a builder before it runs a tool. */
6
7
  export interface PermissionRequest {
7
8
  toolName: string;
@@ -58,16 +59,53 @@ export interface TurnResult {
58
59
  costAuthoritative?: boolean;
59
60
  inputTokens?: number;
60
61
  outputTokens?: number;
62
+ /** Explicit counter semantics. Compatibility counters above must not be summed without this scope. */
63
+ usage?: {
64
+ scope: "turn-delta" | "session-cumulative";
65
+ inputTokens?: number;
66
+ outputTokens?: number;
67
+ totalTokens?: number;
68
+ costUsd?: number;
69
+ };
61
70
  failure?: RuntimeFailure;
71
+ /** Stable host/provider correlation ID for this turn. */
72
+ turnId?: string;
73
+ /** Exact instruction requested by the immediate host caller. */
74
+ hostInstruction?: string;
75
+ /** Exact instruction dispatched to the provider after all wrappers. */
76
+ providerInstruction?: string;
77
+ /** Exact provider response before continuity or contract cleanup. */
78
+ rawResponse?: string;
79
+ /** Effective response consumed by the host contract parser. */
80
+ cleanedResponse?: string;
81
+ providerMetadata?: {
82
+ provider: "claude" | "codex";
83
+ sessionId?: string;
84
+ sessionRef?: ProviderSessionRefV1;
85
+ };
62
86
  }
63
87
  /** Observability events emitted while a builder works. */
64
- export type BuilderEvent = {
88
+ export type BuilderEvent = ({
65
89
  kind: "text";
66
90
  text: string;
91
+ byteCount?: number;
92
+ digest?: string;
67
93
  } | {
68
94
  kind: "tool";
69
95
  name: string;
70
96
  input: unknown;
97
+ inputCompleteness?: "complete" | "truncated" | "unavailable";
98
+ lifecycle?: "started" | "progress" | "completed";
99
+ callId?: string;
100
+ status?: string;
101
+ durationMs?: number;
102
+ exitCode?: number;
103
+ outputSummary?: string;
104
+ outputDigest?: string;
105
+ outputCompleteness?: "complete" | "truncated" | "unavailable";
106
+ rawOutputBytes?: number;
107
+ completionKnown?: boolean;
108
+ providerTurnId?: string;
71
109
  } | {
72
110
  kind: "activity";
73
111
  state: string;
@@ -83,9 +121,12 @@ export type BuilderEvent = {
83
121
  maximum?: number;
84
122
  delayMs?: number;
85
123
  managedBy: "provider" | "rafi";
124
+ retryId?: string;
125
+ providerTurnId?: string;
86
126
  } | {
87
127
  kind: "turn-complete";
88
128
  result: TurnResult;
129
+ turnId?: string;
89
130
  } | {
90
131
  kind: "session-transition";
91
132
  transition: "started" | "resumed" | "compacting" | "compacted" | "fresh-fallback";
@@ -100,6 +141,9 @@ export type BuilderEvent = {
100
141
  } | {
101
142
  kind: "error";
102
143
  message: string;
144
+ }) & {
145
+ eventId?: string;
146
+ observedAt?: string;
103
147
  };
104
148
  export interface ContextUsage {
105
149
  used: number;
@@ -185,6 +229,8 @@ export interface BuilderAdapterOptions {
185
229
  skills?: string[];
186
230
  /** Provider-native context ceiling, as a percentage of that provider's model window. */
187
231
  autoCompactThresholdPercent?: number;
232
+ /** Raw-adapter observability; wrapper adapters must not persist re-emitted events. */
233
+ observer?: RunObserver;
188
234
  }
189
235
  export interface BuilderAdapter {
190
236
  readonly agent: "claude" | "codex";
@@ -22,6 +22,8 @@ export interface RoleBuilderOptions {
22
22
  permissionConfig?: PermissionConfig;
23
23
  extraSkills?: string[];
24
24
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
25
+ /** Disable recovery-session persistence for isolated, non-recoverable roles such as Manager. */
26
+ persistSessionBindings?: boolean;
25
27
  resumeSessionId?: string;
26
28
  /** Location-scoped exact session. Raw IDs are resolved through durable project bindings or treated as legacy candidates. */
27
29
  resumeSessionRef?: ProviderSessionRefV1;
package/dist/agentRun.js CHANGED
@@ -199,6 +199,8 @@ export async function createRoleBuilder(opts) {
199
199
  };
200
200
  const initial = await makeAdapter(runtime, opts.resumeSessionId, requestedResumeRef);
201
201
  const persistSessionRef = (ref) => {
202
+ if (opts.persistSessionBindings === false)
203
+ return;
202
204
  const bindingDb = new WorkflowDb(opts.projectDir);
203
205
  try {
204
206
  bindingDb.recordProviderSessionBinding(ref);
@@ -240,6 +242,8 @@ function phaseForRole(role) {
240
242
  return "qa";
241
243
  if (role === "uninstaller")
242
244
  return "uninstaller";
245
+ if (role === "manager")
246
+ return "manager";
243
247
  return "builder";
244
248
  }
245
249
  export function readRoleDefaultsForExecution(projectDir, role) {
@@ -253,6 +257,8 @@ export function readRoleDefaultsForExecution(projectDir, role) {
253
257
  : undefined;
254
258
  if (!saved)
255
259
  return undefined;
260
+ if (role === "manager")
261
+ return saved;
256
262
  if (isFullyInitializedTracker(projectDir))
257
263
  return saved;
258
264
  const { make: _pendingMake, ...active } = saved;
@@ -293,7 +299,7 @@ export async function runRoleInstruction(opts) {
293
299
  const { builder, effort, roleBundle, skills } = roleBuilder;
294
300
  const viewer = printEvents(builder.events());
295
301
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
296
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
302
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, opts.projectDir);
297
303
  try {
298
304
  const turn = await foreman.runInstruction(opts.instruction);
299
305
  await builder.close();
@@ -333,7 +339,7 @@ export async function runRoleInstruction(opts) {
333
339
  }
334
340
  }
335
341
  function configurableRole(role) {
336
- return ["builder", "qa", "planner", "ticket-maker", "uninstaller"].includes(role)
342
+ return ["builder", "qa", "planner", "ticket-maker", "uninstaller", "manager"].includes(role)
337
343
  ? role
338
344
  : "builder";
339
345
  }
@@ -1,8 +1,18 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
+ import { WorkflowReader } from "../workflowReader.js";
3
4
  export function findResumableBranchSessions(foremanDir) {
4
5
  if (!existsSync(foremanDir))
5
6
  return [];
7
+ const reader = new WorkflowReader(dirname(foremanDir));
8
+ try {
9
+ const structured = reader.branchResumeSessions().filter(session => existsSync(session.worktreePath));
10
+ if (structured.length)
11
+ return structured.sort((a, b) => a.ticket.localeCompare(b.ticket, undefined, { numeric: true }));
12
+ }
13
+ finally {
14
+ reader.close();
15
+ }
6
16
  const byTicket = new Map();
7
17
  const logs = readdirSync(foremanDir)
8
18
  .filter((file) => file.endsWith(".jsonl"))
@@ -1,10 +1,12 @@
1
1
  import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
2
+ import type { RunObserver } from "../observability.js";
2
3
  import type { ProviderSessionRefV1, SessionStrategy } from "rafi-spec";
3
- import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "../qaReview.js";
4
+ import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaReportRecoveryHandler, type QaSessionBoundaryRecovery, type QaSessionHandle } from "../qaReview.js";
4
5
  import type { Log } from "../log.js";
5
6
  import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, MergeMethod, ReviewProvider } from "./types.js";
6
7
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
7
8
  import { SessionUnavailableContinuityError } from "../continuity.js";
9
+ import type { QaRecoveryPacket } from "../qaRecovery.js";
8
10
  export interface DeliveryUnitSession {
9
11
  unitId: string;
10
12
  branch: string;
@@ -25,7 +27,9 @@ export interface BranchRunnerOptions {
25
27
  effort?: EffortLevel;
26
28
  fast?: boolean;
27
29
  notificationsEnabled: boolean;
30
+ terminalBellEnabled?: boolean;
28
31
  qaEnabled: boolean;
32
+ qaMaxFixAttempts?: number;
29
33
  createPr: boolean;
30
34
  completionMode?: CompletionMode;
31
35
  reviewProvider?: ReviewProvider;
@@ -52,7 +56,7 @@ export interface BranchRunnerOptions {
52
56
  recordQaSession?: (session: string | ProviderSessionRefV1, ticketId: string, worktreePath: string) => void;
53
57
  /** Notify the host and stop the branch plan without discarding its worktree. */
54
58
  onSessionUnavailable?: (error: SessionUnavailableError | SessionUnavailableContinuityError) => void;
55
- createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
59
+ createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter | QaSessionHandle>;
56
60
  builderSessionStrategy?: SessionStrategy;
57
61
  qaSessionStrategy?: SessionStrategy;
58
62
  observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
@@ -61,7 +65,12 @@ export interface BranchRunnerOptions {
61
65
  qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
62
66
  beforeBuilderTurn?: (adapter: BuilderAdapter, frozenAction: string, cwd: string) => Promise<BuilderAdapter>;
63
67
  builderSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
64
- qaSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
68
+ qaSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string, recovery?: QaSessionBoundaryRecovery) => Promise<BuilderAdapter>;
69
+ observer?: RunObserver;
70
+ qaRuntimeContext?: unknown;
71
+ qaContinuityManaged?: boolean;
72
+ qaReportRecovery?: QaReportRecoveryHandler;
73
+ qaResumedRecovery?: QaRecoveryPacket;
65
74
  }
66
75
  export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
67
76
  export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
@@ -1,8 +1,8 @@
1
1
  import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
- import { changeManifestAsync } from "../qaSnapshot.js";
2
+ import { changeManifestAsync, deterministicChangeSummaryAsync } from "../qaSnapshot.js";
3
3
  import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
4
4
  import { fireNotification } from "../notify.js";
5
- import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
5
+ import { cmdBlock, cmdComplete, cmdUnblock, 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";
@@ -27,6 +27,7 @@ function deliverySessionPath(projectDir, unitId) {
27
27
  return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
28
28
  }
29
29
  export async function runBranchPlan(opts) {
30
+ let qaResumedRecovery = opts.qaResumedRecovery;
30
31
  const baseWorktreePolicy = opts.baseWorktreePolicy ?? "enforce";
31
32
  if (baseWorktreePolicy !== "skip") {
32
33
  try {
@@ -128,7 +129,7 @@ export async function runBranchPlan(opts) {
128
129
  const resumeSession = opts.resumeSessions?.get(node.ticket.id);
129
130
  const worktreePath = resumeSession?.worktreePath
130
131
  ?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
131
- ?? createTicketWorktree(opts.projectDir, opts.runId, node.branch, node.baseBranch);
132
+ ?? await observeNode(opts, node, "git", "creating ticket worktree", () => createTicketWorktree(opts.projectDir, opts.runId, node.branch, node.baseBranch));
132
133
  node.worktreePath = worktreePath;
133
134
  let builder;
134
135
  let viewer;
@@ -143,10 +144,14 @@ export async function runBranchPlan(opts) {
143
144
  sessionRef: resumeSession.sessionRef,
144
145
  });
145
146
  }
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, {
147
- status: "in_progress", actor: "foreman",
148
- summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
149
- }));
147
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
148
+ if (resumeSession)
149
+ cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
150
+ cmdUpdate(opts.projectDir, node.ticket.id, {
151
+ status: "in_progress", actor: "foreman",
152
+ summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
153
+ });
154
+ });
150
155
  const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
151
156
  // Reattach the predecessor even for a `fresh` strategy so the host can
152
157
  // publish and validate a cumulative handoff before creating its successor.
@@ -173,8 +178,11 @@ export async function runBranchPlan(opts) {
173
178
  }
174
179
  builderWorkSessions += 1;
175
180
  viewer = opts.observeBuilder?.(builder);
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, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
177
- const { result, status } = await foreman.runInstruction(ticketInstruction);
181
+ const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
182
+ const turn = () => foreman.runInstruction(ticketInstruction);
183
+ const { result, status } = opts.observer
184
+ ? await opts.observer.withContext({ role: "builder", stream: "builder", ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, turn)
185
+ : await turn();
178
186
  builder = foreman.builderAdapter();
179
187
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
180
188
  const sessionId = builder.sessionId();
@@ -186,7 +194,7 @@ export async function runBranchPlan(opts) {
186
194
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
187
195
  }
188
196
  if (sessionId) {
189
- opts.log.write("branch-session", {
197
+ const resumeSession = {
190
198
  ticket: node.ticket.id,
191
199
  branch: node.branch,
192
200
  base: node.baseBranch,
@@ -205,7 +213,17 @@ export async function runBranchPlan(opts) {
205
213
  keepWorktrees: opts.keepWorktrees,
206
214
  deliveryUnitId: node.deliveryUnitId,
207
215
  deliveryUnitFinal: node.deliveryUnitFinal,
208
- });
216
+ logPath: "structured-recovery",
217
+ ts: new Date().toISOString(),
218
+ };
219
+ opts.log.write("branch-session", resumeSession);
220
+ const resumeDb = new WorkflowDb(opts.projectDir);
221
+ try {
222
+ resumeDb.recordBranchResumeSession(opts.runId, resumeSession);
223
+ }
224
+ finally {
225
+ resumeDb.close();
226
+ }
209
227
  if (node.deliveryUnitId) {
210
228
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
211
229
  mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
@@ -239,17 +257,25 @@ export async function runBranchPlan(opts) {
239
257
  throw new Error("independent disposable QA factory is required when QA is enabled");
240
258
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-before", node.ticket.id, { worktree: worktreePath });
241
259
  const qa = await runIsolatedQa({
242
- ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
243
- qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
260
+ ticket: node.ticket, builderWorktree: worktreePath, builderSummary: result.text,
261
+ qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: opts.qaMaxFixAttempts ?? 3,
262
+ recovery: { projectDir: opts.projectDir, runId: opts.runId },
263
+ observer: opts.observer,
264
+ qaRuntimeContext: opts.qaRuntimeContext,
265
+ continuityManaged: opts.qaContinuityManaged,
266
+ onReportRecovery: opts.qaReportRecovery,
267
+ resumedRecovery: qaResumedRecovery,
244
268
  sessionBoundary: opts.qaSessionBoundary,
245
269
  observeNativeCompactions: opts.observeQaNativeCompactions,
270
+ resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason, "qa"),
246
271
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
247
- fix: async (issues) => {
272
+ fix: async (request) => {
248
273
  if (!builder)
249
274
  return { ok: false, detail: "Builder session unavailable" };
250
275
  builderWorkSessions += 1;
251
276
  const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
252
- const fixInstruction = buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest);
277
+ const changeSummary = await deterministicChangeSummaryAsync(worktreePath);
278
+ const fixInstruction = buildDurableQaFixHandoff(node.ticket, request, worktreePath, request.latestBuilderResult, digest, changeSummary);
253
279
  const strategy = opts.builderSessionStrategy ?? "compact";
254
280
  if (opts.builderSessionBoundary) {
255
281
  builder = await opts.builderSessionBoundary(builder, fixInstruction, strategy, worktreePath);
@@ -267,8 +293,13 @@ export async function runBranchPlan(opts) {
267
293
  }
268
294
  if (opts.beforeBuilderTurn)
269
295
  builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
270
- const fixed = await builder.sendTurn(fixInstruction);
271
- const fixedStatus = parseStepStatus(fixed.text);
296
+ let fixed = await builder.sendTurn(fixInstruction);
297
+ let fixedStatus = parseStepStatus(fixed.text);
298
+ if (!fixed.isError && fixedStatus.kind === "blocked") {
299
+ const resolved = await foreman.resolveBlocker(builder, fixedStatus.reason ?? "Builder QA fix reported an unspecified blocker");
300
+ fixed = resolved.result;
301
+ fixedStatus = resolved.status;
302
+ }
272
303
  const fixedSessionId = builder.sessionId();
273
304
  const fixedSessionRef = builder.sessionRef?.();
274
305
  if (fixedSessionId)
@@ -277,10 +308,11 @@ export async function runBranchPlan(opts) {
277
308
  opts.recordBuilderSession?.(fixedSessionRef ?? fixedSessionId, node.ticket.id, worktreePath);
278
309
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId: fixedSessionId, sessionRef: fixedSessionRef, worktree: worktreePath, branch: node.branch });
279
310
  }
280
- return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error ?? fixed.text.slice(0, 500) };
311
+ return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error, response: fixed.text, summary: fixedStatus.summary ?? fixed.text };
281
312
  },
282
313
  onNonconvergence: opts.qaNonconvergence,
283
314
  });
315
+ qaResumedRecovery = undefined;
284
316
  if (qaStream.sessionId)
285
317
  opts.recordQaSession?.(qaStream.sessionRef ?? qaStream.sessionId, node.ticket.id, worktreePath);
286
318
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
@@ -316,7 +348,7 @@ export async function runBranchPlan(opts) {
316
348
  const operation = `${opts.runId}:commit:${node.ticket.id}`;
317
349
  planJournal(opts.projectDir, opts.runId, operation, "commit", { ticket: node.ticket.id, branch: node.branch, worktreePath });
318
350
  try {
319
- commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
351
+ commit = await observeNode(opts, node, "git", "committing ticket changes", () => commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`));
320
352
  confirmJournal(opts.projectDir, operation, commit, { sha: commit });
321
353
  workflowCheckpoint(opts.projectDir, opts.runId, "commit-after", node.ticket.id, { sha: commit });
322
354
  }
@@ -334,12 +366,12 @@ export async function runBranchPlan(opts) {
334
366
  workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
335
367
  const pushOperation = `${opts.runId}:push:${node.branch}`;
336
368
  planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
337
- const push = await withActivityPhase(`pushing ${node.branch}`, async () => {
369
+ const push = await observeNode(opts, node, "git", `pushing ${node.branch}`, () => withActivityPhase(`pushing ${node.branch}`, async () => {
338
370
  currentActivity()?.update(`pushing ${node.branch}`, `publishing ${node.ticket.id}`);
339
371
  return reviewProvider === "gitlab"
340
372
  ? pushBranchForMr(worktreePath, node.branch)
341
373
  : pushBranchForPr(worktreePath, node.branch);
342
- });
374
+ }));
343
375
  if (push.ok) {
344
376
  confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
345
377
  opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
@@ -376,7 +408,7 @@ export async function runBranchPlan(opts) {
376
408
  workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
377
409
  const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
378
410
  planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
379
- const pr = await withActivityPhase(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, async () => {
411
+ const pr = await observeNode(opts, node, "external_check", `creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, () => withActivityPhase(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, async () => {
380
412
  currentActivity()?.update(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, node.ticket.id);
381
413
  return reviewProvider === "gitlab"
382
414
  ? createOrReuseMr(opts.projectDir, {
@@ -396,7 +428,7 @@ export async function runBranchPlan(opts) {
396
428
  qaEvidence: qaSummary,
397
429
  commit,
398
430
  });
399
- });
431
+ }));
400
432
  summary.pr = pr;
401
433
  if (pr.status === "created" || pr.status === "existing") {
402
434
  confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
@@ -415,10 +447,10 @@ export async function runBranchPlan(opts) {
415
447
  continue;
416
448
  }
417
449
  if (completionMode === "auto-merge" && reviewProvider === "github") {
418
- const autoMerge = await withActivityPhase("enabling GitHub auto-merge", async () => {
450
+ const autoMerge = await observeNode(opts, node, "external_check", "enabling GitHub auto-merge", () => withActivityPhase("enabling GitHub auto-merge", async () => {
419
451
  currentActivity()?.update("enabling GitHub auto-merge", node.branch);
420
452
  return enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
421
- });
453
+ }));
422
454
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
423
455
  if (autoMerge.status === "failed") {
424
456
  opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -429,10 +461,10 @@ export async function runBranchPlan(opts) {
429
461
  opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
430
462
  }
431
463
  else if (completionMode === "auto-merge" && reviewProvider === "gitlab") {
432
- const autoMerge = await withActivityPhase("enabling GitLab auto-merge", async () => {
464
+ const autoMerge = await observeNode(opts, node, "external_check", "enabling GitLab auto-merge", () => withActivityPhase("enabling GitLab auto-merge", async () => {
433
465
  currentActivity()?.update("enabling GitLab auto-merge", node.branch);
434
466
  return enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
435
- });
467
+ }));
436
468
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
437
469
  if (autoMerge.status === "failed") {
438
470
  opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -450,7 +482,7 @@ export async function runBranchPlan(opts) {
450
482
  else if (commit && completionMode === "direct-merge" && completesSharedUnit) {
451
483
  if (!opts.keepWorktrees)
452
484
  removeTicketWorktree(opts.projectDir, worktreePath);
453
- const mergeCommit = mergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`, opts.mergeMethod ?? "squash");
485
+ const mergeCommit = await observeNode(opts, node, "git", "merging ticket branch to local base", () => mergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`, opts.mergeMethod ?? "squash"));
454
486
  summary.pr = { status: "merged", url: mergeCommit };
455
487
  opts.log.write("branch-direct-merge", {
456
488
  ticket: node.ticket.id,
@@ -482,10 +514,17 @@ export async function runBranchPlan(opts) {
482
514
  status: "done",
483
515
  detail: summary.detail,
484
516
  });
517
+ const resumeDb = new WorkflowDb(opts.projectDir);
518
+ try {
519
+ resumeDb.completeBranchResumeSession(opts.runId, node.ticket.id);
520
+ }
521
+ finally {
522
+ resumeDb.close();
523
+ }
485
524
  if (node.deliveryUnitId && node.deliveryUnitFinal)
486
525
  rmSync(deliverySessionPath(opts.projectDir, node.deliveryUnitId), { force: true });
487
526
  if (!opts.keepWorktrees && completionMode !== "direct-merge" && completesSharedUnit)
488
- removeTicketWorktree(opts.projectDir, worktreePath);
527
+ await observeNode(opts, node, "cleanup", "removing ticket worktree", () => removeTicketWorktree(opts.projectDir, worktreePath));
489
528
  }
490
529
  catch (err) {
491
530
  if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
@@ -581,49 +620,69 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
581
620
  ? null
582
621
  : opts.autoMergeTimeoutMinutes * 60_000;
583
622
  const deadline = opts.autoMergeWait && timeoutMs !== null ? Date.now() + timeoutMs : null;
584
- while (true) {
585
- const pending = [];
586
- for (const dep of dependencyNodes) {
587
- const status = provider === "gitlab"
588
- ? checkGitLabMrMerged(opts.projectDir, dep.branch)
589
- : checkGitHubPrMerged(opts.projectDir, dep.branch);
590
- if (!status.ok) {
623
+ let waitSpanId;
624
+ let waitOutcome = "completed";
625
+ let waitLogged = false;
626
+ try {
627
+ while (true) {
628
+ const pending = [];
629
+ for (const dep of dependencyNodes) {
630
+ const status = provider === "gitlab"
631
+ ? checkGitLabMrMerged(opts.projectDir, dep.branch)
632
+ : checkGitHubPrMerged(opts.projectDir, dep.branch);
633
+ if (!status.ok) {
634
+ waitOutcome = "failed";
635
+ return {
636
+ ok: false,
637
+ code: status.code,
638
+ message: `dependency ${dep.ticket.id} merge check failed: ${status.message}`,
639
+ repairCommands: status.repairCommands,
640
+ command: status.command,
641
+ output: status.output,
642
+ };
643
+ }
644
+ if (!status.merged)
645
+ pending.push(`${dep.ticket.id}${status.state ? ` (${status.state})` : ""}`);
646
+ }
647
+ if (pending.length === 0)
648
+ return { ok: true };
649
+ const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
650
+ currentActivity()?.update("waiting for dependency merge", pending.join(", "));
651
+ if (!opts.autoMergeWait) {
591
652
  return {
592
653
  ok: false,
593
- code: status.code,
594
- message: `dependency ${dep.ticket.id} merge check failed: ${status.message}`,
595
- repairCommands: status.repairCommands,
596
- command: status.command,
597
- output: status.output,
654
+ code: "dependency_unavailable",
655
+ message: `${message}; rerun after it merges or enable auto-merge wait in ticket setup`,
598
656
  };
599
657
  }
600
- if (!status.merged)
601
- pending.push(`${dep.ticket.id}${status.state ? ` (${status.state})` : ""}`);
602
- }
603
- if (pending.length === 0)
604
- return { ok: true };
605
- const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
606
- currentActivity()?.update("waiting for dependency merge", pending.join(", "));
607
- if (!opts.autoMergeWait) {
608
- return {
609
- ok: false,
610
- code: "dependency_unavailable",
611
- message: `${message}; rerun after it merges or enable auto-merge wait in ticket setup`,
612
- };
613
- }
614
- if (deadline !== null && Date.now() >= deadline) {
615
- return {
616
- ok: false,
617
- code: "dependency_unavailable",
618
- message: `${message}; timed out waiting for dependency merge`,
619
- };
658
+ if (!waitSpanId && opts.observer) {
659
+ waitSpanId = opts.observer.store.startSpan({ runId: opts.runId, executionId: opts.observer.executionId, role: "host", stream: "dependency", ticketId: node.ticket.id }, { kind: "dependency_wait", name: "waiting for dependency merge", attributes: { dependencies: pending } });
660
+ opts.observer.store.updateCurrentState({ runId: opts.runId, role: "host", stream: "dependency", executionId: opts.observer.executionId, ticketId: node.ticket.id, phase: "waiting for dependency merge", activeSpanId: waitSpanId, activeSpanKind: "dependency_wait", lastSemanticProgressAt: new Date().toISOString() });
661
+ }
662
+ if (!waitLogged) {
663
+ opts.log.write("branch-auto-merge-wait", {
664
+ ticket: node.ticket.id,
665
+ pending,
666
+ timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
667
+ });
668
+ waitLogged = true;
669
+ }
670
+ if (deadline !== null && Date.now() >= deadline) {
671
+ waitOutcome = "timed_out";
672
+ return {
673
+ ok: false,
674
+ code: "dependency_unavailable",
675
+ message: `${message}; timed out waiting for dependency merge`,
676
+ };
677
+ }
678
+ await sleep(autoMergePollMs());
620
679
  }
621
- opts.log.write("branch-auto-merge-wait", {
622
- ticket: node.ticket.id,
623
- pending,
624
- timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
625
- });
626
- await sleep(autoMergePollMs());
680
+ }
681
+ finally {
682
+ if (waitSpanId && opts.observer)
683
+ opts.observer.store.finishSpan(waitSpanId, { outcome: waitOutcome });
684
+ if (waitLogged)
685
+ opts.log.write("branch-auto-merge-wait", { ticket: node.ticket.id, outcome: waitOutcome, terminal: true });
627
686
  }
628
687
  }
629
688
  function autoMergePollMs() {
@@ -636,6 +695,11 @@ function autoMergePollMs() {
636
695
  function sleep(ms) {
637
696
  return new Promise((resolve) => setTimeout(resolve, ms));
638
697
  }
698
+ function observeNode(opts, node, kind, name, operation) {
699
+ if (!opts.observer)
700
+ return Promise.resolve().then(operation);
701
+ return opts.observer.withContext({ role: "host", stream: kind, ticketId: node.ticket.id, deliveryUnitId: node.deliveryUnitId }, () => opts.observer.span(kind, name, operation));
702
+ }
639
703
  function canonical(path) {
640
704
  try {
641
705
  return realpathSync.native(path);
@@ -1,10 +1,11 @@
1
- import type { BuildRunRecordV2, ProviderSessionRefV1, ResolvedAgentSettings, SessionAvailabilityV1 } from "rafi-spec";
1
+ import type { AutonomyProfile, BuildRunRecordV2, ProviderSessionRefV1, ResolvedAgentSettings, ResolvedAutonomyPolicy, SessionAvailabilityV1 } from "rafi-spec";
2
2
  import { type ResolveSessionAvailabilityOptions } from "./sessionAvailability.js";
3
- export declare const BUILD_RUN_VERSION = 2;
3
+ export declare const BUILD_RUN_VERSION = 3;
4
4
  export declare const BUILD_RUN_DIRECTORY = ".foreman/runs";
5
5
  export declare const BUILD_HEARTBEAT_MS = 10000;
6
6
  export declare const BUILD_LEASE_STALE_MS = 45000;
7
7
  export interface CreateBuildRunInput {
8
+ runId?: string;
8
9
  tickets: string[];
9
10
  deliveryUnit?: string;
10
11
  branchMode?: BuildRunRecordV2["branchMode"];
@@ -17,6 +18,9 @@ export interface CreateBuildRunInput {
17
18
  builder?: LegacyResolvedAgentSettings;
18
19
  qa?: LegacyResolvedAgentSettings;
19
20
  runDecisions?: BuildRunRecordV2["runDecisions"];
21
+ autonomyProfile?: AutonomyProfile;
22
+ frozenPolicy?: ResolvedAutonomyPolicy;
23
+ qaEnabled?: boolean;
20
24
  now?: Date;
21
25
  }
22
26
  export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV2;