@backburner/cli 0.1.0

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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +329 -0
  3. package/dist/src/agents/antigravity-launch.js +162 -0
  4. package/dist/src/agents/antigravity.js +251 -0
  5. package/dist/src/agents/claude-launch.js +117 -0
  6. package/dist/src/agents/claude.js +271 -0
  7. package/dist/src/agents/codex-launch.js +82 -0
  8. package/dist/src/agents/codex.js +261 -0
  9. package/dist/src/agents/composite-runner.js +13 -0
  10. package/dist/src/agents/extraction.js +442 -0
  11. package/dist/src/agents/failure-feedback.js +132 -0
  12. package/dist/src/agents/gemini-launch.js +96 -0
  13. package/dist/src/agents/gemini.js +392 -0
  14. package/dist/src/agents/job-agent-assignments.js +180 -0
  15. package/dist/src/agents/lifecycle-feedback.js +147 -0
  16. package/dist/src/agents/limit-detector.js +83 -0
  17. package/dist/src/agents/opencode-launch.js +138 -0
  18. package/dist/src/agents/opencode.js +156 -0
  19. package/dist/src/agents/plan-breakdown-feedback.js +97 -0
  20. package/dist/src/agents/pool.js +168 -0
  21. package/dist/src/agents/prompt.js +462 -0
  22. package/dist/src/agents/provider-sessions.js +105 -0
  23. package/dist/src/agents/review-feedback.js +255 -0
  24. package/dist/src/agents/selection.js +113 -0
  25. package/dist/src/agents/service.js +1087 -0
  26. package/dist/src/agents/types.js +1 -0
  27. package/dist/src/agents/wave-assessment-feedback.js +69 -0
  28. package/dist/src/attention/derive.js +337 -0
  29. package/dist/src/capabilities/index.js +2 -0
  30. package/dist/src/capabilities/projection.js +183 -0
  31. package/dist/src/capabilities/types.js +1 -0
  32. package/dist/src/capabilities/validator.js +87 -0
  33. package/dist/src/cli/commands/broker-smoke.js +96 -0
  34. package/dist/src/cli/commands/broker.js +56 -0
  35. package/dist/src/cli/commands/init.js +4 -0
  36. package/dist/src/cli/commands/journal.js +43 -0
  37. package/dist/src/cli/commands/run-loop.js +56 -0
  38. package/dist/src/cli/commands/run.js +1241 -0
  39. package/dist/src/cli/commands/tui.js +187 -0
  40. package/dist/src/cli/dispatcher.js +51 -0
  41. package/dist/src/cli/errors.js +2 -0
  42. package/dist/src/cli/init.js +92 -0
  43. package/dist/src/cli/options.js +86 -0
  44. package/dist/src/cli/output.js +231 -0
  45. package/dist/src/cli/run-journal.js +342 -0
  46. package/dist/src/cli/run-types.js +1 -0
  47. package/dist/src/cli/run.js +32 -0
  48. package/dist/src/cli/runtime/dispatch-lifecycle.js +772 -0
  49. package/dist/src/cli/runtime/provider-runtime-types.js +1 -0
  50. package/dist/src/cli/runtime/provider-runtime.js +152 -0
  51. package/dist/src/cli/runtime/provider-tools.js +73 -0
  52. package/dist/src/cli/runtime/providers/antigravity.js +26 -0
  53. package/dist/src/cli/runtime/providers/claude.js +20 -0
  54. package/dist/src/cli/runtime/providers/codex.js +20 -0
  55. package/dist/src/cli/runtime/providers/gemini.js +20 -0
  56. package/dist/src/cli/runtime/providers/opencode.js +20 -0
  57. package/dist/src/cli/runtime/run-context.js +131 -0
  58. package/dist/src/cli/tui/index.js +3 -0
  59. package/dist/src/cli/tui/screen.js +388 -0
  60. package/dist/src/cli/tui/tracker.js +558 -0
  61. package/dist/src/cli/tui/types.js +1 -0
  62. package/dist/src/cli/tui/views.js +1006 -0
  63. package/dist/src/config/loader.js +136 -0
  64. package/dist/src/config/schemas.js +403 -0
  65. package/dist/src/config/types.js +1 -0
  66. package/dist/src/context/types.js +331 -0
  67. package/dist/src/domain/entities.js +1 -0
  68. package/dist/src/git/backburner-git-tool-service.js +680 -0
  69. package/dist/src/git/gitops-lifecycle.js +56 -0
  70. package/dist/src/git/merge-prep-workspace.js +357 -0
  71. package/dist/src/git/types.js +484 -0
  72. package/dist/src/github/broker-model-content.js +215 -0
  73. package/dist/src/github/broker-server.js +237 -0
  74. package/dist/src/github/broker.js +638 -0
  75. package/dist/src/github/composed.js +53 -0
  76. package/dist/src/github/gateway.js +478 -0
  77. package/dist/src/github/normalize.js +222 -0
  78. package/dist/src/github/pr-classification.js +73 -0
  79. package/dist/src/github/scoped-broker.js +35 -0
  80. package/dist/src/github/security.js +126 -0
  81. package/dist/src/github/service.js +332 -0
  82. package/dist/src/github/types.js +1 -0
  83. package/dist/src/github/utils.js +16 -0
  84. package/dist/src/journal/index.js +4 -0
  85. package/dist/src/journal/reader.js +62 -0
  86. package/dist/src/journal/renderer.js +50 -0
  87. package/dist/src/journal/service.js +72 -0
  88. package/dist/src/journal/types.js +1 -0
  89. package/dist/src/journal/writer.js +19 -0
  90. package/dist/src/mcps/bridge.js +166 -0
  91. package/dist/src/mcps/index.js +5 -0
  92. package/dist/src/mcps/reconcile.js +113 -0
  93. package/dist/src/mcps/smithery-client.js +186 -0
  94. package/dist/src/mcps/smithery.js +164 -0
  95. package/dist/src/mcps/types.js +1 -0
  96. package/dist/src/memory/json-store.js +71 -0
  97. package/dist/src/memory/prompt.js +16 -0
  98. package/dist/src/memory/scope.js +31 -0
  99. package/dist/src/memory/store.js +1 -0
  100. package/dist/src/memory/tool-service.js +142 -0
  101. package/dist/src/memory/types.js +12 -0
  102. package/dist/src/onboarding/config-writer.js +96 -0
  103. package/dist/src/onboarding/engine.js +86 -0
  104. package/dist/src/onboarding/prompt.js +102 -0
  105. package/dist/src/onboarding/services/provider-discovery.js +58 -0
  106. package/dist/src/onboarding/services/repo-discovery.js +246 -0
  107. package/dist/src/onboarding/steps/check-github-auth.js +32 -0
  108. package/dist/src/onboarding/steps/check-requirements.js +34 -0
  109. package/dist/src/onboarding/steps/configure-models.js +105 -0
  110. package/dist/src/onboarding/steps/configure-paths.js +33 -0
  111. package/dist/src/onboarding/steps/discover-providers.js +38 -0
  112. package/dist/src/onboarding/steps/discover-repos.js +39 -0
  113. package/dist/src/onboarding/steps/generate-config.js +106 -0
  114. package/dist/src/onboarding/steps/readiness-validation.js +158 -0
  115. package/dist/src/onboarding/steps/select-repos.js +76 -0
  116. package/dist/src/onboarding/steps/show-summary.js +97 -0
  117. package/dist/src/onboarding/steps/welcome.js +27 -0
  118. package/dist/src/onboarding/types.js +1 -0
  119. package/dist/src/onboarding/validators/git-access.js +63 -0
  120. package/dist/src/onboarding/validators/github-auth.js +85 -0
  121. package/dist/src/onboarding/validators/tool-checker.js +153 -0
  122. package/dist/src/onboarding/validators/workspace.js +83 -0
  123. package/dist/src/prompts/resolver.js +106 -0
  124. package/dist/src/retrospectives/candidates.js +224 -0
  125. package/dist/src/retrospectives/derive.js +169 -0
  126. package/dist/src/retrospectives/ingest.js +321 -0
  127. package/dist/src/retrospectives/management-prs.js +380 -0
  128. package/dist/src/retrospectives/proposals.js +199 -0
  129. package/dist/src/retrospectives/scopes.js +272 -0
  130. package/dist/src/retrospectives/scoring.js +171 -0
  131. package/dist/src/retrospectives/types.js +1 -0
  132. package/dist/src/state/loader.js +473 -0
  133. package/dist/src/state/types.js +15 -0
  134. package/dist/src/tasks/derivation/builders.js +70 -0
  135. package/dist/src/tasks/derivation/handlers/comment-response.js +169 -0
  136. package/dist/src/tasks/derivation/handlers/implement-plan.js +103 -0
  137. package/dist/src/tasks/derivation/handlers/index.js +54 -0
  138. package/dist/src/tasks/derivation/handlers/packet-pr-control.js +92 -0
  139. package/dist/src/tasks/derivation/handlers/packet-worktree.js +29 -0
  140. package/dist/src/tasks/derivation/handlers/plan-breakdown.js +45 -0
  141. package/dist/src/tasks/derivation/handlers/prepare-for-merge.js +91 -0
  142. package/dist/src/tasks/derivation/handlers/product-discovery.js +36 -0
  143. package/dist/src/tasks/derivation/handlers/retry-failed-task.js +125 -0
  144. package/dist/src/tasks/derivation/handlers/review-pr.js +28 -0
  145. package/dist/src/tasks/derivation/handlers/shared.js +24 -0
  146. package/dist/src/tasks/derivation/handlers/sync-parent-branch.js +100 -0
  147. package/dist/src/tasks/derivation/handlers/wave-assessment.js +223 -0
  148. package/dist/src/tasks/derivation/handlers/write-plan.js +101 -0
  149. package/dist/src/tasks/derivation/handlers/write-product-spec.js +49 -0
  150. package/dist/src/tasks/derivation/runner.js +18 -0
  151. package/dist/src/tasks/derivation/types.js +1 -0
  152. package/dist/src/tasks/derive.js +810 -0
  153. package/dist/src/tasks/mcp-skip.js +10 -0
  154. package/dist/src/tasks/retry.js +10 -0
  155. package/dist/src/tasks/task.js +184 -0
  156. package/dist/src/tasks/types.js +1 -0
  157. package/dist/src/utils/command-runner.js +192 -0
  158. package/dist/src/utils/json.js +38 -0
  159. package/dist/src/utils/logger.js +43 -0
  160. package/dist/src/utils/paths.js +38 -0
  161. package/dist/src/utils/slug.js +10 -0
  162. package/dist/src/workflows/registry.js +174 -0
  163. package/dist/src/workflows/triage.js +90 -0
  164. package/dist/src/workflows/workstream-machine/derive-events.js +121 -0
  165. package/dist/src/workflows/workstream-machine/evaluate.js +24 -0
  166. package/dist/src/workflows/workstream-machine/events.js +1 -0
  167. package/dist/src/workflows/workstream-machine/machine.js +355 -0
  168. package/dist/src/workflows/workstream-machine/selectors.js +19 -0
  169. package/dist/src/workflows/workstream-machine/types.js +1 -0
  170. package/dist/src/workstreams/branch-sync.js +87 -0
  171. package/dist/src/workstreams/derive.js +629 -0
  172. package/dist/src/workstreams/mcp-blockers.js +420 -0
  173. package/dist/src/workstreams/packet-candidates.js +42 -0
  174. package/dist/src/workstreams/packet-lifecycle-feedback.js +60 -0
  175. package/dist/src/workstreams/packet-lifecycle-report.js +199 -0
  176. package/dist/src/workstreams/packet-plan.js +597 -0
  177. package/dist/src/workstreams/packet-projections.js +190 -0
  178. package/dist/src/workstreams/parent-branch-sync-action.js +312 -0
  179. package/dist/src/workstreams/parent-branch-sync.js +148 -0
  180. package/dist/src/workstreams/phases.js +4 -0
  181. package/dist/src/workstreams/plan-breakdown-generator/export.js +19 -0
  182. package/dist/src/workstreams/plan-breakdown-generator/index.js +6 -0
  183. package/dist/src/workstreams/plan-breakdown-generator/inspect.js +50 -0
  184. package/dist/src/workstreams/plan-breakdown-generator/patch.js +139 -0
  185. package/dist/src/workstreams/plan-breakdown-generator/session.js +19 -0
  186. package/dist/src/workstreams/plan-breakdown-generator/store.js +27 -0
  187. package/dist/src/workstreams/plan-breakdown-generator/tools.js +407 -0
  188. package/dist/src/workstreams/plan-breakdown-generator/types.js +6 -0
  189. package/dist/src/workstreams/plan-breakdown-generator/validate.js +212 -0
  190. package/dist/src/workstreams/plan-breakdown-schema.js +175 -0
  191. package/dist/src/workstreams/utils.js +15 -0
  192. package/dist/src/workstreams/wave-assessment/checkpoint.js +172 -0
  193. package/dist/src/workstreams/wave-assessment/index.js +3 -0
  194. package/dist/src/workstreams/wave-assessment/session.js +84 -0
  195. package/dist/src/workstreams/wave-assessment/store.js +51 -0
  196. package/dist/src/workstreams/wave-assessment/tools.js +419 -0
  197. package/dist/src/workstreams/wave-assessment/types.js +1 -0
  198. package/dist/src/workstreams/wave-assessment/validate.js +78 -0
  199. package/dist/src/worktrees/service.js +839 -0
  200. package/dist/src/worktrees/workspace-ownership.js +34 -0
  201. package/dist/src/worktrees/workspace-recovery.js +177 -0
  202. package/package.json +61 -0
