@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.
- package/LICENSE +21 -0
- package/README.md +329 -0
- package/dist/src/agents/antigravity-launch.js +162 -0
- package/dist/src/agents/antigravity.js +251 -0
- package/dist/src/agents/claude-launch.js +117 -0
- package/dist/src/agents/claude.js +271 -0
- package/dist/src/agents/codex-launch.js +82 -0
- package/dist/src/agents/codex.js +261 -0
- package/dist/src/agents/composite-runner.js +13 -0
- package/dist/src/agents/extraction.js +442 -0
- package/dist/src/agents/failure-feedback.js +132 -0
- package/dist/src/agents/gemini-launch.js +96 -0
- package/dist/src/agents/gemini.js +392 -0
- package/dist/src/agents/job-agent-assignments.js +180 -0
- package/dist/src/agents/lifecycle-feedback.js +147 -0
- package/dist/src/agents/limit-detector.js +83 -0
- package/dist/src/agents/opencode-launch.js +138 -0
- package/dist/src/agents/opencode.js +156 -0
- package/dist/src/agents/plan-breakdown-feedback.js +97 -0
- package/dist/src/agents/pool.js +168 -0
- package/dist/src/agents/prompt.js +462 -0
- package/dist/src/agents/provider-sessions.js +105 -0
- package/dist/src/agents/review-feedback.js +255 -0
- package/dist/src/agents/selection.js +113 -0
- package/dist/src/agents/service.js +1087 -0
- package/dist/src/agents/types.js +1 -0
- package/dist/src/agents/wave-assessment-feedback.js +69 -0
- package/dist/src/attention/derive.js +337 -0
- package/dist/src/capabilities/index.js +2 -0
- package/dist/src/capabilities/projection.js +183 -0
- package/dist/src/capabilities/types.js +1 -0
- package/dist/src/capabilities/validator.js +87 -0
- package/dist/src/cli/commands/broker-smoke.js +96 -0
- package/dist/src/cli/commands/broker.js +56 -0
- package/dist/src/cli/commands/init.js +4 -0
- package/dist/src/cli/commands/journal.js +43 -0
- package/dist/src/cli/commands/run-loop.js +56 -0
- package/dist/src/cli/commands/run.js +1241 -0
- package/dist/src/cli/commands/tui.js +187 -0
- package/dist/src/cli/dispatcher.js +51 -0
- package/dist/src/cli/errors.js +2 -0
- package/dist/src/cli/init.js +92 -0
- package/dist/src/cli/options.js +86 -0
- package/dist/src/cli/output.js +231 -0
- package/dist/src/cli/run-journal.js +342 -0
- package/dist/src/cli/run-types.js +1 -0
- package/dist/src/cli/run.js +32 -0
- package/dist/src/cli/runtime/dispatch-lifecycle.js +772 -0
- package/dist/src/cli/runtime/provider-runtime-types.js +1 -0
- package/dist/src/cli/runtime/provider-runtime.js +152 -0
- package/dist/src/cli/runtime/provider-tools.js +73 -0
- package/dist/src/cli/runtime/providers/antigravity.js +26 -0
- package/dist/src/cli/runtime/providers/claude.js +20 -0
- package/dist/src/cli/runtime/providers/codex.js +20 -0
- package/dist/src/cli/runtime/providers/gemini.js +20 -0
- package/dist/src/cli/runtime/providers/opencode.js +20 -0
- package/dist/src/cli/runtime/run-context.js +131 -0
- package/dist/src/cli/tui/index.js +3 -0
- package/dist/src/cli/tui/screen.js +388 -0
- package/dist/src/cli/tui/tracker.js +558 -0
- package/dist/src/cli/tui/types.js +1 -0
- package/dist/src/cli/tui/views.js +1006 -0
- package/dist/src/config/loader.js +136 -0
- package/dist/src/config/schemas.js +403 -0
- package/dist/src/config/types.js +1 -0
- package/dist/src/context/types.js +331 -0
- package/dist/src/domain/entities.js +1 -0
- package/dist/src/git/backburner-git-tool-service.js +680 -0
- package/dist/src/git/gitops-lifecycle.js +56 -0
- package/dist/src/git/merge-prep-workspace.js +357 -0
- package/dist/src/git/types.js +484 -0
- package/dist/src/github/broker-model-content.js +215 -0
- package/dist/src/github/broker-server.js +237 -0
- package/dist/src/github/broker.js +638 -0
- package/dist/src/github/composed.js +53 -0
- package/dist/src/github/gateway.js +478 -0
- package/dist/src/github/normalize.js +222 -0
- package/dist/src/github/pr-classification.js +73 -0
- package/dist/src/github/scoped-broker.js +35 -0
- package/dist/src/github/security.js +126 -0
- package/dist/src/github/service.js +332 -0
- package/dist/src/github/types.js +1 -0
- package/dist/src/github/utils.js +16 -0
- package/dist/src/journal/index.js +4 -0
- package/dist/src/journal/reader.js +62 -0
- package/dist/src/journal/renderer.js +50 -0
- package/dist/src/journal/service.js +72 -0
- package/dist/src/journal/types.js +1 -0
- package/dist/src/journal/writer.js +19 -0
- package/dist/src/mcps/bridge.js +166 -0
- package/dist/src/mcps/index.js +5 -0
- package/dist/src/mcps/reconcile.js +113 -0
- package/dist/src/mcps/smithery-client.js +186 -0
- package/dist/src/mcps/smithery.js +164 -0
- package/dist/src/mcps/types.js +1 -0
- package/dist/src/memory/json-store.js +71 -0
- package/dist/src/memory/prompt.js +16 -0
- package/dist/src/memory/scope.js +31 -0
- package/dist/src/memory/store.js +1 -0
- package/dist/src/memory/tool-service.js +142 -0
- package/dist/src/memory/types.js +12 -0
- package/dist/src/onboarding/config-writer.js +96 -0
- package/dist/src/onboarding/engine.js +86 -0
- package/dist/src/onboarding/prompt.js +102 -0
- package/dist/src/onboarding/services/provider-discovery.js +58 -0
- package/dist/src/onboarding/services/repo-discovery.js +246 -0
- package/dist/src/onboarding/steps/check-github-auth.js +32 -0
- package/dist/src/onboarding/steps/check-requirements.js +34 -0
- package/dist/src/onboarding/steps/configure-models.js +105 -0
- package/dist/src/onboarding/steps/configure-paths.js +33 -0
- package/dist/src/onboarding/steps/discover-providers.js +38 -0
- package/dist/src/onboarding/steps/discover-repos.js +39 -0
- package/dist/src/onboarding/steps/generate-config.js +106 -0
- package/dist/src/onboarding/steps/readiness-validation.js +158 -0
- package/dist/src/onboarding/steps/select-repos.js +76 -0
- package/dist/src/onboarding/steps/show-summary.js +97 -0
- package/dist/src/onboarding/steps/welcome.js +27 -0
- package/dist/src/onboarding/types.js +1 -0
- package/dist/src/onboarding/validators/git-access.js +63 -0
- package/dist/src/onboarding/validators/github-auth.js +85 -0
- package/dist/src/onboarding/validators/tool-checker.js +153 -0
- package/dist/src/onboarding/validators/workspace.js +83 -0
- package/dist/src/prompts/resolver.js +106 -0
- package/dist/src/retrospectives/candidates.js +224 -0
- package/dist/src/retrospectives/derive.js +169 -0
- package/dist/src/retrospectives/ingest.js +321 -0
- package/dist/src/retrospectives/management-prs.js +380 -0
- package/dist/src/retrospectives/proposals.js +199 -0
- package/dist/src/retrospectives/scopes.js +272 -0
- package/dist/src/retrospectives/scoring.js +171 -0
- package/dist/src/retrospectives/types.js +1 -0
- package/dist/src/state/loader.js +473 -0
- package/dist/src/state/types.js +15 -0
- package/dist/src/tasks/derivation/builders.js +70 -0
- package/dist/src/tasks/derivation/handlers/comment-response.js +169 -0
- package/dist/src/tasks/derivation/handlers/implement-plan.js +103 -0
- package/dist/src/tasks/derivation/handlers/index.js +54 -0
- package/dist/src/tasks/derivation/handlers/packet-pr-control.js +92 -0
- package/dist/src/tasks/derivation/handlers/packet-worktree.js +29 -0
- package/dist/src/tasks/derivation/handlers/plan-breakdown.js +45 -0
- package/dist/src/tasks/derivation/handlers/prepare-for-merge.js +91 -0
- package/dist/src/tasks/derivation/handlers/product-discovery.js +36 -0
- package/dist/src/tasks/derivation/handlers/retry-failed-task.js +125 -0
- package/dist/src/tasks/derivation/handlers/review-pr.js +28 -0
- package/dist/src/tasks/derivation/handlers/shared.js +24 -0
- package/dist/src/tasks/derivation/handlers/sync-parent-branch.js +100 -0
- package/dist/src/tasks/derivation/handlers/wave-assessment.js +223 -0
- package/dist/src/tasks/derivation/handlers/write-plan.js +101 -0
- package/dist/src/tasks/derivation/handlers/write-product-spec.js +49 -0
- package/dist/src/tasks/derivation/runner.js +18 -0
- package/dist/src/tasks/derivation/types.js +1 -0
- package/dist/src/tasks/derive.js +810 -0
- package/dist/src/tasks/mcp-skip.js +10 -0
- package/dist/src/tasks/retry.js +10 -0
- package/dist/src/tasks/task.js +184 -0
- package/dist/src/tasks/types.js +1 -0
- package/dist/src/utils/command-runner.js +192 -0
- package/dist/src/utils/json.js +38 -0
- package/dist/src/utils/logger.js +43 -0
- package/dist/src/utils/paths.js +38 -0
- package/dist/src/utils/slug.js +10 -0
- package/dist/src/workflows/registry.js +174 -0
- package/dist/src/workflows/triage.js +90 -0
- package/dist/src/workflows/workstream-machine/derive-events.js +121 -0
- package/dist/src/workflows/workstream-machine/evaluate.js +24 -0
- package/dist/src/workflows/workstream-machine/events.js +1 -0
- package/dist/src/workflows/workstream-machine/machine.js +355 -0
- package/dist/src/workflows/workstream-machine/selectors.js +19 -0
- package/dist/src/workflows/workstream-machine/types.js +1 -0
- package/dist/src/workstreams/branch-sync.js +87 -0
- package/dist/src/workstreams/derive.js +629 -0
- package/dist/src/workstreams/mcp-blockers.js +420 -0
- package/dist/src/workstreams/packet-candidates.js +42 -0
- package/dist/src/workstreams/packet-lifecycle-feedback.js +60 -0
- package/dist/src/workstreams/packet-lifecycle-report.js +199 -0
- package/dist/src/workstreams/packet-plan.js +597 -0
- package/dist/src/workstreams/packet-projections.js +190 -0
- package/dist/src/workstreams/parent-branch-sync-action.js +312 -0
- package/dist/src/workstreams/parent-branch-sync.js +148 -0
- package/dist/src/workstreams/phases.js +4 -0
- package/dist/src/workstreams/plan-breakdown-generator/export.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/index.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/inspect.js +50 -0
- package/dist/src/workstreams/plan-breakdown-generator/patch.js +139 -0
- package/dist/src/workstreams/plan-breakdown-generator/session.js +19 -0
- package/dist/src/workstreams/plan-breakdown-generator/store.js +27 -0
- package/dist/src/workstreams/plan-breakdown-generator/tools.js +407 -0
- package/dist/src/workstreams/plan-breakdown-generator/types.js +6 -0
- package/dist/src/workstreams/plan-breakdown-generator/validate.js +212 -0
- package/dist/src/workstreams/plan-breakdown-schema.js +175 -0
- package/dist/src/workstreams/utils.js +15 -0
- package/dist/src/workstreams/wave-assessment/checkpoint.js +172 -0
- package/dist/src/workstreams/wave-assessment/index.js +3 -0
- package/dist/src/workstreams/wave-assessment/session.js +84 -0
- package/dist/src/workstreams/wave-assessment/store.js +51 -0
- package/dist/src/workstreams/wave-assessment/tools.js +419 -0
- package/dist/src/workstreams/wave-assessment/types.js +1 -0
- package/dist/src/workstreams/wave-assessment/validate.js +78 -0
- package/dist/src/worktrees/service.js +839 -0
- package/dist/src/worktrees/workspace-ownership.js +34 -0
- package/dist/src/worktrees/workspace-recovery.js +177 -0
- 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
|
+
}
|