Skip to content

Loop constructs leak each other's variables as stale or meaningless values #477

Description

@jeremymanning

Every binding below was observed by executing a pipeline (filesystem tool only, no models) and reading what reached the file.

A construct renders names it has no concept of, and renders them as a plausible-looking value rather than failing:

construct leaked name renders as why it is wrong
for_each body iteration None a for_each has no iteration counter
while body item None a while loop walks no collection
while body length, remaining, has_next, has_prev 0, 0, False, True collection values for a construct with no collection
for_each iterable index, is_first, is_last, position, length, ... 0, True, ... the iterable resolves before the loop exists; these come from a zeroed context that is not the loop's
create_parallel_queue.on index, is_first, is_last, queue_size, parallel_queue_id, parent_task 0, True, ... same: on generates the queue, so nothing per-item exists yet

{{ item }} in a for_each: iterable does error, so the boundary is enforced for exactly one name.

The mechanism for the body cases: ControlSystem._render_task_templates registers every key of metadata["loop_context"] (core/control_system.py:263-267), and that value is LoopContext.get_debug_info() (core/loop_context.py:243) — a dict built for debugging, carrying item_type, nesting_depth, is_current_first and the rest alongside the real bindings. A debug dump became the public template surface by accident.

A narrower list exists 40 lines later (item, index, is_first, is_last, $item, $index) but runs after the wholesale registration and cannot take anything back.

This matters for validation because it makes "what does this construct bind?" unanswerable from the runtime alone: the honest answer is "these six, plus everything in a debug dict". core/loop_contracts.py therefore declares the meaningful bindings and withholds the leaked ones, so validation rejects {{ iteration }} in a for_each. That is a deliberate choice to be stricter than the runtime, and it is the wrong way round — the runtime should stop offering them.

Suggested order:

  1. Give LoopContext an explicit to_template_dict()-style public surface, separate from get_debug_info().
  2. Register only that surface in control_system.py.
  3. Make the two for_each paths (control_system and the ForEachTask expansion) populate the same surface — they currently differ.
  4. Then loop_contracts describes the runtime instead of correcting it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions