fix(FN-4476): handle fully-subsumed branch conflicts across reclaim paths

Fusion-Task-Id: FN-4476
Fusion-Task-Lineage: 8673c1d9-f47e-4d02-89e6-646040b9be32
This commit is contained in:
Fusion
2026-05-14 08:21:15 -07:00
committed by gsxdsm
parent ab4729099c
commit d8255c2bb0
4 changed files with 116 additions and 4 deletions

View File

@@ -341,6 +341,19 @@ describe("WorktreePool", () => {
return Buffer.from("");
});
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "live-foreign",
livePath: "/other/wt",
error: new BranchConflictError({
branchName: "fusion/fn-042",
conflictingWorktreePath: "/other/wt",
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
}),
});
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, {
repoDir: "/tmp/repo",
requestingTaskId: "FN-042",
@@ -371,6 +384,19 @@ describe("WorktreePool", () => {
return Buffer.from("");
});
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "live-foreign",
livePath: "/other/wt",
error: new BranchConflictError({
branchName: "fusion/fn-042",
conflictingWorktreePath: "/other/wt",
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "fusion/fn-041",
recommendedAction: "Run branch recovery",
}),
});
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041", { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
expect(result.branch).toBe("fusion/fn-042-2");
});
@@ -394,6 +420,19 @@ describe("WorktreePool", () => {
return Buffer.from("");
});
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "live-foreign",
livePath: "/other/wt",
error: new BranchConflictError({
branchName: "fusion/fn-042",
conflictingWorktreePath: "/other/wt",
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
}),
});
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
expect(result.branch).toBe("fusion/fn-042-3");
});
@@ -460,6 +499,19 @@ describe("WorktreePool", () => {
return Buffer.from("");
});
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
kind: "live-foreign",
livePath: "/other/wt",
error: new BranchConflictError({
branchName: "fusion/fn-042",
conflictingWorktreePath: "/other/wt",
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
}),
});
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })).rejects.toThrow(/suffixes -2 through -6 are all in use/);
});
});

View File

@@ -6824,6 +6824,11 @@ and show an appropriate message to the user.\`
return "reclaimed";
}
if (inspection.kind === "fully-subsumed") {
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, 0);
return "reclaimed";
}
if (inspection.kind === "live-foreign") {
const cleanupSuccess = await this.cleanupConflictingWorktree(inspection.livePath, error.branchName, task.id);
if (cleanupSuccess) {
@@ -7514,6 +7519,15 @@ and show an appropriate message to the user.\`
return { path: inspection.livePath, branch };
}
if (inspection.kind === "fully-subsumed") {
await this.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${inspection.livePath} (0 commits preserved)`,
inspection.tipSha,
);
return { path: inspection.livePath, branch };
}
if (inspection.kind === "live-foreign") {
const cleanupSuccess = await this.cleanupConflictingWorktree(inspection.livePath, branch, taskId);
if (cleanupSuccess) {

View File

@@ -15,7 +15,7 @@
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { getInReviewStallReason, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type TaskStore, type Settings, type Task, type MergeDetails } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
@@ -29,6 +29,30 @@ import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
const log = createLogger("self-healing");
const execAsync = promisify(exec);
function formatRecoveryTimestamp(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`;
}
async function preserveWorktreeChanges(repoDir: string, worktreePath: string, taskId: string): Promise<string | null> {
try {
const status = (await execAsync("git status --porcelain", { cwd: worktreePath, encoding: "utf-8" })).stdout.trim();
if (!status) {
return null;
}
const diff = (await execAsync("git diff HEAD --binary", { cwd: worktreePath, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 })).stdout;
const recoveryDir = join(repoDir, ".fusion", "recovery");
mkdirSync(recoveryDir, { recursive: true });
const patchPath = join(recoveryDir, `${taskId.toLowerCase()}-${formatRecoveryTimestamp()}.patch`);
writeFileSync(patchPath, diff, "utf-8");
return patchPath;
} catch (error) {
log.warn(`Failed to preserve worktree changes for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
function matchGlob(path: string, pattern: string): boolean {
if (pattern.includes("**")) {
const regexPattern = pattern
@@ -1418,14 +1442,18 @@ export class SelfHealingManager {
if (inspection.kind === "live-foreign") {
throw inspection.error;
}
if (inspection.taskAttributedCommitCount <= 0) {
const preservedCommitCount = inspection.kind === "fully-subsumed"
? 0
: inspection.taskAttributedCommitCount;
if (inspection.kind !== "fully-subsumed" && preservedCommitCount <= 0) {
continue;
}
await this.store.updateTask(task.id, { worktree: inspection.livePath, branch: task.branch });
await this.store.logEntry(
task.id,
`[recovery] reclaimed existing worktree for ${task.id} at ${inspection.livePath} (${inspection.taskAttributedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
`[recovery] reclaimed existing worktree for ${task.id} at ${inspection.livePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
);
try {
@@ -1444,7 +1472,8 @@ export class SelfHealingManager {
branch: task.branch,
worktreePath: inspection.livePath,
existingTipSha: inspection.tipSha,
strandedCommitCount: inspection.strandedCommits.length,
strandedCommitCount: inspection.kind === "fully-subsumed" ? 0 : inspection.strandedCommits.length,
subsumed: inspection.kind === "fully-subsumed",
trigger: "self-healing-sweep",
},
});
@@ -1455,6 +1484,10 @@ export class SelfHealingManager {
recovered++;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
const patchPath = await preserveWorktreeChanges(this.options.rootDir, task.worktree, task.id);
if (patchPath) {
await this.store.logEntry(task.id, `Preserved uncommitted worktree changes before pause: ${patchPath}`);
}
await this.store.updateTask(task.id, {
status: "failed",
error: `Task branch conflict: ${task.branch} is not safely reclaimable (${message})`,

View File

@@ -286,6 +286,19 @@ export class WorktreePool {
};
}
if (inspection.kind === "fully-subsumed") {
worktreePoolLog.log(
`reclaimed fully-subsumed branch conflict for ${branchName}: tip=${inspection.tipSha} strandedSince${base}=0`,
);
return {
branch: branchName,
worktreePath: inspection.livePath,
reclaimed: true,
existingTipSha: inspection.tipSha,
strandedCommitCount: 0,
};
}
if (!options?.allowSiblingBranchRename) {
if (inspection.kind === "live-foreign") {
throw inspection.error;