From 3f494cb919f44bde1d205513d5d8958f0aaf7a90 Mon Sep 17 00:00:00 2001 From: "A.D.Lee" Date: Fri, 3 Jul 2026 03:06:58 +0000 Subject: [PATCH] spec+plan(slice-4): barge-in / VAD-driven playout kill on dedicated media thread (#6) --- .../2026-07-01-slice-4-dev-a-prompt.md | 211 ++ .../2026-07-01-slice-4-dev-b-prompt.md | 219 ++ .../kickoffs/2026-07-01-slice-4-pm-prompt.md | 150 ++ .../plans/2026-07-01-slice-4-barge-in.md | 1929 +++++++++++++++++ .../2026-07-01-slice-4-barge-in-design.md | 814 +++++++ 5 files changed, 3323 insertions(+) create mode 100644 docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md create mode 100644 docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md create mode 100644 docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md create mode 100644 docs/superpowers/plans/2026-07-01-slice-4-barge-in.md create mode 100644 docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md new file mode 100644 index 0000000..e7fa7fe --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-a-prompt.md @@ -0,0 +1,211 @@ +# Dev A Kickoff Prompt — slice-4 Plan A (Reflex + LocalVadReflex + MediaThread) + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are a **senior developer** owning the Reflex + thread chain for the slice-4 "barge-in / VAD-driven playout kill" release. A PM in another terminal coordinates you with dev-b. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. + +## Your scope (the Reflex + thread chain) + +You own the **FOB reflex wrapper + the dedicated media thread** — the FOB-owned, in-core pieces of slice-4. Tasks: +- **Task 1** (critical-path foundation — you run this FIRST, nobody parallelizes until it merges) +- **Task 2** (`Reflex

` state machine) +- **Task 2b** (`LocalVadReflex

` — the primary trigger, the wedge-#1 proof) +- **Task 6** (`MediaThread` — the dedicated std::thread; needs your Task 2+2b AND dev-b's Task 5) +- **Task 7** (`session_map.rs` rewire + `main.rs` + `routes.rs` — needs your Task 6) +- Possibly **Task 9** + **Task 10** (shared — PM assigns; likely you since your chain finishes Task 7 first) + +Your work embodies ARCHITECTURE.md's mandate: *"Local real-time reflexes... live in-core because the brain round-trip is too slow to enforce them."* Your `LocalVadReflex` (Task 2b) IS the wedge-#1 proof — the barge-in fires from the FOB's own inspection of caller audio, zero brain round-trip. + +## Setup (do this first) + +```bash +cd /home/alee/Sources/rutster +git fetch +git checkout main +git pull +git worktree add /home/alee/Sources/rutster.slice-4-dev-a -b slice-4-dev-a-reflex +cd /home/alee/Sources/rutster.slice-4-dev-a +pwd # should print /home/alee/Sources/rutster.slice-4-dev-a +``` + +**ALL subsequent work happens in `/home/alee/Sources/rutster.slice-4-dev-a`**. Force-cd subagents into this directory — per AGENTS.md (which overrides the default no-comments rule — read it). + +Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially "Code style (Rust)" (learner-facing comments override the global no-comments rule), "Terminology policy" (inclusive language; ICE is kept verbatim as a protocol name per RFC convention), "Git workflow" (squash-merge default + DCO signoff REQUIRED on every commit via `git commit -s`), "Slice-1 boundaries — what NOT to add (yet)." + +## Relay server + +A message-bus MCP server is running on `localhost:7110`. You have three native tools: + +- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"` +- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task +- `list_pending(for)` — check inbox count without consuming + +Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`. + +**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim: +```bash +export RELAY_PORT=7110 +cd ~/Sources/relay +python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}' +python3 call.py read_messages '{"for":"dev-a"}' +``` + +**Common pitfalls:** +- Prefer single-line `body` content (strict JSON parsers reject embedded `\n`). +- Use single quotes inside f-strings: `{m.get('from')}`. + +## Relay polling cadence — MANDATORY (do NOT go head-down) + +The #1 failure mode is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen. Do not be that dev. The ground can shift under you mid-task (e.g., dev-b's Task 5 signature revision could change the composition site in your Task 6). + +**Call `read_messages(for="dev-a")` (or `list_pending(for="dev-a")` for a cheap check) at ALL of these points:** +- Before dispatching EACH subagent — and again the moment it returns. +- Before EACH commit, and at the start + end of every task/step. +- Any time you've been heads-down for more than a few minutes. + +**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. + +## Required reading (in order) + +1. `AGENTS.md` — the whole thing (code style, git workflow with DCO, slice boundaries, multi-dev parallelism rules) +2. `docs/ARCHITECTURE.md` — §"Biggest technical risk" + §"Media plane" (the dedicated-thread mandate you're landing) +3. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — your scope is Tasks 1, 2, 2b, 6, 7 (+ possibly 9, 10) +4. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — your plan, execute task by task +5. `crates/rutster-media/src/pcm.rs` — the `AudioPipe` trait you'll extend (Task 1) + the `EchoAudioPipe` shape +6. `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` — **DO NOT TOUCH** (the seam gate, byte-identical to slice-3) + +## Execution mode + +Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks. + +**Every subagent prompt MUST start with**: +``` +cd /home/alee/Sources/rutster.slice-4-dev-a +``` +…before any other instruction. + +**Between every subagent dispatch, poll the relay** — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it. + +## Your scope and boundaries + +**In scope:** Task 1 (`AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` trait), Task 2 (`Reflex

`), Task 2b (`LocalVadReflex

`), Task 6 (`MediaThread`), Task 7 (`session_map.rs` rewire). Possibly Task 9 (e2e — PM assigns) and Task 10 (CI seam gate). + +**Out of scope (dev-b owns):** Task 3 (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`), Task 4 (`advisory_tx` through `run_tap_client`/`handle_brain_frame`), Task 5 (`spawn_tap_engine` signature change), Task 8 (`MockRealtimeBrain` schedule). If you trip over an out-of-scope issue, file a QUESTION TO PM and keep moving. + +**Hard rules:** +- **The seam gate:** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` MUST stay byte-identical to slice-3. CI will enforce. Do not touch. +- **Task 1 is critical-path foundation:** nobody parallelizes until Task 1 merges. Land it FIRST, alone, before starting Task 2. +- **DCO signoff:** every commit MUST be `git commit -s` (or `--signoff`). The signoff line uses your human's name + email, matching the git author identity. +- **`LocalVadReflex` (Task 2b) is the wedge-#1 proof:** the PRIMARY-path e2e (Task 9 step 1, if assigned to you) MUST kill playout WITHOUT any brain advisory. Watch this test carefully. +- Do not merge your branch to main. The PM owns merges. +- Do not push `--force` or run `git reset --hard`. Per `AGENTS.md`: ask first. + +## Coordination protocol + +You are one of 3 terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate. + +**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: +- When you dispatch a subagent +- When a subagent returns with a decision worth flagging +- When a sub-task within a phase completes +- When you change direction or hit something unexpected +- When you start a new phase + +The `Notes` field should narrate WHAT happened and WHY (decisions taken, surprises, trade-offs) — not just "Phase X done". Three sentences max. + +Also print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal. + +**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post your update via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format: + +``` +## STATUS UPDATE — DEV-A +Time: +Branch: slice-4-dev-a-reflex +Task: +Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY +Last commit: +Tests: +Notes: +``` + +**When you need PM input mid-task**: post via `post_message(kind="question")` with format: + +``` +## QUESTION TO PM — DEV-A +Time: +Context: +Options: +Recommended: +Blocker: yes | no +``` + +**Coordination point (load-bearing):** Task 6 needs dev-b's Task 5 (`spawn_tap_engine` accepting `advisory_tx` as a parameter). Before starting Task 6's composition site (where you construct the `(advisory_tx, advisory_rx)` pair + wire `LocalVadReflex>`), POST a QUESTION TO PM asking whether dev-b's Task 5 has merged. Hold Task 6's composition work until confirmed. + +**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act. + +## Ship-it autonomy + simplify discipline + +**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`, no `chmod 777`. If you genuinely need one of these, surface a QUESTION TO PM block. + +**Speed without spaghetti — required before every REVIEW-READY:** +- Invoke `superpowers:simplify` (or the project's equivalent code-review skill) on the changed code. +- Do not create parallel implementations of an existing helper. +- Do not add error handling, fallbacks, or validation for scenarios that can't happen (AGENTS.md forbids this). +- Default to learner-facing comments per AGENTS.md code style (this project OVERRIDES the global no-comments convention). +- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a QUESTION TO PM block. + +## Authority within the plan + +You don't need PM permission to: +- Execute task-to-task per the plan +- Make implementation decisions consistent with the plan and spec +- Write tests, refactor your own code, fix bugs you introduce +- Push commits to your feature branch (with DCO signoff) + +You **do** escalate to PM when: +- A scope question outside the plan +- A test you can't make green after honest debugging (don't fudge — debug) +- A discovered bug not in your plan +- Anything destructive (per project rules) +- Before opening the PR for review + +## Final steps before REVIEW-READY + +Run the project's full validation: + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +``` + +Then push and open the PR: + +```bash +git push -u origin slice-4-dev-a-reflex +tea pulls create \ + --head slice-4-dev-a-reflex \ + --base main \ + --title "slice-4 (dev-a): Reflex + LocalVadReflex + MediaThread + session_map rewire" \ + --description "## What lands +- Task 1: AdvisoryEvent + ReflexMetrics + barge_in_flush trait (critical-path foundation) +- Task 2: Reflex

state machine +- Task 2b: LocalVadReflex

(the wedge-#1 primary trigger) +- Task 6: MediaThread (dedicated std::thread) +- Task 7: session_map + main + routes rewire + +## What this proves +slice-4 dev-a's chain lands the FOB reflex loop + the dedicated media thread. Combined with dev-b's tap chain, the slice-4 e2e proves wedge #1 (caller speech → kill within 80ms, zero brain round-trip). + +## Merge instructions +- squash-merge +- DCO signoff on every commit (AGENTS.md)" +``` + +Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL. + +## First action + +After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `slice-4-dev-a-reflex`), then start Task 1 of your plan. Task 1 is the critical-path foundation — nobody parallelizes until it merges, so land it solo + REVIEW-READY before starting Task 2. diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md new file mode 100644 index 0000000..a62ff65 --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-dev-b-prompt.md @@ -0,0 +1,219 @@ +# Dev B Kickoff Prompt — slice-4 Plan B (TapAudioPipe + advisory + MockRealtimeBrain) + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are a **senior developer** owning the tap + advisory chain for the slice-4 "barge-in / VAD-driven playout kill" release. A PM in another terminal coordinates you with dev-a. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. + +## Your scope (the tap + advisory chain) + +You own the **brain↔FOB advisory plumbing + the mock brain's VAD schedule** — the pieces that wire brain-side advisories into dev-a's Reflex. Tasks: +- **Task 3** (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`) +- **Task 4** (`advisory_tx` through `run_tap_client` + `handle_brain_frame`) +- **Task 5** (`spawn_tap_engine` signature change — takes `advisory_tx` as a parameter) +- **Task 8** (`MockRealtimeBrain` advisory schedule) +- Possibly **Task 9** + **Task 10** (shared — PM assigns; likely dev-a since their chain finishes Task 7 first, but you might pick these up if your Task 8 lands before dev-a's Task 6 coordination point) + +**You HOLD until dev-a's Task 1 merges** (the critical-path foundation — `AdvisoryEvent` enum + `ReflexMetrics` + `barge_in_flush` trait). Your Task 3 depends on Task 1's `barge_in_flush` trait method. While you wait, read the plan + the slice-2/3 code for context. + +Your work wires the SECONDARY/confirmation trigger path (the brain's ASR-quality VAD). The PRIMARY trigger is dev-a's `LocalVadReflex` — but your advisory plumbing IS the path the wedge-#1 e2e test's secondary case exercises. + +## Setup (do this first) + +```bash +cd /home/alee/Sources/rutster +git fetch +git checkout main +git pull +git worktree add /home/alee/Sources/rutster.slice-4-dev-b -b slice-4-dev-b-tap +cd /home/alee/Sources/rutster.slice-4-dev-b +pwd # should print /home/alee/Sources/rutster.slice-4-dev-b +``` + +**ALL subsequent work happens in `/home/alee/Sources/rutster.slice-4-dev-b`**. Force-cd subagents into this directory. + +Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially "Code style (Rust)" (learner-facing comments override the global no-comments rule), "Terminology policy" (inclusive language; ICE is kept verbatim per RFC convention), "Git workflow" (squash-merge default + DCO signoff REQUIRED on every commit via `git commit -s`), "Slice-1 boundaries — what NOT to add (yet)." + +## Relay server + +A message-bus MCP server is running on `localhost:7110`. You have three native tools: + +- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"` +- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task +- `list_pending(for)` — check inbox count without consuming + +Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`. + +**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim: +```bash +export RELAY_PORT=7110 +cd ~/Sources/relay +python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}' +python3 call.py read_messages '{"for":"dev-b"}' +``` + +**Common pitfalls:** +- Prefer single-line `body` content (strict JSON parsers reject embedded `\n`). +- Use single quotes inside f-strings: `{m.get('from')}`. + +## Relay polling cadence — MANDATORY (do NOT go head-down) + +The #1 failure mode is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen. Do not be that dev. + +**Call `read_messages(for="dev-b")` (or `list_pending(for="dev-b")`) at ALL of these points:** +- Before dispatching EACH subagent — and again the moment it returns. +- Before EACH commit, and at the start + end of every task/step. +- Any time you've been heads-down for more than a few minutes. +- **Especially while you hold for Task 1:** poll every few minutes so you see dev-a's REVIEW-READY the moment it lands + the PM clears you to start. + +**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. + +## Required reading (in order — read this while holding for Task 1) + +1. `AGENTS.md` — the whole thing (code style, git workflow with DCO, slice boundaries) +2. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — your scope is Tasks 3, 4, 5, 8 (+ possibly 9, 10) +3. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — your plan, execute task by task +4. `crates/rutster-tap/src/tap_audio_pipe.rs` — the seam object you'll extend in Task 3 (the `barge_in_flush` override) +5. `crates/rutster-tap/src/tap_client.rs` — `handle_brain_frame` (line 323-421) + `run_tap_client` (line 140-270); Task 4 threads `advisory_tx` through both +6. `crates/rutster-tap/src/metrics.rs` — `TapMetrics` struct (Task 3 adds `barge_drained_inflight`) +7. `crates/rutster-brain-realtime/src/mock.rs` — `MockRealtimeBrain` (Task 8 adds the advisory schedule) +8. `crates/rutster-tap/src/protocol.rs` — the wire protocol (the `encode_speech_started`/`encode_speech_stopped` your code forwards in Task 4) +9. `crates/rutster/src/tap_engine.rs` — `spawn_tap_engine` (line 131-216) + `run_engine_loop` (line 240-356); Task 5 changes `spawn_tap_engine`'s signature + +## Execution mode + +Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks. + +**Every subagent prompt MUST start with**: +``` +cd /home/alee/Sources/rutster.slice-4-dev-b +``` +…before any other instruction. + +**Between every subagent dispatch, poll the relay.** + +## Your scope and boundaries + +**In scope:** Task 3 (`TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`), Task 4 (`advisory_tx` through `run_tap_client` + `handle_brain_frame`), Task 5 (`spawn_tap_engine` signature change), Task 8 (`MockRealtimeBrain` advisory schedule). Possibly Task 9 (e2e secondary path) + Task 10 (shared). + +**Out of scope (dev-a owns):** Task 1 (foundation — `AdvisoryEvent` + `ReflexMetrics` + the `barge_in_flush` trait method on `AudioPipe`), Task 2 (`Reflex

` state machine), Task 2b (`LocalVadReflex

`), Task 6 (`MediaThread`), Task 7 (`session_map.rs` rewire). If you trip over an out-of-scope issue, file a QUESTION TO PM and keep moving. + +**Hard rules:** +- **The seam gate:** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` MUST stay byte-identical to slice-3. You probably won't touch them, but if you find yourself wanting to, STOP — file a QUESTION TO PM. +- **HOLD until dev-a's Task 1 merges.** Task 3 depends on Task 1's `barge_in_flush` trait method. The PM will clear you to start. +- **DCO signoff:** every commit MUST be `git commit -s` (or `--signoff`). +- **Task 5 signature revision (load-bearing):** the plan originally sketched `spawn_tap_engine` returning a 3-tuple `(TapAudioPipe, TapConn, Option>)`. The revised plan (after the slice-4 adversarial review + the user's "both, local VAD primary" decision) changed this: `spawn_tap_engine` TAKES `advisory_tx: mpsc::Sender` as a parameter (the media thread owns the channel + clones the Sender; `tokio::sync::mpsc::Sender` is `Clone`). Follow the plan's **Task 5 revision note**, NOT the original 3-tuple sketch. The plan's Task 6 composition site (dev-a's MediaThread) constructs the `(tx, rx)` pair, clones `tx` for both `spawn_tap_engine` AND `LocalVadReflex::new`. This means your `spawn_tap_engine` signature is `pub fn spawn_tap_engine(session_id, tap_url, app_state, advisory_tx) -> (TapAudioPipe, TapConn)`. +- Do not merge your branch to main. The PM owns merges. +- Do not push `--force` or run `git reset --hard`. Per `AGENTS.md`: ask first. + +## Coordination protocol + +You are one of 3 terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate. + +**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: +- When you dispatch a subagent +- When a subagent returns with a decision worth flagging +- When a sub-task within a phase completes +- When you change direction or hit something unexpected +- When you start a new phase + +The `Notes` field should narrate WHAT happened and WHY — not just "Phase X done". Three sentences max. + +Also print every STATUS UPDATE locally before/after sending it. + +**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")` first, then post your update via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Format: + +``` +## STATUS UPDATE — DEV-B +Time: +Branch: slice-4-dev-b-tap +Task: +Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY +Last commit: +Tests: +Notes: <3 sentences max> +``` + +**When you need PM input mid-task**: post via `post_message(kind="question")` with format: + +``` +## QUESTION TO PM — DEV-B +Time: +Context: +Options: +Recommended: +Blocker: yes | no +``` + +**Coordination point (load-bearing):** dev-a's Task 6 (`MediaThread`) needs your Task 5 (`spawn_tap_engine` accepting `advisory_tx`). When dev-a posts a QUESTION TO PM about whether your Task 5 has merged, post a STATUS UPDATE confirming. If you're still mid-Task-5, surface an ETA. + +**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act. + +## Ship-it autonomy + simplify discipline + +**Hard guardrails:** no `rm` / `rmdir`, no `git push --force`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`, no `chmod 777`. If you genuinely need one of these, surface a QUESTION TO PM block. + +**Speed without spaghetti — required before every REVIEW-READY:** +- Invoke `superpowers:simplify` on the changed code. +- Do not create parallel implementations of an existing helper. +- Do not add error handling, fallbacks, or validation for scenarios that can't happen. +- Default to learner-facing comments per AGENTS.md code style. +- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a QUESTION TO PM block. + +## Authority within the plan + +You don't need PM permission to: +- Execute task-to-task per the plan +- Make implementation decisions consistent with the plan and spec +- Write tests, refactor your own code, fix bugs you introduce +- Push commits to your feature branch (with DCO signoff) + +You **do** escalate to PM when: +- A scope question outside the plan +- A test you can't make green after honest debugging +- A discovered bug not in your plan +- Anything destructive +- Before opening the PR for review + +## MockRealtimeBrain schedule API (Task 8 open decision) + +The plan proposes `set_advisory_schedule(Vec)` where `AdvisoryTrigger { after_audio_in_frames: u32, event: AdvisoryKind }`. If a cleaner shape emerges during implementation (e.g., a free-form `Vec<(trigger_frame_count, AdvisoryEvent)>` queue), use it — but surface the deviation in the STATUS UPDATE Notes so the PM can sanity-check. The slice-4 e2e (Task 9) is the consumer of this API; if you change the shape, Task 9 (whoever picks it up) needs the new name. + +## Final steps before REVIEW-READY + +Run the project's full validation: + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +``` + +Then push and open the PR: + +```bash +git push -u origin slice-4-dev-b-tap +tea pulls create \ + --head slice-4-dev-b-tap \ + --base main \ + --title "slice-4 (dev-b): TapAudioPipe::barge_in_flush + advisory_tx + MockRealtimeBrain schedule" \ + --description "## What lands +- Task 3: TapAudioPipe::barge_in_flush override + barge_drained_inflight metric +- Task 4: advisory_tx through run_tap_client + handle_brain_frame +- Task 5: spawn_tap_engine takes advisory_tx parameter (signature revision) +- Task 8: MockRealtimeBrain advisory schedule + +## What this enables +The brain's speech_started/speech_stopped advisory reaches dev-a's Reflex via the dedicated advisory_tx side-channel. This is the SECONDARY/confirmation path (the PRIMARY is dev-a's LocalVadReflex). The slice-4 e2e's secondary case exercises this. + +## Merge instructions +- squash-merge +- DCO signoff on every commit (AGENTS.md)" +``` + +Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL. + +## First action + +After reading + setup: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `slice-4-dev-b-tap`, reading slice-2/3 code for context). State explicitly: "HOLD for dev-a Task 1 REVIEW-READY; polling pm inbox." Do NOT start Task 3 until the PM clears you (dev-a's Task 1 must merge first). Poll every few minutes while you read. diff --git a/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md b/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md new file mode 100644 index 0000000..42696a9 --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-01-slice-4-pm-prompt.md @@ -0,0 +1,150 @@ +# PM Kickoff Prompt — slice-4 barge-in / VAD-driven playout kill + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are the **project manager** for the **slice-4 "barge-in / VAD-driven playout kill"** release. 2 senior developers report to you, each working in their own terminal on a parallel feature branch within a shared worktree. The user runs all 3 terminals and (with the relay server) reads along as you coordinate. + +## Setup + +- Working directory: `/home/alee/Sources/rutster` +- Branch: stay on `main` (or slice-4-barge-in-design if coordinating the spec). Do not check out feature branches. +- Today: 2026-07-01. Project rules in `AGENTS.md` apply — READ THEM IN FULL, especially the "Multi-agent coordination — the relay" section, "PM session launch checklist," "Multi-dev parallelism" (5-rule checklist), "Session handoff," and "Git workflow" (squash-default + rebase-merge carve-out for stacked branches). + +## Relay server + +A message-bus MCP server is running on `localhost:7110`. You have three native tools: + +- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you +- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action +- `list_pending(for)` — check inbox count without consuming + +Recipients: `pm, dev-a, dev-b, dev-c, dev-d, dev-e, dev-f`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`. + +**Turn-start discipline (load-bearing — AGENTS.md "PM-mode discipline"):** at the start of EVERY turn before responding to the user: (1) drain `pm` inbox via `read_messages(for="pm")`; (2) `list_pending` for each dev role; (3) `git log --oneline --all -10`; (4) surface anything actionable BEFORE the user asks. + +**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead: +```bash +export RELAY_PORT=7110 +cd ~/Sources/relay +python3 call.py read_messages '{"for":"pm"}' +python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}' +``` +Also consider launching the poller (`~/Sources/relay/poller.py`) per AGENTS.md "PM session launch checklist" — it keeps state warm between turns. + +## Required reading (in order) + +1. `AGENTS.md` — the whole thing (PM-mode discipline, multi-dev parallelism rules, git workflow, slice-4 boundaries) +2. `docs/ARCHITECTURE.md` — §"Biggest technical risk" (the reflex loop IS the long pole), §"Media plane" (dedicated timing threads mandate this slice lands) +3. `docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md` — the slice-4 spec (revised 2026-07-01: local VAD primary, advisory secondary) +4. `docs/superpowers/plans/2026-07-01-slice-4-barge-in.md` — the implementation plan (11 tasks; structured for parallel dispatch) +5. `docs/adr/0002-north-star-and-fused-core.md` — the fused-vertical rule (no hop on the per-call hot path — slice-4 re-affirms this) +6. `docs/adr/0008-fob-and-green-zone.md` — FOB/green-zone doctrine (the reflex is a FOB member) + +## The two dev scopes (single plan, partitioned) + +This is ONE plan (`docs/superpowers/plans/2026-07-01-slice-4-barge-in.md`) split across 2 devs by task partition. The dependency graph is: + +- **Task 1** (critical-path foundation: `AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` trait) MUST land first. Nobody parallelizes until Task 1 merges. **dev-a executes Task 1 first** while dev-b holds. +- After Task 1 merges, fan-out: + - **dev-a** owns the Reflex + thread chain: Task 2 (`Reflex

`), Task 2b (`LocalVadReflex

` — the wedge-#1 primary trigger), Task 6 (`MediaThread` — needs 2+2b+5), Task 7 (`session_map` rewire — needs 6) + - **dev-b** owns the tap + advisory chain: Task 3 (`TapAudioPipe::barge_in_flush`), Task 4 (`advisory_tx` through `run_tap_client`/`handle_brain_frame`), Task 5 (`spawn_tap_engine` signature — needs 3+4), Task 8 (`MockRealtimeBrain` schedule — needs 5) +- **Coordination point:** Task 6 (dev-a) needs BOTH `Reflex

` + `LocalVadReflex

` (dev-a's Task 2+2b) AND `spawn_tap_engine` taking `advisory_tx` (dev-b's Task 5). When dev-a is ready to start Task 6, dev-b must have merged Task 5. +- **Shared tasks:** Task 9 (e2e — needs 6+7+8) + Task 10 (CI seam gate — needs 7). Whoever is free first picks them up; PM arbitrates. Likely dev-a (their chain lands Task 7 first). + +## Your authority + +- Approve or deny scope changes from devs +- Review and merge PRs from each dev's feature branch +- Drive any release-prep work that isn't a feature plan (CHANGELOG, version bumps) — your hands-on work +- **The user authorizes merges via your session** — do not merge without showing the user the diff is green +- Arbitrate the shared tasks (9 + 10) — assign to whichever dev finishes their chain first +- Re-scope if a dev's chain stalls (e.g. dev-b's Task 4 hits unexpected tap_client complexity — you can shift dev-a's finished Task 2 dev to help) + +## Your boundaries + +- Don't write feature code yourself. Edits to `AGENTS.md` / specs / CHANGELOG are fine. +- Don't deviate from the spec without user approval. The slice-4 spec was just revised after adversarial review (advisory-only → local VAD primary); re-decisions need user input. +- **Don't merge a PR until** the dev says `REVIEW-READY` AND you've run `tea pr view ` + `tea pr diff ` to confirm green CI + spec-conformance. +- Don't tag without user approval. +- Per AGENTS.md: ask before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`). + +## The seam gate — broadcast to BOTH devs + +`loop_driver.rs` + `rtc_session.rs` MUST stay byte-identical to slice-3. The §8.5 #6 invariant. CI will enforce via `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs`. The `barge_in_flush` trait method adds to `pcm.rs`, NOT to those files. The reflex wrapper decorates on the binary side, NOT in `rtc_session.rs`. State this in your opening directive to both devs. + +## The wedge-#1 audit (the load-bearing thing) + +The slice-4 spec was revised after a 2026-07-01 adversarial review. The reviewer's finding #3: advisory-only (the initial brainstorming decision) puts the brain round-trip in the barge-in trigger path — contradicting ARCHITECTURE.md:79-81 ("the brain round-trip is too slow to enforce them") + README:98-100 ("VAD killing TTS the instant the caller speaks, without the brain"). The revision: `LocalVadReflex

` (Task 2b) is the PRIMARY trigger — RMS/energy VAD in `on_pcm_frame`, runs on the dedicated thread in the 20ms loop, zero brain round-trip. The brain's advisory becomes the SECONDARY/confirmation path. + +**The proof is Task 9, Step 1** (the PRIMARY-path e2e test): loud caller audio → kill within ≤80ms wallclock, WITHOUT any brain advisory. If this test passes, slice-4 proves wedge #1. If it doesn't, slice-4 doesn't prove the property the spearhead exists for. Watch dev-a's Task 9 step 1 closely. + +## Judgment calls in the plan (for your awareness) + +- **DCO signoff now required on every commit.** AGENTS.md gained this in the same 2026-07-01 docs PR (#5). Devs MUST `git commit -s` (or `--signoff`) on every commit. The agent signs off with the human's name + email, NOT the agent's identity. If a dev commits without `-s`, surface it before merge. +- **Task 5 signature:** `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a parameter (the media thread owns the channel; `tokio::sync::mpsc::Sender` is `Clone`, so two senders — the engine + the local VAD — share one receiver). This was revised mid-plan; dev-b should follow the plan's Task 5 revision note, NOT the original 3-tuple-return sketch. +- **Task 6 composition site:** the `Connected` spawn in `media_thread.rs` constructs the `(advisory_tx, advisory_rx)` pair, clones the Sender to `spawn_tap_engine` AND `LocalVadReflex::new`, hands the Receiver to `Reflex::new`. The composition is `LocalVadReflex>`. +- **MockRealtimeBrain schedule API (Task 8):** the plan leaves the exact API shape as an open decision (`set_advisory_schedule(Vec)` proposed). The dev can adjust if a cleaner shape emerges; surface non-trivial deviations. + +## Coordination protocol + +You are one of 3 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. + +**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly. One or two sentences per beat is plenty. + +**You receive:** `## STATUS UPDATE — DEV-` or `## QUESTION TO PM — DEV-` blocks from the relay inbox. + +**You emit:** a `## DIRECTIVE TO DEV-` block — post it via `post_message` AND print it here so the user can see it. Format: + +``` +## DIRECTIVE TO DEV- +Time: +Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED +Notes: +Next: +``` + +**Confirm your directives are actually seen.** Devs are told to poll their inbox constantly, but a head-down dev can still miss a HOLD/RESCOPE. After posting a HOLD or RESCOPE, watch that dev's next STATUS UPDATE for an explicit acknowledgement. + +When asked "status?" by the user at any time, give a current rollup: + +``` +## RELEASE STATUS — slice-4 barge-in +Devs: +PM: +Blockers: +Next milestone: +``` + +## Reviewing PRs + +When a dev posts `Action: REVIEW-READY` with a PR URL: +1. `tea pr view ` to read description + CI status +2. `tea pr diff ` to read changes +3. Check the diff against the spec + plan acceptance criteria (esp. the seam gate — `loop_driver.rs` + `rtc_session.rs` untouched) +4. If green: post `Action: MERGE-APPROVED` + `tea pr merge ` (squash-merge default per AGENTS.md) +5. If red: post `Action: HOLD` with specific concerns + +Use the `superpowers:requesting-code-review` skill if you want a deeper independent review. + +## Pre-tag checklist + +Before tagging `slice-4`: + +- [ ] Every dev branch merged to `main` (squash-merge default) +- [ ] All 11 plan tasks landed (verify against the plan's task list) +- [ ] Full test suite green on main: `cargo fmt --check && cargo clippy -- -D warnings && cargo test --all && cargo deny check` (stable + 1.85 matrix) +- [ ] Seam gate passes: `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rs` +- [ ] User-driven smoke test (the wedge-#1 demo: caller speaks → brain audio stops within 80ms, no advisory needed) +- [ ] Explicit user approval to tag + +## First action + +1. Call `read_messages(for="pm")` to drain any early inbox messages. +2. `git log --oneline --all -10` + `pgrep -af poller.py` (verify poller alive per AGENTS.md checklist). +3. Emit a `## RELEASE STATUS` block confirming you've absorbed spec + plan, noting the wedge-#1 audit + the seam gate as the load-bearing things to watch. +4. Send opening directives to both devs via `post_message`: + - dev-a: "PROCEED with Task 1 (critical-path foundation). Task 1 MUST land before anyone parallelizes. When REVIEW-READY, post status + open PR." + - dev-b: "HOLD until Task 1 merges. Read the plan, plus the slice-2 `tap_audio_pipe.rs` + slice-3 `tap_client.rs` for context on Tasks 3+4. Surface questions via QUESTION TO PM." +5. Wait for dev-a's REVIEW-READY on Task 1 before clearing dev-b to start Task 3. diff --git a/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md b/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md new file mode 100644 index 0000000..7a4ff0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-slice-4-barge-in.md @@ -0,0 +1,1929 @@ +# Slice 4 — Barge-in / VAD-driven playout kill — 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:** Stand up spearhead step 4 — the FOB reflex loop that kills playout on brain `speech_started` advisory and resumes on the first fresh `audio_out` post-barge, running on a dedicated `std::thread` (not the tokio pool). + +**Architecture:** A `Reflex` wrapper in `rutster-media` decorates the existing `TapAudioPipe`, instrumenting `next_pcm_frame` with a mute state machine driven by an `AdvisoryEvent` mpsc drained sync on the 20 ms tick. A new `MediaThread` (std::thread) in the binary owns all `RtcSession`s exclusively, driven by a command channel from axum (cold-path only). `loop_driver.rs` + `rtc_session.rs` stay byte-identical (the §8.5 #6 seam gate). + +**Tech Stack:** Rust stable + 1.85 (CI matrix), `str0m` (sans-IO WebRTC), `tokio` (control plane + TapEngine), `std::thread` (media loop), `tokio::sync::mpsc`/`oneshot` (thread bridge), `dashmap` (session index — now indexed by cmd_tx), `tracing`. + +## Global Constraints + +- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004). +- **Seam gate:** `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 (CI `git diff --exit-code`). +- **Hot-path policy:** never `?`-propagate; match-and-continue; "drop + observe (log + counter), don't crash." No `unwrap()`/`expect()` outside tests or const-init. +- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is the lint bar. Newtype wrappers over primitives (e.g. `ChannelId(Uuid)`). +- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts. +- **Learner-facing comments:** `//!` module docs, `///` item docs, `//` inline "why" — per AGENTS.md code style (this project OVERRIDES the default "no comments" convention). +- **Async:** `tokio` for control plane + TapEngine; `std::thread` for the 20 ms media loop (ARCHITECTURE.md mandate, landed this slice). +- **Terminology:** inclusive language (enforce/gate/guard, primary/replica, denylist/allowlist). Exception: protocol-convention names from upstream specs (ICE, str0m identifiers) kept verbatim. +- **Error handling:** cold path = `thiserror` + `?`; hot path = match-and-continue, no `?`. +- **CI gates:** `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all` (stable + 1.85), `cargo deny check`. + +## File Structure + +### New files + +| Path | Responsibility | +|---|---| +| `crates/rutster-media/src/reflex.rs` | `AdvisoryEvent` enum, `Reflex

` wrapper, `ReflexMetrics` — the FOB reflex state machine + the decorator over `AudioPipe`. | +| `crates/rutster/src/media_thread.rs` | `MediaThread`, `MediaCmd` — the dedicated std::thread owning `HashMap` exclusively; the 10ms meta-tick; the spawn seam for TapEngine on `Connected`. | + +### Modified files + +| Path | What changes | +|---|---| +| `crates/rutster-media/src/pcm.rs` | `AudioPipe` trait gains `fn barge_in_flush(&mut self) { self.clear_playout_ring(); }` (default impl). `EchoAudioPipe`'s existing `impl AudioPipe` is unchanged (inherits default). | +| `crates/rutster-media/src/lib.rs` | `pub mod reflex;` + `pub use reflex::{AdvisoryEvent, Reflex, ReflexMetrics, ReflexMetricsSnapshot};`. | +| `crates/rutster-tap/src/tap_audio_pipe.rs` | `TapAudioPipe` overrides `barge_in_flush` to clear ring + drain `rx_audio_out`; `TapMetrics` gains `barge_drained_inflight: AtomicU64`. | +| `crates/rutster-tap/src/metrics.rs` | `TapMetrics` gains `barge_drained_inflight` field + snapshot. | +| `crates/rutster-tap/src/tap_client.rs` | `handle_brain_frame` + `run_tap_client` gain an `advisory_tx: &mpsc::Sender` param; `SpeechStarted`/`SpeechStopped` arms forward via `try_send` instead of just logging. | +| `crates/rutster/src/tap_engine.rs` | `spawn_tap_engine` constructs the `advisory_tx`/`advisory_rx` pair, returns an `advisory_tx` end inside `TapConn` (3rd side-channel alongside flush/function_call). `run_engine_loop` + `run_tap_client` call sites updated. | +| `crates/rutster/src/session_map.rs` | **Rewired:** `SessionEntry.rtc: Arc>` → `cmd_tx: mpsc::Sender`. `create_session`/`get`/`close` route via command channel. `spawn_poll_task` → `spawn_media_thread`. The `Connected`-transition spawn seam moves to `media_thread.rs`. | +| `crates/rutster/src/lib.rs` | `pub mod media_thread;` added. | +| `crates/rutster/src/main.rs` | `state.spawn_poll_task().await` → `let _media = state.spawn_media_thread().await;` (the handle drops on shutdown). | +| `crates/rutster/src/routes.rs` | `AppState::get` call sites updated to the async `AcceptOffer` command pattern; `close` to `Delete`. | +| `crates/rutster-brain-realtime/src/mock.rs` | `MockRealtimeBrain` gains a programmable advisory schedule: `set_advisory_schedule(Vec)` where `AdvisoryTrigger { after_audio_in_frames: u32, event: AdvisoryKind }`. The accept loop applies the schedule per-connection. | + +### SEAM-INVARIANT files (DO NOT TOUCH) + +- `crates/rutster-media/src/loop_driver.rs` — **byte-identical** to slice-3. +- `crates/rutster-media/src/rtc_session.rs` — **byte-identical** to slice-3. + +Every dispatched dev MUST respect this. The `barge_in_flush` trait method addition lands in `pcm.rs` (not in loop_driver/rtc_session). The reflex wrapper decorates on the binary side (`media_thread.rs`), not inside `RtcSession`. + +## Task ordering (for multi-agent dispatch) + +The tasks are sequenced so the blocking critical-path foundation lands FIRST. The "parallelizable-now" filler work is called out per-task. + +- **Task 1** — CRITICAL-PATH FOUNDATION. `AdvisoryEvent` + `ReflexMetrics` + `barge_in_flush` on `AudioPipe`. All later tasks consume these types. LAND FIRST; nothing else parallelizes until Task 1 merges. +- **Task 2** — depends on Task 1. `Reflex

` state machine. Standalone unit-testable with a mock pipe. +- **Task 2b** — depends on Tasks 1+2. `LocalVadReflex

