Skip to content

fix(workflows): skip steps disabled in the builder at compile time - #2826

Open
nkuneman wants to merge 1 commit into
mainfrom
claude/skip-disabled-workflow-steps
Open

fix(workflows): skip steps disabled in the builder at compile time#2826
nkuneman wants to merge 1 commit into
mainfrom
claude/skip-disabled-workflow-steps

Conversation

@nkuneman

@nkuneman nkuneman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem

Reported by client: a model block toggled to Disabled in the Workflow builder still loads its weights onto the GPU at runtime. They have to manually evict it from the device.

Root cause: "Disable" is a UI-only flag stored at metadata.ui.nodes["$steps.<name>"].disabled. The builder strips disabled steps client-side (stripDisabledForExecution in WorkflowBuilderV2/services/disabledBlocks.ts) but only for preview/test-block runs. The persisted spec still contains the steps, and the execution engine has no concept of the flag, so every run by workflow_id (inference server, edge, Dedicated Deployments, serverless) compiles and executes disabled steps.

Fix

New compiler/disabled_steps.py, called in compile_workflow_graph right before parse_workflow_definition. It mirrors the builder's logic:

  • seed: steps flagged disabled: true under metadata.ui.nodes
  • cascade (a): a step whose required (no-default) field would be emptied by removing disabled refs is itself disabled
  • cascade (b): a step gated only by conditional-flow blocks (next_steps) that are all disabled is itself disabled
  • strip: drop disabled steps, remove dangling refs from surviving steps, drop outputs pointing at disabled steps

No-op (returns the same object) when nothing is disabled.

Tests

tests/workflows/unit_tests/execution_engine/compiler/test_disabled_steps.py (5 cases). Full compiler/ unit dir passes (86) in a python:3.12 container.

Open question

Alternative would be stripping server-side in the GET /:workspace/workflows/:workflowUrl API response. Went with the engine so it also covers inline specs posted to /workflows/run and doesn't change the public API's representation of a saved workflow. Happy to move it if the Workflows team prefers otherwise.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

👋 Thanks for the pull request! Here is how automated Claude review works here, so you spend credits (and reviewer time) wisely.

🚦 This PR is marked Ready for review, so automated Claude review will run — and every pass spends real credits.

Warning

💸 The Claude reviewer bills in credits, not vibes

Automated review spins up a real agent that reads real code and spends real credits on every pass. It is glad to help — but it is not a rubber duck, a linter you poke in a loop, or a substitute for reading the contributing guide. Treat it like an expensive senior reviewer whose time you booked, and show up prepared.

Draft when unsure, Ready when you mean it:

  • 🌱 Not sure the PR is in good shape yet? Keep it (or set it back) as a draft — drafts pause review, so you can push and iterate without burning credits on a moving target.
  • 💪 Feel strong about the contents? Mark it Ready for review and the reviewer will take a look.

However you get there, arrive prepared:

  • 🧱 Bring a SOLID, thorough PR. Point your local agent at our skills/ to tune it to our guidelines first — or, if you are one of those fabled carbon-based contributors, read them yourself. A half-baked diff costs exactly the same to review as a finished one.
  • Resolve every comment before you re-request review. Re-requesting with threads still open means paying twice for the same conversation.
  • 🔁 Do not use CI review as an inner loop for a local agent. The reviewer is not a step-by-step debugger — do the unfolding locally and arrive with the answer, not the search.
  • 🙋 If something looks off, ask a human. One question to a maintainer is cheaper and faster than three rounds of agent re-review chasing a misread.

Reviews are not free. A draft costs nothing to review; a Ready PR is a promise that it is worth reviewing.

  • Prefer to skip automated review entirely? Add the skip-claude-review label.

The Workflow builder stores 'disable block' as a UI-only flag at
metadata.ui.nodes["$steps.<name>"].disabled and strips such steps
client-side before preview runs. The persisted spec still contains them,
so any runtime fetching the workflow by id (inference server, edge, DD,
serverless) compiled and executed every step, loading model weights for
disabled model blocks.

Mirror the builder's stripDisabledForExecution in the execution engine:
drop manually disabled steps, cascade to steps whose required inputs or
conditional gates are all disabled, remove dangling references and
outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nkuneman
nkuneman force-pushed the claude/skip-disabled-workflow-steps branch from 1201e9c to 166b4ca Compare August 19, 2026 15:43
@nkuneman
nkuneman marked this pull request as ready for review August 19, 2026 22:22
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit 166b4caa3241eaea0be47f65b4712b9d3251cf0a.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

