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
This commit is contained in:
7
.changeset/fn-7224-workflow-node-prep.md
Normal file
7
.changeset/fn-7224-workflow-node-prep.md
Normal file
@@ -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.
|
||||||
@@ -24,9 +24,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 "./executor-test-helpers.js";
|
||||||
import { TaskExecutor } from "../executor.js";
|
import { TaskExecutor } from "../executor.js";
|
||||||
|
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||||
import {
|
import {
|
||||||
createMockStore,
|
createMockStore,
|
||||||
mockedCreateFnAgent,
|
mockedCreateFnAgent,
|
||||||
@@ -204,7 +205,7 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
expect(captured.step.prompt).toContain("Plan the work.");
|
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();
|
const store = createMockStore();
|
||||||
let live = baseStepTask({
|
let live = baseStepTask({
|
||||||
worktree: undefined,
|
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(result.outcome).toBe("success");
|
||||||
expect((executor as any).createWorktree).toHaveBeenCalled();
|
expect((executor as any).createWorktree).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
type WorkflowLegacySeams,
|
type WorkflowLegacySeams,
|
||||||
} from "./workflow-node-handlers.js";
|
} from "./workflow-node-handlers.js";
|
||||||
import { MERGE_REGION_KINDS, WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND } from "./workflow-graph-executor.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 {
|
import type {
|
||||||
AuditPrimitiveInput,
|
AuditPrimitiveInput,
|
||||||
PreparedWorktree,
|
PreparedWorktree,
|
||||||
@@ -4564,6 +4564,8 @@ export class TaskExecutor {
|
|||||||
runId: resolvedRunId,
|
runId: resolvedRunId,
|
||||||
primitives: this.createAuthoritativeWorkflowPrimitives(settings),
|
primitives: this.createAuthoritativeWorkflowPrimitives(settings),
|
||||||
seams: this.createAuthoritativeWorkflowSeams(settings),
|
seams: this.createAuthoritativeWorkflowSeams(settings),
|
||||||
|
prepareNodeExecution: (node, nodeTask, requirement) =>
|
||||||
|
this.prepareGraphNodeExecution(node, nodeTask, settings, requirement),
|
||||||
runCustomNode: (node, nodeTask) =>
|
runCustomNode: (node, nodeTask) =>
|
||||||
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)),
|
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)),
|
||||||
publishTaskProjection: async (taskId, patch) => {
|
publishTaskProjection: async (taskId, patch) => {
|
||||||
@@ -6550,6 +6552,22 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async prepareGraphNodeExecution(
|
||||||
|
node: WorkflowIrNode,
|
||||||
|
nodeTask: TaskDetail,
|
||||||
|
settings: Settings,
|
||||||
|
requirement: WorkflowNodePreparationRequirement,
|
||||||
|
): Promise<void> {
|
||||||
|
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<boolean> {
|
private async finalizeMergeConfirmedWorkflowGraphTask(taskId: string, reason: string): Promise<boolean> {
|
||||||
const live = await this.store.getTask(taskId).catch(() => null);
|
const live = await this.store.getTask(taskId).catch(() => null);
|
||||||
if (!live || live.mergeDetails?.mergeConfirmed !== true || live.column === "done") return false;
|
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
|
// executeWorkflowStep / model machinery. It is write-capable (the agent edits
|
||||||
// the worktree), so it requires a task worktree like any coding node.
|
// the worktree), so it requires a task worktree like any coding node.
|
||||||
if (executorKind === "cli-agent") {
|
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
|
// 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
|
// main checkout and cross-contaminate other tasks. Reject such nodes until a
|
||||||
// worktree exists. Read-only nodes (default toolMode) are safe against root.
|
// worktree exists. Read-only nodes (default toolMode) are safe against root.
|
||||||
const writeCapable = cfg.toolMode === "coding" || node.kind === "script" || Boolean(scriptName) || Boolean(rawCliCommand);
|
const writeCapable = cfg.toolMode === "coding" || node.kind === "script" || Boolean(scriptName) || Boolean(rawCliCommand);
|
||||||
/*
|
const executionTarget = writeCapable ? await this.store.getTask(live.id) : live;
|
||||||
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;
|
|
||||||
if (writeCapable && !executionTarget.worktree && !this.workspaceConfig) {
|
if (writeCapable && !executionTarget.worktree && !this.workspaceConfig) {
|
||||||
return { outcome: "failure", value: "no-worktree-for-write-node" };
|
return { outcome: "failure", value: "no-worktree-for-write-node" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export interface WorkflowNodeExecutionContext {
|
|||||||
|
|
||||||
export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise<WorkflowNodeResult>;
|
export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise<WorkflowNodeResult>;
|
||||||
|
|
||||||
|
export interface WorkflowNodePreparationRequirement {
|
||||||
|
requiresWorktree: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowGraphExecutorDeps {
|
export interface WorkflowGraphExecutorDeps {
|
||||||
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
|
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
|
||||||
/** Workflow-native runtime primitives. When present, default nodes call these
|
/** Workflow-native runtime primitives. When present, default nodes call these
|
||||||
@@ -84,6 +89,15 @@ export interface WorkflowGraphExecutorDeps {
|
|||||||
seams?: WorkflowLegacySeams;
|
seams?: WorkflowLegacySeams;
|
||||||
/** Executes custom (non-seam) prompt/script/gate nodes. */
|
/** Executes custom (non-seam) prompt/script/gate nodes. */
|
||||||
runCustomNode?: WorkflowCustomNodeRunner;
|
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<void>;
|
||||||
/** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node
|
/** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node
|
||||||
* handler (artifact read, projection write, pin-protection probe, audit).
|
* handler (artifact read, projection write, pin-protection probe, audit).
|
||||||
* Absent → a parse-steps node fails cleanly. */
|
* 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.
|
// 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" });
|
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
|
||||||
try {
|
try {
|
||||||
|
await this.prepareNodeExecution(node, task);
|
||||||
const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal);
|
const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal);
|
||||||
if (pluginResult) {
|
if (pluginResult) {
|
||||||
const projected = await this.publishTaskProjectionFromResult(task.id, node, 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<void> {
|
||||||
|
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 {
|
private isAbortNodeResult(result: WorkflowNodeResult): boolean {
|
||||||
return result.outcome === "failure" && result.value === "aborted";
|
return result.outcome === "failure" && result.value === "aborted";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND,
|
WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND,
|
||||||
WorkflowGraphExecutor,
|
WorkflowGraphExecutor,
|
||||||
type WorkflowGraphExecutorDeps,
|
type WorkflowGraphExecutorDeps,
|
||||||
|
type WorkflowNodePreparationRequirement,
|
||||||
type WorkflowNodeAbortKind,
|
type WorkflowNodeAbortKind,
|
||||||
type WorkflowNodeOutcome,
|
type WorkflowNodeOutcome,
|
||||||
type WorkflowTaskProjection,
|
type WorkflowTaskProjection,
|
||||||
@@ -74,6 +75,12 @@ export interface WorkflowGraphTaskRunnerDeps {
|
|||||||
seams: WorkflowLegacySeams;
|
seams: WorkflowLegacySeams;
|
||||||
primitives?: WorkflowRuntimePrimitives;
|
primitives?: WorkflowRuntimePrimitives;
|
||||||
runCustomNode: WorkflowCustomNodeRunner;
|
runCustomNode: WorkflowCustomNodeRunner;
|
||||||
|
/** Workflow-node prerequisite fulfillment, invoked after graph-level classification. */
|
||||||
|
prepareNodeExecution?: (
|
||||||
|
node: WorkflowIr["nodes"][number],
|
||||||
|
task: TaskDetail,
|
||||||
|
requirement: WorkflowNodePreparationRequirement,
|
||||||
|
) => void | Promise<void>;
|
||||||
maxRetriesPerNode?: number;
|
maxRetriesPerNode?: number;
|
||||||
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
|
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
|
||||||
onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void;
|
onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void;
|
||||||
@@ -266,6 +273,7 @@ export class WorkflowGraphTaskRunner {
|
|||||||
seams: wrappedSeams,
|
seams: wrappedSeams,
|
||||||
primitives: wrappedPrimitives,
|
primitives: wrappedPrimitives,
|
||||||
runCustomNode: wrappedRunCustomNode,
|
runCustomNode: wrappedRunCustomNode,
|
||||||
|
prepareNodeExecution: this.deps.prepareNodeExecution,
|
||||||
maxRetriesPerNode: this.deps.maxRetriesPerNode,
|
maxRetriesPerNode: this.deps.maxRetriesPerNode,
|
||||||
branchPersistence: this.deps.branchPersistence,
|
branchPersistence: this.deps.branchPersistence,
|
||||||
branchSemaphore: this.deps.branchSemaphore,
|
branchSemaphore: this.deps.branchSemaphore,
|
||||||
|
|||||||
Reference in New Issue
Block a user