feat(FN-5279): add merge integration worktree feature with settings UI and

Implements the `mergeIntegrationWorktree` setting (FN-5279) that allows tasks to reuse their own worktree as the integration root instead of spawning a separate one. Steps 1–2 added the settings schema, types, and SettingsModal surface with documentation; Step 3 wired an integration-root resolver th

Fusion-Task-Id: FN-5279
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 08:41:09 -07:00
committed by gsxdsm
parent 807dc5c8df
commit 22d59b5f9a
43 changed files with 2081 additions and 247 deletions

View File

@@ -42,6 +42,7 @@ function runFinalize(dir: string, taskId: string, branch: string, preAttemptHead
undefined,
{
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
commitAuthorEnabled: false,
},
undefined,

View File

@@ -37,6 +37,7 @@ function createStore(task: Task, settings: Partial<Settings>): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "direct",
directMergeCommitStrategy: "auto",
autoMerge: true,

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -562,7 +565,7 @@ describe("resolveTaskDiffBaseRef", () => {
const cmdStr = String(cmd);
// No baseBranch → outer merge-base block is skipped.
// Display recovery: merge-base(HEAD, main).
if (cmdStr === 'git merge-base "HEAD" main') return "current-main-sha" as any;
if (cmdStr === 'git merge-base "HEAD" "main"') return "current-main-sha" as any;
// baseCommitSha is still an ancestor of HEAD…
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "HEAD"') return "" as any;
// …and recoveredBase descends baseCommitSha (rebase fast-forwarded).
@@ -575,6 +578,7 @@ describe("resolveTaskDiffBaseRef", () => {
headRef: "HEAD",
baseBranch: undefined,
baseCommitSha: "old-base-sha",
integrationBranchFallback: "main",
});
expect(diffBase).toBe("current-main-sha");
@@ -587,7 +591,7 @@ describe("resolveTaskDiffBaseRef", () => {
it("keeps baseCommitSha when recoveredBase does not descend it", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" main') return "unrelated-main-sha" as any;
if (cmdStr === 'git merge-base "HEAD" "main"') return "unrelated-main-sha" as any;
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "HEAD"') return "" as any;
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "unrelated-main-sha"') {
throw new Error("not an ancestor");
@@ -600,6 +604,7 @@ describe("resolveTaskDiffBaseRef", () => {
headRef: "HEAD",
baseBranch: undefined,
baseCommitSha: "feature-base-sha",
integrationBranchFallback: "main",
});
expect(diffBase).toBe("feature-base-sha");

View File

@@ -66,7 +66,8 @@ function createMockStore() {
logEntry: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue({ id: "FN-4072", column: "in-review", prompt: "# test" }),
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS, commitAuthorEnabled: false }),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false }),
} as any;
}
@@ -86,7 +87,8 @@ function mergeAttemptParams(dir: string, branch: string, preAttemptHeadSha: stri
attemptNum: 3,
options: {},
result: {},
settings: { ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
settings: { ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
preAttemptHeadSha,
} as any;
}
@@ -351,7 +353,8 @@ describe("diff-volume gate merger integration", () => {
preAttemptHeadSha,
"",
"1 file changed",
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
{ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
undefined,
null,
null,
@@ -390,7 +393,8 @@ describe("diff-volume gate merger integration", () => {
preAttemptHeadSha,
"",
"1 file changed",
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
{ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
undefined,
null,
null,

View File

@@ -37,6 +37,7 @@ function createStore(task: Task, settings: Partial<Settings>): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "direct",
directMergeCommitStrategy: "always-rebase",
mergeConflictStrategy: "fail-fast",

View File

@@ -37,6 +37,7 @@ function createStore(task: Task, settings: Partial<Settings>): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "direct",
directMergeCommitStrategy: "always-rebase",
mergeConflictStrategy: "fail-fast",

View File

@@ -32,6 +32,7 @@ function createStore(task: Task, settings: Partial<Settings> = {}): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "direct",
directMergeCommitStrategy: "auto",
autoMerge: true,

View File

@@ -96,7 +96,8 @@ describe("commitOrAmendMergeWithFixes gitignored guard", () => {
preAttemptHeadSha,
"",
undefined,
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
{ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
undefined,
null,
null,

View File

@@ -0,0 +1,516 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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,
MergeHandoffRefusedError,
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("preserves the legacy cwd-main mode when explicitly selected", () => {
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-main",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
it("uses the project root 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: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
it("defers to the project root when worktrunk owns merge orchestration", () => {
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: "cwd-main",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
});
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("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();
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 }),
);
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("refuses dirty reused worktrees with diagnostics", 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("?? 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");
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",
);
expect(refusal.payload).toMatchObject({
dirtyPaths: ["packages/engine/src/merger.ts", "stray.txt"],
});
expect(refusal.payload.dirtyFingerprint).toEqual(expect.any(String));
});
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("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",
});
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("refuses when no merge queue lease can be acquired", async () => {
const store = createStore();
store.acquireMergeQueueLease.mockReturnValue(null);
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",
);
});
});
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.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);
});
});

View File

@@ -16,7 +16,12 @@ describe("FN-4646 aiMergeTask landedFiles capture", () => {
{ id: "FN-4646", worktree: "/tmp/root/.worktrees/FN-4646" },
[{ id: "FN-4646", worktree: "/tmp/root/.worktrees/FN-4646", column: "in-review" } as Task],
);
(store.getSettings as any).mockResolvedValue({ includeTaskIdInCommit: true, mergeConflictStrategy: "smart-prefer-main", ...settings });
(store.getSettings as any).mockResolvedValue({
includeTaskIdInCommit: true,
mergeConflictStrategy: "smart-prefer-main",
mergeIntegrationWorktree: "cwd-main" as const,
...settings,
});
return store;
}

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -297,6 +300,7 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
includeTaskIdInCommit: false,
});
@@ -359,6 +363,7 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
includeTaskIdInCommit: false,
});
@@ -399,6 +404,7 @@ describe("aiMergeTask — model settings threading", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
@@ -782,6 +788,7 @@ describe("aiMergeTask — merge details collection", () => {
mockedExistsSync.mockReturnValue(false);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
directMergeCommitStrategy: "always-rebase",
});
@@ -816,6 +823,7 @@ describe("aiMergeTask — merge details collection", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
useAiMergeCommitSummary: true,
});
@@ -873,6 +881,7 @@ describe("aiMergeTask — merge details collection", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
useAiMergeCommitSummary: true,
});
@@ -972,6 +981,7 @@ describe("aiMergeTask — merge details collection", () => {
// test isn't about prefer-main semantics, so opt out.
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-branch",
});

