# Goal: parallel-region engine traversal (SPEC §6.9 + §6.11)

After this task, `attractor run` end-to-end executes a workflow
that contains a `component` / `tripleoctagon` parallel region:
fan-out dispatches branches concurrently via `asyncio.TaskGroup`,
each branch runs in its own sub-worktree (using the P4 primitives
already on main), the join AND-folds branch outcomes and writes
its own `NodeCompleted`, and the run continues from the join's
outgoing edge per §6.1.

Resume across an open parallel region (mid-region crash recovery)
is **out of scope** — that's the P5b chunk. A crash mid-region
ends the run `incomplete` for now; resume re-enters the component
and re-dispatches all branches (acceptable interim behaviour).

## Files to touch

- `src/attractor/engine/journal.py` — add the new optional field
  `branch_name: str | None = None` to `NodeCompleted` (Pydantic).
- `src/attractor/engine/engine.py` — the bulk of the work:
  - In `_traverse`, when the current node is `NodeKind.FANOUT`,
    enter a new `_run_parallel_region` code path instead of
    dispatching as a sequential node.
  - `_run_parallel_region` discovers the matching `tripleoctagon`
    (use the same DFS the validator does — `_discover_region` in
    `workflow/validate.py` is the reference; engine should NOT
    re-validate, just trust the graph).
  - For each outgoing edge of the component: derive
    `branch_name = edge.label if edge.label else edge.dst`,
    create a sub-worktree via
    `WorktreeManager.create_branch(run_id, branch_name, fork_oid)`,
    spawn an async task that runs that branch.
  - Each branch task is a per-branch `_traverse_branch` mini-loop:
    sequential dispatch of nodes inside the branch, stopping when
    the next node is the tripleoctagon. Each branch-internal
    `NodeCompleted` carries `branch_name=<X>` and its
    `worktree_commit_after` is the OID on the **branch's** ref
    (§6.10.1), NOT main.
  - Use `asyncio.TaskGroup` to await all branches.
  - After all branches return: AND-fold their terminal outcomes;
    concatenate their captured outputs (branch-name prefixed);
    write the `tripleoctagon`'s `NodeCompleted` (with
    `branch_name=None`, regular sequential entry); route via the
    join's SUCCESS or FAILURE edge per §6.1.
- `src/attractor/engine/engine.py` — synchronization: journal
  writes from concurrent branches must be serialized. Wrap
  `_write_entry` calls inside `_run_parallel_region` with an
  `asyncio.Lock` so `seq` allocation is atomic and entries land
  in a deterministic-enough order. `seq` is the global counter;
  inside the lock, increment then write.
- `src/attractor/engine/engine.py` — sub-worktree commit: branch
  nodes' `commit_worktree_changes` calls must point at the
  branch's worktree dir, not the main one. Pass the branch's
  `RunBranchWorktree.path` as the `worktree_path` argument.
- `tests/test_engine/test_parallel.py` (new file) — end-to-end
  integration tests + targeted unit tests, see below.

## Behaviour rules (SPEC pointers)

- `NodeCompleted.branch_name` is `None` for sequential nodes
  (every entry pre-parallel) and the component/join's own entries.
  It is set to the branch name for nodes executed inside a
  region. (§6.11.1)
- The component's own `NodeCompleted` writes `branch_name=None`,
  `success=True` unconditionally, `next_node=None` (the engine
  derives the next step via the join, not by edge picking on the
  component). (§6.9.1)
- The join's `NodeCompleted` writes `branch_name=None`,
  `success = AND of every branch's terminal `success` bit`,
  `captured_output = concatenated branch outputs prefixed by
  branch name`, and `next_node` per §6.1 routing. (§6.9.2)
- A branch that fails to reach the join (max_visits trip,
  unroutable FAILURE) folds in as `success=False` at the join.
  Surviving branches still run to completion. (§6.9.3)
- No nesting in v0.2 (validator enforced; engine can rely on it).
- Branches do not share filesystem state across the region; each
  has its own sub-worktree (§6.10.1).

## Tests

`tests/test_engine/test_parallel.py` (new file). All tests carry
`# [unit->REQ-EXEC-PARALLEL-FANOUT]` and / or
`# [unit->REQ-EXEC-PARALLEL-JOIN]` as appropriate; the end-to-end
tests also carry `# [int->REQ-DOD-WORKFLOW-RUN]`.

Minimum coverage:

1. **Three-branch tool fan-out happy path.** Graph mirroring
   `workflows/parallel-verifiers.dot` with three tool nodes
   (each script `exit 0`). Run end-to-end; assert status
   `COMPLETED`, every branch's terminal `NodeCompleted` has the
   expected `branch_name`, join's outcome is `SUCCESS`.