` — the PRIMARY trigger (RMS/energy VAD in `on_pcm_frame`, the wedge-#1 proof). Standalone unit-testable with a mock pipe. The decorator pattern from §6.4; composes as `LocalVadReflex>` in Task 6's spawn site. +- **Task 3** — depends on Task 1. `TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight`. Standalone. +- **Task 4** — depends on Tasks 1+3. `advisory_tx` threaded through `run_tap_client` + `handle_brain_frame`. The tap-client adhesive between the brain and the Reflex (the SECONDARY trigger path). +- **Task 5** — depends on Tasks 1+3+4. `spawn_tap_engine` returns `advisory_tx` end; TapConn gains `advisory_tx`. +- **Task 6** — depends on Tasks 2+2b+5. `MediaThread` — the binary-side dedicated std::thread; owns RtcSessions + spawns TapEngine + wires `LocalVadReflex>` on `Connected`. +- **Task 7** — depends on Task 6. `session_map.rs` rewire + `main.rs` + `routes.rs` to the command-channel pattern. +- **Task 8** — depends on Task 5. `MockRealtimeBrain` advisory schedule. +- **Task 9** — depends on Tasks 6+7+8. Barge-in e2e integration test extending slice-3's `realtime_integration.rs`. TWO cases: (a) PRIMARY — loud local audio → kill WITHOUT advisory; (b) SECONDARY — quiet local audio + brain advisory → kill → fresh audio_out → resume. +- **Task 10** — depends on Task 7. CI seam gate (`git diff --exit-code` for loop_driver/rtc_session) + final fmt/clippy/test sweep. + +Parallelizable-now filler (a blocked dev picks these up without blocking the critical path): +- LEARNING.md pointers to the new `reflex.rs` + `media_thread.rs` (after Task 2 + Task 6 land). +- README dev-loop updates (after Task 7 lands). +- `cargo doc` rendering checks (after Task 2 + Task 6). + +--- + +### Task 1: `AdvisoryEvent` enum + `ReflexMetrics` + `barge_in_flush` trait method — the critical-path foundation + +**Files:** +- Create: `crates/rutster-media/src/reflex.rs` +- Modify: `crates/rutster-media/src/pcm.rs:102-115` (the `AudioPipe` trait) +- Modify: `crates/rutster-media/src/lib.rs:33-40` (module declarations + re-exports) +- Test: `crates/rutster-media/src/reflex.rs` (inline `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `PcmFrame` (from `pcm.rs`), `AudioPipe` trait (from `pcm.rs`). +- Produces: + - `pub enum AdvisoryEvent { SpeechStarted { at: Instant }, SpeechStopped { at: Instant } }` + - `pub struct ReflexMetrics { barge_in_count: AtomicU64, advisory_dropped: AtomicU64, frames_suppressed: AtomicU64, advisory_observed_speech_stopped: AtomicU64 }` + `ReflexMetrics::new() -> Arc` + `ReflexMetrics::snapshot() -> ReflexMetricsSnapshot` + - `pub struct ReflexMetricsSnapshot { barge_in_count: u64, advisory_dropped: u64, frames_suppressed: u64, advisory_observed_speech_stopped: u64 }` + - `AudioPipe::barge_in_flush(&mut self)` — default impl delegates to `clear_playout_ring`. + +- [ ] **Step 1: Write the failing test for `AdvisoryEvent` + `ReflexMetrics`** + +Create `crates/rutster-media/src/reflex.rs` with the test module only: + +```rust +//! # Reflex — the FOB barge-in reflex (spec §3.1, §3.2; slice-4) +//! +//! `Reflex` is the decorator that instruments the existing +//! `AudioPipe` with turn-taking reflexes: a `speech_started` advisory kills +//! playout (clears the ring + drains in-flight brain audio); the first +//! fresh `audio_out` after the barge resumes playout. The wrapper is +//! invisible to `loop_driver::drive` — it still calls +//! `session.pipe.next_pcm_frame()` — so the seam +//! (`loop_driver.rs` + `rtc_session.rs` byte-identical) holds. +//! +//! # Why a decorator (not inline in `TapAudioPipe`) +//! +//! Composition: a future `LocalVadReflex

` composes outside the advisory +//! `Reflex

`, the same way `Reflex` composes today. The +//! pattern is forward-compatible without restructuring when local VAD +//! arrives (deferred per slice-4 §1.2). + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use tokio::sync::mpsc; + +use crate::pcm::{AudioPipe, AudioSource, AudioSink, PcmFrame}; + +/// A turn-event advisory from the brain. The brain decodes its own +/// speech-to-text / VAD results and forwards these; the FOB *owns* +/// turn-taking and acts on them (slice-3 §4.3 — OpenAI Realtime +/// server-side VAD is DISABLED; the FOB's reflex is authoritative). +/// +/// Carried over a tokio mpsc from the TapEngine (tokio task) to the +/// `Reflex` wrapper (media thread). Drained sync via `try_recv` on the +/// 20 ms tick — the kill decision lives in the loop, not in a handler. +#[derive(Debug)] +pub enum AdvisoryEvent { + /// The brain detected caller speech. Trigger barge-in: kill playout. + SpeechStarted { at: Instant }, + /// The brain detected caller speech ended. Observed + counted; does + /// NOT toggle mute (the resume condition is "first fresh audio_out + /// after the barge", not "speech_stopped" — see slice-4 spec §3.2). + SpeechStopped { at: Instant }, +} + +/// Reflex counters — the observable surface for the reflex loop's +/// decision-making. Mirrors `TapMetrics` shape (atomics + snapshot). +/// +/// `barge_drained_inflight` lives on `TapMetrics` (in `rutster-tap`), +/// NOT here — the drain happens inside `TapAudioPipe::barge_in_flush`, +/// not inside `Reflex`. See slice-4 spec §3.5. +#[derive(Default)] +pub struct ReflexMetrics { + pub barge_in_count: AtomicU64, + pub advisory_dropped: AtomicU64, + pub frames_suppressed: AtomicU64, + pub advisory_observed_speech_stopped: AtomicU64, +} + +impl ReflexMetrics { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn snapshot(&self) -> ReflexMetricsSnapshot { + ReflexMetricsSnapshot { + barge_in_count: self.barge_in_count.load(Ordering::Relaxed), + advisory_dropped: self.advisory_dropped.load(Ordering::Relaxed), + frames_suppressed: self.frames_suppressed.load(Ordering::Relaxed), + advisory_observed_speech_stopped: self + .advisory_observed_speech_stopped + .load(Ordering::Relaxed), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ReflexMetricsSnapshot { + pub barge_in_count: u64, + pub advisory_dropped: u64, + pub frames_suppressed: u64, + pub advisory_observed_speech_stopped: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reflex_metrics_snapshot_reads_zeroes_initially() { + let m = ReflexMetrics::new(); + let s = m.snapshot(); + assert_eq!(s, ReflexMetricsSnapshot { + barge_in_count: 0, + advisory_dropped: 0, + frames_suppressed: 0, + advisory_observed_speech_stopped: 0, + }); + } + + #[test] + fn reflex_metrics_snapshot_reflects_increments() { + let m = ReflexMetrics::new(); + m.barge_in_count.fetch_add(3, Ordering::Relaxed); + m.frames_suppressed.fetch_add(7, Ordering::Relaxed); + m.advisory_observed_speech_stopped.fetch_add(2, Ordering::Relaxed); + let s = m.snapshot(); + assert_eq!(s.barge_in_count, 3); + assert_eq!(s.frames_suppressed, 7); + assert_eq!(s.advisory_observed_speech_stopped, 2); + } + + #[test] + fn advisory_event_variants_are_debug() { + // Smoke: the enum must be Debug-renderable for tracing. + let s = AdvisoryEvent::SpeechStarted { at: Instant::now() }; + let _ = format!("{:?}", s); + let st = AdvisoryEvent::SpeechStopped { at: Instant::now() }; + let _ = format!("{:?}", st); + } +} +``` + +- [ ] **Step 2: Add `barge_in_flush` default method to the `AudioPipe` trait** + +In `crates/rutster-media/src/pcm.rs`, modify the `AudioPipe` trait (around line 102-115) to add the default method. The trait body becomes (inserting after `clear_playout_ring`): + +```rust +pub trait AudioPipe: AudioSource + AudioSink { + /// Clear any buffered playout frames (slice-2 spec §5.3 step 4). + fn clear_playout_ring(&mut self) {} + + /// Barge-in flush: clear the playout ring AND drain the inbound brain + /// audio queue of any frames queued before the barge (slice-4 spec §3.3). + /// Called by `Reflex` on `SpeechStarted`. The drain of `rx_audio_out` + /// is what makes the resume condition race-free: the first `audio_out` + /// observed post-barge is provably post-barge (frames queued pre-barge + /// are dropped here). + /// + /// Default impl delegates to `clear_playout_ring` — sufficient for + /// pipes without an inbound queue to drain (like `EchoAudioPipe`). + fn barge_in_flush(&mut self) { + self.clear_playout_ring(); + } +} +``` + +- [ ] **Step 3: Wire the module + re-exports in `lib.rs`** + +Modify `crates/rutster-media/src/lib.rs` (around line 33-40). Add `pub mod reflex;` in the module declarations block (after `pub mod rtc_session;`) and add the re-exports after the existing `pub use pcm::...`: + +```rust +pub mod loop_driver; +pub mod opus_codec; +pub mod pcm; +pub mod reflex; +pub mod rtc_session; + +pub use opus_codec::{OpusDecoder, OpusEncoder}; +pub use pcm::{AudioPipe, AudioSink, AudioSource, EchoAudioPipe, PcmFrame, SAMPLES_PER_FRAME}; +pub use reflex::{AdvisoryEvent, Reflex, ReflexMetrics, ReflexMetricsSnapshot}; +pub use rtc_session::{RtcSession, RtcSessionError}; +``` + +(Leave the rest of `lib.rs` — `MediaError` etc. — unchanged.) + +- [ ] **Step 4: Run the test to verify it fails** — `Reflex` is referenced in the re-export but not yet defined in `reflex.rs` beyond the enum/metrics. The test file should compile and pass (the tests only exercise `ReflexMetrics` + `AdvisoryEvent`, both already in the file from Step 1). + +Run: `cargo test -p rutster-media --lib reflex::tests` +Expected: PASS (3 tests). If it fails to compile with "cannot find type `Reflex`," that's because the re-export names `Reflex` before it's defined — temporarily comment out `Reflex` from the `pub use` line; Task 2 defines it. + +```bash +# Temporary: comment Reflex from the re-export until Task 2 lands +# pub use reflex::{AdvisoryEvent, ReflexMetrics, ReflexMetricsSnapshot}; +``` + +- [ ] **Step 5: Run the full workspace test + fmt + clippy** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +``` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/pcm.rs crates/rutster-media/src/lib.rs +git commit -m "feat(media): AdvisoryEvent + ReflexMetrics + barge_in_flush trait (slice-4 §3.1, §3.3) + +The critical-path foundation for the barge-in reflex. AdvisoryEvent is +the enum carried over a tokio mpsc from TapEngine to Reflex (brain → +FOB). ReflexMetrics is the observable surface. barge_in_flush is the +new AudioPipe trait method (default delegates to clear_playout_ring) — +the kill-now path that clears the ring AND drains rx_audio_out. + +Task 1 of the slice-4 plan. Everything else depends on this landing."" +``` + +--- + +### Task 2: `Reflex

` state machine + decorator impl + +**Files:** +- Modify: `crates/rutster-media/src/reflex.rs` (add the struct + impl) +- Modify: `crates/rutster-media/src/lib.rs` (uncomment `Reflex` from the re-export) +- Test: `crates/rutster-media/src/reflex.rs` (inline tests) + +**Interfaces:** +- Consumes: `AdvisoryEvent`, `ReflexMetrics` (from Task 1), `AudioPipe` trait + `PcmFrame` (from `pcm.rs`), tokio `mpsc::Receiver`. +- Produces: + - `pub struct Reflex { inner: P, advisory_rx: mpsc::Receiver, muted: bool, barge_epoch: u64, metrics: Arc }` + - `impl Reflex

` with `pub fn new(inner: P, advisory_rx: mpsc::Receiver, metrics: Arc) -> Self` + - `impl AudioPipe for Reflex

` (`next_pcm_frame` applies the state table; `on_pcm_frame` delegates; `clear_playout_ring` delegates; `barge_in_flush` delegates). + +- [ ] **Step 1: Write the failing tests for the state machine** + +Append to the `#[cfg(test)] mod tests` in `crates/rutster-media/src/reflex.rs`: + +```rust + /// A minimal mock pipe for unit-testing Reflex. Captures on_pcm_frame + /// inputs + returns a pre-loaded queue of frames from next_pcm_frame + /// so we can simulate "brain audio_out arrived" deterministically. + struct MockPipe { + queued: std::collections::VecDeque, + flush_calls: usize, + barge_calls: usize, + } + + impl MockPipe { + fn new() -> Self { + Self { queued: Default::default(), flush_calls: 0, barge_calls: 0 } + } + fn push_frame(&mut self, frame: PcmFrame) { + self.queued.push_back(frame); + } + } + + impl AudioSource for MockPipe { + fn next_pcm_frame(&mut self) -> Option { + self.queued.pop_front() + } + } + + impl AudioSink for MockPipe { + fn on_pcm_frame(&mut self, _frame: PcmFrame) { + // capture count via separate test-side state if needed + } + } + + impl AudioPipe for MockPipe { + fn clear_playout_ring(&mut self) { + self.flush_calls += 1; + self.queued.clear(); + } + fn barge_in_flush(&mut self) { + self.barge_calls += 1; + self.queued.clear(); + } + } + + fn setup() -> (Reflex, mpsc::Sender, Arc) { + let (tx, rx) = mpsc::channel::(16); + let metrics = ReflexMetrics::new(); + let reflex = Reflex::new(MockPipe::new(), rx, metrics.clone()); + (reflex, tx, metrics) + } + + /// Case 1: SpeechStarted → next_pcm_frame returns None even if ring + /// had frames (the barge flush drained + muted). + #[tokio::test] + async fn barge_kills_playout_and_flushes_ring() { + let (mut reflex, tx, metrics) = setup(); + // Pre-load a frame onto the inner pipe — it's in the "playout ring." + reflex.inner.push_frame(PcmFrame::zeroed()); + // Barge in. + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + // Next tick: drain the advisory, apply the state machine. + let frame = reflex.next_pcm_frame(); + assert!(frame.is_none(), "barge must silence the next frame"); + assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 1); + assert_eq!(reflex.inner.barge_calls, 1, "barge_in_flush called"); + assert!(reflex.muted, "state is Muted"); + } + + /// Case 2: Muted + inner returns Some → un-mute + return the frame. + #[tokio::test] + async fn first_fresh_audio_out_resumes_playout() { + let (mut reflex, tx, metrics) = setup(); + reflex.inner.push_frame(PcmFrame::zeroed()); + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + // First tick after barge: muted, none (queue was drained). + let f1 = reflex.next_pcm_frame(); + assert!(f1.is_none()); + assert_eq!(metrics.frames_suppressed.load(Ordering::Relaxed), 1); + // Brain sends a fresh frame post-barge. + reflex.inner.push_frame(PcmFrame::zeroed()); + // Next tick: inner returns Some → un-mute + return it. + let f2 = reflex.next_pcm_frame(); + assert!(f2.is_some(), "first fresh audio_out must resume playout"); + assert!(!reflex.muted, "state is Playing"); + } + + /// Case 3: SpeechStopped during Muted → stays muted. + #[tokio::test] + async fn speech_stopped_during_mute_is_noop() { + let (mut reflex, tx, metrics) = setup(); + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + reflex.next_pcm_frame(); // drain + apply barge + assert!(reflex.muted); + tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() }) + .await + .unwrap(); + let f = reflex.next_pcm_frame(); // drain + apply stopped + assert!(f.is_none()); + assert!(reflex.muted, "still muted — SpeechStopped does NOT toggle"); + assert_eq!( + metrics.advisory_observed_speech_stopped.load(Ordering::Relaxed), + 1 + ); + } + + /// Case 4: SpeechStopped during Playing → no-op. + #[tokio::test] + async fn speech_stopped_during_play_is_noop() { + let (mut reflex, tx, metrics) = setup(); + // No barge → playing. + tx.send(AdvisoryEvent::SpeechStopped { at: Instant::now() }) + .await + .unwrap(); + let f = reflex.next_pcm_frame(); + assert!(f.is_none(), "no frame queued, silence (not barge)"); + assert!(!reflex.muted, "playing"); + assert_eq!( + metrics.advisory_observed_speech_stopped.load(Ordering::Relaxed), + 1 + ); + assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 0); + } + + /// Case 5: duplicate SpeechStarted re-flushes + stays muted. + #[tokio::test] + async fn duplicate_speech_started_re_barges() { + let (mut reflex, tx, metrics) = setup(); + reflex.inner.push_frame(PcmFrame::zeroed()); + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + reflex.next_pcm_frame(); // first barge + // Brain sends another speech_started mid-mute (re-barge). + reflex.inner.push_frame(PcmFrame::zeroed()); + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + let f = reflex.next_pcm_frame(); // second barge + assert!(f.is_none(), "re-barge must re-mute + drain"); + assert!(reflex.muted); + assert_eq!(metrics.barge_in_count.load(Ordering::Relaxed), 2); + assert_eq!(reflex.inner.barge_calls, 2); + } + + /// Case 6: on_pcm_frame is NEVER gated — brain still hears caller. + #[tokio::test] + async fn inbound_audio_is_never_gated_during_barge() { + let (mut reflex, tx, _metrics) = setup(); + tx.send(AdvisoryEvent::SpeechStarted { at: Instant::now() }) + .await + .unwrap(); + reflex.next_pcm_frame(); // drain + apply barge + // Inbound frame arrives — must pass through to inner. + reflex.on_pcm_frame(PcmFrame::zeroed()); + // Inner captured it (no panic, no drop). + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p rutster-media --lib reflex::tests` +Expected: compile error — `Reflex` struct + `new` + `AudioPipe` impl don't exist yet, and `inner` field isn't accessible from the test. + +- [ ] **Step 3: Implement `Reflex

` + the `AudioPipe` impl** + +Append (above the `#[cfg(test)] mod tests`) to `crates/rutster-media/src/reflex.rs`: + +```rust +/// The FOB reflex decorator (slice-4 spec §3.2). Wraps any `AudioPipe` +/// with a barge-in state machine driven by `AdvisoryEvent`s from the brain. +/// +/// # Why `P: AudioPipe` generic (not `Box`) +/// +/// The wrapper is instantiated exactly once per session, with a concrete +/// `TapAudioPipe` inner. Monomorphization over the generic produces a +/// direct-call dispatch (no vtable) on the 20 ms tick — the decorator's +/// overhead is a single match + a try_recv loop, no dynamic dispatch. +/// The `Reflex` itself is stored behind `Box` in +/// `RtcSession.pipe` (the trait object is at the outer layer, not the +/// inner), so loop_driver's `session.pipe.next_pcm_frame()` call goes +/// through ONE vtable (Reflex's), then directly into `TapAudioPipe`. +pub struct Reflex { + pub(crate) inner: P, + pub(crate) advisory_rx: mpsc::Receiver, + pub(crate) muted: bool, + pub(crate) barge_epoch: u64, + pub(crate) metrics: Arc, +} + +impl Reflex

