FN-7990: share worktree classifier so Code Review acquires a worktree
Unify write-capability classification so graph preparation acquires a worktree for inline-fix Code Review before runtime runs, eliminating the immediate no-worktree-for-write-node failure. - Add shared workflowNodeRequiresWorktree helper for preparation and runtime - Plumb optional-group context and reviewerInlineFixes into graph preparation - Acquire/reuse/reacquire worktrees for write-capable inline review nodes - Keep Plan Review and disabled inline fixes read-only - Add regression tests and a patch changeset Files changed: .changeset/fn-7990-code-review-worktree.md | 7 ++ .../__tests__/ce-workflow-step-executor.test.ts | 97 ++++++++++++++++++++++ .../workflow-node-execution-needs.test.ts | 47 +++++++++++ packages/engine/src/executor.ts | 32 +++---- packages/engine/src/workflow-graph-executor.ts | 52 ++++++++---- .../engine/src/workflow-node-execution-needs.ts | 46 ++++++++++ 6 files changed, 243 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-7990 Fusion-Task-Lineage: f5d19181-0b98-4827-8adb-069f7dc05c03 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7990-code-review-worktree.md
Normal file
7
.changeset/fn-7990-code-review-worktree.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Prevent inline Code Review steps from failing before they can run.
|
||||||
|
category: fix
|
||||||
|
dev: Shares workflow write-capability classification between graph preparation and runtime.
|
||||||
@@ -357,6 +357,103 @@ describe("CE workflow-step executor integration", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["acquires an absent worktree", undefined, "/tmp/test/.worktrees/acquired-code-review", 1],
|
||||||
|
["reuses a live worktree", "/tmp/test/.worktrees/live-code-review", "/tmp/test/.worktrees/live-code-review", 0],
|
||||||
|
["reacquires a stale worktree", "/tmp/test/.worktrees/stale-code-review", "/tmp/test/.worktrees/acquired-code-review", 1],
|
||||||
|
])("prepares an inline-fix Code Review node when it %s", async (_scenario, existingWorktree, expectedWorktree, acquisitionCount) => {
|
||||||
|
mockedExistsSync.mockImplementation((path) => path !== "/tmp/test/.worktrees/stale-code-review");
|
||||||
|
const store = createMockStore();
|
||||||
|
let live = baseStepTask({
|
||||||
|
worktree: existingWorktree,
|
||||||
|
branch: existingWorktree ? "fusion/fn-ce-1" : undefined,
|
||||||
|
enabledWorkflowSteps: ["code-review"],
|
||||||
|
});
|
||||||
|
store.getTask.mockImplementation(async () => live as any);
|
||||||
|
store.updateTask.mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
live = { ...live, ...patch };
|
||||||
|
return live as any;
|
||||||
|
});
|
||||||
|
const { executor } = makeExecutor(store);
|
||||||
|
vi.spyOn(executor as any, "createWorktree").mockResolvedValue({
|
||||||
|
path: "/tmp/test/.worktrees/acquired-code-review",
|
||||||
|
branch: "fusion/fn-ce-1",
|
||||||
|
});
|
||||||
|
vi.spyOn(executor as any, "captureBaseCommitSha").mockResolvedValue(undefined);
|
||||||
|
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "APPROVE" });
|
||||||
|
const requirements: any[] = [];
|
||||||
|
const codeReview = {
|
||||||
|
id: "code-review-step",
|
||||||
|
kind: "gate",
|
||||||
|
config: { name: "Code Review", prompt: "Review the implementation." },
|
||||||
|
};
|
||||||
|
const ir: WorkflowIr = {
|
||||||
|
version: "v2",
|
||||||
|
name: "code-review-worktree-test",
|
||||||
|
columns: [{ id: "in-progress", name: "In Progress", traits: [] }],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start" },
|
||||||
|
{
|
||||||
|
id: "code-review",
|
||||||
|
kind: "optional-group",
|
||||||
|
config: { name: "Code Review", defaultOn: true, template: { nodes: [codeReview], edges: [] } },
|
||||||
|
},
|
||||||
|
{ id: "end", kind: "end" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "code-review" },
|
||||||
|
{ from: "code-review", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const settings = { ...(await store.getSettings()), reviewerInlineFixes: true };
|
||||||
|
const graph = new WorkflowGraphExecutor({
|
||||||
|
prepareNodeExecution: (graphNode, task, requirement) => {
|
||||||
|
requirements.push(requirement);
|
||||||
|
return (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(requirements).toContainEqual({ requiresWorktree: true, reason: "write-capable-node" });
|
||||||
|
expect((executor as any).createWorktree).toHaveBeenCalledTimes(acquisitionCount);
|
||||||
|
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executeStep.mock.calls[0]?.[2]).toBe(expectedWorktree);
|
||||||
|
expect(result).toMatchObject({ outcome: "success" });
|
||||||
|
expect(result.context["node:code-review:outcome"]).not.toBe("no-worktree-for-write-node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps disabled inline fixes and Plan Review read-only during graph preparation", async () => {
|
||||||
|
const requirements: any[] = [];
|
||||||
|
const graph = new WorkflowGraphExecutor({
|
||||||
|
prepareNodeExecution: (_node, _task, requirement) => { requirements.push(requirement); },
|
||||||
|
handlers: { gate: async () => ({ outcome: "success" }) },
|
||||||
|
});
|
||||||
|
const optionalGroup = (id: string, name: string) => ({
|
||||||
|
id,
|
||||||
|
kind: "optional-group" as const,
|
||||||
|
config: { name, defaultOn: true, template: { nodes: [{ id: `${id}-step`, kind: "gate" as const, config: { name } }], edges: [] } },
|
||||||
|
});
|
||||||
|
const ir: WorkflowIr = {
|
||||||
|
version: "v2",
|
||||||
|
name: "readonly-review-worktree-test",
|
||||||
|
columns: [],
|
||||||
|
nodes: [{ id: "start", kind: "start" }, optionalGroup("code-review", "Code Review"), optionalGroup("plan-review", "Plan Review"), { id: "end", kind: "end" }],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "code-review" },
|
||||||
|
{ from: "code-review", to: "plan-review", condition: "success" },
|
||||||
|
{ from: "plan-review", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await graph.run(baseStepTask({ enabledWorkflowSteps: ["code-review", "plan-review"] }) as any, {
|
||||||
|
experimentalFeatures: {},
|
||||||
|
reviewerInlineFixes: false,
|
||||||
|
}, ir);
|
||||||
|
expect(requirements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes a merge-confirmed workflow graph task that is stranded before done", async () => {
|
it("finalizes a merge-confirmed workflow graph task that is stranded before done", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
let live = baseStepTask({
|
let live = baseStepTask({
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkflowIrNode } from "@fusion/core";
|
||||||
|
import { workflowNodeRequiresWorktree } from "../workflow-node-execution-needs.js";
|
||||||
|
|
||||||
|
function node(overrides: Partial<WorkflowIrNode> = {}): WorkflowIrNode {
|
||||||
|
return { id: "node", kind: "prompt", ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workflowNodeRequiresWorktree", () => {
|
||||||
|
it.each([
|
||||||
|
["coding tool mode", node({ config: { toolMode: "coding" } })],
|
||||||
|
["script node", node({ kind: "script" })],
|
||||||
|
["named script", node({ config: { scriptName: "validate" } })],
|
||||||
|
["CLI command", node({ config: { executor: "cli", cliCommand: "pnpm lint" } })],
|
||||||
|
["CLI agent", node({ config: { executor: "cli-agent" } })],
|
||||||
|
])("requires a worktree for %s", (_name, workflowNode) => {
|
||||||
|
expect(workflowNodeRequiresWorktree(workflowNode)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["review name", node({ id: "review", config: { name: "Code Review" } }), undefined],
|
||||||
|
["verification name", node({ id: "verify", config: { name: "Browser Verification" } }), undefined],
|
||||||
|
["explicit inline fix config", node({ config: { reviewCanFixInline: true } }), undefined],
|
||||||
|
["code review optional group", node(), "code-review"],
|
||||||
|
["browser verification optional group", node(), "browser-verification"],
|
||||||
|
])("requires a worktree for inline fixes from %s", (_name, workflowNode, optionalGroupId) => {
|
||||||
|
expect(workflowNodeRequiresWorktree(workflowNode, { optionalGroupId })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inline-fix reviews read-only when disabled", () => {
|
||||||
|
expect(workflowNodeRequiresWorktree(node({ config: { name: "Code Review" } }), { reviewerInlineFixes: false })).toBe(false);
|
||||||
|
expect(workflowNodeRequiresWorktree(node(), {
|
||||||
|
optionalGroupId: "code-review",
|
||||||
|
reviewerInlineFixes: false,
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
node({ id: "plan-review-step", config: { name: "Code Review" } }),
|
||||||
|
node({ config: { name: "Plan Review" } }),
|
||||||
|
node(),
|
||||||
|
])("keeps Plan Review read-only", (workflowNode) => {
|
||||||
|
expect(workflowNodeRequiresWorktree(workflowNode, {
|
||||||
|
optionalGroupId: workflowNode.id === "node" ? "plan-review" : undefined,
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from "./workflow-node-handlers.js";
|
} from "./workflow-node-handlers.js";
|
||||||
import { MERGE_REGION_KINDS, WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND, WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY } from "./workflow-graph-executor.js";
|
import { MERGE_REGION_KINDS, WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND, WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY } from "./workflow-graph-executor.js";
|
||||||
import type { WorkflowNodePreparationRequirement, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
import type { WorkflowNodePreparationRequirement, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||||
|
import { workflowNodeRequiresWorktree } from "./workflow-node-execution-needs.js";
|
||||||
import type {
|
import type {
|
||||||
AuditPrimitiveInput,
|
AuditPrimitiveInput,
|
||||||
PreparedWorktree,
|
PreparedWorktree,
|
||||||
@@ -7687,34 +7688,23 @@ export class TaskExecutor {
|
|||||||
const rawCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim()
|
const rawCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim()
|
||||||
? cfg.cliCommand.trim()
|
? cfg.cliCommand.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const nodeNameForReviewDetection = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : node.id;
|
|
||||||
const isPlanReviewNode =
|
|
||||||
node.id === "plan-review-step"
|
|
||||||
|| nodeNameForReviewDetection === "Plan Review"
|
|
||||||
|| optionalGroupId === "plan-review";
|
|
||||||
const inlineFixesEnabledForNode = (settings as Settings & { reviewerInlineFixes?: boolean }).reviewerInlineFixes !== false;
|
|
||||||
const reviewTypeNode =
|
|
||||||
isPlanReviewNode
|
|
||||||
|| cfg.reviewCanFixInline === true
|
|
||||||
|| /(?:^|\b)(?:review|verification)(?:\b|$)/i.test(nodeNameForReviewDetection)
|
|
||||||
|| optionalGroupId === "code-review"
|
|
||||||
|| optionalGroupId === "browser-verification";
|
|
||||||
const inlineFixesMakeNodeWriteCapable =
|
|
||||||
inlineFixesEnabledForNode
|
|
||||||
&& executorKind !== "cli"
|
|
||||||
&& reviewTypeNode
|
|
||||||
&& !isPlanReviewNode;
|
|
||||||
|
|
||||||
// Isolation guard: write-capable nodes must run inside a task worktree, not
|
// Isolation guard: write-capable nodes must run inside a task worktree, not
|
||||||
// the shared repo root. Before the execute seam runs, live.worktree is unset
|
// the shared repo root. Before the execute seam runs, live.worktree is unset
|
||||||
// — a coding/script/CLI node falling back to this.rootDir would mutate the
|
// — a coding/script/CLI node falling back to this.rootDir would mutate the
|
||||||
// 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.
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowReviewers 2026-07-01-13:28:
|
FNXC:WorkflowReviewers 2026-07-15-00:00:
|
||||||
Inline-fix Code Review, Browser Verification, and custom review nodes become write-capable even when the workflow definition says `toolMode: readonly`, so the isolation guard must see that before selecting a worktree. Plan Review is excluded because it uses the narrow PROMPT.md writer instead of source-file write tools.
|
Inline-fix Code Review, Browser Verification, and custom review nodes become
|
||||||
|
write-capable even when their workflow definition says `toolMode: readonly`.
|
||||||
|
Use the shared classifier consumed by graph preparation so issue #2075 cannot
|
||||||
|
leave runtime requiring a worktree that preparation declined to acquire.
|
||||||
|
Plan Review remains excluded because it uses the narrow PROMPT.md writer.
|
||||||
*/
|
*/
|
||||||
const writeCapable = cfg.toolMode === "coding" || inlineFixesMakeNodeWriteCapable || node.kind === "script" || Boolean(scriptName) || Boolean(rawCliCommand);
|
const writeCapable = workflowNodeRequiresWorktree(node, {
|
||||||
|
optionalGroupId,
|
||||||
|
reviewerInlineFixes: (settings as Settings & { reviewerInlineFixes?: boolean }).reviewerInlineFixes,
|
||||||
|
});
|
||||||
const executionTarget = writeCapable ? await this.store.getTask(live.id) : live;
|
const executionTarget = writeCapable ? await this.store.getTask(live.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" };
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ import {
|
|||||||
} from "./workflow-graph-foreach.js";
|
} from "./workflow-graph-foreach.js";
|
||||||
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
|
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
|
||||||
import type { WorkflowNodeRunnerRegistry } from "./workflow-node-runner.js";
|
import type { WorkflowNodeRunnerRegistry } from "./workflow-node-runner.js";
|
||||||
|
import { workflowNodeRequiresWorktree } from "./workflow-node-execution-needs.js";
|
||||||
|
|
||||||
export type WorkflowNodeOutcome = "success" | "failure";
|
export type WorkflowNodeOutcome = "success" | "failure";
|
||||||
|
|
||||||
|
type WorkflowNodeSettings = Pick<Settings, "experimentalFeatures"> & {
|
||||||
|
reviewerInlineFixes?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowNodeAbortKind = "engine-pause";
|
export type WorkflowNodeAbortKind = "engine-pause";
|
||||||
|
|
||||||
export const WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY = "workflow:interruptedNodeId";
|
export const WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY = "workflow:interruptedNodeId";
|
||||||
@@ -79,7 +84,7 @@ export interface WorkflowTaskProjection {
|
|||||||
|
|
||||||
export interface WorkflowNodeExecutionContext {
|
export interface WorkflowNodeExecutionContext {
|
||||||
task: TaskDetail;
|
task: TaskDetail;
|
||||||
settings: Pick<Settings, "experimentalFeatures"> | undefined;
|
settings: WorkflowNodeSettings | undefined;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
/** Set during concurrent branch execution; fail-fast aborts via this signal.
|
/** Set during concurrent branch execution; fail-fast aborts via this signal.
|
||||||
* Undefined on the sequential path (zero behavior change for linear graphs). */
|
* Undefined on the sequential path (zero behavior change for linear graphs). */
|
||||||
@@ -339,7 +344,7 @@ export class WorkflowGraphExecutor {
|
|||||||
|
|
||||||
public async run(
|
public async run(
|
||||||
task: TaskDetail,
|
task: TaskDetail,
|
||||||
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Pick<Settings, "autoMerge">>) | undefined,
|
settings: (WorkflowNodeSettings & Partial<Pick<Settings, "autoMerge">>) | undefined,
|
||||||
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
|
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
|
||||||
): Promise<WorkflowGraphExecutorResult> {
|
): Promise<WorkflowGraphExecutorResult> {
|
||||||
const startNode = ir.nodes.find((node) => node.kind === "start");
|
const startNode = ir.nodes.find((node) => node.kind === "start");
|
||||||
@@ -1236,7 +1241,7 @@ export class WorkflowGraphExecutor {
|
|||||||
private async executeNodeWithRetries(
|
private async executeNodeWithRetries(
|
||||||
node: WorkflowIrNode,
|
node: WorkflowIrNode,
|
||||||
task: TaskDetail,
|
task: TaskDetail,
|
||||||
settings: Pick<Settings, "experimentalFeatures"> | undefined,
|
settings: WorkflowNodeSettings | undefined,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
workflow: WorkflowIr,
|
workflow: WorkflowIr,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@@ -1255,7 +1260,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);
|
await this.prepareNodeExecution(node, task, context, settings);
|
||||||
const progressRecord = recordProgress && this.shouldRecordNodeProgress(node)
|
const progressRecord = recordProgress && this.shouldRecordNodeProgress(node)
|
||||||
? await this.recordNodeProgressStart(task.id, node)
|
? await this.recordNodeProgressStart(task.id, node)
|
||||||
: null;
|
: null;
|
||||||
@@ -1395,23 +1400,36 @@ export class WorkflowGraphExecutor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async prepareNodeExecution(node: WorkflowIrNode, task: TaskDetail): Promise<void> {
|
private async prepareNodeExecution(
|
||||||
const requirement = this.classifyNodePreparation(node);
|
node: WorkflowIrNode,
|
||||||
|
task: TaskDetail,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
settings: WorkflowNodeSettings | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const requirement = this.classifyNodePreparation(node, context, settings);
|
||||||
if (!requirement.requiresWorktree) return;
|
if (!requirement.requiresWorktree) return;
|
||||||
await this.deps.prepareNodeExecution?.(node, task, requirement);
|
await this.deps.prepareNodeExecution?.(node, task, requirement);
|
||||||
}
|
}
|
||||||
|
|
||||||
private classifyNodePreparation(node: WorkflowIrNode): WorkflowNodePreparationRequirement {
|
private classifyNodePreparation(
|
||||||
const cfg = node.config ?? {};
|
node: WorkflowIrNode,
|
||||||
const executorKind = typeof cfg.executor === "string" ? cfg.executor : "model";
|
context: Record<string, unknown>,
|
||||||
const hasScriptName = typeof cfg.scriptName === "string" && cfg.scriptName.trim().length > 0;
|
settings: WorkflowNodeSettings | undefined,
|
||||||
const hasCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim().length > 0;
|
): WorkflowNodePreparationRequirement {
|
||||||
const requiresWorktree =
|
const optionalGroupId = typeof context[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY] === "string"
|
||||||
cfg.toolMode === "coding"
|
? context[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]
|
||||||
|| node.kind === "script"
|
: undefined;
|
||||||
|| executorKind === "cli-agent"
|
/*
|
||||||
|| hasScriptName
|
* FNXC:WorkflowExecution 2026-07-15-00:00:
|
||||||
|| hasCliCommand;
|
* Graph preparation receives the optional-group context and effective inline-fix
|
||||||
|
* setting so it applies the same classifier as runtime. Only an explicit false
|
||||||
|
* disables inline fixes, preserving the default-enabled review worktree contract
|
||||||
|
* that prevents issue #2075's pre-review no-worktree failure.
|
||||||
|
*/
|
||||||
|
const requiresWorktree = workflowNodeRequiresWorktree(node, {
|
||||||
|
optionalGroupId,
|
||||||
|
reviewerInlineFixes: settings?.reviewerInlineFixes,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
requiresWorktree,
|
requiresWorktree,
|
||||||
reason: requiresWorktree ? "write-capable-node" : undefined,
|
reason: requiresWorktree ? "write-capable-node" : undefined,
|
||||||
|
|||||||
46
packages/engine/src/workflow-node-execution-needs.ts
Normal file
46
packages/engine/src/workflow-node-execution-needs.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { WorkflowIrNode } from "@fusion/core";
|
||||||
|
|
||||||
|
export interface WorkflowNodeExecutionNeedsOptions {
|
||||||
|
optionalGroupId?: string;
|
||||||
|
/** Inline review fixes are enabled unless settings explicitly disable them. */
|
||||||
|
reviewerInlineFixes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:WorkflowExecution 2026-07-15-00:00:
|
||||||
|
* Issue #2075 exposed divergent worktree classifiers: graph preparation treated
|
||||||
|
* inline-fix reviews as read-only while runtime rejected them without a worktree.
|
||||||
|
* This pure helper is the single source of truth for write-capable workflow nodes;
|
||||||
|
* preparation and runtime must both use it before selecting an execution target.
|
||||||
|
*/
|
||||||
|
export function workflowNodeRequiresWorktree(
|
||||||
|
node: WorkflowIrNode,
|
||||||
|
{ optionalGroupId, reviewerInlineFixes }: WorkflowNodeExecutionNeedsOptions = {},
|
||||||
|
): boolean {
|
||||||
|
const cfg = node.config ?? {};
|
||||||
|
const executorKind = typeof cfg.executor === "string" ? cfg.executor : "model";
|
||||||
|
const scriptName = typeof cfg.scriptName === "string" && cfg.scriptName.trim()
|
||||||
|
? cfg.scriptName
|
||||||
|
: undefined;
|
||||||
|
const rawCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim()
|
||||||
|
? cfg.cliCommand
|
||||||
|
: undefined;
|
||||||
|
const nodeName = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : node.id;
|
||||||
|
const isPlanReview = node.id === "plan-review-step" || nodeName === "Plan Review" || optionalGroupId === "plan-review";
|
||||||
|
const isInlineFixReview = reviewerInlineFixes !== false
|
||||||
|
&& executorKind !== "cli"
|
||||||
|
&& !isPlanReview
|
||||||
|
&& (
|
||||||
|
cfg.reviewCanFixInline === true
|
||||||
|
|| /(?:^|\b)(?:review|verification)(?:\b|$)/i.test(nodeName)
|
||||||
|
|| optionalGroupId === "code-review"
|
||||||
|
|| optionalGroupId === "browser-verification"
|
||||||
|
);
|
||||||
|
|
||||||
|
return cfg.toolMode === "coding"
|
||||||
|
|| node.kind === "script"
|
||||||
|
|| executorKind === "cli-agent"
|
||||||
|
|| Boolean(scriptName)
|
||||||
|
|| Boolean(rawCliCommand)
|
||||||
|
|| isInlineFixReview;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user