2. **One branch fails → join FAILURE.** Two tool branches `exit 0`,
   one tool branch `exit 1`. Assert join's `success=False`,
   `captured_output` includes the failing branch's name.
3. **Per-branch sub-worktree commits.** After a successful run,
   each branch ref `refs/heads/attractor/run/<id>/branch/<name>`
   exists and resolves to a real commit; the main worktree ref
   is untouched across the region's nodes.
4. **AND-fold combined output prefix.** Assert that the join's
   `captured_output` starts with branch names (e.g.
   `[tests] ...` then `[lint] ...`).
5. **`branch_name` field round-trips through the journal.**
   Build a `NodeCompleted` with `branch_name="X"`, write via
   `BranchStore`, read back, assert the field is preserved.

Additional `tests/test_engine/test_journal.py` coverage:
`branch_name` defaults to `None`; explicit set survives
Pydantic serialization.

## Definition of done

- `engine.py` changes carry `# [impl->REQ-EXEC-PARALLEL-FANOUT]`
  on `_run_parallel_region` and the branch-dispatch loop,
  `# [impl->REQ-EXEC-PARALLEL-JOIN]` on the AND-fold + join
  `NodeCompleted` write.
- `journal.py` `NodeCompleted` field addition carries
  `# [impl->REQ-EXEC-PARALLEL-FANOUT]` (the field is shared by
  fanout and join semantics but the data point lives on fanout's
  branch-internal entries; either tag is fine).
- All new tests carry the corresponding `[unit->REQ]` /
  `[int->REQ]` tags.
- `traceable-reqs.toml` — promote
  `REQ-EXEC-PARALLEL-FANOUT.required_stages` and
  `REQ-EXEC-PARALLEL-JOIN.required_stages` to
  `["doc", "impl", "unit"]`. Leave
  `REQ-EXEC-PARALLEL-RESUME` at `["doc"]` (P5b later).
- `uv run pytest && uv run ruff check src tests && uv run pyright`
  all pass.
- The existing 436 tests must still pass — this is a refactor
  that adds capability, not a rewrite that breaks sequential.
- `uv run traceable-reqs check --json` reports REQ-EXEC-PARALLEL-
  FANOUT and -JOIN complete on doc + impl + unit.
- Smoke test: `uv run attractor run workflows/parallel-verifiers.dot`
  runs end-to-end (would require an `--input` for any goal node,
  but the verifier example uses tool nodes that produce captured
  output — confirm it runs from CLI).

## References

- SPEC.md §6.5 (asyncio model — explicit asyncio.TaskGroup mention)
- SPEC.md §6.9 (parallel-node syntax + per-node semantics)
- SPEC.md §6.10.1 (sub-worktree paths + branch refs — primitives
  exist on main as of commit b73709c)
- SPEC.md §6.11.1 (NodeCompleted.branch_name field)
- SPEC.md §6.11.2 (resume rule — out of scope, but read so this
  impl doesn't make resume harder later)
- `src/attractor/engine/engine.py` — existing `_traverse` and
  `_dispatch_handler` to understand the sequential baseline
- `src/attractor/engine/journal.py` — `NodeCompleted` Pydantic
  model to extend
- `src/attractor/worktree/manager.py` — `create_branch`,
  `list_branches`, `remove_branch`, `RunBranchWorktree` (P4)
- `src/attractor/workflow/validate.py:_discover_region` —
  reference DFS for matching-join discovery

## Out of scope

- **Resume across an open parallel region.** That's P5b — needs
  the new resume rule from §6.11.2. For now: a crash mid-region
  ends the run incomplete; resume re-enters the component and
  re-dispatches all branches (idempotent for stateless
  verifiers; may re-do work for stateful agents).
- **`predecessor_branches` payload to downstream merge agents.**
  The §6.10.3 merger workflow uses this for entry-edge context
  on the node downstream of the join. Pin this in a follow-up
  (it's a §6.3 extension, separate concern from traversal).
- **Token aggregation by branch.** Per-run totals sum across all
  branches (existing rule). Per-branch UI is not part of impl.
- **Branch sub-worktree cleanup on success.** §6.10.1 says dirs
  removed on run success; this can land here OR in P5b. If kept
  here, mirror the existing `WorktreeManager.remove` lifecycle.

After this task lands, parallel workflows RUN end-to-end happy-path.
Crash recovery (P5b) and downstream merge-agent context (§6.3 ext)
remain for follow-ups.
