fix(FN-7261): prevent premature fast workflow merges

Block the merge requester when fast workflow traversal reaches merge before implementation steps are complete, and recover stale no-op merge proof by requeuing executable work instead of looping in blocked finalization.

Fusion-Task-Id: FN-7261
This commit is contained in:
gsxdsm
2026-06-29 23:35:08 -07:00
parent a0b35c13af
commit 41ff08a158
4 changed files with 164 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent fast workflow merges from completing before implementation steps run.
category: fix
dev: Blocks stale no-op merge proof from trapping unfinished workflow tasks and requeues premature merge-node failures.

View File

@@ -388,6 +388,88 @@ describe("CE workflow-step executor integration", () => {
expect(live.mergeDetails?.mergeConfirmed).toBe(true);
});
it("lets stale no-op merge proof fall through when implementation steps are incomplete", async () => {
const store = createMockStore();
const live = baseStepTask({
column: "in-progress",
status: "failed",
error: "Merge confirmed but finalization blocked: task has incomplete steps",
mergeDetails: { mergeConfirmed: true, noOpMerge: true, noOpReason: "already-merged" },
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
});
store.getTask.mockResolvedValue(live as any);
const { executor } = makeExecutor(store);
/*
* FNXC:WorkflowMerge 2026-06-29-23:12:
* A no-op merge confirmation without a landed commit is not implementation proof. When reopened work still has incomplete legacy steps, execute() must continue to stale-merge cleanup/reverification instead of consuming the run in merge-confirmed finalization.
*/
const handled = await (executor as any).finalizeMergeConfirmedWorkflowGraphTask("FN-CE-1", "test");
expect(handled).toBe(false);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-CE-1", "done", expect.anything());
expect(store.logEntry).toHaveBeenCalledWith(
"FN-CE-1",
expect.stringContaining("merge-confirmed finalization blocked"),
undefined,
undefined,
);
});
it("blocks the merge requester when graph traversal reaches merge before implementation steps finish", async () => {
const store = createMockStore();
let live = baseStepTask({
column: "in-progress",
status: null,
error: null,
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
});
store.getTask.mockImplementation(async () => live as any);
store.updateTask.mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
live = { ...live, ...patch };
return live as any;
});
store.moveTask.mockImplementation(async (_id: string, column: string) => {
live = { ...live, column };
return live as any;
});
const { executor } = makeExecutor(store);
const mergeRequester = vi.fn(async () => ({ ok: true, merged: false, noOp: true, mergeConfirmed: true }));
executor.setMergeRequester(mergeRequester as any);
const settings = await store.getSettings();
const primitives = (executor as any).createAuthoritativeWorkflowPrimitives(settings);
/*
* FNXC:WorkflowMerge 2026-06-29-23:18:
* Reaching the merge node is not itself proof that implementation ran. The requester must not create a no-op merge for an unfinished legacy checklist; the graph failure path will route the task back to executable work.
*/
const result = await primitives.requestMerge(
{
run: { runId: "run-1", taskId: "FN-CE-1", workflowId: "builtin:coding" },
node: { node: { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, context: {} },
},
live,
);
expect(result).toEqual(expect.objectContaining({
outcome: "failure",
value: "implementation-incomplete",
}));
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-CE-1",
"Workflow merge blocked before requester: implementation steps are incomplete",
undefined,
undefined,
);
});
it("uses moveTask for workflow graph column transitions so lifecycle notifications fire", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(baseStepTask({ column: "todo" }) as any);

View File

@@ -121,6 +121,50 @@ describe("executor graph execute self-requeue gate", () => {
expect(store.handoffToReview).not.toHaveBeenCalled();
});
it("moves premature merge failures with incomplete in-progress steps back to todo", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-7261",
column: "in-progress",
status: null,
error: null,
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
});
store.getTask.mockResolvedValue(live);
const executor = new TaskExecutor(store, "/tmp/test");
/*
* FNXC:WorkflowMerge 2026-06-29-23:18:
* Fast-mode graph traversal must not turn an unfinished legacy checklist into a no-op merge. If the merge node is reached before implementation proof exists, recover by requeueing executable work instead of parking the task failed in-progress.
*/
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["merge"],
context: { "node:merge:value": "implementation-incomplete" },
});
expect(store.updateTask).toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: null, error: null }),
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith(
live.id,
"todo",
expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }),
);
expect(store.updateTask).not.toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
});
it("does not hand generic graph failures to review", async () => {
resetExecutorMocks();
const store = createMockStore();

View File

@@ -3169,6 +3169,8 @@ export class TaskExecutor {
await this.store.updateTask(task.id, {
mergeDetails: null,
mergeRetries: 0,
status: null,
error: null,
verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0,
workflowStepResults: preservedWorkflowStepResults,
});
@@ -5806,6 +5808,23 @@ export class TaskExecutor {
workflowId: ctx.run.workflowId,
runId: ctx.run.runId,
});
/*
FNXC:WorkflowMerge 2026-06-29-23:18:
FN-7261 reached the merge node in fast mode with every legacy implementation step still pending, producing a no-op merge proof for work that never ran. A graph-native workflow may project its checklist at the merge boundary only when node workflow results prove implementation completed; otherwise incomplete legacy steps are authoritative and merge must fail before the merger can create stale no-op proof.
*/
if (hasNonTerminalWorkflowSteps(mergeTask)) {
await this.store.logEntry(
mergeTask.id,
"Workflow merge blocked before requester: implementation steps are incomplete",
undefined,
this.getRunContextFor(mergeTask.id),
);
return {
outcome: "failure",
value: "implementation-incomplete",
data: { status: "failed", reason: "implementation-incomplete" },
};
}
const GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
@@ -6783,6 +6802,13 @@ export class TaskExecutor {
undefined,
this.getRunContextFor(taskId),
);
if (finalization.reason === "task has incomplete steps" && live.mergeDetails?.noOpMerge === true && !live.mergeDetails?.commitSha) {
/*
FNXC:WorkflowMerge 2026-06-29-23:12:
FN-7261 exposed stale no-op proof as a re-execution blocker: a reopened task with incomplete implementation steps and only no-op merge proof must fall through to merge-state cleanup/reverification, not consume execute() by repeatedly trying blocked finalization.
*/
return false;
}
return true;
}
executorLog.log(`${taskId}: workflow graph merge-confirmed task finalized (${finalization.outcome})`);
@@ -8069,6 +8095,9 @@ export class TaskExecutor {
await this.persistTokenUsage(task.id);
return;
}
if (mergeGraphFailure && failureValue === "implementation-incomplete" && await this.routeGraphFailureToExecutionResume(live, failedNode ?? "unknown", failureValue)) {
return;
}
if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
return;
}
@@ -8167,7 +8196,8 @@ export class TaskExecutor {
if (live.paused || live.userPaused === true) return false;
if (live.column === "done" || live.column === "archived") return false;
const incompleteSteps = hasNonTerminalWorkflowSteps(live);
if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo")) return false;
const prematureMergeWithIncompleteSteps = failedNode === "merge" && failureValue === "implementation-incomplete" && incompleteSteps;
if (live.column !== "in-review" && !(incompleteSteps && live.column === "todo") && !(prematureMergeWithIncompleteSteps && live.column === "in-progress")) return false;
const message = incompleteSteps
? `Workflow graph failed at node '${failedNode}'${failureValue ? ` (${failureValue})` : ""} with incomplete steps — moved back to todo for execution resume`