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:
7
.changeset/empty-merge-landed-proof.md
Normal file
7
.changeset/empty-merge-landed-proof.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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 () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
* 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)", () => {
|
||||
|
||||
@@ -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<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(
|
||||
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<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`);
|
||||
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<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);
|
||||
return { taskId, repos, allLanded, finalized };
|
||||
}
|
||||
|
||||
@@ -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 } */
|
||||
|
||||
Reference in New Issue
Block a user