nexo-driver-loop 0.1.9

Goal orchestrator + LlmDecider + Unix socket bridge for the nexo-rs driver subsystem. Phase 67.4.
Documentation
# nexo-driver-loop

The brain of the programmer agent. Owns `DriverOrchestrator`,
the long-running async runtime that drives one `Goal` end-to-end
through Claude Code, gates every tool call through MCP, runs the
acceptance criteria, and emits events that downstream subscribers
(EventForwarder, registry, hooks) consume.

## Where it sits

```
nexo-driver-types       ← contract
   ↑
nexo-driver-claude      ← subprocess + bindings
   ↑
nexo-driver-permission  ← MCP gate
   ↑
nexo-driver-loop        ← THIS crate (orchestrator)
   ↑
nexo-dispatch-tools     ← tool surface for the LLM
   ↑
nexo-core               ← agent runtime
   ↑
src/main.rs             ← boot wiring
```

## Public surface (`DriverOrchestrator`)

### Construction

`DriverOrchestrator::builder()` — typed builder that requires
`claude_config`, `binding_store`, `decider`, `workspace_manager`,
`bin_path`, `socket_path` and accepts optional `acceptance`,
`event_sink`, `replay_policy`, `compact_policy`,
`compact_context_window`, `progress_every_turns`, `cancel_root`.

`build()` binds the Unix socket the MCP bin connects to (Phase
67.3) and returns the orchestrator.

### Per-goal lifecycle

| Method | Behaviour |
|---|---|
| `spawn_goal(self: Arc<Self>, goal) -> JoinHandle` | Phase 67.C.1 — fire-and-forget. The runner registers per-goal cancel + pause tokens, walks the loop, persists bindings, drains events. |
| `run_goal(&self, goal)` | The actual loop. Used by tests; production goes through `spawn_goal`. |
| `cancel_goal(GoalId)` / `is_cancelled(GoalId)` | Phase 67.G.2 — child token signals the loop to exit at the next safe point. |
| `pause_goal(GoalId)` / `resume_goal(GoalId)` / `is_paused(GoalId)` | Phase 67.C.2 — hold the loop between turns without killing the in-flight Claude turn. |
| `pre_register_goal(GoalId)` | B11 — wires cancel + pause tokens before `run_goal` starts so reattach paths can target them. |
| `interrupt_goal(GoalId, message)` | New — push an operator note that the next turn's Claude prompt sees as `[OPERATOR INTERRUPT]`. FIFO across multiple pushes. |
| `set_goal_max_turns(GoalId, new_max)` | B2 — only-grow override of the live budget. Other axes stay at the original goal's `BudgetGuards`. |
| `shutdown(self)` | Cancel root, drain socket server, await tasks. |

### Per-goal state (DashMap'd)

- `pause_signals: DashMap<GoalId, watch::Sender<bool>>`
- `cancel_tokens: DashMap<GoalId, CancellationToken>`
- `budget_overrides: DashMap<GoalId, BudgetGuards>`
- `pending_interrupts: DashMap<GoalId, VecDeque<String>>`

All four are wiped at goal exit — no leaks.

## The loop in one screen

```
loop {
    drain pause signal — block while paused
    if cancel_root or per-goal cancel → break
    if budget exhausted (with B2 override) → break
    publish AttemptStarted
    build extras: prior_failures + budget_meta + operator_messages
    checkpoint pre-attempt (Phase 67.6 git worktree)
    result = run_attempt(ctx, params)  // see attempt.rs
    publish AttemptCompleted
    if last_was_compact → continue (compact turn doesn't bump turn_index)
    apply diff_stat to extras
    every N turns → publish Progress (Phase 67.C.1)
    classify outcome via replay policy:
      Done → break
      NeedsRetry → feed failures into next prompt, continue
      Continue/Escalate → ask replay-policy: FreshSessionRetry /
                          NextTurn / Escalate
    classify with compact-policy: schedule /compact for next turn
}
```

`attempt.rs` does the actual `spawn_turn` plumbing: builds the
`ClaudeCommand`, drains events, handles `compact_turn` /
`operator_messages` extras, persists the binding with origin +
dispatcher (B1), runs the acceptance evaluator on Claude-claimed
done.

## Subsidiary subsystems in this crate

| Module | Phase | Purpose |
|---|---|---|
| `events` || `DriverEvent` enum + `DriverEventSink` trait. NATS subjects: `agent.driver.{goal,attempt}.{started,completed}`, `decision`, `acceptance`, `budget.exhausted`, `escalate`, `replay`, `compact`, `progress`. `NoopEventSink` for tests; `NatsEventSink` is the production wire. |
| `replay` | 67.8 | `ReplayPolicy` trait + `DefaultReplayPolicy`. Classifies mid-turn errors as `FreshSessionRetry` / `NextTurn` / `Escalate`. Reads recent `Decision` rows for deny-shortcut grounding. |
| `compact` | 67.9 | `CompactPolicy` trait + `DefaultCompactPolicy`. Schedules `/compact <focus>` slash commands when token pressure crosses threshold. |
| `acceptance` | 67.5 | `AcceptanceEvaluator` trait + `DefaultAcceptanceEvaluator`. Runs `Shell` / `FileMatch` / `Custom` criteria post-Claude-claimed-done. Two built-in custom verifiers: `no_paths_touched`, `git_clean`. |
| `workspace` | 67.6 | `WorkspaceManager` — git-worktree-per-goal sandbox. Per-turn checkpoints + rollback. |
| `socket` | 67.3 | `DriverSocketServer` — Unix socket the MCP bin in `nexo-driver-permission` connects to. |
| `mcp_config` | 67.3 | Writes the per-goal MCP config JSON Claude reads. |
| `config` | 67.4 | YAML schema (`DriverConfig`). |
| `bin/nexo_driver.rs` || Standalone `nexo-driver` CLI. `run <goal-yaml>`, `list-active`, `list-worktrees`, `rollback`. The agent-bin (`nexo-rs`) calls into this crate directly via `boot_dispatch_ctx_if_enabled`. |

## See also

- `architecture/project-tracker.md` — programmer agent overview.
- `architecture/driver-subsystem.md` — full Phase 67 walkthrough.