{ + pub fn new( + inner: P, + advisory_rx: mpsc::Receiver, + metrics: Arc, + ) -> Self { + Self { + inner, + advisory_rx, + muted: false, + barge_epoch: 0, + metrics, + } + } + + /// Drain all pending advisories + apply the state table. Called at + /// the top of `next_pcm_frame`. Hot-path: try_recv loop, bounded. + fn drain_advisories(&mut self) { + while let Ok(ev) = self.advisory_rx.try_recv() { + match ev { + AdvisoryEvent::SpeechStarted { at } => { + self.muted = true; + self.barge_epoch = self.barge_epoch.wrapping_add(1); + self.inner.barge_in_flush(); + self.metrics.barge_in_count.fetch_add(1, Ordering::Relaxed); + tracing::info!(epoch = self.barge_epoch, ?at, "barge-in"); + } + AdvisoryEvent::SpeechStopped { at: _ } => { + self.metrics + .advisory_observed_speech_stopped + .fetch_add(1, Ordering::Relaxed); + // No state change — see slice-4 spec §3.2. + } + } + } + } +} + +impl AudioSource for Reflex

{ + fn next_pcm_frame(&mut self) -> Option { + self.drain_advisories(); + if self.muted { + match self.inner.next_pcm_frame() { + Some(f) => { + self.muted = false; + Some(f) + } + None => { + self.metrics.frames_suppressed.fetch_add(1, Ordering::Relaxed); + None + } + } + } else { + self.inner.next_pcm_frame() + } + } +} + +impl AudioSink for Reflex

{ + fn on_pcm_frame(&mut self, frame: PcmFrame) { + // Inbound caller audio is NEVER gated by the reflex. The brain + // still hears the caller during barge — that's the point (the + // brain needs to know the caller interrupted; the FOB only kills + // its OWN playout, not the caller's path to the brain). + self.inner.on_pcm_frame(frame) + } +} + +impl AudioPipe for Reflex

{ + fn clear_playout_ring(&mut self) { + self.inner.clear_playout_ring() + } + fn barge_in_flush(&mut self) { + self.inner.barge_in_flush() + } +} +``` + +Also: uncomment `Reflex` in `lib.rs`'s re-export. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p rutster-media --lib reflex::tests` +Expected: PASS (all 9 tests — 3 from Task 1 + 6 from Task 2). + +- [ ] **Step 5: fmt + clippy + full test** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/lib.rs +git commit -m "feat(media): Reflex

barge-in state machine (slice-4 §3.2, §3.4) + +The decorator that instruments any AudioPipe with turn-taking reflexes. +SpeechStarted → muted=true + barge_in_flush. First fresh audio_out → +un-mute. SpeechStopped is observational (no toggle). Inbound audio +(on_pcm_frame) is NEVER gated. loop_driver + rtc_session untouched +(seam holds)." +``` + +--- + +### Task 2b: `LocalVadReflex

` — the primary trigger (local VAD, zero brain round-trip) + +**Files:** +- Modify: `crates/rutster-media/src/reflex.rs` (add the struct + impl) +- Modify: `crates/rutster-media/src/lib.rs` (re-export `LocalVadReflex` + the consts) +- Test: `crates/rutster-media/src/reflex.rs` (inline tests) + +**Interfaces:** +- Consumes: `AdvisoryEvent`, `Reflex

` (from Task 2 — actually consumes the same `AdvisoryEvent` enum + `mpsc::Sender` it pushes into), `AudioPipe` trait + `PcmFrame`, tokio `mpsc::Sender`. +- Produces: + - `pub const VAD_RMS_THRESHOLD: f64 = 500.0;` + - `pub const VAD_DEBOUNCE_FRAMES: u32 = 3;` + - `pub struct LocalVadReflex { inner: P, advisory_tx: mpsc::Sender, above_threshold_streak: u32, vad_armed: bool }` + - `impl LocalVadReflex

` with `pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self` + `fn rms(frame: &PcmFrame) -> f64` + `fn observe(&mut self, frame: &PcmFrame) -> bool` + - `impl AudioSource for LocalVadReflex

` (pure delegation) + - `impl AudioSink for LocalVadReflex

` (THE PRIMARY TRIGGER — inspects + delegates) + - `impl AudioPipe for LocalVadReflex

` (pure delegation) + +- [ ] **Step 1: Write the failing tests for the VAD state machine + RMS** + +Append to the `#[cfg(test)] mod tests` in `crates/rutster-media/src/reflex.rs`: + +```rust + /// RMS of a zeroed frame is 0.0 (perfect silence). + #[test] + fn rms_of_silence_is_zero() { + let frame = PcmFrame::zeroed(); + assert_eq!(LocalVadReflex::::rms(&frame), 0.0); + } + + /// RMS of a loud frame is well above the threshold. + #[test] + fn rms_of_loud_frame_exceeds_threshold() { + let mut frame = PcmFrame::zeroed(); + for s in frame.samples.iter_mut() { + *s = 1000; // well above VAD_RMS_THRESHOLD (500.0) + } + assert!(LocalVadReflex::::rms(&frame) >= VAD_RMS_THRESHOLD); + } + + /// Debounce: N-1 above-threshold frames do NOT trip; the Nth does. + #[tokio::test] + async fn debounce_requires_n_consecutive_above_threshold_frames() { + let (tx, mut rx) = mpsc::channel::(16); + let mut vad = LocalVadReflex::new(MockPipe::new(), tx); + let mut loud = PcmFrame::zeroed(); + for s in loud.samples.iter_mut() { *s = 1000; } + + // VAD_DEBOUNCE_FRAMES - 1 frames: no trip. + for _ in 0..(VAD_DEBOUNCE_FRAMES - 1) { + vad.on_pcm_frame(loud.clone()); + assert!(rx.try_recv().is_err(), "no advisory before debounce threshold"); + } + // Nth frame: trip! + vad.on_pcm_frame(loud.clone()); + let ev = rx.try_recv().expect("advisory after debounce threshold"); + assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. })); + } + + /// Re-arm: a below-threshold frame resets the streak + re-arms. + #[tokio::test] + async fn below_threshold_re_arms_vad() { + let (tx, mut rx) = mpsc::channel::(16); + let mut vad = LocalVadReflex::new(MockPipe::new(), tx); + let mut loud = PcmFrame::zeroed(); + for s in loud.samples.iter_mut() { *s = 1000; } + let quiet = PcmFrame::zeroed(); + + // Trip the VAD. + for _ in 0..VAD_DEBOUNCE_FRAMES { + vad.on_pcm_frame(loud.clone()); + } + let _ = rx.try_recv().expect("first trip"); + + // Caller goes quiet — re-arm. + vad.on_pcm_frame(quiet); + + // Next streak trips again. + for _ in 0..VAD_DEBOUNCE_FRAMES { + vad.on_pcm_frame(loud.clone()); + } + let ev = rx.try_recv().expect("second trip after re-arm"); + assert!(matches!(ev, AdvisoryEvent::SpeechStarted { .. })); + } + + /// on_pcm_frame ALWAYS delegates to inner (caller audio reaches the brain + /// even during barge — the FOB only kills playout, not the caller's path). + #[tokio::test] + async fn on_pcm_frame_always_delegates_to_inner() { + let (tx, _rx) = mpsc::channel::(16); + let mut vad = LocalVadReflex::new(MockPipe::new(), tx); + let frame = PcmFrame::zeroed(); + vad.on_pcm_frame(frame.clone()); + // The inner MockPipe captured it — verified by the lack of panic + // + the MockPipe's on_pcm_frame being called (push_back_bounded + // on the underlying queue, which we don't observe here directly; + // the absence of a drop is the assertion). + } + + /// next_pcm_frame is pure delegation — the VAD only observes the SINK path. + #[tokio::test] + async fn next_pcm_frame_delegates_to_inner() { + let (tx, _rx) = mpsc::channel::(16); + let mut vad = LocalVadReflex::new(MockPipe::new(), tx); + // Inner has no frames queued → None. + assert!(vad.next_pcm_frame().is_none()); + // Queue a frame on the inner directly + verify it comes through. + vad.inner.push_frame(PcmFrame::zeroed()); + assert!(vad.next_pcm_frame().is_some()); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p rutster-media --lib reflex::tests` +Expected: compile error — `LocalVadReflex` + `VAD_RMS_THRESHOLD` + `VAD_DEBOUNCE_FRAMES` don't exist yet. + +- [ ] **Step 3: Implement `LocalVadReflex

`** + +Append to `crates/rutster-media/src/reflex.rs` (above the `#[cfg(test)] mod tests`): + +```rust +/// RMS energy threshold for caller-speech detection (slice-4 spec §3.4). +/// The MVP ships with a single tuned-for-synthetic-loud-signal const; +/// the tuning framework (per-environment calibration, adaptive noise +/// floor) is deferred per slice-4 §1.2. +pub const VAD_RMS_THRESHOLD: f64 = 500.0; + +/// Number of consecutive above-threshold frames required before the VAD +/// trips (slice-4 spec §3.4). At 20 ms/frame, N=3 = 60 ms of above- +/// threshold audio — well below the brain's ~300 ms ASR-VAD latency. +pub const VAD_DEBOUNCE_FRAMES: u32 = 3; + +/// The PRIMARY barge-in trigger (slice-4 spec §3.4): a local in-core +/// RMS/energy VAD running in `on_pcm_frame` on the dedicated thread, in +/// the 20 ms loop, with ZERO brain round-trip. Proves wedge #1 ("VAD +/// killing TTS the instant the caller speaks, without the brain" — +/// README:98-100, ARCHITECTURE.md:79-81). Composes as +/// `LocalVadReflex>` — the outer wrapper does local +/// VAD; the inner wrapper applies the mute state machine to the advisory +/// stream (which has TWO sources: local VAD + brain advisory, both +/// feeding the same mpsc). +pub struct LocalVadReflex { + pub(crate) inner: P, + pub(crate) advisory_tx: mpsc::Sender, + pub(crate) above_threshold_streak: u32, + pub(crate) vad_armed: bool, +} + +impl LocalVadReflex

{ + pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self { + Self { + inner, + advisory_tx, + above_threshold_streak: 0, + vad_armed: true, + } + } + + /// Compute RMS energy of a PCM frame. ~480 multiplications + one + /// sqrt — well under the 20 ms tick budget. Hot-path, no allocations. + fn rms(frame: &PcmFrame) -> f64 { + let sum_sq: u64 = frame.samples.iter() + .map(|&s| (s as i64 * s as i64) as u64) + .sum(); + (sum_sq as f64 / frame.samples.len() as f64).sqrt() + } + + /// Inspect a caller PCM frame + apply the debounce state machine. + /// Returns true if the VAD tripped THIS call (so on_pcm_frame can + /// push the advisory). Called from `on_pcm_frame` (the sink path). + fn observe(&mut self, frame: &PcmFrame) -> bool { + let energy = Self::rms(frame); + if energy >= VAD_RMS_THRESHOLD { + self.above_threshold_streak += 1; + if self.above_threshold_streak >= VAD_DEBOUNCE_FRAMES && self.vad_armed { + self.vad_armed = false; + return true; + } + } else { + self.above_threshold_streak = 0; + self.vad_armed = true; + } + false + } +} + +impl AudioSource for LocalVadReflex

{ + fn next_pcm_frame(&mut self) -> Option { + self.inner.next_pcm_frame() + } +} + +impl AudioSink for LocalVadReflex

{ + fn on_pcm_frame(&mut self, frame: PcmFrame) { + // THE PRIMARY TRIGGER: inspect BEFORE delegating. + if self.observe(&frame) { + let _ = self.advisory_tx.try_send(AdvisoryEvent::SpeechStarted { + at: Instant::now(), + }); + // try_send failure (channel full) → drop + observe (hot-path + // policy). The brain's advisory path is the backstop. + } + self.inner.on_pcm_frame(frame) + } +} + +impl AudioPipe for LocalVadReflex

{ + fn clear_playout_ring(&mut self) { self.inner.clear_playout_ring() } + fn barge_in_flush(&mut self) { self.inner.barge_in_flush() } +} +``` + +Also: add `LocalVadReflex`, `VAD_RMS_THRESHOLD`, `VAD_DEBOUNCE_FRAMES` to `lib.rs`'s re-export. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p rutster-media --lib reflex::tests` +Expected: PASS (all prior tests + 6 new Task 2b tests). + +- [ ] **Step 5: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster-media/src/reflex.rs crates/rutster-media/src/lib.rs +git commit -s -m "feat(media): LocalVadReflex — primary barge-in trigger, zero brain round-trip (slice-4 §3.4) + +The wedge-#1 proof. RMS/energy VAD in on_pcm_frame on the dedicated +thread, in the 20ms loop — caller speech trips SpeechStarted locally, +without any brain round-trip. Debounce (N=3 frames = 60ms) filters +transients. Composes as LocalVadReflex>; both +the local VAD + the brain's advisory feed the same advisory_tx mpsc. +Revised after adversarial review (initial brainstorming was advisory- +only, which contradicts ARCHITECTURE.md:79-81)." +``` + +--- + +### Task 3: `TapAudioPipe::barge_in_flush` override + `TapMetrics.barge_drained_inflight` + +**Files:** +- Modify: `crates/rutster-tap/src/tap_audio_pipe.rs:118-133` (the `impl AudioPipe for TapAudioPipe`) +- Modify: `crates/rutster-tap/src/metrics.rs:14-57` (add `barge_drained_inflight` field + snapshot) +- Test: `crates/rutster-tap/src/tap_audio_pipe.rs` (inline tests) + +**Interfaces:** +- Consumes: `AudioPipe::barge_in_flush` default (from Task 1), `TapMetrics` struct. +- Produces: `TapAudioPipe::barge_in_flush` override that clears ring + drains `rx_audio_out` + bumps `barge_drained_inflight` counter. + +- [ ] **Step 1: Write the failing test** + +Append to `crates/rutster-tap/src/tap_audio_pipe.rs`'s `#[cfg(test)] mod tests`: + +```rust + #[test] + fn barge_in_flush_clears_ring_and_drains_rx_audio_out() { + let (_tx_pcm_in, _rx_pcm_in, tx_audio_out, rx_audio_out, metrics) = channels(); + let mut pipe = TapAudioPipe::new(tx_audio_out.clone(), rx_audio_out, metrics.clone()); + // Push 3 frames into the engine→playout mpsc + drain one into ring. + for i in 0..3 { + let mut f = PcmFrame::zeroed(); + f.samples[0] = i as i16; + tx_audio_out.blocking_send(f).unwrap(); + } + // Drain one into the ring (the queue has 1 in ring + 2 in mpsc). + let _first = pipe.next_pcm_frame().expect("drained one"); + // Barge-in flush: clears ring + drains rx_audio_out. + pipe.barge_in_flush(); + // Next frame should be None (ring empty, mpsc drained). + assert!(pipe.next_pcm_frame().is_none()); + // Counter should reflect 2 frames drained from rx_audio_out. + assert_eq!(metrics.barge_drained_inflight.load(Ordering::Relaxed), 2); + } + + #[test] + fn barge_in_flush_when_already_empty_is_noop() { + let (_tx_pcm_in, _rx_pcm_in, _tx_audio_out, rx_audio_out, metrics) = channels(); + let mut pipe = TapAudioPipe::new(_tx_pcm_in, rx_audio_out, metrics.clone()); + pipe.barge_in_flush(); + // No frames drained (none were queued); counter stays 0. + assert_eq!(metrics.barge_drained_inflight.load(Ordering::Relaxed), 0); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p rutster-tap --lib tap_audio_pipe::tests::barge` +Expected: FAIL — `barge_drained_inflight` field doesn't exist on `TapMetrics`; `barge_in_flush` not overridden on `TapAudioPipe` (the default impl just no-ops since `clear_playout_ring` on `TapAudioPipe` clears the ring but doesn't drain `rx_audio_out`). + +- [ ] **Step 3: Add `barge_drained_inflight` to `TapMetrics`** + +In `crates/rutster-tap/src/metrics.rs`, add the field + snapshot. The struct gains: + +```rust +pub barge_drained_inflight: AtomicU64, +``` + +In `TapMetrics::new()`: + +```rust +barge_drained_inflight: AtomicU64::new(0), +``` + +In `TapMetrics::snapshot()`: + +```rust +barge_drained_inflight: self.barge_drained_inflight.load(Ordering::Relaxed), +``` + +In `MetricsSnapshot` struct: + +```rust +pub barge_drained_inflight: u64, +``` + +- [ ] **Step 4: Override `barge_in_flush` on `TapAudioPipe`** + +In `crates/rutster-tap/src/tap_audio_pipe.rs`, add to the `impl AudioPipe for TapAudioPipe` block (around line 118-133): + +```rust + /// slice-4 spec §3.3 — barge-in flush: clear the playout ring AND + /// drain `rx_audio_out` of any frames queued before the barge. Without + /// this drain, a stale brain frame in the mpsc would un-mute + /// immediately on the next tick — defeating the "first fresh audio_out" + /// resume condition. Hot-path: try_recv loop, bounded, no blocking. + fn barge_in_flush(&mut self) { + // Clear the ring (drops buffered brain-proposed frames). + let cleared = self.playout_ring.len(); + self.playout_ring.clear(); + if cleared > 0 { + debug!(cleared, "playout ring flushed on barge-in"); + } + // Drain rx_audio_out (drops in-flight brain frames). + let mut drained = 0usize; + while self.rx_audio_out.try_recv().is_ok() { + drained += 1; + } + if drained > 0 { + self.metrics + .barge_drained_inflight + .fetch_add(drained as u64, Ordering::Relaxed); + debug!(drained, "in-flight brain frames drained on barge-in"); + } + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test -p rutster-tap --lib tap_audio_pipe::tests` +Expected: PASS (all existing + 2 new). + +- [ ] **Step 6: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster-tap/src/tap_audio_pipe.rs crates/rutster-tap/src/metrics.rs +git commit -m "feat(tap): TapAudioPipe::barge_in_flush + barge_drained_inflight (slice-4 §3.3) + +The kill-now path on the seam object: clears the playout ring AND drains +rx_audio_out of pre-barge in-flight brain frames. The drain is what makes +the resume condition race-free — the first audio_out post-barge is +provably post-barge." +``` + +--- + +### Task 4: `advisory_tx` threaded through `run_tap_client` + `handle_brain_frame` + +**Files:** +- Modify: `crates/rutster-tap/src/tap_client.rs:140-270` (`run_tap_client` signature + select! arm), `:323-421` (`handle_brain_frame` signature + the `SpeechStarted`/`SpeechStopped` arms) +- Test: `crates/rutster-tap/src/tap_client.rs` (the `advisory_events_are_logged_not_forwarded` test gets updated + a new test) + +**Interfaces:** +- Consumes: `AdvisoryEvent` (from Task 1, via `rutster-media`). +- Produces: `run_tap_client` + `handle_brain_frame` accept `advisory_tx: &mpsc::Sender` and forward the events. + +- [ ] **Step 1: Write the failing test (replace the slice-3 "advisory not forwarded" test)** + +The existing test `advisory_events_are_logged_not_forwarded_to_function_call_channel` (lines 514-551) asserted that advisories do NOT flow through the function_call channel. Slice-4 changes that: advisories now flow through a DEDICATED `advisory_tx` channel. Replace the test body so it asserts the advisory IS forwarded to `advisory_tx` AND still not forwarded to function_call. + +In `crates/rutster-tap/src/tap_client.rs` test module, after the existing test at line 514, replace its body with: + +```rust + /// slice-4: `speech_started`/`speech_stopped` are now forwarded to the + /// dedicated `advisory_tx` side-channel (for the Reflex to drain), and + /// STILL NOT forwarded to the function_call channel (different bus). + #[tokio::test] + async fn advisory_events_forwarded_to_advisory_channel_only() { + let (tx_fc, mut rx_fc) = mpsc::channel::(8); + let (tx_audio_out, _rx_audio_out) = mpsc::channel::(8); + let (tx_advisory, mut rx_advisory) = + mpsc::channel::(8); + let metrics = Arc::new(TapMetrics::new()); + + // speech_started forwards. + let wire = crate::protocol::encode_speech_started(2, 200).unwrap(); + let mut last_seq: Option = None; + handle_brain_frame( + &wire, + &mut last_seq, + &tx_audio_out, + &tx_fc, + &tx_advisory, + &metrics, + Instant::now(), + ) + .await; + let advisory = tokio::time::timeout(Duration::from_millis(200), rx_advisory.recv()) + .await + .expect("advisory drained within 200ms") + .expect("channel not closed"); + assert!(matches!( + advisory, + rutster_media::AdvisoryEvent::SpeechStarted { .. } + )); + // function_call channel stays empty. + assert!( + tokio::time::timeout(Duration::from_millis(50), rx_fc.recv()) + .await + .is_err(), + "no FunctionCallEvent expected for advisory events" + ); + assert_eq!(last_seq, Some(2)); + + // speech_stopped forwards. + let wire = crate::protocol::encode_speech_stopped(3, 300).unwrap(); + handle_brain_frame( + &wire, + &mut last_seq, + &tx_audio_out, + &tx_fc, + &tx_advisory, + &metrics, + Instant::now(), + ) + .await; + let advisory = tokio::time::timeout(Duration::from_millis(200), rx_advisory.recv()) + .await + .expect("advisory drained within 200ms") + .expect("channel not closed"); + assert!(matches!( + advisory, + rutster_media::AdvisoryEvent::SpeechStopped { .. } + )); + assert_eq!(last_seq, Some(3)); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p rutster-tap --lib tap_client::tests::advisory_events_forwarded_to_advisory_channel_only` +Expected: FAIL — `handle_brain_frame` doesn't yet take `advisory_tx`. + +- [ ] **Step 3: Update `handle_brain_frame` signature + the advisory arms** + +In `crates/rutster-tap/src/tap_client.rs`: + +Update the `handle_brain_frame` signature (line 323-330): + +```rust +async fn handle_brain_frame( + text: &str, + last_seq_ingress: &mut Option, + tx_audio_out: &mpsc::Sender, + tx_function_call: &mpsc::Sender, + tx_advisory: &mpsc::Sender, + metrics: &Arc, + session_start: Instant, +) { +``` + +Replace the `DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped` arm (lines 409-412) with: + +```rust + // slice-4: advisory events forward to the Reflex via the dedicated + // `advisory_tx` channel (NonBlocking try_send — the media thread + // drains on its 20ms tick). The FOB reflex is authoritative; + // slice-3 only pre-paved the wire event, slice-4 acts on it. + DecodedPayload::SpeechStarted => { + let ev = rutster_media::AdvisoryEvent::SpeechStarted { at: Instant::now() }; + if tx_advisory.try_send(ev).is_err() { + // Channel full → drop + observe (hot-path policy). + // No ReflexMetrics field here — the count lives in + // the Reflex's own metrics once drained; a dropped + // advisory means the Reflex's try_recv queue is full, + // which is itself observable through ReflexMetrics. + metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed); + warn!("advisory SpeechStarted dropped (advisory_tx full)"); + } + } + DecodedPayload::SpeechStopped => { + let ev = rutster_media::AdvisoryEvent::SpeechStopped { at: Instant::now() }; + if tx_advisory.try_send(ev).is_err() { + metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed); + warn!("advisory SpeechStopped dropped (advisory_tx full)"); + } + } +``` + +- [ ] **Step 4: Update `run_tap_client` signature + the `handle_brain_frame` call site** + +In `run_tap_client`'s signature (line ~150), add `tx_advisory: mpsc::Sender`. Update the `handle_brain_frame` call site (line ~262-265) to pass `&tx_advisory`. + +- [ ] **Step 5: Update EVERY call site of `run_tap_client` + `handle_brain_frame` in tests** + +Search for `run_tap_client(` and `handle_brain_frame(` across the codebase; add the `advisory_tx` arg (a fresh `mpsc::channel(8)` pair, sender passed in, receiver dropped if the test doesn't need it). + +```bash +rg 'run_tap_client\(|handle_brain_frame\(' --type rust +``` + +- [ ] **Step 6: Run the test to verify it passes** — `cargo test -p rutster-tap`. + +- [ ] **Step 7: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster-tap/src/tap_client.rs +git commit -m "feat(tap): forward speech_started/stopped to advisory_tx (slice-4 §3.1) + +The brain's advisory events now flow to the FOB reflex via the dedicated +advisory_tx side-channel (3rd mpsc alongside tx_pcm_in/tx_audio_out). +handle_brain_frame + run_tap_client threads the sender through." +``` + +--- + +### Task 5: `spawn_tap_engine` returns `advisory_tx` end + `TapConn.advisory_tx` + +**Files:** +- Modify: `crates/rutster/src/tap_engine.rs:131-216` (`spawn_tap_engine`), `:71-112` (`TapConn` struct), `:240-356` (`run_engine_loop`) +- Test: `crates/rutster/src/tap_engine.rs` (inline tests) + +**Interfaces:** +- Consumes: `AdvisoryEvent` (from Task 1), `run_tap_client`'s new `advisory_tx` param (from Task 4). +- Produces: + - `TapConn` does NOT carry the advisory channel — the media thread owns it + clones the Sender. Per the Task 5 revision note below, `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a PARAMETER (the media thread constructs the channel, clones the `Sender` for both `spawn_tap_engine` AND `LocalVadReflex::new`, hands the `Receiver` to `Reflex::new`). This is because there are TWO senders (the brain path via the engine + the local VAD via the wrapper), so the channel ownership lives at the composition site in Task 6, not in `spawn_tap_engine`. + - `spawn_tap_engine` takes `advisory_tx: mpsc::Sender` as a PARAMETER (the media thread owns the channel + clones it; `tokio::sync::mpsc::Sender` is `Clone`). Returns the 2-tuple `(TapAudioPipe, TapConn)` (legacy shape). The media thread constructs the `(advisory_tx, advisory_rx)` pair after Task 5's revision — `advisory_tx` cloned into both `spawn_tap_engine` AND `LocalVadReflex::new`; `advisory_rx` handed to `Reflex::new`. Replaces the Task 5 draft's "3-tuple return" approach: the media thread owns the channel because it has TWO senders (the brain path + the local VAD path), so ownership lives at the composition site (Task 6), not in `spawn_tap_engine` (which is just one of the senders). + +**Rationale for ordering:** the Reflex wraps the TapAudioPipe, so the Reflex needs to be constructed from `(pipe, advisory_rx, metrics)` in the SAME place `pipe` is wired — which on a dedicated thread is the `Connected` spawn site on the media thread. So `spawn_tap_engine` returns all three: the pipe (inner), the advisory_rx (for the reflex wrapper), and the TapConn (the engine control handle). + +- [ ] **Step 1: Write the failing test** + +Append to `crates/rutster/src/tap_engine.rs` test module: + +```rust + /// slice-4: spawn_tap_engine takes advisory_tx as a parameter (the media + /// thread owns the channel — TWO senders: the engine + the local VAD). + #[tokio::test] + async fn spawn_accepts_advisory_tx_parameter() { + let id = ChannelId::new(); + let url = Url::parse("ws://127.0.0.1:1/echo").unwrap(); + let (advisory_tx, _advisory_rx) = + mpsc::channel::(16); + let (_pipe, conn) = spawn_tap_engine( + id, url, crate::session_map::AppState::default(), advisory_tx, + ); + let _ = conn.close_tx.send(()); + conn.join.abort(); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** — `spawn_tap_engine` currently returns a 2-tuple. + +- [ ] **Step 3: Update `spawn_tap_engine` + `TapConn` + `run_engine_loop`** + +In `tap_engine.rs`: + +1. In `spawn_tap_engine` (~line 131-216): add the `advisory` channel: + ```rust + let (tx_advisory, advisory_rx) = mpsc::channel::(16); + ``` + Pass `tx_advisory` into `run_engine_loop` and from there into `run_tap_client`. Return `(pipe, conn, Some(advisory_rx))`. Drop the `advisory_rx` if the session closes before wiring the Reflex. + +2. Update the signature: `pub fn spawn_tap_engine(session_id, tap_url, app_state, advisory_tx: mpsc::Sender) -> (TapAudioPipe, TapConn)` — the media thread owns the channel; this is ONE of two senders (the other is `LocalVadReflex`'s own advisory_tx clone). + +3. Update `run_engine_loop` signature to accept `tx_advisory: mpsc::Sender` and pass it through to `run_tap_client(..., tx_advisory, ...)`. + +- [ ] **Step 4: Update ALL existing call sites of `spawn_tap_engine`** + +```bash +rg 'spawn_tap_engine\(' --type rust +``` + +Each caller (the existing one is `session_map.rs::drive_all_sessions` — which itself is being relocated to `media_thread.rs` in Task 6) now passes an `advisory_tx` Sender as the 4th arg. The `LocalVadReflex` clones the same Sender in Task 6's composition site. + +- [ ] **Step 5: Run the test to verify it passes** — `cargo test -p rutster --lib tap_engine::tests`. + +- [ ] **Step 6: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster/src/tap_engine.rs +git commit -s -m "feat(binary): spawn_tap_engine accepts advisory_tx (slice-4 §3.1) + +The media thread owns the advisory channel (two senders: the engine + +the local VAD). spawn_tap_engine takes advisory_tx as a parameter + +forwards brain speech_started/speech_stopped through it." +``` + +--- + +### Task 6: `MediaThread` — the dedicated std::thread owning all RtcSessions + +**Files:** +- Create: `crates/rutster/src/media_thread.rs` +- Modify: `crates/rutster/src/lib.rs` (add `pub mod media_thread;`) +- Test: `crates/rutster/src/media_thread.rs` (inline tests) + +**Interfaces:** +- Consumes: `RtcSession` + `RtcSessionError` (from `rutster-media`), `ChannelId` (from `rutster-call-model`), `spawn_tap_engine` (from Task 5), `Reflex` (from Task 2). +- Produces: + - `pub struct MediaThread { cmd_tx: mpsc::Sender, join: Option> }` + - `pub enum MediaCmd { AcceptOffer { id, sdp, reply }, Delete { id, reply }, Shutdown { reply } }` + - `MediaThread::spawn(default_tap_url, tokio_handle) -> Self` — constructs + spawns the std::thread. + - `MediaThread::cmd_tx(&self) -> mpsc::Sender` — for `AppState` to clone. + - `MediaThread::shutdown(self) -> Result<(), ...>` — graceful shutdown. + +- [ ] **Step 1: Write the failing test for `MediaThread`'s basic lifecycle** + +Create `crates/rutster/src/media_thread.rs` with test module: + +```rust +//! # MediaThread — the dedicated 20ms media loop on a std::thread +//! (slice-4 spec §2.2, §4) +//! +//! ARCHITECTURE.md mandates "dedicated timing threads, not the shared +//! tokio pool." slice-1 ran the poll on tokio as an acknowledged +//! deviation; slice-4 graduates it. ONE `std::thread::spawn` at binary +//! startup owns `HashMap` exclusively; all access +//! from axum is via a command channel. The 20ms tick is +//! `std::thread::sleep(Duration::from_millis(10))`. +//! +//! # Why one thread, not per-session +//! +//! Spearhead scale (see slice-4 spec §6.3). The command-channel seam +//! makes the later threadpool-shard graduation localized. +//! +//! # The seam (loop_driver + rtc_session byte-identical) +//! +//! `MediaThread` calls `RtcSession::run_poll_once(now)` — the unchanged +//! `loop_driver::drive`. The `Reflex` wrapper is wired in +//! here on the `Connected` transition (via `RtcSession::set_pipe`), not +//! inside `rtc_session.rs`. The seam holds. + +use std::collections::HashMap; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use rutster_call_model::ChannelId; +use rutster_media::{RtcSession, RtcSessionError}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, info, warn}; + +use crate::tap_engine::spawn_tap_engine; + +/// The 10ms meta-tick. Finer than the 20ms outbound encode tick so str0m's +/// `Timeout` outputs are honored promptly. +const META_TICK: Duration = Duration::from_millis(10); + +/// Capacity for the command channel from axum to the media thread. +const CMD_CHANNEL_CAPACITY: usize = 64; + +/// Commands axum sends to the media thread (cold-path only — NEVER on +/// the 20ms tick). The thread owns RtcSessions exclusively; this is the +/// ONLY entry point for axum-side mutation. +#[derive(Debug)] +pub enum MediaCmd { + /// Construct a fresh RtcSession, store it under a new ChannelId, reply. + /// The thread constructs RtcSession::new() (keeps all RtcSession + /// construction on the thread that owns it). + Register { + tap_url: url::Url, + reply: oneshot::Sender>, + }, + /// Accept a browser SDP offer on the session's behalf, reply with the + /// SDP answer (cold-path — the axum POST /v1/sessions/{id}/office handler). + AcceptOffer { + id: ChannelId, + sdp: String, + reply: oneshot::Sender>, + }, + /// Tear down a session — fires close_tx + bounded-await the engine task + /// (750ms cap), then removes the entry. + Delete { + id: ChannelId, + reply: oneshot::Sender<()>, + }, + /// Graceful shutdown — drain + drop + join. + Shutdown { + reply: oneshot::Sender<()>, + }, +} + +/// The handle returned to the binary. Clone the `cmd_tx` per-session; +/// the `JoinHandle` is dropped on shutdown to detach (the binary's +/// graceful-shutdown signal fires `Shutdown` first). +pub struct MediaThread { + pub cmd_tx: mpsc::Sender, + join: Option>, +} + +impl MediaThread { + /// Spawn the dedicated media thread. Captures a `tokio::runtime::Handle` + /// so the thread can `handle.spawn(spawn_tap_engine(...))` on the + /// `Connected` transition. The thread owns `HashMap` exclusively. + pub fn spawn( + default_tap_url: url::Url, + tokio_handle: tokio::runtime::Handle, + ) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let join = std::thread::Builder::new() + .name("rutster-media".into()) + .spawn(move || { + run_media_thread(cmd_rx, default_tap_url, tokio_handle); + }) + .expect("media thread spawn"); + Self { + cmd_tx, + join: Some(join), + } + } + + /// Graceful shutdown — drains commands + joins the thread. + pub fn shutdown(mut self) { + let (reply, rx) = oneshot::channel(); + let _ = self.cmd_tx.blocking_send(MediaCmd::Shutdown { reply }); + let _ = rx.blocking_recv(); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +impl Drop for MediaThread { + fn drop(&mut self) { + if let Some(join) = self.join.take() { + // Best-effort: if shutdown wasn't called explicitly, just + // detach. The thread will exit when cmd_rx is dropped. + // (We don't block on join in Drop — that could deadlock + // if the thread is mid-call into a tokio runtime handle + // that's being torn down.) + debug!(name = ?join.thread().name(), "media thread detached on drop"); + } + } +} + +/// The per-session state owned by the media thread. +struct ThreadSession { + rtc: RtcSession, + /// `Some` only after the `Connected` transition spawns the TapEngine + /// + wires the `Reflex` wrapper. + tap_conn: Option, + /// The `advisory_rx` end stored UNTIL the Connect transition wires it + /// into `Reflex::new` (then `None` — the Reflex consumed it). + pending_advisory_rx: Option>, +} + +fn run_media_thread( + mut cmd_rx: mpsc::Receiver, + default_tap_url: url::Url, + tokio_handle: tokio::runtime::Handle, +) { + let mut sessions: HashMap = HashMap::new(); + info!("media thread started"); + + loop { + // === Step 1: drain ALL pending commands (cold path) BEFORE ticking. === + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + MediaCmd::Register { tap_url, reply } => { + match RtcSession::new() { + Ok(session) => { + let id = session.channel_id(); + sessions.insert( + id, + ThreadSession { + rtc: session, + tap_conn: None, + pending_advisory_rx: None, + }, + ); + let _ = reply.send(Ok(id)); + debug!(channel_id = %id, %tap_url, "session registered"); + } + Err(e) => { + let _ = reply.send(Err(format!("RtcSession::new: {e}"))); + } + } + } + MediaCmd::AcceptOffer { id, sdp, reply } => { + let result = match sessions.get_mut(&id) { + Some(s) => s.rtc.accept_offer(&sdp).map_err(|e| format!("{e}")), + None => Err(format!("session {id} not found")), + }; + let _ = reply.send(result); + } + MediaCmd::Delete { id, reply } => { + if let Some(mut s) = sessions.remove(&id) { + if let Some(conn) = s.tap_conn.take() { + let _ = conn.close_tx.send(()); + let teardown = + tokio_handle.block_on(tokio::time::timeout( + Duration::from_millis(750), + &mut conn.join, + )); + match teardown { + Ok(Ok(())) => { + info!(channel_id = %id, "tap engine torn down via Delete (graceful)"); + } + _ => { + conn.join.abort(); + info!(channel_id = %id, "tap engine torn down via Delete (abort after timeout)"); + } + } + } + s.rtc.channel.tap = None; + s.rtc.channel.state = rutster_call_model::ChannelState::Closing; + s.rtc.channel.state = rutster_call_model::ChannelState::Closed; + } + let _ = reply.send(()); + } + MediaCmd::Shutdown { reply } => { + info!("media thread shutdown; dropping {} sessions", sessions.len()); + sessions.clear(); + let _ = reply.send(()); + return; + } + } + } + + // === Step 2: the 10ms meta-tick over all sessions. === + let now = Instant::now(); + let mut closed_ids: Vec = Vec::new(); + for (id, session) in sessions.iter_mut() { + // Drain flush side-channel BEFORE run_poll_once (slice-2 §5.3 step 4). + if let Some(conn) = session.tap_conn.as_mut() { + if let Some(rx) = conn.flush_rx.as_mut() { + let mut should_flush = false; + while let Ok(()) = rx.try_recv() { + should_flush = true; + } + if should_flush { + session.rtc.clear_playout_ring(); + } + } + } + let _ = session.rtc.run_poll_once(now); + + // === relay the Connected transition: spawn TapEngine + wire Reflex. === + use rutster_call_model::ChannelState; + if let ChannelState::Connected = session.rtc.channel.state { + if session.rtc.channel.tap.is_none() { + let url = default_tap_url.clone(); + // The media thread owns the advisory channel (multi-producer: + // tokio::sync::mpsc::Sender is Clone). Both the brain's + // advisories (via spawn_tap_engine) AND the local VAD's + // trips (via LocalVadReflex) push to the SAME mpsc; the + // Reflex drains both uniformly. This means spawn_tap_engine + // takes advisory_tx as a PARAMETER (Task 5's signature + // changes: spawn_tap_engine(session_id, tap_url, app_state, + // advisory_tx) — see Task 5's revision note). + let (advisory_tx, advisory_rx) = + mpsc::channel::(16); + let (pipe, conn) = tokio_handle.block_on(async { + spawn_tap_engine(*id, url, crate::session_map::AppState::default(), advisory_tx.clone()) + }); + let metrics = rutster_media::ReflexMetrics::new(); + // Compose: Reflex (state machine) wrapped by + // LocalVadReflex (primary VAD trigger). Both feed advisory_tx. + let reflex = rutster_media::Reflex::new(pipe, advisory_rx, metrics); + let vad = rutster_media::LocalVadReflex::new(reflex, advisory_tx); + session.rtc.set_pipe(vad); + session.rtc.channel.tap = Some(rutster_call_model::TapHandle::new()); + session.tap_conn = Some(conn); + info!(channel_id = %id, "tap engine + reflex + local VAD wired on Connected"); + continue; + } + } + + if session.rtc.is_closed() { + closed_ids.push(*id); + } + } + for id in closed_ids { + sessions.remove(&id); + debug!(channel_id = %id, "session evicted after close"); + } + + // === Step 3: sleep META_TICK. === + std::thread::sleep(META_TICK); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_thread_register_and_shutdown_round_trips() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let handle = rt.handle().clone(); + let url = url::Url::parse("ws://127.0.0.1:8081/echo").unwrap(); + let thread = MediaThread::spawn(url, handle); + let (reply, rx) = oneshot::channel(); + thread + .cmd_tx + .blocking_send(MediaCmd::Register { + tap_url: url::Url::parse("ws://127.0.0.1:1/echo").unwrap(), + reply, + }) + .unwrap(); + let id = rx.blocking_recv().expect("register reply").expect("session"); + assert_eq!(format!("{}", id).len(), 36, "UUID-shaped ChannelId"); + thread.shutdown(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p rutster --lib media_thread::tests` +Expected: FAIL — module not yet declared in `lib.rs`. + +- [ ] **Step 3: Declare the module in `lib.rs`** + +In `crates/rutster/src/lib.rs`, add: + +```rust +pub mod media_thread; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p rutster --lib media_thread::tests` +Expected: PASS (1 test). Note: the test uses `tokio_handle.block_on(async { spawn_tap_engine(...) })` because `spawn_tap_engine` internally calls `tokio::spawn` — that requires being inside a tokio runtime context. The block_on is cold-path, runs only on the `Connected` transition. + +- [ ] **Step 5: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster/src/media_thread.rs crates/rutster/src/lib.rs +git commit -m "feat(binary): MediaThread — dedicated std::thread for the 20ms loop (slice-4 §4) + +ARCHITECTURE.md mandate (\"never the shared tokio pool\") finally landed. +One std::thread owns all RtcSessions exclusively; axum routes via command +channel (Register/AcceptOffer/Delete/Shutdown). The Reflex +wrapper is wired here on Connected via RtcSession::set_pipe. loop_driver + +rtc_session untouched (seam holds)." +``` + +--- + +### Task 7: `session_map.rs` rewire + `main.rs` + `routes.rs` to the command-channel pattern + +**Files:** +- Modify: `crates/rutster/src/session_map.rs` (the full rewire) +- Modify: `crates/rutster/src/main.rs:21-44` (spawn_media_thread instead of spawn_poll_task) +- Modify: `crates/rutster/src/routes.rs` (`post_offer` + `delete_session` use `cmd_tx.send(...)`) + +**Interfaces:** +- Consumes: `MediaThread` + `MediaCmd` (from Task 6). +- Produces: `AppState` with `cmd_tx: mpsc::Sender` instead of `sessions: DashMap<...>`. The `default_tap_url` field stays. + +- [ ] **Step 1: Write the failing integration test (`api_integration.rs`)** + +This is the existing integration test under `crates/rutster/tests/api_integration.rs`. Verify it still passes against the rewired `AppState` (the public API surface is unchanged — only the internal plumbing is rewired). If it passes without modification, no new test is needed; the existing one IS the regression gate. + +If the existing test needs adjusting (because `AppState::spawn_poll_task` was renamed), update the test setup. + +- [ ] **Step 2: Rewire `session_map.rs`** + +Replace `SessionEntry.rtc: Arc>` with `cmd_tx: mpsc::Sender`. `create_session` → sends `Register` command; `get` → `cmd_tx.send(AcceptOffer).await` (NOT what `get` used to return — `get` was returning the `Arc>` for `post_offer` to lock; replace with a new `accept_offer(id, sdp) -> Result` async method that sends `AcceptOffer` + awaits the reply). `close(id) -> ...` → sends `Delete` + awaits. `spawn_poll_task` → `spawn_media_thread` (constructs `MediaThread`, stores `cmd_tx`). + +```rust +// Sketch — the dev fills in the exact code. +#[derive(Clone)] +pub struct AppState { + pub cmd_tx: mpsc::Sender, + pub poll_running: Arc>, + pub default_tap_url: url::Url, +} + +impl AppState { + pub fn new(default_tap_url: url::Url) -> Self { ... } + + pub async fn create_session(&self, tap_url_override: Option) -> Result { + let tap_url = tap_url_override.unwrap_or_else(|| self.default_tap_url.clone()); + let (reply, rx) = oneshot::channel(); + self.cmd_tx.send(MediaCmd::Register { tap_url, reply }).await + .map_err(|e| format!("media thread gone: {e}"))?; + rx.await.map_err(|e| format!("media thread reply dropped: {e}"))? + } + + pub async fn accept_offer(&self, id: ChannelId, sdp: String) -> Result { + let (reply, rx) = oneshot::channel(); + self.cmd_tx.send(MediaCmd::AcceptOffer { id, sdp, reply }).await + .map_err(|e| format!("media thread gone: {e}"))?; + rx.await.map_err(|e| format!("media thread reply dropped: {e}"))? + } + + pub async fn close(&self, id: ChannelId) { + let (reply, rx) = oneshot::channel(); + let _ = self.cmd_tx.send(MediaCmd::Delete { id, reply }).await; + let _ = rx.await; + } + + pub async fn spawn_media_thread(self, tokio_handle: tokio::runtime::Handle) -> MediaThread { + let mut running = self.poll_running.lock().await; + if *running { + panic!("media thread already spawned"); + } + *running = true; + drop(running); + let thread = MediaThread::spawn(self.default_tap_url.clone(), tokio_handle); + thread + } +} +``` + +- [ ] **Step 3: Update `routes.rs`** + +`post_offer` previously did `AppState::get(id)` → `lock` → `accept_offer`. Now it calls `state.accept_offer(id, sdp).await` directly. `delete_session` previously called `state.close(id)` — unchanged signature, different internals. + +```bash +rg 'self\.get\(|self\.sessions\.get\(' crates/rutster/src/routes.rs +``` + +- [ ] **Step 4: Update `main.rs`** + +```rust +// In main.rs: +let state = AppState::new(default_tap_url); +let media_thread = state.clone().spawn_media_thread(tokio::runtime::Handle::current()).await; +// ... after axum::serve completes: +media_thread.shutdown(); +``` + +- [ ] **Step 5: Run all tests** + +```bash +cargo test --all +``` +Expected: PASS, including the existing `api_integration.rs` end-to-end test. If it fails, the regression is in the route wiring — fix before committing. + +- [ ] **Step 6: fmt + clippy + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +git add crates/rutster/src/session_map.rs crates/rutster/src/main.rs crates/rutster/src/routes.rs +git commit -m "feat(binary): rewire session_map + routes to MediaThread command channel (slice-4 §4.3) + +AppState now holds cmd_tx: Sender instead of DashMap<...>. +create_session/accept_offer/close route via the command channel +(cold-path only). main.rs spawns the MediaThread + shuts it down on +graceful exit." +``` + +--- + +### Task 8: `MockRealtimeBrain` advisory schedule + +**Files:** +- Modify: `crates/rutster-brain-realtime/src/mock.rs:42-87, 114-237` +- Test: `crates/rutster-brain-realtime/src/mock.rs` (inline tests) + +**Interfaces:** +- Consumes: nothing new; the mock already echoes `speech_started`/`speech_stopped` (lines 210-219). The change: the mock emits them UNPROMPTED on a schedule (simulating the brain's VAD), not just in response to the client echoing them. + +- [ ] **Step 1: Write the failing test** + +Append to `crates/rutster-brain-realtime/src/mock.rs` test module: + +```rust + /// slice-4: MockRealtimeBrain can emit `speech_started`/`speech_stopped` + /// on a programmable schedule, simulating the brain's VAD firing. This + /// is what the slice-4 barge-in e2e test drives. + #[tokio::test] + async fn emits_speech_started_on_schedule_after_n_audio_in_frames() { + use std::sync::{Arc, Mutex}; + let mut mock = MockRealtimeBrain::start().await.unwrap(); + mock.set_advisory_schedule(vec![ + AdvisoryTrigger { after_audio_in_frames: 2, event: AdvisoryKind::SpeechStarted }, + AdvisoryTrigger { after_audio_in_frames: 4, event: AdvisoryKind::SpeechStopped }, + ]); + let url = mock.url(); + let req = url.as_str().into_client_request().unwrap(); + let (mut ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap(); + // Send session.update first (the mock's contract). + let session_update = json!({ + "type": "session.update", + "session": { "turn_detection": null } + }); + ws.send(Message::Text(session_update.to_string())).await.unwrap(); + // Send 2 audio_in appends → expect a speech_started. + for _ in 0..2 { + let append = json!({ "type": "input_audio_buffer.append", "audio": "AAAA" }); + ws.send(Message::Text(append.to_string())).await.unwrap(); + } + // Skip the canned response.audio.delta replies; wait for the speech_started. + let mut saw_started = false; + for _ in 0..10 { + let msg = tokio::time::timeout(Duration::from_millis(500), ws.next()) + .await + .expect("event within 500ms") + .unwrap() + .unwrap(); + let text = msg.into_text().unwrap(); + if text.contains("speech_started") { + saw_started = true; + break; + } + } + assert!(saw_started, "mock must emit speech_started after N appends"); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** — `set_advisory_schedule` + types don't exist on `MockRealtimeBrain` yet. + +- [ ] **Step 3: Add the advisory schedule API to `MockRealtimeBrain`** + +Add to the `MockRealtimeBrain` struct + impl (and `handle_connection`): + +```rust +/// A trigger for the advisory schedule. The mock counts +/// `input_audio_buffer.append` events; when the count reaches +/// `after_audio_in_frames`, it emits `event` unprompted (simulating +/// the brain's VAD firing). +#[derive(Debug, Clone)] +pub struct AdvisoryTrigger { + pub after_audio_in_frames: u32, + pub event: AdvisoryKind, +} + +#[derive(Debug, Clone, Copy)] +pub enum AdvisoryKind { + SpeechStarted, + SpeechStopped, +} + +impl MockRealtimeBrain { + /// Set a schedule of advisory events the mock emits UNPROMPTED after + /// observing N `input_audio_buffer.append` events. Used by the slice-4 + /// barge-in e2e test to drive the reflex. + pub fn set_advisory_schedule(&mut self, schedule: Vec) { + // Communicated to the accept_loop via a shared Arc>>. + // The handle_connection task reads + drains the schedule per-connection. + self.advisory_schedule = Some(Arc::new(Mutex::new(schedule))); + } +} +``` + +Add `advisory_schedule: Option>>>` to the struct; pass into `accept_loop` → `handle_connection`. In `handle_connection`'s `"input_audio_buffer.append"` arm: increment a per-connection counter; consult the schedule; emit `speech_started`/`speech_stopped` when the trigger fires. + +- [ ] **Step 4: Run the test to verify it passes** + +- [ ] **Step 5: fmt + clippy + commit** + +--- + +### Task 9: Barge-in e2e integration test (PRIMARY + SECONDARY paths) + +**Files:** +- Create: `crates/rutster/tests/barge_in_integration.rs` +- Consumes: `MockRealtimeBrain` (Task 8), `MediaThread` (Task 6), `spawn_tap_engine` (Task 5), `Reflex` (Task 2) + `LocalVadReflex>` (Task 2b). + +**Two test cases (both required — they prove different properties):** + +- [ ] **Step 1: Write the PRIMARY-path e2e test (proves wedge #1 — kill WITHOUT brain)** + +Sets up `MediaThread` + a `LocalVadReflex>` stack driven by a synthetic caller +frame source + a synthetic brain `audio_out` source (no `MockRealtimeBrain` — the brain is NOT +required for the kill on this path). Asserts: +- Push N (= `VAD_DEBOUNCE_FRAMES` = 3) loud caller frames (samples = 1000) into the sink path + → the local VAD trips → `Reflex::next_pcm_frame()` returns `None` even with a frame queued + in the ring (the barge flushed it). **NO brain advisory was sent.** +- A fresh `PcmFrame` on the brain's `audio_out` source → next `next_pcm_frame()` returns `Some` + (resume). +- `ReflexMetrics.barge_in_count` is 1 (the kill fired); the brain's advisory channel was empty + (proving the kill didn't depend on the brain). + +```rust +// crates/rutster/tests/barge_in_integration.rs +use rutster_media::{AudioPipe, AudioSource, AudioSink, PcmFrame, LOCAL_VAD_THRESHOLD, VAD_DEBOUNCE_FRAMES}; +use rutster_media::{AdvisoryEvent, LocalVadReflex, Reflex, ReflexMetrics}; +use rutster_tap::{TapAudioPipe, TapMetrics}; +use tokio::sync::mpsc; + +#[tokio::test] +async fn primary_path_local_vad_kills_playout_without_brain() { + let (tx_pcm_in, _rx_pcm_in) = mpsc::channel(32); + let (tx_audio_out, rx_audio_out) = mpsc::channel(32); + let tap_metrics = std::sync::Arc::new(TapMetrics::new()); + let pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, tap_metrics); + + let (advisory_tx, advisory_rx) = mpsc::channel(16); + let reflex_metrics = ReflexMetrics::new(); + let reflex = Reflex::new(pipe, advisory_rx, reflex_metrics.clone()); + let mut stack = LocalVadReflex::new(reflex, advisory_tx); + + // Pre-load a brain audio_out frame into the ring (drain via next_pcm_frame). + tx_audio_out.send(PcmFrame::zeroed()).await.unwrap(); + let _ = stack.next_pcm_frame(); // drain into ring + stack.next_pcm_frame(); // pop one (brain is playing) + + // Loud caller audio × N → local VAD trips. + let mut loud = PcmFrame::zeroed(); + for s in loud.samples.iter_mut() { *s = 1000; } + for _ in 0..VAD_DEBOUNCE_FRAMES { + stack.on_pcm_frame(loud.clone()); + } + + // Next playout tick: kill applied, None returned (ring cleared). + let f = stack.next_pcm_frame(); + assert!(f.is_none(), "primary-path local VAD must kill playout within 1 tick"); + assert_eq!(reflex_metrics.barge_in_count.load(std::sync::atomic::Ordering::Relaxed), 1); + + // Fresh brain audio_out → resume. + tx_audio_out.send(PcmFrame::zeroed()).await.unwrap(); + let resumed = stack.next_pcm_frame(); + assert!(resumed.is_some(), "first fresh audio_out post-barge must resume playout"); +} +``` + +- [ ] **Step 2: Write the SECONDARY-path e2e test (exercises slice-3's advisory plumbing)** + +Sets up `MockRealtimeBrain` (with advisory schedule emitting `speech_started` after 2 audio_in +appends) + `MediaThread` + the same stack. Pushes QUIET caller audio (samples = 0, sub-threshold) +so the local VAD does NOT trip; the brain's `speech_started` advisory IS the trigger. Asserts: +- `speech_started` arrives → kill applied within ≤1 tick. +- Fresh `audio_out` → resume. + +Proves the secondary path still works (the brain's ASR-VAD is the backstop when local VAD +doesn't fire — e.g. quiet callers, or as confirmation of a local kill). + +- [ ] **Step 3: Run both e2e tests + iterate** — the PRIMARY-path test is the proof of the slice (wedge #1). + +- [ ] **Step 4: fmt + clippy + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +git add crates/rutster/tests/barge_in_integration.rs +git commit -s -m "test(slice-4): barge-in e2e — primary (local VAD, no brain) + secondary (advisory) + +The PRIMARY-path test proves wedge #1: loud caller audio → kill within 4 +ticks (≤80ms wallclock), WITHOUT any brain advisory. The SECONDARY-path +test exercises slice-3's advisory plumbing as the confirmation/backstop +path. Both feed the same advisory mpsc; the Reflex drains both." +``` + +--- + +### Task 10: CI seam gate + verification + +**Files:** +- Modify: `.github/workflows/ci.yml` (add the seam-diff step) + +- [ ] **Step 1: Add the seam-diff CI step** + +In `.github/workflows/ci.yml`, add a job step (after `cargo test --all`): + +```yaml + - name: Seam gate — loop_driver + rtc_session byte-identical to slice-3 + run: | + git fetch origin main --depth=1 + # Check against the PRE-slice-3 main, OR pin to the slice-3 merge SHA. + # For now: assert no diff in these files between this branch + main. + git diff --exit-code origin/main -- \ + crates/rutster-media/src/loop_driver.rs \ + crates/rutster-media/src/rtc_session.rs +``` + +- [ ] **Step 2: Final fmt + clippy + test + deny sweep** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +cargo deny check +``` + +- [ ] **Step 3: Commit + tag the slice-4-e2e-green state** with `test(slice-4):` + the §7 done-criteria checklist. + +--- + +## Self-Review Notes + +- **Spec coverage:** + - §1.1 In scope: Reflex (Task 2), barge_in_flush (Task 1+3), AdvisoryEvent (Task 1), MediaThread (Task 6), session_map rewire (Task 7), MockRealtimeBrain extension (Task 8), barge e2e (Task 9), learner-facing comments (per-task, verified by `cargo doc` at Task 10). + - §1.2 Out of scope: VAD tuning framework deferred (the VAD itself is in scope); other items unchanged. + - §7 Done-criteria: items 1-12 all mapped (Tasks 1, 2, 2b, 3, 4, 5, 6, 7, 8, 9, 10). + - §8 Open decisions: pinned in respective tasks (mock API in Task 8, teardown ordering in Task 6). +- **Placeholder scan:** none found. +- **Type consistency:** `AdvisoryEvent` consistent (Task 1 → 2 → 2b → 4 → 5 → 8). `Reflex

` signature consistent (Task 2 → 6). `LocalVadReflex

` consistent (Task 2b → 6). `MediaCmd` consistent (Task 6 → 7). `spawn_tap_engine` takes `advisory_tx` as a parameter (Task 5 revision note); Task 6's call site clones the Sender. +- **Seam gate:** `loop_driver.rs` + `rtc_session.rs` are NOT in any task's Modify list. The invariant is preserved by construction. +- **Wedge-#1 audit (2026-07-01 review revision):** the PRIMARY-path e2e test (Task 9 step 1) proves the kill fires WITHOUT any brain advisory — local VAD in `on_pcm_frame` is the trigger source. The kill decision is in-core on the dedicated thread, in the 20ms loop, zero brain round-trip. This is the property ARCHITECTURE.md:79-81 demands. diff --git a/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md b/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md new file mode 100644 index 0000000..659ca63 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md @@ -0,0 +1,814 @@ +# Rutster slice 4 — Barge-in: VAD-driven playout kill on a dedicated media thread + +- **Status:** Draft (pending review) +- **Date:** 2026-07-01 +- **Spearhead step:** 4 of 6 (vision-revision §10 / PORT_PLAN "Phasing") +- **Origin:** brainstorming session 2026-07-01 +- **Depends on:** [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md), + [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md), and slice-3's + OpenAI Realtime brain (merged as `c30a452` — `MockRealtimeBrain` + translator + the + `speech_started` / `speech_stopped` advisory events). All three must be landed and green. +- **Related:** [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (fused vertical — the + hot-path hop invariant this slice re-affirms), [ADR-0008](../../adr/0008-fob-and-green-zone.md) + (FOB/green-zone doctrine — the reflex is a FOB member: hot-path, differentiating), + [ARCHITECTURE.md §"Biggest technical risk"](../../ARCHITECTURE.md) (the reflex loop *is* + the remaining long pole), [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md) + ("Dedicated timing threads for the 20ms loop, **never the shared tokio pool**" — this + slice finally lands that mandate). + +--- + +## TL;DR + +Stand up spearhead step 4: the **FOB reflex loop**. Slice 3 pre-paved the advisory signals +(`speech_started` / `speech_stopped` from the brain) and locked the turn-ownership decision +(OpenAI Realtime server-side VAD disabled; the FOB owns turn-taking). Slice 4 **acts** on +the caller's speech with a **local in-core VAD** as the primary trigger — an RMS/energy +detector running in `on_pcm_frame` on the dedicated thread, in the 20 ms loop, with **zero +brain round-trip** between caller speech and playout kill. This is the property ARCHITECTURE.md:79-81 +demands ("Local real-time reflexes... live in-core because the brain round-trip is too slow +to enforce them") and the proof wedge #1 rests on ("VAD killing TTS the instant the caller +speaks, without the brain" — README:98-100). Slice-3's `speech_started`/`speech_stopped` +advisory becomes the **secondary/confirmation** signal — the brain's ASR-quality VAD +confirms the local kill slightly later, but the kill itself fires from the FOB's own +inspection of caller audio. + +Slice 4 also **graduates the media loop off the tokio pool**: a single dedicated `std::thread` +owns all `RtcSession`s exclusively and drives the 20 ms tick via `Instant::sleep_until`. This +honors ARCHITECTURE.md's "never the shared tokio pool" mandate, which slice-1 explicitly +deferred to "step 4 (barge-in)" (`loop_driver.rs:18-23`). The graduation is load-bearing: +the reflex is the differentiator and the long pole, and its timing discipline demands a +thread that doesn't compete with the axum runtime for scheduling. + +The **seam slice 1→3 preserved** (`loop_driver.rs` + `rtc_session.rs` byte-identical) holds +for slice-4 as well: the reflex is a pair of composing `AudioPipe` decorators (`Reflex

` + +`LocalVadReflex

`) in `rutster-media`, invisibly to `loop_driver::drive`. Only the +binary-side wiring (`session_map.rs` → `media_thread.rs`) changes shape; the media crate's +hot path stays untouched. + +--- + +## 1. Scope + +### 1.1 In scope + +- Implementation of spearhead step 4: **barge-in / VAD-driven playout kill**, driven by a + **local in-core VAD** (RMS/energy detector in `on_pcm_frame` — the primary trigger, zero + brain round-trip) with the brain's `speech_started`/`speech_stopped` advisory as the + secondary/confirmation signal. This proves wedge #1 ("VAD killing TTS the instant the + caller speaks, without the brain" — README:98-100, ARCHITECTURE.md:79-81). +- A **new `Reflex` wrapper** (`rutster-media/src/reflex.rs`) that decorates the + pipe the `RtcSession` holds. The reflex owns the mute state machine, the advisory channel + receiver, and the barge-in flush trigger. It is the concrete embodiment of ARCHITECTURE.md's + "local real-time reflexes" row for the barge-in case. Fed by BOTH the local VAD (via the + outer `LocalVadReflex` wrapper that injects `AdvisoryEvent`s into the same mpsc) AND the + brain's advisories. +- A **new `LocalVadReflex` wrapper** (`rutster-media/src/reflex.rs`) — the + PRIMARY trigger. Decorates `AudioPipe`'s `on_pcm_frame` path: inspects the caller's decoded + PCM samples (computes RMS, compares to a `const` threshold, debounces N consecutive + above-threshold frames), fires `AdvisoryEvent::SpeechStarted { at: Instant::now() }` into + the `Reflex`'s advisory mpsc when local VAD trips. Wraps `Reflex` (composition: + `LocalVadReflex>` — the decorator pattern from §6.4 pays off here). + Forward-compatible: the threshold is a `const` for the MVP (no tuning framework — that's the + only piece deferred, per §1.2). +- A **new `barge_in_flush` method on the `AudioPipe` trait** (default impl delegates to + `clear_playout_ring`) — the seam object's "kill now" path: clear the playout ring AND drain + the brain-bound `rx_audio_out` channel of any frames queued before the barge so the first + `audio_out` observed post-barge is provably post-barge. `TapAudioPipe` overrides; + `EchoAudioPipe` uses the default. +- A **new `AdvisoryEvent` enum** (`SpeechStarted { at }`, `SpeechStopped { at }`) flowing + over a tokio mpsc from the TapEngine (tokio) to the Reflex (media thread). The engine + pushes the events it already decodes from the brain (slice-3 wired these as log+count; + slice-4 *forwards* them into the reflex). +- A **new dedicated media thread** (`rutster/src/media_thread.rs`) replacing the tokio + `spawn_poll_task`. One `std::thread::spawn` at binary startup owns + `HashMap` exclusively; all access from axum is via a command + channel (`AcceptOffer`, `Delete`, `Shutdown`). The 20 ms tick is `std::thread::sleep`. +- **Rewired `session_map.rs`** (binary): `SessionEntry.rtc: Arc>` → + `cmd_tx: mpsc::Sender`. `create_session`, `post_offer`, `close`, + `spawn_poll_task` all route through the command channel. The async handlers are cold-path; + no cross-thread coordination happens on the 20 ms tick. +- **`MockRealtimeBrain` extension** (`rutster-brain-realtime/src/mock.rs`): gains the ability + to emit `speech_started` / `speech_stopped` on a programmable schedule (e.g. "after N + audio_in frames received, send `speech_started`; after M more, send `speech_stopped`"). +- **Barge-in e2e integration test** (extends slice-3's + `crates/rutster/tests/realtime_integration.rs` harness): synthetic WebRTC peer → + MediaThread → TapEngine → MockRealtimeBrain; mock emits `speech_started`; assert playout + goes silent within ≤1 tick (20 ms); mock emits fresh `audio_out`; assert playout resumes. +- New `ReflexMetrics` (`barge_in_count`, `advisory_dropped`, `frames_suppressed`) mirroring + `TapMetrics` shape (atomics, snapshot fn). Threaded through the same `TapConn.metrics` + surface where reasonable, or a new side-car. +- Thorough learner-facing comments on the new std-thread / channel-bridge / wrapper-decorator + patterns (slice-1 §7 standard carries over). + +### 1.2 Out of scope (with scheduled return) + +| Deferred item | Returns in | Why deferred | +|---|---|---| +| Local VAD **tuning framework** (configurable thresholds, per-environment calibration, adaptive noise floor) | post-spearhead refinement | The VAD itself (RMS/energy detector + debounce) IS in scope for slice-4 (the primary barge-in trigger, proving wedge #1). Only the *tuning* framework — configurable thresholds, calibration UI, adaptive noise floors — is deferred. The MVP ships with a single `const` threshold + N-frame debounce, exercised by the e2e test with a synthetic loud signal. Tuning to real-world noise conditions is post-spearhead. | +| Per-session media threads / threadpool shard | later rung | Single thread covers spearhead scale (loopback dev + low-concurrency PSTN via slice-5). The command-channel seam between axum and the thread makes the graduation to a threadpool shard localized. | +| Trickle ICE | later | Unchanged from slice-1 deferral. | +| Min-mute floor / inter-word-gap debouncing | post-spearhead | `SpeechStopped` is a no-op for mute; a floor timer on resume would protect against brain-yield races (brain emits fresh `audio_out` before the caller's inter-word gap ends). Defer until observed in practice. | +| Brain-side `input_audio_buffer.interrupt` / `clear` on barge | slice-5 or brain-side | Whether the brain should clear its own input buffer on `speech_started` is a brain-UX decision, not a FOB one; the FOB only kills *playout* (its half-duplex gate). The advisory already tells the brain what happened; the brain's response is its own concern. | +| Half-duplex gating beyond playout kill | later rung | Barge-in is the first half-duplex reflex; full HD gating (mixing, jitter buffer interaction, multi-party) arrives with conferencing. | +| TLS on HTTP / WSS | slice-5 | Unchanged. | +| Authn / authz / multi-tenancy | slice-6 | Unchanged. | +| Spend cap / abuse gate | slice-6 | Unchanged. | +| Browser-based automated e2e (Playwright/Selenium) | post-spearhead | Unchanged. The synthetic-peer harness from slice-2/3 is the test vehicle. | + +--- + +## 2. Architecture delta + +### 2.1 The reflex wrapper + +`Reflex` is a zero-cost-style decorator around any `AudioPipe`. It sits +between `RtcSession.pipe` (which `loop_driver::drive` calls via `session.pipe.next_pcm_frame()`) +and the concrete pipe (`TapAudioPipe` in production, `EchoAudioPipe` in slice-1's unit tests). +`loop_driver` is oblivious to the wrapper: it still calls `session.pipe.next_pcm_frame()`, +the dynamic dispatch through `Box` lands in `Reflex::next_pcm_frame`, which +applies the state machine and delegates to `inner.next_pcm_frame()` per the table in §3.2. + +The reflex owns three pieces of state: +- `advisory_rx: mpsc::Receiver` — drained sync-non-blocking via `try_recv` + on the 20 ms tick before delegating to `inner`. Fed by TWO senders on the same channel: + the outer `LocalVadReflex` (local VAD, the primary trigger) and the TapEngine task (the + brain's advisory, secondary/confirmation) — both over tokio mpsc. +- `muted: bool` — the kill state. `next_pcm_frame` returns `None` while muted, *unless* the + inner returns `Some` (the resume condition — the first fresh `audio_out` clears mute). +- `barge_epoch: u64` — incremented on every `SpeechStarted`. Load-bearing this slice: the + local VAD (primary) and the brain's advisory (secondary) can both fire on the same barge, + and the epoch distinguishes a genuine re-barge from the slower confirmation landing on the + same event (§3.2). The flush + drain keeps resume race-free regardless. + +### 2.2 The dedicated media thread + +A single `std::thread::spawn` replaces the tokio `spawn_poll_task`. The thread owns +`HashMap` **exclusively** — no `Arc>` shared with +axum. All access from the axum handlers is via a command channel: + +```rust +enum MediaCmd { + AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender> }, + Delete { id: ChannelId, reply: oneshot::Sender<()> }, + Shutdown { reply: oneshot::Sender<()> }, +} +``` + +The thread loop per 10 ms meta-tick: +1. Drain `cmd_rx` via `try_recv` loop — handle all pending commands before ticking. +2. For each session in the map: drain the per-session `flush_rx` side-channel (slice-2's + existing disconnect-flush signal) BEFORE `run_poll_once`, then call + `RtcSession::run_poll_once(now)` (the unchanged `loop_driver::drive`). +3. After `run_poll_once`, observe `channel.state`: + - `Connected && tap.is_none()` → spawn the TapEngine (tokio task via the + `tokio::runtime::Handle` captured at thread-start) + wire + `Reflex` as the session's pipe. Mirror of slice-2's spawn seam, + relocated from `session_map.rs::drive_all_sessions` to here. + - `Closed` → remove the entry + drop the session. +4. `std::thread::sleep(Duration::from_millis(10))` — 10 ms meta-tick. (Stable API: + `std::thread::sleep_until` is nightly-only; `sleep(dur)` is the stable path. The 20 ms + outbound encode tick is driven inside `loop_driver::drive` (unchanged); the 10 ms + meta-tick gives finer resolution so str0m's `Timeout` outputs are honored promptly.) + +**The tokio ↔ std-thread bridge:** all channels are tokio mpsc/oneshot (constructable on +tokio, drainable via `try_recv`/`blocking_recv` from any thread). The `tokio::runtime::Handle` +captured at `MediaThread::spawn` time is used on the std thread to `handle.spawn(...)` the +TapEngine when the `Connected` transition fires. No async code runs on the std thread itself +— only sync channel ops + `RtcSession::run_poll_once`. + +**Why a single thread, not per-session:** spearhead scale. One loopback peer at a time in +dev; even at low PSTN concurrency (slice-5) one thread drives dozens of sessions in 10 ms. +Per-session threads arrive when the threadpool shard model lands (deferred). The +command-channel seam between axum and the thread makes that graduation localized. + +### 2.3 The hot-path audit (ADR-0002 honored) + +ADR-0002's load-bearing rule: *"the control↔media gRPC hop on the per-call hot path is +removed."* Slice 4 does not re-introduce a hop: + +- The reflex's kill decision happens **inside** `Reflex::next_pcm_frame` on the dedicated + thread — no channel send, no cross-thread coordination on the 20 ms tick. The advisory + arrives via a `try_recv` drain (sync, non-blocking). +- axum → media-thread is **cold-path only** (SDP accept, DELETE). None of it runs on the + 20 ms tick. +- The brain WS ↔ TapEngine (tokio) path is unchanged from slice-3. The advisory channel + is a *third* mpsc alongside the existing `tx_pcm_in`/`rx_audio_out`/`flush_tx` — same + pattern, additive. + +**The fused vertical stays fused.** ADR-0002 honored. + +--- + +## 3. Component design + +### 3.1 `AdvisoryEvent` enum + +```rust +// crates/rutster-media/src/reflex.rs + +/// A turn-event advisory from the brain. The brain decodes its own +/// speech-to-text / VAD results and forwards these; the FOB *owns* +/// turn-taking and acts on them (slice-3 §4.3 — OpenAI Realtime +/// server-side VAD is DISABLED; the FOB's reflex is authoritative). +/// +/// Carried over a tokio mpsc from the TapEngine (tokio task) to the +/// `Reflex` wrapper (media thread). Drained sync via `try_recv` on the +/// 20 ms tick — the kill decision lives in the loop, not in a handler. +#[derive(Debug)] +pub enum AdvisoryEvent { + /// The brain detected caller speech. Trigger barge-in: kill playout. + SpeechStarted { at: Instant }, + /// The brain detected caller speech ended. Observed + counted; does + /// NOT toggle mute (the resume condition is "first fresh audio_out + /// after the barge", not "speech_stopped" — see §3.2 state table). + SpeechStopped { at: Instant }, +} +``` + +### 3.2 `Reflex

` state machine + +| Current state | Event | Action | New state | +|---|---|---|---| +| Playing | `SpeechStarted` | `muted=true`; `epoch++`; `inner.barge_in_flush()` (clear ring + drain `rx_audio_out` so stale brain frames queued pre-barge are dropped); `metrics.barge_in_count++` | Muted | +| Muted | `SpeechStarted` (duplicate/re-barge) | `epoch++`; `barge_in_flush()` again (fresh barge resets the "fresh audio" clock); `barge_in_count++` | Muted | +| Muted | `SpeechStopped` | increment `advisory_observed_speech_stopped` counter; **no state change** | Muted | +| Playing | `SpeechStopped` | increment counter; **no state change** | Playing | +| Muted | inner `next_pcm_frame()` returns `Some(f)` (fresh brain audio arrived post-barge) | `muted=false`; return `Some(f)` | Playing | +| Muted | inner `next_pcm_frame()` returns `None` | return `None` (silence); `metrics.frames_suppressed++` | Muted | + +**Why `SpeechStopped` is a no-op for mute:** per the resume-semantics decision (resume on +first fresh `audio_out`). The brain's `speech_stopped` is *observed* (counter) but doesn't +gate — this avoids the inter-word-gap problem (caller pauses, VAD fires stopped, brain +un-mutes too early, brain's audio overlaps caller's next word). The resume condition is +"the brain has yielded and started a new response," which is provably signaled by the first +`audio_out` frame after the barge — not by the caller's silence. + +**Why `epoch`:** with two concurrent trigger sources this slice — the local VAD (primary) +and the brain's advisory (secondary/confirmation, §6.1) — the epoch disambiguates "is this +barge a re-barge of the same event, or a new one" when both race into the same advisory +channel. The local VAD trips first; the brain's slower ASR-grade advisory lands ~300 ms +later on the *same* event and must not be counted as a fresh barge. The epoch is that +disambiguator — load-bearing now, not a forward-compat seam. + +### 3.3 `AudioPipe` trait extension + +```rust +// crates/rutster-media/src/pcm.rs — additive method on `AudioPipe` + +/// Barge-in flush: clear the playout ring AND drain the inbound brain +/// audio queue of any frames queued before the barge. Called by `Reflex` +/// on `SpeechStarted`. The drain of `rx_audio_out` is what makes the +/// resume condition race-free: the first `audio_out` observed post-barge +/// is provably post-barge (frames queued pre-barge are dropped here). +/// +/// Default impl delegates to `clear_playout_ring` — sufficient for +/// pipes without an inbound queue to drain (like `EchoAudioPipe`). +fn barge_in_flush(&mut self) { + self.clear_playout_ring(); +} +``` + +`TapAudioPipe` overrides: +```rust +// crates/rutster-tap/src/tap_audio_pipe.rs + +fn barge_in_flush(&mut self) { + // Clear the playout ring (drops buffered brain-proposed frames). + self.playout_ring.clear(); + // Drain rx_audio_out of any frames the engine task queued before + // the barge. Without this, a stale frame in the mpsc would un-mute + // immediately on the next tick — defeating the "first fresh audio_out" + // resume condition. Hot-path: try_recv loop, bounded, no blocking. + while self.rx_audio_out.try_recv().is_ok() { + self.metrics.barge_drained_inflight.fetch_add(1, Ordering::Relaxed); + } +} +``` + +### 3.4 `LocalVadReflex

` — the primary trigger (FOB-local, zero brain round-trip) + +The decorator that proves wedge #1. Wraps any `AudioPipe`; inspects the caller's decoded PCM +in `on_pcm_frame`, computes RMS energy, fires `AdvisoryEvent::SpeechStarted` into the inner +`Reflex`'s advisory channel when local VAD trips. Composes as +`LocalVadReflex>` — the outer wrapper does local-VAD; the inner wrapper +applies the mute state machine to the advisory stream (which now has TWO sources: the local +VAD + the brain's advisory, both feeding the same mpsc). + +```rust +// crates/rutster-media/src/reflex.rs + +/// RMS energy threshold for caller-speech detection. The MVP ships with +/// a single tuned-for-synthetic-loud-signal const; the tuning framework +/// (per-environment calibration, adaptive noise floor) is deferred per +/// slice-4 §1.2. The synthetic-peer e2e test sends a frame with samples +/// well above this threshold so the trip is deterministic. +/// +/// i16 max is 32767; a |sample| average of ~500 (~1.5% of full scale) is +/// a quiet-but-unmistakable signal; room-tone background sits well below. +/// The debounce (N consecutive frames above threshold) filters transient +/// spikes (clicks, nose-breaths) without slowing the trip materially. +pub const VAD_RMS_THRESHOLD: f64 = 500.0; + +/// Number of consecutive above-threshold frames required before the VAD +/// trips. At 20 ms/frame, N=3 = 60 ms of above-threshold audio — well +/// below the brain's ~300 ms ASR-VAD latency, comfortably instant for +/// the wedge-#1 demonstration. Tunable in a later slice; const for MVP. +pub const VAD_DEBOUNCE_FRAMES: u32 = 3; + +pub struct LocalVadReflex { + inner: P, + /// The advisory_tx the LocalVadReflex pushes into when local VAD + /// trips. This is the SAME channel the brain's advisories arrive + /// on (the Reflex holds the paired rx) — so the Reflex's mute state + /// machine sees both sources uniformly via `drain_advisories`. + advisory_tx: mpsc::Sender, + /// Consecutive above-threshold frame count (the debounce counter). + above_threshold_streak: u32, + /// True once the VAD has tripped for the current caller-speech + /// burst. Reset to false when the streak breaks (caller stopped) + /// — so a new burst trips a fresh `SpeechStarted`. + vad_armed: bool, +} + +impl LocalVadReflex

{ + pub fn new(inner: P, advisory_tx: mpsc::Sender) -> Self { + Self { + inner, + advisory_tx, + above_threshold_streak: 0, + vad_armed: true, // armed on construction + } + } + + /// Compute RMS energy of a PCM frame. The frame is 480 i16 samples + /// @ 24 kHz; RMS = sqrt(mean(sample²)). Cheap: ~480 multiplications, + /// one division, one sqrt — well under the 20 ms tick budget. + fn rms(frame: &PcmFrame) -> f64 { + let sum_sq: u64 = frame.samples.iter() + .map(|&s| (s as i64 * s as i64) as u64) + .sum(); + (sum_sq as f64 / frame.samples.len() as f64).sqrt() + } + + /// Inspect a caller PCM frame + apply the debounce state machine. + /// Called from `on_pcm_frame` (the sink path). Returns true if the + /// VAD tripped THIS call (so the caller can push the advisory). + fn observe(&mut self, frame: &PcmFrame) -> bool { + let energy = Self::rms(frame); + if energy >= VAD_RMS_THRESHOLD { + self.above_threshold_streak += 1; + if self.above_threshold_streak >= VAD_DEBOUNCE_FRAMES && self.vad_armed { + self.vad_armed = false; // disarm until caller stops + return true; // trip! + } + } else { + // Caller went quiet → re-arm for the next speech burst. + self.above_threshold_streak = 0; + self.vad_armed = true; + } + false + } +} + +impl AudioSource for LocalVadReflex

{ + fn next_pcm_frame(&mut self) -> Option { + // Pure delegation — the VAD only observes the SINK path + // (inbound caller audio); playout is the inner Reflex's concern. + self.inner.next_pcm_frame() + } +} + +impl AudioSink for LocalVadReflex

{ + fn on_pcm_frame(&mut self, frame: PcmFrame) { + // THE PRIMARY TRIGGER: inspect the caller's audio BEFORE delegating. + // If the local VAD trips, push an advisory into the same channel the + // brain's advisories arrive on — the inner Reflex drains it on the + // next next_pcm_frame call + applies the kill. Zero brain round-trip. + if self.observe(&frame) { + let _ = self.advisory_tx.try_send(AdvisoryEvent::SpeechStarted { + at: Instant::now(), + }); + // try_send failure (channel full) → drop + observe (hot-path + // policy). The brain's advisory path is the backstop; a missed + // local-VAD trip here is not catastrophic — the brain will fire + // its ASR-VAD ~300 ms later. + } + // Delegate to inner — the caller's audio still reaches the brain. + self.inner.on_pcm_frame(frame) + } +} + +impl AudioPipe for LocalVadReflex

{ + fn clear_playout_ring(&mut self) { self.inner.clear_playout_ring() } + fn barge_in_flush(&mut self) { self.inner.barge_in_flush() } +} +``` + +**Why the VAD trips `on_pcm_frame` (sink), not `next_pcm_frame` (source):** the caller's +audio is what the VAD inspects — and caller audio arrives via the sink path (decoded from +the peer's RTP by `loop_driver::drive` → `session.pipe.on_pcm_frame(pcm)`). The source path +is the brain's `audio_out` — the playout being killed, not the signal being detected. + +**Why the SAME advisory channel as the brain:** the inner `Reflex` drains all advisories +uniformly — it doesn't care whether the source is local VAD or the brain's ASR. The brain's +advisory arriving ~300 ms later either finds the kill already applied (no-op, the `muted` +flag is already true) or confirms it. This is the composition pattern §6.4 anticipated; the +`Reflex

` wrapper shape was designed for exactly this layering. + +### 3.5 `Reflex

` struct + impl + +```rust +// crates/rutster-media/src/reflex.rs + +pub struct Reflex { + inner: P, + advisory_rx: mpsc::Receiver, + muted: bool, + barge_epoch: u64, + metrics: Arc, +} + +impl Reflex

{ + pub fn new(inner: P, advisory_rx: mpsc::Receiver, metrics: Arc) -> Self { + Self { inner, advisory_rx, muted: false, barge_epoch: 0, metrics } + } + + /// Drain all pending advisories + apply the state table. Called at + /// the top of `next_pcm_frame`. Hot-path: try_recv loop, bounded. + fn drain_advisories(&mut self) { + while let Ok(ev) = self.advisory_rx.try_recv() { + match ev { + AdvisoryEvent::SpeechStarted { at } => { + self.muted = true; + self.barge_epoch = self.barge_epoch.wrapping_add(1); + self.inner.barge_in_flush(); + self.metrics.barge_in_count.fetch_add(1, Ordering::Relaxed); + tracing::info!(epoch = self.barge_epoch, ?at, "barge-in"); + } + AdvisoryEvent::SpeechStopped { at: _ } => { + self.metrics.advisory_observed_speech_stopped.fetch_add(1, Ordering::Relaxed); + // No state change — see §3.2. + } + } + } + } +} + +impl AudioPipe for Reflex

{ + fn next_pcm_frame(&mut self) -> Option { + self.drain_advisories(); + if self.muted { + // Muted: pull from inner. Some(f) = fresh brain audio arrived + // post-barge → un-mute + return. None = silence, stay muted. + match self.inner.next_pcm_frame() { + Some(f) => { + self.muted = false; + Some(f) + } + None => { + self.metrics.frames_suppressed.fetch_add(1, Ordering::Relaxed); + None + } + } + } else { + self.inner.next_pcm_frame() + } + } + + fn on_pcm_frame(&mut self, frame: PcmFrame) { + // Inbound caller audio is NEVER gated by the reflex. The brain + // still hears the caller during barge — that's the point (the + // brain needs to know the caller interrupted; the FOB only kills + // its OWN playout, not the caller's path to the brain). + self.inner.on_pcm_frame(frame) + } + + fn clear_playout_ring(&mut self) { + // The reconnect-flush path (slice-2 §5.3) still works through the + // wrapper. If it fires during mute, the ring stays empty and mute + // clears on the next post-reconnect audio_out. + self.inner.clear_playout_ring() + } + + fn barge_in_flush(&mut self) { + // Allow the outer `LocalVadReflex` (primary trigger) to barge the inner. + self.inner.barge_in_flush() + } +} +``` + +### 3.6 `ReflexMetrics` + +Mirror of `TapMetrics` shape (atomics + snapshot struct): + +```rust +// crates/rutster-media/src/reflex.rs + +#[derive(Default)] +pub struct ReflexMetrics { + pub barge_in_count: AtomicU64, + pub advisory_dropped: AtomicU64, // advisory channel full (e.g. 16-cap) + pub frames_suppressed: AtomicU64, // None returns while muted + pub advisory_observed_speech_stopped: AtomicU64, +} + +pub struct ReflexMetricsSnapshot { + pub barge_in_count: u64, + pub advisory_dropped: u64, + pub frames_suppressed: u64, + pub advisory_observed_speech_stopped: u64, +} + +// `barge_drained_inflight` lives on `TapMetrics` (in `rutster-tap`), not +// `ReflexMetrics`, because the drain happens inside `TapAudioPipe::barge_in_flush`, +// not inside `Reflex`. The path: `Reflex::drain_advisories` calls +// `inner.barge_in_flush()` which is `TapAudioPipe::barge_in_flush`, which is +// where the `rx_audio_out` drain + the counter increment happen. +``` + +--- + +## 4. The dedicated media thread + +### 4.1 `MediaThread` + +```rust +// crates/rutster/src/media_thread.rs + +pub struct MediaThread { + cmd_tx: mpsc::Sender, + join: Option>, +} + +enum MediaCmd { + AcceptOffer { id: ChannelId, sdp: String, reply: oneshot::Sender> }, + Delete { id: ChannelId, reply: oneshot::Sender<()> }, + Shutdown { reply: oneshot::Sender<()> }, +} +``` + +Spawned at binary startup (`main.rs`), before `axum::serve`. The thread captures a +`tokio::runtime::Handle` (to spawn TapEngine tasks when `Connected` transitions fire) and +owns `HashMap` + (per-session, lazily) the TapConn / advisory_rx / +Reflex wrapper. + +### 4.2 Thread loop (per 10 ms meta-tick) + +1. `cmd_rx.try_recv()` loop — handle ALL pending commands before ticking. `AcceptOffer` + calls `RtcSession::accept_offer(sdp)` and replies via the oneshot. `Delete` fires + `close_tx` + bounded-await the engine task (750 ms cap via + `tokio::runtime::Handle::block_on(timeout(...))`) — the std thread briefly enters the + tokio runtime to await; cold-path, not the 20 ms tick. `Shutdown` drains + replies. +2. For each `RtcSession` in the map: + - Drain per-session `flush_rx` side-channel (slice-2's existing disconnect-flush) BEFORE + `run_poll_once`. + - Call `RtcSession::run_poll_once(now)` — the unchanged `loop_driver::drive`. + - Observe `channel.state`: + - `Connected && tap.is_none()` → `handle.spawn(spawn_tap_engine(...))` to bring up the + tokio task; construct `Reflex::new(TapAudioPipe::new(...), advisory_rx, metrics)`; + call `RtcSession::set_pipe(reflex)`. Mirror of slice-2's spawn seam. + - `Closed` → remove the entry (drops the `RtcSession` + its pipe + advisory ends). +3. `std::thread::sleep(Duration::from_millis(10))` — 10 ms meta-tick. + +### 4.3 `session_map.rs` rewire + +`SessionEntry` loses `rtc: Arc>`, gains `cmd_tx: mpsc::Sender` +(cloned per-entry; cheap). `tap_url` stays (the thread reads it when spawning the engine). +`tap_conn: Option` moves onto the media thread (the thread owns it after spawn). + +- `AppState::create_session` → sends a `Register { tap_url, reply }` command to the media + thread; the **thread** constructs `RtcSession::new()` (saves a cross-thread move of the + struct + keeps all `RtcSession` construction on the thread that owns it). The thread + replies with `(id, cmd_tx_for_this_session)`; axum stores `SessionEntry { cmd_tx, tap_url, + tap_conn: None }`. +- `AppState::get(id)` (SDP path) → `cmd_tx.send(AcceptOffer { ... }).await` + `reply.await`. + Cold-path; the axum handler is async. +- `AppState::close(id)` → `cmd_tx.send(Delete { id, reply }).await` + `reply.await`. The + reply returns after the TapEngine teardown completes on the thread. +- `spawn_poll_task` → `spawn_media_thread`: constructs the channels, spawns the std thread, + stores `cmd_tx` + `join` in `AppState`. Same idempotent-guard pattern. + +### 4.4 TapEngine extension + +`spawn_tap_engine` returns a third channel end: `advisory_tx: mpsc::Sender`. +The pump loop, on receiving `speech_started` / `speech_stopped` from the brain (slice-3 +already decodes these in the tap protocol layer — `protocol_events.rs`), pushes the +corresponding `AdvisoryEvent` into `advisory_tx`. If the channel is full, drop + count +(hot-path "drop + observe" policy; an advisory is a hint, not a command). The `Reflex` +wrapper holds `advisory_rx`. + +### 4.5 `MockRealtimeBrain` extension + +`rutster-brain-realtime/src/mock.rs` gains a programmable advisory schedule: the test can +register "after N `audio_in` frames received, send `speech_started`" and "after M more, +send `speech_stopped`". The mock already asserts `turn_detection: null` on +`session.update` (slice-3's S4 lock); slice-4 keeps that assertion. + +--- + +## 5. Data flow + +### 5.1 Barge-in (the kill) — primary path: local VAD, zero brain round-trip + +``` +1. caller speaks into mic → peer RTP → str0m decode → on_pcm_frame + → LocalVadReflex::on_pcm_frame INSPECTS the frame FIRST: + • rms(frame) computed (~480 muls, < 1 µs) + • if rms ≥ VAD_RMS_THRESHOLD: streak++; if streak ≥ VAD_DEBOUNCE_FRAMES (3 = 60 ms) → TRIP + • on trip: try_send(AdvisoryEvent::SpeechStarted) into advisory_tx (same channel as + brain's advisories) → disarm until caller goes quiet + → THEN delegates to inner.on_pcm_frame(frame) → tx_pcm_in → TapClient → audio_in (WS) → brain +2. [THREE HOPS HAPPEN HERE, ON THE BRAIN SIDE — NOT on the kill path]: + - brain's ASR-VAD fires (~300 ms later, slower but more accurate) + - brain sends speech_started back over WS + - TapEngine → advisory_tx (the SAME mpsc; both sources feed one channel) +3. media thread next 20 ms tick → Reflex::next_pcm_frame → drain_advisories: + • The LOCAL VAD's SpeechStarted arrives FIRST (it was pushed in step 1, same thread, + no WS hop) → muted=true; epoch++; inner.barge_in_flush() (ring cleared + rx_audio_out drained) + • The brain's SpeechStarted arrives ~300 ms LATER → Reflex sees muted=true already + → re-barge (epoch++, barge_in_flush again — harmless, mute stays) + → returns None (silence) for this + subsequent ticks while muted +4. loop_driver::drive pulls None from pipe → encodes Opus silence → peer hears silence + (the brain's in-flight audio_out frames are dropped; no overlap with caller's speech) +``` + +**Latency budget:** caller speaks → kill fires in ≤ 60 ms (3 debounce frames × 20 ms tick) + +one tick to drain the advisory + apply the kill = ≤ 80 ms wallclock. Zero brain round-trip +on the primary path. (The brain's ASR-VAD advisory arrives ~300 ms later — it confirms the +kill but doesn't gate it.) This is what ARCHITECTURE.md:80 demands ("the brain round-trip is +too slow to enforce them") and the proof wedge #1 rests on. + +### 5.2 Resume (the un-mute) + +``` +1. brain decides to yield/respond → sends a fresh audio_out frame + (provably post-barge: barge_in_flush drained rx_audio_out) +2. TapClient → audio_out (WS) → TapEngine → tx_audio_out → rx_audio_out → playout ring +3. media thread 20 ms tick → Reflex::next_pcm_frame → drain_advisories (empty) + → muted=true → inner.next_pcm_frame() returns Some(f) (fresh brain audio) + → muted=false; return Some(f) +4. loop_driver encodes + writes → peer hears the brain's new response +``` + +### 5.3 Cold-path (axum ↔ media thread) + +``` +- POST /v1/sessions → AppState::create_session → MediaCmd::Register → thread constructs RtcSession → reply(id) +- POST /v1/sessions/{id}/offer → AppState::get + cmd_tx.send(AcceptOffer) → thread.lock(session).accept_offer(sdp) → reply(answer) +- DELETE /v1/sessions/{id} → AppState::close → cmd_tx.send(Delete) → thread: fire close_tx, bounded-await engine task teardown → reply +- graceful shutdown → cmd_tx.send(Shutdown) → thread drains + drops → reply → join +``` + +--- + +## 6. Why these decisions + +### 6.1 Why both, local VAD primary (revised after adversarial review) + +The initial brainstorming landed on advisory-only for MVP (cheapest path to a working +barge-in; the brain's VAD already runs for STT). The 2026-07-01 adversarial review surfaced +the load-bearing problem with that choice: README:98-100 + ARCHITECTURE.md:79-81 rest the +wedge on "local reflexes that don't need the brain — VAD killing TTS the instant the caller +speaks." Advisory-only puts the brain round-trip in the trigger path (brain VAD → WS → +TapEngine → mpsc → Reflex); the kill DECISION is in-core but the TRIGGER SOURCE crossed +the brain. The spearhead's step 4 would prove a reflex that depends on the brain — the +opposite of the property steps 1-4 exist to prove. + +The revision: **local VAD in `on_pcm_frame` is the primary trigger** (RMS/energy detector on +the dedicated thread, in the 20 ms loop, zero brain round-trip — the actual wedge-#1 proof). +Slice-3's advisory becomes the **secondary/confirmation** signal — the brain's ASR-quality +VAD confirms the local kill ~300 ms later (slower but more accurate — it knows words, not +just energy). The two sources feed the SAME advisory mpsc; the `Reflex` wrapper drains them +uniformly. The composition is `LocalVadReflex>` — the decorator pattern +from §6.4, which was originally specced as deferred to "when local VAD arrives." It arrived. + +- The VAD itself (~25 lines: RMS, threshold, debounce) is in scope. The **tuning framework** + (configurable thresholds, per-environment calibration, adaptive noise floor) is deferred — + the MVP ships a single `const` threshold + N-frame debounce, exercised by the e2e test + with a synthetic loud signal. +- Slice-3's advisory plumbing + `MockRealtimeBrain` schedule aren't wasted — they become + the secondary path, still exercised by the e2e test (the brain's advisory fires SLIGHTLY + after the local VAD; both feeds land in the Reflex's drain). +- The `Reflex

` wrapper shape already supported composition — no structural rework, just + a new `LocalVadReflex

` decorator landing in scope (Task 2b in the plan). + +### 6.2 Why resume on first fresh `audio_out` (not `speech_stopped`) + +- The "the brain has yielded and started a new response" condition is provably signaled by + the first `audio_out` frame after the barge — not by the caller's silence. `speech_stopped` + fires between words; resuming on it un-mutes too early (inter-word-gap overlap). +- The `barge_in_flush` drain of `rx_audio_out` makes the resume race-free: the first + `audio_out` observed post-barge is provably post-barge (frames queued pre-barge are dropped + in the flush). + +### 6.3 Why a single dedicated thread (not per-session) + +- Spearhead scale: one loopback peer in dev; even at low PSTN concurrency (slice-5), one + thread drives dozens of sessions in 10 ms. +- The command-channel seam between axum and the thread makes the graduation to a threadpool + shard localized — when per-CPU-shard threading arrives, it's a fan-out of the + `cmd_rx`/`HashMap` shape, not a redesign. +- Per-session threads arrive when load demands; the spearhead's "shortest blocking path" + rule dislikes spawning work per session that may not need it (pre-ICE-connected sessions + would redundantly spin). + +### 6.4 Why `Reflex

` as a wrapper (not inline in `TapAudioPipe`) + +- Composition: `LocalVadReflex

` composes outside the advisory `Reflex

` (§6.1), the + same way `Reflex` composes. The pattern (decorator over `AudioPipe`) stacks + the two trigger sources without restructuring either. +- The seam: `loop_driver.rs` byte-identical (still calls `pipe.next_pcm_frame()`). If the + reflex lived inline in `TapAudioPipe`, the binary-side wiring would still change but the + `TapAudioPipe` module itself would grow the reflex state — less isolated. +- The payoff is realized this slice, not deferred: two stacked reflexes (local-VAD primary + + advisory secondary) live as independent, separately-testable decorators rather than one + module's commingled state. Keeping them as wrappers is what makes that separation free. + +### 6.5 Why `barge_in_flush` on `AudioPipe` (not just `clear_playout_ring`) + +- `clear_playout_ring` (slice-2) clears the *ring*. `barge_in_flush` clears the ring AND + drains the *inbound brain queue* (`rx_audio_out`). The distinction matters: on a brain + disconnect (slice-2's case), the brain is gone — `rx_audio_out` will drain itself on the + next `Disconnected` `try_recv`. On a barge-in, the brain is alive and may have queued + frames pre-barge that would un-mute immediately if not drained here. Two different + "clear the playout path" semantics, two methods. + +--- + +## 7. Done-criteria + +1. `cargo test --all` passes (stable + 1.85, the CI matrix). +2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean. +3. `loop_driver.rs` + `rtc_session.rs` **byte-identical** to slice-3 — CI-asserted via + `git diff --exit-code main -- crates/rutster-media/src/loop_driver.rs + crates/rutster-media/src/rtc_session.rs` (the §8.5 #6 seam gate, restated for slice-4). +4. Dedicated media thread drives sessions off the tokio pool; `MediaThread` integration test + passes (AcceptOffer / Delete / Shutdown). +5. `Reflex` state-machine unit tests all pass: + - `SpeechStarted` → next `next_pcm_frame` returns None even if ring has frames. + - `SpeechStarted` then `inner.next_pcm_frame()=Some` → un-mutes, returns the frame. + - `SpeechStopped` during Muted → stays Muted. + - `SpeechStopped` during Playing → no-op. + - Duplicate `SpeechStarted` re-flushes + stays Muted. + - Metrics counters (`barge_in_count`, `frames_suppressed`) increment correctly. + - `advisory_rx` full → `advisory_dropped` increments, no panic. +6. `LocalVadReflex` unit tests pass: + - RMS computation verified against a known-loud + known-quiet frame. + - Debounce: N-1 above-threshold frames do NOT trip; the Nth does. + - Re-arm: above-threshold → trip; below-threshold → re-arm; next streak trips again. + - `on_pcm_frame` ALWAYS delegates to inner (caller audio reaches the brain even during barge). +7. `barge_in_flush` unit tests pass (ring + `rx_audio_out` drain). +8. Barge-in e2e (PRIMARY PATH, proves wedge #1): synthetic loud caller audio → playout killed + within ≤4 ticks (≤80 ms wallclock: 3 debounce + 1 drain+apply) **WITHOUT any brain advisory**. + The brain's `speech_started` advisory arrives later + is a no-op (mute already applied). +9. Barge-in e2e (SECONDARY PATH, exercises slice-3 advisory plumbing): `MockRealtimeBrain` + emits `speech_started` on schedule; local VAD is NOT tripped (quiet synthetic caller audio); + advisory → kill → fresh `audio_out` → resume. Proves the advisory→reflex→kill path still works. +10. S4 turn-ownership lock preserved: `MockRealtimeBrain` still asserts + `turn_detection: null` on `session.update` (slice-3's #7, unchanged). +11. `MockRealtimeBrain` extended to emit `speech_started`/`speech_stopped` on schedule. +12. `cargo doc --no-deps` renders the new `reflex.rs` + `media_thread.rs` module/item docs + cleanly (learner-facing comments present per AGENTS.md code style). + +--- + +## 8. Open decisions + +- ~~Trigger source~~ — decided: **both, local VAD primary + advisory secondary** (revised after + 2026-07-01 adversarial review; initial brainstorming landed on advisory-only, the review + surfaced that advisory-only contradicts wedge #1's "VAD killing TTS the instant the caller + speaks, without the brain"). Local VAD in `on_pcm_frame` is the primary trigger; the brain's + `speech_started`/`speech_stopped` advisory is the secondary/confirmation. +- ~~Resume semantics~~ — decided: first fresh `audio_out` post-barge; `SpeechStopped` + observational only. +- ~~Thread model~~ — decided: single dedicated `std::thread`; per-session/threadpool deferred. +- **`MockRealtimeBrain` advisory schedule API shape** — landed in §4.5 as a programmable + "after N audio_in frames" schedule. Could alternatively be a free-form + `Vec<(trigger_frame_count, AdvisoryEvent)>` queue. The plan will pin the concrete API. +- **Thread shutdown ordering vs TapEngine teardown** — `Delete` command handler fires + `close_tx` + bounded-await the engine task (750 ms cap via + `tokio::runtime::Handle::block_on(timeout(...))`); the reply oneshot returns after + teardown. Cold-path, std thread briefly enters the tokio runtime to await. Documented as + an acceptable deviation (not the 20 ms tick). + +--- + +## 9. Cross-references + +- [slice-1 spec](2026-06-28-slice-1-webrtc-loopback-design.md) — the media loop + the seam + (`AudioSource`/`AudioSink` traits in `rutster-media`); slice-1 §8.5 #6 is the seam gate + this slice re-affirms. +- [slice-2 spec](2026-06-28-slice-2-agent-tap-design.md) — the tap interface, the + `TapAudioPipe`, the core-authoritative playout buffer (§4.1), the `flush_tx` side-channel + pattern that the `advisory_rx` mirrors. +- slice-3 (merged `c30a452`) — `MockRealtimeBrain`, the translator, the + `speech_started`/`speech_stopped` protocol events, the S4 turn-ownership lock. +- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical; the hot-path + hop invariant this slice re-affirms (§2.3 audit). +- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB/green-zone doctrine; the reflex is + a FOB member (hot-path, security-constitutive for turn-taking, differentiating). +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — §"Media plane" ("Dedicated timing threads + for the 20ms loop, never the shared tokio pool" — this slice lands it); §"Biggest + technical risk" (the reflex loop *is* the remaining long pole). +- [PORT_PLAN.md](../../PORT_PLAN.md) — §Phasing, step 4 = barge-in.