if manifest_class is None:
return False
for field_name, field_info in manifest_class.model_fields.items():
if field_name in RESERVED_STEP_KEYS or field_name == NEXT_STEPS_FIELD:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — disabling the only downstream target of a flow-control block makes the whole workflow fail to compile.

next_steps is excluded from the cascade-(a) check here, but it is not excluded from _strip_references (L185-197). _clean_value on a list that ends up empty returns _DELETE (L223), and _strip_references then drops the key entirely.

next_steps is a required field (no default) on every flow-control block that has it:

  • core_steps/flow_control/continue_if/v1.py:115
  • core_steps/flow_control/rate_limiter/v1.py:104
  • core_steps/flow_control/delta_filter/v1.py:93

Concrete failure — this is the mirror image of the scenario your own test covers:

steps: [
  {"type": "roboflow_core/continue_if@v1", "name": "gate", ..., "next_steps": ["$steps.second_model"]},
  {"type": "roboflow_core/roboflow_object_detection_model@v2", "name": "second_model", ...}
]
metadata: {"ui": {"nodes": {"$steps.second_model": {"disabled": true}}}}   // user disables the TARGET, keeps the gate
  • seed = {second_model}
  • cascade (a) for gate: condition_statement has no step refs, next_steps is skipped by this line → gate survives
  • cascade (b): gate has no control predecessors → survives
  • _strip_references(gate): ["$steps.second_model"] → empty list → _DELETEnext_steps key removed
  • parse_workflow_definition → pydantic field requiredWorkflowSyntaxError

So the user disables one block in the builder and the entire workflow stops running with a syntax error on every runtime that fetches it by id — strictly worse than the bug being fixed.

