feat(FN-4068): add branch conflict detection and recovery for stale worktre

Implements branch conflict detection and recovery across the Fusion engine, CLI, and dashboard — surfacing git worktree conflicts when tasks conflict with unrelated branch state, and providing a recovery workflow to resolve them. The executor and worktree pool now integrate typed branch conflict che

Fusion-Task-Id: FN-4068
This commit is contained in:
Fusion
2026-05-12 17:10:26 -07:00
committed by gsxdsm
parent ceccc382b1
commit bddcc3a931
28 changed files with 1341 additions and 209 deletions

View File

@@ -1,5 +1,7 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -16,6 +18,7 @@ import {
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const execAsync = promisify(exec);
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
@@ -161,6 +164,70 @@ async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function getCanonicalTaskBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
function formatRecoveryCandidate(candidate: BranchRecoveryCandidate): string[] {
const lines = [
` • ${candidate.branchName}${candidate.isCanonical ? " (canonical)" : ""}`,
` tip: ${candidate.tipSha}`,
` worktree: ${candidate.worktreePath ?? "(not attached to a worktree)"}`,
];
if (candidate.strandedCommits.length === 0) {
lines.push(" stranded commits: none");
} else {
lines.push(" stranded commits:");
for (const commit of candidate.strandedCommits) {
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject}`);
}
}
return lines;
}
async function runGit(projectPath: string, command: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd: projectPath, encoding: "utf-8" });
return stdout.trim();
}
async function resolveBranchRecoveryCandidates(id: string, projectName?: string): Promise<{
store: TaskStore;
projectPath: string;
task: Awaited<ReturnType<TaskStore["getTask"]>>;
canonicalBranch: string;
candidates: BranchRecoveryCandidate[];
}> {
const context = await getCommandContext(projectName);
const task = await context.store.getTask(id);
const canonicalBranch = getCanonicalTaskBranch(task.id);
const candidates = await listBranchRecoveryCandidates({
repoDir: context.projectPath,
branchName: canonicalBranch,
startPoint: task.executionStartBranch ?? undefined,
});
return {
store: context.store,
projectPath: context.projectPath,
task,
canonicalBranch,
candidates,
};
}
async function resolveRecoveryCandidateOrExit(id: string, branch: string, projectName?: string) {
const resolved = await resolveBranchRecoveryCandidates(id, projectName);
const candidate = resolved.candidates.find((entry) => entry.branchName === branch);
if (!candidate) {
console.error(`Error: Branch recovery candidate not found for ${id}: ${branch}`);
process.exit(1);
}
return { ...resolved, candidate };
}
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore();
await central.init();
@@ -830,6 +897,95 @@ export async function runTaskRetry(id: string, projectName?: string) {
console.log();
}
export async function runTaskBranchRecovery(
id: string,
options: { reclaim?: string; discard?: string; yes?: boolean } = {},
projectName?: string,
) {
if (options.reclaim && options.discard) {
console.error("Error: --reclaim and --discard are mutually exclusive");
process.exit(1);
}
if (options.reclaim) {
const { store, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.reclaim, projectName);
await store.updateTask(task.id, {
branch: candidate.branchName,
worktree: candidate.worktreePath,
status: null,
error: null,
});
await store.logEntry(
task.id,
`Branch recovery: reclaimed ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Reclaimed ${candidate.branchName} for ${task.id}`);
console.log(` Tip: ${candidate.tipSha}`);
console.log(` Worktree: ${candidate.worktreePath ?? "(none)"}`);
console.log();
return;
}
if (options.discard) {
if (!options.yes) {
console.error("Error: Refusing to discard branch recovery state without --yes");
process.exit(1);
}
const { store, projectPath, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.discard, projectName);
if (candidate.worktreePath) {
await runGit(projectPath, `git worktree remove ${quoteShellArg(candidate.worktreePath)} --force`);
}
await runGit(projectPath, `git branch -D ${quoteShellArg(candidate.branchName)}`);
const patch: Record<string, unknown> = { status: null, error: null };
if (task.branch === candidate.branchName) {
patch.branch = null;
}
if (task.worktree && task.worktree === candidate.worktreePath) {
patch.worktree = null;
}
await store.updateTask(task.id, patch);
await store.logEntry(
task.id,
`Branch recovery: discarded ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Discarded ${candidate.branchName} for ${task.id}`);
if (candidate.worktreePath) {
console.log(` Removed worktree: ${candidate.worktreePath}`);
}
console.log(` Deleted branch tip: ${candidate.tipSha}`);
console.log();
return;
}
const { task, candidates, canonicalBranch } = await resolveBranchRecoveryCandidates(id, projectName);
console.log();
console.log(` Branch recovery candidates for ${task.id}`);
console.log(` Canonical branch: ${canonicalBranch}`);
console.log(` Current task branch: ${task.branch ?? "(none)"}`);
console.log(` Current task worktree: ${task.worktree ?? "(none)"}`);
if (candidates.length === 0) {
console.log(" No matching canonical or sibling branches were found.");
console.log();
return;
}
for (const candidate of candidates) {
for (const line of formatRecoveryCandidate(candidate)) {
console.log(line);
}
}
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
const store = await getStore(projectName);