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:
9
.changeset/fix-handoff-reattach-authoritative-branch.md
Normal file
9
.changeset/fix-handoff-reattach-authoritative-branch.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Engine reliability: auto-recover merge handoff from HEAD drift when the branch ref is authoritative.
|
||||||
|
|
||||||
|
- `acquireReuseHandoff` previously refused outright with `head-branch-mismatch / unexpected-branch` whenever the worktree's HEAD pointed at anything other than `fusion/<id>` (detached, recycled to `main`, on a sibling branch). The existing case-mismatch autocorrect did not cover these states, leaving FN-5339-class tasks wedged in review even though their branch ref still held a clean, task-attributed lineage.
|
||||||
|
- New `isBranchAuthoritativeForTask` helper (in `branch-conflicts.ts`) confirms the expected branch ref exists, its tip carries the `Fusion-Task-Id: <id>` trailer, and the `base..branch` range has no foreign FN-attributed commits.
|
||||||
|
- When that probe passes, the handoff now performs a plain `git checkout <branch>` (not `-B`, which would clobber the ref) inside the already-asserted-clean worktree, re-reads HEAD, and emits a `branch:auto-reattach-authoritative` audit. Refusal still fires unchanged when the branch ref itself is missing, missing a trailer, or contaminated — so the FN-5363 strict-lease and foreign-commit protections remain authoritative.
|
||||||
@@ -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 () => {
|
it("reconciles stale same-task activeSessionRegistry entries before proceeding", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
activeSessionRegistry.registerPath("/tmp/task-worktree", {
|
activeSessionRegistry.registerPath("/tmp/task-worktree", {
|
||||||
|
|||||||
@@ -275,6 +275,62 @@ async function summarizeTaskAttributedCommits(repoDir: string, range: string, ta
|
|||||||
return { ownCount, foreignCount };
|
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(
|
export async function assertCleanBranchAtBase(
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
reconcileSelfOwnedActiveSessionForRemoval,
|
reconcileSelfOwnedActiveSessionForRemoval,
|
||||||
} from "./active-session-registry.js";
|
} from "./active-session-registry.js";
|
||||||
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
||||||
|
import { isBranchAuthoritativeForTask } from "./branch-conflicts.js";
|
||||||
import { MeshLeaseManager } from "./mesh-lease-manager.js";
|
import { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||||
import {
|
import {
|
||||||
canonicalizePath,
|
canonicalizePath,
|
||||||
@@ -352,12 +353,56 @@ export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<Han
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (observedBranch !== expectedBranch) {
|
if (observedBranch !== expectedBranch) {
|
||||||
throw new MergeHandoffRefusedError("head-branch-mismatch", "unexpected-branch", {
|
// The worktree's HEAD points elsewhere (detached or different branch) but
|
||||||
taskId: input.task.id,
|
// the expected branch ref may still hold this task's authoritative work.
|
||||||
worktreePath,
|
// If the branch tip carries the task's Fusion-Task-Id trailer and the
|
||||||
observedBranch,
|
// 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,
|
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);
|
const activeRecord = activeSessionRegistry.lookupByPath(worktreePath);
|
||||||
|
|||||||
Reference in New Issue
Block a user