Same crash from a second trigger: _clean_value returns _DELETE for a collection that was already empty before stripping (L222-223 doesn't distinguish "emptied by us" from "empty to begin with"). A continue_if persisted with "next_steps": [] — explicitly documented as valid ("If empty, the branch terminates even when the condition is true", continue_if/v1.py:115) — will lose the key and fail to parse as soon as any step anywhere in the workflow is disabled. More generally, an explicitly-empty list/dict on a surviving step is silently replaced by the manifest default rather than preserved.

Suggested direction: when a cleaned collection becomes empty, keep []/{} instead of deleting when the field is required (or at minimum special-case next_steps[], which the blocks already handle as "terminate branch"), and only fall back to _DELETE for optional fields that were non-empty to begin with.

result: Dict[str, Set[str]] = {}
for step in steps:
source = _step_name(step)
if not source or NEXT_STEPS_FIELD not in step:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — cascade (b) misses switch_case@v1, so disabling a Switch Case makes its gated branches run unconditionally.

The control-predecessor map only looks at a field literally named next_steps. roboflow_core/switch_case@v1 routes control through cases: Dict[str, StepSelector] and default_next_steps (core_steps/flow_control/switch_case/v1.py:99 and :115) — neither is named next_steps.

Failure path with $steps.router (a Switch Case) disabled and on_red / on_blue reachable only through it:

  • seed = {router}, cascade (b) finds no control predecessors for on_red/on_blue (map is empty)
  • cascade (a) doesn't fire either — their required fields reference $inputs.image, not the router
  • router is stripped; on_red/on_blue survive with their data dependencies intact

Control flow only suppresses steps in the executor, so once the gate is gone both branches execute on every run. Before this PR they ran only on a matching case. That inverts the PR's stated goal: disabling a Switch Case now causes more model weights to be loaded and more branches to execute than before, on every runtime that fetches the workflow by id.

rate_limiter and delta_filter happen to be covered because they use the literal next_steps name — the map should be driven by the manifest's StepSelector-typed fields (you already have manifests_by_type available) rather than by a hardcoded field name, so any current or future flow-control block is picked up.

available_blocks=available_blocks,
profiler=profiler,
)
inlined_raw_workflow_definition = strip_disabled_steps(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — running after inline_inner_workflow_steps makes the fix a silent no-op for disabled Inner Workflow blocks.

inline_inner_workflow_steps replaces a roboflow_core/inner_workflow@v1 step named my_sub with its children, renamed to {inner}__{child} (inner_workflow/inline.py:75-92, _unique_prefixed_step_name). The my_sub step no longer exists in steps by the time strip_disabled_steps runs.

So for metadata.ui.nodes["$steps.my_sub"].disabled = true:

  • disabled = {"my_sub"} matches no surviving step name (my_sub__child, …) → nothing dropped
  • disabled_node_ids = {"$steps.my_sub"} matches no surviving reference either, because inlining already rewrote $steps.my_sub.<output> to the child selectors

Net effect: a user who disables an Inner Workflow block in the builder still gets every child step compiled and executed, weights included — exactly the reported symptom the PR is fixing. (The related gap: disabled flags stored in a child workflow's own metadata are never read, since only root-level metadata is consulted.)

Running the strip on raw_workflow_definition before inlining would handle the outer case naturally. If ordering has to stay as-is, the inner-workflow case needs explicit handling — and either way it needs a test, since nothing here fails loudly.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Skills: review-workflows-execution-engine, review-topic-backward-compat-and-versioning, review-topic-test-hygiene

The intent is sound and the module is readable, but the stripping rules diverge from the block manifests in ways that turn a "disable one block" action into either a hard compile failure or more execution than before. Three findings posted inline:

Severity Where What
High disabled_steps.py:137 next_steps is exempt from cascade (a) but not from _strip_references; it is a required field on continue_if / rate_limiter / delta_filter. Disabling the sole target of a flow-control block deletes the key and the whole workflow fails to parse (WorkflowSyntaxError). Also fires for an already-empty next_steps: [] as soon as anything else is disabled.
High disabled_steps.py:167 Cascade (b) keys off the literal field name next_steps, so switch_case@v1 (cases / default_next_steps) is invisible. Disabling a Switch Case orphans its branches, which then run unconditionally on every run - the opposite of the PR's goal.
Medium core.py:175 Runs after inner-workflow inlining, so disabling a roboflow_core/inner_workflow@v1 block is a silent no-op - its children keep compiling and loading weights.

A separate release-coordination comment covers the missing EE changelog entry.

Tests to add

The existing 5 unit cases all exercise strip_disabled_steps in isolation with hand-built definitions, which is why the two High findings pass CI. Suggested behavior-level coverage:

  1. Workflow compilation unit (tests/workflows/unit_tests/execution_engine/compiler/) - disable the target of a continue_if (not the gate) and assert the definition still parses; assert next_steps becomes [] rather than disappearing. Same for rate_limiter and delta_filter.
  2. Workflow compilation unit - a continue_if persisted with "next_steps": [] plus one unrelated disabled step; assert compilation succeeds.
  3. Workflow compilation unit - disable a switch_case@v1 router and assert its cases / default_next_steps targets are removed too.
  4. Workflow compilation unit - drive at least one scenario through compile_workflow_graph rather than strip_disabled_steps alone, so manifest validation is actually exercised end to end.
  5. Workflow execution integration (tests/workflows/integration_tests/execution/) - the skill's required companion for a compile-behavior fix: run a workflow whose model step is disabled and assert the step neither appears in the results nor is instantiated. This is also the only level that proves the reported symptom (weights resident on the GPU) is actually fixed.
  6. Workflow compilation unit - an inner-workflow scenario for the core.py:175 finding.
Minor doubts (non-blocking)
  • CompiledWorkflow.workflow_json keeps the pre-strip definition, so usage tracking (usage_tracking/decorator_helpers.py:188) still counts disabled steps as billed resources - flagging only because it is now inconsistent with what actually runs.
  • Partial stripping of a required Dict[str, Selector] (e.g. expression@v1.data, continue_if.evaluation_parameters) leaves the surviving step referencing an operand name that no longer exists; cascade (a) only fires when the field is emptied entirely.
  • All-steps-disabled produces steps: [] / outputs: []; I did not trace whether prepare_execution_graph handles that gracefully.

Commands that informed this review: gh pr diff 2826 --patch, gh pr view 2826, the three read-only gh api comment/review listings, plus reads of compiler/core.py, compiler/syntactic_parser.py, inner_workflow/inline.py, and the flow_control/{continue_if,rate_limiter,delta_filter,switch_case}/v1.py manifests.

Reviewed at HEAD: 166b4ca

@github-actions

Copy link
Copy Markdown
Contributor

📦 Release coordination — Execution Engine

Medium finding (contributor-actionable): this PR changes Execution Engine compile behaviorcompile_workflow_graph now rewrites the definition before parsing, so the same saved workflow JSON compiles to a different graph, produces a different set of output keys, and skips steps it previously executed. Per .cursor/rules/execution-engine-version-changelog.mdc that requires a user-facing entry under ## Unreleased in the EE changelog in the roboflow/docs repo (workflows/developer-guide/execution-engine-changelog.md). None was added.

The entry should be behavior-level, and should call out the compatibility edge explicitly, because it is a silent behavior change on upgrade for every already-saved workflow: any workflow that carries metadata.ui.nodes["$steps.<name>"].disabled = true will stop executing those steps and stop emitting their outputs after the upgrade, with no flag or opt-out. Downstream consumers of /infer/workflows/... that read those output keys will start seeing them absent rather than empty.

Please do not pick or bump a version — just add the ## Unreleased bullets.


@PawelPeczek-Roboflow @grzegorz-roboflow @dkosowski87 — maintainer notice (non-blocking, not a gate on the contributor):

Execution Engine requires a version bump for release. Compile behavior changed, so at release time the ## Unreleased entries move under the final ## Execution Engine \vX.Y.Z` | inference `vA.B.C`heading,EXECUTION_ENGINE_V1_VERSIONis updated, and the mirrored assertions intests/inference/integration_tests/test_workflow_endpoints.py::test_get_versions_of_execution_engineandtests/inference/hosted_platform_tests/test_workflows.py::test_get_versions_of_execution_engine` are updated with it.

inference-models is not affected by this PR.

Reviewed at HEAD: 166b4ca

@github-actions

Copy link
Copy Markdown
Contributor

@nkuneman

This PR is on hold pending your answers — the review will not advance to sign-off until the IMPORTANT questions below are answered.

Unanswered questions may keep this change out of a release.


1. Is metadata.ui.nodes actually inside the persisted specification blob that reaches the engine?

get_workflow_specification (inference/core/roboflow_api.py:1587-1709) returns workflow_config["specification"] verbatim and passes it to compile_workflow. Nothing in this repo carries a fixture, schema, or test with metadata.ui.nodes in it — the only reference is the new module — and the workflow definition model (syntactic_parser.py:105-119) ignores unknown top-level keys, so metadata would pass through silently whether or not it is populated. If the builder stores the UI graph as a sibling field on the workflow record (workflow_config["metadata"], a separate column, …) rather than inside specification, this fix is inert in production and every test in the PR would still pass, because they hand-build the definition.
What would change the outcome: a pointer to a real persisted payload (or platform code) showing specification.metadata.ui.nodes[...].disabled present — if it is not there, the whole approach has to move, which is exactly the trade-off your "Open question" section raises.

2. Does the builder's stripDisabledForExecution agree with this implementation on flow-control blocks?

Specifically: when a continue_if / rate_limiter / delta_filter has its only next_steps target disabled, does the builder emit next_steps: [] or drop the key? This implementation drops the key, which is a WorkflowSyntaxError server-side (see the inline comment on disabled_steps.py:137). And when a switch_case@v1 is disabled, does the builder cascade through cases / default_next_steps? This implementation does not (inline comment on disabled_steps.py:167).
What would change the outcome: if the builder cascades on any field referencing a disabled step rather than only fully-emptied required fields, the cascade rules here need to be rewritten to match, not just patched — preview and production diverging on which steps run is a worse failure than the original bug.

3. Is the silent, ungated behavior change on upgrade the intended rollout?

Every already-saved workflow with a disabled flag changes results the moment a runtime picks up this build: steps stop running and their output keys disappear from the response. There is no EE-version gate, no env flag, and no way for an operator to fall back.
What would change the outcome: if the intent is "this must be gated / staged", the change needs a flag or a version condition before merge; if the intent is "this is a bug fix and the old behavior was never contractual", say so and it stands as-is, with the changelog carrying the warning.


New commits are not auto-reviewed — add the claude-review label to request a re-review. The label is consumed when the review starts, so a fresh request is always a plain add.

Reviewed at HEAD: 166b4ca

@PawelPeczek-Roboflow PawelPeczek-Roboflow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR gets blocked as it looks it changes blocks disabled into compilation error in some cases.
Plus I insist on making disable flag core part of the syntax, not UI metadata treated so far as opaque.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants