Add optional workflow-step revision budgets that can cap or unbound remediation loops. - Add optional-group maxRevisions parsing, validation, exports, and docs. - Expose numeric and unbounded revision budget controls in the workflow node editor. - Apply per-step budgets in executor and self-healing fix loops with regression coverage. Files changed: .changeset/fn-7129-per-step-revision-budget.md | 7 ++ docs/dashboard-guide.md | 1 + docs/settings-reference.md | 2 +- docs/workflow-steps.md | 10 ++- .../__tests__/workflow-ir-optional-group.test.ts | 29 ++++++- packages/core/src/index.ts | 2 + packages/core/src/workflow-ir-types.ts | 37 +++++++++ packages/core/src/workflow-ir.ts | 15 ++++ .../app/components/WorkflowNodeEditor.tsx | 87 ++++++++++++++++---- .../__tests__/WorkflowNodeEditor.test.tsx | 95 ++++++++++++++++++++++ packages/engine/src/__tests__/self-healing.test.ts | 63 ++++++++++++++ .../workflow-graph-optional-group.test.ts | 30 ++++++- .../workflow-graph-optional-step-fix.test.ts | 53 ++++++++++++ packages/engine/src/executor.ts | 15 ++-- packages/engine/src/self-healing.ts | 64 +++++++++++---- packages/engine/src/workflow-graph-executor.ts | 6 +- 16 files changed, 469 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-7129 Fusion-Task-Lineage: dbde94e5-3c99-4f67-aed9-458563e845ae Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
60 KiB
Workflow Steps
Workflow steps are reusable quality gates that run around task completion.
Workflow overview
Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is Plan/Triage → Execute → Workflow steps → Review → Merge, but that path is now represented as a workflow selection rather than only as fixed engine behavior. A task with no explicit workflow resolves to builtin:coding; an explicit missing/corrupt custom workflow fails closed instead of silently falling back.
Selecting workflows
Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover and assign them with the workflow tools:
fn_workflow_list— list built-in and custom workflow definitions.fn_workflow_select— assign a workflow to the current or named task.workflow_idonfn_task_create/ delegation tools — create a task with a workflow already selected.
Agent-initiated workflow assignment is intentionally narrow: an agent may select or change a task's workflow only when the user explicitly requested that workflow, or when the agent created that task itself (for example by passing workflow_id to fn_task_create / delegation tools). Executors should not call fn_workflow_select to reroute the task they are currently executing unless that task's instructions or a user steering comment explicitly asks for the workflow change.
Decision-only or investigation tasks can also declare noCommitsExpected / **No commits expected:** true; that marker does not change workflow selection by itself. Tasks without an explicit workflow request or creator-owned workflow selection stay on the project default (builtin:coding).
Built-in workflow catalog
| Workflow | ID | Notes |
|---|---|---|
| Coding | builtin:coding |
Default coding lifecycle and fallback for tasks without an explicit selection. |
| Quick fix | builtin:quick-fix |
Short path for trivial or no-commit/decision work; omits the standard review stage. |
| Review-heavy | builtin:review-heavy |
Standard execute/review/merge path with an additional gated security review. |
| Marketing | builtin:marketing |
Content pipeline with custom Ideation, Backlog, Drafting, Editorial review, Published, and Archived columns plus structured marketing brief/draft/editorial prompts; drafts are persisted as task documents for review while the workflow reuses standard lifecycle traits and merge primitives. |
| Compound engineering | builtin:compound-engineering |
Plugin-gated workflow that invokes Compound Engineering skills for planning, work, review, PR/feedback, and learnings capture. |
| Stepwise coding | builtin:stepwise-coding |
Graph-executor workflow that models per-step parse/execute/review/rework explicitly. |
| Design | builtin:design |
UI-heavy work path that implements, persists a user-facing design preview task document, runs a gated design/UX review, then performs the standard review and merge. |
| PR lifecycle | builtin:pr-workflow |
Reusable PR lifecycle graph fragment (create PR → await review → respond → gate → merge); it is a fragment, not directly selectable as a task workflow. |
| Lead generation | builtin:lead-generation |
Selectable business workflow for sourcing, qualifying, enriching, and contacting leads with custom lead fields, stage columns, and reviewable enrichment/outreach task documents; requires the workflow graph executor for custom board columns. |
Custom workflow authoring
Use the dashboard Workflow Editor to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
Workflow graph integrity validation
Workflow definitions are validated through the same central IR gate before they can be saved, imported, AI-designed, selected/materialized for a task, or launched by the graph interpreter. Dashboard routes and workflow tools surface WorkflowIrError / WorkflowCompileError messages as author-facing validation failures instead of persisting partial definitions.
The enforced integrity classes include:
- exactly one
startnode and exactly oneendnode; - unique top-level node ids and unique column/field/setting ids;
- every top-level edge endpoint references a declared top-level node;
- no illegal non-rework cycles in DAG-required regions;
- required reachability/dominance rules: every required top-level node must be reachable from
start, andparse-stepsmust dominateforeach(source:"task-steps"); interpreter-owned recovery entry primitives remain valid even when they are re-entered from persisted runtime state instead of the author-facing start path; - valid node-specific references, including
parse-stepsartifacts,loop.exitWhen.nodeId, foreach/loop/optional-group template entry/exit references, and registered plugin workflow-extension keys.
At run time, WorkflowGraphTaskRunner resolves the selected built-in or custom workflow, re-runs this integrity validation before any seam, primitive, or custom-node side effect, and fails closed with an invalid-ir: <message> reason when the resolved IR is malformed. Once a node side effect has run, runtime failures keep the existing failed-run behavior rather than re-running the legacy pipeline.
Overriding built-in workflow prompts
Built-in workflow graph structure is still shipped and read-only: nodes, edges, columns, traits, executor configuration, and workflow setting declarations cannot be edited in place. Prompt-bearing nodes are the exception. In the workflow editor, select any prompt or gate node in a built-in workflow and edit its Prompt field to create a project-scoped override.
Prompt overrides are stored per (workflowId, nodeId, projectId). At runtime Fusion resolves the effective prompt as:
- the stored override for that workflow/node/project, when present and non-empty; otherwise
- the shipped prompt text from the built-in workflow IR.
This same overlay is used by the dashboard preview, seam prompt resolution during live task runs, synchronous workflow IR resolution used by lifecycle movement, and optional-group gate materialization for non-seam prompt/gate nodes. Empty or whitespace-only prompt edits are treated as reset/delete operations, never as blank prompts.
Use Reset to default on an overridden prompt to delete the stored override and return to the shipped built-in prompt. Duplicating a built-in remains the path when you need to change topology, columns, traits, settings declarations, or non-prompt configuration.
Workflow IR (v1)
Fusion also defines a separate Workflow Intermediate Representation (IR) contract in @fusion/core for editor↔interpreter graph exchange. The post-implementation quality gates documented on this page are themselves IR constructs now — optional-group graph nodes — rather than a separate execution subsystem. For the user-facing visual authoring surface, see the Workflow Editor guide.
Workflow IR v1 is a JSON-safe graph document:
schemaVersion: must be exactly"1.0.0"for v1 (WORKFLOW_IR_SCHEMA_VERSION)metadata: workflow-level JSON metadata (namerequired)nodes: node array with built-in kinds (start,prompt,script,gate,end)edges: directed links referencing node ids
Contract behavior:
- Parsing is strict: unsupported/missing versions, invalid node kinds, invalid shapes, and dangling edges are rejected at parse time.
- Serialization is stable JSON via
serializeWorkflowIr; round-trippingparseWorkflowIr(serializeWorkflowIr(ir))preserves data. BUILTIN_WORKFLOW_IR_FIXTUREprovides a complete built-in reference flow for parity testing.
Out of scope for v1:
- Plugin-contributed node kinds
- Layout/position metadata for editors
- Execution history/runtime traces
- Migration tooling for future schema versions (future versions should use explicit
schemaVersionmigrations)
Workflow Runtime
The workflow runtime is the authoritative execution path for task lifecycle work. WorkflowGraphExecutor owns graph traversal and routing; node handlers call runtime primitives supplied by TaskExecutor for side-effecting operations such as planning, coding sessions, review, step execution/reset, merge requests, transitions, and audit.
The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows.
The default built-in catalog entry builtin:coding is backed by the canonical BUILTIN_CODING_WORKFLOW_IR, which is also the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing/corrupt explicit custom selections fail closed as workflow-resolution failures instead of silently running the default. The built-in IR encodes the legacy lifecycle path as graph stages, with merge represented by workflow-native policy primitives rather than a single linear merge seam:
triage/planning→execute→optional-groupquality gates →review→merge-gate/ branch-group integration /merge-attempt/ retry or manual hold → post-mergeoptional-groupgates →end
builtin:stepwise-coding is a separate graph variant backed by BUILTIN_STEPWISE_CODING_WORKFLOW_IR; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure.
builtin:marketing is a non-coding content workflow with marketing-specific columns (ideation, backlog, drafting, editorial-review, published, archived) and prompt seams for content brief, draft, and editorial review. Its draft stage saves the primary content deliverable as a task document for human review, while the workflow uses the same lifecycle traits (intake, hold, wip, merge-blocker, human-review, complete, archived) and the same merge-gate/branch-group/merge-attempt primitive region as coding workflows, so scheduler, capacity, review blocking, and merge orchestration behavior remain standard.
During triage/planning sessions, agents can call fn_workflow_list to discover available built-in and custom workflows and read their descriptions before routing work. They can call fn_workflow_select only when the user explicitly requested a workflow or when selecting a workflow for a task they created, and they can pass workflow_id when creating child tasks with fn_task_create; decision-only or investigation tasks can also set noCommitsExpected / **No commits expected:** true when no code changes are expected. The built-in triage thresholds, decision-only verb list, and default routing IDs are workflow-native typed settings resolved from the selected workflow.
Runtime invariant criterion
Workflow-driven coding runs must preserve observable task transitions and reliability invariants: file-scope guards including FileScopeViolationError, squash/merge contract, recovery expectations, autoMerge:false terminal-until-merged, and moveTask(in-progress→todo) hard-cancel semantics.
For grouped branch flows (branch_groups), auto-merge precedence is split: per-task autoMerge controls member→group-integration landing, while group autoMerge controls group→default-branch promotion eligibility.
IR-gap reconciliation (v1)
The workflow redesign brief references agent-call nodes and typed edges (success|failure|conditional|fan-out-join), but shipped v1 IR only supports node kinds start|prompt|script|gate|end plus optional string edge condition.
Current reconciliation in v1:
agent-callsemantics are represented using existingpromptnodes withconfigfields (for example stage/role metadata).- Typed-edge semantics are represented using
conditiontoken conventions.
FN-5769 evaluated whether those conventions required a 1.1.0 schema bump and recorded the answer as no: the current prompt + config and canonical edge.condition token conventions are sufficient for the parity-critical interpreter rollout, so they remain the canonical v1 contract until a future consumer needs stronger schema-level validation or discoverability.
Workflow IR v2 — columns, traits, hold & split/join nodes
The workflowColumns track introduces IR v2 (version: "v2"), where a workflow additionally defines its own columns ({ id, name, traits: [{ trait, config }] }), places nodes in columns (node.column), and gains hold, split, and join node kinds. Columns become first-class, workflow-defined task state carrying composable traits (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the gateMode semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in docs/architecture.md § 9 "Workflow-defined columns & traits" and the Concepts glossary (column, trait, lane, hold node, split/join, default workflow, transitionPending). The whole v2 model is gated behind experimentalFeatures.workflowColumns; with the flag off, the v1 IR and the graph-native quality-gate model below are unchanged.
Workflow IR v2 — per-column agent assignment
A v2 column can optionally name a permanent agent from the agent registry, staffing every card that flows through it once instead of node-by-node or task-by-task. The binding is a first-class optional field on the column (not a trait — traits are board-transition policy; this is execution identity):
{ id: "review", name: "Review", traits: [],
agent: { agentId: "agent-001", mode: "defer" | "override" } }
Binding shape. agent.agentId is a non-empty registry agent id; agent.mode is defer or override. The field is omitted entirely when unset — a column with no agent key yields no binding, and the built-in default workflow carries none (it stays byte-identical, the parity oracle). Adding a binding forces the workflow to v2.
Which column governs. The binding keys off the node's declared IR column (node.column), never the task's current board lane. A node with no declared column resolves normally (no column agent), even when other columns carry override bindings.
defer vs override.
defer— the column agent is the default only when the work carries no agent/model settings of its own. "Own settings" is all-or-nothing: an own agent identity or a completemodelProvider+modelIdpair suppresses the column agent entirely. An incomplete model pair (provider with no model id) does not count as own settings, so the column agent still wins (matching the executor's both-present model rule). The column agent is never blended with own settings — filling only the missing half would create hybrid identities that are impossible to audit.override— the column agent supersedes node-level and task-level agent/model settings: identity, model, and persona.
Where it applies. The effective agent governs all session-running work attributable to the column's nodes: custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions. Raw CLI script nodes run no session, so the binding is a no-op there (the skip is audited). Every adoption is logged (running as column agent '<id>' (<mode>)) so the audit trail explains who ran and why.
Foreach template inheritance. A node inside a foreach template subgraph inherits the enclosing foreach node's column, unless the template node declares its own column (which then wins). Each per-step instance session is attributed to the resolved column agent.
Principal semantics. The effective column agent becomes the principal, not merely a model source. Action gating is computed for the agent actually running (a security boundary — never task.assignedAgentId when an override governs). Heartbeat serialization follows it in both directions: a column agent with allowParallelExecution=false is serialized like an assigned agent, the engine re-dispatches tasks whose effective column agent matches (not only assignedAgentId matches), and the heartbeat scheduler never lets a column agent heartbeat concurrently with its own override session. A workflow-definition edit or agent runtimeConfig change that re-keys the effective agent/model hot-swaps a running session, the same way a task.modelProvider change does today.
Missing-agent fallback. A missing or deleted agent at resolution time logs and falls back to normal resolution — a live session is never aborted because its column agent was deleted mid-flight.
Flag requirements. Column agents act only when both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor are on; with either off the binding is inert (config is still stored and round-trips — only execution is gated), and the editor surfaces that the picker is disabled with a tooltip naming both flags.
Write-time validation. Saving a workflow validates agent references: an unknown agentId is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (confirmPolicyEscalation) at save time, so override cannot silently re-key action gates to a more-privileged agent.
Workflow IR v2 — step inversion (foreach, loop, step-review, parse-steps, code, notify)
The step-inversion track makes task steps themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — run one step inside a task's session, and reset one step to its baseline — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by experimentalFeatures.workflowGraphExecutor. The default coding workflow is untouched and byte-identical (it keeps its monolithic execute seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in stepwise coding workflow.
parse-steps node — step list as graph structure
parse-steps reads a declared artifact and runs a named parser to write the canonical step list (Task.steps[]). Config: { artifact: <key>, parser: "step-headings" | "json-steps" | "plugin:<id>:<parser>" }.
- Built-in parsers:
step-headings(the### Step N:convention, extracted byte-identically from the legacy regex) andjson-steps(a[{ name, depends? }]JSON document). Plugins register additional parsers underplugin:<pluginId>:<parserId>. - Outcomes:
success,outcome:no-steps(parsed cleanly, zero steps — routable, defaults to success),outcome:parse-error(malformed artifact or a throwing/unavailable plugin parser — fail-closed, routable, defaults to failure). A plugin parser never crashes the run. - It is the only graph-side writer of the step list, and must dominate (precede on all paths) any
foreach(source:"task-steps")— a validator rule that prevents merging a task that reached the foreach before steps were parsed.
foreach node — a per-step template region
foreach instantiates an inline template subgraph once per planned step. Config:
{ source: "task-steps", template: { nodes, edges },
mode?: "sequential" | "parallel", // default sequential
isolation?: "shared" | "worktree", // default: shared (sequential), worktree (parallel)
concurrency?: number, // parallel only, 1..8, default 2
maxReworkCycles?: number } // default 3, cap 10
- The template has exactly one entry and one exit. A
step-executeseam node is legal only inside a foreach template;step-executemay not appear insplitbranches. - Expansion happens when the walk reaches the node; the step count is pinned at expansion and persisted (PROMPT.md edits afterward do not re-expand — a
pin-mismatchfailure surfaces if the live step list later disagrees on resume). - Zero steps → the foreach traverses its
successedge immediately (no merge blocker, matching today).
loop node — a bounded repeated template region
loop repeats an inline template subgraph until a configured output condition matches or a budget is exhausted. Config:
{ template: { nodes, edges },
exitWhen: {
type: "output-contains", value: string, nodeId?: string
} | {
type: "output-matches", pattern: string, flags?: string, nodeId?: string
},
maxIterations?: number, // default 3, cap 50
timeoutMs?: number } // default 300000, cap 3600000
- The template has exactly one entry and one exit. If
exitWhen.nodeIdis omitted, the loop tests the template exit node's output. - Loop templates may contain ordinary workflow nodes, but not nested
loop/foreachregions, foreach-onlystep-executeseam nodes, rework edges, or normal cycles. The repeated execution is represented by the loop node itself. - Success emits the normal
successoutcome and writesnode:<loopId>:loopcontext withiterations,exitReason: "matched",finalValue, and per-iteration history. - Exhausting
maxIterationsemitsfailurewith valueloop-iteration-exhausted; exceedingtimeoutMsemitsfailurewith valueloop-timeout. Authors can route those viaoutcome:loop-iteration-exhaustedoroutcome:loop-timeoutedges.
Parallel mode & the (depends:) annotation
mode and isolation are independent axes. parallel + shared is rejected (concurrent writers in one worktree are unguardable). Under worktree isolation each instance runs in its own worktree/branch off a common base, with an ordered integration stage that lands step branches in step order (done iff integrated); a rebase conflict routes outcome:integration-conflict (default: rework on the updated base, budget-counted).
Parallelism is opt-in per step by the planner, not asserted by the workflow author. A step depends on the previous step unless its PROMPT.md heading carries a (depends: N,M) annotation listing the 1-indexed steps it actually depends on — e.g. ### Step 3 (depends: 1): Title. An unannotated plan is fully sequential regardless of mode. Annotate conservatively: only mark a step independent when it genuinely does not read or modify the prior step's output, or heavily-overlapping "independent" steps will loop integrate→conflict→rework until the budget exhausts.
step-review node & rework edges
step-review ({ type: "plan" | "code", model? }, legal only inside a foreach template) runs the reviewer against the current instance's step and maps the verdict to outcome edges: outcome:approve (marks the step done), outcome:revise (typically a rework edge — revise in place, no reset), outcome:rethink (a rework edge whose traversal first triggers reset-to-baseline: git reset + session rewind + step→pending), outcome:unavailable (bounded retry then route). The validator requires approve and revise routed; rethink defaults to the revise target with reset semantics. Verdict authority is single-writer — review nodes inside split branches are advisory-only.
rework edges (edge.kind: "rework") are the only legal cycles: a loop-back within one foreach instance, bounded by maxReworkCycles. Exhaustion emits outcome:rework-exhausted (validator requires it routed — escalation, hold, or failure; defaults to failure). Non-rework cycles still throw.
code node — sandboxed TypeScript
code ({ source, timeoutMs? }, default 30s, cap 300s) runs inline TypeScript (compiled with esbuild, executed in a timeout-bounded child process with cwd = the task worktree) for logic no built-in node covers. The script default-exports async (ctx) => result where ctx = { task, steps, customFields, context, artifacts: { read(key) }, instance? } (instance present inside a foreach template). The returned { outcome?, value?, contextPatch?, customFields? } routes outcome:<value> edges, merges contextPatch into walk context, and writes customFields through the validated field authority. It gets no store handle, cannot write the step list, and a throw/timeout/non-zero exit becomes an audited failure. Source compile errors are rejected at save time (a dashboard 400 listing the failing node ids). It runs at the same trust tier as existing project-local script steps.
notify node — workflow-authored notifications
notify ({ event, title?, message? }) dispatches a notification through Fusion's active notification service and then always continues on the normal success path. event may be one of the standard notification events (for example in-review, merged, or failed), the built-in workflow-authored event workflow-notify, or a provider-specific custom event string. title and message are optional templates; the engine interpolates {{taskTitle}}, {{taskId}}, {{workflowName}}, and {{context:key}} from the workflow walk context.
Notification delivery is intentionally best-effort: a missing/unconfigured notification service, an empty event, or a provider delivery failure is logged/audited but does not fail the workflow node. Providers receive the rendered title/message in notification metadata so ntfy and webhook notifications can show workflow-specific copy. workflow-notify is not part of the default ntfy event allowlist; add it to ntfyEvents or the provider events filter when you want workflow-authored notifications delivered.
Workflow-defined custom task fields
Workflows declare typed task fields via IR fields: [{ id, name, type, required?, default?, options?, render? }] (type ∈ string | text | number | boolean | enum | multi-enum | date | url; options for enum kinds; render.placement ∈ card | detail | detail-section, render.widget, render.badge). Values live in tasks.customFields and are validated through a single store authority (updateTaskCustomFields) with typed rejections (offending fieldId + code). Editing or switching a workflow orphans (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via fn_task_update's custom_fields patch; authors set them via fn_workflow_create/update. Field values are surfaced in task/session context.
Workflow-declared optional steps (optional-group nodes)
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 } }.
defaultOnseeds the per-task enable set at task creation; operators can still toggle it.maxRevisionsoptionally overrides the workflow/projectmaxPostReviewFixesbudget 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,0to disable automatic fixes for that step, or"unbounded"to keep cycling until the step returnsAPPROVE/APPROVE_WITH_NOTES. When omitted, the step keeps the globalmaxPostReviewFixesbehavior.phasedefaults to"pre-merge"(the prior, only behavior)."post-merge"marks a group the executor runs after a successful merge (see Execution Phases).- Enable state lives on the per-task
enabledWorkflowStepsarray, keyed by the group node id (for examplebrowser-verification,code-review). The graph executor runs an optional-group node only when its id is present inenabledWorkflowSteps.
Built-in optional gates ship as inlined IR builders, not as a template catalog:
builtin:codingcarries thebrowser-verificationoptional-group node (builtin-browser-verification-group.ts), opt-in by default, so browser verification runs only for tasks whoseenabledWorkflowStepsincludesbrowser-verification.- The
code-reviewoptional-group node (builtin-code-review-group.ts) is the inlined code-review gate. - 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. adocumentstep 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) 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.
What They Are
A workflow step is a reusable quality gate (AI prompt or script) that can be enabled on tasks. Each gate is an optional-group node in the workflow graph; the graph executor runs it and records the outcome onto the task. There is no separate workflow-step execution engine, no workflow_steps table, and no step CRUD surface — everything is graph-native.
Common use cases:
- Documentation review
- QA/test verification
- Security scanning
- Performance checks
- Accessibility checks
- Browser-level verification
Execution Phases
An optional-group node's phase config selects one of two phases:
- Pre-merge (default): runs before merge/finalization; a gate failure blocks completion.
- Post-merge: runs after a successful merge; failure is logged/recorded but non-blocking.
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).
Execution Modes
- Prompt mode: starts an AI agent for the step
- Script mode: runs a named script from project settings (
settings.scripts)
Prompt mode can run with readonly or coding-capable tool access depending on step/template configuration.
Tool Modes
toolMode: "readonly" is enforced as a hard session-level allowlist. Readonly workflow-step agents can only access:
readgrepfindlsfn_web_fetchfn_task_showfn_task_listfn_insight_listfn_insight_showfn_list_agentsfn_get_agent_config
Readonly steps cannot hold edit, write, bash, or task/agent mutation tools. Attempts to use denied tools fail closed with READONLY_VIOLATION and are surfaced as a [readonly-violation] workflow-step failure outcome.
Use toolMode: "coding" for any prompt step that must modify files, run shell commands, or perform mutation actions.
Gate Modes
A gate node also has a gateMode:
gate: failures block merge/completion and follow normal remediation/retry flows.advisory: failures are recorded asadvisory_failureand shown as polish feedback, but never block merge.
Defaults:
- gates are
advisoryby default (advisory-by-default per FN-4368); opt in togateby setting the node'sgateModein the Workflow Editor.
Built-In Quality Gates
The built-in quality gates ship as inlined optional-group node builders in @fusion/core, not as a template catalog (the former WORKFLOW_STEP_TEMPLATES array was removed):
- Browser Verification (
browser-verification,builtin-browser-verification-group.ts) — browser-automation-style checks for UI validation flows; an optional-group node onbuiltin:coding. - Code Review (
code-review,builtin-code-review-group.ts) — the inlined code-review gate.
Plugin-contributed gate kinds (documentation review, QA, security audit, performance, accessibility, frontend UX design, etc.) can still be supplied as palette templates; see Plugin-Contributed Steps.
Built-in gate prompts emit the structured {"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."} envelope (final line JSON only). The legacy REQUEST REVISION prose path remains as a backward-compatible fallback. See Prompt-mode Structured Verdict Contract.
Plugin-Contributed Steps
Installed plugins can provide workflow step templates that act as the workflow editor's optional-group palette. GET /api/workflow-step-templates now serves plugin-contributed templates only (the built-in catalog was removed) and the editor projects each palette template into an optional-group node you drop into a workflow graph.
Plugin entries are labeled/grouped as plugin-contributed (including plugin attribution in the template metadata) so you can distinguish them from Fusion's inlined built-in gates.
A palette template still carries the same prompt or script mode, pre-merge or post-merge phase, and defaultOn behavior; once projected into a workflow it executes exactly like any other optional-group gate.
For plugin installation and authoring details, see the Plugin Authoring Guide (Section 16: Registering Workflow Steps).
Authoring a Custom Quality Gate
There is no longer a Settings → Workflow Steps manager or step CRUD form. To add a custom quality gate, open the Workflow Editor, duplicate a built-in (or edit a custom workflow), and add an optional-group node:
- Set the node's
name,defaultOn, andphase(pre-merge/post-merge). - Author the gate inside the node's
templatesubgraph asprompt/script/gatenodes (single pass, no rework edges). - Tasks enable the gate by its node id via
enabledWorkflowSteps(seeded fromdefaultOnand toggled at task creation).
Plugin palette templates (above) can be dropped in as a starting point instead of authoring a node from scratch.
Model Overrides for Prompt Steps
A prompt-mode gate node can set its own model with:
modelProvidermodelId
If both are set, node execution uses that model; otherwise it falls back to default model selection. Dashboard node summaries show that unpinned prompt-node state as Default model.
Default-On Behavior for New Tasks
optional-group nodes support defaultOn.
When defaultOn: true, the gate is preselected automatically for newly created tasks (users can still deselect it).
Workflow Step Revision Loop
A gate can request implementation revisions instead of just blocking completion. The revision/remediation flow runs through the graph executor, which calls the same executor revision primitives described below (the legacy runWorkflowSteps loop that previously owned this flow was deleted).
How It Works
Prompt-mode gate output is parsed in this order:
- Structured JSON verdict (
parseWorkflowStepVerdict) - Legacy prose fallback (
inferWorkflowStepVerdictFromProse) malformedwhen neither format can be interpreted
Structured Verdict Output
Use a JSON object with this schema:
{ "verdict": "APPROVE|APPROVE_WITH_NOTES|REVISE", "notes": "..." }
- Valid
verdictvalues are exactly:APPROVE,APPROVE_WITH_NOTES,REVISE. notesis optional and defaults to""when missing or non-string.- The parser checks fenced and inline JSON candidates, and the last valid candidate wins.
Accepted shapes:
- Fenced JSON block (supports both
andjson fences):
{"verdict":"REVISE","notes":"Fix auth lock handling in src/auth.ts."}
- Inline JSON object scanned from prose:
Review complete. {"verdict":"APPROVE_WITH_NOTES","notes":"Looks good; consider tightening error copy."}
Additional example:
{"verdict":"APPROVE"}
Prose Fallback
Legacy prose is still supported when structured JSON is missing:
- Output beginning with
REQUEST REVISION(case-insensitive) maps toREVISE.- Remaining prose becomes
notes. - If nothing follows, notes default to
"Revision requested".
- Remaining prose becomes
- Output containing one of these phrases maps to
APPROVEwith empty notes:approve,approved,looks good,no issues,out of scope.
For new workflow step prompts, prefer the structured JSON contract.
Malformed Output
If output matches neither structured JSON nor known prose fallback patterns, Fusion records the step output as malformed. Operationally, this means no workflow verdict could be inferred from that response. A malformed gateMode: "gate" prompt step is a blocking failure rather than an approval; a malformed gateMode: "advisory" step is recorded as advisory_failure and does not block completion.
Behavior
When a revision is requested:
- Fusion scope-checks any explicit file paths named in the feedback against the task's declared
## File Scope - In-scope feedback is appended to a Workflow Revision Instructions section in the task's
PROMPT.md - Explicitly out-of-scope feedback is forked into a dependent follow-up triage task instead of mutating the original task branch
- If both kinds are present, Fusion splits the feedback: the original task reruns only with the retained in-scope block while the follow-up captures the unrelated work
- If no in-scope feedback remains after splitting, the original task is left untouched and continues its normal completion path while only the follow-up task is created
- When the original task retains in-scope feedback, only the last implementation step is reopened and a fresh executor session is scheduled
Feedback Format
Recommended (structured JSON, prompt-mode):
{"verdict":"REVISE","notes":"[Clear, actionable description of what needs to be fixed]"}
Also valid for approvals:
{"verdict":"APPROVE","notes":""}
{"verdict":"APPROVE_WITH_NOTES","notes":"Optional non-blocking feedback"}
Legacy fallback (still supported via prose inference):
REQUEST REVISION
[Clear, actionable description of what needs to be fixed]
The revision block replaces any prior revision instructions (no accumulation).
By default this split-and-fork behavior is enabled through the project setting workflowRevisionForkOnScopeMismatch. Set it to false to restore the legacy behavior that appends all workflow revision feedback to the original task even when it references files outside the declared File Scope.
End-of-step file-scope invariant for prompt pre-merge steps (FN-4343)
Known follow-up (not yet on the graph path): The original FN-4343 per-step invariant ran after each successful prompt-mode pre-merge workflow step under the legacy
runWorkflowStepsloop. That loop was deleted in the graph-native cutover, and this per-step invariant has not yet been replicated on the optional-group graph path. TheworkflowStepScopeEnforcementsetting ("block"/"warn"/"off", default"block") is still declared and round-trips, but the graph executor does not currently enforce it per step. Merge-time File Scope enforcement (FileScopeViolationErrorand squash/file-scope overlap) is a separate gate and is unaffected — off-scope writes are still caught at merge.
The original (legacy) invariant evaluated files newly touched by a prompt-mode pre-merge step (committed delta plus uncommitted working-tree edits):
- If declared
## File Scopewas empty, the invariant was skipped. - If task
scopeOverride === true, it was bypassed (same semantics as merge-time scope enforcement). - If touched files had zero overlap with declared scope, it emitted a scope-leak log and applied
workflowStepScopeEnforcement("block"→ markfailed+ request revision;"warn"→ log and pass;"off"→ disable).
Executor fn_task_done scope-leak guard for Plan-Only tasks (FN-4482)
Fusion also enforces a completion-time scope-leak check in the executor fn_task_done path:
- Applies to tasks with declared
## File Scope. - Uses touched files from branch committed delta plus uncommitted working-tree edits at completion time.
- Emits
[scope-leak]activity-log entries when touched files are off-scope.- Off-scope touched-file and declared-scope lists are truncated to the first 10 entries with
… (+N more)when longer. - Log entries include
total off-scope=andtotal scope=counters so full list sizes remain explicit. - In
"block"mode, thefn_task_donerefusal message uses the same truncated off-scope preview.
- Off-scope touched-file and declared-scope lists are truncated to the first 10 entries with
- Honors
task.scopeOverride === trueas an explicit bypass.
planOnlyScopeLeakEnforcement controls Review Level 1 behavior:
"warn"(default): log and allow completion."block": refusefn_task_doneand ask the agent to revert off-scope paths."off": disable this completion-time guard.
Review Level 0 and >=2 run in warn-only telemetry mode (never block).
Hard Failures vs Revisions
Not all workflow failures are revision requests:
- Revision requested: Implementation needs changes → routes back to executor in-place while keeping the task in
in-progress - Hard failure: Treated as remediable until retries are exhausted; the executor injects feedback and sends the task through
todo → in-progressfor a fresh remediation pass
Pre-merge hard failure remediation flow
For pre-merge gate hard failures, the graph executor drives remediation through the executor primitives (gate-mode nodes):
- Retry the failing check up to
MAX_WORKFLOW_STEP_RETRIESwithin the same execution lifecycle - On retry exhaustion, add a steering comment with failure details and inject a
Workflow Step Failuresection intoPROMPT.md - Reopen only the last implementation step (
pending) so prior completed work remains preserved - Schedule
todo → in-progressafter guard unwind, triggering a fresh executor remediation run
Tasks are not parked in in-review for this remediable path unless additional terminal failures occur.
Workflow Interpreter Dual-Observe (retired parity instrumentation)
The workflow interpreter dual-observe seam is retired. experimentalFeatures.workflowInterpreterDualObserve is now inert: runtime feature helpers force it OFF even when stale persisted settings contain true, and Fusion must not invisibly re-enable shadow interpreter observation.
- Flag:
experimentalFeatures.workflowInterpreterDualObserve(retired; forced OFF) - Mode: strict no-op (no shadow run, no parity audit records)
- Historical behavior: earlier rollout builds compared legacy and interpreter observations plus comparable run-audit slices and emitted the parity audit records below
Historical run-audit events in the database domain:
workflow:parity-observed— emitted for an enabled parity check withmetadata.agreeworkflow:parity-drift— emitted when parity differed (or shadow execution failed), carryingmetadata.diffs
The parity contract is exported from @fusion/core (compareWorkflowRunObservations, compareWorkflowRunAudits) and produces deterministic drift reports shaped as { agree, diffs[] }, where each diff includes field name, legacy/interpreter values, category, and severity.
Authoritative cutover now depends on existing/current parity summary evidence, not on re-enabling dual-observe. The interpreter may become authoritative only when the separate experimentalFeatures.workflowInterpreterAuthoritative flag is ON and the cutover-readiness guard sees a populated parity summary with enough observed runs, zero summary drift, and zero unresolved parity reports.
Self-healing recovery for parked review tasks
If a task is found in in-review with failed pre-merge workflow results and no active executor, self-healing can auto-revive it (bounded by maxPostReviewFixes) by replaying the same remediation send-back flow.
During a live graph run, an enabled pre-merge optional step that returns REVISE (including the built-in Code Review / code-review and Browser Verification / browser-verification groups) sends the task back to the executor for a fix pass before the graph continues to review or merge. The workflow graph restarts on the next executor pass, so the optional step re-runs against the fixed diff; the cycle repeats until the step returns APPROVE / APPROVE_WITH_NOTES or the resolved revision budget is exhausted. By default, each step uses the workflow/project maxPostReviewFixes value (built-in default: 3 fix passes). A workflow author can override that for a specific optional-group with config.maxRevisions: a non-negative integer sets that step's ceiling, 0 disables automatic fixes for that step, and "unbounded" removes the ceiling check. The counter remains the task's shared postReviewFixCount; per-step counters are not maintained.
The same resolved per-step budget is used by self-healing when it revives an in-review task that is parked with a failed pre-merge workflow result. If the failed step's IR cannot be resolved, self-healing falls back to maxPostReviewFixes so existing behavior is preserved. "unbounded" relies on the optional step eventually approving; a step that always returns REVISE will continue cycling until a human intervenes or another guard (pause, worktree/lease, auto-merge policy, dependency blocker) stops recovery. When the budget is exhausted or disabled, behavior falls through to the prior semantics: advisory results remain non-blocking and gate failures remain failed/parked.
Post-merge optional groups never trigger this send-back path because merge has already happened; their failures are recorded/logged as non-blocking post-merge results.
Advisory failures are intentionally excluded from merge blocking and self-healing auto-revive after a task is already in review; their live-run fix pass is only the bounded pre-review remediation described above.
Viewing Results
Gate results are recorded on the task's workflowStepResults field (WorkflowStepResult[]), written by the graph executor and keyed by the optional-group node id. Each entry carries status, optional verdict, notes, output, and phase. The unified progress bar and the Workflow tab read this field directly.
The persisted status values are pending, passed, failed, advisory_failure, and skipped. The verdict→status mapping is: APPROVE / APPROVE_WITH_NOTES → passed; an advisory REVISE (success outcome) → advisory_failure (non-blocking); a gate REVISE or hard failure → failed. The UI derives an additional running display state from a pending entry that has a startedAt and no completedAt. Advisory failures (advisory_failure) are shown as polish feedback and never block merge; only failed blocks.
Workflow status is visible in multiple places:
- Task cards: gate checks are shown after normal implementation steps in the step list; each workflow row uses the compact
workflowbadge label (while still retaining pre/post-merge styling semantics) and progress counts include both implementation and workflow checks - List view (desktop + mobile): progress labels/bars use the same unified step model as task cards
- Task detail modal: includes a Workflow tab when workflow data exists
In the Workflow tab, you can inspect:
- pending/running/passed/advisory_failure/failed/skipped status
- outputs/findings
- timing metadata
Output Rendering
Workflow step outputs support both markdown rendering and plain text modes:
- Markdown mode (default): Renders output with proper markdown formatting including tables, code blocks, lists, and GFM extensions (task lists, strikethrough, etc.)
- Plain mode: Shows raw text without markdown interpretation
Toggle between modes using the "Markdown"/"Plain" button that appears when an output is expanded.
Expanded Output Viewer
For long outputs, click the expand icon (maximize) to open a larger viewer modal. The expanded view:
- Displays the full output in a modal overlay
- Supports the same markdown/plain toggle as the inline view
- Closes via the X button, backdrop click, or Escape key
- Syncs with the current render mode of the step
This makes it easier to read structured markdown output and long logs.
Prompt-mode Structured Verdict Contract
Prompt-mode workflow agents should emit a trailing JSON object:
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}
verdictandnotesare persisted onWorkflowStepResultwhen present.- Script-mode steps do not populate these fields.
- Backward compatibility remains for legacy prose-only responses via heuristic fallback (
REQUEST REVISIONand approval keywords). - If neither structured JSON nor fallback prose can be interpreted, output is recorded as
malformed(no inferable verdict). Malformed blocking gates fail closed; advisory gates recordadvisory_failurewithout blocking.
Workflow Graph Executor
Workflow graph execution is the task lifecycle runtime. TaskExecutor pins workflowGraphExecutor for the run and unselected tasks resolve to builtin:coding.
Default node dispatch:
prompt/scriptnodes withconfig.seamdispatch through workflow runtime primitives (planning,execute,review,merge,schedule,step-execute). The legacyworkflow-stepseam/primitive was removed in FN-7039 — quality gates are graph nodes now, and an IR node still declaringconfig.seam: "workflow-step"fails loudly rather than silently skipping.optional-groupnodes run their template subgraph once when enabled for the task (perenabledWorkflowSteps) and pass through when disabled; the executor records the outcome ontotask.workflowStepResultsstep-review,parse-steps,code,notify, and PR nodes use their dedicated primitive/dependency adaptersgatenodes evaluate context-key expectations or run configured executable checks
Traversal semantics:
- edge with no condition or
successroutes on success failureroutes on failureoutcome:<value>routes when the node result value matches exactly- unsupported conditions throw
WorkflowIrError - per-node retries are bounded and deterministic
- terminal success requires every workflow-declared task-document artifact key (
ir.artifacts[].key) to exist. No-artifact workflows keep the implicitPROMPT.mdparse-step default and do not require a task document.
Coverage includes lifecycle ordering, primitive invocation, merge/file-scope failure routing, and downstream halt behavior for hard-cancel/recovery style failures.
Workflow-native Cutover
TaskExecutor.execute() gives graph routing first claim. The graph runtime resolves a workflow selection, using builtin:coding for unselected/default tasks, failing closed for missing explicit custom workflows, and parking interpreter failures as workflow failures instead of re-running the old imperative lifecycle.
Authoritative node execution uses WorkflowRuntimePrimitives. The built-in coding workflow includes explicit planning and pre-merge gate nodes (as optional-group nodes such as browser-verification) before review/merge.
Reliability invariants preserved under authoritative mode:
- file-scope enforcement including
FileScopeViolationError - squash/file-scope overlap enforcement via
assertSquashOverlapsFileScope autoMerge: falseterminal-until-merged behavior inin-reviewmoveTask(in-progress → todo)hard-cancel semantics without strayuserPausedrebounds- existing self-healing routing and fail-soft fallback behavior
The interaction backstop lives in packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts.
Workflow Step APIs
The legacy workflow-step CRUD routes were removed — quality gates are graph nodes authored in the Workflow Editor, not REST-managed records. Only the plugin-contributed palette endpoints remain:
| Endpoint | Purpose |
|---|---|
GET /api/workflow-step-templates |
List plugin-contributed step templates (the editor's optional-group palette); the built-in catalog was removed, so this serves plugin templates only |
GET /api/plugin-workflow-step-templates |
Same plugin templates with full palette metadata (plugin attribution) |
Per-task gate enablement is not a REST CRUD surface: it is the enabledWorkflowSteps array on the task (optional-group node ids), set at create time and via task update.
Workflow Settings
Workflows can declare typed settings in their IR — the same authoring pattern as
custom task fields, one level up. A setting declaration carries { id, name, type, default?, options?, description? } with the type whitelist string | text | number | boolean | enum | multi-enum. Declarations are validated at save (unique ids, type
whitelist, options only for enum kinds, default validates against its own type).
Setting values persist per (workflowId, projectId) in a dedicated value table,
separate from the declarations: built-in workflows declare settings but their
declarations are non-editable, while their values are writable per project. The
engine resolves effective settings per task as stored value ?? declaration default, dropping any stored value that no longer validates against the current
declaration (drop-on-orphan) and falling back to the default.
The step-execution, review/approval, and per-phase model-lane knobs that
used to be project settings are now workflow settings declared by builtin:coding
with their former defaults. See
Settings Reference → Workflow Settings
for the full moved-key catalog, the editor walkthrough, and the export/sync posture.
Authoring surfaces:
- Workflow editor → Settings panel — Definitions (declarations/defaults) and Values (per-project) tabs.
- Agent tools —
fn_workflow_create/fn_workflow_updateacceptsettingsdeclarations;fn_workflow_settingsreads/writes values.
Screenshot
See also: Task Management and Settings Reference.
