fix(FN-7261): confirm AI no-op merge finalization
This commit is contained in:
@@ -403,6 +403,18 @@ describe("runAiMerge", () => {
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(result.merged).toBe(false);
|
||||
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.objectContaining({
|
||||
status: null,
|
||||
mergeDetails: expect.objectContaining({
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason: "no-net-changes",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
|
||||
});
|
||||
|
||||
it("demotes a no-commits task with skipped-out work instead of AI empty-merge finalizing done", async () => {
|
||||
@@ -475,6 +487,41 @@ describe("runAiMerge", () => {
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers an executed missing-branch task with prior AI no-op finalization proof", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const { store } = makeStore(dir, {
|
||||
branch: "fusion/ghost",
|
||||
baseCommitSha: "0123456789abcdef",
|
||||
log: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "AI merge: fusion/ghost had no net changes vs main — finalizing as no-op",
|
||||
},
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "AI merge: finalized FN-1 (no-op), finalizing task row",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: vi.fn(), reviewAgent: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.objectContaining({
|
||||
status: null,
|
||||
mergeDetails: expect.objectContaining({
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));
|
||||
});
|
||||
|
||||
it("finalizes as a no-op when an already-merged task's branch is gone (re-process)", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
const { store } = makeStore(dir, { branch: "fusion/ghost", baseCommitSha: "0123456789abcdef", mergeDetails: { mergeConfirmed: true } });
|
||||
|
||||
@@ -763,11 +763,29 @@ function noOpResult(task: Task, branch: string, reason: string): MergeResult {
|
||||
noOp: true,
|
||||
ok: true,
|
||||
reason,
|
||||
/*
|
||||
* FNXC:WorkflowMerge 2026-06-29-21:42:
|
||||
* No-branch no-op finalization is only reached after runAiMerge proves the task is either already merged or was never executed; executed/unmerged missing branches fail loudly before this helper. Carry confirmed no-op proof so workflow task finalization does not stall on missing-merge-confirmation.
|
||||
*/
|
||||
mergeConfirmed: true,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function hasPriorAiNoOpFinalizationProof(task: Task, branch: string, integrationBranch: string): boolean {
|
||||
/*
|
||||
* FNXC:WorkflowMerge 2026-06-29-21:49:
|
||||
* FN-7261 exposed a forward-fix recovery gap: older AI no-op finalizers deleted the task branch, then failed before persisting mergeDetails.mergeConfirmed. Treat the paired durable task-log entries as recovery proof only for this narrow already-finalized no-op shape; executed missing branches without those entries still fail as possible lost work.
|
||||
*/
|
||||
const actions = task.log?.map((entry) => entry.action) ?? [];
|
||||
return actions.some((action) =>
|
||||
action.includes(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`)
|
||||
) && actions.some((action) =>
|
||||
action.includes(`AI merge: finalized ${task.id} (no-op), finalizing task row`)
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAiMerge(
|
||||
store: TaskStore,
|
||||
projectRootDir: string,
|
||||
@@ -822,7 +840,10 @@ export async function runAiMerge(
|
||||
// landing), the branch should still exist — its work appears lost. Fail
|
||||
// loudly rather than silently marking the task done.
|
||||
const wasExecuted = !!task.baseCommitSha;
|
||||
const alreadyMerged = task.mergeDetails?.mergeConfirmed === true || !!task.mergeDetails?.commitSha;
|
||||
const alreadyMerged =
|
||||
task.mergeDetails?.mergeConfirmed === true ||
|
||||
!!task.mergeDetails?.commitSha ||
|
||||
hasPriorAiNoOpFinalizationProof(task, branch, integrationBranch);
|
||||
if (wasExecuted && !alreadyMerged) {
|
||||
await audit.git({
|
||||
type: "merge:ai-no-branch",
|
||||
@@ -1548,7 +1569,11 @@ async function finalizeMerged(
|
||||
ok: true,
|
||||
reason: opts.empty ? "no-net-changes" : undefined,
|
||||
commitSha: opts.empty ? undefined : mergeDetails?.commitSha ?? landedSha,
|
||||
mergeConfirmed: !opts.empty,
|
||||
/*
|
||||
* FNXC:WorkflowMerge 2026-06-29-21:38:
|
||||
* AI empty-merge finalization is durable proof, not a bypass: the clean-room merge loop reached this branch only after proving the task branch has no net diff against the integration tip. Persist mergeConfirmed for that no-op proof so workflow tasks do not stall in-review with missing-merge-confirmation, while the shared proof validator still rejects no-op rows that later show branch diff or landed files.
|
||||
*/
|
||||
mergeConfirmed: true,
|
||||
worktreeRemoved,
|
||||
branchDeleted,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user