View File

@@ -200,7 +200,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
worktree: "/tmp/root",
steps: [],
currentStep: 0,
log: [],
@@ -217,7 +217,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -328,7 +331,7 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
if (cmdStr.includes("rev-list --left-right --count")) {
return `${behind}\t${ahead}` as any;
}
if (cmdStr.includes("git fetch origin")) {
if (cmdStr.includes('git fetch "origin" "main"') || cmdStr.includes("git fetch origin main")) {
fetchCalled = true;
if (fetchFails) throw new Error("fatal: unable to access remote");
return Buffer.from("");
@@ -359,8 +362,8 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
it("fast-forwards local main when origin is strictly ahead (default smart-prefer-main)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 2, ahead: 0 });
@@ -372,8 +375,8 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
it("skips fast-forward when local main has unpushed commits (divergent)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 1, ahead: 1 });
@@ -385,8 +388,8 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
it("continues merge when fetch fails (graceful degrade)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 0, ahead: 0, fetchFails: true });
@@ -399,11 +402,12 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
it("does not fetch for ai-only strategy", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "ai-only",
});
const probe = setupSyncMock({ behind: 5, ahead: 0 });
@@ -415,11 +419,12 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
it("normalizes legacy 'smart' setting and still fetches", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart" as any,
});
const probe = setupSyncMock({ behind: 1, ahead: 0 });
@@ -463,6 +468,7 @@ describe("aiMergeTask abort handling", () => {
const store = createMockStore();
store.getSettings = vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "pnpm test",
});
@@ -507,7 +513,7 @@ describe("aiMergeTask autostash cleanup", () => {
});
it("drops task autostash after successful merge restore", async () => {
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" });
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root" });
const stashSha = "1111111111111111111111111111111111111111";
let dropped = false;
@@ -564,7 +570,7 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
it("does NOT remove worktree when another task references the same path", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const worktreePath = "/tmp/root";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
@@ -584,7 +590,7 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
it("removes worktree when no other task references it", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const worktreePath = "/tmp/root";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
@@ -602,7 +608,7 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
it("clears task.worktree/branch after the worktree is removed", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const worktreePath = "/tmp/root";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
@@ -625,7 +631,7 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
it("always deletes the branch regardless of worktree sharing", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const worktreePath = "/tmp/root";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
@@ -645,7 +651,7 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
it("result.worktreeRemoved is false when worktree is retained", async () => {
const worktreePath = "/tmp/root/.worktrees/KB-050";
const worktreePath = "/tmp/root";
const store = createMockStore(
{ id: "FN-050", worktree: worktreePath },
[
@@ -675,12 +681,13 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
it("attempts rebase abort in the task worktree and continues merge when abort succeeds", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
// Fall-through is only allowed for prefer-branch; prefer-main hard-fails (see test below).
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-branch",
});
@@ -718,7 +725,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
&& typeof options === "object"
&& options !== null
&& "cwd" in options
&& (options as { cwd?: string }).cwd === "/tmp/root/.worktrees/KB-050",
&& (options as { cwd?: string }).cwd === "/tmp/root",
),
).toBe(true);
expect(mockedExecSync.mock.calls.some(([command]) => String(command).includes("merge --squash"))).toBe(true);
@@ -726,11 +733,12 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
it("logs abort cleanup failure details but still falls through to merge", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-branch",
});
@@ -783,11 +791,12 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
// successfully. Only when BOTH are explicitly disabled is the
// configuration incoherent.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
worktreeRebaseBeforeMerge: false,
worktreeRebaseLocalBase: false,
@@ -811,11 +820,12 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
// the stages are independent — local-base runs even when remote can't
// resolve, providing prefer-main with a usable safety net.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
});
@@ -855,11 +865,12 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
it("does not silently fall through to -X ours when smart-prefer-main rebase aborts and recovery layers 1+2 fail", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
});
@@ -919,6 +930,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
});
@@ -996,8 +1008,8 @@ describe("aiMergeTask — task.branch field", () => {
it("uses task.branch when set instead of deriving from task ID", async () => {
const store = createMockStore(
{ id: "FN-050", branch: "fusion/fn-050-2", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", branch: "fusion/fn-050-2", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1019,8 +1031,8 @@ describe("aiMergeTask — task.branch field", () => {
it("falls back to conventional branch name when task.branch is not set", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1048,7 +1060,7 @@ describe("aiMergeTask — merge-target branch resolution", () => {
id: "FN-050",
branch: "feature/fn-050-work",
baseBranch: "release/2026-05",
worktree: "/tmp/root/.worktrees/KB-050",
worktree: "/tmp/root",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1077,7 +1089,7 @@ describe("aiMergeTask — merge-target branch resolution", () => {
id: "FN-050",
branch: "feature/fn-050-work",
baseBranch: undefined,
worktree: "/tmp/root/.worktrees/KB-050",
worktree: "/tmp/root",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1269,8 +1281,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 1 success: sets resolutionStrategy to 'ai' and attemptsMade to 1", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
// Clean merge with no conflicts - simulate empty diff for conflicts
@@ -1300,11 +1312,12 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("with autoResolveConflicts disabled: only makes 1 attempt on conflict", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
autoResolveConflicts: false, // Disabled
});
@@ -1367,8 +1380,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
// the cascade fell into "all conflicts auto-resolved" and returned true,
// recording merge metadata for a merge that never happened.
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
let mergeCallCount = 0;
@@ -1399,8 +1412,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
let mergeCallCount = 0;
@@ -1446,13 +1459,14 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 3 uses -X theirs strategy: sets resolutionStrategy to 'theirs'", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
// Pin the strategy: default is now "smart-prefer-main" (-X ours), but
// this test specifically exercises the -X theirs fallback path.
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-branch",
});
@@ -1534,12 +1548,13 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("attempt 3 under smart-prefer-main restores overlapping files from the branch by default", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
});
@@ -1606,12 +1621,13 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("warn-only logs overlap but keeps legacy -X ours fallback", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "warn-only",
});
@@ -1667,12 +1683,13 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("ignore preserves legacy behavior and skips overlap detection", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "ignore",
});
@@ -1720,8 +1737,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("final cleanup reset succeeds after all 3 attempts fail", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const resetCalls: string[] = [];
@@ -1788,8 +1805,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("final cleanup reset failure is logged but does not change thrown error", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const resetFailureMessage = "reset failed: dirty worktree";
@@ -1859,8 +1876,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("retry-cleanup reset failure after attempt 1 is logged and merge continues to attempt 2", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
@@ -1919,12 +1936,13 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("build-retry reset failure is logged when build verification fails", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildRetryCount: 1,
verificationFixRetries: 0,
});
@@ -1985,8 +2003,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("error-path retry cleanup reset failure is logged and merge still retries", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
@@ -2072,8 +2090,8 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
// Clean merge with no conflicts
@@ -2109,8 +2127,8 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
it("retry-cleanup reset failure after failed attempt is logged", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
@@ -2192,8 +2210,8 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
it("error-path retry cleanup reset failure is logged", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
const warnSpy = vi.spyOn(mergerLog, "warn");
@@ -2266,11 +2284,12 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
it("build-verification reset failure is logged in executeMergeAttempt", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
{ id: "FN-050", worktree: "/tmp/root" },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
buildRetryCount: 0,
verificationFixRetries: 0,
@@ -2405,11 +2424,12 @@ describe("aiMergeTask post-squash audit gate", () => {
taskOverrides: Partial<Task> & { prompt?: string } = {},
) {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", ...taskOverrides } as Task & { prompt?: string },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review", ...taskOverrides } as Task & { prompt?: string }],
{ id: "FN-050", worktree: "/tmp/root", ...taskOverrides } as Task & { prompt?: string },
[{ id: "FN-050", worktree: "/tmp/root", column: "in-review", ...taskOverrides } as Task & { prompt?: string }],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "pnpm test",
mergeConflictStrategy: "ai-only",
worktreeRebaseBeforeMerge: false,

View File

@@ -41,6 +41,7 @@ function assertIsolatedWorkspace(dir: string): void {
const STUB_SETTINGS = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
commitAuthorEnabled: false,
};

View File

@@ -117,6 +117,7 @@ function makeStore(dir: string, taskId: string, settingsOverrides: Record<string
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
commitAuthorEnabled: false,
mergeConflictStrategy: "smart-prefer-main",
...settingsOverrides,

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -469,6 +472,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
defaultProvider: "anthropic",
@@ -681,6 +685,7 @@ describe("aiMergeTask — post-merge workflow steps", () => {
// Override settings to include scripts
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
scripts: { build: "pnpm build" },
});

View File

@@ -32,7 +32,13 @@ describe("aiMergeTask — post-push mergeDetails stats refresh", () => {
prompt: "# test",
} as Task;
(store.getTask as any).mockResolvedValue(task);
(store.getSettings as any).mockResolvedValue({ pushAfterMerge: true, pushRemote: "origin", includeTaskIdInCommit: true, mergeConflictStrategy: "smart-prefer-main" });
(store.getSettings as any).mockResolvedValue({
pushAfterMerge: true,
pushRemote: "origin",
includeTaskIdInCommit: true,
mergeConflictStrategy: "smart-prefer-main",
mergeIntegrationWorktree: "cwd-main" as const,
});
return store;
}

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -351,6 +354,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
@@ -388,6 +392,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "pull-request",
pushAfterMerge: true,
pushRemote: "origin",
@@ -416,6 +421,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
@@ -444,6 +450,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "upstream main",
});
@@ -498,6 +505,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
@@ -557,6 +565,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
@@ -596,6 +605,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
@@ -644,6 +654,7 @@ describe("push-after-merge", () => {
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});

View File

@@ -100,7 +100,10 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -118,7 +121,8 @@ describe("aiMergeTask rebaseBaseSha persistence", () => {
it("stores rebaseBaseSha for rebase-routed merges", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS, directMergeCommitStrategy: "always-rebase" }),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, directMergeCommitStrategy: "always-rebase" }),
});
mockedExecSync.mockImplementation((cmd: any) => {
@@ -147,6 +151,7 @@ describe("aiMergeTask rebaseBaseSha persistence", () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
directMergeCommitStrategy: "always-rebase",
postMergeAuditMode: "off",
}),

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -462,6 +465,7 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
smartConflictResolution: true, // Enable all 3 attempts
});
@@ -565,6 +569,7 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
smartConflictResolution: true,
});

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -301,6 +304,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -348,6 +352,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -388,6 +393,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -421,6 +427,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
// No agentStore provided
@@ -451,6 +458,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -497,6 +505,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -540,6 +549,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -602,6 +612,7 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {
@@ -644,6 +655,7 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const mockAgentStore = {

View File

@@ -92,7 +92,10 @@ function createMockStore(column: Task["column"]): TaskStore {
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),

View File

@@ -86,6 +86,7 @@ function assertIsolatedWorkspace(dir: string): void {
const STUB_SETTINGS = {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
commitAuthorEnabled: false, // skip --author flag to avoid user config issues
};

View File

@@ -61,7 +61,8 @@ describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
preAttemptHeadSha,
"",
undefined,
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
{ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false },
undefined,
null,
null,

View File

@@ -184,7 +184,10 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
@@ -342,6 +345,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
});
@@ -387,6 +391,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
});
@@ -439,6 +444,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
verificationFixRetries: 0, // Disable in-merge fix for this test
});
@@ -466,6 +472,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
verificationFixRetries: 0,
});
@@ -547,6 +554,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: " ", // whitespace-only, should be treated as undefined
});
@@ -600,6 +608,7 @@ describe("aiMergeTask — build verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
});
@@ -704,6 +713,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
@@ -745,6 +755,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -819,6 +830,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 0,
});
@@ -876,6 +888,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -945,6 +958,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -1014,6 +1028,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
@@ -1083,6 +1098,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -1138,6 +1154,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
// Neither testCommand nor buildCommand configured
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1186,6 +1203,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(cacheHit);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
@@ -1248,6 +1266,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(null);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -1304,6 +1323,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -1328,6 +1348,7 @@ describe("aiMergeTask — deterministic merge verification", () => {
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 0,
});
@@ -1373,7 +1394,8 @@ describe("aiMergeTask — deterministic merge verification", () => {
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn(), dispose: vi.fn() } } as any);
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", verificationFixRetries: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", verificationFixRetries: 0 });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
@@ -1406,7 +1428,8 @@ describe("aiMergeTask — deterministic merge verification", () => {
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn(), dispose: vi.fn() } } as any);
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", verificationFixRetries: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", verificationFixRetries: 0 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError",
@@ -1449,7 +1472,8 @@ describe("aiMergeTask — deterministic merge verification", () => {
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn(), dispose: vi.fn() } } as any);
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", verificationFixRetries: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", verificationFixRetries: 0 });
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1485,7 +1509,8 @@ describe("aiMergeTask — deterministic merge verification", () => {
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn(), dispose: vi.fn() } } as any);
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", verificationFixRetries: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", verificationFixRetries: 0 });
try {
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1735,6 +1760,7 @@ describe("aiMergeTask — inferred test command execution", () => {
// testCommand is not set (undefined in DEFAULT_SETTINGS)
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1770,6 +1796,7 @@ describe("aiMergeTask — inferred test command execution", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1816,6 +1843,7 @@ describe("aiMergeTask — inferred test command execution", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
@@ -1867,6 +1895,7 @@ describe("aiMergeTask — inferred test command execution", () => {
// Explicit testCommand is set
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});
@@ -1905,6 +1934,7 @@ describe("aiMergeTask — inferred test command execution", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
@@ -1965,6 +1995,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 1,
});
@@ -2034,7 +2065,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toMatchObject({
branchDeleted: true,
@@ -2063,7 +2095,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
});
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task]);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 2 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 2 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
});
@@ -2087,7 +2120,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
});
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task]);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1, buildRetryCount: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1, buildRetryCount: 0 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toMatchObject({
branchDeleted: true,
});
@@ -2114,7 +2148,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
});
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
const store = createMockStore({ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task]);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 2, buildRetryCount: 0 });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 2, buildRetryCount: 0 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
});
@@ -2174,6 +2209,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 1,
});
@@ -2265,6 +2301,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
buildCommand: "pnpm build",
verificationFixRetries: 1,
buildRetryCount: 0,
@@ -2326,6 +2363,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 0,
});
@@ -2384,6 +2422,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 1,
defaultProvider: "anthropic",
@@ -2438,6 +2477,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 0,
defaultProvider: "anthropic",
@@ -2504,6 +2544,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 1,
});
@@ -2553,6 +2594,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
verificationFixRetries: 10, // Exceeds max
});
@@ -2604,6 +2646,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
// Use core defaults (verificationFixRetries defaults to 3)
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "vitest run",
});

