Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/post-merge-gate-scheduling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---

summary: Re-arm an unreported post-merge gate so a confirmed merge is not deferred forever.
category: fix
dev: The `post-merge-verification` optional group is a graph post-merge hop that only runs while the `merge` node executes. After the merge seam returns, finalization refused completion on the missing gate and nothing re-entered the graph, so on a repository with no post-landing CI evidence the card was permanently parked in review. `finalizeProvenAutoMergeTask` now schedules the unreported gate as a runnable continuation before deferring. Completion is still refused until the gate actually reports; only the silent deadlock is removed.
91 changes: 91 additions & 0 deletions packages/engine/src/__tests__/post-merge-gate-scheduling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";

import { finalizeProvenAutoMergeTask } from "../merge/auto-merge-finalization.js";

/*
* FNXC:PostMergeGateScheduling 2026-09-29:
* FN-9369 made the post-merge-verification optional group a REQUIRED gate, and
* upgradeLegacyCodingPostMergeVerificationStepIds() auto-migrates older coding tasks onto it.
* The gate is a graph post-merge hop: it only runs while the graph is executing the `merge`
* node (runLegacyMergeSeam -> postMergeEntryNodeIds -> walk(entryId)). Once the merge seam has
* already returned, a later finalizeProvenAutoMergeTask() call finds the gate unreported and
* returns `blocked` forever. Nothing ever re-enters the graph at the post-merge node, so on a
* repository that cannot produce the gate's post-landing CI evidence the card can never
* finalize. This test pins the missing scheduling half: finalization must make the pending
* gate runnable instead of only logging that it is missing.
*/
function makeStore(task: Task, opts: { workItems?: unknown[] } = {}): TaskStore {
const workItems = opts.workItems ?? [];
const store = {
getTask: vi.fn(async () => task),
updateTask: vi.fn(async (_id: string, patch: Partial<Task>) => Object.assign(task, patch)),
updateTaskAtomic: vi.fn(async (_id: string, update: (current: Task) => Partial<Task>) => Object.assign(task, update(task))),
moveTask: vi.fn(async (_id: string, column: string) => Object.assign(task, { column })),
logEntry: vi.fn(),
recordRunAuditEvent: vi.fn(),
getSettings: vi.fn(async () => ({})),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "builtin:coding", stepIds: task.enabledWorkflowSteps ?? [] })),
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "builtin:coding", stepIds: task.enabledWorkflowSteps ?? [] })),
getCompletionHandoffAcceptedMarker: vi.fn(async () => null),
listWorkflowWorkItemsForTask: vi.fn(async () => workItems),
upsertWorkflowWorkItem: vi.fn(async (input: unknown) => ({ id: "wi-post-merge", ...(input as object) })),
} as unknown as TaskStore;
store.moveTaskIf = vi.fn(async (_id, column, predicate, options) => {
if (!await predicate(task)) return { task, moved: false };
return { task: await store.moveTask(task.id, column, options), moved: true };
});
return store;
}

function mergedTaskWithPendingGate(): Task {
return {
id: "FN-PM-schedule",
column: "in-review",
steps: [{ name: "implementation", status: "done" }],
mergeDetails: { mergeConfirmed: true, commitSha: "abc123" },
enabledWorkflowSteps: ["post-merge-verification"],
workflowStepResults: [],
} as unknown as Task;
}

describe("FN-9369 post-merge gate must become runnable after the merge seam returned", () => {
it("schedules the unreported post-merge gate instead of only reporting it missing", async () => {
const task = mergedTaskWithPendingGate();
const store = makeStore(task);

const result = await finalizeProvenAutoMergeTask({ store, taskId: task.id, source: "self-healing" });

// Still refuses to finalize: the gate has not run, so there is no evidence.
expect(result).toMatchObject({ outcome: "blocked" });
expect(task.column).toBe("in-review");
// ...but the missing gate is now dispatched, so the block is not terminal.
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledTimes(1);
const seeded = store.upsertWorkflowWorkItem.mock.calls[0]?.[0] as { nodeId?: string; state?: string; kind?: string };
expect(seeded.nodeId).toBe("post-merge-verification");
expect(seeded.state).toBe("runnable");
});

it("does not re-seed a gate that already has an active continuation", async () => {
const task = mergedTaskWithPendingGate();
const store = makeStore(task, {
workItems: [{ id: "wi-existing", nodeId: "post-merge-verification", state: "runnable" }],
});

const result = await finalizeProvenAutoMergeTask({ store, taskId: task.id, source: "self-healing" });

expect(result).toMatchObject({ outcome: "blocked" });
expect(store.upsertWorkflowWorkItem).not.toHaveBeenCalled();
});

it("still finalizes normally when the post-merge gate is disabled", async () => {
const task = mergedTaskWithPendingGate();
task.enabledWorkflowSteps = [];
const store = makeStore(task);

const result = await finalizeProvenAutoMergeTask({ store, taskId: task.id, source: "self-healing" });

expect(result.outcome).toBe("done");
expect(store.upsertWorkflowWorkItem).not.toHaveBeenCalled();
});
});
75 changes: 75 additions & 0 deletions packages/engine/src/merge/auto-merge-finalization.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ACTIVE_WORKFLOW_WORK_ITEM_STATES,
getPostMergeFinalizeBlocker,
getRequiredPostMergeEvidenceBlocker,
planConfirmedMergeChecklistReconciliation,
Expand Down Expand Up @@ -223,6 +224,77 @@ function hasDurableMergeProof(task: Task, result?: MergeResult): boolean {
return task.mergeDetails?.mergeConfirmed === true || result?.mergeConfirmed === true;
}

