From 44cfae14f053bdc4b2f9e6db5b0fc862cbbd8121 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Tue, 29 Sep 2026 07:56:02 -0300 Subject: [PATCH] fix(engine): re-arm an unreported post-merge gate instead of deferring forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FN-9369 made the `post-merge-verification` optional group 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 `finalizeProvenAutoMergeTask` call finds the gate unreported and returns `blocked`, and nothing re-entered the graph at the post-merge node. Measured on a local checkout with no upstream CI: a task with real commits landed (`mergeDetails.mergeConfirmed`, mergedAt recorded) stayed in `in-review` forever while the engine logged `Auto-merge finalization deferred ... required post-merge evidence gate 'post-merge-verification' has not reported` every 15s. The gate's own prompt demands post-landing Full Suite / shard / timing evidence that such a repository can never produce, so the block was terminal by construction. The guard is right and is kept: no evidence, no completion. What was missing is who owns RUNNING the gate. Finalization now schedules the unreported gate as a runnable continuation before deferring, so the deadlock resolves instead of silently parking the card. The write goes through `replaceActiveTaskWorkflowContinuation` because a bare upsert targets a different constraint than the one-active-continuation index and raises when a row already exists — the failure mode that previously deadlocked the board. The advisory pre-check skips a gate that already has a live continuation, and a write failure is logged rather than thrown, so a refused finalization never becomes a crash. No existing open PR in the fork touches `confirmed-merge-reconciliation.ts`, `workflow-optional-steps.ts`, `builtin-coding-workflow-ir.ts` or `builtin-post-merge-group.ts`; PR #26 (fm/fusion-noop-merge) addresses a different defect (in-review cards that committed nothing) and does not apply to cards with real commits. Verified: 2198 engine tests pass, 17 files fail identically on unmodified origin/main (pre-existing, not a regression), typecheck and the full static gate clean. --- .changeset/post-merge-gate-scheduling.md | 7 ++ .../post-merge-gate-scheduling.test.ts | 91 +++++++++++++++++++ .../src/merge/auto-merge-finalization.ts | 75 +++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 .changeset/post-merge-gate-scheduling.md create mode 100644 packages/engine/src/__tests__/post-merge-gate-scheduling.test.ts diff --git a/.changeset/post-merge-gate-scheduling.md b/.changeset/post-merge-gate-scheduling.md new file mode 100644 index 0000000000..627a25a093 --- /dev/null +++ b/.changeset/post-merge-gate-scheduling.md @@ -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. diff --git a/packages/engine/src/__tests__/post-merge-gate-scheduling.test.ts b/packages/engine/src/__tests__/post-merge-gate-scheduling.test.ts new file mode 100644 index 0000000000..e23d2d659e --- /dev/null +++ b/packages/engine/src/__tests__/post-merge-gate-scheduling.test.ts @@ -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) => Object.assign(task, patch)), + updateTaskAtomic: vi.fn(async (_id: string, update: (current: Task) => Partial) => 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(); + }); +}); diff --git a/packages/engine/src/merge/auto-merge-finalization.ts b/packages/engine/src/merge/auto-merge-finalization.ts index a56a612189..4fdb9a79ea 100644 --- a/packages/engine/src/merge/auto-merge-finalization.ts +++ b/packages/engine/src/merge/auto-merge-finalization.ts @@ -1,4 +1,5 @@ import { + ACTIVE_WORKFLOW_WORK_ITEM_STATES, getPostMergeFinalizeBlocker, getRequiredPostMergeEvidenceBlocker, planConfirmedMergeChecklistReconciliation, @@ -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, +): Promise { + 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>[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. @@ -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 }; }