diff --git a/.changeset/fn-6461-no-commits-finalize-guard.md b/.changeset/fn-6461-no-commits-finalize-guard.md new file mode 100644 index 0000000000..c2cd8c0741 --- /dev/null +++ b/.changeset/fn-6461-no-commits-finalize-guard.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index afe0fde15a..e8586cff29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `..HEAD` has zero attributable own commits but the source `fusion/` 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. diff --git a/packages/core/src/__tests__/no-commits-finalize-guard.test.ts b/packages/core/src/__tests__/no-commits-finalize-guard.test.ts new file mode 100644 index 0000000000..74693b201e --- /dev/null +++ b/packages/core/src/__tests__/no-commits-finalize-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { evaluateNoCommitsNoOpFinalize, type TaskStep } from "../index.js"; + +function steps(statuses: Array): 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 }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3bd55b0625..dbe4fbc7c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/no-commits-finalize-guard.ts b/packages/core/src/no-commits-finalize-guard.ts new file mode 100644 index 0000000000..895737d861 --- /dev/null +++ b/packages/core/src/no-commits-finalize-guard.ts @@ -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, +): 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, + }; +} diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index b9cdd7f77b..7cbd67cdb6 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts b/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts index 2589cbc5fd..39ed357807 100644 --- a/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts +++ b/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts @@ -209,6 +209,182 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", () expect((store.moveTask as ReturnType).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); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 248a9665b4..c5f6569349 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -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).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).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).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).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).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", diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 09bc7d2813..e4d3e96cc9 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -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[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[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 }); } diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index a4d5cf7852..925708ed80 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -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[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, { diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 066e8d0746..dd8f3bf6f8 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -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 } */ diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 4efffa2e1c..48bd4acdf5 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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): Promise { + 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): Promise { 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,