feat(FN-4499): complete Step 4 — wire bootstrap contamination recovery

Fusion-Task-Id: FN-4499
Fusion-Task-Lineage: 3e9fee75-5c3d-4ce5-aa8b-3f2bb94e48eb
This commit is contained in:
Fusion
2026-05-14 13:21:23 -07:00
committed by gsxdsm
parent 311bee43a0
commit 259903e56b
3 changed files with 123 additions and 32 deletions

View File

@@ -60,7 +60,7 @@ describe("resolveContaminationBaseRef (FN-4417)", () => {
});
});
describe("branch cross-contamination recovery (FN-4428)", () => {
describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExec.mockImplementation(((_cmd: any, _opts: any, cb: any) => {
@@ -76,6 +76,8 @@ describe("branch cross-contamination recovery (FN-4428)", () => {
title: "Test",
description: "Test",
column: "in-progress",
worktree: "/tmp/test/.worktrees/fn-4428",
branch: "fusion/fn-4428",
dependencies: [],
steps: [],
currentStep: 0,
@@ -86,7 +88,38 @@ describe("branch cross-contamination recovery (FN-4428)", () => {
} as any;
}
it("auto-recovers when all foreign commits are already-upstream", async () => {
it("FN-4488 shape: reanchors bootstrap misbinding and requeues to todo", async () => {
const store = createMockStore();
const contamination = new branchConflicts.BranchCrossContaminationError({
branchName: "fusion/fn-4488",
baseSha: "abc123",
taskId: "FN-4488",
foreignCommits: [
{ sha: "1111111111111111111111111111111111111111", subject: "feat(FN-4367): dep 1", foreignTaskId: "FN-4367" },
{ sha: "2222222222222222222222222222222222222222", subject: "fix(FN-4367): dep 2", foreignTaskId: "FN-4367" },
],
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({
isBootstrapMisbinding: true,
ownCommitCount: 0,
nonAttributedCount: 0,
});
vi.spyOn(branchConflicts, "reanchorBranchToBase").mockResolvedValueOnce({
previousTipSha: "3333333333333333333333333333333333333333",
newTipSha: "4444444444444444444444444444444444444444",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({ ...makeTask(), id: "FN-4488", branch: "fusion/fn-4488" } as any);
expect(store.moveTask).toHaveBeenCalledWith("FN-4488", "todo", { preserveResumeState: false, preserveWorktree: true });
expect(store.updateTask).toHaveBeenCalledWith("FN-4488", expect.objectContaining({ paused: false, pausedReason: null, error: null }));
expect(store.updateTask).not.toHaveBeenCalledWith("FN-4488", expect.objectContaining({ pausedReason: "branch-cross-contamination" }));
});
it("falls back to existing auto-recovery when contamination is post-start", async () => {
const store = createMockStore();
const contamination = new branchConflicts.BranchCrossContaminationError({
branchName: "fusion/fn-4428",
@@ -96,6 +129,7 @@ describe("branch cross-contamination recovery (FN-4428)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: contamination.foreignCommits, unique: [] });
vi.spyOn(branchConflicts, "autoRecoverCrossContamination").mockResolvedValueOnce({
newTipSha: "2222222222222222222222222222222222222222",
@@ -106,46 +140,25 @@ describe("branch cross-contamination recovery (FN-4428)", () => {
await executor.execute(makeTask());
expect(store.moveTask).toHaveBeenCalledWith("FN-4428", "todo", { preserveResumeState: true });
expect(store.updateTask).toHaveBeenCalledWith("FN-4428", expect.objectContaining({ paused: false, pausedReason: null, recoveryRetryCount: 1 }));
expect(store.logEntry).toHaveBeenCalledWith("FN-4428", expect.stringContaining("auto-recovered branch-cross-contamination"), undefined, expect.any(Object));
});
it("escalates to paused failure when unique foreign commits remain", async () => {
it("falls back to terminal contamination failure when bootstrap reanchor throws", async () => {
const store = createMockStore();
const contamination = new branchConflicts.BranchCrossContaminationError({
branchName: "fusion/fn-4428",
branchName: "fusion/fn-4488",
baseSha: "abc123",
taskId: "FN-4428",
foreignCommits: [{ sha: "3333333333333333333333333333333333333333", subject: "feat(FN-4410): unique", foreignTaskId: "FN-4410" }],
taskId: "FN-4488",
foreignCommits: [{ sha: "1111111111111111111111111111111111111111", subject: "feat(FN-4367): dep", foreignTaskId: "FN-4367" }],
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: true, ownCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "reanchorBranchToBase").mockRejectedValueOnce(new Error("reanchor failed"));
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [], unique: contamination.foreignCommits });
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
await executor.execute({ ...makeTask(), id: "FN-4488", branch: "fusion/fn-4488" } as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-4428", expect.objectContaining({ status: "failed", paused: true, pausedReason: "branch-cross-contamination" }));
});
it("escalates immediately when auto-recovery was already attempted", async () => {
const store = createMockStore();
const contamination = new branchConflicts.BranchCrossContaminationError({
branchName: "fusion/fn-4428",
baseSha: "abc123",
taskId: "FN-4428",
foreignCommits: [{ sha: "4444444444444444444444444444444444444444", subject: "feat(FN-4412): upstream", foreignTaskId: "FN-4412" }],
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: contamination.foreignCommits, unique: [] });
const autoRecoverSpy = vi.spyOn(branchConflicts, "autoRecoverCrossContamination");
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask(1));
expect(autoRecoverSpy).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-4428", expect.stringContaining("auto-recovery already attempted"), undefined, expect.objectContaining({ agentId: "executor" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-4428", expect.objectContaining({ status: "failed", paused: true, pausedReason: "branch-cross-contamination" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-4488", expect.objectContaining({ status: "failed", paused: true, pausedReason: "branch-cross-contamination" }));
});
});

View File

@@ -44,8 +44,10 @@ import {
BranchCrossContaminationError,
assertCleanBranchAtBase,
autoRecoverCrossContamination,
classifyBootstrapMisbinding,
classifyForeignCommits,
isBranchConflictError,
reanchorBranchToBase,
inspectBranchConflict,
} from "./branch-conflicts.js";
import { AgentLogger } from "./agent-logger.js";
@@ -2656,7 +2658,18 @@ export class TaskExecutor {
// that are attributed to OTHER tasks? Compute the merge-base fresh.
const contaminationBaseRef = await this.resolveContaminationBaseRef(worktreePath);
if (contaminationBaseRef) {
await assertCleanBranchAtBase(this.rootDir, acquisition.branch, contaminationBaseRef, task.id);
try {
await assertCleanBranchAtBase(this.rootDir, acquisition.branch, contaminationBaseRef, task.id);
} catch (contaminationError: unknown) {
if (!(contaminationError instanceof BranchCrossContaminationError)) {
throw contaminationError;
}
const recovered = await this.tryBootstrapMisbindingRecovery(task, contaminationError, audit);
if (recovered) {
return;
}
throw contaminationError;
}
}
const expectedRoot = canonicalizePath(this.rootDir);
@@ -4164,6 +4177,11 @@ export class TaskExecutor {
await this.store.logEntry(task.id, `[recovery] branch cross-contamination detected on ${err.branchName} since ${err.baseSha}: ${details}`, undefined, this.currentRunContext);
try {
const recoveredBootstrapMisbinding = await this.tryBootstrapMisbindingRecovery(task, err, audit);
if (recoveredBootstrapMisbinding) {
return;
}
const classified = await classifyForeignCommits({
repoDir: this.rootDir,
branchName: err.branchName,
@@ -6942,6 +6960,65 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
private readonly MAX_AUTO_RECOVERY_ATTEMPTS = 3;
private readonly BRANCH_CONFLICT_TRIPWIRE_THRESHOLD = 5;
private async tryBootstrapMisbindingRecovery(
task: Task,
contamination: BranchCrossContaminationError,
audit: ReturnType<typeof createRunAuditor>,
): Promise<boolean> {
const bootstrap = await classifyBootstrapMisbinding({
repoDir: this.rootDir,
branchName: contamination.branchName,
baseSha: contamination.baseSha,
taskId: task.id,
foreignCommits: contamination.foreignCommits,
});
if (!bootstrap.isBootstrapMisbinding) {
return false;
}
const worktreePath = task.worktree;
if (!worktreePath || !await isUsableTaskWorktree(this.rootDir, worktreePath)) {
await this.store.logEntry(task.id, `[recovery] bootstrap misbinding detected but worktree unavailable for re-anchor: ${worktreePath ?? "none"}`, undefined, this.currentRunContext);
return false;
}
await this.store.logEntry(task.id, `[recovery] bootstrap-time branch misbinding detected on ${contamination.branchName}: 0 own commits, re-anchoring to ${contamination.baseSha}`, undefined, this.currentRunContext);
try {
const reanchor = await reanchorBranchToBase({
repoDir: this.rootDir,
worktreePath,
branchName: contamination.branchName,
baseSha: contamination.baseSha,
taskId: task.id,
});
await audit.git({
type: "branch:reanchor",
target: contamination.branchName,
metadata: {
taskId: task.id,
baseSha: contamination.baseSha,
previousTipSha: reanchor.previousTipSha,
newTipSha: reanchor.newTipSha,
trigger: "bootstrap-misbinding",
},
});
await this.store.updateTask(task.id, {
recoveryRetryCount: null,
nextRecoveryAt: null,
error: null,
paused: false,
pausedReason: null,
});
await this.store.moveTask(task.id, "todo", { preserveResumeState: false, preserveWorktree: true });
return true;
} catch (error) {
await this.store.logEntry(task.id, `[recovery] bootstrap re-anchor failed; falling back to contamination safety path: ${formatError(error)}`, undefined, this.currentRunContext);
return false;
}
}
private async reclaimExistingWorktree(
task: Task,
livePath: string,

View File

@@ -79,6 +79,7 @@ export type GitMutationType =
| "branch:auto-reclaim"
| "branch:orphan-prune"
| "branch:orphan-rescued"
| "branch:reanchor"
| "stash:push"
| "stash:pop";