ai-foreman 1.7.12 → 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 (73) 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 +7 -1
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +11 -3
  15. package/dist/branch/runner.js +114 -60
  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 +289 -53
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +26 -8
  27. package/dist/config.d.ts +2 -0
  28. package/dist/config.js +23 -0
  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 +16 -6
  33. package/dist/foreman.js +70 -82
  34. package/dist/handoffs.d.ts +5 -1
  35. package/dist/handoffs.js +4 -1
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +2 -2
  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/observability.d.ts +248 -0
  44. package/dist/observability.js +1018 -0
  45. package/dist/planChanges.d.ts +4 -0
  46. package/dist/planChanges.js +16 -0
  47. package/dist/projectDiagnostics.d.ts +27 -0
  48. package/dist/projectDiagnostics.js +407 -0
  49. package/dist/qaRecovery.d.ts +140 -0
  50. package/dist/qaRecovery.js +595 -0
  51. package/dist/qaReview.d.ts +128 -14
  52. package/dist/qaReview.js +974 -57
  53. package/dist/qaSnapshot.d.ts +31 -5
  54. package/dist/qaSnapshot.js +209 -134
  55. package/dist/recoveryPolicy.d.ts +37 -0
  56. package/dist/recoveryPolicy.js +251 -0
  57. package/dist/roles.js +1 -0
  58. package/dist/supervisor.d.ts +40 -0
  59. package/dist/supervisor.js +102 -0
  60. package/dist/ticketHistoryReader.d.ts +19 -0
  61. package/dist/ticketHistoryReader.js +58 -0
  62. package/dist/tickets/details.d.ts +8 -0
  63. package/dist/tickets/details.js +59 -11
  64. package/dist/tickets/output.d.ts +5 -0
  65. package/dist/tickets/output.js +73 -0
  66. package/dist/tickets/stateDb.d.ts +1 -0
  67. package/dist/tickets/stateDb.js +5 -0
  68. package/dist/workflowDb.d.ts +31 -2
  69. package/dist/workflowDb.js +192 -23
  70. package/dist/workflowReader.d.ts +42 -0
  71. package/dist/workflowReader.js +171 -0
  72. package/foreman.yaml +9 -0
  73. 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;
