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

@@ -3,6 +3,7 @@ import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec);
@@ -181,14 +182,20 @@ export class WorktreePool {
* 4. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point
*
* Returns the actual branch name used. This may differ from `branchName`
* when conflict recovery generates a suffixed name (e.g., `fusion/fn-042-2`).
* when legacy conflict recovery is explicitly enabled and generates a suffixed
* name (e.g., `fusion/fn-042-2`).
*
* @param worktreePath — Absolute path to the recycled worktree
* @param branchName — Branch name for the new task (e.g., `fusion/fn-042`)
* @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`.
* @returns The actual branch name checked out in the worktree
*/
async prepareForTask(worktreePath: string, branchName: string, startPoint?: string): Promise<string> {
async prepareForTask(
worktreePath: string,
branchName: string,
startPoint?: string,
options?: { allowSiblingBranchRename?: boolean; repoDir?: string },
): Promise<string> {
// Clean tracked modifications
try {
await execAsync("git checkout -- .", { cwd: worktreePath });
@@ -223,20 +230,27 @@ export class WorktreePool {
throw err;
}
// The branch is checked out in a different worktree.
// First check if the conflicting worktree still exists on disk.
// The branch is checked out in a different worktree. Keep stale-conflict
// cleanup behavior for missing paths; otherwise either surface a typed
// conflict or, when explicitly enabled, fall back to the legacy sibling
// suffix flow.
const conflictingPath = match[1];
if (!existsSync(conflictingPath)) {
// Conflicting worktree no longer exists — prune and retry with original name
const inspection = await inspectBranchConflict({
repoDir: options?.repoDir ?? worktreePath,
branchName,
conflictingWorktreePath: conflictingPath,
startPoint: base,
});
if (inspection.kind === "stale") {
await execAsync("git worktree prune", { cwd: worktreePath });
await execAsync(checkoutCmd, { cwd: worktreePath });
return branchName;
}
// Conflicting worktree exists and is active — use a suffixed branch name
// to avoid disrupting the other worktree. Seed the suffix from the
// original task branch tip rather than the generic base ref so retries
// preserve the task's commits instead of resetting to main/baseBranch.
if (!options?.allowSiblingBranchRename) {
throw inspection.error;
}
const conflictBase = branchName;
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedName = `${branchName}-${suffix}`;
@@ -252,11 +266,9 @@ export class WorktreePool {
if (!suffixStderr.includes("already used by worktree")) {
throw suffixErr;
}
// This suffixed name is also in use — try the next one
}
}
// All suffixed names exhausted — should not happen in practice
throw new Error(
`Cannot create branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`,
);