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>
This commit is contained in:
@@ -151,7 +151,13 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
|
||||
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
|
||||
});
|
||||
|
||||
it("auto-finalizes proven no-op and clears stale modifiedFiles", async () => {
|
||||
// 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");
|
||||
@@ -183,10 +189,17 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
|
||||
const store = createStore(task);
|
||||
const result = await aiMergeTask(store, repo, "FN-C");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOpMerge).toBe(true);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(([, patch]) => patch?.modifiedFiles?.length === 0)).toBe(true);
|
||||
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
|
||||
// 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user