fix(workflows): namespace nested descendant step ids in loops/fan-out - #4338
fix(workflows): namespace nested descendant step ids in loops/fan-out#4338Noor-ul-ain001 wants to merge 4 commits into
Conversation
`while`/`do-while` loop bodies and `fan-out` templates namespace nested
step ids per iteration/item so logs and `state.step_results` entries stay
unique — but the namespacing only rewrote the id of the *immediate* child
step, not any descendant nested deeper (e.g. a `shell` step inside an `if`
inside a `while` body, or inside a `fan-out` template's `if`/`switch`
branch). That grandchild kept its bare, unnamespaced id across every
iteration/item, so each iteration/item silently overwrote the previous
one's entry in `state.step_results` under that same key — only the last
iteration's or item's result for that nested step ever survived, and no
per-iteration/per-item record of it ever existed.
This is also a correctness gap beyond bookkeeping: nested/template step ids
are deliberately exempted from the workflow's global id-uniqueness
validation, on the assumption that runtime namespacing makes any collision
safe. Since only the top-level child was actually namespaced, a step
nested one level deeper could collide with an unrelated step of the same
id elsewhere in the workflow and silently overwrite its result.
Fix: add `_rename_step_tree_ids`, which recursively rewrites every id in a
step's subtree (walking `then`/`else`/`steps`/`default`/`cases.*` — the
same nesting keys `overlays/merge.py` walks for step-tree attribution) and
returns a `{new_id: original_id}` map. Both the while/do-while loop body
and fan-out's `run_item` now use this helper instead of renaming only the
top-level id, and alias every renamed descendant's result back to its
original id (mirroring the existing single-level aliasing) so sibling
steps within the same iteration/item and code reading `steps.<id>.output`
after the loop/fan-out still see that iteration's/item's value.
## Test plan
- Added `test_while_loop_namespaces_nested_descendant_steps` and
`test_fan_out_namespaces_nested_descendant_steps` to
`tests/test_workflows.py::TestWorkflowEngine`: a `shell` step nested
inside an `if` inside a `while` body (and inside a `fan-out` template)
gets a distinct namespaced `state.step_results` entry per
iteration/item, while the unprefixed key still holds the latest value.
- Verified both fail without the fix (test-the-test): the namespaced keys
(`retry-loop:leaf:1`, `fan:leaf:0`, etc.) were simply absent, and
`step_results` only ever held the last iteration's/item's bare-keyed
entry — reproducing the exact bug.
- Ran the full `tests/test_workflows.py` suite: 926 passed, 20 pre-existing
Windows symlink-elevation failures (need admin rights, unrelated to this
change), 7 skipped. All `While`/`DoWhile`/`FanOut`/`FanOutConcurrency`
tests pass, including the concurrent-execution and per-thread context
isolation tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9
There was a problem hiding this comment.
🟡 Changes recommended
Initial loop iterations remain unnamespaced, while delayed and shared aliases break sibling references and concurrent fan-out isolation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds recursive runtime namespacing for nested workflow steps in loops and fan-out templates.
Changes:
- Adds recursive descendant ID rewriting and aliasing.
- Adds while and fan-out regression tests.
- Preserves namespaced execution results.
File summaries
| File | Description |
|---|---|
src/specify_cli/workflows/engine.py |
Recursively namespaces nested step IDs. |
tests/test_workflows.py |
Tests nested loop and fan-out results. |
Review details
Suppressed comments (1)
src/specify_cli/workflows/engine.py:1447
- These aliases are created only after the entire renamed subtree finishes. If a branch contains step
afollowed by stepbthat referencessteps.a,bexecutes beforeais aliased and reads the previous iteration's value (or no value), whereas both steps previously used their bare IDs. Alias each descendant immediately after that descendant completes so intra-branch references retain their existing semantics.
for new_id, orig_id in id_map.items():
if new_id in context.steps:
self._record_result(
context, state, orig_id,
context.steps[new_id],
)
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Please address Copilot feedback |
… post-subtree Addresses Copilot review feedback on PR github#4338: - while/do-while loop iteration 0 ran through a separate, unnamespaced code path before the loop-specific namespacing logic was reached, so it had no dedicated state.step_results entry and was immediately overwritten the moment iteration 1's aliasing ran. Every iteration, including the first, now goes through the same _rename_step_tree_ids + alias_map path. - Bare-id aliasing for a namespaced descendant happened only after its entire renamed subtree finished executing, so a later sibling step in the same iteration/item that referenced an earlier sibling by its original id ran before that alias existed and read a stale (or absent) value. _execute_steps now threads an alias_map through so each descendant is aliased immediately after it completes, not in bulk afterward. - For a concurrent fan-out (max_concurrency > 1), that same bare-id alias write raced across worker threads sharing context.steps, so one item's sibling read could observe another item's value. Each concurrent item now runs against a private ChainMap overlay for its bare-id aliases; only the namespaced (disjoint-key) result is published to shared state during execution. Once every item has finished (back on the single thread), the last item's aliases are applied to shared state once, deterministically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhR6g8xT8at5pPMhkrC3e2
|
Strong direction — this is a real correctness bug (silent per-iteration result overwrite + the uniqueness-exemption collision risk), disclosed and tested. Two review findings to resolve before merge: the first loop iteration still isn't namespaced (it goes through the generic Separately: you currently have 7 open PRs. Per CONTRIBUTING, that's past the point where further submissions may be deprioritized in the queue. Several are small workflow/config validation fixes (#4319, #4323, #4324, #4325) — could you consolidate the related ones so review can focus? This PR can stay standalone; it's the smaller validation fixes I'd group. |
There was a problem hiding this comment.
🟡 Changes recommended
Nested-loop aliasing, expression resolution, and bare-ID collisions remain incorrect.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/specify_cli/workflows/engine.py:1457
- When this loop is itself inside another loop or a fan-out template, its
result.next_stepswere already recursively renamed by the outer_rename_step_tree_ids. Renaming them again treats the first generated ID as the original (for examplefan:leaf:0becomesfan:while:0:fan:leaf:0:0) and aliases only back tofan:leaf:0, so a later inner-loop sibling readingsteps.leafcannot see the current value. Preserve canonical original IDs/compose the outer alias map, or avoid eagerly renaming bodies that receive their own runtime namespace.
ns_copy, id_map = _rename_step_tree_ids(
ns, step_id, str(_loop_iter),
default_id=f"step-{ns_idx}",
src/specify_cli/workflows/engine.py:1752
- The concurrent path has the same bare-ID collision as the sequential alias path: because each fan-out template is validated in its own ID set,
orig_idcan already belong to an unrelated workflow step or another fan-out, and this call deterministically overwrites that result after the pool joins. Namespaced entries survive, but the unrelated bare-key result still does not, so runtime namespacing has not made collisions safe.
for orig_id, data in alias_slots[last_idx].items():
self._record_result(context, state, orig_id, data)
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
| if alias_local_only: | ||
| context.steps[orig_id] = step_data | ||
| else: | ||
| self._record_result(context, state, orig_id, step_data) |
There was a problem hiding this comment.
Fixed both this (sequential) and the concurrent post-join write at line 1751/now further down. Added _collect_reserved_step_ids, which walks the workflow's regular control-flow nesting keys (then/else/steps/default/cases) -- the same shape _validate_steps walks into its global seen_ids -- but deliberately skips a fan-out's own step template, mirroring the fresh-set() exemption at engine.py:569-580. That gives a static, run-wide set of every id declared OUTSIDE a fan-out template. Both alias-write sites (this one and the concurrent post-join loop) now skip writing the bare-id alias when orig_id is a member of that set -- the namespaced entry is written unconditionally either way, so nothing is lost, only the unsafe convenience alias is suppressed. Kept it scoped to fan-out specifically (a new alias_may_collide flag, set only for fan-out template execution) rather than checking every alias write, since a while/do-while loop body's ids ARE globally unique by validation and gating those too would have broken the loop-aliasing this PR adds. Added tests for both the sequential and concurrent paths, each confirmed via test-the-test to fail pre-fix (the unrelated step's result gets clobbered with the fan-out's last item's data) and pass now (7365250).
| original_steps = item_ctx.steps | ||
| local_overlay: dict[str, dict[str, Any]] = {} | ||
| if local_only: | ||
| item_ctx.steps = ChainMap(local_overlay, original_steps) |
There was a problem hiding this comment.
Confirmed and fixed. Verified directly: isinstance(ChainMap(...), dict) is False, and _build_namespace's ns["steps"] = context.steps or {} put that ChainMap straight into the expression namespace, so _resolve_dot_path's isinstance(current, dict) check failed on the very first steps.<id> hop -- every such expression inside a concurrent fan-out item silently resolved to None. Replaced the ChainMap with a plain dict(original_steps) snapshot taken once per item: still isolates this item's own writes from concurrently-running siblings (a fresh copy, never shared), but is a real dict so every expression path works. Added a test that goes through evaluate_expression("{{ steps.first.output.marker }}", context) (the real templating path) rather than context.steps.get(), and confirmed via test-the-test that it fails against the ChainMap version (None == 0) and passes now (a8188b6).
…liasing
Two follow-up bugs in the nested step-id namespacing added for loops and
fan-out templates:
1. A while/do-while step nested inside an outer loop iteration or
fan-out item had its OWN 'steps' body eagerly renamed by the outer
_rename_step_tree_ids pass (since 'steps' is a walked nesting key).
When that while step then ran its own per-iteration rename, it treated
the already-namespaced id (e.g. "fan:leaf:0") as the original and
aliased back to that synthetic id instead of the workflow author's
real bare id ("leaf") -- so a doubly-prefixed id like
"fan:while:0:fan:leaf:0:0" was the only place the value ever landed,
and state.step_results["leaf"] was never populated at all.
_rename_step_tree_ids now still renames a nested while/do-while step's
own id, but leaves its 'steps' body untouched for the loop's own
runtime namespacing to rename exactly once, against the real ids.
2. A fan-out template's bare-id convenience alias (writing the current
item's result under its unprefixed id, so `steps.<id>` sees the latest
value) could silently clobber an unrelated, distinctly-authored step's
result if the two happened to share an id -- fan-out templates are
deliberately exempt from the workflow's global id-uniqueness check, so
nothing prevents that collision. This applied to both the sequential
path's immediate alias write and the concurrent path's deferred
post-join write. Added _collect_reserved_step_ids, which computes the
set of step ids declared outside any fan-out template (mirroring
_validate_steps' global id set), and gate both alias-write sites on
it via a new alias_may_collide flag -- set only for fan-out's own
template execution, so ordinary while/do-while loop-body aliasing
(always globally unique by validation) is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U74yBbvVQCPwB7Ed8Dzeu6
…n-out
The concurrent fan-out item isolation gave each item's context.steps a
ChainMap(local_overlay, original_steps) so a sibling step could resolve
an earlier one by its bare id without racing other concurrently-running
items. But _resolve_dot_path (the function every {{ steps.x.output... }}
expression goes through) only descends when isinstance(current, dict)
is true, and ChainMap is not a dict subclass -- so _build_namespace's
`ns["steps"] = context.steps or {}` put a non-dict at "steps", and every
steps.* expression evaluated inside a concurrent fan-out item silently
resolved to None. The existing test only exercised context.steps.get()
directly, which IS supported by ChainMap, so it never caught this.
Replaced the ChainMap with a plain dict snapshot of the shared steps
dict, taken once when the item starts. It still isolates this item's
own writes from concurrently-running siblings (a fresh copy per item,
never shared), it's a real dict so isinstance(..., dict) and every
expression path work normally, and the snapshot copy is safe under the
GIL against a concurrent sibling's writes to the source dict.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U74yBbvVQCPwB7Ed8Dzeu6
|
Addressed everything in this review — both the two posted inline comments and the two suppressed ones summarized in the review body. 1. Double-renaming when a while/do-while loop is nested inside an outer loop iteration or fan-out item (the suppressed comment quoting 2 & 3. Fan-out bare-id alias clobbering an unrelated step (both the suppressed concurrent-path comment quoting 4. Each fix has a dedicated regression test, and each was confirmed via test-the-test (temporarily reverting just that fix) to fail against the prior code and pass now. Full |
Summary
while/do-whileloop bodies andfan-outtemplates namespace nested step ids per iteration/item so logs andstate.step_resultsentries stay unique — but the namespacing only rewrote the id of the immediate child step, not any descendant nested deeper (e.g. ashellstep inside anifinside awhilebody, or inside afan-outtemplate'sif/switchbranch). That grandchild kept its bare, unnamespaced id across every iteration/item, so each iteration/item silently overwrote the previous one's entry instate.step_resultsunder that same key — only the last iteration's or item's result for that nested step ever survived, and no per-iteration/per-item record of it ever existed._rename_step_tree_ids, which recursively rewrites every id in a step's subtree (walkingthen/else/steps/default/cases.*— the same nesting keysoverlays/merge.pywalks for step-tree attribution) and returns a{new_id: original_id}map. Both the while/do-while loop body and fan-out'srun_itemnow use this helper instead of renaming only the top-level id, and alias every renamed descendant's result back to its original id (mirroring the existing single-level aliasing) so sibling steps within the same iteration/item and code readingsteps.<id>.outputafter the loop/fan-out still see that iteration's/item's value.Test plan
test_while_loop_namespaces_nested_descendant_stepsandtest_fan_out_namespaces_nested_descendant_stepstotests/test_workflows.py::TestWorkflowEngine: ashellstep nested inside anifinside awhilebody (and inside afan-outtemplate) gets a distinct namespacedstate.step_resultsentry per iteration/item, while the unprefixed key still holds the latest value.retry-loop:leaf:1,fan:leaf:0, etc.) were simply absent, andstep_resultsonly ever held the last iteration's/item's bare-keyed entry — reproducing the exact bug.tests/test_workflows.pysuite: 926 passed, 20 pre-existing Windows symlink-elevation failures (need admin rights, unrelated to this change), 7 skipped. AllWhile/DoWhile/FanOut/FanOutConcurrencytests pass, including the concurrent-execution and per-thread context isolation tests.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9