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:
committed by
gsxdsm
parent
79850b233f
commit
b3f995c857
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user