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
@@ -298,7 +298,7 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
|
||||
- Resolution order: `settings.integrationBranch` → `settings.baseBranch` (legacy bridge) → `origin/HEAD` symbolic ref → fallback `"main"`.
|
||||
- `resolveTaskMergeTarget()` remains the task-level resolver; `resolveIntegrationBranch` supplies its `projectDefaultBranch` input and all project-level merge/self-heal/branch-conflict defaults.
|
||||
- Always-checked-out invariant: merger and conflict-inspection paths must not `git checkout <integration>` in task/root worktrees. Final integration-branch advance via `git update-ref` is tracked separately in FN-5350.
|
||||
- `cwd-main` integration mode removal is out of scope for this change stream and tracked separately in FN-5348.
|
||||
- **cwd-integration auto-fallback removed (FN-5348).** The merger never silently selects a `cwd-<integration-branch>` mode after a `reuse-task-worktree` handoff is refused. On refusal the existing rethrow path parks the task in `in-review` (`status: "failed"`) with the refusal gate + reason in `error`. The `worktrunk.enabled → cwd-main` shortcut in `resolveMergeIntegrationRoot` has been removed; worktrunk-enabled projects now keep the requested `reuse-task-worktree` mode and let worktrunk's own integration plumbing handle the merge target. `mergeIntegrationWorktree: "cwd-integration-branch"` (legacy alias: `"cwd-main"`) remains as explicit opt-in only and emits a `mergerLog.warn` line per merge. The audit event `merge:cwd-integration-fallback-removed` is reserved as a forensic tripwire for any future regression that reintroduces a silent fallback.
|
||||
|
||||
1. **Drop duplicate commits before merging.** If a branch contains commits that duplicate work already on main, rebase to drop them. Auto-resolvers cannot tell which side is canonical and will silently discard refinements. `git log main..branch --format=%s` should not overlap with `git log <base>..main --format=%s`.
|
||||
2. **Rebase over squash for multi-commit branches.** Fusion's direct merger defaults `directMergeCommitStrategy="auto"`: squash for 0–1 substantive commits, history-preserving rebase/cherry-pick otherwise. Force via project setting or `**Direct Merge Commit Strategy:** auto|always-squash|always-rebase` in PROMPT.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js";
|
||||
import {
|
||||
__resetLegacyCwdMainWarningForTests,
|
||||
normalizeMergeIntegrationWorktreeMode,
|
||||
} from "../types.js";
|
||||
import {
|
||||
@@ -10,6 +11,11 @@ import {
|
||||
} from "../worktrunk-settings.js";
|
||||
|
||||
describe("settings defaults invariants", () => {
|
||||
afterEach(() => {
|
||||
__resetLegacyCwdMainWarningForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps worktrunk default off in global and project defaults", () => {
|
||||
expect(DEFAULT_GLOBAL_SETTINGS.worktrunk.enabled).toBe(false);
|
||||
expect(DEFAULT_PROJECT_SETTINGS.worktrunk.enabled).toBe(false);
|
||||
@@ -82,7 +88,20 @@ describe("settings defaults invariants", () => {
|
||||
|
||||
it("preserves both supported values through normalization", () => {
|
||||
expect(normalizeMergeIntegrationWorktreeMode("reuse-task-worktree")).toBe("reuse-task-worktree");
|
||||
expect(normalizeMergeIntegrationWorktreeMode("cwd-main")).toBe("cwd-main");
|
||||
expect(normalizeMergeIntegrationWorktreeMode("cwd-integration-branch")).toBe("cwd-integration-branch");
|
||||
expect(normalizeMergeIntegrationWorktreeMode("cwd-main")).toBe("cwd-integration-branch");
|
||||
});
|
||||
|
||||
it("warns once per process for legacy cwd-main mode", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(normalizeMergeIntegrationWorktreeMode("cwd-main")).toBe("cwd-integration-branch");
|
||||
expect(normalizeMergeIntegrationWorktreeMode("cwd-main")).toBe("cwd-integration-branch");
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves legacy missing values to the new default", () => {
|
||||
|
||||
@@ -182,14 +182,33 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
|
||||
|
||||
export type PrStatus = "open" | "closed" | "merged" | "draft";
|
||||
export type MergeStrategy = "direct" | "pull-request";
|
||||
export type MergeIntegrationWorktreeMode = "reuse-task-worktree" | "cwd-main";
|
||||
export type MergeIntegrationWorktreeMode =
|
||||
| "reuse-task-worktree"
|
||||
| "cwd-integration-branch" // explicit opt-in; surfaces a warning at startup. See FN-5348.
|
||||
| "cwd-main"; // legacy alias for cwd-integration-branch; deprecated. Normalized at read time.
|
||||
|
||||
let warnedLegacyCwdMain = false;
|
||||
|
||||
export function __resetLegacyCwdMainWarningForTests(): void {
|
||||
warnedLegacyCwdMain = false;
|
||||
}
|
||||
|
||||
export function normalizeMergeIntegrationWorktreeMode(
|
||||
value: unknown,
|
||||
): MergeIntegrationWorktreeMode {
|
||||
return value === "cwd-main" || value === "reuse-task-worktree"
|
||||
? value
|
||||
: "reuse-task-worktree";
|
||||
if (value === "reuse-task-worktree" || value === "cwd-integration-branch") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === "cwd-main") {
|
||||
if (!warnedLegacyCwdMain) {
|
||||
warnedLegacyCwdMain = true;
|
||||
console.warn("[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch");
|
||||
}
|
||||
return "cwd-integration-branch";
|
||||
}
|
||||
|
||||
return "reuse-task-worktree";
|
||||
}
|
||||
|
||||
export const DIRECT_MERGE_COMMIT_STRATEGIES = ["auto", "always-squash", "always-rebase"] as const;
|
||||
@@ -2707,7 +2726,10 @@ export interface ProjectSettings {
|
||||
directMergeCommitStrategy?: DirectMergeCommitStrategy;
|
||||
/** Auto-merge integration-root mode.
|
||||
* - "reuse-task-worktree" (default): run the auto-merge cascade in the task worktree
|
||||
* - "cwd-main": preserve the legacy project-root integration flow
|
||||
* - "cwd-integration-branch": explicit opt-in only. Runs merge operations in the user's
|
||||
* checked-out integration-branch worktree, violating the FN-5349 invariant unless the user
|
||||
* explicitly accepts that risk.
|
||||
* - "cwd-main": legacy alias for "cwd-integration-branch" (normalized at read time)
|
||||
* Auto-merge only; manual/direct merge entrypoints outside auto-merge are unchanged. */
|
||||
mergeIntegrationWorktree?: MergeIntegrationWorktreeMode;
|
||||
/** Explicit integration branch name (e.g. `main`, `master`, `trunk`, `develop`).
|
||||
|
||||
@@ -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