View File

@@ -23,6 +23,7 @@ function makeStore(task: Task, settings: Partial<Settings> = {}, events: unknown
includeTaskIdInCommit: false,
commitAuthorEnabled: false,
useAiMergeCommitSummary: false,
mergeIntegrationWorktree: "cwd-main" as const,
...settings,
} as Settings;
return Object.assign(emitter, {

View File

@@ -0,0 +1,455 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("../../pi.js", () => ({
createFnAgent: vi.fn(async () => ({
prompt: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined),
})),
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<unknown> }, prompt: string) => {
await session.prompt(prompt);
}),
compactSessionContext: vi.fn(),
}));
import { activeSessionRegistry, executingTaskLock } from "../../active-session-registry.js";
import { aiMergeTask } from "../../merger.js";
import { createFnAgent } from "../../pi.js";
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
it.skipIf(!hasGit)("happy path merges from a reused task worktree without mutating the project root", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-HAPPY",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
worktreeRebaseRemote: "origin",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-happy.ts", "export const value = 1;\n", "feat: add reuse merge content");
await fixture.checkout("master");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
store.enqueueMergeQueue(task.id);
const rootHeadBefore = git(rootDir, "git rev-parse HEAD");
const rootTrackedStatusBefore = git(rootDir, "git status --porcelain --untracked-files=no");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
const mergedTask = await store.getTask(task.id);
expect(mergedTask?.column).toBe("done");
const audits = store.getRunAuditEvents({ taskId: task.id });
const auditTypes = audits.map((event) => event.mutationType);
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
expect(auditTypes).toContain("merge:reuse-handoff-released");
const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired");
expect(acquired?.metadata).toMatchObject({ integrationRemote: "origin", integrationBranch: "master" });
expect(git(rootDir, "git rev-parse HEAD")).toBe(rootHeadBefore);
expect(git(rootDir, "git status --porcelain --untracked-files=no")).toBe(rootTrackedStatusBefore);
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("dirty reused worktree refuses handoff and leaves the task in review", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-DIRTY",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-dirty.ts", "export const dirty = true;\n", "feat: add dirty merge content");
await fixture.checkout("master");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
store.enqueueMergeQueue(task.id);
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
name: "MergeHandoffRefusedError",
gate: "working-tree-dirty",
});
expect((await store.getTask(task.id))?.column).toBe("in-review");
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
expect(refused?.metadata).toMatchObject({ gate: "working-tree-dirty" });
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("active session binding refuses handoff until the worktree is released", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-ACTIVE",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-active.ts", "export const active = true;\n", "feat: add active merge content");
await fixture.checkout("master");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
store.enqueueMergeQueue(task.id);
activeSessionRegistry.registerPath(worktreePath, { taskId: task.id, kind: "executor", ownerKey: task.id });
executingTaskLock.tryClaim(task.id);
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
name: "MergeHandoffRefusedError",
gate: "active-session-binding",
});
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
expect(refused?.metadata).toMatchObject({ gate: "active-session-binding" });
} finally {
activeSessionRegistry.clear();
executingTaskLock._clearForTest();
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("branch/worktree mapping mismatches refuse handoff", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-MISMATCH",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch: "fusion/fn-other",
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-mismatch.ts", "export const mismatch = true;\n", "feat: add mismatch merge content");
await fixture.checkout("master");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath } as any);
store.enqueueMergeQueue(task.id);
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
name: "MergeHandoffRefusedError",
gate: "branch-worktree-mapping",
});
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
expect(refused?.metadata).toMatchObject({ gate: "branch-worktree-mapping" });
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("missing merge queue lease refuses handoff with no-lease diagnostics", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-NO-LEASE",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-no-lease.ts", "export const noLease = true;\n", "feat: add no-lease merge content");
await fixture.checkout("master");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
name: "MergeHandoffRefusedError",
gate: "lease-handoff-failed",
reason: "no-lease",
});
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
expect(refused?.metadata).toMatchObject({ gate: "lease-handoff-failed", reason: "no-lease" });
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("already-landed branch auto-finalizes from the reused worktree path", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-ALREADY-LANDED",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-already-landed.ts", "export const landed = true;\n", "feat: add already-landed merge content");
await fixture.checkout("master");
git(rootDir, `git merge --ff-only ${JSON.stringify(branch)}`);
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
store.enqueueMergeQueue(task.id);
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect(result.mergeConfirmed).toBe(true);
expect((await store.getTask(task.id))?.column).toBe("done");
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
expect(auditTypes).toContain("merge:reuse-handoff-released");
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("Layer 3 conflict resolution sessions run from the reused worktree", async () => {
mockedCreateFnAgent.mockClear();
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-LAYER3",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
mergeConflictStrategy: "smart-prefer-main",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
prompt: "## File Scope\n- packages/engine/src/**\n",
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-layer3.ts", "export const value = 'branch';\n", "feat: branch conflict content");
await fixture.checkout("master");
git(rootDir, "mkdir -p packages/engine/src");
git(rootDir, "sh -c \"printf \\\"export const value = 'main';\\n\\\" > packages/engine/src/fn-5279-ri-layer3.ts\"");
git(rootDir, "git add packages/engine/src/fn-5279-ri-layer3.ts");
git(rootDir, "git commit -m 'feat: main conflict content'");
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
store.enqueueMergeQueue(task.id);
await aiMergeTask(store, rootDir, task.id);
expect(
mockedCreateFnAgent.mock.calls.some(([input]) => (input as any)?.cwd === worktreePath),
).toBe(true);
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("cwd-main mode stays on the legacy path and emits no reuse handoff events", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-CWD-MAIN",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "cwd-main" as const,
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-cwd-main.ts", "export const legacy = true;\n", "feat: add cwd-main merge content");
await fixture.checkout("master");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
expect(auditTypes.filter((type) => type.startsWith("merge:reuse-handoff"))).toHaveLength(0);
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("worktrunk override records deferred-to-worktrunk without acquiring reuse handoff", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-WORKTRUNK",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
worktrunk: { enabled: true } as any,
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
git(rootDir, "git branch -m main master");
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
await store.updateTask(task.id, {
baseBranch: "master",
branch,
steps: completedSteps,
currentStep: completedSteps.length,
} as any);
await fixture.createBranch(branch);
await fixture.writeAndCommit("packages/engine/src/fn-5279-ri-worktrunk.ts", "export const deferred = true;\n", "feat: add worktrunk merge content");
await fixture.checkout("master");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
expect(auditTypes).toContain("merge:reuse-handoff-deferred-to-worktrunk");
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
} finally {
await fixture.cleanup();
}
}, 30_000);
it.skipIf(!hasGit)("autoMerge off remains inert and emits no reuse handoff events", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-AUTO-OFF",
settings: {
autoMerge: false,
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
const { rootDir, store, task } = fixture;
const actualTask = await store.getTask(task.id);
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
const worktreeRoot = `${rootDir}-worktrees`;
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
git(rootDir, "git branch -m main master");
await fixture.createBranch(branch);
await fixture.checkout("master");
await store.updateTask(task.id, { baseBranch: "master", worktree: worktreePath, branch } as any);
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
const latest = await store.getTask(task.id);
expect(latest?.column).toBe("in-review");
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
expect(auditTypes.filter((type) => type.startsWith("merge:reuse-handoff"))).toHaveLength(0);
} finally {
await fixture.cleanup();
}
}, 20_000);
});

