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>
255 lines
10 KiB
TypeScript
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);
|
|
});
|