@@ -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;
@@ -27,6 +29,7 @@ export interface BranchRunnerOptions {
27
29
  notificationsEnabled: boolean;
28
30
  terminalBellEnabled?: boolean;
29
31
  qaEnabled: boolean;
32
+ qaMaxFixAttempts?: number;
30
33
  createPr: boolean;
31
34
  completionMode?: CompletionMode;
32
35
  reviewProvider?: ReviewProvider;
@@ -53,7 +56,7 @@ export interface BranchRunnerOptions {
53
56
  recordQaSession?: (session: string | ProviderSessionRefV1, ticketId: string, worktreePath: string) => void;
54
57
  /** Notify the host and stop the branch plan without discarding its worktree. */
55
58
  onSessionUnavailable?: (error: SessionUnavailableError | SessionUnavailableContinuityError) => void;
56
- createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
59
+ createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter | QaSessionHandle>;
57
60
  builderSessionStrategy?: SessionStrategy;
58
61
  qaSessionStrategy?: SessionStrategy;
59
62
  observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
@@ -62,7 +65,12 @@ export interface BranchRunnerOptions {
62
65
  qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
63
66
  beforeBuilderTurn?: (adapter: BuilderAdapter, frozenAction: string, cwd: string) => Promise<BuilderAdapter>;
64
67
  builderSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
65
- 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;
66
74
  }
67
75
  export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
68
76
  export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
@@ -1,5 +1,5 @@
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
5
  import { cmdBlock, cmdComplete, cmdUnblock, cmdUpdate } from "../tickets/commands.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;
@@ -178,7 +179,10 @@ export async function runBranchPlan(opts) {
178
179
  builderWorkSessions += 1;
179
180
  viewer = opts.observeBuilder?.(builder);
180
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));
181
- const { result, status } = await foreman.runInstruction(ticketInstruction);
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();
182
186
  builder = foreman.builderAdapter();
183
187
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
184
188
  const sessionId = builder.sessionId();
@@ -190,7 +194,7 @@ export async function runBranchPlan(opts) {
190
194
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
191
195
  }
192
196
  if (sessionId) {
193
- opts.log.write("branch-session", {
197
+ const resumeSession = {
194
198
  ticket: node.ticket.id,
195
199
  branch: node.branch,
196
200
  base: node.baseBranch,
@@ -209,7 +213,17 @@ export async function runBranchPlan(opts) {
209
213
  keepWorktrees: opts.keepWorktrees,
210
214
  deliveryUnitId: node.deliveryUnitId,
211
215
  deliveryUnitFinal: node.deliveryUnitFinal,
212
- });
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
+ }
213
227
  if (node.deliveryUnitId) {
214
228
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
215
229
  mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
@@ -243,18 +257,25 @@ export async function runBranchPlan(opts) {
243
257
  throw new Error("independent disposable QA factory is required when QA is enabled");
244
258
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-before", node.ticket.id, { worktree: worktreePath });
245
259
  const qa = await runIsolatedQa({
246
- ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
247
- 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,
248
268
  sessionBoundary: opts.qaSessionBoundary,
249
269
  observeNativeCompactions: opts.observeQaNativeCompactions,
250
- resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason),
270
+ resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason, "qa"),
251
271
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
252
- fix: async (issues) => {
272
+ fix: async (request) => {
253
273
  if (!builder)
254
274
  return { ok: false, detail: "Builder session unavailable" };
255
275
  builderWorkSessions += 1;
256
276
  const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
257
- 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);
258
279
  const strategy = opts.builderSessionStrategy ?? "compact";
259
280
  if (opts.builderSessionBoundary) {
260
281
  builder = await opts.builderSessionBoundary(builder, fixInstruction, strategy, worktreePath);
@@ -287,10 +308,11 @@ export async function runBranchPlan(opts) {
287
308
  opts.recordBuilderSession?.(fixedSessionRef ?? fixedSessionId, node.ticket.id, worktreePath);
288
309
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId: fixedSessionId, sessionRef: fixedSessionRef, worktree: worktreePath, branch: node.branch });
289
310
  }
290
- 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 };
291
312
  },
292
313
  onNonconvergence: opts.qaNonconvergence,
293
314
  });
315
+ qaResumedRecovery = undefined;
294
316
  if (qaStream.sessionId)
295
317
  opts.recordQaSession?.(qaStream.sessionRef ?? qaStream.sessionId, node.ticket.id, worktreePath);
296
318
  workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
@@ -326,7 +348,7 @@ export async function runBranchPlan(opts) {
326
348
  const operation = `${opts.runId}:commit:${node.ticket.id}`;
327
349
  planJournal(opts.projectDir, opts.runId, operation, "commit", { ticket: node.ticket.id, branch: node.branch, worktreePath });
328
350
  try {
329
- 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}`));
330
352
  confirmJournal(opts.projectDir, operation, commit, { sha: commit });
331
353
  workflowCheckpoint(opts.projectDir, opts.runId, "commit-after", node.ticket.id, { sha: commit });
332
354
  }
@@ -344,12 +366,12 @@ export async function runBranchPlan(opts) {
344
366
  workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
345
367
  const pushOperation = `${opts.runId}:push:${node.branch}`;
346
368
  planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
347
- const push = await withActivityPhase(`pushing ${node.branch}`, async () => {
369
+ const push = await observeNode(opts, node, "git", `pushing ${node.branch}`, () => withActivityPhase(`pushing ${node.branch}`, async () => {
348
370
  currentActivity()?.update(`pushing ${node.branch}`, `publishing ${node.ticket.id}`);
349
371
  return reviewProvider === "gitlab"
350
372
  ? pushBranchForMr(worktreePath, node.branch)
351
373
  : pushBranchForPr(worktreePath, node.branch);
352
- });
374
+ }));
353
375
  if (push.ok) {
354
376
  confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
355
377
  opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
@@ -386,7 +408,7 @@ export async function runBranchPlan(opts) {
386
408
  workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
387
409
  const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
388
410
  planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
389
- 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 () => {
390
412
  currentActivity()?.update(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, node.ticket.id);
391
413
  return reviewProvider === "gitlab"
392
414
  ? createOrReuseMr(opts.projectDir, {
@@ -406,7 +428,7 @@ export async function runBranchPlan(opts) {
406
428
  qaEvidence: qaSummary,
407
429
  commit,
408
430
  });
409
- });
431
+ }));
410
432
  summary.pr = pr;
411
433
  if (pr.status === "created" || pr.status === "existing") {
412
434
  confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
@@ -425,10 +447,10 @@ export async function runBranchPlan(opts) {
425
447
  continue;
426
448
  }
427
449
  if (completionMode === "auto-merge" && reviewProvider === "github") {
428
- 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 () => {
429
451
  currentActivity()?.update("enabling GitHub auto-merge", node.branch);
430
452
  return enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
431
- });
453
+ }));
432
454
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
433
455
  if (autoMerge.status === "failed") {
434
456
  opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -439,10 +461,10 @@ export async function runBranchPlan(opts) {
439
461
  opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
440
462
  }
441
463
  else if (completionMode === "auto-merge" && reviewProvider === "gitlab") {
442
- 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 () => {
443
465
  currentActivity()?.update("enabling GitLab auto-merge", node.branch);
444
466
  return enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
445
- });
467
+ }));
446
468
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
447
469
  if (autoMerge.status === "failed") {
448
470
  opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -460,7 +482,7 @@ export async function runBranchPlan(opts) {
460
482
  else if (commit && completionMode === "direct-merge" && completesSharedUnit) {
461
483
  if (!opts.keepWorktrees)
462
484
  removeTicketWorktree(opts.projectDir, worktreePath);
463
- 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"));
464
486
  summary.pr = { status: "merged", url: mergeCommit };
465
487
  opts.log.write("branch-direct-merge", {
466
488
  ticket: node.ticket.id,
@@ -492,10 +514,17 @@ export async function runBranchPlan(opts) {
492
514
  status: "done",
493
515
  detail: summary.detail,
494
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
+ }
495
524
  if (node.deliveryUnitId && node.deliveryUnitFinal)
496
525
  rmSync(deliverySessionPath(opts.projectDir, node.deliveryUnitId), { force: true });
497
526
  if (!opts.keepWorktrees && completionMode !== "direct-merge" && completesSharedUnit)
498
- removeTicketWorktree(opts.projectDir, worktreePath);
527
+ await observeNode(opts, node, "cleanup", "removing ticket worktree", () => removeTicketWorktree(opts.projectDir, worktreePath));
499
528
  }
500
529
  catch (err) {
501
530
  if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
@@ -591,49 +620,69 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
591
620
  ? null
592
621
  : opts.autoMergeTimeoutMinutes * 60_000;
593
622
  const deadline = opts.autoMergeWait && timeoutMs !== null ? Date.now() + timeoutMs : null;
594
- while (true) {
595
- const pending = [];
596
- for (const dep of dependencyNodes) {
597
- const status = provider === "gitlab"
598
- ? checkGitLabMrMerged(opts.projectDir, dep.branch)
599
- : checkGitHubPrMerged(opts.projectDir, dep.branch);
600
- 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) {
601
652
  return {
602
653
  ok: false,
603
- code: status.code,
604
- message: `dependency ${dep.ticket.id} merge check failed: ${status.message}`,
605
- repairCommands: status.repairCommands,
606
- command: status.command,
607
- output: status.output,
654
+ code: "dependency_unavailable",
655
+ message: `${message}; rerun after it merges or enable auto-merge wait in ticket setup`,
608
656
  };
609
657
  }
610
- if (!status.merged)
611
- pending.push(`${dep.ticket.id}${status.state ? ` (${status.state})` : ""}`);
612
- }
613
- if (pending.length === 0)
614
- return { ok: true };
615
- const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
616
- currentActivity()?.update("waiting for dependency merge", pending.join(", "));
617
- if (!opts.autoMergeWait) {
618
- return {
619
- ok: false,
620
- code: "dependency_unavailable",
621
- message: `${message}; rerun after it merges or enable auto-merge wait in ticket setup`,
622
- };
623
- }
624
- if (deadline !== null && Date.now() >= deadline) {
625
- return {
626
- ok: false,
627
- code: "dependency_unavailable",
628
- message: `${message}; timed out waiting for dependency merge`,
629
- };
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());
630
679
  }
631
- opts.log.write("branch-auto-merge-wait", {
632
- ticket: node.ticket.id,
633
- pending,
634
- timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
635
- });
636
- 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 });
637
686
  }
638
687
  }
639
688
  function autoMergePollMs() {
@@ -646,6 +695,11 @@ function autoMergePollMs() {
646
695
  function sleep(ms) {
647
696
  return new Promise((resolve) => setTimeout(resolve, ms));
648
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
+ }
649
703
  function canonical(path) {
650
704
  try {
651
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;