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 fdd0af3b49
commit 75fe39d051
28 changed files with 1341 additions and 209 deletions

View File

@@ -14,6 +14,19 @@ vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
}) as any;
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve) => {
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
});
return { exec: execFn };
});
// Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => {
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
@@ -45,7 +58,7 @@ vi.mock("@fusion/core", () => {
});
// Mock @fusion/engine
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
// Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({
@@ -83,7 +96,8 @@ vi.mock("../../project-context.js", () => ({
import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import { exec } from "node:child_process";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import {
getCurrentRepo,
isGhAuthenticated,
@@ -93,7 +107,9 @@ import {
import { GitHubClient } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js";
import { aiMergeTask } from "@fusion/engine";
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -2052,6 +2068,153 @@ describe("runTaskRetry", () => {
});
});
describe("runTaskBranchRecovery", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdateTask: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetTask = vi.fn().mockResolvedValue(makeTask({
id: "FN-001",
branch: "fusion/fn-001",
worktree: "/tmp/fn-001",
executionStartBranch: "main",
status: "failed",
column: "todo",
}));
mockUpdateTask = vi.fn().mockResolvedValue(undefined);
mockLogEntry = vi.fn().mockResolvedValue(undefined);
mockedExec.mockReset();
vi.mocked(listBranchRecoveryCandidates).mockReset();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: mockGetTask,
updateTask: mockUpdateTask,
logEntry: mockLogEntry,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("prints branch recovery candidates", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Branch recovery candidates for FN-001");
expect(output).toContain("fusion/fn-001 (canonical)");
expect(output).toContain("abc123def456");
expect(output).toContain("Sibling patch");
});
it("reclaims the selected branch for the next run", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { reclaim: "fusion/fn-001-2" });
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
branch: "fusion/fn-001-2",
worktree: "/tmp/fn-001-2",
status: null,
error: null,
});
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: reclaimed fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
it("refuses discard without explicit confirmation", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await expect(runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Refusing to discard branch recovery state without --yes");
expect(mockedExec).not.toHaveBeenCalled();
});
it("discards the selected branch and worktree when confirmed", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2", yes: true });
expect(mockedExec).toHaveBeenCalledWith(
"git worktree remove '/tmp/fn-001-2' --force",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockedExec).toHaveBeenCalledWith(
"git branch -D 'fusion/fn-001-2'",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: discarded fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
});
// --- Logs Tests ---
describe("runTaskLogs", () => {

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);