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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 09:46:26 -07:00
parent b450dd493d
commit 7eca99c8b1
11 changed files with 539 additions and 63 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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<string[]>([]);
const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(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}

View File

@@ -205,6 +205,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState<string[]>([]);
const [isFastMode, setIsFastMode] = useState(false);
const isFastModeRef = useRef(isFastMode);
useEffect(() => {
isFastModeRef.current = isFastMode;
}, [isFastMode]);
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(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,
<button
type="button"
className={`btn btn-sm ${isFastMode ? "btn-primary" : ""}`}
onClick={() => setIsFastMode((prev) => !prev)}
onClick={toggleFastMode}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={isFastMode}
data-testid="quick-entry-fast-toggle"

View File

@@ -50,6 +50,9 @@ export interface PendingImage {
type TaskExecutionModeSelection = "standard" | "fast";
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new" | "shared-group";
export interface EnabledWorkflowStepsChangeMeta {
optionalStepsAvailable: boolean;
}
const PRESET_OPTION_SEPARATOR = "──────────";
@@ -106,7 +109,7 @@ export interface TaskFormProps {
// from the selected workflow's `defaultOn` and lifts the enabled set to the
// parent (which puts it in the create payload). Only active in create mode.
enabledWorkflowSteps?: string[];
onEnabledWorkflowStepsChange?: (ids: string[]) => void;
onEnabledWorkflowStepsChange?: (ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => void;
// Attachments
pendingImages: PendingImage[];
@@ -250,6 +253,10 @@ export function TaskForm({
(githubRepoOverride || "") !== "";
const [showDepDropdown, setShowDepDropdown] = useState(false);
const executionModeRef = useRef(executionMode);
useEffect(() => {
executionModeRef.current = executionMode;
}, [executionMode]);
const [showMoreOptions, setShowMoreOptions] = useState(
autoExpandMoreOptionsOnSelection ? hasInitialMoreOptions : false,
);
@@ -339,7 +346,7 @@ export function TaskForm({
// Clear any in-flight loading state (a prior fetch may have been cancelled
// mid-flight when switching to "No workflow"), so the loading row never sticks.
setOptionalStepsLoading(false);
onEnabledWorkflowStepsChange?.([]);
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
return;
}
setOptionalStepsLoading(true);
@@ -347,13 +354,19 @@ export function TaskForm({
.then((steps) => {
if (cancelled) return;
setOptionalSteps(steps);
// Re-seed the enabled set from each step's defaultOn on every workflow change.
onEnabledWorkflowStepsChange?.(steps.filter((s) => s.defaultOn).map((s) => s.templateId));
/*
FNXC:FastOptionalSteps 2026-06-30-10:25:
Optional-step fetches race user mode changes. When the latest mode is Fast, seed an explicit empty set after loading instead of defaultOn ids so async workflow metadata cannot re-enable optional gates the operator has not manually reselected.
*/
const seededSteps = executionModeRef.current === "fast"
? []
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
})
.catch(() => {
if (cancelled) return;
setOptionalSteps([]);
onEnabledWorkflowStepsChange?.([]);
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
})
.finally(() => {
if (!cancelled) setOptionalStepsLoading(false);
@@ -367,15 +380,26 @@ export function TaskForm({
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
/*
FNXC:FastOptionalSteps 2026-06-30-09:08:
Full-dialog Fast controls share one transition contract: entering fast mode clears currently enabled optional workflow steps exactly once, while the inline dropdown remains active so manual reselection is persisted as explicit create intent.
*/
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
onExecutionModeChange?.(nextMode);
if (nextMode === "fast") {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 });
}
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, optionalSteps.length]);
const toggleOptionalStep = useCallback(
(templateId: string) => {
const current = enabledWorkflowSteps ?? [];
const next = current.includes(templateId)
? current.filter((id) => id !== templateId)
: [...current, templateId];
onEnabledWorkflowStepsChange?.(next);
onEnabledWorkflowStepsChange?.(next, { optionalStepsAvailable: optionalSteps.length > 0 });
},
[enabledWorkflowSteps, onEnabledWorkflowStepsChange],
[enabledWorkflowSteps, onEnabledWorkflowStepsChange, optionalSteps.length],
);
const availablePresets = settings?.modelPresets || [];
@@ -956,7 +980,7 @@ export function TaskForm({
<button
type="button"
className={`btn btn-sm ${executionMode === "fast" ? "btn-primary" : ""}`}
onClick={() => onExecutionModeChange(executionMode === "fast" ? "standard" : "fast")}
onClick={() => handleExecutionModeChange(executionMode === "fast" ? "standard" : "fast")}
aria-pressed={executionMode === "fast"}
disabled={disabled}
data-testid="task-form-inline-fast"
@@ -1368,7 +1392,7 @@ export function TaskForm({
id="task-execution-mode"
data-testid="task-form-execution-mode-select"
value={executionMode}
onChange={(e) => onExecutionModeChange(e.target.value as TaskExecutionModeSelection)}
onChange={(e) => handleExecutionModeChange(e.target.value as TaskExecutionModeSelection)}
disabled={disabled}
>
<option value="standard">{t("taskForm.executionModeStandard", "Standard")}</option>

View File

@@ -866,6 +866,64 @@ describe("NewTaskModal", () => {
});
});
it("submits explicit empty optional steps when Fast is created before optional-step metadata loads", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockReturnValue(new Promise(() => undefined) as any);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast before metadata" } });
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-x", undefined));
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
});
it("persists explicit empty optional steps after Fast clears defaults and allows manual reselection", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task" } });
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
vi.mocked(props.onCreateTask).mockClear();
vi.mocked(props.onCreateTask).mockResolvedValue(makeTask("FN-002"));
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task with browser" } });
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
const nextTrigger = await screen.findByTestId("task-form-inline-optional-steps");
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.click(nextTrigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: ["browser-verification"] }),
);
});
});
it("renders no dropdown and omits enabledWorkflowSteps for 'No workflow'", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);

View File

@@ -1735,6 +1735,90 @@ describe("QuickEntryBox", () => {
});
});
it("clears default-on optional steps when Fast is selected and preserves manual reselection", async () => {
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP, MANUAL_STEP]);
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
const trigger = await screen.findByTestId("quick-entry-optional-steps-trigger");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Fast without optional steps" } });
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
});
it("submits explicit empty optional steps when Fast is created before optional-step metadata loads", async () => {
vi.mocked(fetchWorkflowOptionalSteps).mockReturnValue(new Promise(() => undefined));
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-explicit", TEST_PROJECT_ID));
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Fast before metadata" } });
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
});
it("keeps Fast-selected tasks empty when optional-step loading resolves after the toggle", async () => {
let resolveOptionalSteps: (steps: typeof DEFAULT_ON_STEP[]) => void = () => {};
vi.mocked(fetchWorkflowOptionalSteps).mockReturnValue(new Promise((resolve) => {
resolveOptionalSteps = resolve;
}));
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-explicit", TEST_PROJECT_ID));
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
await act(async () => {
resolveOptionalSteps([DEFAULT_ON_STEP]);
});
const trigger = await screen.findByTestId("quick-entry-optional-steps-trigger");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Fast before optional steps loaded" } });
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
});
it("keeps optional steps available for manual reselection after Fast clears defaults", async () => {
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP, MANUAL_STEP]);
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
const trigger = await screen.findByTestId("quick-entry-optional-steps-trigger");
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.click(trigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-manual-smoke"));
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Fast with manual step" } });
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenLastCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: ["manual-smoke"] }),
);
});
});
it("submits defaultOn optional steps via Enter key", async () => {
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useState } from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TaskForm } from "../TaskForm";
import type { Task, Column } from "@fusion/core";
@@ -214,6 +214,89 @@ describe("TaskForm", () => {
expect(options).toEqual(["standard", "fast"]);
});
it("clears optional workflow steps when inline Fast or execution-mode select enters fast mode", async () => {
const { fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
] as any);
const onExecutionModeChange = vi.fn();
const onEnabledWorkflowStepsChange = vi.fn();
const { rerender, props } = renderTaskForm({
executionMode: "standard",
onExecutionModeChange,
enabledWorkflowSteps: ["code-review"],
onEnabledWorkflowStepsChange,
});
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
expect(trigger).toHaveTextContent("Steps: 1 selected");
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
expect(onExecutionModeChange).toHaveBeenCalledWith("fast");
expect(onEnabledWorkflowStepsChange).toHaveBeenCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
rerender(
<TaskForm
{...props}
executionMode="fast"
enabledWorkflowSteps={[]}
onExecutionModeChange={onExecutionModeChange}
onEnabledWorkflowStepsChange={onEnabledWorkflowStepsChange}
/>,
);
expect(screen.getByTestId("task-form-inline-optional-steps")).toHaveTextContent("Steps: none");
fireEvent.click(screen.getByTestId("task-form-inline-optional-steps"));
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
expect(onEnabledWorkflowStepsChange).toHaveBeenLastCalledWith(["browser-verification"], expect.objectContaining({ optionalStepsAvailable: true }));
onExecutionModeChange.mockClear();
onEnabledWorkflowStepsChange.mockClear();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
expect(onExecutionModeChange).toHaveBeenCalledWith("fast");
expect(onEnabledWorkflowStepsChange).toHaveBeenCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
});
it("seeds no optional steps when loading resolves after Fast is selected", async () => {
const { fetchWorkflowOptionalSteps } = await import("../../api");
let resolveOptionalSteps: (steps: Array<{ templateId: string; name: string; phase: string; defaultOn: boolean }>) => void = () => {};
vi.mocked(fetchWorkflowOptionalSteps).mockReturnValue(new Promise((resolve) => {
resolveOptionalSteps = resolve;
}) as any);
const onExecutionModeChange = vi.fn();
const onEnabledWorkflowStepsChange = vi.fn();
const { rerender, props } = renderTaskForm({
executionMode: "standard",
onExecutionModeChange,
enabledWorkflowSteps: [],
onEnabledWorkflowStepsChange,
selectedWorkflowId: "wf-explicit",
});
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-explicit", undefined));
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
expect(onExecutionModeChange).toHaveBeenCalledWith("fast");
rerender(
<TaskForm
{...props}
executionMode="fast"
enabledWorkflowSteps={[]}
onExecutionModeChange={onExecutionModeChange}
onEnabledWorkflowStepsChange={onEnabledWorkflowStepsChange}
/>,
);
await act(async () => {
resolveOptionalSteps([{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true }]);
});
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
expect(onEnabledWorkflowStepsChange).toHaveBeenLastCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
});
it("calls onExecutionModeChange when execution mode selection changes", () => {
const onExecutionModeChange = vi.fn();

View File

@@ -199,6 +199,50 @@ describe("fast mode workflow/runtime invariants", () => {
expect(seams.merge).toHaveBeenCalledTimes(1);
});
it("fast builtin:coding executes explicitly selected optional-group template nodes", async () => {
const calls: string[] = [];
const prompt = "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files";
const taskSteps = [{ name: "Do the work", status: "pending" }];
const seams = {
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
stepExecute: vi.fn(async () => ({ outcome: "success", value: "step-done" })),
};
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
},
seams,
parseStepsDeps: {
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
writeSteps: async (target) => {
target.steps = taskSteps;
},
},
runCustomNode: vi.fn(async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success", value: "APPROVE" };
}),
});
const result = await runner.run(task({
id: "FN-7283",
executionMode: "fast",
enabledWorkflowSteps: ["browser-verification"],
prompt,
}), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toContain("browser-verification::browser-verification-step");
expect(calls).toContain("custom:browser-verification-step");
expect(result.visitedNodeIds).toContain("code-review");
expect(result.visitedNodeIds).not.toContain("code-review::code-review-step");
});
it("blocks fast builtin:coding merge when parsed implementation proof is missing", async () => {
const liveTask = task({
id: "FN-7271",
@@ -333,6 +377,30 @@ describe("fast mode workflow/runtime invariants", () => {
expect(executeScript).not.toHaveBeenCalled();
});
it.each(["prompt", "script", "gate"])("executes optional-group template %s nodes in fast mode", async (kind) => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" };
const result = await (executor as any).runGraphCustomNode(
{ id: `custom-${kind}`, kind, config },
task({ executionMode: "fast" }),
{},
undefined,
{ "workflow:optionalGroupActive": "browser-verification" },
);
expect(result).toMatchObject({ outcome: "success" });
if (kind === "script") {
expect(executeScript).toHaveBeenCalledTimes(1);
expect(executeStep).not.toHaveBeenCalled();
} else {
expect(executeStep).toHaveBeenCalledTimes(1);
expect(executeScript).not.toHaveBeenCalled();
}
});
it("does not bypass await-input custom graph nodes in fast mode", async () => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast" }));
const awaitInput = vi.spyOn(executor as any, "runAwaitInputNode").mockResolvedValue({ outcome: "success", value: "awaiting-input" });
@@ -354,6 +422,72 @@ describe("fast mode workflow/runtime invariants", () => {
// covered above by the custom-node tests ("skips custom %s nodes in fast mode")
// and by builtin-coding-workflow-step-results.test.ts (graph recording path).
it("re-enters graph recovery for fast completed tasks with unsatisfied explicit optional steps", async () => {
const liveTask = task({
id: "FN-7283-RECOVERY",
executionMode: "fast",
enabledWorkflowSteps: ["browser-verification"],
worktree: "/tmp/wt",
baseCommitSha: "base",
steps: [{ name: "Do it", status: "done" }],
workflowStepResults: [],
});
const { executor } = makeExecutorForTask(liveTask);
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
const graph = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
const recovered = await executor.recoverCompletedTask(liveTask as any);
expect(recovered).toBe(true);
expect(graph).toHaveBeenCalledWith(liveTask);
});
it("fails closed when a fast task has explicit optional steps but the store cannot resolve workflow selection", async () => {
const liveTask = task({
id: "FN-7283-MINIMAL-STORE",
executionMode: "fast",
enabledWorkflowSteps: ["browser-verification"],
worktree: "/tmp/wt",
});
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
const executor = new TaskExecutor(store, "/tmp/test") as any;
const graphFailure = vi.spyOn(executor, "handleGraphFailure").mockResolvedValue(undefined);
const handled = await executor.maybeExecuteWorkflowGraph(liveTask);
expect(handled).toBe(true);
expect(graphFailure).toHaveBeenCalledWith(liveTask, expect.objectContaining({
disposition: "failed",
outcome: "failure",
reason: expect.stringContaining("workflow-selection-api-unavailable"),
}));
});
it("skips graph recovery for fast completed tasks with no explicit optional steps", async () => {
const liveTask = task({
id: "FN-7283-RECOVERY-EMPTY",
executionMode: "fast",
enabledWorkflowSteps: [],
worktree: "/tmp/wt",
baseCommitSha: "base",
steps: [{ name: "Do it", status: "done" }],
workflowStepResults: [],
});
const { store, executor } = makeExecutorForTask(liveTask);
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
const graph = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
const recovered = await executor.recoverCompletedTask(liveTask as any);
expect(recovered).toBe(true);
expect(graph).not.toHaveBeenCalled();
expect(store.handoffToReview).toHaveBeenCalledWith(
"FN-7283-RECOVERY-EMPTY",
expect.objectContaining({ evidence: expect.objectContaining({ reason: "completed-task-recovered" }) }),
);
});
it("keeps fn_task_done mandatory while excluding fn_review_step in fast mode", async () => {
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
session: {

View File

@@ -40,7 +40,7 @@ import {
type ForeachActiveContext,
type WorkflowLegacySeams,
} 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, WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY } from "./workflow-graph-executor.js";
import type { WorkflowNodePreparationRequirement, WorkflowNodeResult } from "./workflow-graph-executor.js";
import type {
AuditPrimitiveInput,
@@ -3871,20 +3871,26 @@ export class TaskExecutor {
executorLog.log(`${task.id}: recovered ${modifiedFiles.length} modified files`);
}
// Run workflow steps before transitioning — skip in fast mode
if (task.executionMode !== "fast") {
if (areEnabledPreMergeWorkflowStepsSatisfied(liveForCompletenessCheck)) {
/*
FNXC:WorkflowLifecycle 2026-06-29-04:37:
Completed graph-owned tasks can be observed briefly as in-progress after
the main graph already recorded every enabled pre-merge gate. Recovery
must not restart the graph from parse in that state; foreach pins from
the completed run make parse fail with pin-mismatch. Hand off to review
instead, which is the same terminal seam the completed graph reached.
*/
executorLog.log(`${task.id}: completed recovery found satisfied workflow gates — skipping graph re-entry`);
} else {
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow-graph re-entry during completed-task recovery")) {
const enabledWorkflowStepsAlreadySatisfied = task.executionMode === "fast"
? areExplicitEnabledWorkflowStepsSatisfied(liveForCompletenessCheck)
: areEnabledPreMergeWorkflowStepsSatisfied(liveForCompletenessCheck);
const shouldReenterWorkflowGraph = task.executionMode === "fast"
? hasUnsatisfiedExplicitEnabledWorkflowSteps(liveForCompletenessCheck)
: !enabledWorkflowStepsAlreadySatisfied;
// Run workflow steps before transitioning — fast mode still honors explicit optional-step selections.
if (enabledWorkflowStepsAlreadySatisfied) {
/*
FNXC:WorkflowLifecycle 2026-06-29-04:37:
Completed graph-owned tasks can be observed briefly as in-progress after
the main graph already recorded every enabled pre-merge gate. Recovery
must not restart the graph from parse in that state; foreach pins from
the completed run make parse fail with pin-mismatch. Hand off to review
instead, which is the same terminal seam the completed graph reached.
*/
executorLog.log(`${task.id}: completed recovery found satisfied workflow gates — skipping graph re-entry`);
} else if (shouldReenterWorkflowGraph) {
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow-graph re-entry during completed-task recovery")) {
return false;
}
/*
@@ -3916,13 +3922,16 @@ export class TaskExecutor {
executorLog.log(`✓ ${task.id} auto-recovered completed task via workflow-graph re-entry`);
return true;
}
// Graph declined (minimal store WITHOUT the workflow-selection API and no
// enabled gates to run — a store WITH enabled steps would have been parked
// fail-closed above): there is nothing to gate, so fall through to the
// legacy in-review handoff below.
}
} else {
executorLog.log(`${task.id}: fast mode — skipping workflow steps on auto-recovery`);
// Graph declined (minimal store WITHOUT the workflow-selection API and no
// enabled gates to run — a store WITH enabled steps would have been parked
// fail-closed above): there is nothing to gate, so fall through to the
// legacy in-review handoff below.
} else if (task.executionMode === "fast") {
/*
FNXC:FastOptionalSteps 2026-06-30-12:00:
Fast recovery can hand off completed implementation directly only when the operator did not explicitly enable optional workflow steps, or when those enabled steps already have passed pre-merge results. Explicit optional selections are stronger than the fast default, so completed-task recovery must re-enter the workflow graph before review when any selected optional group is still unsatisfied.
*/
executorLog.log(`${task.id}: fast mode — no unsatisfied explicit workflow steps on auto-recovery`);
}
}
@@ -4493,7 +4502,10 @@ export class TaskExecutor {
Graph execution is the default for production TaskStore implementations, which expose workflow-selection APIs. Minimal test stores and older embedded adapters can lack that API; fall back to the legacy executor instead of half-entering graph routing with no workflow persistence surface.
FNXC:WorkflowExecution 2026-06-25-00:00:
U4 (KTD-2/KTD-5) FAIL-CLOSED. The legacy `runWorkflowSteps` execution path was deleted; the graph is now the sole workflow-step executor. A store without `getTaskWorkflowSelection` can no longer reach a legacy executor that runs the enabled pre-merge gates. If we returned `false` here for a task that has enabled workflow steps, execute() would proceed and SILENTLY SKIP every gate (the exact FN-7039 silent-skip class) before handing off to review. So when the task has enabled pre-merge workflow steps (and is not fast mode, which intentionally skips them), park the task as a workflow failure instead — loud, never silent. Tasks with NO enabled steps have nothing to gate, so they keep the legacy implementation path (no behavior change), which is what minimal test stores exercise.
U4 (KTD-2/KTD-5) FAIL-CLOSED. The legacy `runWorkflowSteps` execution path was deleted; the graph is now the sole workflow-step executor. A store without `getTaskWorkflowSelection` can no longer reach a legacy executor that runs the enabled pre-merge gates. If we returned `false` here for a task that has enabled workflow steps, execute() would proceed and SILENTLY SKIP every gate (the exact FN-7039 silent-skip class) before handing off to review. So when the task has enabled pre-merge workflow steps, park the task as a workflow failure instead — loud, never silent. Tasks with NO enabled steps have nothing to gate, so they keep the legacy implementation path (no behavior change), which is what minimal test stores exercise.
FNXC:FastOptionalSteps 2026-06-30-09:45:
Fast mode only clears optional workflow steps by default; explicit `enabledWorkflowSteps` remains operator intent. Minimal or older stores that cannot resolve the graph must fail closed for non-empty explicit selections, even in fast mode, rather than falling through and silently skipping the selected optional-group body.
*/
let liveForGate: Task | null = null;
try {
@@ -4503,7 +4515,7 @@ export class TaskExecutor {
}
const gateTask = liveForGate ?? task;
const hasEnabledSteps = (gateTask.enabledWorkflowSteps?.length ?? 0) > 0;
if (hasEnabledSteps && gateTask.executionMode !== "fast") {
if (hasEnabledSteps) {
await this.handleGraphFailure(task, {
disposition: "failed",
outcome: "failure",
@@ -4603,8 +4615,8 @@ export class TaskExecutor {
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)),
runCustomNode: (node, nodeTask, context) =>
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id), context),
publishTaskProjection: async (taskId, patch) => {
await this.store.updateTaskAtomic(taskId, (liveTask) => {
const update: Parameters<TaskStore["updateTask"]>[1] = {};
@@ -6898,6 +6910,7 @@ export class TaskExecutor {
nodeTask: TaskDetail,
settings: Settings,
columnBinding?: WorkflowColumnAgent,
graphContext?: Record<string, unknown>,
): Promise<WorkflowNodeResult> {
const cfg = node.config ?? {};
let live = await this.store.getTask(nodeTask.id);
@@ -6963,7 +6976,14 @@ export class TaskExecutor {
// WorkflowStep executions below, so skip them here before worktree or CLI
// approval gates can fire. Human waits (`awaitInput`) and implementation
// CLI-agent nodes are handled above and remain enforced.
if (live.executionMode === "fast" && !cfg.seam && (node.kind === "prompt" || node.kind === "script" || node.kind === "gate")) {
const optionalGroupId = typeof graphContext?.[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY] === "string"
? graphContext[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]
: undefined;
/*
FNXC:FastOptionalSteps 2026-06-30-09:14:
Fast skips top-level custom prompt/script/gate review bodies by default, but an enabled optional-group template is explicit operator intent. The graph marks those template nodes so Browser Verification and custom optional groups still run under fast mode.
*/
if (live.executionMode === "fast" && !optionalGroupId && !cfg.seam && (node.kind === "prompt" || node.kind === "script" || node.kind === "gate")) {
executorLog.log(`${live.id}: fast mode — skipping custom graph node '${node.id}'`);
await this.store.logEntry(
live.id,
@@ -17359,6 +17379,30 @@ function hasNonTerminalWorkflowSteps(task: Pick<TaskDetail, "steps">): boolean {
return task.steps.length > 0 && task.steps.some((step) => step.status !== "done" && step.status !== "skipped");
}
function workflowStepResultPassed(task: Pick<Task, "workflowStepResults"> | undefined, workflowStepId: string): boolean {
const results = task?.workflowStepResults ?? [];
return results.some((result) =>
result.workflowStepId === workflowStepId
&& result.phase === "pre-merge"
&& result.status === "passed",
);
}
function areExplicitEnabledWorkflowStepsSatisfied(
task: Pick<Task, "enabledWorkflowSteps" | "workflowStepResults"> | undefined,
): boolean {
const enabled = task?.enabledWorkflowSteps;
if (!Array.isArray(enabled) || enabled.length === 0) return false;
return enabled.every((id) => workflowStepResultPassed(task, id));
}
function hasUnsatisfiedExplicitEnabledWorkflowSteps(
task: Pick<Task, "enabledWorkflowSteps" | "workflowStepResults"> | undefined,
): boolean {
const enabled = task?.enabledWorkflowSteps;
return Array.isArray(enabled) && enabled.length > 0 && !areExplicitEnabledWorkflowStepsSatisfied(task);
}
function areEnabledPreMergeWorkflowStepsSatisfied(
task: Pick<Task, "enabledWorkflowSteps" | "workflowStepResults"> | undefined,
): boolean {
@@ -17376,14 +17420,7 @@ function areEnabledPreMergeWorkflowStepsSatisfied(
: ["plan-review", "code-review"];
if (enabledPreMerge.length === 0) return false;
if (Array.isArray(enabled) && enabledPreMerge.length !== enabled.length) return false;
const results = task?.workflowStepResults ?? [];
return enabledPreMerge.every((id) =>
results.some((result) =>
result.workflowStepId === id
&& result.phase === "pre-merge"
&& result.status === "passed",
),
);
return enabledPreMerge.every((id) => workflowStepResultPassed(task, id));
}
function preservePreExecutionWorkflowStepResults(task: Pick<Task, "workflowStepResults" | "log">): CoreWorkflowStepResult[] {

View File

@@ -47,6 +47,7 @@ export type WorkflowNodeAbortKind = "engine-pause";
export const WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY = "workflow:interruptedNodeId";
export const WORKFLOW_INTERRUPTED_NODE_ABORT_KIND_CONTEXT_KEY = "workflow:interruptedNodeAbortKind";
export const WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY = "workflow:optionalGroupActive";
export const WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND: WorkflowNodeAbortKind = "engine-pause";
export interface WorkflowNodeResult {
@@ -683,8 +684,17 @@ export class WorkflowGraphExecutor {
const groupResult = await runOptionalGroup(node, {
context,
runTemplateNode: (tNode, sig, contextOverride) =>
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false),
runTemplateNode: (tNode, sig, contextOverride) => {
/*
FNXC:FastOptionalSteps 2026-06-30-09:12:
Optional-group template execution carries the parent group id in context so fast mode can skip only top-level review/validation gates. Once an operator explicitly enables an optional group, that selection is stronger than the fast default and its prompt/script/gate body must run.
*/
const optionalGroupContext = {
...(contextOverride ?? context),
[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]: node.id,
};
return this.executeNodeWithRetries(tNode, task, settings, optionalGroupContext, ir, sig, false);
},
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
signal: this.deps.signal,
});