ai-foreman 1.7.10 → 1.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/start.js CHANGED
@@ -31,6 +31,7 @@ import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig
31
31
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
32
32
  import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
33
33
  import { currentActivity, withActivityPhase } from "../activity.js";
34
+ import { fireTerminalBell } from "../notify.js";
34
35
  import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
35
36
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
36
37
  import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
@@ -38,7 +39,7 @@ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js
38
39
  import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
39
40
  import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
40
41
  import { ContextCapabilityError, RoleSessionController, ThresholdCompactionController } from "../sessionLifecycle.js";
41
- import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
+ import { HandoffAcceptanceError, HandoffLoopError, HandoffRecoveryPausedError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
43
  import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
43
44
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
44
45
  import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
@@ -374,6 +375,9 @@ export function buildStartCommand() {
374
375
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
375
376
  if (opts.recoverRun && !recoveryRecord)
376
377
  fail(`recoverable build run not found: ${opts.recoverRun}`);
378
+ const autoApprovePlanUpdates = recoveryRecord
379
+ ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
380
+ : Boolean(opts.yes);
377
381
  if (opts.recoveryMode) {
378
382
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
379
383
  if (!allowed.includes(String(opts.recoveryMode)))
@@ -676,6 +680,34 @@ export function buildStartCommand() {
676
680
  settings_revision: revision,
677
681
  };
678
682
  };
683
+ const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
684
+ ? { ...base, make: requestedRuntime, model: "default", source: "cli" }
685
+ : base;
686
+ const handoffRecoveryOptions = (role) => ({
687
+ allowProviderSwitch: true,
688
+ desktopNotifications: config.notifications.enabled,
689
+ terminalBell: config.notifications.terminal_bell,
690
+ onAccepted: (accepted) => {
691
+ if (role === "builder" && accepted.successor.agent !== agent) {
692
+ agent = accepted.successor.agent;
693
+ model = undefined;
694
+ agentExecutable = undefined;
695
+ }
696
+ if (role === "qa" && accepted.successor.agent !== qaAgent) {
697
+ qaAgent = accepted.successor.agent;
698
+ qaModel = undefined;
699
+ qaExecutable = undefined;
700
+ }
701
+ },
702
+ });
703
+ const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
704
+ ? current
705
+ : { ...current, make, model: acceptedModel ?? "default", source: "cli" };
706
+ const withRunLocalAcceptedRuntimes = (run) => ({
707
+ ...run,
708
+ ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
709
+ ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
710
+ });
679
711
  const createBuilderForSettings = async (builderCwd, settings) => {
680
712
  const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
681
713
  label: "live Builder settings",
@@ -757,7 +789,7 @@ export function buildStartCommand() {
757
789
  initial,
758
790
  runtime: agent,
759
791
  label: "builder turn",
760
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
792
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
761
793
  allowSwitch: !(opts.resume || opts.continue),
762
794
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
763
795
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
@@ -806,7 +838,7 @@ export function buildStartCommand() {
806
838
  } } : {}),
807
839
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
808
840
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
809
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
841
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
810
842
  log.write("handoff-transfer", {
811
843
  role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
812
844
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -854,7 +886,7 @@ export function buildStartCommand() {
854
886
  compactionCount,
855
887
  compactMaximum: effectiveSettings.compact_maximum,
856
888
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
857
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
889
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
858
890
  log.write("handoff-transfer", {
859
891
  requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
860
892
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -869,7 +901,8 @@ export function buildStartCommand() {
869
901
  let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
870
902
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
871
903
  const service = new HandoffService(cwd);
872
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
904
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
905
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
873
906
  adapter = accepted.successor;
874
907
  pendingHandoffGeneration = undefined;
875
908
  log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
@@ -904,7 +937,7 @@ export function buildStartCommand() {
904
937
  } } : {}),
905
938
  compactionCount, compactMaximum: input.next.compact_maximum,
906
939
  resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
907
- }, () => createBuilderForSettings(builderCwd, input.next));
940
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
908
941
  log.write("handoff-transfer", {
909
942
  role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
910
943
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -960,7 +993,7 @@ export function buildStartCommand() {
960
993
  initial,
961
994
  runtime: qaAgent,
962
995
  label: "QA turn",
963
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
996
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
964
997
  allowSwitch: !sessionRef,
965
998
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
966
999
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
@@ -1025,7 +1058,7 @@ export function buildStartCommand() {
1025
1058
  } } : {}),
1026
1059
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1027
1060
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1028
- }, () => createQaForSettings(qaCwd, effectiveSettings));
1061
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
1029
1062
  log.write("handoff-transfer", {
1030
1063
  role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1031
1064
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1082,7 +1115,8 @@ export function buildStartCommand() {
1082
1115
  let explicitRecoveryAccepted = false;
1083
1116
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1084
1117
  const service = new HandoffService(cwd);
1085
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1118
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1119
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1086
1120
  adapter = accepted.successor;
1087
1121
  pendingHandoffGeneration = undefined;
1088
1122
  explicitRecoveryAccepted = true;
@@ -1116,7 +1150,7 @@ export function buildStartCommand() {
1116
1150
  compactionCount,
1117
1151
  compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1118
1152
  });
1119
- const accepted = await service.acceptStaged(staged, adapter);
1153
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1120
1154
  adapter = accepted.successor;
1121
1155
  log.write("handoff-transfer", {
1122
1156
  role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
@@ -1155,7 +1189,7 @@ export function buildStartCommand() {
1155
1189
  } } : {}),
1156
1190
  compactionCount, compactMaximum: input.next.compact_maximum,
1157
1191
  resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1158
- }, () => createQaForSettings(qaCwd, input.next));
1192
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
1159
1193
  log.write("handoff-transfer", {
1160
1194
  role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1161
1195
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1198,7 +1232,7 @@ export function buildStartCommand() {
1198
1232
  } } : {}),
1199
1233
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1200
1234
  resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1201
- }, () => createQaForSettings(qaCwd, settings));
1235
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1202
1236
  log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1203
1237
  await predecessor.close().catch(() => { });
1204
1238
  return decorateQa(transfer.successor, qaCwd, undefined, settings);
@@ -1219,7 +1253,7 @@ export function buildStartCommand() {
1219
1253
  });
