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:
committed by
gsxdsm
parent
807dc5c8df
commit
22d59b5f9a
@@ -42,6 +42,7 @@ function runFinalize(dir: string, taskId: string, branch: string, preAttemptHead
|
||||
undefined,
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeIntegrationWorktree: "cwd-main" as const,
|
||||
commitAuthorEnabled: false,
|
||||
},
|
||||
undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,7 @@ function assertIsolatedWorkspace(dir: string): void {
|
||||
|
||||
const STUB_SETTINGS = {
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeIntegrationWorktree: "cwd-main" as const,
|
||||
commitAuthorEnabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user