From 7eca99c8b1f92a9a01afb7370749599ad38601d0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 09:46:26 -0700 Subject: [PATCH] FN-7283: clear optional workflow steps in fast mode Fast mode now treats optional workflow steps as opt-out by default while preserving manual reselection. - Clear default-on optional workflow steps when switching task creation forms into Fast mode. - Submit explicit empty optional-step selections in Fast mode to prevent store defaults from reappearing. - Allow explicitly enabled optional workflow groups to run under Fast mode and during recovery. - Document the Fast-mode optional-step contract and add regression coverage for dashboard and engine flows. Files changed: .changeset/FN-7283-fast-mode-optional-steps.md | 7 ++ docs/workflow-steps.md | 15 ++- packages/dashboard/app/components/NewTaskModal.tsx | 27 +++-- .../dashboard/app/components/QuickEntryBox.tsx | 29 ++++- packages/dashboard/app/components/TaskForm.tsx | 42 +++++-- .../app/components/__tests__/NewTaskModal.test.tsx | 58 +++++++++ .../components/__tests__/QuickEntryBox.test.tsx | 84 +++++++++++++ .../app/components/__tests__/TaskForm.test.tsx | 85 ++++++++++++- .../__tests__/executor-fast-mode-workflows.test.ts | 134 +++++++++++++++++++++ packages/engine/src/executor.ts | 107 ++++++++++------ packages/engine/src/workflow-graph-executor.ts | 14 ++- 11 files changed, 539 insertions(+), 63 deletions(-) Fusion-Task-Id: FN-7283 Fusion-Task-Lineage: e7028af8-3d05-4db7-8db9-a16c7f7575de Co-authored-by: Fusion (runfusion.ai) --- .../FN-7283-fast-mode-optional-steps.md | 7 + docs/workflow-steps.md | 15 +- .../dashboard/app/components/NewTaskModal.tsx | 27 +++- .../app/components/QuickEntryBox.tsx | 29 +++- .../dashboard/app/components/TaskForm.tsx | 42 ++++-- .../__tests__/NewTaskModal.test.tsx | 58 ++++++++ .../__tests__/QuickEntryBox.test.tsx | 84 +++++++++++ .../components/__tests__/TaskForm.test.tsx | 85 ++++++++++- .../executor-fast-mode-workflows.test.ts | 134 ++++++++++++++++++ packages/engine/src/executor.ts | 107 +++++++++----- .../engine/src/workflow-graph-executor.ts | 14 +- 11 files changed, 539 insertions(+), 63 deletions(-) create mode 100644 .changeset/FN-7283-fast-mode-optional-steps.md diff --git a/.changeset/FN-7283-fast-mode-optional-steps.md b/.changeset/FN-7283-fast-mode-optional-steps.md new file mode 100644 index 0000000000..0d94045672 --- /dev/null +++ b/.changeset/FN-7283-fast-mode-optional-steps.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fast-mode tasks now clear optional steps by default while honoring manual selections. +category: fix +dev: Fast create surfaces submit explicit optional-step selections, and graph execution runs explicitly enabled optional groups even in fast mode. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index f213656ea8..a7ef857777 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -360,18 +360,21 @@ Workflows declare typed task fields via IR `fields: [{ id, name, type, required? FNXC:WorkflowOptionalGroup 2026-06-26-15:00: FN-7039 retired the declaration-based optional-steps model (`WorkflowOptionalStep` / the `optionalSteps` IR field). Optional quality gates are now first-class graph `optional-group` NODES; per-task persisted selections reuse `enabledWorkflowSteps` keyed by the group NODE ID (not a template id). Docs must describe the node model, not the deleted declaration facet. -FNXC:WorkflowOptionalGroup 2026-06-29-16:10: -Default-on optional groups are effective for execution and in-progress display even when a task has an empty persisted selection array. Keep edit-mode language separate because editors show the stored ids, not the default-augmented runtime set. +FNXC:FastOptionalSteps 2026-06-30-09:18: +Default-on optional groups apply only when `enabledWorkflowSteps` is omitted. An explicit persisted empty array means the operator disabled all optional groups (for example by selecting Fast during task creation), while explicit ids on a Fast task still run because manual selection is stronger than the fast default. + +FNXC:FastOptionalSteps 2026-06-30-10:48: +Fast creation surfaces submit explicit `enabledWorkflowSteps: []` even before optional-step metadata loads, so asynchronous dropdown loading cannot accidentally turn default-on gates back on. --> Optional quality gates are authored directly in the workflow graph as `optional-group` **nodes**. An `optional-group` node is a container (mirroring `foreach`/`loop`) whose `template` subgraph the executor runs **once** when the group is enabled for the task, and passes through (skips) when disabled. There is no iteration and no rework budget — a single pass — and rework edges inside the template are rejected by `validateOptionalGroup`. Node config (`WorkflowOptionalGroupConfig`): `{ name?, defaultOn?, maxRevisions?: number | "unbounded", phase?: "pre-merge" | "post-merge", template: { nodes, edges } }`. -- `defaultOn` contributes to the runtime/display effective enable set for the task; operators can still toggle persisted selections when creating or editing tasks. +- `defaultOn` contributes to the runtime/display effective enable set only when the task has no persisted `enabledWorkflowSteps` array; operators can still toggle persisted selections when creating or editing tasks. - `maxRevisions` optionally overrides the workflow/project `maxPostReviewFixes` budget for this one optional group's pre-merge fix → re-review loop. Use a non-negative integer for a bounded number of automatic fix passes, `0` to disable automatic fixes for that step, or `"unbounded"` to keep cycling until the step returns `APPROVE` / `APPROVE_WITH_NOTES`. When omitted, the step keeps the global `maxPostReviewFixes` behavior. - `phase` defaults to `"pre-merge"` (the prior, only behavior). `"post-merge"` marks a group the executor runs after a successful merge (see [Execution Phases](#execution-phases)). -- Persisted enable state lives on the per-task `enabledWorkflowSteps` array, keyed by the **group node id** (for example `browser-verification`, `code-review`). For execution and in-progress display, Fusion treats a group as enabled when its id is present in `enabledWorkflowSteps` **or** the workflow node has `defaultOn: true`; this preserves default-on gates even for tasks whose persisted array is empty. Edit-mode controls remain based on the persisted array so an operator can distinguish stored selections from workflow defaults. +- Persisted enable state lives on the per-task `enabledWorkflowSteps` array, keyed by the **group node id** (for example `browser-verification`, `code-review`). For execution, Fusion treats a group as enabled when `enabledWorkflowSteps` is present and includes the group id; if the field is omitted, Fusion falls back to the workflow node's `defaultOn: true`. An explicit empty array disables every optional group and prevents default-on gates from reappearing. Built-in optional gates ship as inlined IR builders, not as a template catalog: @@ -380,7 +383,7 @@ Built-in optional gates ship as inlined IR builders, not as a template catalog: - The `code-review` optional-group node (`builtin-code-review-group.ts`) is the inlined default-on code-review gate. On default `builtin:coding`, this is the only final review surface before merge; it is effective by default even when no explicit optional-step ids are stored. On `builtin:stepwise-coding`, it remains a post-foreach optional final review gate before the workflow's final review seam. - A workflow (for example compound-engineering) can add a **post-merge** optional-group node via the generic `postMergeOptionalGroupNode(...)` builder (`builtin-post-merge-group.ts`) — e.g. a `document` step that runs after merge. -Create-time optional-step controls appear in the quick-add action row and the **New Task** dialog inline quick buttons for the active workflow. They resolve the workflow's optional-group nodes (plus plugin-contributed palette templates, see [Plugin-Contributed Steps](#plugin-contributed-steps)) into toggleable rows. Workflows with no optional groups render no trigger, and the selected node ids are submitted through `enabledWorkflowSteps` when the task is created. Unknown or removed ids are skipped during resolution so stale selections never render blank controls or break workflow loading. +Create-time optional-step controls appear in the quick-add action row and the **New Task** dialog inline quick buttons for the active workflow. They resolve the workflow's optional-group nodes (plus plugin-contributed palette templates, see [Plugin-Contributed Steps](#plugin-contributed-steps)) into toggleable rows. Selecting **Fast** clears currently enabled optional steps and submits `enabledWorkflowSteps: []` even if optional-step metadata is still loading, but the dropdown stays available once loaded; any manual reselection before create is submitted as explicit ids and executes even on the Fast task. Workflows with no optional groups render no trigger and omit `enabledWorkflowSteps` unless the operator selects Fast, where the explicit empty array preserves the speed-first opt-out. Unknown or removed ids are skipped during resolution so stale selections never render blank controls or break workflow loading. ## What They Are @@ -409,7 +412,7 @@ An `optional-group` node's `phase` config selects one of two phases: Post-merge runs **graph-native**: after a successful merge the executor continues traversal to any post-merge optional-group node reachable from the merge region (and to plain post-merge nodes that follow a `seam:"merge"` node), running it via the same optional-group execution + recording path with `phase: "post-merge"` and non-blocking failures. This is gated by `experimentalFeatures.graphNativePostMerge`, which is **default-ON** and is now the single owner of post-merge execution — the legacy merger-owned post-merge path was deleted, so there is no fallback and post-merge work runs exactly once via the graph. -> **Note on Fast Mode:** When a task has `executionMode: "fast"`, pre-merge optional-group gates are bypassed entirely during executor completion on the workflow graph executor path (custom pre-merge prompt/script/gate validation nodes are skipped too). Post-merge steps remain active and run normally (post-merge is unaffected by execution mode). +> **Note on Fast Mode:** When a task has `executionMode: "fast"`, omitted/default optional groups are bypassed for speed and top-level custom pre-merge prompt/script/gate validation nodes are skipped. Explicitly selected optional groups still run their template prompt/script/gate nodes, so a Fast task with `enabledWorkflowSteps: ["browser-verification"]` runs Browser Verification. Post-merge steps remain active and run normally (post-merge is unaffected by execution mode). ## Execution Modes diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index e85181671c..ac9ca54bcb 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -21,7 +21,7 @@ import { Bot } from "lucide-react"; import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { SetupWarningBanner } from "./SetupWarningBanner"; import { LoadingSpinner } from "./LoadingSpinner"; -import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm"; +import { TaskForm, type BranchSelectionMode, type EnabledWorkflowStepsChangeMeta, type PendingImage } from "./TaskForm"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { REPO_OVERRIDE_RE } from "./githubTracking"; import { useConfirm } from "../hooks/useConfirm"; @@ -579,6 +579,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // Optional workflow steps the user opted into; TaskForm fetches + seeds these // from the selected workflow's defaultOn and lifts the enabled set up here. const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState([]); + const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false); const [reviewLevel, setReviewLevel] = useState(undefined); const [autoMerge, setAutoMerge] = useState(undefined); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); @@ -592,6 +593,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, */ const [executionMode, setExecutionMode] = useState<"standard" | "fast">("standard"); const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false); + /* + FNXC:FastOptionalSteps 2026-06-30-09:10: + New task create payloads must distinguish omitted optional-step intent (no controls/no workflow; allow store defaults) from explicit `[]` (operator chose Fast or deselected all; do not re-seed default-on groups) and non-empty manual selections. + + FNXC:FastOptionalSteps 2026-06-30-10:42: + Fast is itself explicit optional-step intent. Submit the current enabledWorkflowSteps array even before optional-step metadata finishes loading so default-on workflow gates cannot revive through an omitted field. + */ + const handleEnabledWorkflowStepsChange = useCallback((ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => { + setEnabledWorkflowSteps(ids); + setShouldSubmitEnabledWorkflowSteps(meta?.optionalStepsAvailable === true); + }, []); const [githubRepoOverride, setGithubRepoOverride] = useState(""); // Agent assignment state @@ -690,6 +702,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // Optional workflow steps the user toggled count as unsaved work. (Workflows // whose steps are defaultOn:false — today's only shipped step — seed an empty // set, so this stays false until the user actually opts a step in.) + shouldSubmitEnabledWorkflowSteps || enabledWorkflowSteps.length > 0 || executorModel !== "" || validatorModel !== "" || @@ -707,7 +720,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, githubTrackingEnabled || githubRepoOverrideTrimmed !== ""; setHasDirtyState(isDirty); - }, [description, dependencies, pendingImages, selectedWorkflowId, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); + }, [description, dependencies, pendingImages, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); const resetForm = useCallback(() => { // Clean up object URLs @@ -724,6 +737,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setPresetMode("default"); setSelectedWorkflowId(undefined); setEnabledWorkflowSteps([]); + setShouldSubmitEnabledWorkflowSteps(false); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); @@ -778,9 +792,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // - null → explicit "No workflow" (store skips default materialization) // - string → that workflow, materialized atomically at create time. ...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}), - // Optional steps the user toggled on (omit when none so the store keeps its - // default materialization behavior). - ...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}), + // Optional steps are omitted only when no controls were available. Fast always submits explicit []/ids so async metadata races cannot fall back to store defaultOn gates. + ...(shouldSubmitEnabledWorkflowSteps || executionMode === "fast" ? { enabledWorkflowSteps } : {}), ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, @@ -834,7 +847,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, resetForm(); addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success"); onClose(); - }, [executorModel, validatorModel, planningModel, thinkingLevel, dependencies, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]); + }, [executorModel, validatorModel, planningModel, thinkingLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); @@ -1143,7 +1156,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, selectedWorkflowId={selectedWorkflowId} onWorkflowIdChange={setSelectedWorkflowId} enabledWorkflowSteps={enabledWorkflowSteps} - onEnabledWorkflowStepsChange={setEnabledWorkflowSteps} + onEnabledWorkflowStepsChange={handleEnabledWorkflowStepsChange} pendingImages={pendingImages} onImagesChange={setPendingImages} tasks={tasks} diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index e27bfbb6b6..bd3c58757f 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -205,6 +205,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const [optionalSteps, setOptionalSteps] = useState([]); const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState([]); const [isFastMode, setIsFastMode] = useState(false); + const isFastModeRef = useRef(isFastMode); + useEffect(() => { + isFastModeRef.current = isFastMode; + }, [isFastMode]); const [githubTrackingOverride, setGithubTrackingOverride] = useState(null); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [nodeId, setNodeId] = useState(undefined); @@ -340,7 +344,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, .then((steps) => { if (cancelled) return; setOptionalSteps(steps); - setEnabledOptionalStepIds(steps.filter((step) => step.defaultOn).map((step) => step.templateId)); + /* + FNXC:FastOptionalSteps 2026-06-30-10:24: + Optional-step metadata can resolve after the operator has already selected Fast. Seed `[]` in that race so async defaultOn loading cannot undo Fast's speed-first opt-out; later dropdown clicks still add explicit selections normally. + */ + setEnabledOptionalStepIds(isFastModeRef.current ? [] : steps.filter((step) => step.defaultOn).map((step) => step.templateId)); }) .catch(() => { if (cancelled) return; @@ -361,6 +369,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, )); }, []); + /* + FNXC:FastOptionalSteps 2026-06-30-09:05: + Fast task creation is speed-first: switching standard → fast clears currently enabled optional workflow steps. The dropdown remains enabled so the operator can manually opt Browser Verification, Plan Review, Code Review, or a custom optional group back in before create. + + FNXC:FastOptionalSteps 2026-06-30-10:41: + A Fast create must submit explicit `[]` even if optional-step metadata has not loaded yet; otherwise the store/engine see `enabledWorkflowSteps` as omitted and re-seed default-on gates. + */ + const toggleFastMode = useCallback(() => { + setIsFastMode((prev) => { + const next = !prev; + if (next) setEnabledOptionalStepIds([]); + return next; + }); + }, []); + const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId); const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId); const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId); @@ -654,7 +677,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, FNXC:QuickAddWorkflowSteps 2026-06-29-01:31: Quick Add optional-step toggles are explicit task intent. When the workflow exposes optional steps and the user unchecks every one, submit an empty array instead of omitting the field so default-on Plan Review / Code Review do not reappear on the created task. */ - enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined, + enabledWorkflowSteps: isFastMode || optionalSteps.length > 0 ? enabledOptionalStepIds : undefined, ...(isFastMode ? { executionMode: "fast" } : {}), githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined, priority, @@ -1734,7 +1757,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,