From 79f30544268d6b4582d74fc87733430b17167f07 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 4 Sep 2026 05:23:04 +0700 Subject: [PATCH] docs: plan foreach back-edge implementation --- .../plans/2026-09-04-foreach-back-edges.md | 850 ++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-foreach-back-edges.md diff --git a/docs/superpowers/plans/2026-09-04-foreach-back-edges.md b/docs/superpowers/plans/2026-09-04-foreach-back-edges.md new file mode 100644 index 00000000..8833ed82 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-foreach-back-edges.md @@ -0,0 +1,850 @@ +# Foreach Back-Edges Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make foreach bodies return through validated back-edges to their +immediate owner, with unique static control regions and fresh persisted state +for every dynamic foreach activation. + +**Architecture:** Add one pure control-region analysis module and make both +validation and context inventory consume its result. Keep dynamic execution +separate: foreach activation metadata owns a barrier and item identities for +one controller visit, while frame advancement recognizes an immediate-owner +back-edge as item completion. + +**Tech Stack:** Python 3.14, Pydantic workflow models, dataclass runtime state, +pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2. + +**Spec:** +[`docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md`](../specs/2026-09-04-foreach-back-edge-design.md) + +## Global Constraints + +- Preserve the flat serialized workflow graph; do not add nested body models. +- Keep `END` as a workflow/subgraph terminal, never a foreach-item return. +- Give every reachable node use exactly one static foreach-owner stack. +- Reject unreachable workflow nodes and structurally impossible item returns. +- Permit ordinary cycles when their nodes remain in one control region. +- Create a fresh persisted activation for every dynamic foreach-controller + visit, including repeated visits in the same parent frame. +- Keep current `fail`, `skip`, `collect`, reducer, interrupt, sync, and async + behavior. +- Keep nested context innermost-only; inherited structured foreach context is + deferred. +- Do not add break/continue, fork/gather, retry execution, a step limit, a new + Python DSL, or compatibility behavior for unobserved persisted data. +- Add docstrings or comments around owner-stack traversal, return-edge handling, + activation lifecycle, and any fail-closed runtime checks. +- Do not modify or commit the user's dirty `docs/AGENTS.md`. + +--- + +### Task 1: Build the Pure Control-Region Analyzer + +**Files:** + +- Create: `src/wf_core/analysis/control_regions.py` +- Modify: `src/wf_core/analysis/__init__.py` +- Test: `tests/core/test_foreach_control_regions.py` + +**Interfaces:** + +- Consumes: `Workflow`, `ForeachNode`, `EndNode`, `Edge`, and `END`. +- Produces: + + ```python + type ForeachOwnerStack = tuple[str, ...] + + class ControlRegionIssueKind(StrEnum): + UNREACHABLE_NODE = "unreachable_node" + FOREACH_REGION_CONFLICT = "foreach_region_conflict" + INVALID_FOREACH_RETURN = "invalid_foreach_return" + INVALID_FOREACH_TERMINAL = "invalid_foreach_terminal" + EMPTY_FOREACH_BODY = "empty_foreach_body" + FOREACH_BODY_NO_RETURN = "foreach_body_no_return" + + @dataclass(frozen=True, slots=True) + class ControlRegionIssue: + kind: ControlRegionIssueKind + path: str + message: str + + @dataclass(frozen=True, slots=True) + class ControlRegionAnalysis: + owner_stack_by_node: dict[str, ForeachOwnerStack] + issues: tuple[ControlRegionIssue, ...] + + ``` + + Function: + `analyze_control_regions(workflow: Workflow) -> ControlRegionAnalysis`. + +- `owner_stack_by_node` contains only nodes whose region is unambiguous. Later + context analysis must not grant foreach fields to a conflicted node. + +- [ ] **Step 1: Write failing acceptance tests for legal regions** + + Add explicit tests named: + + - `test_closed_root_cycle_has_one_empty_control_region` + - `test_foreach_cycle_with_possible_return_is_valid` + - `test_conditional_foreach_paths_can_both_return` + - `test_nested_foreach_assigns_static_owner_stacks` + - `test_reentering_completed_foreach_keeps_one_static_region` + + The nested assertion must be exact: + + ```python + assert analysis.owner_stack_by_node == { + "f1": (), + "f2": ("f1",), + "work": ("f1", "f2"), + "tail": ("f1",), + "after": (), + } + assert analysis.issues == () + ``` + +- [ ] **Step 2: Run legal-region tests and confirm the missing module fails** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_control_regions.py + ``` + + Expected: collection fails because `wf_core.analysis.control_regions` does + not exist. + +- [ ] **Step 3: Implement semantic traversal over node and owner stack** + + Use a bounded worklist of `(node_id, owner_stack)` states. The special edge + handling must follow this order: + + ```python + if source_is_foreach_loop: + if edge.to == source.id: + issue(EMPTY_FOREACH_BODY, edge_path) + continue + target_stack = (*owner_stack, source.id) + else: + target_stack = owner_stack + + if target_is_terminal and target_stack: + issue(INVALID_FOREACH_TERMINAL, edge_path) + elif edge.to == target_stack[-1]: + record_item_return(edge, target_stack[-1]) + connect_to_resumed_owner(edge.to, target_stack[:-1]) + elif edge.to in target_stack: + issue(INVALID_FOREACH_RETURN, edge_path) + else: + enqueue(edge.to, target_stack) + ``` + + An immediate return resumes the owner controller in the popped stack for + structural analysis; it does not assign the owner node to its child's stack. + Ignore unknown sources and targets here because ordinary edge validation + already owns those diagnostics. + +- [ ] **Step 4: Write failing tests for every invalid pressure case** + + Add one explicit test per topology: + + - `test_external_entry_into_foreach_body_is_region_conflict` + - `test_foreach_body_escape_is_region_conflict` + - `test_skipping_inner_foreach_owner_is_invalid_return` + - `test_entering_sibling_foreach_body_is_region_conflict` + - `test_empty_foreach_body_is_rejected` + - `test_closed_foreach_body_cycle_has_no_return` + - `test_foreach_body_cannot_target_end_token` + - `test_foreach_body_cannot_target_explicit_end_node` + - `test_every_unreachable_node_is_reported` + + Assert issue kind and location, for example: + + ```python + assert (issue.kind, issue.path) == ( + ControlRegionIssueKind.FOREACH_REGION_CONFLICT, + "nodes[b]", + ) + ``` + +- [ ] **Step 5: Implement conflicts, reachability, and returnability** + + Record the first stack for each node. If a second distinct stack reaches the + same node, remove it from `owner_stack_by_node` and emit one region conflict. + After traversal, report every workflow node never reached from `start`. + + Build semantic state adjacency while traversing. For every reached state with + a non-empty stack, require a graph path to a return edge for its current top + owner. Returns from deeper nested foreach bodies may resume their controllers + on the way. A closed root cycle remains valid because its stack is empty. + Suppress cascading no-return diagnostics when a region conflict, invalid + return, invalid terminal, or empty body already makes that state ambiguous. + +- [ ] **Step 6: Run analyzer tests** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_control_regions.py + uv run ruff check src/wf_core/analysis/control_regions.py \ + tests/core/test_foreach_control_regions.py + uv run basedpyright --level error src/wf_core/analysis/control_regions.py + ``` + + Expected: all commands pass. + +- [ ] **Step 7: Commit the analyzer** + + ```bash + git add src/wf_core/analysis/control_regions.py \ + src/wf_core/analysis/__init__.py \ + tests/core/test_foreach_control_regions.py + git commit -m "feat: analyze foreach control regions" + ``` + +### Task 2: Make Context Inventory Consume Static Regions + +**Files:** + +- Modify: `src/wf_core/analysis/context_scopes.py` +- Modify: `tests/core/test_context_scopes.py` + +**Interfaces:** + +- Consumes: `analyze_control_regions(workflow)` from Task 1. +- Produces: unchanged public functions `context_fields_by_node(workflow)` and + `context_analysis_warnings(workflow)`. +- Keeps the existing runtime contract: the innermost foreach supplies + `loop_item`, `loop_index`, and its alias; completing it restores the outer + context. It does not expose all enclosing aliases. + +- [ ] **Step 1: Rewrite context tests to canonical back-edges** + + Replace successful item routes such as: + + ```python + {"from": "body", "outcome": "ok", "to": END} + ``` + + with: + + ```python + {"from": "body", "outcome": "ok", "to": "each"} + ``` + + For the nested fixture use: + + ```python + {"from": "inner_body", "outcome": "ok", "to": "inner"} + {"from": "after_inner", "outcome": "ok", "to": "outer"} + ``` + +- [ ] **Step 2: Replace the mixed-reachability expectation** + + Delete the test that expects one node to receive conditional loop fields when + reached both inside and outside a foreach. Add a test proving conflicted nodes + receive no guaranteed foreach fields and the analysis warning includes the + region conflict. + + Keep the nested assertions: + + ```python + assert "outer_item" not in inner + assert inner["inner_item"].availability == "available" + assert after_inner["outer_item"].availability == "available" + assert "inner_item" not in after_inner + ``` + +- [ ] **Step 3: Run the context tests and confirm old traversal fails** + + Run: + + ```bash + uv run pytest -q tests/core/test_context_scopes.py + ``` + + Expected: failures show that the single `FrameScope` traversal neither pops + canonical return edges nor consumes region-conflict diagnostics. + +- [ ] **Step 4: Replace duplicate traversal with the analyzer result** + + Remove the local breadth-first scope traversal. For each unambiguous node, + derive its active context from the final stack item: + + ```python + stack = analysis.owner_stack_by_node.get(node_id) + active_foreach_id = stack[-1] if stack else None + ``` + + Pass the controller's own static stack into item-schema resolution so an + inner foreach may still declare `over="context.outer_item"`. Convert analyzer + diagnostics to bounded context warnings. Do not reintroduce multiple scopes + or conditional fields for a single node use. + +- [ ] **Step 5: Run context and authoring-contract tests** + + Run: + + ```bash + uv run pytest -q tests/core/test_context_scopes.py \ + tests/wf_api/test_authoring_contracts.py + uv run ruff check src/wf_core/analysis/context_scopes.py \ + tests/core/test_context_scopes.py + uv run basedpyright --level error src/wf_core/analysis + ``` + + Expected: all commands pass. + +- [ ] **Step 6: Commit context integration** + + ```bash + git add src/wf_core/analysis/context_scopes.py \ + tests/core/test_context_scopes.py + git commit -m "refactor: derive context from foreach control regions" + ``` + +### Task 3: Give Every Foreach Visit a Persisted Activation + +**Files:** + +- Modify: `src/wf_core/runtime/foreach_state.py` +- Modify: `src/wf_core/runtime/scheduler.py` +- Modify: `src/wf_core/runtime/lineage.py` +- Modify: `src/wf_core/runtime/ops/foreach.py` +- Modify: `src/wf_core/runtime/ops/nodes.py` +- Modify: `src/wf_core/runtime/step.py` +- Modify: `tests/core/test_foreach_barrier_state.py` +- Modify: `tests/core/test_scheduler.py` +- Modify: `tests/core/test_concurrent_foreach.py` +- Modify: `tests/core/test_concurrent_foreach_errors.py` +- Modify: `tests/core/test_concurrent_foreach_interrupts.py` +- Test: `tests/core/test_foreach_activations.py` + +**Interfaces:** + +- Produces: + + ```python + @dataclass(slots=True) + class ForeachActivationState: + id: str + foreach_node_id: str + barrier: ForeachBarrierState + + @dataclass(frozen=True, slots=True) + class ForeachItemOwner: + parent_frame_id: str + foreach_node_id: str + activation_id: str + item_index: int + + ``` + + Functions: + + - `load_or_begin_foreach_activation(frame: ExecutionFrame, + foreach_node_id: str, *, mode: Literal["serial", "concurrent"]) + -> ForeachActivationState` + - `save_foreach_activation(frame: ExecutionFrame, activation: + ForeachActivationState) -> None` + - `close_foreach_activation(frame: ExecutionFrame, activation: + ForeachActivationState) -> None` + - `item_frame_owner(frame: ExecutionFrame) -> ForeachItemOwner | None` + +- `ForeachIterationMetadata` gains required `activation_id: str`. +- Callers compare owner fields by name; remove tuple slicing and positional + unpacking. + +- [ ] **Step 1: Write failing activation-lifecycle tests** + + Add tests proving: + + ```python + first = load_or_begin_foreach_activation(frame, "each", mode="serial") + save_foreach_activation(frame, first) + restored = load_or_begin_foreach_activation(frame, "each", mode="serial") + assert restored.id == first.id + + close_foreach_activation(frame, restored) + second = load_or_begin_foreach_activation(frame, "each", mode="serial") + assert second.id != first.id + assert second.barrier.next_index == 0 + ``` + + Also test malformed metadata, mode mismatch, closing a stale activation, and + JSON round-trip through `ExecutionFrame.metadata`. + +- [ ] **Step 2: Run activation tests and confirm failure** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_activations.py + ``` + + Expected: imports fail because activation lifecycle helpers do not exist. + +- [ ] **Step 3: Implement the activation metadata seam** + + Hide the JSON dictionary shape inside `foreach_state.py`. Persist, per parent + frame and foreach node id, a monotonically increasing visit sequence plus at + most one active activation. Derive opaque ids from parent frame id, foreach + node id, and the persisted sequence; callers must never parse them. + + Closing removes the active barrier but preserves the next sequence. Do not + retain a compatibility reader for the old barrier-only shape because the spec + found no real persisted foreach data. + +- [ ] **Step 4: Add activation identity to item metadata and helpers** + + Require this shape: + + ```python + ForeachIterationMetadata( + foreach_node_id=step.id, + activation_id=activation.id, + loop_index=loop_index, + loop_item=item, + loop_alias=step.as_, + ) + ``` + + Child frame and lineage ids must include `activation.id`, so a later visit at + item index zero cannot collide with the first visit. + +- [ ] **Step 5: Move runtime callers onto named owner and activation state** + + Update lineage reads, node-result buffering, async batching, failure + collection, refill, and barrier commit to load the activation named by the + child. Fail closed when a child result names a closed or different active + activation. + +- [ ] **Step 6: Update focused metadata tests** + + Replace hand-written item metadata in `test_foreach_barrier_state.py` and + `test_scheduler.py` with required activation ids. Update hard-coded child + frame and lineage ids in the concurrent, error, and interrupt suites to + include the activation identity. Assert `item_frame_owner` returns + `ForeachItemOwner`, not a tuple. + +- [ ] **Step 7: Run runtime-state tests** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_activations.py \ + tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py + uv run ruff check src/wf_core/runtime tests/core/test_foreach_activations.py \ + tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py + uv run basedpyright --level error src/wf_core/runtime + ``` + + Expected: all commands pass. + +- [ ] **Step 8: Commit activation identity** + + ```bash + git add src/wf_core/runtime/foreach_state.py \ + src/wf_core/runtime/scheduler.py src/wf_core/runtime/lineage.py \ + src/wf_core/runtime/ops/foreach.py src/wf_core/runtime/ops/nodes.py \ + src/wf_core/runtime/step.py tests/core/test_foreach_activations.py \ + tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py + git commit -m "feat: identify dynamic foreach activations" + ``` + +### Task 4: Execute Immediate-Owner Back-Edges + +**Files:** + +- Modify: `src/wf_core/runtime/ops/flow.py` +- Modify: `src/wf_core/runtime/foreach_state.py` +- Modify: `src/wf_core/runtime/ops/foreach.py` +- Test: `tests/core/test_foreach_back_edges.py` +- Modify: `tests/core/test_concurrent_foreach.py` +- Modify: `tests/core/test_concurrent_foreach_async.py` +- Modify: `tests/core/test_concurrent_foreach_errors.py` +- Modify: `tests/core/test_concurrent_foreach_interrupts.py` +- Modify: `examples/raw_concurrent_foreach.py` +- Modify: `examples/authoring_concurrent_foreach.py` +- Modify: `examples/demo_workflow.py` +- Modify: `tests/authoring/test_demo_workflow.py` + +**Interfaces:** + +- Consumes: `ForeachItemOwner` and activation lifecycle from Task 3. +- Produces: `advance_frame` recognizes a target equal to the immediate + owner's foreach node as item completion before generic node advancement. +- Produces an internal helper that derives ancestor foreach owners from frame + ancestry for defensive non-local-return rejection. + +- [ ] **Step 1: Write failing serial return tests** + + Add tests with canonical edges: + + ```python + Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"}) + Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}) + Edge.model_validate({"from": "each", "outcome": "done", "to": END}) + ``` + + Prove two items execute, the child finishes at `each`, the parent wakes, and + the final workflow outcome remains `ok`. + +- [ ] **Step 2: Write failing cycle, nested, and re-entry runtime tests** + + Add explicit tests named: + + - `test_foreach_body_cycle_can_repeat_then_return` + - `test_conditional_body_can_return_on_either_outcome` + - `test_nested_foreach_returns_inner_then_outer` + - `test_reentering_foreach_uses_fresh_activation_and_item_frames` + - `test_subgraph_end_returns_to_subgraph_node_then_foreach_owner` + - `test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed` + + The re-entry test must visit one foreach node twice in the root frame and + assert two distinct activation ids and two distinct item-zero frame ids. + The nested test must prove an inactive foreach is entered normally, while the + direct defensive test constructs an invalid frame chain without running + workflow preparation and asserts `WorkflowExecutionError`. + +- [ ] **Step 3: Implement immediate-owner return in frame advancement** + + Before `END` handling or ordinary enqueue: + + ```python + owner = item_frame_owner(frame) + if owner is not None and next_node_id == owner.foreach_node_id: + source_node_id = frame.node_id + frame.prior_outcome = outcome + frame.activated_incoming_edge = source_node_id + frame.node_id = owner.foreach_node_id + frame.status = FrameStatus.COMPLETED + frame.finished_at_node_id = owner.foreach_node_id + wake_parent_for_child_progress(run, frame.id) + run.sync_from_current_frame() + return + ``` + + Add a comment explaining why the child does not execute the target node. If + an item targets `END`, or targets a foreach found below its immediate owner in + the active ancestor chain, raise `WorkflowExecutionError` defensively. + +- [ ] **Step 4: Close activations before controller completion edges** + + In both serial and concurrent completion paths, close the active activation + before calling `advance_frame` for `done` or `completed_with_errors`. This + makes a self-looping or later returning completion edge start a fresh visit. + +- [ ] **Step 5: Migrate executable foreach fixtures** + + Change item-success routes from `END` to their owner in every file listed for + this task. Keep controller completion routes to `END` or their real outer + continuation. For nested fixtures, return inner bodies to the inner foreach + and outer-tail nodes to the outer foreach. + +- [ ] **Step 6: Prove concurrent, async, error, and interrupt behavior** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_back_edges.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_async.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py \ + tests/core/test_raw_canonical_workflow_example.py \ + tests/authoring/test_concurrent_foreach_examples.py \ + tests/authoring/test_demo_workflow.py + ``` + + Assert resumed interrupts retain the same activation id. Add a direct + fail-closed test showing a completed activation cannot accept a result or + wake-up from another activation. + +- [ ] **Step 7: Run runtime static checks** + + Run: + + ```bash + uv run ruff check src/wf_core/runtime examples \ + tests/core/test_foreach_back_edges.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_async.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py + uv run basedpyright --level error src/wf_core/runtime + ``` + + Expected: all commands pass. + +- [ ] **Step 8: Commit runtime back-edges** + + ```bash + git add src/wf_core/runtime/ops/flow.py \ + src/wf_core/runtime/foreach_state.py \ + src/wf_core/runtime/ops/foreach.py \ + examples/raw_concurrent_foreach.py \ + examples/authoring_concurrent_foreach.py examples/demo_workflow.py \ + tests/core/test_foreach_back_edges.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_async.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py \ + tests/authoring/test_demo_workflow.py + git commit -m "feat: return foreach items through owner back-edges" + ``` + +### Task 5: Enforce Control Regions Through Public Validation + +**Files:** + +- Modify: `src/wf_core/validation/issues.py` +- Modify: `src/wf_core/validation/core.py` +- Modify: `tests/core/test_foreach_control_regions.py` +- Modify: `tests/core/test_foreach_policy.py` +- Modify: `tests/artifacts/test_draft_models.py` +- Modify: `tests/artifacts/test_draft_adapter.py` + +**Interfaces:** + +- Consumes: `ControlRegionAnalysis` from Task 1. +- Produces public `ValidationIssueCode` values matching every + `ControlRegionIssueKind` value. +- Keeps `Workflow.validate_structure()` and `ValidationReport` signatures + unchanged. + +- [ ] **Step 1: Add failing public-validation assertions** + + For every pressure-case test, call both the pure analyzer and + `workflow.validate_structure()`. Invalid cases must assert the public code and + offending path: + + ```python + matching = [ + issue + for issue in workflow.validate_structure().errors + if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT + ] + assert matching[0].path == "nodes[b]" + ``` + + Legal cases assert `report.ok`. Add a test proving every unreachable node in + one component receives its own `UNREACHABLE_NODE` issue. + +- [ ] **Step 2: Run public-validation tests and confirm failure** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_control_regions.py + ``` + + Expected: analyzer tests pass, but public reports lack the new issue codes. + +- [ ] **Step 3: Wire analysis into validation once** + + Add enum members with exactly the analyzer values. Call + `analyze_control_regions(workflow)` after ordinary node and edge validation, + then translate each diagnostic: + + ```python + report.add( + ValidationIssueCode(issue.kind.value), + issue.path, + issue.message, + ) + ``` + + Do not add a second graph traversal inside validation. + +- [ ] **Step 4: Canonicalize policy and draft fixtures** + + Policy-only workflow helpers must include a distinct body node and route it + back to the foreach owner. Draft fixtures with: + + ```python + "routes": {"each_item": {"loop": "echo", "done": "__end__"}, + "echo": {"ok": "__end__"}} + ``` + + become: + + ```python + "routes": {"each_item": {"loop": "echo", "done": "__end__"}, + "echo": {"ok": "each_item"}} + ``` + + Parse-only policy fixtures still use a distinct body; do not preserve an + invalid `loop -> __end__` shortcut just because the test does not execute it. + +- [ ] **Step 5: Run validation and draft suites** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_control_regions.py \ + tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \ + tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py + ``` + + If another fixture expected an unreachable node to be valid, either connect + it when it represents intended execution or change that test to assert + `UNREACHABLE_NODE` when disconnection is the behavior under test. Do not add + an allow-unreachable flag. + +- [ ] **Step 6: Run core validation static checks** + + Run: + + ```bash + uv run ruff check src/wf_core/validation \ + tests/core/test_foreach_control_regions.py \ + tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \ + tests/artifacts/test_draft_adapter.py + uv run basedpyright --level error src/wf_core/validation + ``` + + Expected: all commands pass. + +- [ ] **Step 7: Commit fail-closed validation** + + ```bash + git add src/wf_core/validation tests/core/test_foreach_control_regions.py \ + tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \ + tests/artifacts/test_draft_adapter.py + git commit -m "feat: validate foreach control regions" + ``` + +### Task 6: Finish Migration, Documentation, and Full Verification + +**Files:** + +- Modify: `docs/wf_core_architecture.md` +- Modify: `docs/wf_authoring_control_flow.md` +- Modify: `docs/current_roadmap.md` +- Modify: `docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md` +- Move after all checks pass: + `docs/superpowers/plans/2026-09-04-foreach-back-edges.md` to + `docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md` + +**Interfaces:** + +- Documents the implemented graph and runtime interface; adds no new runtime + surface. +- Preserves historical reports and recorded agent-challenge outputs verbatim. + +- [ ] **Step 1: Search for stale canonical foreach returns** + + Run targeted searches: + + ```bash + rg -n 'child reaches `END`|body.*->.*END|record.*->.*END' \ + docs skills examples tests src -g '*.md' -g '*.py' \ + -g '!docs/historical/**' -g '!examples/agent_challenges/**' + rg -n '"outcome": "loop".*"to": END|"loop": "__end__"' \ + tests examples src -g '*.py' + ``` + + Classify each match: controller completion remains terminal; item-body + completion changes to the owner. Do not rewrite unrelated ordinary terminal + routes or immutable historical evidence. + +- [ ] **Step 2: Update live architecture and authoring docs** + + Replace the old architecture statement that item children reach `END` with: + + ```text + An item child returns by targeting its immediate owning foreach. The child + finishes at that owner location without executing the controller; the parent + activation consumes the result and continues or completes its barrier. + ``` + + Add the canonical authoring example: + + ```python + g.connect(each, "loop", record) + g.connect(record, "ok", each) + g.connect(each, "done", END) + ``` + + Document that region conflicts, unreachable nodes, body terminals, non-local + returns, empty bodies, and bodies without possible returns fail validation. + +- [ ] **Step 3: Mark the design implemented and roadmap item complete** + + Set the spec status to `Implemented on 2026-09-04`. Move the roadmap bullet + from active correction to recently completed runtime work. Keep fork/gather + explicitly deferred. + +- [ ] **Step 4: Run the focused acceptance matrix** + + Run: + + ```bash + uv run pytest -q tests/core/test_foreach_control_regions.py \ + tests/core/test_context_scopes.py tests/core/test_foreach_activations.py \ + tests/core/test_foreach_back_edges.py \ + tests/core/test_concurrent_foreach.py \ + tests/core/test_concurrent_foreach_async.py \ + tests/core/test_concurrent_foreach_errors.py \ + tests/core/test_concurrent_foreach_interrupts.py \ + tests/core/test_subgraph_step.py tests/authoring/test_demo_workflow.py \ + tests/authoring/test_concurrent_foreach_examples.py + ``` + + Expected: every current pressure-case row passes. The future-fork row remains + documented and unimplemented because no fork node exists. + +- [ ] **Step 5: Run repository verification** + + Run: + + ```bash + uv run pytest -q + uv run ruff check + uv run ruff format --check + uv run basedpyright --level error + pnpx markdownlint-cli2 \ + 'docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md' \ + 'docs/wf_core_architecture.md' \ + 'docs/wf_authoring_control_flow.md' + git diff --check + ``` + + Expected: all commands pass. If repository-wide Markdown files retain known + unrelated lint debt, do not run an unsafe global auto-fix; report it and keep + this slice's edited documents clean. + +- [ ] **Step 6: Commit implementation documentation** + + ```bash + git add docs/wf_core_architecture.md docs/wf_authoring_control_flow.md \ + docs/current_roadmap.md \ + docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md + git commit -m "docs: publish foreach back-edge semantics" + ``` + +- [ ] **Step 7: Archive the completed plan** + + After every prior task is complete and committed: + + ```bash + git mv docs/superpowers/plans/2026-09-04-foreach-back-edges.md \ + docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md + git add docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md + git commit -m "docs: archive foreach back-edge plan" + ``` + + Search for and update any live links to the plan's historical path before + committing. Leave the live implemented spec in `docs/superpowers/specs/`.