@@ -0,0 +1,772 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { dispatchPendingTasks, buildSinglePostDispatchHandoffFailureExecution, buildSinglePreDispatchFailureExecution, safelyNotifyFailure } from "../../agents/service.js";
4
+ import { buildMcpBlockerNotificationComment } from "../../agents/failure-feedback.js";
5
+ import { buildMergePrepPrComment } from "../../git/merge-prep-workspace.js";
6
+ import { Task } from "../../tasks/task.js";
7
+ import { CommandExecutionError } from "../../utils/command-runner.js";
8
+ import { classifyMidTaskMcpFailure, evaluateMcpDispatchGate, markMcpBlockersSurfaced, rehydrateTaskMcpSkipContexts, resumeMidTaskBlockedTasks, upsertMcpBlocker } from "../../workstreams/mcp-blockers.js";
9
+ import { createSmitheryMcpResolverFromEnv, reconcileMcpStatus } from "../../mcps/index.js";
10
+ function repoSlug(repo) {
11
+ return `${repo.owner}/${repo.name}`;
12
+ }
13
+ function replaceWorkstream(workstreams, updated) {
14
+ return workstreams.map((workstream) => workstream.id === updated.id ? updated : workstream);
15
+ }
16
+ function resolveTaskPacketId(task, worktrees) {
17
+ if (task.context.packetId) {
18
+ return task.context.packetId;
19
+ }
20
+ const localPath = task.kind === "prepare_pr_for_merge"
21
+ ? task.context.prepareForMerge?.persistentWorktreePath
22
+ : task.context.localRepoPath;
23
+ const branchName = task.kind === "prepare_pr_for_merge"
24
+ ? task.context.prepareForMerge?.headBranch
25
+ : task.context.branchName;
26
+ return worktrees.find((worktree) => worktree.path === localPath && worktree.branchName === branchName)
27
+ ?.packet?.packetId;
28
+ }
29
+ async function refreshMcpStatusesForMidTaskCheck(input) {
30
+ if (input.refreshMcpStatuses) {
31
+ const refreshed = await input.refreshMcpStatuses(input.previousStatuses);
32
+ await input.stateStore.writeMcpStatusState(refreshed);
33
+ return refreshed;
34
+ }
35
+ if (input.declaredMcps.length === 0) {
36
+ return input.previousStatuses;
37
+ }
38
+ try {
39
+ const resolvedMcps = await createSmitheryMcpResolverFromEnv().resolveDependencies([...input.declaredMcps], input.previousStatuses);
40
+ const reconciliation = reconcileMcpStatus([...input.declaredMcps], input.previousStatuses, resolvedMcps, new Date().toISOString());
41
+ await input.stateStore.writeMcpStatusState(reconciliation.records);
42
+ return reconciliation.records;
43
+ }
44
+ catch (err) {
45
+ input.logger.warn(`Failed to refresh MCP readiness after task failure: ${err instanceof Error ? err.message : String(err)}`);
46
+ return input.previousStatuses;
47
+ }
48
+ }
49
+ async function surfaceUnsurfacedMcpBlockers(input) {
50
+ let workstreams = input.workstreams;
51
+ const taskById = new Map(input.tasks.map((task) => [task.id, task]));
52
+ const reposById = new Map(input.enabledRepos.map((repo) => [repo.id, repo]));
53
+ for (const workstream of workstreams) {
54
+ const unsurfaced = (workstream.mcpBlockers ?? []).filter((blocker) => blocker.resolutionState === "waiting_for_user" && blocker.surfacedAt === undefined);
55
+ if (unsurfaced.length === 0) {
56
+ continue;
57
+ }
58
+ let currentWorkstream = workstreams.find((candidate) => candidate.id === workstream.id) ?? workstream;
59
+ for (const blocker of unsurfaced) {
60
+ const task = taskById.get(blocker.blockedTaskId);
61
+ const repo = task ? reposById.get(task.repoId) : undefined;
62
+ const targetNumber = task ? Task.from(task).owningThreadNumber() : undefined;
63
+ if (!task || !repo || targetNumber === undefined) {
64
+ input.logger.warn(`[mcp-blocker] Cannot surface blocker ${blocker.id}: missing task, repo, or target thread.`);
65
+ continue;
66
+ }
67
+ try {
68
+ await input.githubGateway.postIssueComment({
69
+ owner: repo.owner,
70
+ name: repo.name,
71
+ issueNumber: targetNumber,
72
+ body: buildMcpBlockerNotificationComment(task, blocker, input.executionsByTaskId.get(task.id))
73
+ });
74
+ }
75
+ catch (err) {
76
+ input.logger.warn(`[mcp-blocker] Failed to surface blocker ${blocker.id}: ${err instanceof Error ? err.message : String(err)}`);
77
+ continue;
78
+ }
79
+ currentWorkstream = markMcpBlockersSurfaced(currentWorkstream, new Set([blocker.id]), input.now());
80
+ workstreams = replaceWorkstream(workstreams, currentWorkstream);
81
+ await input.stateStore.writeWorkstreamState(workstreams);
82
+ }
83
+ }
84
+ return workstreams;
85
+ }
86
+ async function classifyAndSurfaceMidTaskMcpFailures(input) {
87
+ if (!input.capabilitiesConfig) {
88
+ return { tasks: input.tasks, workstreams: input.workstreams, mcpBlockedTaskIds: new Set() };
89
+ }
90
+ const previousStatusByTaskId = new Map(input.inputTasks.map((task) => [task.id, task.status]));
91
+ const candidates = input.tasks.filter((task) => task.status === "failed" &&
92
+ previousStatusByTaskId.get(task.id) !== "failed");
93
+ if (candidates.length === 0) {
94
+ return { tasks: input.tasks, workstreams: input.workstreams, mcpBlockedTaskIds: new Set() };
95
+ }
96
+ const refreshedMcpStatuses = await refreshMcpStatusesForMidTaskCheck({
97
+ declaredMcps: input.declaredMcps,
98
+ previousStatuses: input.mcpStatuses,
99
+ refreshMcpStatuses: input.refreshMcpStatuses,
100
+ stateStore: input.stateStore,
101
+ logger: input.logger
102
+ });
103
+ let tasks = input.tasks;
104
+ let workstreams = input.workstreams;
105
+ const mcpBlockedTaskIds = new Set();
106
+ const executionByTaskId = new Map(input.executions.map((execution) => [execution.taskId, execution]));
107
+ for (const task of candidates) {
108
+ const blockedCapabilities = classifyMidTaskMcpFailure({
109
+ task,
110
+ capabilities: input.capabilitiesConfig,
111
+ declaredMcps: [...input.declaredMcps],
112
+ refreshedMcpStatuses
113
+ });
114
+ if (blockedCapabilities.length === 0 || !task.workstreamId) {
115
+ continue;
116
+ }
117
+ const workstream = workstreams.find((candidate) => candidate.id === task.workstreamId);
118
+ if (!workstream) {
119
+ continue;
120
+ }
121
+ let updatedWorkstream = workstream;
122
+ for (const blockedCapability of blockedCapabilities) {
123
+ const mcpStatus = refreshedMcpStatuses.find((status) => status.handle === blockedCapability.uses);
124
+ if (!mcpStatus) {
125
+ continue;
126
+ }
127
+ updatedWorkstream = upsertMidTaskBlocker(updatedWorkstream, task, blockedCapability, mcpStatus, resolveTaskPacketId(task, input.worktrees), new Date().toISOString());
128
+ }
129
+ workstreams = replaceWorkstream(workstreams, updatedWorkstream);
130
+ const taskIndex = tasks.findIndex((candidate) => candidate.id === task.id);
131
+ if (taskIndex !== -1) {
132
+ tasks = [
133
+ ...tasks.slice(0, taskIndex),
134
+ {
135
+ ...tasks[taskIndex],
136
+ context: {
137
+ ...tasks[taskIndex].context,
138
+ recoveryNeeded: false
139
+ }
140
+ },
141
+ ...tasks.slice(taskIndex + 1)
142
+ ];
143
+ }
144
+ mcpBlockedTaskIds.add(task.id);
145
+ }
146
+ if (mcpBlockedTaskIds.size > 0) {
147
+ await input.stateStore.writeTaskState(tasks);
148
+ await input.stateStore.writeWorkstreamState(workstreams);
149
+ workstreams = await surfaceUnsurfacedMcpBlockers({
150
+ workstreams,
151
+ tasks,
152
+ executionsByTaskId: executionByTaskId,
153
+ enabledRepos: input.enabledRepos,
154
+ githubGateway: input.githubGateway,
155
+ stateStore: input.stateStore,
156
+ logger: input.logger,
157
+ now: () => new Date().toISOString()
158
+ });
159
+ }
160
+ return { tasks, workstreams, mcpBlockedTaskIds };
161
+ }
162
+ function upsertMidTaskBlocker(workstream, task, blockedCapability, mcpStatus, packetId, now) {
163
+ return upsertMcpBlocker(workstream, task, blockedCapability, mcpStatus, packetId, now);
164
+ }
165
+ async function notifyOrdinaryDeferredFailures(input) {
166
+ const previousStatusByTaskId = new Map(input.inputTasks.map((task) => [task.id, task.status]));
167
+ const executionByTaskId = new Map(input.executions.map((execution) => [execution.taskId, execution]));
168
+ let tasks = input.tasks;
169
+ let changed = false;
170
+ for (let index = 0; index < tasks.length; index += 1) {
171
+ const task = tasks[index];
172
+ if (task.status !== "failed" ||
173
+ previousStatusByTaskId.get(task.id) === "failed" ||
174
+ input.mcpBlockedTaskIds.has(task.id)) {
175
+ continue;
176
+ }
177
+ const execution = task.lastExecutionId ? input.executions.find((entry) => entry.id === task.lastExecutionId) : executionByTaskId.get(task.id);
178
+ if (!execution) {
179
+ continue;
180
+ }
181
+ const failureCommentId = await safelyNotifyFailure(input.failureNotifier, task, execution);
182
+ if (failureCommentId !== undefined) {
183
+ tasks = [
184
+ ...tasks.slice(0, index),
185
+ { ...task, lastFailureCommentId: failureCommentId },
186
+ ...tasks.slice(index + 1)
187
+ ];
188
+ changed = true;
189
+ }
190
+ }
191
+ if (changed) {
192
+ await input.stateStore.writeTaskState(tasks);
193
+ }
194
+ return tasks;
195
+ }
196
+ async function startPrepareForMerge(input) {
197
+ const { commandRunner, clonePath, headBranch, expectedHeadSha, baseBranch } = input;
198
+ await commandRunner.run("git", ["fetch", "--prune", "origin"], { cwd: clonePath });
199
+ await commandRunner.run("git", ["checkout", headBranch], { cwd: clonePath });
200
+ await commandRunner.run("git", ["reset", "--hard", expectedHeadSha], { cwd: clonePath });
201
+ const currentHead = (await commandRunner.run("git", ["rev-parse", "HEAD"], { cwd: clonePath })).stdout.trim();
202
+ if (currentHead !== expectedHeadSha) {
203
+ throw new Error(`Prepare-for-merge clone reset to ${currentHead}, expected ${expectedHeadSha}`);
204
+ }
205
+ const status = (await commandRunner.run("git", ["status", "--porcelain"], { cwd: clonePath })).stdout.trim();
206
+ if (status !== "") {
207
+ throw new Error("Prepare-for-merge clone is dirty before merge start.");
208
+ }
209
+ await commandRunner.run("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${baseBranch}`], { cwd: clonePath });
210
+ try {
211
+ await commandRunner.run("git", ["merge", "--no-ff", "--no-edit", `origin/${baseBranch}`], {
212
+ cwd: clonePath
213
+ });
214
+ return {
215
+ status: "merged_cleanly",
216
+ message: `Merged origin/${baseBranch} into ${headBranch} without conflicts.`
217
+ };
218
+ }
219
+ catch (error) {
220
+ const conflictedFiles = await getUnresolvedConflictFiles(commandRunner, clonePath);
221
+ if (error instanceof CommandExecutionError && conflictedFiles.length > 0) {
222
+ return {
223
+ status: "merge_conflict",
224
+ message: `Merge from origin/${baseBranch} stopped on conflicts.`,
225
+ conflictedFiles
226
+ };
227
+ }
228
+ throw error;
229
+ }
230
+ }
231
+ async function getUnresolvedConflictFiles(commandRunner, clonePath) {
232
+ const result = await commandRunner.run("git", ["diff", "--name-only", "--diff-filter=U"], {
233
+ cwd: clonePath
234
+ });
235
+ return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
236
+ }
237
+ async function buildOrchestratorMergePrepSuccessExecution(input) {
238
+ const executionId = `exec-${input.task.id.replace(/[^a-zA-Z0-9_-]+/g, "_")}-merge-prep-orchestrator-${input.timestamp.replace(/[:.]/g, "-")}`;
239
+ const artifactRoot = path.join(input.logDir, "artifacts", executionId);
240
+ const promptArtifactPath = path.join(artifactRoot, "prompt.md");
241
+ const stdoutArtifactPath = path.join(artifactRoot, "stdout.txt");
242
+ const stderrArtifactPath = path.join(artifactRoot, "stderr.txt");
243
+ const promptPacketPath = path.join(artifactRoot, "prompt-packet.json");
244
+ await mkdir(artifactRoot, { recursive: true });
245
+ await Promise.all([
246
+ writeFile(promptArtifactPath, `Task: ${input.task.id}\nPhase: prepare-for-merge orchestrator merge\n\n${input.summary}\n`, "utf8"),
247
+ writeFile(stdoutArtifactPath, `${input.summary}\n`, "utf8"),
248
+ writeFile(stderrArtifactPath, "", "utf8"),
249
+ writeFile(promptPacketPath, JSON.stringify({
250
+ taskId: input.task.id,
251
+ taskKind: input.task.kind,
252
+ status: "merged_cleanly",
253
+ summary: input.summary
254
+ }, null, 2), "utf8")
255
+ ]);
256
+ return {
257
+ id: executionId,
258
+ taskId: input.task.id,
259
+ taskKind: input.task.kind,
260
+ taskStatus: "completed",
261
+ repoId: input.task.repoId,
262
+ repoSlug: input.task.repoSlug,
263
+ agentId: "orchestrator",
264
+ agentProvider: "unassigned",
265
+ command: {
266
+ executable: "git",
267
+ args: ["merge", "--no-ff", "--no-edit", `origin/${input.task.context.prepareForMerge?.baseBranch ?? "base"}`],
268
+ cwd: input.clonePath,
269
+ timeoutMs: 0
270
+ },
271
+ promptArtifactPath,
272
+ stdoutArtifactPath,
273
+ stderrArtifactPath,
274
+ promptPacketPath,
275
+ startedAt: input.timestamp,
276
+ endedAt: input.timestamp,
277
+ exitCode: 0
278
+ };
279
+ }
280
+ /**
281
+ * Run a single dispatch phase with full merge-prep lifecycle:
282
+ * 1. Pre-dispatch: refresh persistent worktrees; create merge-prep clones.
283
+ * 2. Dispatch: call dispatchPendingTasks with executionCwdByTaskId.
284
+ * 3. Post-dispatch: inspect/push/audit/refresh/cleanup for each merge-prep task.
285
+ *
286
+ * Returns updated tasks and executions; state store is kept in sync at each step.
287
+ */
288
+ export async function runDispatchWithMergePrepLifecycle(input) {
289
+ const { tasks: inputTasks, executions: inputExecutions, mergePrepService, logger, stateStore, logDir, failureNotifier, enabledRepos, codeRoot, githubGateway, backburnerGitToolService, commandRunner, gitGateway, workspaceRecoveryService } = input;
290
+ let dispatchWorkstreams = input.workstreams;
291
+ let hydratedInputTasks = inputTasks;
292
+ const skipHydration = rehydrateTaskMcpSkipContexts({
293
+ tasks: hydratedInputTasks,
294
+ workstreams: dispatchWorkstreams
295
+ });
296
+ if (skipHydration.changed) {
297
+ hydratedInputTasks = skipHydration.tasks;
298
+ await stateStore.writeTaskState(hydratedInputTasks);
299
+ }
300
+ // ── Phase 0: Recover workspaces of failed workspace-write tasks ─────────────
301
+ const seenRecoveryPaths = new Set();
302
+ const existingRecoveryRecords = await stateStore.loadWorkspaceRecoveryState();
303
+ const recoveryRecords = [...existingRecoveryRecords];
304
+ let recoveryStateChanged = false;
305
+ let taskStateChangedByRecovery = false;
306
+ const counts = {
307
+ recovered: 0,
308
+ skipped: 0,
309
+ rejected: 0,
310
+ failed: 0
311
+ };
312
+ const recoveryResultByTaskId = new Map();
313
+ for (const task of hydratedInputTasks) {
314
+ if (!workspaceRecoveryService.isRecoveryNeeded(task)) {
315
+ continue;
316
+ }
317
+ const localPath = workspaceRecoveryService.resolveTaskWorkspacePath(task);
318
+ if (localPath && seenRecoveryPaths.has(localPath)) {
319
+ continue;
320
+ }
321
+ if (localPath) {
322
+ seenRecoveryPaths.add(localPath);
323
+ }
324
+ const recoveryResult = await workspaceRecoveryService.recoverWorkspace({
325
+ task,
326
+ codeRoot,
327
+ gitGateway
328
+ });
329
+ counts[recoveryResult.outcome]++;
330
+ recoveryRecords.push(recoveryResult);
331
+ recoveryResultByTaskId.set(task.id, recoveryResult);
332
+ recoveryStateChanged = true;
333
+ if (recoveryResult.outcome === "recovered" || recoveryResult.outcome === "skipped") {
334
+ task.context.recoveryNeeded = false;
335
+ taskStateChangedByRecovery = true;
336
+ }
337
+ logger.info(JSON.stringify({
338
+ component: "workspace-recovery-phase",
339
+ taskId: task.id,
340
+ taskKind: task.kind,
341
+ localPath,
342
+ outcome: recoveryResult.outcome,
343
+ message: recoveryResult.reason
344
+ }));
345
+ }
346
+ if (taskStateChangedByRecovery) {
347
+ await stateStore.writeTaskState(hydratedInputTasks);
348
+ }
349
+ if (recoveryStateChanged) {
350
+ await stateStore.writeWorkspaceRecoveryState(recoveryRecords);
351
+ }
352
+ logger.info(`Workspace recovery phase: recovered=${counts.recovered}, skipped=${counts.skipped}, rejected=${counts.rejected}, failed=${counts.failed}`);
353
+ // ── Phase 1: Pre-dispatch worktree refresh ──────────────────────────────────
354
+ // Build map of localPath → expectedBranch for all pending task persistent paths.
355
+ // We exclude tasks that just failed or were rejected during recovery.
356
+ const persistentPathsToRefresh = new Map();
357
+ for (const task of hydratedInputTasks) {
358
+ if (task.status !== "pending" || !task.relevant)
359
+ continue;
360
+ const recoveryResult = recoveryResultByTaskId.get(task.id);
361
+ if (recoveryResult && (recoveryResult.outcome === "failed" || recoveryResult.outcome === "rejected")) {
362
+ continue;
363
+ }
364
+ if (task.kind !== "prepare_pr_for_merge" && task.context.localRepoPath && task.context.branchName) {
365
+ persistentPathsToRefresh.set(task.context.localRepoPath, task.context.branchName);
366
+ }
367
+ const preflight = task.context.prepareForMerge;
368
+ if (task.kind === "prepare_pr_for_merge" && preflight?.persistentWorktreePath) {
369
+ persistentPathsToRefresh.set(preflight.persistentWorktreePath, preflight.headBranch);
370
+ }
371
+ }
372
+ const blockedPaths = new Set();
373
+ for (const [localPath, expectedBranch] of persistentPathsToRefresh) {
374
+ const refreshResult = await mergePrepService.refreshPersistentWorktreeBeforeDispatch({
375
+ localPath,
376
+ expectedBranch
377
+ });
378
+ logger.info(`Pre-dispatch worktree refresh for ${localPath}: ${refreshResult.status} — ${refreshResult.message}`);
379
+ if (refreshResult.status.startsWith("blocked")) {
380
+ blockedPaths.add(localPath);
381
+ }
382
+ }
383
+ // Mark tasks with blocked non-merge-prep persistent paths OR failed recovery as pre-dispatch failures.
384
+ // Merge-prep tasks are NOT blocked by their persistent worktree refresh failure —
385
+ // they always create a fresh clone. The persistent worktree refresh for merge-prep
386
+ // is for the benefit of future review/discussion agents, not for the merge-prep task itself.
387
+ let currentTasks = [...hydratedInputTasks];
388
+ const executionsAccumulated = [...inputExecutions];
389
+ for (let i = 0; i < currentTasks.length; i++) {
390
+ const task = currentTasks[i];
391
+ if (!task || task.status !== "pending")
392
+ continue;
393
+ let blockerReason;
394
+ const recoveryResult = recoveryResultByTaskId.get(task.id);
395
+ if (recoveryResult && (recoveryResult.outcome === "failed" || recoveryResult.outcome === "rejected")) {
396
+ blockerReason = `Pre-dispatch workspace recovery ${recoveryResult.outcome}: ${recoveryResult.reason}`;
397
+ }
398
+ else if (task.kind !== "prepare_pr_for_merge" &&
399
+ task.context.localRepoPath &&
400
+ blockedPaths.has(task.context.localRepoPath)) {
401
+ blockerReason = `Pre-dispatch worktree refresh blocked: persistent worktree at ${task.context.localRepoPath} could not be safely fast-forwarded.`;
402
+ }
403
+ if (blockerReason) {
404
+ const { updatedTask, execution } = await buildSinglePreDispatchFailureExecution({
405
+ task,
406
+ reason: blockerReason,
407
+ logDir,
408
+ now: () => new Date().toISOString()
409
+ });
410
+ currentTasks[i] = updatedTask; // update in-place
411
+ executionsAccumulated.push(execution); // accumulate without losing prior failures
412
+ await stateStore.writeExecutionState(executionsAccumulated);
413
+ await stateStore.writeTaskState(currentTasks);
414
+ const failureCommentId = await safelyNotifyFailure(failureNotifier, updatedTask, execution);
415
+ if (failureCommentId !== undefined) {
416
+ currentTasks[i] = { ...currentTasks[i], lastFailureCommentId: failureCommentId };
417
+ await stateStore.writeTaskState(currentTasks);
418
+ }
419
+ }
420
+ }
421
+ // ── Phase 2: Create dedicated clones for pending merge-prep tasks ────────────
422
+ // Runtime-only map: taskId → clone path. NEVER written to durable task state.
423
+ const mergePrepClonePathByTaskId = new Map();
424
+ for (let i = 0; i < currentTasks.length; i++) {
425
+ const task = currentTasks[i];
426
+ if (!task || task.status !== "pending" || task.kind !== "prepare_pr_for_merge")
427
+ continue;
428
+ const preflight = task.context.prepareForMerge;
429
+ if (!preflight)
430
+ continue;
431
+ const repo = enabledRepos.find(repo => repoSlug(repo) === task.repoSlug);
432
+ if (!repo) {
433
+ const reason = `No repo config found for ${task.repoSlug} — cannot create merge-prep clone.`;
434
+ const { updatedTask, execution } = await buildSinglePreDispatchFailureExecution({
435
+ task, reason, logDir, now: () => new Date().toISOString()
436
+ });
437
+ currentTasks[i] = updatedTask;
438
+ executionsAccumulated.push(execution);
439
+ await stateStore.writeExecutionState(executionsAccumulated);
440
+ await stateStore.writeTaskState(currentTasks);
441
+ const failureCommentId = await safelyNotifyFailure(failureNotifier, updatedTask, execution);
442
+ if (failureCommentId !== undefined) {
443
+ currentTasks[i] = { ...currentTasks[i], lastFailureCommentId: failureCommentId };
444
+ await stateStore.writeTaskState(currentTasks);
445
+ }
446
+ continue;
447
+ }
448
+ const sshUrl = `git@github.com:${repo.owner}/${repo.name}.git`;
449
+ let setupClonePath;
450
+ try {
451
+ const cloneResult = await mergePrepService.prepareMergePrepClone({
452
+ codeRoot,
453
+ repoSshUrl: sshUrl,
454
+ owner: repo.owner,
455
+ repoName: repo.name,
456
+ prNumber: preflight.prNumber,
457
+ headBranch: preflight.headBranch,
458
+ expectedHeadSha: preflight.headSha
459
+ });
460
+ setupClonePath = cloneResult.clonePath;
461
+ logger.info(`Created merge-prep clone for task ${task.id} at ${cloneResult.clonePath}`);
462
+ const mergeStart = await startPrepareForMerge({
463
+ commandRunner,
464
+ clonePath: cloneResult.clonePath,
465
+ headBranch: preflight.headBranch,
466
+ expectedHeadSha: preflight.headSha,
467
+ baseBranch: preflight.baseBranch
468
+ });
469
+ mergePrepClonePathByTaskId.set(task.id, cloneResult.clonePath);
470
+ if (mergeStart.status === "merge_conflict") {
471
+ if (!task.workstreamId) {
472
+ throw new Error(`Prepare-for-merge task ${task.id} has no workstream ID for Backburner Git session context.`);
473
+ }
474
+ backburnerGitToolService.registerPrepareForMergeWorkstream({
475
+ backburnerGitSessionId: task.id,
476
+ workstreamId: task.workstreamId,
477
+ repoRoot: cloneResult.clonePath,
478
+ expectedHeadSha: preflight.headSha,
479
+ baseRef: `origin/${preflight.baseBranch}`,
480
+ currentBranch: preflight.headBranch,
481
+ state: "merge_conflict"
482
+ });
483
+ logger.info(`Base merge for task ${task.id} stopped on conflicts; registered Backburner Git session ${task.id} for workstream ${task.workstreamId}`);
484
+ }
485
+ else {
486
+ const completedAt = new Date().toISOString();
487
+ const execution = await buildOrchestratorMergePrepSuccessExecution({
488
+ task,
489
+ clonePath: cloneResult.clonePath,
490
+ logDir,
491
+ timestamp: completedAt,
492
+ summary: mergeStart.message
493
+ });
494
+ currentTasks[i] = {
495
+ ...task,
496
+ status: "completed",
497
+ lastExecutionId: execution.id,
498
+ updatedAt: completedAt
499
+ };
500
+ executionsAccumulated.push(execution);
501
+ await stateStore.writeExecutionState(executionsAccumulated);
502
+ await stateStore.writeTaskState(currentTasks);
503
+ logger.info(`Base merge for task ${task.id} completed without conflicts; skipping agent dispatch.`);
504
+ }
505
+ }
506
+ catch (err) {
507
+ if (setupClonePath && !mergePrepClonePathByTaskId.has(task.id)) {
508
+ await mergePrepService.cleanupMergePrepClone({ clonePath: setupClonePath });
509
+ }
510
+ const reason = err instanceof Error ? err.message : String(err);
511
+ const { updatedTask, execution } = await buildSinglePreDispatchFailureExecution({
512
+ task, reason, logDir, now: () => new Date().toISOString()
513
+ });
514
+ currentTasks[i] = updatedTask;
515
+ executionsAccumulated.push(execution);
516
+ await stateStore.writeExecutionState(executionsAccumulated);
517
+ await stateStore.writeTaskState(currentTasks);
518
+ const failureCommentId = await safelyNotifyFailure(failureNotifier, updatedTask, execution);
519
+ if (failureCommentId !== undefined) {
520
+ currentTasks[i] = { ...currentTasks[i], lastFailureCommentId: failureCommentId };
521
+ await stateStore.writeTaskState(currentTasks);
522
+ }
523
+ }
524
+ }
525
+ // ── Phase 2.5: MCP dispatch gate ─────────────────────────────────────────────
526
+ const gateResult = evaluateMcpDispatchGate({
527
+ tasks: currentTasks,
528
+ worktrees: input.worktrees,
529
+ workstreams: dispatchWorkstreams,
530
+ capabilityValidation: input.capabilityValidation,
531
+ mcpStatuses: input.mcpStatuses,
532
+ now: new Date().toISOString()
533
+ });
534
+ if (gateResult.workstreamsChanged) {
535
+ dispatchWorkstreams = gateResult.workstreams;
536
+ await stateStore.writeWorkstreamState(dispatchWorkstreams);
537
+ }
538
+ dispatchWorkstreams = await surfaceUnsurfacedMcpBlockers({
539
+ workstreams: dispatchWorkstreams,
540
+ tasks: currentTasks,
541
+ executionsByTaskId: new Map(),
542
+ enabledRepos,
543
+ githubGateway,
544
+ stateStore,
545
+ logger,
546
+ now: () => new Date().toISOString()
547
+ });
548
+ const resumeResult = resumeMidTaskBlockedTasks({
549
+ tasks: currentTasks,
550
+ clearedBlockers: gateResult.clearedBlockers,
551
+ skipExclusionsAppliedThisCycle: [],
552
+ now: new Date().toISOString()
553
+ });
554
+ if (resumeResult.resumedTaskIds.size > 0) {
555
+ currentTasks = resumeResult.tasks;
556
+ await stateStore.writeTaskState(currentTasks);
557
+ }
558
+ // ── Phase 3: Dispatch ────────────────────────────────────────────────────────
559
+ const dispatchResult = await dispatchPendingTasks({
560
+ tasks: currentTasks,
561
+ agents: input.agents,
562
+ existingExecutions: executionsAccumulated,
563
+ stateStore,
564
+ logDir,
565
+ agentRunner: input.agentRunner,
566
+ failureNotifier,
567
+ reviewPublisher: input.reviewPublisher,
568
+ planBreakdownPublisher: input.planBreakdownPublisher,
569
+ waveAssessmentPublisher: input.waveAssessmentPublisher,
570
+ lifecycleNotifier: input.lifecycleNotifier,
571
+ jobMemoryRuntime: input.jobMemoryRuntime,
572
+ logger,
573
+ promptsConfig: input.promptsConfig,
574
+ managementRoot: input.managementRoot,
575
+ ...(input.capabilitiesByProvider ? { capabilitiesByProvider: input.capabilitiesByProvider } : {}),
576
+ ...(input.providerBrokerToolAllowLists ? { providerBrokerToolAllowLists: input.providerBrokerToolAllowLists } : {}),
577
+ ...(input.brokerToolScope ? { brokerToolScope: input.brokerToolScope } : {}),
578
+ mcpBlockedTaskIds: gateResult.blockedTaskIds,
579
+ deferFailureNotifications: true,
580
+ executionCwdByTaskId: mergePrepClonePathByTaskId,
581
+ ...(input.agentPool ? { agentPool: input.agentPool } : {})
582
+ });
583
+ // ── Phase 4: Post-dispatch merge-prep lifecycle ──────────────────────────────
584
+ let postTasks = dispatchResult.tasks;
585
+ const postExecutions = [...dispatchResult.executions];
586
+ const midTaskMcpResult = await classifyAndSurfaceMidTaskMcpFailures({
587
+ inputTasks: currentTasks,
588
+ tasks: postTasks,
589
+ executions: postExecutions,
590
+ worktrees: input.worktrees,
591
+ workstreams: dispatchWorkstreams,
592
+ mcpStatuses: input.mcpStatuses,
593
+ declaredMcps: input.declaredMcps ?? [],
594
+ capabilitiesConfig: input.capabilitiesConfig,
595
+ refreshMcpStatuses: input.refreshMcpStatuses,
596
+ enabledRepos,
597
+ githubGateway,
598
+ stateStore,
599
+ logger
600
+ });
601
+ postTasks = midTaskMcpResult.tasks;
602
+ dispatchWorkstreams = midTaskMcpResult.workstreams;
603
+ postTasks = await notifyOrdinaryDeferredFailures({
604
+ inputTasks: currentTasks,
605
+ tasks: postTasks,
606
+ executions: postExecutions,
607
+ failureNotifier,
608
+ stateStore,
609
+ mcpBlockedTaskIds: midTaskMcpResult.mcpBlockedTaskIds
610
+ });
611
+ const mergePrepTasksToProcess = postTasks.filter(task => task.kind === "prepare_pr_for_merge" &&
612
+ (task.status === "completed" || task.status === "failed") &&
613
+ mergePrepClonePathByTaskId.has(task.id));
614
+ for (const task of mergePrepTasksToProcess) {
615
+ const clonePath = mergePrepClonePathByTaskId.get(task.id);
616
+ try {
617
+ const preflight = task.context.prepareForMerge;
618
+ if (!preflight)
619
+ continue;
620
+ const repo = enabledRepos.find(repo => repoSlug(repo) === task.repoSlug);
621
+ const auditBase = {
622
+ prNumber: preflight.prNumber,
623
+ headBranch: preflight.headBranch,
624
+ baseBranch: preflight.baseBranch,
625
+ originalHeadSha: preflight.headSha
626
+ };
627
+ let audit;
628
+ let postDispatchFailureReason;
629
+ if (task.status === "completed") {
630
+ const inspectResult = await mergePrepService.inspectMergePrepCloneResult({
631
+ clonePath,
632
+ expectedBranch: preflight.headBranch,
633
+ originalHeadSha: preflight.headSha
634
+ });
635
+ logger.info(`Merge-prep inspect for task ${task.id}: ${inspectResult.status} — ${inspectResult.message}`);
636
+ if (inspectResult.status === "ready_to_push") {
637
+ // Write audit log entry into the clone and commit it so it travels with the push.
638
+ // This prevents direct writes to the persistent worktree (which would leave it dirty).
639
+ if (preflight.persistentLogDocumentPath && preflight.persistentWorktreePath) {
640
+ const prePushAudit = {
641
+ prNumber: preflight.prNumber,
642
+ headBranch: preflight.headBranch,
643
+ baseBranch: preflight.baseBranch,
644
+ originalHeadSha: preflight.headSha,
645
+ finalLocalSha: inspectResult.localSha,
646
+ commitLog: inspectResult.commitLog,
647
+ diffStats: inspectResult.diffStats,
648
+ pushed: true, // optimistic: only reached in ready_to_push path
649
+ persistentRefreshStatus: "skipped" // refresh happens after push
650
+ };
651
+ await mergePrepService.commitAuditInClone({
652
+ clonePath,
653
+ persistentWorktreePath: preflight.persistentWorktreePath,
654
+ persistentLogDocumentPath: preflight.persistentLogDocumentPath,
655
+ audit: prePushAudit
656
+ });
657
+ }
658
+ const pushResult = await mergePrepService.publishMergePrepResult({
659
+ clonePath,
660
+ headBranch: preflight.headBranch,
661
+ originalHeadSha: preflight.headSha
662
+ });
663
+ logger.info(`Merge-prep push for task ${task.id}: ${pushResult.status} — ${pushResult.message}`);
664
+ let persistentRefreshStatus = "skipped";
665
+ let persistentRefreshMessage;
666
+ // Refresh persistent worktree after successful push.
667
+ // Refresh status is always included in the audit — even if push succeeded but refresh failed.
668
+ if (pushResult.status === "pushed" && preflight.persistentWorktreePath) {
669
+ const refreshResult = await mergePrepService.refreshPersistentWorktreeAfterMergePrep({
670
+ persistentWorktreePath: preflight.persistentWorktreePath,
671
+ headBranch: preflight.headBranch
672
+ });
673
+ persistentRefreshStatus = refreshResult.status;
674
+ persistentRefreshMessage = refreshResult.message;
675
+ if (refreshResult.status === "succeeded") {
676
+ logger.info(`Refreshed persistent worktree at ${preflight.persistentWorktreePath}`);
677
+ }
678
+ else {
679
+ logger.warn(`Failed to refresh persistent worktree at ${preflight.persistentWorktreePath}: ${refreshResult.message}`);
680
+ }
681
+ }
682
+ audit = {
683
+ ...auditBase,
684
+ finalLocalSha: inspectResult.localSha,
685
+ commitLog: inspectResult.commitLog,
686
+ diffStats: inspectResult.diffStats,
687
+ pushed: pushResult.status === "pushed",
688
+ pushedSha: pushResult.pushedSha,
689
+ blockerReason: pushResult.status !== "pushed" ? pushResult.message : undefined,
690
+ safeNextAction: pushResult.status === "lease_failed"
691
+ ? "The remote branch was updated concurrently. Re-trigger prepare-for-merge."
692
+ : pushResult.status === "push_failed"
693
+ ? "Check network connectivity and remote repository access, then retry."
694
+ : undefined,
695
+ persistentRefreshStatus,
696
+ persistentRefreshMessage
697
+ };
698
+ if (pushResult.status !== "pushed") {
699
+ postDispatchFailureReason = `Merge-prep push failed: ${pushResult.message}`;
700
+ }
701
+ }
702
+ else {
703
+ // Inspection failed — do not push
704
+ postDispatchFailureReason = `Merge-prep clone inspection failed: ${inspectResult.message}`;
705
+ audit = {
706
+ ...auditBase,
707
+ finalLocalSha: inspectResult.localSha,
708
+ pushed: false,
709
+ blockerReason: inspectResult.message,
710
+ safeNextAction: "Review the agent execution log and retry or manually resolve the state.",
711
+ persistentRefreshStatus: "skipped"
712
+ };
713
+ }
714
+ }
715
+ else {
716
+ // Task failed during agent execution
717
+ audit = {
718
+ ...auditBase,
719
+ pushed: false,
720
+ blockerReason: "Task failed during agent execution. Check execution log for details.",
721
+ safeNextAction: "Review agent execution log and retry.",
722
+ persistentRefreshStatus: "skipped"
723
+ };
724
+ }
725
+ // If the agent exited successfully but orchestrator-owned inspection/push
726
+ // failed, the task must be failed in durable state. The local agent result is
727
+ // not the success boundary for prepare_pr_for_merge; successful publication is.
728
+ if (task.status === "completed" && postDispatchFailureReason) {
729
+ const { updatedTask, execution } = await buildSinglePostDispatchHandoffFailureExecution({
730
+ task,
731
+ reason: postDispatchFailureReason,
732
+ logDir,
733
+ now: () => new Date().toISOString()
734
+ });
735
+ const postTaskIdx = postTasks.findIndex(t => t.id === task.id);
736
+ if (postTaskIdx !== -1) {
737
+ postTasks[postTaskIdx] = updatedTask;
738
+ }
739
+ postExecutions.push(execution);
740
+ await stateStore.writeExecutionState(postExecutions);
741
+ await stateStore.writeTaskState(postTasks);
742
+ const failureCommentId = await safelyNotifyFailure(failureNotifier, updatedTask, execution);
743
+ if (failureCommentId !== undefined && postTaskIdx !== -1) {
744
+ postTasks[postTaskIdx] = { ...postTasks[postTaskIdx], lastFailureCommentId: failureCommentId };
745
+ await stateStore.writeTaskState(postTasks);
746
+ }
747
+ }
748
+ // Orchestrator posts the single final audit comment to the GitHub PR
749
+ if (repo) {
750
+ try {
751
+ const comment = buildMergePrepPrComment(audit);
752
+ await githubGateway.postIssueComment({
753
+ owner: repo.owner,
754
+ name: repo.name,
755
+ issueNumber: preflight.prNumber,
756
+ body: comment
757
+ });
758
+ }
759
+ catch (err) {
760
+ logger.warn(`Failed to post merge-prep audit comment for PR #${preflight.prNumber}: ${err instanceof Error ? err.message : String(err)}`);
761
+ }
762
+ }
763
+ }
764
+ finally {
765
+ backburnerGitToolService.unregisterBackburnerGitSession(task.id);
766
+ // Always clean up the clone
767
+ await mergePrepService.cleanupMergePrepClone({ clonePath });
768
+ logger.info(`Cleaned up merge-prep clone at ${clonePath}`);
769
+ }
770
+ }
771
+ return { tasks: postTasks, executions: postExecutions, workstreams: dispatchWorkstreams };
772
+ }