fix(engine): auto-reattach HEAD at handoff when branch ref is authoritative

The merge handoff refused with head-branch-mismatch whenever the reused
worktree's HEAD wasn't on fusion/<id> (detached, recycled to main, or on
a sibling branch), even when the branch ref itself still held a clean,
task-attributed lineage. That wedged FN-5339-class tasks in review for
no good reason.

Add isBranchAuthoritativeForTask in branch-conflicts.ts (branch ref
exists, tip carries Fusion-Task-Id trailer, base..branch is foreign-
contamination-free) and use it in acquireReuseHandoff: when HEAD drifts
but the branch ref is authoritative, run a plain `git checkout <branch>`
inside the already-asserted-clean worktree, re-read HEAD, and emit a
branch:auto-reattach-authoritative audit. Refusal still fires unchanged
when the branch ref is missing, missing the trailer, or contaminated,
so FN-5363 strict-lease and foreign-commit guards remain authoritative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-21 19:03:35 -07:00
parent 025683ca60
commit d947197c2a
4 changed files with 184 additions and 5 deletions

View File

@@ -410,6 +410,75 @@ describe("acquireReuseHandoff", () => {
);
});
it("re-attaches a detached HEAD when the branch ref carries the task's trailer", async () => {
vi.spyOn(branchAutocorrect, "attemptBranchAutocorrect").mockResolvedValue({ status: "failed", reason: "case-not-applicable" });
const store = createStore();
const auditEmit = vi.fn();
let headReads = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git rev-parse --abbrev-ref HEAD") {
headReads += 1;
// First read sees the detached state, post-checkout read sees the branch.
return Buffer.from(headReads === 1 ? "HEAD\n" : "fusion/fn-5279\n");
}
if (command === "git diff -z --name-only") return Buffer.from("");
if (command === "git diff -z --cached --name-only") return Buffer.from("");
if (command === "git status -z --porcelain") return Buffer.from("");
if (command === "git diff HEAD") return Buffer.from("");
if (command.startsWith("git rev-parse --verify")) return Buffer.from("abc123\n");
if (command.startsWith("git log -1 --pretty=%B")) {
return Buffer.from("feat(FN-5279): step 3\n\nFusion-Task-Id: FN-5279\n");
}
if (command === "git checkout fusion/fn-5279") return Buffer.from("");
return Buffer.from("");
});
const handoff = await acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
auditEmit,
});
expect(handoff.ok).toBe(true);
expect(auditEmit).toHaveBeenCalledWith(expect.objectContaining({
type: "branch:auto-reattach-authoritative",
metadata: expect.objectContaining({ taskId: "FN-5279", expectedBranch: "fusion/fn-5279" }),
}));
});
it("still refuses when HEAD is wrong AND the branch ref is not authoritative", async () => {
vi.spyOn(branchAutocorrect, "attemptBranchAutocorrect").mockResolvedValue({ status: "failed", reason: "case-not-applicable" });
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git rev-parse --abbrev-ref HEAD") return Buffer.from("feature/elsewhere\n");
if (command === "git diff -z --name-only") return Buffer.from("");
if (command === "git diff -z --cached --name-only") return Buffer.from("");
if (command === "git status -z --porcelain") return Buffer.from("");
if (command === "git diff HEAD") return Buffer.from("");
if (command.startsWith("git rev-parse --verify")) return Buffer.from("abc123\n");
// Trailer absent => not authoritative.
if (command.startsWith("git log -1 --pretty=%B")) return Buffer.from("feat(FN-9999): unrelated\n");
return Buffer.from("");
});
const refusal = await expectRefusal(
acquireReuseHandoff({
task: await createStore().getTask("FN-5279"),
store: createStore(),
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"head-branch-mismatch",
"unexpected-branch",
);
expect(refusal.payload).toMatchObject({ authorityProbe: "tip-missing-task-trailer" });
});
it("reconciles stale same-task activeSessionRegistry entries before proceeding", async () => {
const store = createStore();
activeSessionRegistry.registerPath("/tmp/task-worktree", {