Files
fusion/packages/engine/src/__tests__/merger-integration-worktree.test.ts
gsxdsm 7763ba5fab FN-6278: preflight reusable merge worktree cwd
Stabilize reusable merge worktree cwd resolution before merge-runner git spawns.

- Add a reusable merge integration root preflight that detects missing, empty, or de-registered task worktrees before using them as cwd.
- Reacquire/repair unusable reuse-task-worktree roots before merge execution and reject unusable roots during handoff.
- Cover vanished and unregistered worktree recovery paths plus focused preflight unit cases.
- Document the FN-6278 preflight behavior alongside transient merge recovery and merge integration worktree contracts.

Files changed:
 docs/architecture.md                               |   4 +-
 .../__tests__/merger-integration-worktree.test.ts  |  82 ++++++++
 .../merge-runner-spawn-enoent-prevention.test.ts   | 233 +++++++++++++++++++++
 packages/engine/src/merger-integration-worktree.ts |  71 +++++++
 packages/engine/src/merger.ts                      |  19 +-
 5 files changed, 396 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6278

Fusion-Task-Lineage: ceecf70f-9e2d-4ffe-8c6f-a9a5b673d72d
2026-06-11 20:27:29 -07:00

898 lines
33 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import {
createMockStore,
mockedExec,
mockedExecSync,
mockedCreateFnAgent,
setupHappyPathExecSync,
} from "./merger-test-helpers.js";
import { activeSessionRegistry, executingTaskLock } from "../active-session-registry.js";
import * as branchAutocorrect from "../branch-autocorrect.js";
import {
acquireReuseHandoff,
ensureUsableMergeIntegrationRoot,
MergeHandoffRefusedError,
probeIntegrationWorktreeState,
releaseReuseHandoff,
resolveIntegrationRemote,
resolveMergeIntegrationRoot,
} from "../merger-integration-worktree.js";
import * as worktreePool from "../worktree-pool.js";
import { PoolDoubleLeaseError } from "../worktree-pool.js";
describe("resolveMergeIntegrationRoot", () => {
it("defaults to reusing the task worktree", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", branch: "fusion/FN-5279", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: undefined, worktrunk: { enabled: false } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "reuse-task-worktree",
rootDir: "/tmp/task-worktree",
branchName: "fusion/fn-5279",
});
});
it("maps explicit opt-in to canonical cwd-integration-branch mode", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: "cwd-integration-branch" as const, worktrunk: { enabled: false } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "cwd-integration-branch",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
it("maps legacy cwd-main input to canonical cwd-integration-branch mode", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: "cwd-main" as const, worktrunk: { enabled: false } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "cwd-integration-branch",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
it("returns empty sentinel rootDir when the task worktree is missing", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: undefined } as any,
settings: { mergeIntegrationWorktree: "reuse-task-worktree", worktrunk: { enabled: false } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "reuse-task-worktree",
rootDir: "",
branchName: "fusion/fn-5279",
});
});
it("keeps reuse-task-worktree mode when worktrunk is enabled", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: "reuse-task-worktree", worktrunk: { enabled: true } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "reuse-task-worktree",
rootDir: "/tmp/task-worktree",
branchName: "fusion/fn-5279",
});
});
});
describe("ensureUsableMergeIntegrationRoot", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("treats an empty reuse worktree sentinel as missing without probing git", async () => {
const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree");
await expect(
ensureUsableMergeIntegrationRoot({
resolution: { mode: "reuse-task-worktree", rootDir: "", branchName: "fusion/fn-5279" },
projectRoot: "/tmp/project-root",
}),
).resolves.toMatchObject({
ok: false,
checked: "reuse-task-worktree",
reason: "missing-task-worktree",
});
expect(classifySpy).not.toHaveBeenCalled();
});
it("classifies absent reuse worktrees before they can be used as cwd", async () => {
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({
ok: false,
classification: "missing",
reason: "worktree directory does not exist",
});
const result = await ensureUsableMergeIntegrationRoot({
resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/dead-task-worktree", branchName: "fusion/fn-5279" },
projectRoot: "/tmp/project-root",
});
expect(result).toMatchObject({
ok: false,
checked: "reuse-task-worktree",
reason: "unusable-task-worktree",
classification: { classification: "missing" },
});
expect(worktreePool.classifyTaskWorktree).toHaveBeenCalledWith("/tmp/project-root", "/tmp/dead-task-worktree");
});
it("classifies de-registered reuse worktrees before handoff", async () => {
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({
ok: false,
classification: "unregistered",
reason: "not registered in git worktree list",
});
await expect(
ensureUsableMergeIntegrationRoot({
resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/unregistered-task-worktree", branchName: "fusion/fn-5279" },
projectRoot: "/tmp/project-root",
}),
).resolves.toMatchObject({
ok: false,
reason: "unusable-task-worktree",
classification: { classification: "unregistered" },
});
});
it("leaves healthy reuse roots unchanged", async () => {
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true });
const resolution = { mode: "reuse-task-worktree" as const, rootDir: "/tmp/task-worktree", branchName: "fusion/fn-5279" };
await expect(
ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }),
).resolves.toEqual({ ok: true, resolution, checked: "reuse-task-worktree" });
});
it("does not stat or git-probe projectRoot/default mode", async () => {
const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree");
const resolution = { mode: "cwd-integration-branch" as const, rootDir: "/tmp/project-root", branchName: "fusion/fn-5279" };
await expect(
ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }),
).resolves.toEqual({ ok: true, resolution, checked: false });
expect(classifySpy).not.toHaveBeenCalled();
});
});
describe("resolveIntegrationRemote", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("prefers the explicit worktreeRebaseRemote setting", async () => {
await expect(
resolveIntegrationRemote({
settings: { worktreeRebaseRemote: "upstream" } as any,
rootDir: "/tmp/project-root",
integrationBranch: "master",
}),
).resolves.toBe("upstream");
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("falls back to the configured branch remote and then repo remotes", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command.includes("git config --get branch.master.remote")) return Buffer.from("fork\n");
throw new Error(`Unexpected command: ${command}`);
});
await expect(
resolveIntegrationRemote({
settings: { worktreeRebaseRemote: "" } as any,
rootDir: "/tmp/project-root",
integrationBranch: "master",
}),
).resolves.toBe("fork");
});
});
describe("probeIntegrationWorktreeState", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns null state when integration branch is not checked out in any linked worktree", async () => {
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-1", "/tmp/task"]]));
await expect(
probeIntegrationWorktreeState({
rootDir: "/tmp/project-root",
integrationBranch: "main",
projectRoot: "/tmp/project-root",
}),
).resolves.toEqual({ userCheckout: null, dirtyFingerprint: null });
});
it("returns clean user checkout state", async () => {
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["main", "/tmp/project-root"]]));
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
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("");
return Buffer.from("");
});
const state = await probeIntegrationWorktreeState({
rootDir: "/tmp/project-root",
integrationBranch: "main",
projectRoot: "/tmp/project-root",
});
expect(state).toEqual({
userCheckout: {
worktreePath: "/tmp/project-root",
dirty: false,
untrackedCount: 0,
dirtyPathSample: [],
},
dirtyFingerprint: null,
});
});
it("returns dirty user checkout state with staged, unstaged, and untracked files", async () => {
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["main", "/tmp/project-root"]]));
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git diff -z --name-only") return Buffer.from("unstaged.ts\0");
if (command === "git diff -z --cached --name-only") return Buffer.from("staged.ts\0");
if (command === "git status -z --porcelain") return Buffer.from("M modified.ts\0?? untracked.txt\0");
if (command === "git diff HEAD") return Buffer.from("diff --git a/a b/a\n");
return Buffer.from("");
});
const state = await probeIntegrationWorktreeState({
rootDir: "/tmp/project-root",
integrationBranch: "main",
projectRoot: "/tmp/project-root",
});
expect(state.userCheckout).toMatchObject({
worktreePath: "/tmp/project-root",
dirty: true,
untrackedCount: 1,
});
expect(state.userCheckout?.dirtyPathSample).toEqual([
"staged.ts",
"unstaged.ts",
"untracked.txt",
]);
expect(state.dirtyFingerprint).toEqual(expect.any(String));
});
it("supports master as integration branch", async () => {
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["master", "/tmp/project-root"]]));
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
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("");
return Buffer.from("");
});
const state = await probeIntegrationWorktreeState({
rootDir: "/tmp/project-root",
integrationBranch: "master",
projectRoot: "/tmp/project-root",
});
expect(state.userCheckout?.worktreePath).toBe("/tmp/project-root");
expect(state.userCheckout?.dirty).toBe(false);
});
});
describe("merger fresh reacquire init wiring", () => {
it("wires fresh acquireTaskWorktree calls with init command execution", () => {
const mergerSource = readFileSync(new URL("../merger.ts", import.meta.url), "utf-8");
const freshAcquireBlock = mergerSource.match(/const acquisition = await acquireTaskWorktree\(\{[\s\S]*?\n\s*\}\);/);
expect(freshAcquireBlock?.[0]).toContain("runInitCommand: true");
expect(freshAcquireBlock?.[0]).toContain("runConfiguredCommand:");
expect(freshAcquireBlock?.[0]).toContain("runConfiguredMergeWorktreeCommand");
});
it("keeps direct-reuse shortcut on existing registrations", () => {
const mergerSource = readFileSync(new URL("../merger.ts", import.meta.url), "utf-8");
expect(mergerSource).toContain("Skip acquireTaskWorktree's");
});
});
describe("acquireReuseHandoff", () => {
beforeEach(() => {
vi.clearAllMocks();
activeSessionRegistry.clear();
executingTaskLock._clearForTest();
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true });
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(
new Map([["fusion/fn-5279", "/tmp/task-worktree"]]),
);
vi.spyOn(worktreePool, "canonicalizePath").mockImplementation((value) => value);
vi.spyOn(branchAutocorrect, "attemptBranchAutocorrect").mockResolvedValue({ status: "renamed" });
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git rev-parse --abbrev-ref HEAD") return Buffer.from("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("");
return Buffer.from("");
});
});
function createStore(taskOverrides: Record<string, unknown> = {}) {
const store = createMockStore({
id: "FN-5279",
branch: "fusion/fn-5279",
worktree: "/tmp/task-worktree",
checkedOutBy: undefined,
checkedOutAt: undefined,
checkoutLeaseRenewedAt: undefined,
checkoutNodeId: undefined,
checkoutRunId: undefined,
checkoutLeaseEpoch: undefined,
...taskOverrides,
}) as any;
store.listTasks.mockResolvedValue([
{ id: "FN-5279", column: "in-review", worktree: "/tmp/task-worktree" },
]);
store.acquireMergeQueueLease = vi.fn().mockReturnValue({ taskId: "FN-5279" });
store.releaseMergeQueueLease = vi.fn();
store.peekMergeQueueHead = vi.fn().mockReturnValue({ taskId: "FN-5000", leasedBy: "merger-reuse-handoff", column: "todo" });
return store;
}
async function expectRefusal(
promise: Promise<unknown>,
gate: string,
reason: string,
): Promise<MergeHandoffRefusedError> {
await expect(promise).rejects.toBeInstanceOf(MergeHandoffRefusedError);
try {
await promise;
throw new Error("expected refusal");
} catch (error) {
expect(error).toMatchObject({ gate, reason });
return error as MergeHandoffRefusedError;
}
}
it("acquires and releases the merge queue lease on the happy path", async () => {
const store = createStore();
const auditEmit = vi.fn();
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).toMatchObject({
ok: true,
taskId: "FN-5279",
worktreePath: "/tmp/task-worktree",
branch: "fusion/fn-5279",
});
expect(store.acquireMergeQueueLease).toHaveBeenCalledWith(
"merger-reuse-handoff",
expect.objectContaining({ leaseDurationMs: 900000, targetTaskId: "FN-5279" }),
);
await releaseReuseHandoff({ handoff, outcome: "success", auditEmit });
expect(store.releaseMergeQueueLease).toHaveBeenCalledWith("FN-5279", "merger-reuse-handoff", { kind: "success" });
expect(auditEmit).toHaveBeenCalledWith({
type: "merge:reuse-handoff-released",
target: "/tmp/task-worktree",
metadata: expect.objectContaining({ taskId: "FN-5279", outcome: "success" }),
});
});
it("autostashes dirty reused worktrees instead of refusing the handoff", async () => {
const stashSha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git diff -z --name-only") return Buffer.from("packages/engine/src/merger.ts\0");
if (command === "git diff -z --cached --name-only") return Buffer.from("");
if (command === "git status -z --porcelain") return Buffer.from("?? stray.txt\0");
if (command === "git diff HEAD") return Buffer.from("diff --git a/x b/x\n");
if (command === "git rev-parse --abbrev-ref HEAD") return Buffer.from("fusion/fn-5279\n");
if (command === "git add -A") return Buffer.from("");
if (command === "git stash create") return Buffer.from(`${stashSha}\n`);
if (command.startsWith("git stash store ")) return Buffer.from("");
if (command === "git reset --hard HEAD") return Buffer.from("");
if (command === "git clean -fd") return Buffer.from("");
return Buffer.from("");
});
const store = createStore();
const auditEmit = vi.fn();
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).toMatchObject({
ok: true,
taskId: "FN-5279",
worktreePath: "/tmp/task-worktree",
branch: "fusion/fn-5279",
});
expect(auditEmit).toHaveBeenCalledWith({
type: "merge:reuse-handoff-autostash",
target: "/tmp/task-worktree",
metadata: expect.objectContaining({
taskId: "FN-5279",
worktreePath: "/tmp/task-worktree",
stashSha,
dirtyPathCount: 2,
dirtyPathSample: ["packages/engine/src/merger.ts", "stray.txt"],
}),
});
});
it("refuses the handoff when autostash of a dirty worktree itself fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git diff -z --name-only") return Buffer.from("packages/engine/src/merger.ts\0");
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("diff --git a/x b/x\n");
if (command === "git rev-parse --abbrev-ref HEAD") return Buffer.from("fusion/fn-5279\n");
if (command === "git add -A") return Buffer.from("");
if (command === "git stash create") return Buffer.from("");
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",
}),
"working-tree-dirty",
"dirty-worktree-autostash-failed",
);
expect(refusal.payload).toMatchObject({
dirtyPaths: ["packages/engine/src/merger.ts"],
});
});
it("attempts FN-5083 case canonicalization before continuing", async () => {
const store = createStore();
const auditEmit = vi.fn();
let reads = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const command = String(cmd);
if (command === "git rev-parse --abbrev-ref HEAD") {
reads += 1;
return Buffer.from(reads === 1 ? "fusion/FN-5279\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("");
return Buffer.from("");
});
await acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
auditEmit,
});
expect(branchAutocorrect.attemptBranchAutocorrect).toHaveBeenCalledWith({
worktreePath: "/tmp/task-worktree",
observedBranch: "fusion/FN-5279",
expectedBranch: "fusion/fn-5279",
rootDir: "/tmp/project-root",
});
expect(auditEmit).toHaveBeenCalledWith({
type: "branch:auto-canonicalize-case",
target: "/tmp/task-worktree",
metadata: expect.objectContaining({
taskId: "FN-5279",
observed: "fusion/FN-5279",
expected: "fusion/fn-5279",
}),
});
});
it("refuses wrong-branch heads when canonicalization cannot recover them", async () => {
vi.spyOn(branchAutocorrect, "attemptBranchAutocorrect").mockResolvedValue({ status: "failed", reason: "nope" });
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("");
return Buffer.from("");
});
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",
);
});
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", {
taskId: "FN-5279",
kind: "executor",
ownerKey: "FN-5279",
});
// FN-5256: backdate so the new min-idle window doesn't refuse the reconcile.
(activeSessionRegistry.lookupByPath("/tmp/task-worktree") as any).registeredAt = 0;
await acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
});
expect(activeSessionRegistry.lookupByPath("/tmp/task-worktree")).toBeNull();
});
it("refuses live active session bindings", async () => {
const store = createStore();
activeSessionRegistry.registerPath("/tmp/task-worktree", {
taskId: "FN-5279",
kind: "executor",
ownerKey: "FN-5279",
});
executingTaskLock.tryClaim("FN-5279");
const refusal = await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"active-session-binding",
"active-session-present",
);
expect(refusal.payload).toMatchObject({
activeRecord: expect.objectContaining({ taskId: "FN-5279" }),
executingTaskLockHeld: true,
});
});
it("refuses non-canonical branch/worktree mappings", async () => {
vi.spyOn(worktreePool, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-5279", "/tmp/elsewhere"]]));
const store = createStore();
await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"branch-worktree-mapping",
"registered-branch-mismatch",
);
});
it("refuses live executor leases", async () => {
const store = createStore({
checkedOutBy: "agent-1",
checkedOutAt: new Date().toISOString(),
checkoutLeaseRenewedAt: new Date().toISOString(),
});
await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"lease-handoff-failed",
"executor-lease-active",
);
});
it("refuses central-claim conflicts when surfaced by the store shim", async () => {
const store = createStore() as any;
store.projectId = "project-1";
store.getTaskClaim = vi.fn().mockReturnValue({ ownerAgentId: "agent-2" });
await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"lease-handoff-failed",
"central-conflict",
);
});
it("surfaces pool double-lease failures with structured diagnostics", async () => {
const store = createStore();
store.acquireMergeQueueLease.mockImplementation(() => {
throw new PoolDoubleLeaseError("/tmp/task-worktree", "FN-1234", "FN-5279", "acquire");
});
const refusal = await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"lease-handoff-failed",
"pool-double-lease",
);
expect(refusal.payload).toMatchObject({
existingHolder: "FN-1234",
path: "/tmp/task-worktree",
phase: "acquire",
});
});
it("FN-5444: no-lease refusal carries queue-head diagnostics and nulls when head is absent", async () => {
const store = createStore();
store.acquireMergeQueueLease.mockReturnValue({ taskId: "FN-5329" });
store.peekMergeQueueHead.mockReturnValue({ taskId: "FN-5329", leasedBy: "merger-reuse-handoff", column: "todo" });
const refusalWithHead = await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"lease-handoff-failed",
"no-lease",
);
expect(refusalWithHead.payload).toMatchObject({
taskId: "FN-5279",
worktreePath: "/tmp/task-worktree",
acquiredTaskId: "FN-5329",
queueHeadTaskId: "FN-5329",
queueHeadLeasedBy: "merger-reuse-handoff",
});
store.acquireMergeQueueLease.mockReturnValue({ taskId: "FN-5329" });
store.peekMergeQueueHead.mockReturnValue(null);
const refusalWithoutHead = await expectRefusal(
acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
}),
"lease-handoff-failed",
"no-lease",
);
expect(refusalWithoutHead.payload).toMatchObject({
queueHeadTaskId: null,
queueHeadLeasedBy: null,
});
});
// FN-5363 regression: when the merge queue head is polluted with unrelated tasks
// (e.g. FN-5329, FN-5321, FN-5349), acquiring a lease for a different task (FN-5279)
// via targetTaskId must succeed — not grab the queue head and then fail with
// "no-lease" because the returned taskId didn't match.
it("targets specific task via targetTaskId even when queue head is a different task", async () => {
const store = createStore();
// Simulate queue head = different task (polluted queue scenario)
store.acquireMergeQueueLease = vi.fn().mockReturnValue({ taskId: "FN-5279" });
const handoff = await acquireReuseHandoff({
task: await store.getTask("FN-5279"),
store,
projectRoot: "/tmp/project-root",
settings: {} as any,
worktreePath: "/tmp/task-worktree",
});
expect(handoff.taskId).toBe("FN-5279");
expect(store.acquireMergeQueueLease).toHaveBeenCalledWith(
"merger-reuse-handoff",
expect.objectContaining({ targetTaskId: "FN-5279" }),
);
});
});
describe("aiMergeTask integration-root behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("detaches the reused task worktree at the integration branch before merging", async () => {
const store = createMockStore({
id: "FN-5279",
worktree: "/tmp/task-worktree",
branch: "fusion/fn-5279",
baseBranch: "master",
}) as any;
store.getTask.mockResolvedValue({
...(await store.getTask("FN-5279")),
id: "FN-5279",
worktree: "/tmp/task-worktree",
branch: "fusion/fn-5279",
baseBranch: "master",
checkedOutBy: undefined,
checkedOutAt: undefined,
checkoutLeaseRenewedAt: undefined,
checkoutNodeId: undefined,
checkoutRunId: undefined,
checkoutLeaseEpoch: undefined,
});
store.getSettings.mockResolvedValue({
...await store.getSettings(),
mergeIntegrationWorktree: "reuse-task-worktree",
worktreeRebaseBeforeMerge: true,
worktreeRebaseLocalBase: false,
worktreeRebaseRemote: "origin",
baseBranch: "master",
});
store.acquireMergeQueueLease = vi.fn().mockReturnValue({ taskId: "FN-5279" });
store.releaseMergeQueueLease = vi.fn();
store.enqueueMergeQueue = vi.fn();
store.listTasks.mockResolvedValue([{ id: "FN-5279", column: "in-review", worktree: "/tmp/task-worktree" }]);
const baseImpl = mockedExecSync.getMockImplementation();
mockedExecSync.mockImplementation((cmd: any, opts: any) => {
const command = String(cmd);
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 === "git rev-parse --abbrev-ref HEAD") return Buffer.from("fusion/fn-5279\n");
if (command === 'git fetch "origin" "master"') return Buffer.from("");
if (command === 'git fetch "origin"') return Buffer.from("");
if (command === 'git checkout --detach "master"') return Buffer.from("");
if (command === 'git rebase "origin/master"') return Buffer.from("");
if (command === 'git rev-list --left-right --count "origin/master...HEAD"') return Buffer.from("0\t0");
return baseImpl ? baseImpl(cmd, opts) : Buffer.from("");
});
const { aiMergeTask } = await import("../merger.js");
await aiMergeTask(store, "/tmp/project-root", "FN-5279");
expect(
mockedExec.mock.calls.some(([command, opts]) =>
String(command) === 'git checkout --detach "master"' && (opts as any)?.cwd === "/tmp/task-worktree",
),
).toBe(true);
expect(
mockedExec.mock.calls.some(([command, opts]) =>
String(command) === 'git fetch "origin" "master"' && (opts as any)?.cwd === "/tmp/task-worktree",
),
).toBe(true);
});
});