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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user