From 57d80657788b0ad853fdd1a7050bebbae165ee06 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 08:22:49 -0700 Subject: [PATCH] fix(FN-7224): let workflow nodes own worktree prep Classify write-capable graph nodes before handler dispatch and let executor adapters fulfill the declared worktree requirement, keeping custom-node execution out of lifecycle decision making. Fusion-Task-Id: FN-7224 --- .changeset/fn-7224-workflow-node-prep.md | 7 ++++ .../ce-workflow-step-executor.test.ts | 29 ++++++++++++-- packages/engine/src/executor.ts | 30 ++++++++++----- .../engine/src/workflow-graph-executor.ts | 38 +++++++++++++++++++ .../engine/src/workflow-graph-task-runner.ts | 8 ++++ 5 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-7224-workflow-node-prep.md diff --git a/.changeset/fn-7224-workflow-node-prep.md b/.changeset/fn-7224-workflow-node-prep.md new file mode 100644 index 0000000000..809a1d3903 --- /dev/null +++ b/.changeset/fn-7224-workflow-node-prep.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Let workflow graphs prepare task worktrees before coding-mode nodes run. +category: fix +dev: Adds graph-owned node preparation so executor adapters only fulfill declared worktree requirements. diff --git a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts index 89606cdd36..fad94639fc 100644 --- a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts +++ b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts @@ -24,9 +24,10 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BUILTIN_WORKFLOWS } from "@fusion/core"; +import { BUILTIN_WORKFLOWS, type WorkflowIr } from "@fusion/core"; import "./executor-test-helpers.js"; import { TaskExecutor } from "../executor.js"; +import { WorkflowGraphExecutor } from "../workflow-graph-executor.js"; import { createMockStore, mockedCreateFnAgent, @@ -204,7 +205,7 @@ describe("CE workflow-step executor integration", () => { expect(captured.step.prompt).toContain("Plan the work."); }); - it("acquires a task worktree when the first CE coding-mode node runs before execute", async () => { + it("lets the graph prepare a task worktree before the first CE coding-mode node runs", async () => { const store = createMockStore(); let live = baseStepTask({ worktree: undefined, @@ -242,7 +243,29 @@ describe("CE workflow-step executor integration", () => { }, }; - const result = await (executor as any).runGraphCustomNode(node, { id: "FN-CE-1" }, await store.getSettings(), undefined); + const ir: WorkflowIr = { + version: "v2", + name: "ce-plan-test", + columns: [{ id: "in-progress", name: "In Progress", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + node as any, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "end", condition: "success" }, + ], + }; + const settings = await store.getSettings(); + const graph = new WorkflowGraphExecutor({ + prepareNodeExecution: (graphNode, task, requirement) => + (executor as any).prepareGraphNodeExecution(graphNode, task, settings, requirement), + runCustomNode: (graphNode, task, context) => + (executor as any).runGraphCustomNode(graphNode, task, settings, undefined, context), + }); + + const result = await graph.run(live as any, settings, ir); expect(result.outcome).toBe("success"); expect((executor as any).createWorktree).toHaveBeenCalled(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 2f8c3e334d..081c74ac2e 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -39,7 +39,7 @@ import { type WorkflowLegacySeams, } from "./workflow-node-handlers.js"; import { MERGE_REGION_KINDS, WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND } from "./workflow-graph-executor.js"; -import type { WorkflowNodeResult } from "./workflow-graph-executor.js"; +import type { WorkflowNodePreparationRequirement, WorkflowNodeResult } from "./workflow-graph-executor.js"; import type { AuditPrimitiveInput, PreparedWorktree, @@ -4564,6 +4564,8 @@ export class TaskExecutor { runId: resolvedRunId, primitives: this.createAuthoritativeWorkflowPrimitives(settings), seams: this.createAuthoritativeWorkflowSeams(settings), + prepareNodeExecution: (node, nodeTask, requirement) => + this.prepareGraphNodeExecution(node, nodeTask, settings, requirement), runCustomNode: (node, nodeTask) => this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)), publishTaskProjection: async (taskId, patch) => { @@ -6550,6 +6552,22 @@ export class TaskExecutor { } } + private async prepareGraphNodeExecution( + node: WorkflowIrNode, + nodeTask: TaskDetail, + settings: Settings, + requirement: WorkflowNodePreparationRequirement, + ): Promise { + if (!requirement.requiresWorktree) return; + const live = await this.store.getTask(nodeTask.id); + if (live.worktree) return; + /* + FNXC:WorkflowExecution 2026-06-29-09:50: + The workflow graph decides which nodes require pre-execution lifecycle resources. This adapter only fulfills a graph-declared worktree requirement with executor-owned git mechanics; custom-node handlers remain ordinary node execution and no longer decide when to bootstrap task isolation. + */ + await this.ensureGraphCustomNodeWorktree(live, settings, node.id); + } + private async finalizeMergeConfirmedWorkflowGraphTask(taskId: string, reason: string): Promise { const live = await this.store.getTask(taskId).catch(() => null); if (!live || live.mergeDetails?.mergeConfirmed !== true || live.column === "done") return false; @@ -6666,7 +6684,7 @@ export class TaskExecutor { // executeWorkflowStep / model machinery. It is write-capable (the agent edits // the worktree), so it requires a task worktree like any coding node. if (executorKind === "cli-agent") { - return this.runCliAgentNode(node, live, cfg); + return this.runCliAgentNode(node, await this.store.getTask(live.id), cfg); } // Fast mode bypasses pre-merge automated review/validation gates. Custom @@ -6696,13 +6714,7 @@ export class TaskExecutor { // main checkout and cross-contaminate other tasks. Reject such nodes until a // worktree exists. Read-only nodes (default toolMode) are safe against root. const writeCapable = cfg.toolMode === "coding" || node.kind === "script" || Boolean(scriptName) || Boolean(rawCliCommand); - /* - FNXC:CompoundEngineering 2026-06-29-08:18: - Compound engineering starts with a coding-mode `ce-plan` skill node so it can load CE spawn tools before implementation. The graph custom-node path must therefore bootstrap the task worktree itself; requiring an earlier execute seam makes the built-in CE workflow fail at node `plan` before it can start. - */ - const executionTarget = writeCapable && !live.worktree - ? await this.ensureGraphCustomNodeWorktree(live, settings, node.id) - : live; + const executionTarget = writeCapable ? await this.store.getTask(live.id) : live; if (writeCapable && !executionTarget.worktree && !this.workspaceConfig) { return { outcome: "failure", value: "no-worktree-for-write-node" }; } diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index f980d0a15c..c8cf6238cf 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -76,6 +76,11 @@ export interface WorkflowNodeExecutionContext { export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise; +export interface WorkflowNodePreparationRequirement { + requiresWorktree: boolean; + reason?: string; +} + export interface WorkflowGraphExecutorDeps { handlers?: Partial>; /** Workflow-native runtime primitives. When present, default nodes call these @@ -84,6 +89,15 @@ export interface WorkflowGraphExecutorDeps { seams?: WorkflowLegacySeams; /** Executes custom (non-seam) prompt/script/gate nodes. */ runCustomNode?: WorkflowCustomNodeRunner; + /* + * FNXC:WorkflowExecution 2026-06-29-09:43: + * Workflow nodes own lifecycle prerequisites. The graph classifies a node's execution requirements (for example a coding/script node needing a task worktree) before dispatching the handler; executor adapters only fulfill that request with concrete git/session mechanics. + */ + prepareNodeExecution?: ( + node: WorkflowIrNode, + task: TaskDetail, + requirement: WorkflowNodePreparationRequirement, + ) => void | Promise; /** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node * handler (artifact read, projection write, pin-protection probe, audit). * Absent → a parse-steps node fails cleanly. */ @@ -1061,6 +1075,7 @@ export class WorkflowGraphExecutor { // Fail-fast cancellation: a branch or top-level graph abort mid-retry stops re-trying. if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" }); try { + await this.prepareNodeExecution(node, task); const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal); if (pluginResult) { const projected = await this.publishTaskProjectionFromResult(task.id, node, pluginResult); @@ -1095,6 +1110,29 @@ export class WorkflowGraphExecutor { }; } + private async prepareNodeExecution(node: WorkflowIrNode, task: TaskDetail): Promise { + const requirement = this.classifyNodePreparation(node); + if (!requirement.requiresWorktree) return; + await this.deps.prepareNodeExecution?.(node, task, requirement); + } + + private classifyNodePreparation(node: WorkflowIrNode): WorkflowNodePreparationRequirement { + const cfg = node.config ?? {}; + const executorKind = typeof cfg.executor === "string" ? cfg.executor : "model"; + const hasScriptName = typeof cfg.scriptName === "string" && cfg.scriptName.trim().length > 0; + const hasCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim().length > 0; + const requiresWorktree = + cfg.toolMode === "coding" + || node.kind === "script" + || executorKind === "cli-agent" + || hasScriptName + || hasCliCommand; + return { + requiresWorktree, + reason: requiresWorktree ? "write-capable-node" : undefined, + }; + } + private isAbortNodeResult(result: WorkflowNodeResult): boolean { return result.outcome === "failure" && result.value === "aborted"; } diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index 3ff8a17000..3aaccf429d 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -13,6 +13,7 @@ import { WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND, WorkflowGraphExecutor, type WorkflowGraphExecutorDeps, + type WorkflowNodePreparationRequirement, type WorkflowNodeAbortKind, type WorkflowNodeOutcome, type WorkflowTaskProjection, @@ -74,6 +75,12 @@ export interface WorkflowGraphTaskRunnerDeps { seams: WorkflowLegacySeams; primitives?: WorkflowRuntimePrimitives; runCustomNode: WorkflowCustomNodeRunner; + /** Workflow-node prerequisite fulfillment, invoked after graph-level classification. */ + prepareNodeExecution?: ( + node: WorkflowIr["nodes"][number], + task: TaskDetail, + requirement: WorkflowNodePreparationRequirement, + ) => void | Promise; maxRetriesPerNode?: number; /** Optional diagnostics hook (audit/log emission). Never throws into the run. */ onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void; @@ -266,6 +273,7 @@ export class WorkflowGraphTaskRunner { seams: wrappedSeams, primitives: wrappedPrimitives, runCustomNode: wrappedRunCustomNode, + prepareNodeExecution: this.deps.prepareNodeExecution, maxRetriesPerNode: this.deps.maxRetriesPerNode, branchPersistence: this.deps.branchPersistence, branchSemaphore: this.deps.branchSemaphore,