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(() => {
|
||||
|
||||
@@ -34,6 +34,15 @@ export {
|
||||
type MergerOptions,
|
||||
type AutostashOrphanRecord,
|
||||
} from "./merger.js";
|
||||
export {
|
||||
resolveMergeIntegrationRoot,
|
||||
resolveIntegrationRemote,
|
||||
acquireReuseHandoff,
|
||||
releaseReuseHandoff,
|
||||
MergeHandoffRefusedError,
|
||||
type HandoffResult,
|
||||
type MergeIntegrationRootResolution,
|
||||
} from "./merger-integration-worktree.js";
|
||||
export {
|
||||
auditSquashMerge,
|
||||
formatSquashAuditReport,
|
||||
|
||||
456
packages/engine/src/merger-integration-worktree.ts
Normal file
456
packages/engine/src/merger-integration-worktree.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
MergeIntegrationWorktreeMode,
|
||||
MergeQueueReleaseOutcome,
|
||||
ProjectSettings,
|
||||
Task,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
|
||||
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
||||
import { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import {
|
||||
canonicalizePath,
|
||||
classifyTaskWorktree,
|
||||
getRegisteredWorktreeBranchMap,
|
||||
PoolDoubleLeaseError,
|
||||
} from "./worktree-pool.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MERGE_HANDOFF_WORKER_ID = "merger-reuse-handoff";
|
||||
|
||||
export interface MergeIntegrationRootResolution {
|
||||
mode: MergeIntegrationWorktreeMode;
|
||||
rootDir: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface ResolveMergeIntegrationRootInput {
|
||||
task: Pick<Task, "id" | "branch" | "worktree">;
|
||||
settings: Pick<ProjectSettings, "mergeIntegrationWorktree" | "worktrunk">;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export function resolveMergeIntegrationRoot(
|
||||
input: ResolveMergeIntegrationRootInput,
|
||||
): MergeIntegrationRootResolution {
|
||||
const branchName = canonicalFusionBranchName(input.task.id);
|
||||
|
||||
if (input.settings.worktrunk?.enabled === true) {
|
||||
return {
|
||||
mode: "cwd-main",
|
||||
rootDir: input.projectRoot,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
|
||||
const mode = input.settings.mergeIntegrationWorktree === "cwd-main"
|
||||
? "cwd-main"
|
||||
: "reuse-task-worktree";
|
||||
|
||||
return {
|
||||
mode,
|
||||
rootDir: mode === "reuse-task-worktree"
|
||||
? input.task.worktree?.trim() || input.projectRoot
|
||||
: input.projectRoot,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolveIntegrationRemoteInput {
|
||||
settings: Pick<ProjectSettings, "worktreeRebaseRemote">;
|
||||
rootDir: string;
|
||||
integrationBranch: string;
|
||||
}
|
||||
|
||||
export async function resolveIntegrationRemote(
|
||||
input: ResolveIntegrationRemoteInput,
|
||||
): Promise<string | undefined> {
|
||||
const configured = input.settings.worktreeRebaseRemote?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git config --get branch.${input.integrationBranch}.remote`,
|
||||
{ cwd: input.rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const branchRemote = stdout.trim();
|
||||
if (branchRemote) {
|
||||
return branchRemote;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to repo remote discovery.
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", {
|
||||
cwd: input.rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const remotes = stdout.trim().split(/\s+/).filter(Boolean);
|
||||
if (remotes.length === 1) {
|
||||
return remotes[0];
|
||||
}
|
||||
if (remotes.includes("origin")) {
|
||||
return "origin";
|
||||
}
|
||||
} catch {
|
||||
// No remote resolvable.
|
||||
}
|
||||
|
||||
return "origin";
|
||||
}
|
||||
|
||||
export class MergeHandoffRefusedError extends Error {
|
||||
readonly reason: string;
|
||||
readonly gate: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
|
||||
constructor(gate: string, reason: string, payload: Record<string, unknown> = {}) {
|
||||
super(`Merge handoff refused (${gate}): ${reason}`);
|
||||
this.name = "MergeHandoffRefusedError";
|
||||
this.gate = gate;
|
||||
this.reason = reason;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReuseHandoffSuccess {
|
||||
ok: true;
|
||||
taskId: string;
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
workerId: string;
|
||||
releaseLease: (outcome: MergeQueueReleaseOutcome) => void;
|
||||
}
|
||||
|
||||
export type HandoffResult = ReuseHandoffSuccess;
|
||||
|
||||
export interface ReuseHandoffInput {
|
||||
task: Pick<
|
||||
Task,
|
||||
| "id"
|
||||
| "branch"
|
||||
| "worktree"
|
||||
| "checkedOutBy"
|
||||
| "checkedOutAt"
|
||||
| "checkoutLeaseRenewedAt"
|
||||
| "checkoutNodeId"
|
||||
| "checkoutRunId"
|
||||
| "checkoutLeaseEpoch"
|
||||
>;
|
||||
store: TaskStore;
|
||||
projectRoot: string;
|
||||
settings: ProjectSettings;
|
||||
worktreePath: string;
|
||||
auditEmit?: (event: { type: string; target?: string; metadata?: Record<string, unknown> }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
async function snapshotDirtyFilesLocal(rootDir: string): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
try {
|
||||
const [unstagedOut, stagedOut, porcelainOut] = await Promise.all([
|
||||
execFileAsync("git", ["diff", "-z", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
execFileAsync("git", ["diff", "-z", "--cached", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
]);
|
||||
|
||||
for (const entry of unstagedOut.split("\0")) {
|
||||
const path = entry.trim();
|
||||
if (path) paths.add(path);
|
||||
}
|
||||
for (const entry of stagedOut.split("\0")) {
|
||||
const path = entry.trim();
|
||||
if (path) paths.add(path);
|
||||
}
|
||||
for (const entry of porcelainOut.split("\0")) {
|
||||
if (!entry.startsWith("?? ")) continue;
|
||||
const path = entry.slice(3);
|
||||
if (path) paths.add(path);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort gate input.
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function gitDirtyFingerprintLocal(rootDir: string): Promise<string> {
|
||||
try {
|
||||
const [diffOut, statusOut] = await Promise.all([
|
||||
execFileAsync("git", ["diff", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
}).then((r) => r.stdout, () => ""),
|
||||
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
]);
|
||||
if (!diffOut && !statusOut) return "";
|
||||
return createHash("sha256").update(diffOut).update("\0").update(statusOut).digest("hex");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function findOtherWorktreeUser(store: TaskStore, worktreePath: string, excludeTaskId: string): Promise<string | null> {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false } as never);
|
||||
for (const task of tasks) {
|
||||
if (task.id === excludeTaskId) continue;
|
||||
if (task.worktree === worktreePath && task.column !== "done") {
|
||||
return task.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function asCentralClaimAccessor(store: TaskStore): {
|
||||
projectId?: string;
|
||||
getTaskClaim?: (projectId: string, taskId: string) => { ownerAgentId?: string | null } | null;
|
||||
} {
|
||||
const candidate = store as TaskStore & {
|
||||
projectId?: string;
|
||||
getTaskClaim?: (projectId: string, taskId: string) => { ownerAgentId?: string | null } | null;
|
||||
};
|
||||
return {
|
||||
projectId: typeof candidate.projectId === "string" ? candidate.projectId : undefined,
|
||||
getTaskClaim: typeof candidate.getTaskClaim === "function" ? candidate.getTaskClaim.bind(candidate) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<HandoffResult> {
|
||||
const expectedBranch = canonicalFusionBranchName(input.task.id);
|
||||
const worktreePath = input.worktreePath;
|
||||
const dirtyPaths = Array.from(await snapshotDirtyFilesLocal(worktreePath)).sort();
|
||||
const dirtyFingerprint = await gitDirtyFingerprintLocal(worktreePath);
|
||||
if (dirtyPaths.length > 0 || dirtyFingerprint) {
|
||||
throw new MergeHandoffRefusedError("working-tree-dirty", "dirty-worktree", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
dirtyPaths,
|
||||
dirtyFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
const { stdout: headStdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
let observedBranch = headStdout.trim();
|
||||
if (observedBranch && observedBranch !== expectedBranch && observedBranch.toLowerCase() === expectedBranch.toLowerCase()) {
|
||||
const autocorrectResult = await attemptBranchAutocorrect({
|
||||
worktreePath,
|
||||
observedBranch,
|
||||
expectedBranch,
|
||||
rootDir: input.projectRoot,
|
||||
});
|
||||
if (autocorrectResult.status !== "failed") {
|
||||
await input.auditEmit?.({
|
||||
type: "branch:auto-canonicalize-case",
|
||||
target: worktreePath,
|
||||
metadata: {
|
||||
taskId: input.task.id,
|
||||
observed: observedBranch,
|
||||
expected: expectedBranch,
|
||||
worktreePath,
|
||||
mode: autocorrectResult.status,
|
||||
},
|
||||
});
|
||||
const { stdout: correctedHead } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
observedBranch = correctedHead.trim();
|
||||
}
|
||||
}
|
||||
if (observedBranch !== expectedBranch) {
|
||||
throw new MergeHandoffRefusedError("head-branch-mismatch", "unexpected-branch", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
observedBranch,
|
||||
expectedBranch,
|
||||
});
|
||||
}
|
||||
|
||||
const activeRecord = activeSessionRegistry.lookupByPath(worktreePath);
|
||||
if (activeRecord) {
|
||||
if (activeRecord.taskId === input.task.id && !executingTaskLock.has(input.task.id)) {
|
||||
const reconciled = activeSessionRegistry.reconcileStaleSelfOwned(worktreePath, input.task.id);
|
||||
if (!reconciled.reconciled) {
|
||||
throw new MergeHandoffRefusedError("active-session-binding", "active-session-present", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
activeRecord,
|
||||
executingTaskLockHeld: executingTaskLock.has(input.task.id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new MergeHandoffRefusedError("active-session-binding", "active-session-present", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
activeRecord,
|
||||
executingTaskLockHeld: executingTaskLock.has(input.task.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const classification = await classifyTaskWorktree(input.projectRoot, worktreePath);
|
||||
if (!classification.ok) {
|
||||
throw new MergeHandoffRefusedError("branch-worktree-mapping", classification.classification, {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
classification,
|
||||
});
|
||||
}
|
||||
const otherTaskId = await findOtherWorktreeUser(input.store, worktreePath, input.task.id);
|
||||
if (otherTaskId) {
|
||||
throw new MergeHandoffRefusedError("branch-worktree-mapping", "foreign-task-worktree-owner", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
otherTaskId,
|
||||
});
|
||||
}
|
||||
if (input.task.branch?.trim() && input.task.branch.trim().toLowerCase() !== expectedBranch.toLowerCase()) {
|
||||
throw new MergeHandoffRefusedError("branch-worktree-mapping", "task-branch-metadata-mismatch", {
|
||||
taskId: input.task.id,
|
||||
taskBranch: input.task.branch,
|
||||
expectedBranch,
|
||||
});
|
||||
}
|
||||
const branchMap = await getRegisteredWorktreeBranchMap(input.projectRoot);
|
||||
const registeredBranchPath = branchMap.get(expectedBranch);
|
||||
const canonicalWorktreePath = canonicalizePath(worktreePath);
|
||||
if (!registeredBranchPath || canonicalizePath(registeredBranchPath) !== canonicalWorktreePath) {
|
||||
throw new MergeHandoffRefusedError("branch-worktree-mapping", "registered-branch-mismatch", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
expectedBranch,
|
||||
registeredBranchPath: registeredBranchPath ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const staleCheck = new MeshLeaseManager({
|
||||
taskStore: input.store,
|
||||
getExecutingTaskIds: () => {
|
||||
const active = new Set<string>();
|
||||
if (executingTaskLock.has(input.task.id)) {
|
||||
active.add(input.task.id);
|
||||
}
|
||||
return active;
|
||||
},
|
||||
});
|
||||
if (input.task.checkedOutBy) {
|
||||
const recoverable = await staleCheck.isLeaseRecoverable(input.task as Task);
|
||||
if (!recoverable.recoverable) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "executor-lease-active", {
|
||||
taskId: input.task.id,
|
||||
checkedOutBy: input.task.checkedOutBy,
|
||||
checkoutNodeId: input.task.checkoutNodeId ?? null,
|
||||
checkoutRunId: input.task.checkoutRunId ?? null,
|
||||
reason: recoverable.reason ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (executingTaskLock.has(input.task.id)) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "executor-lease-active", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
reason: "active_local_execution",
|
||||
});
|
||||
}
|
||||
const centralAccessor = asCentralClaimAccessor(input.store);
|
||||
if (centralAccessor.projectId && centralAccessor.getTaskClaim) {
|
||||
const claim = centralAccessor.getTaskClaim(centralAccessor.projectId, input.task.id);
|
||||
if (claim?.ownerAgentId) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "central-conflict", {
|
||||
taskId: input.task.id,
|
||||
projectId: centralAccessor.projectId,
|
||||
ownerAgentId: claim.ownerAgentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let lease;
|
||||
try {
|
||||
// Non-atomic fallback: executor lease checks above race with mergeQueue lease acquisition.
|
||||
// TaskStore does not yet expose an atomic executor-lease absence check inside mergeQueue leasing.
|
||||
lease = (input.store as TaskStore & {
|
||||
acquireMergeQueueLease(workerId: string, opts: { leaseDurationMs: number; now?: string }): unknown;
|
||||
}).acquireMergeQueueLease(MERGE_HANDOFF_WORKER_ID, {
|
||||
leaseDurationMs: 15 * 60 * 1000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PoolDoubleLeaseError) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "pool-double-lease", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
path: error.path,
|
||||
existingHolder: error.existingHolder,
|
||||
requestingTaskId: error.requestingTaskId,
|
||||
phase: error.phase,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!lease || typeof lease !== "object" || (lease as { taskId?: string }).taskId !== input.task.id) {
|
||||
throw new MergeHandoffRefusedError("lease-handoff-failed", "no-lease", {
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
taskId: input.task.id,
|
||||
worktreePath,
|
||||
branch: expectedBranch,
|
||||
workerId: MERGE_HANDOFF_WORKER_ID,
|
||||
releaseLease: (outcome) => {
|
||||
(input.store as TaskStore & {
|
||||
releaseMergeQueueLease(taskId: string, workerId: string, outcome: MergeQueueReleaseOutcome): void;
|
||||
}).releaseMergeQueueLease(input.task.id, MERGE_HANDOFF_WORKER_ID, outcome);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function releaseReuseHandoff(input: {
|
||||
handoff: ReuseHandoffSuccess;
|
||||
outcome: string;
|
||||
auditEmit?: (event: { type: string; target?: string; metadata?: Record<string, unknown> }) => Promise<void> | void;
|
||||
}): Promise<void> {
|
||||
input.handoff.releaseLease(
|
||||
input.outcome === "success"
|
||||
? { kind: "success" }
|
||||
: { kind: "failure", error: input.outcome },
|
||||
);
|
||||
await input.auditEmit?.({
|
||||
type: "merge:reuse-handoff-released",
|
||||
target: input.handoff.worktreePath,
|
||||
metadata: {
|
||||
taskId: input.handoff.taskId,
|
||||
outcome: input.outcome,
|
||||
branch: input.handoff.branch,
|
||||
worktreePath: input.handoff.worktreePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -98,6 +98,14 @@ import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume
|
||||
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
|
||||
import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js";
|
||||
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js";
|
||||
import {
|
||||
acquireReuseHandoff,
|
||||
MergeHandoffRefusedError,
|
||||
releaseReuseHandoff,
|
||||
resolveIntegrationRemote,
|
||||
resolveMergeIntegrationRoot,
|
||||
type HandoffResult,
|
||||
} from "./merger-integration-worktree.js";
|
||||
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
|
||||
@@ -1248,11 +1256,13 @@ async function attemptInMergeVerificationFix(
|
||||
: null;
|
||||
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
|
||||
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
|
||||
// FN-5279: verification-fix sessions run in the resolved integration root,
|
||||
// which is the reused task worktree in handoff mode.
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
runtimeHint: mergerRuntimeHint,
|
||||
pluginRunner: options.pluginRunner,
|
||||
cwd: rootDir, // Runs on the main branch in the project root
|
||||
cwd: rootDir,
|
||||
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
|
||||
|
||||
A merge has been applied and the verification command failed. Your job is to fix the failing code directly in the working directory.
|
||||
@@ -4274,6 +4284,8 @@ interface DiffBaseResolutionInput {
|
||||
headRef: string;
|
||||
baseBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
integrationBranchFallback?: string;
|
||||
integrationRemoteFallback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4294,13 +4306,17 @@ export async function resolveTaskDiffBaseRef({
|
||||
headRef,
|
||||
baseBranch,
|
||||
baseCommitSha,
|
||||
integrationBranchFallback,
|
||||
integrationRemoteFallback,
|
||||
}: DiffBaseResolutionInput): Promise<string | undefined> {
|
||||
// When baseBranch was nulled (e.g., upstream dep merged and its branch was
|
||||
// deleted) but a task-scoped baseCommitSha is still recorded, skip the
|
||||
// merge-base step so we don't widen the diff range to merge-base(HEAD, main)
|
||||
// and surface unrelated history. Only fall back to "main" when neither hint
|
||||
// is available (legacy tasks).
|
||||
const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? undefined : "main");
|
||||
// merge-base step so we don't widen the diff range to merge-base(HEAD, integration)
|
||||
// and surface unrelated history. Only fall back to the resolved integration
|
||||
// branch when neither hint is available.
|
||||
const fallbackBranch = integrationBranchFallback?.trim() || undefined;
|
||||
const fallbackRemote = integrationRemoteFallback?.trim() || undefined;
|
||||
const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? undefined : fallbackBranch);
|
||||
const quotedHeadRef = quoteArg(headRef);
|
||||
let mergeBase: string | undefined;
|
||||
|
||||
@@ -4313,7 +4329,10 @@ export async function resolveTaskDiffBaseRef({
|
||||
});
|
||||
mergeBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
|
||||
if (!fallbackRemote) {
|
||||
throw new Error("missing integration remote fallback");
|
||||
}
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`${fallbackRemote}/${resolvedBaseBranch}`)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
@@ -4341,28 +4360,30 @@ export async function resolveTaskDiffBaseRef({
|
||||
|
||||
// Display recovery (mirrors dashboard `resolveDiffBase` with
|
||||
// `enableDisplayRecovery: true`): when baseBranch is missing — common for
|
||||
// legacy/imported tasks — compute merge-base(headRef, main) so we can
|
||||
// tighten an outdated-but-still-ancestor baseCommitSha after a pre-merge
|
||||
// rebase. Without this the scope warning compares against a stale
|
||||
// baseCommitSha and surfaces every unrelated commit landed on main since
|
||||
// the task forked.
|
||||
// legacy/imported tasks — compute merge-base(headRef, integration branch) so
|
||||
// we can tighten an outdated-but-still-ancestor baseCommitSha after a
|
||||
// pre-merge rebase. Without this the scope warning compares against a stale
|
||||
// baseCommitSha and surfaces every unrelated commit landed on the integration
|
||||
// branch since the task forked.
|
||||
let recoveredBase: string | undefined;
|
||||
if (!baseBranch?.trim()) {
|
||||
if (!baseBranch?.trim() && fallbackBranch) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} main`, {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(fallbackBranch)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
recoveredBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg("origin/main")}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
recoveredBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
// no recovery available
|
||||
if (fallbackRemote) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`${fallbackRemote}/${fallbackBranch}`)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
recoveredBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
// no recovery available
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6342,8 +6363,125 @@ export async function aiMergeTask(
|
||||
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const projectRootDir = rootDir;
|
||||
const settings = await store.getSettings();
|
||||
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||
projectDefaultBranch,
|
||||
});
|
||||
const branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch || "main";
|
||||
|
||||
const mergeRunId = generateSyntheticRunId("merge", taskId);
|
||||
const engineRunContext: EngineRunContext = {
|
||||
runId: mergeRunId,
|
||||
agentId: "merger",
|
||||
taskId,
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "merge",
|
||||
};
|
||||
const audit = createRunAuditor(store, engineRunContext);
|
||||
const emitReuseHandoffAuditEvent = async (
|
||||
type:
|
||||
| "merge:reuse-handoff-acquired"
|
||||
| "merge:reuse-handoff-refused"
|
||||
| "merge:reuse-handoff-released"
|
||||
| "merge:reuse-handoff-deferred-to-worktrunk"
|
||||
| "branch:auto-canonicalize-case",
|
||||
metadata: Record<string, unknown>,
|
||||
target: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await audit.git({ type, target, metadata });
|
||||
} catch (auditErr: unknown) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: failed to emit ${type}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
|
||||
? "cwd-main"
|
||||
: "reuse-task-worktree";
|
||||
const integrationRoot = resolveMergeIntegrationRoot({
|
||||
task,
|
||||
settings,
|
||||
projectRoot: projectRootDir,
|
||||
});
|
||||
const reuseTaskWorktreeMerge = integrationRoot.mode === "reuse-task-worktree";
|
||||
rootDir = integrationRoot.rootDir;
|
||||
const integrationRemote = await resolveIntegrationRemote({
|
||||
settings,
|
||||
rootDir: rootDir,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
});
|
||||
if (
|
||||
settings.worktrunk?.enabled === true
|
||||
&& requestedIntegrationMode === "reuse-task-worktree"
|
||||
&& integrationRoot.mode === "cwd-main"
|
||||
) {
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-handoff-deferred-to-worktrunk",
|
||||
{
|
||||
taskId,
|
||||
worktreePath: task.worktree ?? null,
|
||||
integrationRemote: integrationRemote ?? null,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
},
|
||||
projectRootDir,
|
||||
);
|
||||
}
|
||||
|
||||
let reuseHandoff: HandoffResult | undefined;
|
||||
if (integrationRoot.mode === "reuse-task-worktree") {
|
||||
try {
|
||||
reuseHandoff = await acquireReuseHandoff({
|
||||
task,
|
||||
store,
|
||||
projectRoot: projectRootDir,
|
||||
settings,
|
||||
worktreePath: integrationRoot.rootDir,
|
||||
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? integrationRoot.rootDir),
|
||||
});
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-handoff-acquired",
|
||||
{
|
||||
taskId,
|
||||
branch: reuseHandoff.branch,
|
||||
worktreePath: reuseHandoff.worktreePath,
|
||||
integrationRemote: integrationRemote ?? null,
|
||||
integrationBranch: mergeTarget.branch,
|
||||
},
|
||||
reuseHandoff.worktreePath,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof MergeHandoffRefusedError) {
|
||||
await emitReuseHandoffAuditEvent(
|
||||
"merge:reuse-handoff-refused",
|
||||
{
|
||||
taskId,
|
||||
gate: error.gate,
|
||||
reason: error.reason,
|
||||
diagnostics: error.payload,
|
||||
},
|
||||
integrationRoot.rootDir,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch?.trim() || mergeTarget.branch;
|
||||
const releaseReuseHandoffEarly = async (outcome: string): Promise<void> => {
|
||||
if (!reuseHandoff) return;
|
||||
const handoff = reuseHandoff;
|
||||
reuseHandoff = undefined;
|
||||
await releaseReuseHandoff({
|
||||
handoff,
|
||||
outcome,
|
||||
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? handoff.worktreePath),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveAheadCount = async (): Promise<{ aheadCount: number; baseRef: string } | null> => {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(branch)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
@@ -6355,7 +6493,10 @@ export async function aiMergeTask(
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(baseRef)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
} catch {
|
||||
const remoteRef = `origin/${requestedBaseRef}`;
|
||||
if (!integrationRemote) {
|
||||
return null;
|
||||
}
|
||||
const remoteRef = `${integrationRemote}/${requestedBaseRef}`;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${quoteArg(remoteRef)}`, { cwd: rootDir, timeout: 30_000 });
|
||||
baseRef = remoteRef;
|
||||
@@ -6408,6 +6549,7 @@ export async function aiMergeTask(
|
||||
mergeTargetBranch: aheadInfo.baseRef,
|
||||
};
|
||||
await completeTask(store, taskId, result);
|
||||
await releaseReuseHandoffEarly("success");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6446,6 +6588,7 @@ export async function aiMergeTask(
|
||||
mergeTargetBranch: classification.baseRef,
|
||||
};
|
||||
await completeTask(store, taskId, result);
|
||||
await releaseReuseHandoffEarly("success");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6463,6 +6606,7 @@ export async function aiMergeTask(
|
||||
metadata: { reason: classification.reason, details: classification.details, autoRetry: true },
|
||||
});
|
||||
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
|
||||
await releaseReuseHandoffEarly(unprovenError);
|
||||
return {
|
||||
task,
|
||||
branch,
|
||||
@@ -6506,6 +6650,7 @@ export async function aiMergeTask(
|
||||
// Hoisted so the finally block (below) can attach the autostash outcome
|
||||
// to the result object the caller will receive.
|
||||
let resultForFinally: MergeResult | undefined;
|
||||
let reuseHandoffOutcome = "success";
|
||||
try {
|
||||
|
||||
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
|
||||
@@ -6519,25 +6664,12 @@ export async function aiMergeTask(
|
||||
};
|
||||
resultForFinally = result;
|
||||
|
||||
// Build merge-run context for audit instrumentation (FN-1404)
|
||||
const mergeRunId = generateSyntheticRunId("merge", taskId);
|
||||
const engineRunContext: EngineRunContext = {
|
||||
runId: mergeRunId,
|
||||
agentId: "merger",
|
||||
taskId,
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "merge",
|
||||
};
|
||||
|
||||
// Create run auditor for TaskStore-backed audit emission (no-ops if store doesn't support it)
|
||||
const audit = createRunAuditor(store, engineRunContext);
|
||||
|
||||
if (!worktreePath) {
|
||||
mergerLog.warn(`${taskId}: no worktree path set — skipping worktree cleanup`);
|
||||
}
|
||||
|
||||
// 2. Read settings
|
||||
const settings = await store.getSettings();
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
||||
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
||||
@@ -6553,7 +6685,7 @@ export async function aiMergeTask(
|
||||
// by `-X ours`/`-X theirs` falling back to a stale base. Best-effort: any
|
||||
// failure (no remote, network down, divergent local) logs and continues.
|
||||
if (mergeConflictStrategy === "smart-prefer-main" || mergeConflictStrategy === "smart-prefer-branch") {
|
||||
await tryFastForwardFromOrigin(rootDir, taskId);
|
||||
await tryFastForwardFromOrigin(rootDir, taskId, mergeTarget.branch, integrationRemote);
|
||||
}
|
||||
|
||||
// Tracks the "empty squash" success path — when `git merge --squash`
|
||||
@@ -6565,11 +6697,6 @@ export async function aiMergeTask(
|
||||
let mergeWasEmpty = false;
|
||||
let recoveredMergeSha: string | undefined;
|
||||
|
||||
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||
projectDefaultBranch,
|
||||
});
|
||||
|
||||
// 3. Check branch exists
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
@@ -6637,8 +6764,11 @@ export async function aiMergeTask(
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3b. Ensure rootDir is on the resolved merge target before merging.
|
||||
// Without this, a merge could land on whatever branch was last checked out.
|
||||
// 3b. Ensure rootDir is based on the resolved integration target before merging.
|
||||
// In reuse-task-worktree mode the task worktree is branch-bound, so we detach
|
||||
// to the integration branch tip instead of checking out the integration branch
|
||||
// directly; this keeps the project root untouched while preserving the merge
|
||||
// cascade's expected `preAttemptHeadSha === integration target` invariant.
|
||||
try {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
|
||||
@@ -6646,7 +6776,15 @@ export async function aiMergeTask(
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}).trim();
|
||||
if (currentBranch !== mergeTarget.branch) {
|
||||
if (reuseTaskWorktreeMerge) {
|
||||
mergerLog.log(
|
||||
`${taskId}: reusing task worktree — detaching HEAD at '${mergeTarget.branch}' before merge (${mergeTarget.source})`,
|
||||
);
|
||||
await execAsync(`git checkout --detach "${mergeTarget.branch}"`, {
|
||||
cwd: rootDir,
|
||||
});
|
||||
await audit.git({ type: "branch:checkout", target: mergeTarget.branch });
|
||||
} else if (currentBranch !== mergeTarget.branch) {
|
||||
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mergeTarget.branch}' before merge (${mergeTarget.source})`);
|
||||
await execAsync(`git checkout "${mergeTarget.branch}"`, {
|
||||
cwd: rootDir,
|
||||
@@ -6659,6 +6797,10 @@ export async function aiMergeTask(
|
||||
}
|
||||
|
||||
// 3c. Pre-merge remote rebase.
|
||||
// `rootDir` is the resolved integration root for this merge attempt: either
|
||||
// the project root (`cwd-main`) or the reused task worktree after the FN-5279
|
||||
// handoff gates. All fetch/rebase commands below intentionally stay on that
|
||||
// resolved root so the full conflict cascade runs in one place.
|
||||
//
|
||||
// When another collaborator (or another fusion worker on a different
|
||||
// machine) pushes to the remote while our task branch is in flight, the
|
||||
@@ -6858,61 +7000,19 @@ export async function aiMergeTask(
|
||||
// ── Stage 1: remote rebase ────────────────────────────────────────────
|
||||
if (settings.worktreeRebaseBeforeMerge !== false) {
|
||||
try {
|
||||
// Resolve which remote to fetch. An explicit setting wins; otherwise
|
||||
// the repo's configured default (branch.<main>.remote) or the sole
|
||||
// remote if there's exactly one.
|
||||
let remote = settings.worktreeRebaseRemote?.trim();
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout: mainBranchOut } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const mainBranch = mainBranchOut.trim();
|
||||
const { stdout: configuredRemote } = await execAsync(
|
||||
`git config --get branch.${mainBranch}.remote`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
).catch(() => ({ stdout: "" }));
|
||||
remote = configuredRemote.trim();
|
||||
} catch {
|
||||
// Fall through to listing remotes below.
|
||||
}
|
||||
}
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout: remotesOut } = await execAsync("git remote", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const remotes = remotesOut.trim().split(/\s+/).filter(Boolean);
|
||||
if (remotes.length === 1) {
|
||||
remote = remotes[0];
|
||||
} else if (remotes.includes("origin")) {
|
||||
remote = "origin";
|
||||
}
|
||||
} catch {
|
||||
// Ignore — we'll skip the rebase if no remote is resolvable.
|
||||
}
|
||||
}
|
||||
|
||||
if (!remote) {
|
||||
mergerLog.log(`${taskId}: no remote resolvable — skipping remote rebase stage (local-base stage may still run)`);
|
||||
if (!integrationRemote) {
|
||||
mergerLog.log(`${taskId}: no integration remote resolvable — skipping remote rebase stage (local-base stage may still run)`);
|
||||
} else if (!worktreePath) {
|
||||
mergerLog.warn(`${taskId}: no worktreePath — skipping remote rebase stage`);
|
||||
} else {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
|
||||
await execAsync(`git fetch "${remote}"`, { cwd: rootDir });
|
||||
mergerLog.log(`${taskId}: fetching ${integrationRemote} before merge`);
|
||||
await execAsync(`git fetch ${quoteArg(integrationRemote)}`, { cwd: rootDir });
|
||||
|
||||
try {
|
||||
const { stdout: mainBranchOut } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const mainBranch = mainBranchOut.trim();
|
||||
const remoteRef = `${remote}/${mainBranch}`;
|
||||
const remoteRef = `${integrationRemote}/${mergeTarget.branch}`;
|
||||
throwIfAborted(options.signal, taskId);
|
||||
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
||||
await execAsync(`git rebase ${quoteArg(remoteRef)}`, { cwd: worktreePath });
|
||||
rebaseHappened = true;
|
||||
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
|
||||
await store.appendAgentLog(
|
||||
@@ -6934,7 +7034,7 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
if (mergeConflictStrategy === "smart-prefer-main") {
|
||||
preferMainRebaseFailureMessage = `Pre-merge rebase onto remote main aborted (${msg})`;
|
||||
preferMainRebaseFailureMessage = `Pre-merge rebase onto remote ${mergeTarget.branch} aborted (${msg})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7226,6 +7326,8 @@ export async function aiMergeTask(
|
||||
headRef: branch,
|
||||
baseBranch: task.baseBranch,
|
||||
baseCommitSha: task.baseCommitSha,
|
||||
integrationBranchFallback: mergeTarget.branch,
|
||||
integrationRemoteFallback: integrationRemote,
|
||||
});
|
||||
const preferBranchOnOverlapFiles = new Set<string>();
|
||||
if (
|
||||
@@ -8429,6 +8531,9 @@ export async function aiMergeTask(
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
reuseHandoffOutcome = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (autostashHandle) {
|
||||
try {
|
||||
@@ -8482,6 +8587,18 @@ export async function aiMergeTask(
|
||||
// threw — a stale advisory makes the dashboard show a phantom "merge
|
||||
// running" indefinitely, which is worse than a missing one.
|
||||
clearActiveMergerStatus(activeStatusPath, taskId);
|
||||
if (reuseHandoff) {
|
||||
const handoff = reuseHandoff;
|
||||
reuseHandoff = undefined;
|
||||
const handoffOutcome = reuseHandoffOutcome === "success"
|
||||
? resultForFinally?.error ?? "success"
|
||||
: reuseHandoffOutcome;
|
||||
await releaseReuseHandoff({
|
||||
handoff,
|
||||
outcome: handoffOutcome,
|
||||
auditEmit: (event) => emitReuseHandoffAuditEvent(event.type as any, event.metadata ?? {}, event.target ?? handoff.worktreePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8490,21 +8607,18 @@ export async function aiMergeTask(
|
||||
* (no remote configured, network down, divergent local commits, etc.).
|
||||
* Only called for the smart strategies, which want to avoid resolving a
|
||||
* conflict against a stale local base. */
|
||||
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
|
||||
let currentBranch: string;
|
||||
try {
|
||||
currentBranch = execSyncText("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}).trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!currentBranch || currentBranch === "HEAD") return;
|
||||
async function tryFastForwardFromOrigin(
|
||||
rootDir: string,
|
||||
taskId: string,
|
||||
integrationBranch: string,
|
||||
integrationRemote?: string,
|
||||
): Promise<void> {
|
||||
const currentBranch = integrationBranch.trim();
|
||||
const remote = integrationRemote?.trim();
|
||||
if (!currentBranch || !remote) return;
|
||||
|
||||
try {
|
||||
await execAsync(`git fetch origin "${currentBranch}"`, { cwd: rootDir });
|
||||
await execAsync(`git fetch ${quoteArg(remote)} ${quoteArg(currentBranch)}`, { cwd: rootDir });
|
||||
} catch (err) {
|
||||
mergerLog.log(`${taskId}: pre-merge fetch failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
@@ -8513,8 +8627,9 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
|
||||
// Detect divergence: local must be strictly behind remote (no local-only commits).
|
||||
let behind = 0;
|
||||
let ahead = 0;
|
||||
const remoteRef = `${remote}/${currentBranch}`;
|
||||
try {
|
||||
const counts = execSyncText(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
|
||||
const counts = execSyncText(`git rev-list --left-right --count ${quoteArg(`${remoteRef}...HEAD`)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
@@ -8533,8 +8648,8 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git merge --ff-only "origin/${currentBranch}"`, { cwd: rootDir });
|
||||
mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from origin`);
|
||||
await execAsync(`git merge --ff-only ${quoteArg(remoteRef)}`, { cwd: rootDir });
|
||||
mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from ${remote}`);
|
||||
} catch (err) {
|
||||
mergerLog.log(`${taskId}: fast-forward failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -9453,6 +9568,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
|
||||
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
|
||||
|
||||
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
|
||||
// root so arbiter edits land in the reused task worktree when handoff mode
|
||||
// is active.
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
runtimeHint: mergerRuntimeHint,
|
||||
|
||||
@@ -155,6 +155,10 @@ export type GitMutationType =
|
||||
| "merge:auto-prerebase:failed"
|
||||
| "merge:layer3:foreign-file-skipped"
|
||||
| "merge:layer3:scope-override-bypass"
|
||||
| "merge:reuse-handoff-acquired"
|
||||
| "merge:reuse-handoff-refused"
|
||||
| "merge:reuse-handoff-released"
|
||||
| "merge:reuse-handoff-deferred-to-worktrunk"
|
||||
| "merge:audit-failure"
|
||||
| "branch:auto-reclaim"
|
||||
| "branch:auto-canonicalize-case"
|
||||
|
||||
Reference in New Issue
Block a user