From c7e278c3dbd24aba7505c99a75a49d8b59ce49f3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 11 Jun 2026 08:04:11 -0700 Subject: [PATCH] fix(FN-000): honor manual merge work states Address PR #1578 feedback by deriving merge-gate routing from task/settings auto-merge policy and persisting manual-hold work items as manual-required instead of failed. --- .../__tests__/workflow-task-runtime.test.ts | 90 +++++++++++++++++++ packages/engine/src/workflow-node-handlers.ts | 14 +-- packages/engine/src/workflow-task-runtime.ts | 15 +++- 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/__tests__/workflow-task-runtime.test.ts b/packages/engine/src/__tests__/workflow-task-runtime.test.ts index 6edb205b2a..b37953e171 100644 --- a/packages/engine/src/__tests__/workflow-task-runtime.test.ts +++ b/packages/engine/src/__tests__/workflow-task-runtime.test.ts @@ -411,6 +411,96 @@ describe("WorkflowTaskRuntime", () => { ]); }); + it("routes merge-gate work items off when task auto-merge is disabled", async () => { + const transitions: Array<{ id: string; state: WorkflowWorkItemState; patch?: Record }> = []; + const workItem = { + id: "work-merge-gate", + runId: "run-merge-gate", + taskId: task.id, + nodeId: "merge-gate", + kind: "merge", + state: "running", + attempt: 0, + retryAfter: null, + leaseOwner: "scheduler-a", + leaseExpiresAt: "2026-06-09T00:01:00.000Z", + lastError: null, + blockedReason: null, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + } satisfies WorkflowWorkItem; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => ({ ...task, autoMerge: false } as TaskDetail), + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + transitionWorkflowWorkItem: (id, state, patch) => { + transitions.push({ id, state, patch }); + return { ...workItem, state }; + }, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + }); + + const result = await runtime.runWorkItem(workItem, { ...flagOff, autoMerge: true } as Settings); + + expect(result.disposition).toBe("completed"); + expect(result.context["node:merge-gate:value"]).toBe("auto-off"); + expect(transitions).toEqual([ + { + id: "work-merge-gate", + state: "succeeded", + patch: { leaseOwner: null, leaseExpiresAt: null, lastError: null }, + }, + ]); + }); + + it("persists manual merge holds as manual-required work items", async () => { + const transitions: Array<{ id: string; state: WorkflowWorkItemState; patch?: Record }> = []; + const workItem = { + id: "work-manual-hold", + runId: "run-manual-hold", + taskId: task.id, + nodeId: "merge-manual-hold", + kind: "manual-hold", + state: "running", + attempt: 0, + retryAfter: null, + leaseOwner: "scheduler-a", + leaseExpiresAt: "2026-06-09T00:01:00.000Z", + lastError: null, + blockedReason: null, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + } satisfies WorkflowWorkItem; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => task, + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + transitionWorkflowWorkItem: (id, state, patch) => { + transitions.push({ id, state, patch }); + return { ...workItem, state }; + }, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + }); + + const result = await runtime.runWorkItem(workItem, flagOff); + + expect(result.disposition).toBe("manual-required"); + expect(result.reason).toBe("manual-required"); + expect(transitions).toEqual([ + { + id: "work-manual-hold", + state: "manual-required", + patch: { leaseOwner: null, leaseExpiresAt: null, lastError: "manual-required" }, + }, + ]); + }); + it("uses the built-in workflow id in the default run id for unselected tasks", async () => { const observedRunIds: string[] = []; const runtime = new WorkflowTaskRuntime({ diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index 091fdc6f64..9d7fcc4378 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -1,5 +1,5 @@ import { WorkflowIrError, getStepParser, instanceNodeId } from "@fusion/core"; -import type { NotificationEvent, NotificationPayload, TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core"; +import type { NotificationEvent, NotificationPayload, Settings, TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core"; import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js"; import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js"; @@ -925,10 +925,14 @@ export function createDefaultNodeHandlers( "parse-steps": parseSteps, code: createCodeNodeHandler(deps?.runCode), notify: createNotifyHandler(deps?.notifyDispatch), - "merge-gate": async (_node, ctx) => ({ - outcome: "success", - value: ctx.context.autoMerge === false ? "auto-off" : "auto-on", - }), + "merge-gate": async (_node, ctx) => { + const settingsAutoMerge = (ctx.settings as Partial | undefined)?.autoMerge; + const autoMerge = ctx.task.autoMerge !== false && settingsAutoMerge !== false; + return { + outcome: "success", + value: autoMerge ? "auto-on" : "auto-off", + }; + }, "merge-attempt": async (_node, ctx) => { if (!deps?.primitives) return { outcome: "failure", value: "merge-primitives-unwired" }; const result = await deps.primitives.requestMerge(primitiveContextForNode(_node, ctx.task, ctx.context), ctx.task); diff --git a/packages/engine/src/workflow-task-runtime.ts b/packages/engine/src/workflow-task-runtime.ts index eacdb4b7ae..9d64177c90 100644 --- a/packages/engine/src/workflow-task-runtime.ts +++ b/packages/engine/src/workflow-task-runtime.ts @@ -20,7 +20,7 @@ import { } from "./workflow-node-handlers.js"; import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js"; -export type WorkflowTaskRuntimeDisposition = "completed" | "failed"; +export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required"; export interface WorkflowTaskRuntimeResult { disposition: WorkflowTaskRuntimeDisposition; @@ -178,8 +178,17 @@ export class WorkflowTaskRuntime { reason = `workflow-work-item-node-error:${err instanceof Error ? err.message : String(err)}`; } - const disposition: WorkflowTaskRuntimeDisposition = outcome === "success" ? "completed" : "failed"; - this.deps.store.transitionWorkflowWorkItem(workItem.id, disposition === "completed" ? "succeeded" : "failed", { + const disposition: WorkflowTaskRuntimeDisposition = outcome === "success" + ? "completed" + : reason === "manual-required" + ? "manual-required" + : "failed"; + const terminalState: WorkflowWorkItemState = disposition === "completed" + ? "succeeded" + : disposition === "manual-required" + ? "manual-required" + : "failed"; + this.deps.store.transitionWorkflowWorkItem(workItem.id, terminalState, { leaseOwner: null, leaseExpiresAt: null, lastError: reason ?? null,