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:
7
.changeset/fn-7261-fast-merge-guard.md
Normal file
7
.changeset/fn-7261-fast-merge-guard.md
Normal 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.
|
||||||
@@ -388,6 +388,88 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
expect(live.mergeDetails?.mergeConfirmed).toBe(true);
|
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 () => {
|
it("uses moveTask for workflow graph column transitions so lifecycle notifications fire", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(baseStepTask({ column: "todo" }) as any);
|
store.getTask.mockResolvedValue(baseStepTask({ column: "todo" }) as any);
|
||||||
|
|||||||
@@ -121,6 +121,50 @@ describe("executor graph execute self-requeue gate", () => {
|
|||||||
expect(store.handoffToReview).not.toHaveBeenCalled();
|
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 () => {
|
it("does not hand generic graph failures to review", async () => {
|
||||||
resetExecutorMocks();
|
resetExecutorMocks();
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
|
|||||||
@@ -3169,6 +3169,8 @@ export class TaskExecutor {
|
|||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
mergeDetails: null,
|
mergeDetails: null,
|
||||||
mergeRetries: 0,
|
mergeRetries: 0,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0,
|
verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0,
|
||||||
workflowStepResults: preservedWorkflowStepResults,
|
workflowStepResults: preservedWorkflowStepResults,
|
||||||
});
|
});
|
||||||
@@ -5806,6 +5808,23 @@ export class TaskExecutor {
|
|||||||
workflowId: ctx.run.workflowId,
|
workflowId: ctx.run.workflowId,
|
||||||
runId: ctx.run.runId,
|
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 GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -6783,6 +6802,13 @@ export class TaskExecutor {
|
|||||||
undefined,
|
undefined,
|
||||||
this.getRunContextFor(taskId),
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
executorLog.log(`${taskId}: workflow graph merge-confirmed task finalized (${finalization.outcome})`);
|
executorLog.log(`${taskId}: workflow graph merge-confirmed task finalized (${finalization.outcome})`);
|
||||||
@@ -8069,6 +8095,9 @@ export class TaskExecutor {
|
|||||||
await this.persistTokenUsage(task.id);
|
await this.persistTokenUsage(task.id);
|
||||||
return;
|
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)) {
|
if (mergeGraphFailure && !this.isTerminalMergeGraphFailureValue(failureValue) && await this.routeGraphMergeFailureToRetry(live, result, abortProvenance)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -8167,7 +8196,8 @@ export class TaskExecutor {
|
|||||||
if (live.paused || live.userPaused === true) return false;
|
if (live.paused || live.userPaused === true) return false;
|
||||||
if (live.column === "done" || live.column === "archived") return false;
|
if (live.column === "done" || live.column === "archived") return false;
|
||||||
const incompleteSteps = hasNonTerminalWorkflowSteps(live);
|
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
|
const message = incompleteSteps
|
||||||
? `Workflow graph failed at node '${failedNode}'${failureValue ? ` (${failureValue})` : ""} with incomplete steps — moved back to todo for execution resume`
|
? `Workflow graph failed at node '${failedNode}'${failureValue ? ` (${failureValue})` : ""} with incomplete steps — moved back to todo for execution resume`
|
||||||
|
|||||||
Reference in New Issue
Block a user