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:
@@ -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", {
|
||||
|
||||
@@ -275,6 +275,62 @@ async function summarizeTaskAttributedCommits(repoDir: string, range: string, ta
|
||||
return { ownCount, foreignCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `branch`'s tip commit carries a `Fusion-Task-Id: <taskId>` trailer.
|
||||
* Used as the cheap "is this branch ref authoritative for this task" probe
|
||||
* at merge handoff so that HEAD drift (detached, wrong branch) can recover
|
||||
* via a safe re-attach instead of refusing the handoff outright.
|
||||
*/
|
||||
export async function branchTipCarriesTaskIdTrailer(
|
||||
repoDir: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const body = await runGit(repoDir, `git log -1 --pretty=%B ${quoteShellArg(branch)}`);
|
||||
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escaped}\\s*(?:\\n|$)`);
|
||||
return pattern.test(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-branch authority check: the branch ref exists, its tip carries the
|
||||
* task's Fusion-Task-Id trailer, and (when a base is supplied) the range
|
||||
* `base..branch` has no foreign FN-attributed commits.
|
||||
*
|
||||
* Returns `{ ok: true }` when safe to treat the branch ref as authoritative
|
||||
* for `taskId`. On failure, returns `{ ok: false, reason }` so callers can
|
||||
* log/audit why the gentle recovery was refused.
|
||||
*/
|
||||
export async function isBranchAuthoritativeForTask(
|
||||
repoDir: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
baseSha?: string,
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
try {
|
||||
await revParse(repoDir, `refs/heads/${branch}`);
|
||||
} catch {
|
||||
return { ok: false, reason: "branch-ref-missing" };
|
||||
}
|
||||
const tipCarriesTrailer = await branchTipCarriesTaskIdTrailer(repoDir, branch, taskId);
|
||||
if (!tipCarriesTrailer) {
|
||||
return { ok: false, reason: "tip-missing-task-trailer" };
|
||||
}
|
||||
if (baseSha) {
|
||||
try {
|
||||
await assertCleanBranchAtBase(repoDir, branch, baseSha, taskId);
|
||||
} catch (err) {
|
||||
const reason = err instanceof BranchCrossContaminationError ? "foreign-contamination" : "clean-branch-check-failed";
|
||||
return { ok: false, reason };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function assertCleanBranchAtBase(
|
||||
repoDir: string,
|
||||
branchName: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
reconcileSelfOwnedActiveSessionForRemoval,
|
||||
} from "./active-session-registry.js";
|
||||
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
||||
import { isBranchAuthoritativeForTask } from "./branch-conflicts.js";
|
||||
import { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import {
|
||||
canonicalizePath,
|
||||
@@ -352,12 +353,56 @@ export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<Han
|
||||
}
|
||||
}
|
||||
if (observedBranch !== expectedBranch) {
|
||||
throw new MergeHandoffRefusedError("head-branch-mismatch", "unexpected-branch", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
observedBranch,
|
||||
// The worktree's HEAD points elsewhere (detached or different branch) but
|
||||
// the expected branch ref may still hold this task's authoritative work.
|
||||
// If the branch tip carries the task's Fusion-Task-Id trailer and the
|
||||
// range against base is contamination-free, re-attach via plain checkout
|
||||
// (worktree was already asserted clean above, so this is safe and
|
||||
// non-destructive — unlike `checkout -B` which would clobber the ref).
|
||||
const authority = await isBranchAuthoritativeForTask(
|
||||
input.projectRoot,
|
||||
expectedBranch,
|
||||
});
|
||||
input.task.id,
|
||||
);
|
||||
if (authority.ok) {
|
||||
const reattach = await execAsync(`git checkout ${expectedBranch}`, {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}).then(
|
||||
() => ({ ok: true as const }),
|
||||
(err: unknown) => ({ ok: false as const, reason: err instanceof Error ? err.message : String(err) }),
|
||||
);
|
||||
if (reattach.ok) {
|
||||
const { stdout: reattachedHead } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
observedBranch = reattachedHead.trim();
|
||||
await input.auditEmit?.({
|
||||
type: "branch:auto-reattach-authoritative",
|
||||
target: worktreePath,
|
||||
metadata: {
|
||||
taskId: input.task.id,
|
||||
previousHead: observedBranch === expectedBranch ? undefined : observedBranch,
|
||||
expectedBranch,
|
||||
worktreePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (observedBranch !== expectedBranch) {
|
||||
throw new MergeHandoffRefusedError("head-branch-mismatch", "unexpected-branch", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
observedBranch,
|
||||
expectedBranch,
|
||||
authorityProbe: authority.ok ? "ok" : authority.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeRecord = activeSessionRegistry.lookupByPath(worktreePath);
|
||||
|
||||
Reference in New Issue
Block a user