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:
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,7 @@ export type GitMutationType =
|
||||
| "branch:auto-reclaim"
|
||||
| "branch:orphan-prune"
|
||||
| "branch:orphan-rescued"
|
||||
| "branch:reanchor"
|
||||
| "stash:push"
|
||||
| "stash:pop";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user