fix(engine): un-deadcode bootstrap-misbinding auto-recovery fallback

The auto-recovery handler in branch-worktree.ts passed
foreignCommits: [] to classifyBootstrapMisbinding, and the classifier
gated isBootstrapMisbinding on foreignCommits.length > 0. The entire
reanchor block was dead code on this path — the FN-5475 cascade hit
"human adjudication" instead of recovering.

The classifier now derives the foreign-commit count from its own
git log walk; the input field is advisory/optional. Result type gains
foreignCommitCount. The fallback handler also stops using
ctx.task.baseCommitSha (deliberately stale per FN-4417) and computes
a fresh merge-base against local main / origin/main, matching the
executor's primary contamination path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 02:29:38 -07:00
parent 76bd3a7d90
commit 9ce26eef26
6 changed files with 121 additions and 15 deletions

View File

@@ -60,12 +60,19 @@ describe("BranchWorktreeAutoRecoveryHandler", () => {
it("reanchors bootstrap misbinding then requeues", async () => {
const f = createFixtures();
branchConflictMocks.inspectBranchConflict.mockResolvedValue({ kind: "reclaimable", livePath: "/tmp/wt", tipSha: "abc", taskAttributedCommitCount: 0, strandedCommits: [] });
branchConflictMocks.classifyBootstrapMisbinding.mockResolvedValue({ isBootstrapMisbinding: true, ownCommitCount: 0, nonAttributedCount: 0 });
branchConflictMocks.classifyBootstrapMisbinding.mockResolvedValue({ isBootstrapMisbinding: true, ownCommitCount: 0, foreignCommitCount: 2, nonAttributedCount: 0 });
branchConflictMocks.reanchorBranchToBase.mockResolvedValue({});
await f.handler.issueRetry(f.failure, f.decision, f.ctx);
expect(branchConflictMocks.reanchorBranchToBase).toHaveBeenCalledTimes(1);
expect(f.taskStore.moveTask).toHaveBeenCalledWith("FN-4536", "todo", expect.objectContaining({ moveSource: "engine" }));
expect(f.runAudit.database).toHaveBeenCalledWith(expect.objectContaining({ type: "branch-worktree:auto-requeue", metadata: expect.objectContaining({ rationale: "bootstrap-misbinding-reanchor" }) }));
// Regression: prior to the fix, the handler passed `foreignCommits: []`
// to classifyBootstrapMisbinding, which silently disabled the predicate
// (foreignCommits.length > 0 was always false) and turned this entire
// branch into dead code for the FN-5475-class misbinding.
const classifyCall = branchConflictMocks.classifyBootstrapMisbinding.mock.calls[0][0];
expect(classifyCall.foreignCommits).toBeUndefined();
});
it("unparks stale paused conflict", async () => {

View File

@@ -125,10 +125,32 @@ describe("branch contamination recovery classification", () => {
expect(result).toEqual({
isBootstrapMisbinding: true,
ownCommitCount: 0,
foreignCommitCount: 1,
nonAttributedCount: 0,
});
}, 20_000);
// Regression: the auto-recovery fallback in branch-worktree.ts has no
// BranchCrossContaminationError in hand (it walks via inspectBranchConflict),
// so it cannot supply a foreignCommits array. Before the fix that path
// passed `[]` and the predicate became dead code.
it("classifies bootstrap misbinding when foreignCommits is omitted by the caller", async () => {
const { repoDir, baseSha } = await setupRepo();
await makeCommit(repoDir, "foreign-bootstrap-no-list", "feat(FN-4367): dependency change", "FN-4367");
const result = await classifyBootstrapMisbinding({
repoDir,
branchName: "feature",
baseSha,
taskId: "FN-4488",
});
expect(result.isBootstrapMisbinding).toBe(true);
expect(result.foreignCommitCount).toBe(1);
expect(result.ownCommitCount).toBe(0);
expect(result.nonAttributedCount).toBe(0);
}, 20_000);
it("does not classify bootstrap misbinding when an own-task commit exists", async () => {
const { repoDir, baseSha } = await setupRepo();
const foreign = await makeCommit(repoDir, "foreign-mixed", "feat(FN-4367): dependency change", "FN-4367");
@@ -182,6 +204,7 @@ describe("branch contamination recovery classification", () => {
expect(result).toEqual({
isBootstrapMisbinding: false,
ownCommitCount: 0,
foreignCommitCount: 0,
nonAttributedCount: 0,
});
});

View File

@@ -112,6 +112,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({
isBootstrapMisbinding: true,
ownCommitCount: 0,
foreignCommitCount: 1,
nonAttributedCount: 0,
});
vi.spyOn(branchConflicts, "reanchorBranchToBase").mockResolvedValueOnce({
@@ -139,7 +140,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: contamination.foreignCommits, unique: [] });
const recoverySpy = vi.spyOn(branchConflicts, "autoRecoverCrossContamination").mockResolvedValueOnce({
newTipSha: "2222222222222222222222222222222222222222",
@@ -173,7 +174,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: contamination.foreignCommits, unique: [] });
const recoverySpy = vi.spyOn(branchConflicts, "autoRecoverCrossContamination").mockResolvedValueOnce({
newTipSha: "2222222222222222222222222222222222222222",
@@ -202,7 +203,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [], unique: [misroutedCommit] });
vi.spyOn(branchConflicts, "classifyMisroutedForeignCommit").mockResolvedValueOnce({
misrouted: true,
@@ -237,7 +238,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [], unique: [foreignCommit] });
vi.spyOn(branchConflicts, "classifyMisroutedForeignCommit").mockResolvedValueOnce({
misrouted: false,
@@ -276,7 +277,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
const firstStore = createMockStore();
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [upstreamCommit], unique: [misroutedCommit] });
vi.spyOn(branchConflicts, "classifyMisroutedForeignCommit").mockResolvedValueOnce({ misrouted: true, foreignTaskId: "FN-5003", paths: [".changeset/fn-5003-fix.md"] });
const recoverySpy = vi.spyOn(branchConflicts, "autoRecoverCrossContamination").mockResolvedValueOnce({
@@ -290,7 +291,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
const secondStore = createMockStore();
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: false, ownCommitCount: 1, foreignCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [upstreamCommit], unique: [misroutedCommit] });
vi.spyOn(branchConflicts, "classifyMisroutedForeignCommit").mockResolvedValueOnce({ misrouted: true, foreignTaskId: "FN-5003", paths: [".changeset/fn-5003-fix.md"] });
@@ -309,7 +310,7 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
});
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: true, ownCommitCount: 0, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({ isBootstrapMisbinding: true, ownCommitCount: 0, foreignCommitCount: 1, nonAttributedCount: 0 });
vi.spyOn(branchConflicts, "reanchorBranchToBase").mockRejectedValueOnce(new Error("reanchor failed"));
vi.spyOn(branchConflicts, "classifyForeignCommits").mockResolvedValueOnce({ alreadyUpstream: [], unique: contamination.foreignCommits });