FN-6461: block incomplete no-commits no-op finalization

Add a shared no-op finalize guard so no-commits tasks with mostly incomplete steps return to todo instead of silently completing.

- Add core step-evidence evaluation for no-commits no-op finalization.
- Apply the guard across AI empty merge, early empty-own-diff, legacy no-op classifier, direct no-op, and self-healing finalize lanes.
- Emit run-audit/log evidence and preserve progress when demoting blocked tasks.
- Cover guard behavior with core, merger, AI merge, real-git, and self-healing tests.
- Document the finalize integrity invariant and add a patch changeset.

Files changed:
 .changeset/fn-6461-no-commits-finalize-guard.md    |   5 +
 docs/architecture.md                               |   2 +
 .../__tests__/no-commits-finalize-guard.test.ts    |  58 +++++++
 packages/core/src/index.ts                         |   2 +
 packages/core/src/no-commits-finalize-guard.ts     |  42 +++++
 packages/engine/src/__tests__/merger-ai.test.ts    |  59 +++++++
 .../merger-finalize-unproven.real-git.test.ts      | 176 +++++++++++++++++++++
 packages/engine/src/__tests__/self-healing.test.ts | 125 +++++++++++++++
 packages/engine/src/merger-ai.ts                   |  45 ++++++
 packages/engine/src/merger.ts                      | 130 +++++++++++++++
 packages/engine/src/run-audit.ts                   |   6 +
 packages/engine/src/self-healing.ts                |  41 ++++-
 12 files changed, 689 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6461
Fusion-Task-Lineage: 36647768-42da-414a-9304-d8e0ed61b99c
This commit is contained in:
gsxdsm
2026-06-14 20:46:20 -07:00
parent da44392fea
commit fee0178b4f
12 changed files with 689 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Guard no-commits-expected tasks from being finalized as done by no-op merge/self-healing lanes when skipped or incomplete steps outweigh completed work.

View File

