Files
fusion/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts
gsxdsm acf3502a25 fix(merger): refuse no-op finalize when modifiedFiles claims work was done
Third root-cause fix in the FN-5475 sweep. When `aiMergeTask` /
`recoverNoOpReviewTasks` classified a task as `proven-no-op` or
`no-changes-finalized`, both call sites moved the task to Done while
clearing `modifiedFiles: []` — silently destroying the audit trail when the
work product was uncommitted in the worktree, squashed against the wrong
branch, or dropped by reuse-handoff churn. This was the load-bearing site
of the FN-5490 / FN-5517 / FN-5526 / FN-5540 lost-work patterns.

Both call sites now check `task.modifiedFiles.length` before finalizing as
no-op. If the task claims work was done but no commit landed, the task is
moved back to `todo` with progress preserved and a new
`task:finalize-lost-work-blocked` audit event is emitted. The next
executor run re-attempts the work; the operator sees the audit event in
the timeline.

The post-hoc `reconcileDoneTaskIntegrity` path is intentionally NOT gated
— it cleans up already-Done tasks (legacy state) and is out-of-scope for
prevention. 9 lost-work tasks already in this state at sweep time are
cataloged in docs/incidents/2026-05-23-lost-work-tasks.md for fresh
re-spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:42:21 -07:00

255 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { DEFAULT_SETTINGS } from "@fusion/core";
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(async () => ({ session: { prompt: vi.fn(async () => undefined), dispose: vi.fn() } })),
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session: any, prompt: string, options?: any) => {
if (options === undefined) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
}),
compactSessionContext: vi.fn(),
}));
import { aiMergeTask, classifyOwnedLandedEvidence } from "../merger.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
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,
includeTaskIdInCommit: false,
commitAuthorEnabled: false,
useAiMergeCommitSummary: false,
...settings,
} as Settings;
return {
getTask: vi.fn(async () => currentTask),
getSettings: vi.fn(async () => mergedSettings),
listTasks: vi.fn(async () => [currentTask]),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
currentTask = { ...currentTask, ...updates, updatedAt: new Date().toISOString() } as Task;
return currentTask;
}),
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
currentTask = {
...currentTask,
column,
columnMovedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
return currentTask;
}),
logEntry: vi.fn(async () => undefined),
appendAgentLog: vi.fn(async () => undefined),
updateSettings: vi.fn(async () => mergedSettings),
getActiveMergingTask: vi.fn(() => null),
emit: vi.fn(),
on: vi.fn(),
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
getVerificationCacheHit: vi.fn(() => null),
recordVerificationCachePass: vi.fn(() => undefined),
upsertTaskCommitAssociation: vi.fn(async () => undefined),
} as unknown as TaskStore;
}
describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true });
}
});
it("classifies owned-commit when landed trailer commit exists on target", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-owned-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
git(repo, "git checkout -b fusion/fn-owned");
writeFileSync(join(repo, "owned.txt"), "owned\n", "utf-8");
git(repo, "git add owned.txt && git commit -m 'feat(FN-OWNED): landed' -m 'Fusion-Task-Id: FN-OWNED'");
const ownedSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout main");
git(repo, `git cherry-pick ${ownedSha}`);
const classification = await classifyOwnedLandedEvidence(repo, { id: "FN-OWNED", branch: "fusion/fn-owned" } as Task, {
mergeTargetBranch: "main",
});
expect(classification.kind).toBe("owned-commit");
});
it("classifies proven-no-op when branch is zero-ahead from merge target and base is reachable", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-proven-noop-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-noop");
git(repo, "git checkout main");
const classification = await classifyOwnedLandedEvidence(
repo,
{ id: "FN-NOOP", branch: "fusion/fn-noop", baseCommitSha: baseSha } as Task,
{ mergeTargetBranch: "main" },
);
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
// FN-5345/FN-5377 direct classifier coverage: empty-own-diff (aheadCount > 0
// but zero net diff vs merge-base) is logically equivalent to proven-no-op.
// This pairs with the merger's early fast-path and exercises the new branch
// in classifyOwnedLandedEvidence that self-healing and post-handoff finalize
// paths also rely on.
it("classifies proven-no-op for empty-own-diff branches (FN-5345/FN-5377)", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-empty-own-diff-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-empty-own-diff");
// 1 own commit with zero net tree change vs merge-base.
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-OWN-DIFF): handoff'");
const branchTipSha = git(repo, "git rev-parse HEAD");
expect(branchTipSha).not.toBe(baseSha); // aheadCount >= 1
git(repo, "git checkout main");
const classification = await classifyOwnedLandedEvidence(
repo,
{ id: "FN-EMPTY-OWN-DIFF", branch: "fusion/fn-empty-own-diff", baseCommitSha: baseSha } as Task,
{ mergeTargetBranch: "main" },
);
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
// FN-5490/FN-5517/FN-5526/FN-5540 regression: the previous contract here
// was "auto-finalize proven no-op and clear stale modifiedFiles", which
// turned out to be the bug — claimed modifiedFiles + no commit = lost work
// (uncommitted in the worktree or squashed against the wrong branch), not
// a legitimate no-op. The merger now refuses to finalize and moves the
// task back to todo with progress preserved instead.
it("FN-5490: refuses no-op finalize when modifiedFiles are claimed without a commit", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-noop-finalize-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-c");
git(repo, "git checkout main");
const task = {
id: "FN-C",
title: "FN-C",
description: "FN-C",
column: "in-review",
branch: "fusion/fn-c",
baseBranch: "main",
baseCommitSha: baseSha,
modifiedFiles: ["borrowed.txt"],
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-C",
} as unknown as Task;
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-C");
// Lost-work guard fires — task does NOT advance to done, does NOT have
// modifiedFiles cleared, and gets moved back to todo with progress.
expect(result.merged).toBe(false);
expect(result.error).toMatch(/lost-work/);
expect(
(store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(
([, patch]) => Array.isArray(patch?.modifiedFiles) && patch.modifiedFiles.length === 0,
),
).toBe(false);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(false);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "todo")).toBe(true);
}, 20_000);
it("blocks FN-4653 shape: foreign start-point branch with no FN-owned commits", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-unproven-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
writeFileSync(join(repo, "README.md"), "init\n", "utf-8");
git(repo, "git add README.md && git commit -m 'chore: init'");
git(repo, "git checkout -b fusion/fn-a");
writeFileSync(join(repo, "foreign.txt"), "from fn-a\n", "utf-8");
git(repo, "git add foreign.txt");
git(repo, "git commit -m 'feat(FN-A): foreign start point' -m 'Fusion-Task-Id: FN-A'");
const foreignBaseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout main");
git(repo, "git checkout -b fusion/fn-b");
git(repo, "git checkout main");
const task = {
id: "FN-B",
title: "FN-B",
description: "FN-B",
column: "in-review",
branch: "fusion/fn-b",
baseBranch: "main",
baseCommitSha: foreignBaseSha,
modifiedFiles: ["foreign.txt"],
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-B",
} as unknown as Task;
const classification = await classifyOwnedLandedEvidence(repo, task, { mergeTargetBranch: "main" });
expect(classification.kind).toBe("unproven");
if (classification.kind === "unproven") {
expect(classification.reason).toBe("foreign-start-point");
}
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-B");
expect(result.merged).toBe(false);
expect(result.error).toContain("finalize-unproven");
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(false);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "todo")).toBe(true);
}, 20_000);
});