feat(FN-5348): remove cwd-main integration mode fallback

Removes the `cwd-main` integration fallback mode (FN-5348), eliminating the legacy shortcut path where the merger would operate directly on the project root instead of a dedicated worktree. Steps normalize the `reuse-task-worktree` integration mode as the sole path, wire stricter mode invariants in

Fusion-Task-Id: FN-5348
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 12:49:02 -07:00
committed by gsxdsm
parent 79850b233f
commit b3f995c857
12 changed files with 293 additions and 34 deletions

View File

@@ -0,0 +1,131 @@
import { readFileSync } from "node:fs";
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 { aiMergeTask } from "../merger.js";
import { mergerLog } from "../logger.js";
import { resolveMergeIntegrationRoot } from "../merger-integration-worktree.js";
import { git, hasGit, makeReliabilityFixture } from "./reliability-interactions/_helpers.js";
describe("FN-5348 cwd integration fallback removed", () => {
it.skipIf(!hasGit)("Scenario A/B: dirty refusal keeps integration ref unchanged and emits refusal audit on master", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5348-DIRTY-REFUSAL",
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-5348-dirty.ts", "export const dirty = true;\n", "feat: add dirty refusal content");
await fixture.checkout("master");
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'");
const integrationBefore = git(rootDir, "git rev-parse refs/heads/master");
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
name: "MergeHandoffRefusedError",
gate: "working-tree-dirty",
reason: "dirty-worktree",
});
const integrationAfter = git(rootDir, "git rev-parse refs/heads/master");
expect(integrationAfter).toBe(integrationBefore);
const refused = store.getRunAuditEvents({ taskId: task.id }).filter((event) => event.mutationType === "merge:reuse-handoff-refused");
expect(refused).toHaveLength(1);
expect(refused[0]?.metadata).toMatchObject({ gate: "working-tree-dirty", reason: "dirty-worktree" });
expect(refused[0]?.metadata?.integrationBranch).toBeUndefined();
expect(JSON.stringify(refused[0]?.metadata ?? {})).not.toContain("\"main\"");
const latest = await store.getTask(task.id);
expect(latest?.column).toBe("in-review");
// aiMergeTask rethrows refusal; upstream project-engine catch maps this to status=failed.
expect(JSON.stringify({ gate: "working-tree-dirty", reason: "dirty-worktree" })).toContain("dirty-worktree");
} finally {
await fixture.cleanup();
}
}, 30_000);
it("Scenario C: worktrunk no longer forces cwd mode", () => {
const root = resolveMergeIntegrationRoot({
task: { id: "FN-5348", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: "reuse-task-worktree", worktrunk: { enabled: true } } as any,
projectRoot: "/tmp/project-root",
});
expect(root.mode).toBe("reuse-task-worktree");
});
it.skipIf(!hasGit)("Scenario D: explicit opt-in (legacy alias) emits warning", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5348-CWD-OPTIN",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "cwd-main",
} as any,
});
const warnSpy = vi.spyOn(mergerLog, "warn").mockImplementation(() => undefined 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-5348-optin.ts", "export const optin = true;\n", "feat: add cwd opt-in merge content");
await fixture.checkout("master");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("mergeIntegrationWorktree=cwd-integration-branch is explicit opt-in"));
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
expect(auditTypes).not.toContain("merge:cwd-integration-fallback-removed");
} finally {
await fixture.cleanup();
}
}, 30_000);
it.todo("Scenario E: reserved tripwire — no production emit site after Step 3; future regression would add one");
it("Scenario E: no production code path assigns integrationRoot.mode = cwd-main", () => {
const merger = readFileSync(new URL("../merger.ts", import.meta.url), "utf-8");
expect(merger).not.toMatch(/^\s*integrationRoot\.mode\s*=\s*\"cwd-main\"/m);
});
});

View File

@@ -34,7 +34,21 @@ describe("resolveMergeIntegrationRoot", () => {
});
});
it("preserves the legacy cwd-main mode when explicitly selected", () => {
it("maps explicit opt-in to canonical cwd-integration-branch mode", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
settings: { mergeIntegrationWorktree: "cwd-integration-branch" as const, worktrunk: { enabled: false } } as any,
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "cwd-integration-branch",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
});
it("maps legacy cwd-main input to canonical cwd-integration-branch mode", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
@@ -42,7 +56,7 @@ describe("resolveMergeIntegrationRoot", () => {
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "cwd-main",
mode: "cwd-integration-branch",
rootDir: "/tmp/project-root",
branchName: "fusion/fn-5279",
});
@@ -62,7 +76,7 @@ describe("resolveMergeIntegrationRoot", () => {
});
});
it("defers to the project root when worktrunk owns merge orchestration", () => {
it("keeps reuse-task-worktree mode when worktrunk is enabled", () => {
expect(
resolveMergeIntegrationRoot({
task: { id: "FN-5279", worktree: "/tmp/task-worktree" } as any,
@@ -70,8 +84,8 @@ describe("resolveMergeIntegrationRoot", () => {
projectRoot: "/tmp/project-root",
}),
).toEqual({
mode: "cwd-main",
rootDir: "/tmp/project-root",
mode: "reuse-task-worktree",
rootDir: "/tmp/task-worktree",
branchName: "fusion/fn-5279",
});
});

View File

@@ -0,0 +1,57 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { aiMergeTask } from "../../merger.js";
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
describe("FN-5348 reliability interactions: cwd fallback removal", () => {
it.skipIf(!hasGit)("autoMerge=false + reuse refusal stays in-review and emits no cwd fallback events", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5348-RI-AUTO-OFF-REFUSAL",
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");
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-5348-ri-refusal.ts", "export const refusal = true;\n", "feat: add refusal 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",
});
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).toContain("merge:reuse-handoff-refused");
expect(auditTypes).not.toContain("merge:cwd-integration-fallback-removed");
} finally {
await fixture.cleanup();
}
}, 30_000);
});