@@ -1075,6 +1075,7 @@ The run-audit system records every mutation performed by the engine across four
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.
- **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`).
- **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`.
- **Database / `task:no-commits-finalize-blocked-incomplete-steps`** — emitted by no-op finalize lanes when a `noCommitsExpected` task has no net branch changes but incomplete/skipped steps outweigh done steps. Metadata includes `{ reason, doneCount, incompleteCount, lane, classification?, baseRef? }`; the accompanying task log explains that the task was demoted to `todo` with progress preserved instead of finalized as done.
- **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put).
- **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution.
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
@@ -1642,6 +1643,7 @@ The GitHub tracking state listener now attaches to every registered project stor
#### Finalize integrity gate
- Finalize-to-done now runs an ownership classifier with three outcomes: `owned-commit` (task trailer/subject commit proven landed on merge target), `proven-no-op` (zero-ahead branch plus start point reachable from target), and `unproven` (missing ownership evidence, including foreign start-point inheritance).
- `owned-commit` and `proven-no-op` can finalize. `proven-no-op` explicitly reconciles metadata by clearing stale `task.modifiedFiles` and stamping `mergeDetails.noOpMerge=true` with `landedFiles: []`.
- `noCommitsExpected === true` tasks have an additional no-op finalize guard (FN-6461): if a zero-net-change lane reaches finalize with step evidence showing incomplete/skipped work outweighing completed work (`incompleteCount >= doneCount`, with at least one step), the task must not move to `done`. Merger and self-healing write `task.error`, log an operator-visible reason, emit `task:no-commits-finalize-blocked-incomplete-steps`, and move the task back to `todo` with `preserveProgress: true`. All-done no-commits tasks, mostly-done tasks with only a minor skipped tail, zero-step tasks, ordinary tasks, and no-commits tasks with real landed changes keep the existing finalize behavior.
- `unproven` no longer silently completes as done; merger/self-healing emit `task:finalize-unproven-blocked` audit events and auto-retry by requeuing to `todo` for a fresh execution pass.
- Historical cleanup is additive: `reconcileDoneTaskIntegrity()` scans done tasks missing `mergeDetails.commitSha` but still carrying `modifiedFiles`, then either recovers owned commit metadata, clears no-op stale files, or emits `task:integrity-warning` without regressing done tasks back to review. `task:integrity-warning` is transition-only on the persisted warning reason: first warning emits once, repeated sweeps with the same `mergeDetails.integrityWarning.reason` stay silent, and a new warning reason emits again.
- This integrity gate complements FN-4646 landed-file capture (metadata truth source) and FN-4647 dashboard labeling (UI presentation); gate enforcement is in merger/self-healing, while display semantics remain UI-owned.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { evaluateNoCommitsNoOpFinalize, type TaskStep } from "../index.js";
function steps(statuses: Array<TaskStep["status"]>): TaskStep[] {
return statuses.map((status, index) => ({ name: `Step ${index}`, status }));
}
describe("evaluateNoCommitsNoOpFinalize", () => {
it("blocks the FN-6455 skipped-release shape", () => {
const result = evaluateNoCommitsNoOpFinalize({
noCommitsExpected: true,
steps: steps(["done", "skipped", "skipped", "skipped", "skipped", "skipped"]),
});
expect(result).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 5 });
expect(result.reason).toContain("done=1, incomplete=5");
});
it("allows legitimate all-done no-op tasks", () => {
expect(evaluateNoCommitsNoOpFinalize({
noCommitsExpected: true,
steps: steps(["done", "done", "done"]),
})).toEqual({ blocked: false, doneCount: 3, incompleteCount: 0 });
});
it("allows mostly-done no-op tasks with only a minor skipped tail", () => {
expect(evaluateNoCommitsNoOpFinalize({
noCommitsExpected: true,
steps: steps(["done", "done", "done", "done", "done", "skipped"]),
})).toEqual({ blocked: false, doneCount: 5, incompleteCount: 1 });
});
it("blocks pending or in-progress work on no-commits tasks", () => {
expect(evaluateNoCommitsNoOpFinalize({
noCommitsExpected: true,
steps: steps(["done", "pending"]),
})).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 1 });
expect(evaluateNoCommitsNoOpFinalize({
noCommitsExpected: true,
steps: steps(["in-progress"]),
})).toMatchObject({ blocked: true, doneCount: 0, incompleteCount: 1 });
});
it("preserves zero-step behavior", () => {
expect(evaluateNoCommitsNoOpFinalize({ noCommitsExpected: true, steps: [] }))
.toEqual({ blocked: false, doneCount: 0, incompleteCount: 0 });
});
it("does not block ordinary tasks", () => {
expect(evaluateNoCommitsNoOpFinalize({
noCommitsExpected: false,
steps: steps(["done", "skipped", "skipped"]),
})).toEqual({ blocked: false, doneCount: 1, incompleteCount: 2 });
expect(evaluateNoCommitsNoOpFinalize({
steps: steps(["pending"]),
})).toEqual({ blocked: false, doneCount: 0, incompleteCount: 1 });
});
});

View File

