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:
gsxdsm
2026-05-23 17:42:21 -07:00
parent d5cfa92c23
commit acf3502a25
6 changed files with 206 additions and 7 deletions

View File

@@ -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 () => {

View File

@@ -621,8 +621,23 @@ export type OwnedLandedClassification =
details: Record<string, unknown>;
};
function escapeRegexForOwnership(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Decide whether a git commit belongs to a given task. Line-anchored trailers
* and subject-anchored conventional commits only — prose mentions never count.
* Mirrors `commitOwnedByTask` in self-healing.ts (FN-5441/FN-5446 regression).
*/
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId);
if (new RegExp(`(?:^|\\n)${escapeRegexForOwnership(FUSION_TASK_ID_TRAILER_KEY)}: ${escapeRegexForOwnership(taskId)}\\s*(?:\\n|$)`).test(body)) {
return true;
}
const subjectAnchor = new RegExp(
`^(?:[A-Za-z]+(?:\\([^)]*\\b${escapeRegexForOwnership(taskId)}\\b[^)]*\\))?:|${escapeRegexForOwnership(taskId)}:)`,
);
return subjectAnchor.test(subject);
}
async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promise<OwnedLandedCommit | null> {
@@ -7886,6 +7901,44 @@ export async function aiMergeTask(
}
if (classification.kind === "proven-no-op" || classification.kind === "no-changes-finalized") {
// FN-5490/FN-5517/FN-5526/FN-5540 guard: the classifier only sees git
// evidence, but the task itself can attest that work happened. When
// modifiedFiles is non-empty AND no commit landed, that's lost work
// (uncommitted in the worktree, or the squash committed the wrong tree)
// — NOT a legitimate no-op. Demote to the unproven-recovery path which
// moves the task back to todo with progress preserved instead of
// clearing modifiedFiles to [].
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
const reason = `lost-work-detected: ${task.modifiedFiles.length} modifiedFiles claimed but no commit landed`;
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (lost-work guard): task claims ${task.modifiedFiles.length} modifiedFiles but classification would finalize as no-op — moving back to todo with progress preserved`,
JSON.stringify({
modifiedFilesSample: task.modifiedFiles.slice(0, 5),
classification: classification.kind,
}, null, 2),
);
await (store as any).recordRunAuditEvent?.({
domain: "database",
mutationType: "task:finalize-lost-work-blocked",
target: taskId,
metadata: {
modifiedFilesCount: task.modifiedFiles.length,
classification: classification.kind,
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
await releaseReuseHandoffEarly("lost-work-blocked");
return {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
error: reason,
};
}
const noOpReason = classification.kind === "proven-no-op"
? `branch has zero commits ahead of ${classification.baseRef}`
: "verification-only finalize: no branch and no owned commits";

View File

@@ -497,6 +497,14 @@ export type DatabaseMutationType =
| "session:runtime-resolved"
| "task:in-review-stall-deadlock-disposed"
| "task:finalize-unproven-blocked"
/**
* FN-5490/FN-5517/FN-5526/FN-5540 lost-work guard: the merger or self-heal
* sweep refused to finalize a task as no-op because its record claimed
* `modifiedFiles` while no commit landed. Task is moved back to todo with
* progress preserved instead of silently clearing modifiedFiles to [].
* Metadata: { modifiedFilesCount, classification, baseRef? }
*/
| "task:finalize-lost-work-blocked"
| "task:integrity-reconcile-modified-files"
| "task:integrity-warning"
/** FN-5092 watchdog: stale `status: "merging"` / `"merging-pr"` cleared on a done/archived task. Metadata: { previousColumn, previousStatus, ageMs, mergeConfirmed?: boolean } */

View File

@@ -3989,7 +3989,7 @@ export class SelfHealingManager {
return recovered;
}
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:finalize-lost-work-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-healing-integrity", taskId),
agentId: "self-healing",
@@ -4159,6 +4159,32 @@ export class SelfHealingManager {
await this.store.updateTask(task.id, { mergeDetails });
await this.store.logEntry(task.id, `Auto-finalized: recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
} else {
// FN-5490/FN-5517/FN-5526/FN-5540 guard: same lost-work check as
// merger.ts:aiMergeTask. The self-heal path was the historical
// primary site of the bug — it would clear `modifiedFiles: []`
// (line below) while moving the task to Done, silently destroying
// the audit trail of the lost work. Now we refuse to finalize and
// move the task back to todo with progress preserved so the next
// executor run can re-attempt.
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
await this.store.logEntry(
task.id,
`Finalize blocked (lost-work guard): task claims ${task.modifiedFiles.length} modifiedFiles but classification would finalize as no-op — moving back to todo with progress preserved`,
JSON.stringify({
modifiedFilesSample: task.modifiedFiles.slice(0, 5),
classification: "proven-no-op",
baseRef: classification.baseRef,
}, null, 2),
);
await this.recordIntegrityAudit(task.id, "task:finalize-lost-work-blocked", {
modifiedFilesCount: task.modifiedFiles.length,
classification: "proven-no-op",
baseRef: classification.baseRef,
});
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" });
recovered++;
continue;
}
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}),