1220
1254
  await controller.observeNativeCompactions(adapter);
1221
1255
  };
1222
- const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
1256
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner);
1223
1257
  if (branchMode) {
1224
1258
  const ticketsConfig = loadTicketsConfig(cwd);
1225
1259
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -1408,7 +1442,7 @@ export function buildStartCommand() {
1408
1442
  console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
1409
1443
  }
1410
1444
  console.log();
1411
- if (!opts.yes && plan.issues.every((issue) => !issue.blocking)) {
1445
+ if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
1412
1446
  const action = await select({
1413
1447
  message: branchPresentation.prompt,
1414
1448
  options: [
@@ -1484,7 +1518,7 @@ export function buildStartCommand() {
1484
1518
  ...(nativeUsage ? { sessionUsage: { version: 1, runId: masterRun.runId, role: "builder", provider: adapter.agent, providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source, cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens, cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd } } : {}),
1485
1519
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1486
1520
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1487
- }, () => createBuilderForSettings(worktreePath, settings));
1521
+ }, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1488
1522
  log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1489
1523
  if (adapter instanceof ContinuityAdapter) {
1490
1524
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1500,93 +1534,111 @@ export function buildStartCommand() {
1500
1534
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
1501
1535
  branchHeartbeat.unref();
1502
1536
  let branchSessionFailure;
1503
- const summaries = await runBranchPlan({
1504
- projectDir: cwd,
1505
- runId: masterRun.runId,
1506
- plan,
1507
- log,
1508
- agent,
1509
- model,
1510
- effort: builderEffort,
1511
- fast: builderFast,
1512
- notificationsEnabled: config.notifications.enabled,
1513
- qaEnabled,
1514
- createPr: branchDefaults.createReview,
1515
- completionMode: branchDefaults.completionMode,
1516
- reviewProvider: branchDefaults.reviewProvider,
1517
- prReady: branchDefaults.prReady,
1518
- keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1519
- cleanupBranches: branchDefaults.cleanupBranches,
1520
- autoMergeWait: branchDefaults.autoMergeWait,
1521
- autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1522
- mergeMethod: branchDefaults.mergeMethod,
1523
- allowedBaseDirtyPaths,
1524
- baseWorktreePolicy,
1525
- trackerPaths: {
1526
- progressDoc: ticketsConfig.paths.progressDoc,
1527
- archiveDoc: ticketsConfig.paths.archiveDoc,
1528
- },
1529
- resumeSessions: resumeSessionByTicket,
1530
- createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1531
- recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1532
- recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1533
- onSessionUnavailable: (error) => { branchSessionFailure = error; },
1534
- createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1535
- builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1536
- qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1537
- observeBuilder: async (builder) => {
1538
- liveStatusReporter?.stop();
1539
- activeBuilderForStatus = () => builder;
1540
- const reporter = liveStatusReporter = new AgentStatusReporter({
1541
- runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1542
- reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1543
- phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1544
- settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1545
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
1546
- return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1547
- }
1548
- finally {
1549
- db.close();
1550
- } },
1551
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1552
- return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1553
- }
1554
- finally {
1555
- db.close();
1556
- } },
1557
- }, (line, snapshot) => {
1558
- const activity = currentActivity();
1559
- if (activity && process.stdout.isTTY)
1560
- activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1561
- else
1562
- console.log(line);
1563
- log.write("agent-status", { ...snapshot });
1564
- const db = new WorkflowDb(cwd);
1537
+ let summaries;
1538
+ try {
1539
+ summaries = await runBranchPlan({
1540
+ projectDir: cwd,
1541
+ runId: masterRun.runId,
1542
+ plan,
1543
+ log,
1544
+ agent,
1545
+ model,
1546
+ effort: builderEffort,
1547
+ fast: builderFast,
1548
+ notificationsEnabled: config.notifications.enabled,
1549
+ terminalBellEnabled: config.notifications.terminal_bell,
1550
+ qaEnabled,
1551
+ createPr: branchDefaults.createReview,
1552
+ completionMode: branchDefaults.completionMode,
1553
+ reviewProvider: branchDefaults.reviewProvider,
1554
+ prReady: branchDefaults.prReady,
1555
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1556
+ cleanupBranches: branchDefaults.cleanupBranches,
1557
+ autoMergeWait: branchDefaults.autoMergeWait,
1558
+ autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1559
+ mergeMethod: branchDefaults.mergeMethod,
1560
+ allowedBaseDirtyPaths,
1561
+ baseWorktreePolicy,
1562
+ trackerPaths: {
1563
+ progressDoc: ticketsConfig.paths.progressDoc,
1564
+ archiveDoc: ticketsConfig.paths.archiveDoc,
1565
+ },
1566
+ resumeSessions: resumeSessionByTicket,
1567
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1568
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1569
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1570
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
1571
+ createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1572
+ builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1573
+ qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1574
+ observeBuilder: async (builder) => {
1575
+ liveStatusReporter?.stop();
1576
+ activeBuilderForStatus = () => builder;
1577
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1578
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1579
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1580
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1581
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1582
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1583
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1584
+ }
1585
+ finally {
1586
+ db.close();
1587
+ } },
1588
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1589
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1590
+ }
1591
+ finally {
1592
+ db.close();
1593
+ } },
1594
+ }, (line, snapshot) => {
1595
+ const activity = currentActivity();
1596
+ if (activity && process.stdout.isTTY)
1597
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1598
+ else
1599
+ console.log(line);
1600
+ log.write("agent-status", { ...snapshot });
1601
+ const db = new WorkflowDb(cwd);
1602
+ try {
1603
+ db.recordTelemetry(masterRun.runId, snapshot);
1604
+ db.recordContextSample(snapshot.contextSample);
1605
+ if (snapshot.sessionUsage)
1606
+ db.recordSessionUsage(snapshot.sessionUsage);
1607
+ }
1608
+ finally {
1609
+ db.close();
1610
+ }
1611
+ });
1612
+ reporter.start();
1565
1613
  try {
1566
- db.recordTelemetry(masterRun.runId, snapshot);
1567
- db.recordContextSample(snapshot.contextSample);
1568
- if (snapshot.sessionUsage)
1569
- db.recordSessionUsage(snapshot.sessionUsage);
1614
+ await printEvents(builder.events());
1570
1615
  }
1571
1616
  finally {
1572
- db.close();
1617
+ reporter.stop();
1573
1618
  }
1574
- });
1575
- reporter.start();
1576
- try {
1577
- await printEvents(builder.events());
1578
- }
1579
- finally {
1580
- reporter.stop();
1581
- }
1582
- },
1583
- qaNonconvergence,
1584
- beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1585
- observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1586
- builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1587
- qaSessionBoundary,
1588
- observeQaNativeCompactions,
1589
- });
1619
+ },
1620
+ qaNonconvergence,
1621
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1622
+ observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1623
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1624
+ qaSessionBoundary,
1625
+ observeQaNativeCompactions,
1626
+ });
1627
+ }
1628
+ catch (error) {
1629
+ if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
1630
+ throw error;
1631
+ const summary = error.message.slice(0, 1000);
1632
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
1633
+ status: "recoverable",
1634
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1635
+ }), "recoverable");
1636
+ clearInterval(branchHeartbeat);
1637
+ log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
1638
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
1639
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)}`);
1640
+ process.exit(2);
1641
+ }
1590
1642
  if (branchSessionFailure) {
1591
1643
  const failure = branchSessionFailure;
1592
1644
  const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
@@ -1624,6 +1676,7 @@ export function buildStartCommand() {
1624
1676
  }
1625
1677
  console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
1626
1678
  }
1679
+ masterRun = withRunLocalAcceptedRuntimes(masterRun);
1627
1680
  masterRun = summaries.every((row) => row.buildStatus === "done")
1628
1681
  ? completeBuildRun(cwd, masterRun)
1629
1682
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
@@ -1651,6 +1704,7 @@ export function buildStartCommand() {
1651
1704
  if (masterRun.status === "completed")
1652
1705
  printHandoffPruneCommand(masterRun.runId);
1653
1706
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
1707
+ fireTerminalBell(config.notifications.terminal_bell);
1654
1708
  process.exit(failed ? 2 : 0);
1655
1709
  }
1656
1710
  const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
@@ -1759,7 +1813,7 @@ export function buildStartCommand() {
1759
1813
  compactionCount,
1760
1814
  compactMaximum: settings.compact_maximum ?? 10,
1761
1815
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1762
- }, () => createBuilderForSettings(cwd, settings));
1816
+ }, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1763
1817
  log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1764
1818
  if (adapter instanceof ContinuityAdapter) {
1765
1819
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1769,7 +1823,7 @@ export function buildStartCommand() {
1769
1823
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1770
1824
  },
1771
1825
  });
1772
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
1826
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
1773
1827
  activeBuilderForStatus = () => foreman.builderAdapter();
1774
1828
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1775
1829
  runId: buildRun.runId,
@@ -1825,12 +1879,13 @@ export function buildStartCommand() {
1825
1879
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
1826
1880
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
1827
1881
  await foreman.runPreflight(steps, ticketsContent, preferredTicket);
1882
+ buildRun = withRunLocalAcceptedRuntimes(buildRun);
1828
1883
  if (foreman.builderSessionId())
1829
1884
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1830
1885
  if (foreman.qaSessionId())
1831
1886
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
1832
1887
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
1833
- if (!opts.yes) {
1888
+ if (!autoApprovePlanUpdates) {
1834
1889
  while (true) {
1835
1890
  console.log();
1836
1891
  const action = await select({
@@ -1902,7 +1957,8 @@ export function buildStartCommand() {
1902
1957
  console.log(line);
1903
1958
  }
1904
1959
  }
1905
- process.exit(result.outcome === "needs-human" ? 2 : 0);
1960
+ fireTerminalBell(config.notifications.terminal_bell);
1961
+ process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
1906
1962
  }
1907
1963
  catch (err) {
1908
1964
  clearInterval(heartbeat);
@@ -1934,6 +1990,18 @@ export function buildStartCommand() {
1934
1990
  : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1935
1991
  process.exit(2);
1936
1992
  }
1993
+ if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
1994
+ const summary = err.message.slice(0, 1000);
1995
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
1996
+ status: "recoverable",
1997
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1998
+ }), "recoverable");
1999
+ await foreman.close().catch(() => { });
2000
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
2001
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2002
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)}`);
2003
+ process.exit(2);
2004
+ }
1937
2005
  if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
