From 1c4fdb7b599c7a2f493070b653fa451d080fb01c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 19:36:41 -0700 Subject: [PATCH] fix(engine): require already-landed proof before finalizing an empty AI merge as done (#2259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why Task FN-8141 laundered a failed task into `done`: its branch had no net changes vs `main` **only because the executor reverted its own work five times**, and the AI empty-merge lane took the "empty means already-landed or nothing-to-do → finalize as no-op done" path, stamping `mergeConfirmed: true` with no reviewer or operator sign-off. This restores the invariant: **a commit-expected task that reaches the empty AI-merge outcome must not finalize `done` without positive evidence the work already landed.** `packages/engine/src/merger-ai.ts` empty-outcome lane now, for a commit-expected task (`noCommitsExpected !== true`), requires one of: 1. Durable recorded landing on the task (`mergeDetails.mergeConfirmed`/`commitSha`). 2. A prior AI no-op finalization proof pair in the task log (`hasPriorAiNoOpFinalizationProof`, FN-7261 shape). 3. The branch tip is an **ancestor of the integration branch** (fast-forwarded / zero-ahead / already-integrated). 4. The already-on-main classifier (`detectAlreadyLandedOnMain`) finds a distinct landing commit via a **strong** strategy (`trailer`/`ancestry`/`patch-id`). The classifier's weak `tree-equal`/`no-diff` strategies are **deliberately rejected**: a branch that committed work then reverted it back to base has a tree equal to `main` (main never advanced), which is exactly the FN-8141 shape and would false-positive. Absent proof, the task gets `task.error` set, emits run-audit `task:empty-merge-finalize-blocked-no-landed-proof` (ids/counts/outcomes-only), and is moved back to `todo` with progress preserved — mirroring the existing FN-6461 blocked lane. `noCommitsExpected === true` tasks are untouched (hardened separately in the sibling Task 1). The non-empty landed path, group/PR routing, and push-after-merge behavior are unchanged. ## Surface enumeration - **Single-repo empty-outcome finalize (primary lane)** — guarded in `runAiMerge`. - **Workspace/multi-repo caller** — `landWorkspaceTask`'s all-empty finalize is a second route. Already-landed sub-repos are proven up front by `findProvenLandedCommit` and marked `status:"landed"`; when `landedCount === 0` the guard re-checks each empty sub-repo's branch and blocks the FN-8141 reverted shape (tip not an ancestor / branch vanished) identically. (Note: the genuinely-integrated all-empty workspace case already throws `missing-merge-confirmation` on `mergeConfirmed:false`, so it never reached `done`; that pre-existing path is left intact.) - **Re-promotion ping-pong** — the blocked path sets `task.error`, and `recoverStrandedCompletedTodoTasks` excludes any task with `task.error`, so the promoter cannot re-promote the unchanged blocked task. Regression-tested. ## Test evidence Scoped tests (all green): ``` vitest run merger-ai.test.ts workspace-merger.test.ts → 46 passed vitest run self-healing.test.ts -t recoverStrandedCompletedTodoTasks → 4 passed vitest run merger.test.ts merger-finalize-unproven.real-git → 20 passed vitest run self-healing-workspace + workspace-merger-lease + workspace-merger-deps-resilient → 26 passed ``` New tests: - merger-ai.test.ts: commit-expected empty (reverted) → blocked to todo + error + audit event, NOT done; empty + prior no-op proof → still no-op done; empty + branch-ancestor-of-main → still no-op done; noCommitsExpected empty → unchanged done path. - workspace-merger.test.ts: all-empty (reverted) workspace → blocked to todo + error, not done / not `task:merged`. - self-healing.test.ts: a task blocked by this guard (all steps done/skipped, `task.error` set) is NOT re-promoted by `recoverStrandedCompletedTodoTasks`. **`pnpm verify:fast` is red on this branch due to the pre-existing pi SDK breakage** (`auth-storage.ts`/`pi.ts`/`provider-registration.ts` — the FN-8142/FN-8145 `AuthStorage`/`ModelRegistry` removal that is the root of the FN-8141 incident). Verified those identical build errors reproduce with my changes stashed; this PR adds **zero** new type errors (no build error is in `merger-ai.ts` or `run-audit.ts`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus --- .changeset/empty-merge-landed-proof.md | 7 + .../engine/src/__tests__/merger-ai.test.ts | 110 +++++++++++++ .../engine/src/__tests__/self-healing.test.ts | 31 ++++ .../src/__tests__/workspace-merger.test.ts | 60 +++++++ packages/engine/src/merger-ai.ts | 154 +++++++++++++++++- packages/engine/src/run-audit.ts | 8 + 6 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 .changeset/empty-merge-landed-proof.md diff --git a/.changeset/empty-merge-landed-proof.md b/.changeset/empty-merge-landed-proof.md new file mode 100644 index 0000000000..158cd84fea --- /dev/null +++ b/.changeset/empty-merge-landed-proof.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reverted-work tasks no longer merge to done as empty no-ops; they park for review. +category: fix +dev: merger-ai.ts empty-outcome lane now requires positive already-landed proof (recorded merge, prior no-op proof, branch tip ancestor of main, or a strong already-on-main classifier match) before finalizing a commit-expected task; otherwise it sets task.error, emits `task:empty-merge-finalize-blocked-no-landed-proof`, and moves the task back to todo. Same guard mirrored in the workspace all-empty finalize (blocks the reverted/net-zero shape). noCommitsExpected tasks keep their existing path (FN-8141). diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index 134b6ea986..56eb6ddb5c 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -600,6 +600,116 @@ describe("runAiMerge", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); }); + /* + * FN-8141 regression: the AI empty-merge lane laundered a task whose branch was empty ONLY because + * the executor reverted its own work. A commit-expected empty branch must not finalize `done` without + * POSITIVE already-landed proof. Invariant asserted across surfaces: reverted/lost work (no proof) → + * blocked to todo; genuinely-integrated (ancestor) / prior-no-op-proof → still finalizes no-op done; + * noCommitsExpected tasks keep their existing (separately-hardened) path. + */ + /** A branch that committed work then reverted it: AHEAD of main (real commits) but net-zero, tip NOT an ancestor of main. */ + function revertBranchToNetZero(dir: string, branch: string): void { + git(dir, `checkout -q ${branch}`); + rmSync(join(dir, "feature.txt")); + git(dir, "add -A"); + git(dir, "commit -q -m 'revert: undo the work (net-zero vs main)'"); + git(dir, "checkout -q main"); + } + + it("blocks a commit-expected empty branch with no landed proof (reverted work) to todo, not done", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + revertBranchToNetZero(dir, "fusion/fn-1"); + const { store, task } = makeStore(dir); // commit-expected (noCommitsExpected unset) + const mainBefore = git(dir, "rev-parse main"); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + // Leave HEAD at the tip in the clean room → squash produces no net changes → empty outcome. + mergeAgent: vi.fn(async () => { /* nothing lands */ }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result.merged).toBe(false); + expect(result.noOp).toBe(false); + expect(result.error).toContain("operator review required"); + expect(task.column).toBe("todo"); + expect(task.error).toContain("operator review required"); + expect(store.moveTask).toHaveBeenCalledWith("FN-1", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" })); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-1", "done"); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ mutationType: "task:empty-merge-finalize-blocked-no-landed-proof" }), + ); + // The integration branch must NOT advance and NOT be marked done. + expect(git(dir, "rev-parse main")).toBe(mainBefore); + }); + + it("still finalizes an empty branch as no-op when a prior AI no-op finalization proof exists", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + revertBranchToNetZero(dir, "fusion/fn-1"); // no ancestor/classifier proof — only the log proof qualifies + const { store, task } = makeStore(dir, { + log: [ + { action: "AI merge: fusion/fn-1 had no net changes vs main — finalizing as no-op" }, + { action: "AI merge: finalized FN-1 (no-op), finalizing task row" }, + ], + }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: vi.fn(async () => { /* nothing lands */ }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result.noOp).toBe(true); + expect(result.merged).toBe(false); + expect(task.column).toBe("done"); + expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ mutationType: "task:empty-merge-finalize-blocked-no-landed-proof" }), + ); + expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); + }); + + it("still finalizes an empty branch as no-op when the branch tip is already an ancestor of main", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + // Fast-forward main to the branch tip: the work is genuinely integrated (branch ⊑ main). + git(dir, "merge -q fusion/fn-1"); + const { store, task } = makeStore(dir); // commit-expected + const mainBefore = git(dir, "rev-parse main"); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: vi.fn(async () => { /* nothing lands */ }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result.noOp).toBe(true); + expect(result.merged).toBe(false); + expect(task.column).toBe("done"); + expect(git(dir, "rev-parse main")).toBe(mainBefore); + expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ mutationType: "task:empty-merge-finalize-blocked-no-landed-proof" }), + ); + }); + + it("leaves a noCommitsExpected empty (net-zero, non-ancestor) branch on its existing done path — guard does not apply", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + revertBranchToNetZero(dir, "fusion/fn-1"); // would trip the commit-expected guard, but noCommitsExpected opts out + const { store, task } = makeStore(dir, { + noCommitsExpected: true, + steps: [ + { name: "Preflight", status: "done" }, + { name: "Execute", status: "done" }, + ], + }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: vi.fn(async () => { /* nothing lands */ }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result.noOp).toBe(true); + expect(task.column).toBe("done"); + expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ mutationType: "task:empty-merge-finalize-blocked-no-landed-proof" }), + ); + }); + 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__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 77cd87faf0..f67a32640a 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -2957,6 +2957,37 @@ describe("SelfHealingManager", () => { managerWithRecovery.stop(); }); + it("FN-8141: does not re-promote a task the empty-merge guard blocked back to todo (error set)", async () => { + // The empty-merge no-landed-proof guard (merger-ai.ts) moves a commit-expected empty branch + // back to todo with all steps done/skipped AND task.error set. The stranded-todo promoter must + // NOT immediately re-promote it (task.error exclusion) or the task ping-pongs to in-review. + const recoverFn = vi.fn().mockResolvedValue(true); + + const managerWithRecovery = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + recoverCompletedTask: recoverFn, + getExecutingTaskIds: () => new Set(), + }); + + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-8141", + column: "todo", + paused: false, + error: "branch had no net changes vs main — work may have been reverted or lost; operator review required", + reviewLevel: 2, + steps: [{ status: "done" }, { status: "done" }, { status: "done" }, { status: "skipped" }, { status: "skipped" }], + }, + ]); + + const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks(); + + expect(result).toBe(0); + expect(recoverFn).not.toHaveBeenCalled(); + + managerWithRecovery.stop(); + }); + it("recovers blockedBy todo tasks when all steps are complete", async () => { const recoverFn = vi.fn().mockResolvedValue(true); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 9a4d91aa16..8d3ebea169 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -94,6 +94,24 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s fx.git(repoRel, `git worktree remove --force ${worktreePath}`); } +/** + * FN-8141 shape: a `fusion/` branch that committed work then REVERTED it — AHEAD of the + * integration tip (two real commits) but net-zero, so its tip is NOT an ancestor of main and the + * squash lands nothing (empty outcome with zero landed). + */ +function addRepoRevertedBranch(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-revert"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), "work\n", "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + execSync("git rm feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "revert(${TASK_ID}): undo feature (net-zero) in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + /** Make a sub-repo's integration tip and the task branch BOTH edit README so the * squash conflicts. */ function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { @@ -275,6 +293,48 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(store.moveTaskCalls).toHaveLength(0); expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); }); + + /* + * FN-8141 workspace parity: an all-empty workspace where every sub-repo branch committed work then + * REVERTED it (ahead of main, net-zero, tip NOT an ancestor) has zero landed sub-repos and no + * already-landed proof. It must be blocked back to todo with error — NOT laundered into `done`. + */ + it("FN-8141: blocks a commit-expected all-empty (reverted) workspace to todo, not done", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoRevertedBranch(fx, "repo-a"); + addRepoRevertedBranch(fx, "repo-b"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // No sub-repo FAILED, but nothing landed → blocked, NOT finalized. + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(false); + for (const r of result.repos) expect(r.status).toBe("empty"); + + // Moved back to todo with error set; never moved done and never emitted task:merged. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "todo" }]); + expect(store.updateTask).toHaveBeenCalledWith( + TASK_ID, + expect.objectContaining({ error: expect.stringContaining("operator review required") }), + ); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + + // Integration refs unchanged. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipABefore); + expect(fx.git("repo-b", "git rev-parse refs/heads/main")).toBe(tipBBefore); + }); }); describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () => { diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 2639eb2bd2..7509126108 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -93,7 +93,7 @@ import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from se */ import { isRepoLanded, findProvenLandedCommit, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; -import { getCommitTaskOwnership } from "./already-merged-detector.js"; +import { getCommitTaskOwnership, detectAlreadyLandedOnMain } from "./already-merged-detector.js"; import { resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; import { cleanupAiMergeWorktree, @@ -978,6 +978,66 @@ function hasPriorAiNoOpFinalizationProof(task: Task, branch: string, integration ); } +/* +FNXC:Lifecycle 2026-07-16-00:00: +FN-8141 incident: a commit-expected task's branch had no net changes vs the integration tip ONLY +because the executor reverted its own work five times. The empty-merge lane assumed "empty means the +work already landed or there was nothing to do" and finalized the task `done` with mergeConfirmed — +laundering reverted/lost work into a completed state with no reviewer or operator sign-off. + +Invariant: a commit-expected empty-merge outcome may finalize as no-op ONLY with POSITIVE evidence the +work already landed. Positive evidence is any of: + 1. Durable recorded landing on this task's own mergeDetails (mergeConfirmed / commitSha). + 2. A prior AI no-op finalization proof pair in the task log (FN-7261 forward-fix recovery shape). + 3. The task branch tip is an ANCESTOR of the integration branch — its history is already contained in + main (fast-forwarded / zero-ahead / already-integrated); nothing was reverted or lost. + 4. The already-on-main classifier finds a DISTINCT landing commit for this task on the integration + branch via a STRONG strategy (trailer / ancestry / patch-id) — e.g. a squash whose history is not + an ancestor of the branch. The classifier's WEAK `tree-equal` / `no-diff` strategies are DELIBERATELY + rejected here: a branch that committed work and then reverted it back to base has a tree equal to + main (main never advanced), so `tree-equal` would false-positive on exactly the FN-8141 lost-work + shape this guard exists to catch. +Absent all four, the branch is treated as reverted/lost work and the task is blocked, NOT finalized. +Returns the proof marker when landed; null when unproven. +*/ +const STRONG_LANDED_STRATEGIES: ReadonlySet = new Set(["trailer", "ancestry", "patch-id"]); + +async function proveEmptyMergeAlreadyLanded( + task: Task, + branch: string, + integrationBranch: string, + projectRootDir: string, +): Promise<{ strategy: string; sha?: string } | null> { + // 1. Durable landing already recorded on this task. + if (task.mergeDetails?.mergeConfirmed === true || !!task.mergeDetails?.commitSha) { + return { strategy: "recorded-merge-details", sha: task.mergeDetails?.commitSha }; + } + // 2. Prior AI no-op finalization proof (older finalizer landed then failed pre-persist). + if (hasPriorAiNoOpFinalizationProof(task, branch, integrationBranch)) { + return { strategy: "prior-no-op-finalization" }; + } + // 3. Branch tip already contained in the integration branch (its work is genuinely integrated, + // not reverted). This is what distinguishes a fast-forwarded/zero-ahead no-op from an + // ahead-but-net-zero reverted branch whose tip is NOT an ancestor of main. + const branchTip = await git(["rev-parse", "--verify", `refs/heads/${branch}`], projectRootDir).catch(() => ""); + if (branchTip && (await gitOk(["merge-base", "--is-ancestor", branchTip, integrationBranch], projectRootDir))) { + return { strategy: "branch-ancestor-of-main", sha: branchTip }; + } + // 4. A distinct landing commit exists on main via a STRONG classifier strategy (squash-landed). + const landed = await detectAlreadyLandedOnMain({ + rootDir: projectRootDir, + taskId: task.id, + lineageId: task.lineageId, + baseBranch: integrationBranch, + taskBranch: branch, + baseCommitSha: task.baseCommitSha, + }).catch(() => null); + if (landed && STRONG_LANDED_STRATEGIES.has(landed.strategy)) { + return { strategy: landed.strategy, sha: landed.sha }; + } + return null; +} + export async function runAiMerge( store: TaskStore, projectRootDir: string, @@ -1157,6 +1217,56 @@ export async function runAiMerge( branchDeleted: false, }; } + /* + * FNXC:Lifecycle 2026-07-16-00:00: + * FN-8141: for a commit-expected task (noCommitsExpected !== true), an empty branch is only a + * safe no-op if the work provably already landed. Without positive already-landed proof the + * branch is assumed reverted/lost (the FN-8141 executor reverted its work five times); block the + * finalize, record a precise error, emit an audit event, and move back to todo with progress + * preserved so an operator (or reviewer) sees it instead of it laundering into `done`. + * task.error keeps recoverStrandedCompletedTodoTasks from re-promoting the unchanged task (it + * excludes any task with `task.error` set), mirroring the FN-6461 blocked lane above. + */ + if (task.noCommitsExpected !== true) { + const landedProof = await proveEmptyMergeAlreadyLanded(task, branch, integrationBranch, projectRootDir); + if (!landedProof) { + const reason = + "branch had no net changes vs main — work may have been reverted or lost; operator review required"; + await store.updateTask(taskId, { error: reason }); + await store.logEntry( + taskId, + `Finalize blocked (empty-merge no-landed-proof guard): ${reason} — moving back to todo with progress preserved`, + JSON.stringify({ branch, integrationBranch, lane: "ai-empty-merge", baseCommitSha: task.baseCommitSha }, null, 2), + ); + await audit.database({ + type: "task:empty-merge-finalize-blocked-no-landed-proof" as Parameters[0]["type"], + target: taskId, + metadata: { + reason, + branch, + integrationBranch, + lane: "ai-empty-merge", + baseCommitSha: task.baseCommitSha, + hadPriorNoOpProof: false, + }, + }); + 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} but work already landed (proof=${landedProof.strategy}${landedProof.sha ? ` sha=${landedProof.sha.slice(0, 8)}` : ""}) — finalizing as no-op`, + ); + } await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); const noOpFinalized = await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }, mergeTarget, groupRouting, options.syncGroupPr); await runPushAfterMergeStep({ store, projectRootDir, taskId, settings, integrationBranch, audit, log, options, result: noOpFinalized }); @@ -1642,6 +1752,48 @@ export async function landWorkspaceTask( // existing `task:merged` consumer is satisfied. On a partial land we do NOT move // done (the landed repos' `landedSha` is already persisted for the retry). if (allLanded) { + /* + * FNXC:Lifecycle 2026-07-16-00:00 (FN-8141 workspace parity): + * Mirror the single-repo empty-merge guard. `allLanded` here means "no sub-repo FAILED", but every + * acquired sub-repo may have come back `empty` (zero landed). Already-landed sub-repos are proven up + * front by findProvenLandedCommit and pushed as `status:"landed"`. When NO repo landed, distinguish + * the two empty shapes exactly as the single-repo guard does: a genuinely-integrated / zero-ahead + * sub-repo (branch tip ⊑ its integration tip) is a safe no-op; an AHEAD-but-net-zero sub-repo (tip + * NOT an ancestor — the FN-8141 reverted/lost shape) is not. Block only when at least one empty + * sub-repo shows the reverted shape (or its branch vanished with nothing landed): set task.error + * (keeps recoverStrandedCompletedTodoTasks from re-promoting), emit the audit event, and move back + * to todo instead of laundering it into `done`. noCommitsExpected tasks keep their existing path. + */ + const landedCount = repos.filter((r) => r.status === "landed" && r.landedSha).length; + let hasRevertedEmptyRepo = false; + if (task.noCommitsExpected !== true && repos.length > 0 && landedCount === 0) { + for (const r of repos) { + const tip = await git(["rev-parse", "--verify", `refs/heads/${r.branch}`], r.repoRootDir).catch(() => ""); + // Branch gone with nothing landed → treat as lost. Ahead-but-empty (tip not an ancestor of the + // integration branch) → reverted/lost shape. Zero-ahead / already-integrated → safe no-op. + if (!tip || !(await gitOk(["merge-base", "--is-ancestor", tip, r.integrationBranch], r.repoRootDir))) { + hasRevertedEmptyRepo = true; + break; + } + } + } + if (hasRevertedEmptyRepo) { + const reason = + "branch had no net changes vs main — work may have been reverted or lost; operator review required"; + await store.updateTask(taskId, { error: reason }); + await store.logEntry( + taskId, + `Finalize blocked (empty-merge no-landed-proof guard, workspace): ${reason} — moving back to todo with progress preserved`, + JSON.stringify({ lane: "ai-empty-merge-workspace", repoCount: repos.length, landedCount, repos: repos.map((r) => r.repo) }, null, 2), + ).catch(() => undefined); + await audit.database({ + type: "task:empty-merge-finalize-blocked-no-landed-proof" as Parameters[0]["type"], + target: taskId, + metadata: { reason, lane: "ai-empty-merge-workspace", repoCount: repos.length, landedCount, hadPriorNoOpProof: false }, + }).catch(() => undefined); + await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters[2]); + return { taskId, repos, allLanded, finalized: false }; + } const finalized = await finalizeWorkspaceTask(store, taskId, task, repos); return { taskId, repos, allLanded, finalized }; } diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 5288b4a3fc..9b58998343 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -703,6 +703,14 @@ export type DatabaseMutationType = * Metadata: { reason, doneCount, incompleteCount, classification?, baseRef?, lane } */ | "task:no-commits-finalize-blocked-incomplete-steps" + /** + * FNXC:Lifecycle 2026-07-16-00:00: + * FN-8141: the AI empty-merge lane refused to finalize a commit-expected task `done` because its + * branch had no net changes vs the integration tip AND no positive proof the work already landed + * (commits reverted/lost). The task is moved back to `todo` with progress preserved for operator review. + * Metadata: { reason, branch, integrationBranch, lane, baseCommitSha?, hadPriorNoOpProof? } + */ + | "task:empty-merge-finalize-blocked-no-landed-proof" | "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 } */