ContextLab / ContextLab/orchestrator
Loop constructs leak each other's variables as stale or meaningless values
- Dominant language
- Python
- Stars
- 3
- Forks
- 2
- Avg merge
- 13m
- Merged PRs (30d)
- 1
Description
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.
Contributor guide
Assessment
This issue has not been assessed yet.