View File

@@ -438,7 +438,7 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
}
}, 30_000);
it.skipIf(!hasGit)("worktrunk override records deferred-to-worktrunk without acquiring reuse handoff", async () => {
it.skipIf(!hasGit)("worktrunk-enabled reuse mode still acquires reuse handoff", async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-WORKTRUNK",
settings: {
@@ -452,6 +452,8 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
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 }));
@@ -464,12 +466,16 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
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");
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);
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");
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
} finally {
await fixture.cleanup();
}

View File

@@ -2,6 +2,11 @@ import type { AutoRecoveryFailureClass, AutoRecoveryMode, AutoRecoverySettings,
import { createLogger, type Logger } from "./logger.js";
import type { RunAuditor } from "./run-audit.js";
// FN-5348 invariant: auto-recovery paths MUST NOT select cwd-integration-branch
// (or legacy cwd-main) as a fallback after reuse-task-worktree handoff refusal.
// Reuse refusal handling must reacquire a fresh task worktree or preserve failed
// in-review state; any future mode fallback must emit merge:cwd-integration-fallback-removed.
export type AutoRecoveryAction = "retry" | "spawn-ai-recovery" | "pause";
export interface AutoRecoveryFailure {

View File

@@ -1,6 +1,9 @@
import { createHash } from "node:crypto";
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import {
normalizeMergeIntegrationWorktreeMode,
} from "@fusion/core";
import type {
MergeIntegrationWorktreeMode,
MergeQueueReleaseOutcome,
@@ -44,17 +47,9 @@ export function resolveMergeIntegrationRoot(
): 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";
const mode = normalizeMergeIntegrationWorktreeMode(
input.settings.mergeIntegrationWorktree,
);
return {
mode,

View File

@@ -48,6 +48,7 @@ import {
normalizeMergeStrategyOverlapBehavior,
normalizePostMergeAuditMode,
resolveTaskMergeTarget,
normalizeMergeIntegrationWorktreeMode,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
@@ -6721,8 +6722,9 @@ export async function aiMergeTask(
// still go through the standard reuse-handoff path so the existing
// handoff lease lifecycle and FN-5083 branch-rebind invariants are
// preserved.
// - cwd-main integration mode (legacy / unit-test default) is unchanged.
if (settings.mergeIntegrationWorktree !== "cwd-main") {
// - cwd-integration-branch mode (explicit opt-in) is unchanged.
const requestedIntegrationMode = normalizeMergeIntegrationWorktreeMode(settings.mergeIntegrationWorktree);
if (requestedIntegrationMode !== "cwd-integration-branch") {
try {
const earlyResult = await tryEarlyEmptyOwnDiffFinalize({
task,
@@ -6742,9 +6744,12 @@ export async function aiMergeTask(
}
}
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
? "cwd-main"
: "reuse-task-worktree";
if (requestedIntegrationMode === "cwd-integration-branch") {
mergerLog.warn(
`${taskId}: mergeIntegrationWorktree=cwd-integration-branch is explicit opt-in and runs merge operations in the user's working directory (FN-5348). The engine assumes the integration branch is checked out there.`,
);
}
let integrationRoot = resolveMergeIntegrationRoot({
task,
settings,
@@ -6967,7 +6972,6 @@ export async function aiMergeTask(
if (
settings.worktrunk?.enabled === true
&& requestedIntegrationMode === "reuse-task-worktree"
&& integrationRoot.mode === "cwd-main"
) {
await emitReuseHandoffAuditEvent(
"merge:reuse-handoff-deferred-to-worktrunk",
@@ -7020,6 +7024,11 @@ export async function aiMergeTask(
reuseHandoff.worktreePath,
);
} catch (error: unknown) {
// FN-5348 invariant: a reuse-task-worktree handoff refusal MUST NOT fall back to
// cwd-main / cwd-<integration-branch>. Acceptable outcomes: reacquire a fresh task
// worktree (below) or rethrow MergeHandoffRefusedError so upstream parks the task
// in-review with status: "failed". Any new branch added here must NOT assign
// the legacy cwd-main mode to integrationRoot.
if (!(error instanceof MergeHandoffRefusedError)) {
throw error;
}
@@ -7384,7 +7393,7 @@ 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
// the project root (`cwd-integration-branch`) 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.
//

View File

@@ -160,6 +160,7 @@ export type GitMutationType =
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:cwd-integration-fallback-removed"
| "merge:reuse-fallback-new-worktree"
| "merge:reuse-fallback-pruned-stale-registration"
| "merge:reuse-fallback-reused-existing-registration"

View File

@@ -46,7 +46,7 @@ import { activeSessionRegistry } from "./active-session-registry.js";
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
import { resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import { resolveIntegrationBranch, resolveIntegrationBranchSync } from "./integration-branch.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import type { OwnedLandedClassification } from "./merger.js";
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
import {