/*
FNXC:PostMergeGateScheduling 2026-09-29:
FN-9369 turned the `post-merge-verification` optional group into a REQUIRED gate, and
upgradeLegacyCodingPostMergeVerificationStepIds() auto-migrates older built-in coding tasks onto
it, so a task can acquire a gate it never enabled by hand. That gate is a GRAPH post-merge hop:
workflow-graph-executor only walks it from inside runLegacyMergeSeam (postMergeEntryNodeIds ->
walk(entryId)), i.e. while the `merge` node is still executing.

Once the merge seam has returned, every later finalization attempt finds the gate unreported and
returns `blocked`. Nothing re-entered the graph at the post-merge node, so on a repository that
cannot produce the gate's post-landing CI evidence (a local checkout with no upstream pipeline)
the card was provably unable to finalize: merged, proven, and permanently parked in review. This
is the fence the code itself names in self-healing ("Do not strand graph re-entry waiting for
evidence that can only be produced after merge") — the guard refuses completion but nobody owns
RUNNING the gate.

So finalization now owns the missing half: when it is about to defer a confirmed merge because a
required post-merge gate has not reported, it makes that gate runnable again. The refusal to
complete is preserved exactly (no evidence, no completion); only the terminal silence is removed.
*/
async function scheduleMissingPostMergeGate(
store: TaskStore,
task: Task,
blocker: string,
log?: (message: string) => Promise<void> | void,
): Promise<boolean> {
const gateIds = [...blocker.matchAll(/gate '([^']+)'/g)].map((match) => match[1]).filter(Boolean);
if (gateIds.length === 0) return false;

// Never race the engine for the card: a live continuation already owns the slot, and the
// durable index permits only ONE active kind:"task" row per task. Writing while one exists is
// what previously deadlocked the board, so the pre-check stays advisory and the write itself
// goes through the atomic replace primitive.
let scheduled = false;
for (const nodeId of gateIds) {
try {
const active = await store.listWorkflowWorkItemsForTask(task.id);
if (active.some((item) => item.nodeId === nodeId && ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state as never))) {
continue;
}
const input: Parameters<NonNullable<TaskStore["upsertWorkflowWorkItem"]>>[0] & { kind: "task" } = {
runId: `post-merge-gate:${task.id}:${nodeId}`,
taskId: task.id,
nodeId,
nodeInstanceId: nodeId,
kind: "task",
state: "runnable",
leaseOwner: null,
leaseExpiresAt: null,
blockedReason: null,
lastError: null,
sourceColumn: task.column,
};
if (typeof store.replaceActiveTaskWorkflowContinuation === "function") {
await store.replaceActiveTaskWorkflowContinuation(input);
} else if (typeof store.upsertWorkflowWorkItem === "function") {
await store.upsertWorkflowWorkItem(input);
} else {
continue;
}
scheduled = true;
await log?.(`Re-armed unreported post-merge gate '${nodeId}' for ${task.id} so it can run before finalization.`);
} catch (err) {
// Recording the re-arm is best effort. A failure here must never turn a refused
// finalization into a thrown error: the gate stays blocking either way.
await log?.(`Could not re-arm post-merge gate '${nodeId}' for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return scheduled;
}

/**
* FNXC:AutoMergeLifecycle 2026-06-22-19:28:
* Proven auto-merge completion must refresh the authoritative row before moving to done because the merge CAS and queue retry paths can leave a landed task in todo with stale queued/overlap state. Use TaskStore recovery rehome for those column mismatches so completion remains idempotent without direct database surgery.
Expand Down Expand Up @@ -259,6 +331,9 @@ export async function finalizeProvenAutoMergeTask({
auditAgentId,
auditPhase,
});
// The block stands (no evidence, no completion), but an unreported post-merge gate is a
// missing RUN, not a verdict. Re-arm it so the engine can actually produce the evidence.
await scheduleMissingPostMergeGate(store, latest, evidenceBlocker, log);
await log?.(`Auto-merge finalization deferred for ${taskId}: ${evidenceBlocker}`);
return { outcome: "blocked", task: latest, previousColumn: latest.column, reason: evidenceBlocker };
}
Expand Down
Loading