feat(FN-000): extract workflow merge node capability
Fusion-Task-Id: FN-000
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: "S06: git and merge capability extraction"
|
||||||
|
type: refactor
|
||||||
|
status: draft-stack-handoff
|
||||||
|
date: 2026-06-09
|
||||||
|
slice: S06
|
||||||
|
milestone: "Runtime"
|
||||||
|
origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md
|
||||||
|
stack_base: feature/workflow-owned-merge-s05-runtime-work-item-driver
|
||||||
|
---
|
||||||
|
|
||||||
|
# S06: git and merge capability extraction
|
||||||
|
|
||||||
|
## Stack Role
|
||||||
|
|
||||||
|
This draft PR reserves the S06 review slot in the workflow-owned merge,
|
||||||
|
retry, scheduling, and recovery migration stack. It is intentionally a handoff
|
||||||
|
artifact, not the completed implementation for this slice.
|
||||||
|
|
||||||
|
## Milestone
|
||||||
|
|
||||||
|
Runtime
|
||||||
|
|
||||||
|
## Depends On
|
||||||
|
|
||||||
|
S4 built-in IR regions and S5 runtime work-item driver.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Put checkout preparation, branch integration, merge attempt, squash, finalize, and conflict classification behind workflow node capability modules.
|
||||||
|
|
||||||
|
## Expected File Scope
|
||||||
|
|
||||||
|
packages/engine/src/merger*.ts; packages/engine/src/workflow-merge-nodes.ts; merge capability tests.
|
||||||
|
|
||||||
|
## Expected Tests
|
||||||
|
|
||||||
|
Checkout preparation, file-scope failure, already-on-main finalize, transient retry, permanent conflict routing, and guard-service coverage.
|
||||||
|
|
||||||
|
## Exit Gate
|
||||||
|
|
||||||
|
A merge attempt can be driven by a workflow node capability with the same guard behavior as merger.ts.
|
||||||
|
|
||||||
|
## Full Plan
|
||||||
|
|
||||||
|
See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`.
|
||||||
61
packages/engine/src/__tests__/workflow-merge-nodes.test.ts
Normal file
61
packages/engine/src/__tests__/workflow-merge-nodes.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TaskDetail } from "@fusion/core";
|
||||||
|
import { classifyMergePrimitiveResult, runWorkflowMergeAttemptNode } from "../workflow-merge-nodes.js";
|
||||||
|
import type { WorkflowPrimitiveContext } from "../runtime-primitives.js";
|
||||||
|
|
||||||
|
const task = { id: "FN-MERGE" } as TaskDetail;
|
||||||
|
const ctx: WorkflowPrimitiveContext = {
|
||||||
|
run: { runId: "run-1", taskId: task.id, workflowId: "builtin:coding" },
|
||||||
|
node: { node: { id: "merge-attempt", kind: "merge-attempt" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("workflow merge nodes", () => {
|
||||||
|
it("classifies guarded merge primitive results into workflow outcomes", () => {
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "merged" }, undefined, "success")).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "merged",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "merged", noOp: true }, undefined, "success")).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "already-landed",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "manual-required", reason: "conflict" }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "manual-required",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "timeout" }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "transient-failure",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult({ status: "failed", reason: "File scope violation" }, undefined, "failure")).toEqual({
|
||||||
|
outcome: "failure",
|
||||||
|
value: "file-scope-violation",
|
||||||
|
});
|
||||||
|
expect(classifyMergePrimitiveResult(undefined, "transient-failure", "failure")).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "transient-failure",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the existing merge primitive and emits a workflow capability audit event", async () => {
|
||||||
|
const audit = vi.fn();
|
||||||
|
const requestMerge = vi.fn().mockResolvedValue({
|
||||||
|
outcome: "success",
|
||||||
|
data: { status: "merged" },
|
||||||
|
contextPatch: { mergedBranch: "main" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runWorkflowMergeAttemptNode({ primitives: { requestMerge, audit } }, ctx, task);
|
||||||
|
|
||||||
|
expect(requestMerge).toHaveBeenCalledWith(ctx, task);
|
||||||
|
expect(audit).toHaveBeenCalledWith(ctx, expect.objectContaining({
|
||||||
|
type: "workflow-merge-node",
|
||||||
|
metadata: expect.objectContaining({ taskId: task.id, primitiveOutcome: "success" }),
|
||||||
|
}));
|
||||||
|
expect(result).toEqual({
|
||||||
|
outcome: "success",
|
||||||
|
value: "merged",
|
||||||
|
contextPatch: { mergedBranch: "main", "workflow:merge-status": "merged" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,11 @@ export {
|
|||||||
type WorkflowWorkDispatch,
|
type WorkflowWorkDispatch,
|
||||||
type WorkflowWorkSchedulerStore,
|
type WorkflowWorkSchedulerStore,
|
||||||
} from "./workflow-work-scheduler.js";
|
} from "./workflow-work-scheduler.js";
|
||||||
|
export {
|
||||||
|
classifyMergePrimitiveResult,
|
||||||
|
runWorkflowMergeAttemptNode,
|
||||||
|
type WorkflowMergeNodeDeps,
|
||||||
|
} from "./workflow-merge-nodes.js";
|
||||||
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
||||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||||
|
|||||||
66
packages/engine/src/workflow-merge-nodes.ts
Normal file
66
packages/engine/src/workflow-merge-nodes.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { TaskDetail } from "@fusion/core";
|
||||||
|
import type { MergePrimitiveResult, WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||||
|
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||||
|
|
||||||
|
export interface WorkflowMergeNodeDeps {
|
||||||
|
primitives: Pick<WorkflowRuntimePrimitives, "requestMerge" | "audit">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWorkflowMergeAttemptNode(
|
||||||
|
deps: WorkflowMergeNodeDeps,
|
||||||
|
ctx: WorkflowPrimitiveContext,
|
||||||
|
task: TaskDetail,
|
||||||
|
): Promise<WorkflowNodeResult> {
|
||||||
|
const result = await deps.primitives.requestMerge(ctx, task);
|
||||||
|
const classified = classifyMergePrimitiveResult(result.data, result.value, result.outcome);
|
||||||
|
await deps.primitives.audit(ctx, {
|
||||||
|
type: "workflow-merge-node",
|
||||||
|
message: `workflow merge node classified ${classified.value ?? classified.outcome}`,
|
||||||
|
metadata: { taskId: task.id, primitiveOutcome: result.outcome, primitiveValue: result.value, primitiveData: result.data },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
outcome: classified.outcome,
|
||||||
|
value: classified.value,
|
||||||
|
contextPatch: { ...(result.contextPatch ?? {}), "workflow:merge-status": classified.value ?? classified.outcome },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyMergePrimitiveResult(
|
||||||
|
data: MergePrimitiveResult | undefined,
|
||||||
|
value: string | undefined,
|
||||||
|
primitiveOutcome: WorkflowNodeResult["outcome"],
|
||||||
|
): WorkflowNodeResult {
|
||||||
|
if (data?.status === "merged") {
|
||||||
|
return { outcome: "success", value: data.noOp ? "already-landed" : "merged" };
|
||||||
|
}
|
||||||
|
if (data?.status === "manual-required") {
|
||||||
|
return { outcome: "success", value: "manual-required" };
|
||||||
|
}
|
||||||
|
if (data?.status === "timeout") {
|
||||||
|
return { outcome: "success", value: "transient-failure" };
|
||||||
|
}
|
||||||
|
if (data?.status === "failed") {
|
||||||
|
return classifyMergeFailure(data.reason);
|
||||||
|
}
|
||||||
|
if (value === "transient-failure" || value === "manual-required" || value === "stale-head" || value === "not-actionable") {
|
||||||
|
return { outcome: "success", value };
|
||||||
|
}
|
||||||
|
return { outcome: primitiveOutcome, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyMergeFailure(reason: string): WorkflowNodeResult {
|
||||||
|
const normalized = reason.toLowerCase();
|
||||||
|
if (normalized.includes("file scope") || normalized.includes("filescope")) {
|
||||||
|
return { outcome: "failure", value: "file-scope-violation" };
|
||||||
|
}
|
||||||
|
if (normalized.includes("already") && (normalized.includes("main") || normalized.includes("merged") || normalized.includes("landed"))) {
|
||||||
|
return { outcome: "success", value: "already-landed" };
|
||||||
|
}
|
||||||
|
if (normalized.includes("timeout") || normalized.includes("econnreset") || normalized.includes("socket") || normalized.includes("transient")) {
|
||||||
|
return { outcome: "success", value: "transient-failure" };
|
||||||
|
}
|
||||||
|
if (normalized.includes("manual") || normalized.includes("conflict")) {
|
||||||
|
return { outcome: "success", value: "manual-required" };
|
||||||
|
}
|
||||||
|
return { outcome: "failure", value: "merge-failed" };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type WorkflowPrimitiveContext,
|
type WorkflowPrimitiveContext,
|
||||||
type WorkflowRuntimePrimitives,
|
type WorkflowRuntimePrimitives,
|
||||||
} from "./runtime-primitives.js";
|
} from "./runtime-primitives.js";
|
||||||
|
import { runWorkflowMergeAttemptNode } from "./workflow-merge-nodes.js";
|
||||||
|
|
||||||
export type WorkflowSeamName =
|
export type WorkflowSeamName =
|
||||||
| "planning"
|
| "planning"
|
||||||
@@ -938,8 +939,11 @@ export function createDefaultNodeHandlers(
|
|||||||
const attempt = typeof ctx.context["workflow:work-item-attempt"] === "number"
|
const attempt = typeof ctx.context["workflow:work-item-attempt"] === "number"
|
||||||
? ctx.context["workflow:work-item-attempt"]
|
? ctx.context["workflow:work-item-attempt"]
|
||||||
: undefined;
|
: undefined;
|
||||||
const result = await deps.primitives.requestMerge(primitiveContextForNode(_node, ctx.task, ctx.context, attempt), ctx.task);
|
return runWorkflowMergeAttemptNode(
|
||||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
{ primitives: deps.primitives },
|
||||||
|
primitiveContextForNode(_node, ctx.task, ctx.context, attempt),
|
||||||
|
ctx.task,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
"manual-merge-hold": async () => ({ outcome: "failure", value: "manual-required" }),
|
"manual-merge-hold": async () => ({ outcome: "failure", value: "manual-required" }),
|
||||||
"retry-backoff": async () => ({ outcome: "success" }),
|
"retry-backoff": async () => ({ outcome: "success" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user