1938
2006
  const summary = err.message.slice(0, 1000);
1939
2007
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
@@ -1026,7 +1026,7 @@ export async function cmdPopulateCli(opts) {
1026
1026
  }));
1027
1027
  builder = roleBuilder.builder;
1028
1028
  viewer = printEvents(builder.events());
1029
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
1029
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, dir);
1030
1030
  console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
1031
1031
  console.log(`foreman tickets: project ${dir}`);
1032
1032
  console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
package/dist/config.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface PermissionConfig {
13
13
  export interface NotificationsConfig {
14
14
  /** Fire a desktop notification when the builder needs user input. Default: false. */
15
15
  enabled: boolean;
16
+ /** Ring the terminal bell when user attention is required or a run finishes. Default: true. */
17
+ terminal_bell: boolean;
16
18
  }
17
19
  export interface QaConfig {
18
20
  /** Run a QA review pass after each completed ticket or step. Default: true. */
package/dist/config.js CHANGED
@@ -65,7 +65,7 @@ export const DEFAULT_CONFIG = {
65
65
  ],
66
66
  escalateTools: ["WebFetch", "WebSearch"],
67
67
  },
68
- notifications: { enabled: false },
68
+ notifications: { enabled: false, terminal_bell: true },
69
69
  qa: { enabled: true },
70
70
  };