View File

@@ -24,7 +24,7 @@ describe("FN-4954 reliability interactions: merger pooled release ordering", ()
const fixture = await makeReliabilityFixture({
taskId: "FN-4954-RI-A",
task: { steps: [] as any[] },
settings: { recycleWorktrees: true },
settings: { recycleWorktrees: true, mergeIntegrationWorktree: "cwd-main" as const },
});
try {
@@ -46,6 +46,7 @@ describe("FN-4954 reliability interactions: merger pooled release ordering", ()
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
await store.updateTask(task.id, { branch, worktree: worktreePath });
await store.moveTask(task.id, "in-review");
store.enqueueMergeQueue(task.id);
const pool = new WorktreePool();
const result = await aiMergeTask(store, rootDir, task.id, { pool });

View File

@@ -288,7 +288,10 @@ function createMockStore(overrides: Record<string, any> = {}) {
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
}),
setPluginWorkflowStepTemplates: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
getFusionDir: vi.fn().mockReturnValue("/tmp/root/.fusion"),
@@ -518,6 +521,7 @@ describe("In-progress task resume after restart", () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
worktreeInitCommand: "pnpm install --frozen-lockfile",
});
const task = makeTask("FN-030", "in-progress");
@@ -1126,7 +1130,8 @@ describe("Scheduler after restart", () => {
const t = allTasks.find((t) => t.id === id)!;
return makeTaskDetail(id, t.column);
});
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, autoMerge: true });
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, autoMerge: true });
mockAgentSuccess();
@@ -1423,6 +1428,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
store.listTasks.mockResolvedValue([]);
store.getSettings.mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
recycleWorktrees: true,
});
store.getTask.mockResolvedValue(makeTaskDetail("FN-110", "in-progress"));
@@ -1663,7 +1669,8 @@ describe("Engine pause/unpause cycle", () => {
const store = createMockStore();
const todoTask = makeTask("FN-EP3", "todo");
store.listTasks.mockResolvedValue([todoTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, enginePaused: false });
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, enginePaused: false });
store.parseFileScopeFromPrompt.mockResolvedValue([]);
// Mock getTask for compare-and-swap verification in schedule()
store.getTask.mockResolvedValue(todoTask);
@@ -1688,17 +1695,21 @@ describe("Engine pause/unpause cycle", () => {
onSchedule.mockClear();
// During pause, scheduler halts
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, enginePaused: true });
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, enginePaused: true });
// Add a new todo task
const newTask = makeTask("FN-EP4", "todo");
store.listTasks.mockResolvedValue([newTask]);
// Unpause — trigger settings:updated to wake the scheduler
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS, enginePaused: false });
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, enginePaused: false });
store._trigger("settings:updated", {
settings: { ...DEFAULT_SETTINGS, enginePaused: false },
previous: { ...DEFAULT_SETTINGS, enginePaused: true },
settings: { ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, enginePaused: false },
previous: { ...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const, enginePaused: true },
});
await waitForAsyncExpectation(() => {