fix(engine): require already-landed proof before finalizing an empty AI merge as done (#2259)

## 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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 19:36:41 -07:00
committed by GitHub
parent 19eb179473
commit 1c4fdb7b59
6 changed files with 369 additions and 1 deletions

View File

@@ -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).

View File

@@ -600,6 +600,116 @@ describe("runAiMerge", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); 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 () => { it("fails loudly when an executed, never-merged task has no branch (possible lost work)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
// branch points at a ref that doesn't exist; task was executed (baseCommitSha) and never merged. // branch points at a ref that doesn't exist; task was executed (baseCommitSha) and never merged.

View File

@@ -2957,6 +2957,37 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop(); 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<string>(),
});
(store.listTasks as ReturnType<typeof vi.fn>).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 () => { it("recovers blockedBy todo tasks when all steps are complete", async () => {
const recoverFn = vi.fn().mockResolvedValue(true); const recoverFn = vi.fn().mockResolvedValue(true);

View File

@@ -94,6 +94,24 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s
fx.git(repoRel, `git worktree remove --force ${worktreePath}`); fx.git(repoRel, `git worktree remove --force ${worktreePath}`);
} }
/**
* FN-8141 shape: a `fusion/<id>` 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 /** Make a sub-repo's integration tip and the task branch BOTH edit README so the
* squash conflicts. */ * squash conflicts. */
function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { 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.moveTaskCalls).toHaveLength(0);
expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); 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)", () => { describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () => {

View File

@@ -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 { isRepoLanded, findProvenLandedCommit, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.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 { resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
import { import {
cleanupAiMergeWorktree, 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<string> = 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( export async function runAiMerge(
store: TaskStore, store: TaskStore,
projectRootDir: string, projectRootDir: string,
@@ -1157,6 +1217,56 @@ export async function runAiMerge(
branchDeleted: false, 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<typeof audit.database>[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<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} 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`); 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); 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 }); 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 // 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). // done (the landed repos' `landedSha` is already persisted for the retry).
if (allLanded) { 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<typeof audit.database>[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<TaskStore["moveTask"]>[2]);
return { taskId, repos, allLanded, finalized: false };
}
const finalized = await finalizeWorkspaceTask(store, taskId, task, repos); const finalized = await finalizeWorkspaceTask(store, taskId, task, repos);
return { taskId, repos, allLanded, finalized }; return { taskId, repos, allLanded, finalized };
} }

View File

@@ -703,6 +703,14 @@ export type DatabaseMutationType =
* Metadata: { reason, doneCount, incompleteCount, classification?, baseRef?, lane } * Metadata: { reason, doneCount, incompleteCount, classification?, baseRef?, lane }
*/ */
| "task:no-commits-finalize-blocked-incomplete-steps" | "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-reconcile-modified-files"
| "task:integrity-warning" | "task:integrity-warning"
/** FN-5092 watchdog: stale `status: "merging"` / `"merging-pr"` cleared on a done/archived task. Metadata: { previousColumn, previousStatus, ageMs, mergeConfirmed?: boolean } */ /** FN-5092 watchdog: stale `status: "merging"` / `"merging-pr"` cleared on a done/archived task. Metadata: { previousColumn, previousStatus, ageMs, mergeConfirmed?: boolean } */