ContextLab / ContextLab/orchestrator

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

Open
#477 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.