@@ -494,6 +494,8 @@ export {
type NoOpCompletionMarker,
type NoOpCompletionMarkerKind,
} from "./no-op-completion-marker.js";
export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js";
export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js";
export {
__getDeterministicGuardMutexSize,
deterministicGuardLocks,

View File

@@ -0,0 +1,42 @@
import type { Task } from "./types.js";
export interface NoCommitsNoOpFinalizeEvaluation {
blocked: boolean;
reason?: string;
doneCount: number;
incompleteCount: number;
}
/**
* FNXC:Lifecycle 2026-06-14-19:54:
* FN-6461/FN-6455 showed that release and ops tasks marked `noCommitsExpected` can be silently finalized as no-op after skipping substantive steps.
* Zero-diff finalize lanes must only trust step evidence when completed work outweighs incomplete work; ties block because a todo requeue is recoverable while dropping operational work is not.
*/
export function evaluateNoCommitsNoOpFinalize(
task: Pick<Task, "noCommitsExpected" | "steps">,
): NoCommitsNoOpFinalizeEvaluation {
const steps = task.steps ?? [];
const doneCount = steps.filter((step) => step.status === "done").length;
const incompleteCount = steps.length - doneCount;
if (
task.noCommitsExpected === true &&
steps.length > 0 &&
incompleteCount > 0 &&
// Equal counts still block: requeueing is recoverable, but silently dropping ops work is not.
incompleteCount >= doneCount
) {
return {
blocked: true,
reason: `no-commits task skipped/incomplete work outweighs completed work (done=${doneCount}, incomplete=${incompleteCount}) with no net branch changes`,
doneCount,
incompleteCount,
};
}
return {
blocked: false,
doneCount,
incompleteCount,
};
}

View File

@@ -308,6 +308,65 @@ describe("runAiMerge", () => {
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
it("demotes a no-commits task with skipped-out work instead of AI empty-merge finalizing done", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
git(dir, "merge -q fusion/fn-1");
const { store, task } = makeStore(dir, {
noCommitsExpected: true,
steps: [
{ name: "Preflight", status: "done" },
{ name: "Dry-run", status: "skipped" },
{ name: "Execute", status: "skipped" },
{ name: "Verify", status: "skipped" },
{ name: "Testing", status: "skipped" },
{ name: "Documentation", status: "skipped" },
],
});
const mainBefore = git(dir, "rev-parse main");
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.merged).toBe(false);
expect(result.noOp).toBe(false);
expect(result.error).toContain("done=1, incomplete=5");
expect(task.column).toBe("todo");
expect(task.error).toContain("done=1, incomplete=5");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
expect(store.moveTask).not.toHaveBeenCalledWith("FN-1", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
expect.stringContaining("ai-empty-merge"),
);
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
it("still finalizes an all-done no-commits task on the AI empty-merge path", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
git(dir, "merge -q fusion/fn-1");
const { store, task } = makeStore(dir, {
noCommitsExpected: true,
steps: [
{ name: "Preflight", status: "done" },
{ name: "Dry-run", status: "done" },
{ name: "Execute", status: "done" },
],
});
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.noOp).toBe(true);
expect(result.ok).toBe(true);
expect(task.column).toBe("done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
});
it("fails loudly when an executed, never-merged task has no branch (possible lost work)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
// branch points at a ref that doesn't exist; task was executed (baseCommitSha) and never merged.

View File

@@ -209,6 +209,182 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "todo")).toBe(true);
}, 20_000);
it("FN-6461: demotes no-commits proven no-op tasks when skipped work outweighs done work", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-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-no-commits");
git(repo, "git checkout main");
const task = {
id: "FN-NO-COMMITS",
title: "FN-NO-COMMITS",
description: "FN-NO-COMMITS",
column: "in-review",
branch: "fusion/fn-no-commits",
baseBranch: "main",
baseCommitSha: baseSha,
noCommitsExpected: true,
dependencies: [],
steps: [
{ name: "Preflight", status: "done" },
{ name: "Dry-run", status: "skipped" },
{ name: "Execute", status: "skipped" },
{ name: "Verify", status: "skipped" },
{ name: "Testing", status: "skipped" },
{ name: "Documentation", status: "skipped" },
],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-NO-COMMITS",
} as unknown as Task;
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-NO-COMMITS");
expect(result.merged).toBe(false);
expect(result.noOp).toBe(false);
expect(result.error).toContain("done=1, incomplete=5");
expect(store.updateTask).toHaveBeenCalledWith("FN-NO-COMMITS", expect.objectContaining({ error: expect.stringContaining("done=1, incomplete=5") }));
expect(store.moveTask).toHaveBeenCalledWith("FN-NO-COMMITS", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
expect(store.moveTask).not.toHaveBeenCalledWith("FN-NO-COMMITS", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-NO-COMMITS",
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
expect.stringContaining("legacy-no-op-classifier"),
);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
target: "FN-NO-COMMITS",
}));
}, 20_000);
it("FN-6461: allows all-done no-commits proven no-op tasks to finalize", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-done-"));
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-no-commits-done");
git(repo, "git checkout main");
const task = {
id: "FN-NO-COMMITS-DONE",
title: "FN-NO-COMMITS-DONE",
description: "FN-NO-COMMITS-DONE",
column: "in-review",
branch: "fusion/fn-no-commits-done",
baseBranch: "main",
baseCommitSha: baseSha,
noCommitsExpected: true,
dependencies: [],
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-NO-COMMITS-DONE",
} as unknown as Task;
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-NO-COMMITS-DONE");
expect(result.merged).toBe(true);
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-NO-COMMITS-DONE", "done");
}, 20_000);
it("FN-6461: demotes no-commits empty-own-diff fast-path before cleanup", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-empty-own-"));
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-block");
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-BLOCK): no content change'");
git(repo, "git checkout main");
const task = {
id: "FN-EMPTY-BLOCK",
title: "FN-EMPTY-BLOCK",
description: "FN-EMPTY-BLOCK",
column: "in-review",
branch: "fusion/fn-empty-block",
baseBranch: "main",
baseCommitSha: baseSha,
noCommitsExpected: true,
dependencies: [],
steps: [{ name: "Preflight", status: "done" }, { name: "Execute", status: "skipped" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-EMPTY-BLOCK",
} as unknown as Task;
const store = createStore(task, { mergeIntegrationWorktree: "reuse-task-worktree" as any });
const result = await aiMergeTask(store, repo, "FN-EMPTY-BLOCK");
expect(result.merged).toBe(false);
expect(result.error).toContain("done=1, incomplete=1");
expect(store.moveTask).toHaveBeenCalledWith("FN-EMPTY-BLOCK", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
expect(store.moveTask).not.toHaveBeenCalledWith("FN-EMPTY-BLOCK", "done");
expect(git(repo, "git show-ref --verify --quiet refs/heads/fusion/fn-empty-block; echo $?")).toBe("0");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-EMPTY-BLOCK",
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
expect.stringContaining("early-empty-own-diff"),
);
}, 20_000);
it("FN-6461: allows all-done no-commits empty-own-diff fast-path tasks", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-empty-done-"));
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-done");
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-DONE): no content change'");
git(repo, "git checkout main");
const task = {
id: "FN-EMPTY-DONE",
title: "FN-EMPTY-DONE",
description: "FN-EMPTY-DONE",
column: "in-review",
branch: "fusion/fn-empty-done",
baseBranch: "main",
baseCommitSha: baseSha,
noCommitsExpected: true,
dependencies: [],
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prompt: "# FN-EMPTY-DONE",
} as unknown as Task;
const store = createStore(task, { mergeIntegrationWorktree: "reuse-task-worktree" as any });
const result = await aiMergeTask(store, repo, "FN-EMPTY-DONE");
expect(result.merged).toBe(true);
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-EMPTY-DONE", "done");
}, 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);