71
71
  /** Load foreman.yaml if present and deep-merge it over the defaults. */
@@ -104,6 +104,10 @@ function validateConfig(raw, path) {
104
104
  }
105
105
  if (cfg.notifications !== undefined) {
106
106
  validateBooleanObject(cfg.notifications, "notifications", path);
107
+ const terminalBell = cfg.notifications.terminal_bell;
108
+ if (terminalBell !== undefined && typeof terminalBell !== "boolean") {
109
+ throw new Error(`${path}: notifications.terminal_bell must be a boolean`);
110
+ }
107
111
  }
108
112
  if (cfg.qa !== undefined) {
109
113
  validateBooleanObject(cfg.qa, "qa", path);
package/dist/foreman.d.ts CHANGED
@@ -48,11 +48,14 @@ export interface BatchResult {
48
48
  outcome: "all-done" | "plan-complete" | "blocked" | "needs-human";
49
49
  detail?: string;
50
50
  }
51
+ export interface ForemanNotificationOptions {
52
+ desktop: boolean;
53
+ terminalBell: boolean;
54
+ }
51
55
  /** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
52
56
  export declare class Foreman {
53
57
  private builder;
54
58
  private readonly log;
55
- private readonly notificationsEnabled;
56
59
  private readonly qaEnabled;
57
60
  private readonly qaMaxCycles;
58
61
  private readonly projectDir?;
@@ -69,9 +72,11 @@ export declare class Foreman {
69
72
  /** Persist a Builder's provider-native event immediately after every turn. */
70
73
  private readonly observeBuilderNativeCompactions?;
71
74
  private readonly ticketsEnabled;
75
+ private readonly notificationsEnabled;
76
+ private readonly terminalBellEnabled;
72
77
  private readonly qaStream;
73
78
  private builderWorkSessions;
74
- constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
79
+ constructor(builder: BuilderAdapter, log: Log, notifications?: boolean | ForemanNotificationOptions, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
75
80
  /** Persist a Builder's provider-native event immediately after every turn. */
76
81
  observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined);
77
82
  /**
@@ -90,6 +95,11 @@ export declare class Foreman {
90
95
  result: TurnResult;
91
96
  status: StepStatus;
92
97
  }>;
98
+ /** Ask an already-active role session to propose choices for its reported blocker. */
99
+ resolveBlocker(adapter: BuilderAdapter, reason: string): Promise<{
100
+ result: TurnResult;
101
+ status: StepStatus;
102
+ }>;
93
103
  private observeBuilderNative;
94
104
  /**
95
105
  * Run a QA review pass on the ticket the builder just completed.