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.
This commit is contained in:
gsxdsm
2026-06-11 08:04:11 -07:00
parent fa4445138a
commit c7e278c3db
3 changed files with 111 additions and 8 deletions

View File

@@ -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<string, unknown> }> = [];
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<string, unknown> }> = [];
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({

View File

@@ -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<Settings> | 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);

View File

@@ -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,