View File

@@ -3944,6 +3944,131 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("FN-6461: demotes no-commits no-op review tasks with skipped-out work", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: true,
globalPause: false,
enginePaused: false,
});
mockedExecSync.mockImplementation((command) => {
const cmd = String(command);
if (cmd.includes("rev-parse --verify 'fusion/fn-6461'")) return "ok" as any;
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
if (cmd.includes("rev-list --count 'main'..'fusion/fn-6461'")) return "0\n" as any;
return "" as any;
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-6461",
column: "in-review",
paused: false,
status: null,
worktree: "/tmp/test-project/.worktrees/fn-6461",
branch: "fusion/fn-6461",
noCommitsExpected: true,
steps: [
{ name: "Preflight", status: "done" },
{ name: "Dry-run", status: "skipped" },
{ name: "Execute", status: "skipped" },
{ name: "Verify", status: "skipped" },
{ name: "Testing", status: "skipped" },
{ name: "Documentation", status: "skipped" },
],
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
mergeDetails: undefined,
log: [],
},
]);
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-6461", expect.objectContaining({ error: expect.stringContaining("done=1, incomplete=5") }));
expect(store.moveTask).toHaveBeenCalledWith("FN-6461", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }));
expect(store.moveTask).not.toHaveBeenCalledWith("FN-6461", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-6461",
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
expect.stringContaining("self-healing-finalize-no-op-review"),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
target: "FN-6461",
}));
managerWithRecovery.stop();
});
it("FN-6461: still finalizes all-done no-commits no-op review tasks", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: true,
globalPause: false,
enginePaused: false,
});
mockedExecSync.mockImplementation((command) => {
const cmd = String(command);
if (cmd.includes("rev-parse --verify 'fusion/fn-6462'")) return "ok" as any;
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
if (cmd.includes("rev-list --count 'main'..'fusion/fn-6462'")) return "0\n" as any;
return "" as any;
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-6462",
column: "in-review",
paused: false,
status: null,
worktree: "/tmp/test-project/.worktrees/fn-6462",
branch: "fusion/fn-6462",
noCommitsExpected: true,
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
mergeDetails: undefined,
log: [],
},
]);
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
expect(result).toBe(1);
expect(store.moveTask).toHaveBeenCalledWith("FN-6462", "done");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-6462", "todo", expect.anything());
managerWithRecovery.stop();
});
it("FN-6461: stranded todo recovery does not promote skipped-to-completion no-commits tasks", async () => {
const recoverCompletedTask = vi.fn().mockResolvedValue(true);
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverCompletedTask,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-6463",
column: "todo",
paused: false,
status: null,
noCommitsExpected: true,
steps: [{ name: "Preflight", status: "done" }, { name: "Execute", status: "skipped" }],
log: [],
},
]);
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
expect(result).toBe(0);
expect(recoverCompletedTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("blocks unproven no-op finalize candidates and emits audit", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",

View File

@@ -38,6 +38,7 @@ import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import {
buildTaskLineageTrailer,
evaluateNoCommitsNoOpFinalize,
getPrimaryPrInfo,
getTaskMergeBlocker,
resolveAgentPrompt,
@@ -1125,6 +1126,50 @@ export async function runAiMerge(
if (!squashSha) {
// Branch had no net changes vs the tip — nothing to land.
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:02:
* FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done.
*/
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
}, null, 2),
);
await audit.database({
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
target: taskId,
metadata: {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
integrationBranch,
lane: "ai-empty-merge",
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
return {
task,
branch,
merged: false,
noOp: false,
ok: true,
reason,
error: reason,
worktreeRemoved: false,
branchDeleted: false,
};
}
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true });
}

View File

@@ -74,6 +74,7 @@ import { isBranchAuthoritativeForTask } from "./branch-conflicts.js";
import { hostname } from "node:os";
import {
buildTaskLineageTrailer,
evaluateNoCommitsNoOpFinalize,
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
normalizeMergeStrategyOverlapBehavior,
@@ -7370,6 +7371,51 @@ async function tryEarlyEmptyOwnDiffFinalize(input: {
return null;
}
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:06:
* FN-6461/FN-6455 requires the early empty-own-diff fast-path to block before mergeDetails writes or branch/worktree cleanup so incomplete release/ops work remains recoverable.
*/
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
mergeTargetBranch,
lane: "early-empty-own-diff",
}, null, 2),
);
await audit.database({
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
target: taskId,
metadata: {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
branch,
mergeTargetBranch,
lane: "early-empty-own-diff",
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
return {
task,
branch,
merged: false,
noOp: false,
ok: true,
reason,
error: reason,
worktreeRemoved: false,
branchDeleted: false,
};
}
const noOpReason = `early fast-path: branch ${branch} has ${aheadCount} own commit(s) but zero net diff vs merge-base of ${mergeTargetBranch}`;
const mergedAt = new Date().toISOString();
const mergeDetails: MergeDetails = {
@@ -8382,6 +8428,52 @@ export async function aiMergeTask(
// — 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 [].
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:08:
* FN-6461/FN-6455 extends the FN-5490 no-op demotion pattern to no-commits tasks whose skipped/incomplete steps outweigh completed work.
*/
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: classification.kind,
baseRef: classification.baseRef,
lane: "legacy-no-op-classifier",
}, null, 2),
);
await (store as any).recordRunAuditEvent?.({
domain: "database",
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
target: taskId,
metadata: {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: classification.kind,
baseRef: classification.baseRef,
lane: "legacy-no-op-classifier",
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
await releaseReuseHandoffEarly("no-commits-incomplete-blocked");
return {
task,
branch,
merged: false,
noOp: false,
ok: true,
reason,
worktreeRemoved: false,
branchDeleted: false,
error: reason,
};
}
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 });
@@ -8641,6 +8733,44 @@ export async function aiMergeTask(
result.mergeTargetSource = mergeTarget.source;
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
} else {
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:10:
* FN-6461/FN-6455 applies the same no-commits incomplete-work guard when branch-missing classification would otherwise finalize a zero-change task.
*/
result.error = reason;
result.reason = reason;
result.noOp = false;
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: classification.kind,
baseRef: classification.baseRef,
lane: "legacy-branch-missing-no-op",
}, null, 2),
);
await (store as any).recordRunAuditEvent?.({
domain: "database",
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
target: taskId,
metadata: {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: classification.kind,
baseRef: classification.baseRef,
lane: "legacy-branch-missing-no-op",
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
return result;
}
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
const mergedAt = new Date().toISOString();
await store.updateTask(taskId, {

View File

@@ -565,6 +565,12 @@ export type DatabaseMutationType =
* Metadata: { modifiedFilesCount, classification, baseRef? }
*/
| "task:finalize-lost-work-blocked"
/**
* FNXC:Lifecycle 2026-06-14-20:16:
* FN-6461 records every no-op finalize lane that refuses to mark a no-commits task done because incomplete/skipped steps outweigh completed work.
* Metadata: { reason, doneCount, incompleteCount, classification?, baseRef?, lane }
*/
| "task:no-commits-finalize-blocked-incomplete-steps"
| "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

@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -2267,6 +2267,11 @@ export class SelfHealingManager {
if (task.column !== "todo" || task.paused) return false;
if (executingIds.has(task.id)) return false;
if (task.steps.length === 0 || !task.steps.every((s) => s.status === "done" || s.status === "skipped")) return false;
/*
* FNXC:Lifecycle 2026-06-14-20:12:
* FN-6461 keeps skipped-to-completion no-commits tasks out of the stranded-todo promoter so a finalize guard demotion cannot loop back into in-review before an operator fixes the incomplete work.
*/
if (evaluateNoCommitsNoOpFinalize(task).blocked) return false;
if (task.error) return false;
if (task.status && STRANDED_COMPLETED_TODO_ACTIVE_STATUSES.has(task.status)) return false;
if (task.reviewState?.refreshStatus === "refreshing") return false;
@@ -4943,7 +4948,7 @@ export class SelfHealingManager {
return recovered;
}
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> {
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:finalize-lost-work-blocked" | "task:no-commits-finalize-blocked-incomplete-steps" | "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",
@@ -5131,6 +5136,38 @@ export class SelfHealingManager {
// 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.
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
if (noCommitsFinalize.blocked) {
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
/*
* FNXC:Lifecycle 2026-06-14-20:14:
* FN-6461/FN-6455 requires self-healing no-op finalization to demote no-commits tasks with incomplete/skipped work and set an error so stranded-todo recovery will not immediately re-promote them.
*/
await this.store.updateTask(task.id, { error: reason });
await this.store.logEntry(
task.id,
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
JSON.stringify({
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: "proven-no-op",
baseRef: classification.baseRef,
lane: "self-healing-finalize-no-op-review",
}, null, 2),
);
await this.recordIntegrityAudit(task.id, "task:no-commits-finalize-blocked-incomplete-steps", {
reason,
doneCount: noCommitsFinalize.doneCount,
incompleteCount: noCommitsFinalize.incompleteCount,
classification: "proven-no-op",
baseRef: classification.baseRef,
lane: "self-healing-finalize-no-op-review",
});
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
recovered++;
continue;
}
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
await this.store.logEntry(
task.id,