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(() => {

View File

@@ -34,6 +34,15 @@ export {
type MergerOptions,
type AutostashOrphanRecord,
} from "./merger.js";
export {
resolveMergeIntegrationRoot,
resolveIntegrationRemote,
acquireReuseHandoff,
releaseReuseHandoff,
MergeHandoffRefusedError,
type HandoffResult,
type MergeIntegrationRootResolution,
} from "./merger-integration-worktree.js";
export {
auditSquashMerge,
formatSquashAuditReport,

View File

@@ -0,0 +1,456 @@
import { createHash } from "node:crypto";
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
MergeIntegrationWorktreeMode,
MergeQueueReleaseOutcome,
ProjectSettings,
Task,
TaskStore,
} from "@fusion/core";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { MeshLeaseManager } from "./mesh-lease-manager.js";
import {
canonicalizePath,
classifyTaskWorktree,
getRegisteredWorktreeBranchMap,
PoolDoubleLeaseError,
} from "./worktree-pool.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MERGE_HANDOFF_WORKER_ID = "merger-reuse-handoff";
export interface MergeIntegrationRootResolution {
mode: MergeIntegrationWorktreeMode;
rootDir: string;
branchName: string;
}
export interface ResolveMergeIntegrationRootInput {
task: Pick<Task, "id" | "branch" | "worktree">;
settings: Pick<ProjectSettings, "mergeIntegrationWorktree" | "worktrunk">;
projectRoot: string;
}
export function resolveMergeIntegrationRoot(
input: ResolveMergeIntegrationRootInput,
): MergeIntegrationRootResolution {
const branchName = canonicalFusionBranchName(input.task.id);
if (input.settings.worktrunk?.enabled === true) {
return {
mode: "cwd-main",
rootDir: input.projectRoot,
branchName,
};
}
const mode = input.settings.mergeIntegrationWorktree === "cwd-main"
? "cwd-main"
: "reuse-task-worktree";
return {
mode,
rootDir: mode === "reuse-task-worktree"
? input.task.worktree?.trim() || input.projectRoot
: input.projectRoot,
branchName,
};
}
export interface ResolveIntegrationRemoteInput {
settings: Pick<ProjectSettings, "worktreeRebaseRemote">;
rootDir: string;
integrationBranch: string;
}
export async function resolveIntegrationRemote(
input: ResolveIntegrationRemoteInput,
): Promise<string | undefined> {
const configured = input.settings.worktreeRebaseRemote?.trim();
if (configured) {
return configured;
}
try {
const { stdout } = await execAsync(
`git config --get branch.${input.integrationBranch}.remote`,
{ cwd: input.rootDir, encoding: "utf-8" },
);
const branchRemote = stdout.trim();
if (branchRemote) {
return branchRemote;
}
} catch {
// Fall through to repo remote discovery.
}
try {
const { stdout } = await execAsync("git remote", {
cwd: input.rootDir,
encoding: "utf-8",
});
const remotes = stdout.trim().split(/\s+/).filter(Boolean);
if (remotes.length === 1) {
return remotes[0];
}
if (remotes.includes("origin")) {
return "origin";
}
} catch {
// No remote resolvable.
}
return "origin";
}
export class MergeHandoffRefusedError extends Error {
readonly reason: string;
readonly gate: string;
readonly payload: Record<string, unknown>;
constructor(gate: string, reason: string, payload: Record<string, unknown> = {}) {
super(`Merge handoff refused (${gate}): ${reason}`);
this.name = "MergeHandoffRefusedError";
this.gate = gate;
this.reason = reason;
this.payload = payload;
}
}
export interface ReuseHandoffSuccess {
ok: true;
taskId: string;
worktreePath: string;
branch: string;
workerId: string;
releaseLease: (outcome: MergeQueueReleaseOutcome) => void;
}
export type HandoffResult = ReuseHandoffSuccess;
export interface ReuseHandoffInput {
task: Pick<
Task,
| "id"
| "branch"
| "worktree"
| "checkedOutBy"
| "checkedOutAt"
| "checkoutLeaseRenewedAt"
| "checkoutNodeId"
| "checkoutRunId"
| "checkoutLeaseEpoch"
>;
store: TaskStore;
projectRoot: string;
settings: ProjectSettings;
worktreePath: string;
auditEmit?: (event: { type: string; target?: string; metadata?: Record<string, unknown> }) => Promise<void> | void;
}
async function snapshotDirtyFilesLocal(rootDir: string): Promise<Set<string>> {
const paths = new Set<string>();
try {
const [unstagedOut, stagedOut, porcelainOut] = await Promise.all([
execFileAsync("git", ["diff", "-z", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
execFileAsync("git", ["diff", "-z", "--cached", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
]);
for (const entry of unstagedOut.split("\0")) {
const path = entry.trim();
if (path) paths.add(path);
}
for (const entry of stagedOut.split("\0")) {
const path = entry.trim();
if (path) paths.add(path);
}
for (const entry of porcelainOut.split("\0")) {
if (!entry.startsWith("?? ")) continue;
const path = entry.slice(3);
if (path) paths.add(path);
}
} catch {
// Best-effort gate input.
}
return paths;
}
async function gitDirtyFingerprintLocal(rootDir: string): Promise<string> {
try {
const [diffOut, statusOut] = await Promise.all([
execFileAsync("git", ["diff", "HEAD"], {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
}).then((r) => r.stdout, () => ""),
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
]);
if (!diffOut && !statusOut) return "";
return createHash("sha256").update(diffOut).update("\0").update(statusOut).digest("hex");
} catch {
return "";
}
}
async function findOtherWorktreeUser(store: TaskStore, worktreePath: string, excludeTaskId: string): Promise<string | null> {
const tasks = await store.listTasks({ slim: true, includeArchived: false } as never);
for (const task of tasks) {
if (task.id === excludeTaskId) continue;
if (task.worktree === worktreePath && task.column !== "done") {
return task.id;
}
}
return null;
}
function asCentralClaimAccessor(store: TaskStore): {
projectId?: string;
getTaskClaim?: (projectId: string, taskId: string) => { ownerAgentId?: string | null } | null;
} {
const candidate = store as TaskStore & {
projectId?: string;
getTaskClaim?: (projectId: string, taskId: string) => { ownerAgentId?: string | null } | null;
};
return {
projectId: typeof candidate.projectId === "string" ? candidate.projectId : undefined,
getTaskClaim: typeof candidate.getTaskClaim === "function" ? candidate.getTaskClaim.bind(candidate) : undefined,
};
}
export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<HandoffResult> {
const expectedBranch = canonicalFusionBranchName(input.task.id);
const worktreePath = input.worktreePath;
const dirtyPaths = Array.from(await snapshotDirtyFilesLocal(worktreePath)).sort();
const dirtyFingerprint = await gitDirtyFingerprintLocal(worktreePath);
if (dirtyPaths.length > 0 || dirtyFingerprint) {
throw new MergeHandoffRefusedError("working-tree-dirty", "dirty-worktree", {
taskId: input.task.id,
worktreePath,
dirtyPaths,
dirtyFingerprint,
});
}
const { stdout: headStdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
encoding: "utf-8",
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
let observedBranch = headStdout.trim();
if (observedBranch && observedBranch !== expectedBranch && observedBranch.toLowerCase() === expectedBranch.toLowerCase()) {
const autocorrectResult = await attemptBranchAutocorrect({
worktreePath,
observedBranch,
expectedBranch,
rootDir: input.projectRoot,
});
if (autocorrectResult.status !== "failed") {
await input.auditEmit?.({
type: "branch:auto-canonicalize-case",
target: worktreePath,
metadata: {
taskId: input.task.id,
observed: observedBranch,
expected: expectedBranch,
worktreePath,
mode: autocorrectResult.status,
},
});
const { stdout: correctedHead } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
encoding: "utf-8",
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
observedBranch = correctedHead.trim();
}
}
if (observedBranch !== expectedBranch) {
throw new MergeHandoffRefusedError("head-branch-mismatch", "unexpected-branch", {
taskId: input.task.id,
worktreePath,
observedBranch,
expectedBranch,
});
}
const activeRecord = activeSessionRegistry.lookupByPath(worktreePath);
if (activeRecord) {
if (activeRecord.taskId === input.task.id && !executingTaskLock.has(input.task.id)) {
const reconciled = activeSessionRegistry.reconcileStaleSelfOwned(worktreePath, input.task.id);
if (!reconciled.reconciled) {
throw new MergeHandoffRefusedError("active-session-binding", "active-session-present", {
taskId: input.task.id,
worktreePath,
activeRecord,
executingTaskLockHeld: executingTaskLock.has(input.task.id),
});
}
} else {
throw new MergeHandoffRefusedError("active-session-binding", "active-session-present", {
taskId: input.task.id,
worktreePath,
activeRecord,
executingTaskLockHeld: executingTaskLock.has(input.task.id),
});
}
}
const classification = await classifyTaskWorktree(input.projectRoot, worktreePath);
if (!classification.ok) {
throw new MergeHandoffRefusedError("branch-worktree-mapping", classification.classification, {
taskId: input.task.id,
worktreePath,
classification,
});
}
const otherTaskId = await findOtherWorktreeUser(input.store, worktreePath, input.task.id);
if (otherTaskId) {
throw new MergeHandoffRefusedError("branch-worktree-mapping", "foreign-task-worktree-owner", {
taskId: input.task.id,
worktreePath,
otherTaskId,
});
}
if (input.task.branch?.trim() && input.task.branch.trim().toLowerCase() !== expectedBranch.toLowerCase()) {
throw new MergeHandoffRefusedError("branch-worktree-mapping", "task-branch-metadata-mismatch", {
taskId: input.task.id,
taskBranch: input.task.branch,
expectedBranch,
});
}
const branchMap = await getRegisteredWorktreeBranchMap(input.projectRoot);
const registeredBranchPath = branchMap.get(expectedBranch);
const canonicalWorktreePath = canonicalizePath(worktreePath);
if (!registeredBranchPath || canonicalizePath(registeredBranchPath) !== canonicalWorktreePath) {
throw new MergeHandoffRefusedError("branch-worktree-mapping", "registered-branch-mismatch", {
taskId: input.task.id,
worktreePath,
expectedBranch,
registeredBranchPath: registeredBranchPath ?? null,
});
}
const staleCheck = new MeshLeaseManager({
taskStore: input.store,
getExecutingTaskIds: () => {
const active = new Set<string>();
if (executingTaskLock.has(input.task.id)) {
active.add(input.task.id);
}
return active;
},
});
if (input.task.checkedOutBy) {
const recoverable = await staleCheck.isLeaseRecoverable(input.task as Task);
if (!recoverable.recoverable) {
throw new MergeHandoffRefusedError("lease-handoff-failed", "executor-lease-active", {
taskId: input.task.id,
checkedOutBy: input.task.checkedOutBy,
checkoutNodeId: input.task.checkoutNodeId ?? null,
checkoutRunId: input.task.checkoutRunId ?? null,
reason: recoverable.reason ?? null,
});
}
}
if (executingTaskLock.has(input.task.id)) {
throw new MergeHandoffRefusedError("lease-handoff-failed", "executor-lease-active", {
taskId: input.task.id,
worktreePath,
reason: "active_local_execution",
});
}
const centralAccessor = asCentralClaimAccessor(input.store);
if (centralAccessor.projectId && centralAccessor.getTaskClaim) {
const claim = centralAccessor.getTaskClaim(centralAccessor.projectId, input.task.id);
if (claim?.ownerAgentId) {
throw new MergeHandoffRefusedError("lease-handoff-failed", "central-conflict", {
taskId: input.task.id,
projectId: centralAccessor.projectId,
ownerAgentId: claim.ownerAgentId,
});
}
}
let lease;
try {
// Non-atomic fallback: executor lease checks above race with mergeQueue lease acquisition.
// TaskStore does not yet expose an atomic executor-lease absence check inside mergeQueue leasing.
lease = (input.store as TaskStore & {
acquireMergeQueueLease(workerId: string, opts: { leaseDurationMs: number; now?: string }): unknown;
}).acquireMergeQueueLease(MERGE_HANDOFF_WORKER_ID, {
leaseDurationMs: 15 * 60 * 1000,
});
} catch (error) {
if (error instanceof PoolDoubleLeaseError) {
throw new MergeHandoffRefusedError("lease-handoff-failed", "pool-double-lease", {
taskId: input.task.id,
worktreePath,
path: error.path,
existingHolder: error.existingHolder,
requestingTaskId: error.requestingTaskId,
phase: error.phase,
});
}
throw error;
}
if (!lease || typeof lease !== "object" || (lease as { taskId?: string }).taskId !== input.task.id) {
throw new MergeHandoffRefusedError("lease-handoff-failed", "no-lease", {
taskId: input.task.id,
worktreePath,
});
}
return {
ok: true,
taskId: input.task.id,
worktreePath,
branch: expectedBranch,
workerId: MERGE_HANDOFF_WORKER_ID,
releaseLease: (outcome) => {
(input.store as TaskStore & {
releaseMergeQueueLease(taskId: string, workerId: string, outcome: MergeQueueReleaseOutcome): void;
}).releaseMergeQueueLease(input.task.id, MERGE_HANDOFF_WORKER_ID, outcome);
},
};
}
export async function releaseReuseHandoff(input: {
handoff: ReuseHandoffSuccess;
outcome: string;
auditEmit?: (event: { type: string; target?: string; metadata?: Record<string, unknown> }) => Promise<void> | void;
}): Promise<void> {
input.handoff.releaseLease(
input.outcome === "success"
? { kind: "success" }
: { kind: "failure", error: input.outcome },
);
await input.auditEmit?.({
type: "merge:reuse-handoff-released",
target: input.handoff.worktreePath,
metadata: {
taskId: input.handoff.taskId,
outcome: input.outcome,
branch: input.handoff.branch,
worktreePath: input.handoff.worktreePath,
},
});
}

View File

@@ -98,6 +98,14 @@ import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js";
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js";
import {
acquireReuseHandoff,
MergeHandoffRefusedError,
releaseReuseHandoff,
resolveIntegrationRemote,
resolveMergeIntegrationRoot,
type HandoffResult,
} from "./merger-integration-worktree.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
@@ -1248,11 +1256,13 @@ async function attemptInMergeVerificationFix(
: null;
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FN-5279: verification-fix sessions run in the resolved integration root,
// which is the reused task worktree in handoff mode.
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,
pluginRunner: options.pluginRunner,
cwd: rootDir, // Runs on the main branch in the project root
cwd: rootDir,
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
A merge has been applied and the verification command failed. Your job is to fix the failing code directly in the working directory.
@@ -4274,6 +4284,8 @@ interface DiffBaseResolutionInput {
headRef: string;
baseBranch?: string;
baseCommitSha?: string;
integrationBranchFallback?: string;
integrationRemoteFallback?: string;
}
/**
@@ -4294,13 +4306,17 @@ export async function resolveTaskDiffBaseRef({
headRef,
baseBranch,
baseCommitSha,
integrationBranchFallback,
integrationRemoteFallback,
}: DiffBaseResolutionInput): Promise<string | undefined> {
// When baseBranch was nulled (e.g., upstream dep merged and its branch was
// deleted) but a task-scoped baseCommitSha is still recorded, skip the
// merge-base step so we don't widen the diff range to merge-base(HEAD, main)
// and surface unrelated history. Only fall back to "main" when neither hint
// is available (legacy tasks).
const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? undefined : "main");
// merge-base step so we don't widen the diff range to merge-base(HEAD, integration)
// and surface unrelated history. Only fall back to the resolved integration
// branch when neither hint is available.
const fallbackBranch = integrationBranchFallback?.trim() || undefined;
const fallbackRemote = integrationRemoteFallback?.trim() || undefined;
const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? undefined : fallbackBranch);
const quotedHeadRef = quoteArg(headRef);
let mergeBase: string | undefined;
@@ -4313,7 +4329,10 @@ export async function resolveTaskDiffBaseRef({
});
mergeBase = stdout.trim() || undefined;
} catch {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
if (!fallbackRemote) {
throw new Error("missing integration remote fallback");
}
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`${fallbackRemote}/${resolvedBaseBranch}`)}`, {
cwd,
encoding: "utf-8",
});
@@ -4341,28 +4360,30 @@ export async function resolveTaskDiffBaseRef({
// Display recovery (mirrors dashboard `resolveDiffBase` with
// `enableDisplayRecovery: true`): when baseBranch is missing — common for
// legacy/imported tasks — compute merge-base(headRef, main) so we can
// tighten an outdated-but-still-ancestor baseCommitSha after a pre-merge
// rebase. Without this the scope warning compares against a stale
// baseCommitSha and surfaces every unrelated commit landed on main since
// the task forked.
// legacy/imported tasks — compute merge-base(headRef, integration branch) so
// we can tighten an outdated-but-still-ancestor baseCommitSha after a
// pre-merge rebase. Without this the scope warning compares against a stale
// baseCommitSha and surfaces every unrelated commit landed on the integration
// branch since the task forked.
let recoveredBase: string | undefined;
if (!baseBranch?.trim()) {
if (!baseBranch?.trim() && fallbackBranch) {
try {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} main`, {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(fallbackBranch)}`, {
cwd,
encoding: "utf-8",
});
recoveredBase = stdout.trim() || undefined;
} catch {
try {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg("origin/main")}`, {
cwd,
encoding: "utf-8",
});
recoveredBase = stdout.trim() || undefined;
} catch {
// no recovery available
if (fallbackRemote) {
try {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`${fallbackRemote}/${fallbackBranch}`)}`, {
cwd,
encoding: "utf-8",
});
recoveredBase = stdout.trim() || undefined;
} catch {
// no recovery available
}
}
}
}
@@ -6342,8 +6363,125 @@ export async function aiMergeTask(
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
}
const projectRootDir = rootDir;
const settings = await store.getSettings();
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
const branch = task.branch || canonicalFusionBranchName(taskId);
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch || "main";
const mergeRunId = generateSyntheticRunId("merge", taskId);
const engineRunContext: EngineRunContext = {
runId: mergeRunId,
agentId: "merger",
taskId,
taskLineageId: task.lineageId,
phase: "merge",
};
const audit = createRunAuditor(store, engineRunContext);
const emitReuseHandoffAuditEvent = async (
type:
| "merge:reuse-handoff-acquired"
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk"
| "branch:auto-canonicalize-case",
metadata: Record<string, unknown>,
target: string,
): Promise<void> => {
try {
await audit.git({ type, target, metadata });
} catch (auditErr: unknown) {
mergerLog.warn(
`${taskId}: failed to emit ${type}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
);
}
};
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
? "cwd-main"
: "reuse-task-worktree";
const integrationRoot = resolveMergeIntegrationRoot({
task,
settings,
projectRoot: projectRootDir,
});
const reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
rootDir = integrationRoot.rootDir;
const integrationRemote = await resolveIntegrationRemote({
settings,
rootDir: rootDir,
integrationBranch: mergeTarget.branch,
});
if (
settings.worktrunk?.enabled === true
&& requestedIntegrationMode === "reuse-task-worktree"
&& integrationRoot.mode === "cwd-main"
) {
await emitReuseHandoffAuditEvent(
"merge:reuse-handoff-deferred-to-worktrunk",
{
taskId,
worktreePath: task.worktree ?? null,
integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch,
},
projectRootDir,
);
}
let reuseHandoff: HandoffResult | undefined;
if (integrationRoot.mode === "reuse-task-worktree") {
try {
reuseHandoff = await acquireReuseHandoff({
task,
store,
projectRoot: projectRootDir,
settings,
worktreePath: integrationRoot.rootDir,
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? integrationRoot.rootDir),
});
await emitReuseHandoffAuditEvent(
"merge:reuse-handoff-acquired",
{
taskId,
branch: reuseHandoff.branch,
worktreePath: reuseHandoff.worktreePath,
integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch,
},
reuseHandoff.worktreePath,
);
} catch (error) {
if (error instanceof MergeHandoffRefusedError) {
await emitReuseHandoffAuditEvent(
"merge:reuse-handoff-refused",
{
taskId,
gate: error.gate,
reason: error.reason,
diagnostics: error.payload,
},
integrationRoot.rootDir,
);
}
throw error;
}
}
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch?.trim() || mergeTarget.branch;
const releaseReuseHandoffEarly = async (outcome: string): Promise<void> => {
if (!reuseHandoff) return;
const handoff = reuseHandoff;
reuseHandoff = undefined;
await releaseReuseHandoff({
handoff,
outcome,
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? handoff.worktreePath),
});
};
const resolveAheadCount = async (): Promise<{ aheadCount: number; baseRef: string } | null> => {
try {
await execAsync(`git rev-parse --verify ${quoteArg(branch)}`, { cwd: rootDir, timeout: 30_000 });
@@ -6355,7 +6493,10 @@ export async function aiMergeTask(
try {
await execAsync(`git rev-parse --verify ${quoteArg(baseRef)}`, { cwd: rootDir, timeout: 30_000 });
} catch {
const remoteRef = `origin/${requestedBaseRef}`;
if (!integrationRemote) {
return null;
}
const remoteRef = `${integrationRemote}/${requestedBaseRef}`;
try {
await execAsync(`git rev-parse --verify ${quoteArg(remoteRef)}`, { cwd: rootDir, timeout: 30_000 });
baseRef = remoteRef;
@@ -6408,6 +6549,7 @@ export async function aiMergeTask(
mergeTargetBranch: aheadInfo.baseRef,
};
await completeTask(store, taskId, result);
await releaseReuseHandoffEarly("success");
return result;
}
@@ -6446,6 +6588,7 @@ export async function aiMergeTask(
mergeTargetBranch: classification.baseRef,
};
await completeTask(store, taskId, result);
await releaseReuseHandoffEarly("success");
return result;
}
@@ -6463,6 +6606,7 @@ export async function aiMergeTask(
metadata: { reason: classification.reason, details: classification.details, autoRetry: true },
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
await releaseReuseHandoffEarly(unprovenError);
return {
task,
branch,
@@ -6506,6 +6650,7 @@ export async function aiMergeTask(
// Hoisted so the finally block (below) can attach the autostash outcome
// to the result object the caller will receive.
let resultForFinally: MergeResult | undefined;
let reuseHandoffOutcome = "success";
try {
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
@@ -6519,25 +6664,12 @@ export async function aiMergeTask(
};
resultForFinally = result;
// Build merge-run context for audit instrumentation (FN-1404)
const mergeRunId = generateSyntheticRunId("merge", taskId);
const engineRunContext: EngineRunContext = {
runId: mergeRunId,
agentId: "merger",
taskId,
taskLineageId: task.lineageId,
phase: "merge",
};
// Create run auditor for TaskStore-backed audit emission (no-ops if store doesn't support it)
const audit = createRunAuditor(store, engineRunContext);
if (!worktreePath) {
mergerLog.warn(`${taskId}: no worktree path set — skipping worktree cleanup`);
}
// 2. Read settings
const settings = await store.getSettings();
const includeTaskId = settings.includeTaskIdInCommit !== false;
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
@@ -6553,7 +6685,7 @@ export async function aiMergeTask(
// by `-X ours`/`-X theirs` falling back to a stale base. Best-effort: any
// failure (no remote, network down, divergent local) logs and continues.
if (mergeConflictStrategy === "smart-prefer-main" || mergeConflictStrategy === "smart-prefer-branch") {
await tryFastForwardFromOrigin(rootDir, taskId);
await tryFastForwardFromOrigin(rootDir, taskId, mergeTarget.branch, integrationRemote);
}
// Tracks the "empty squash" success path — when `git merge --squash`
@@ -6565,11 +6697,6 @@ export async function aiMergeTask(
let mergeWasEmpty = false;
let recoveredMergeSha: string | undefined;
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
// 3. Check branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
@@ -6637,8 +6764,11 @@ export async function aiMergeTask(
return result;
}
// 3b. Ensure rootDir is on the resolved merge target before merging.
// Without this, a merge could land on whatever branch was last checked out.
// 3b. Ensure rootDir is based on the resolved integration target before merging.
// In reuse-task-worktree mode the task worktree is branch-bound, so we detach
// to the integration branch tip instead of checking out the integration branch
// directly; this keeps the project root untouched while preserving the merge
// cascade's expected `preAttemptHeadSha === integration target` invariant.
try {
throwIfAborted(options.signal, taskId);
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
@@ -6646,7 +6776,15 @@ export async function aiMergeTask(
encoding: "utf-8",
stdio: "pipe",
}).trim();
if (currentBranch !== mergeTarget.branch) {
if (reuseTaskWorktreeMerge) {
mergerLog.log(
`${taskId}: reusing task worktree — detaching HEAD at '${mergeTarget.branch}' before merge (${mergeTarget.source})`,
);
await execAsync(`git checkout --detach "${mergeTarget.branch}"`, {
cwd: rootDir,
});
await audit.git({ type: "branch:checkout", target: mergeTarget.branch });
} else if (currentBranch !== mergeTarget.branch) {
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mergeTarget.branch}' before merge (${mergeTarget.source})`);
await execAsync(`git checkout "${mergeTarget.branch}"`, {
cwd: rootDir,
@@ -6659,6 +6797,10 @@ export async function aiMergeTask(
}
// 3c. Pre-merge remote rebase.
// `rootDir` is the resolved integration root for this merge attempt: either
// the project root (`cwd-main`) or the reused task worktree after the FN-5279
// handoff gates. All fetch/rebase commands below intentionally stay on that
// resolved root so the full conflict cascade runs in one place.
//
// When another collaborator (or another fusion worker on a different
// machine) pushes to the remote while our task branch is in flight, the
@@ -6858,61 +7000,19 @@ export async function aiMergeTask(
// ── Stage 1: remote rebase ────────────────────────────────────────────
if (settings.worktreeRebaseBeforeMerge !== false) {
try {
// Resolve which remote to fetch. An explicit setting wins; otherwise
// the repo's configured default (branch.<main>.remote) or the sole
// remote if there's exactly one.
let remote = settings.worktreeRebaseRemote?.trim();
if (!remote) {
try {
const { stdout: mainBranchOut } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: rootDir, encoding: "utf-8" },
);
const mainBranch = mainBranchOut.trim();
const { stdout: configuredRemote } = await execAsync(
`git config --get branch.${mainBranch}.remote`,
{ cwd: rootDir, encoding: "utf-8" },
).catch(() => ({ stdout: "" }));
remote = configuredRemote.trim();
} catch {
// Fall through to listing remotes below.
}
}
if (!remote) {
try {
const { stdout: remotesOut } = await execAsync("git remote", {
cwd: rootDir,
encoding: "utf-8",
});
const remotes = remotesOut.trim().split(/\s+/).filter(Boolean);
if (remotes.length === 1) {
remote = remotes[0];
} else if (remotes.includes("origin")) {
remote = "origin";
}
} catch {
// Ignore — we'll skip the rebase if no remote is resolvable.
}
}
if (!remote) {
mergerLog.log(`${taskId}: no remote resolvable — skipping remote rebase stage (local-base stage may still run)`);
if (!integrationRemote) {
mergerLog.log(`${taskId}: no integration remote resolvable — skipping remote rebase stage (local-base stage may still run)`);
} else if (!worktreePath) {
mergerLog.warn(`${taskId}: no worktreePath — skipping remote rebase stage`);
} else {
throwIfAborted(options.signal, taskId);
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
await execAsync(`git fetch "${remote}"`, { cwd: rootDir });
mergerLog.log(`${taskId}: fetching ${integrationRemote} before merge`);
await execAsync(`git fetch ${quoteArg(integrationRemote)}`, { cwd: rootDir });
try {
const { stdout: mainBranchOut } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: rootDir, encoding: "utf-8" },
);
const mainBranch = mainBranchOut.trim();
const remoteRef = `${remote}/${mainBranch}`;
const remoteRef = `${integrationRemote}/${mergeTarget.branch}`;
throwIfAborted(options.signal, taskId);
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
await execAsync(`git rebase ${quoteArg(remoteRef)}`, { cwd: worktreePath });
rebaseHappened = true;
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
await store.appendAgentLog(
@@ -6934,7 +7034,7 @@ export async function aiMergeTask(
}
}
if (mergeConflictStrategy === "smart-prefer-main") {
preferMainRebaseFailureMessage = `Pre-merge rebase onto remote main aborted (${msg})`;
preferMainRebaseFailureMessage = `Pre-merge rebase onto remote ${mergeTarget.branch} aborted (${msg})`;
}
}
}
@@ -7226,6 +7326,8 @@ export async function aiMergeTask(
headRef: branch,
baseBranch: task.baseBranch,
baseCommitSha: task.baseCommitSha,
integrationBranchFallback: mergeTarget.branch,
integrationRemoteFallback: integrationRemote,
});
const preferBranchOnOverlapFiles = new Set<string>();
if (
@@ -8429,6 +8531,9 @@ export async function aiMergeTask(
await completeTask(store, taskId, result);
return result;
} catch (error) {
reuseHandoffOutcome = error instanceof Error ? error.message : String(error);
throw error;
} finally {
if (autostashHandle) {
try {
@@ -8482,6 +8587,18 @@ export async function aiMergeTask(
// threw — a stale advisory makes the dashboard show a phantom "merge
// running" indefinitely, which is worse than a missing one.
clearActiveMergerStatus(activeStatusPath, taskId);
if (reuseHandoff) {
const handoff = reuseHandoff;
reuseHandoff = undefined;
const handoffOutcome = reuseHandoffOutcome === "success"
? resultForFinally?.error ?? "success"
: reuseHandoffOutcome;
await releaseReuseHandoff({
handoff,
outcome: handoffOutcome,
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? handoff.worktreePath),
});
}
}
}
@@ -8490,21 +8607,18 @@ export async function aiMergeTask(
* (no remote configured, network down, divergent local commits, etc.).
* Only called for the smart strategies, which want to avoid resolving a
* conflict against a stale local base. */
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
let currentBranch: string;
try {
currentBranch = execSyncText("git rev-parse --abbrev-ref HEAD", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim();
} catch {
return;
}
if (!currentBranch || currentBranch === "HEAD") return;
async function tryFastForwardFromOrigin(
rootDir: string,
taskId: string,
integrationBranch: string,
integrationRemote?: string,
): Promise<void> {
const currentBranch = integrationBranch.trim();
const remote = integrationRemote?.trim();
if (!currentBranch || !remote) return;
try {
await execAsync(`git fetch origin "${currentBranch}"`, { cwd: rootDir });
await execAsync(`git fetch ${quoteArg(remote)} ${quoteArg(currentBranch)}`, { cwd: rootDir });
} catch (err) {
mergerLog.log(`${taskId}: pre-merge fetch failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
return;
@@ -8513,8 +8627,9 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
// Detect divergence: local must be strictly behind remote (no local-only commits).
let behind = 0;
let ahead = 0;
const remoteRef = `${remote}/${currentBranch}`;
try {
const counts = execSyncText(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
const counts = execSyncText(`git rev-list --left-right --count ${quoteArg(`${remoteRef}...HEAD`)}`, {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
@@ -8533,8 +8648,8 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
}
try {
await execAsync(`git merge --ff-only "origin/${currentBranch}"`, { cwd: rootDir });
mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from origin`);
await execAsync(`git merge --ff-only ${quoteArg(remoteRef)}`, { cwd: rootDir });
mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from ${remote}`);
} catch (err) {
mergerLog.log(`${taskId}: fast-forward failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
}
@@ -9453,6 +9568,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
// root so arbiter edits land in the reused task worktree when handoff mode
// is active.
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,

View File

@@ -155,6 +155,10 @@ export type GitMutationType =
| "merge:auto-prerebase:failed"
| "merge:layer3:foreign-file-skipped"
| "merge:layer3:scope-override-bypass"
| "merge:reuse-handoff-acquired"
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:audit-failure"
| "branch:auto-reclaim"
| "branch:auto-canonicalize-case"