diff --git a/.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md b/.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md new file mode 100644 index 0000000..e8e2244 --- /dev/null +++ b/.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md @@ -0,0 +1,420 @@ +# Strategic plan — spearhead step 4½ (benchmark + sim harness) AND step 5 (rented transport) + +- **Status:** Draft (awaiting Momus review) +- **Date:** 2026-07-05 +- **Spearhead footprint:** steps 4½ + 5 ([ADR-0010](../../docs/adr/0010-spearhead-benchmark-sim-harness.md) inserts 4½ between 4 and 5; [ADR-0007](../../docs/adr/0007-trunk-rented-transport.md) defines step 5) +- **Depends on (already merged):** + - slice-1 WebRTC media core (PR #1) + - slice-2 agent tap (PR #2) + - slice-3 OpenAI Realtime brain (PR #4) + - slice-4 barge-in / VAD-driven playout kill (PRs #7–#13) + - slice-"5" scalability seams (librutster, NOT spearhead 5) — config.rs, event_sink.rs, MediaThread tick-lag gauge, drain lifecycle, advertised media address (PR #14) + - ADR-0009 amendment shared spend accounting (PR #15) +- **Execution paradigm:** tmux-based multi-agent PM + N-dev, relay on port 7110, dev slots driven by Kimi K2.7 worker subagents (256k context window). See AGENTS.md "Multi-agent coordination — the relay". +- **Multi-agent-kickoff role count:** 1 PM + 3 devs (dev-a / dev-b / dev-c). +- **Branch prefix:** `slice-4-half/` and `slice-5/` (slice-level, not umbrella — see Git workflow). + +--- + +## 1. Strategic scope + +Two spearhead milestones bundled into one planning pass because (a) both ship on the same fused-vertical spine, (b) step 4½'s measurement harness is the proof artifact step 5's reflex claims depend on, and (c) bundling captures one PM setup + one relay poller lifecycle + one worktree layout. + +### 1.1 What each slice IS + +| Slice | Spearhead step | ADR | One-sentence goal | Output crate | +|---|---|---|---|---| +| 4½ — benchmark + sim | 4½ (inserted by ADR-0010) | ADR-0010 | A self-hostable simulation harness + CI-regressed latency thresholds that *measures* slice-4's ≤60 ms barge-in kill budget at 1/10/50 concurrency, exposing the single-thread HoL block debt with data not vibes. | `crates/rutster-sim/` (new) | +| 5 — rented transport | 5 of 6 | ADR-0007 | A real phone number via rented CPaaS raw-media fork (Twilio Media Streams MVP), no first-party SIP. PSTN audio enters the same reflex loop WebRTC legs use. | `crates/rutster-trunk/` (currently a stub; fills in this slice) | + +### 1.2 The ADR-0010 sequencing fork (load-bearing — surface to user before execution starts) + +[ADR-0010](../../docs/adr/0010-spearhead-benchmark-sim-harness.md) explicitly re-sequences the back half of the spearhead: + +> **Sequencing after 4½:** rung-2 escalation (human takeover) is pulled forward ahead of steps 5–6. It is the white space no competitor ships… it works entirely on WebRTC ingress (no PSTN dependency), and it is the capability that answers "why not point the trunk at OpenAI directly?" before step 5 makes that question live. Steps 5 (rented transport) and 6 (spend cap) follow, unchanged in content. + +Per ADR-0010, the canonical post-4½ order is: + +``` +4 (merged) → 4½ (this plan) → rung-2 escalation (deferred) → step 5 (this plan) → step 6 (spend cap) +``` + +This plan **bundles 4½ + step 5 per the user's explicit 2026-07-05 directive** ("write a plan to cover then implement 4.5 and 5"), which pushes step 5 AHEAD of rung-2 escalation. **This is a deliberate ADR-0010 deviation**, recorded here so it isn't a silent override. Reasons the user might choose this: +- The momentum value of a real phone number is the visceral demo the spearhead has been aiming at since slice-1. +- 4½'s simulation harness already proves the wedge; escalation's white-space argument ("why not point trunk at OpenAI") waits, but doesn't degrade. +- Rung-2 escalation is genuinely greenfield (no precedence in the codebase); step 5 is a more bounded integration against an existing slice-5-seams config. Risk profile favors 5 first. + +**Default action:** proceed with this plan as written (4½ + step 5). If on review the user prefers to honor ADR-0010 literally, swap step 5 → rung-2 escalation in Phase 2; everything in Phase 1 + 3 still applies (Phase 1 = both specs, Phase 3 dispatch infra is ADR-agnostic). + +### 1.3 Out-of-scope for THIS strategic plan + +| Item | Returns in | Why out | +|---|---|---| +| rung-2 escalation (human takeover, whisper, warm handoff) | later, post-step-5 OR per ADR-0010 (preferred ordering) | ADR-0010 says first; this plan defers per §1.2 fork | +| Spend cap / abuse gate (spearhead step 6) | later (post-step-5) | sequenced last; needs trunk to spend against | +| Layer-2 out-of-tree SBC adapter | graduation rung | ADR-0007 explicitly defers — only the layer-1 CPaaS-fork MVP ships in step 5 | +| Twilio ConversationRelay / managed Voice-AI products | never in core | ADR-0007 forbids — they consume the reflex loop that is rutster's differentiation | +| LLM-driven synthetic callers (4½ extension) | post-4½ refinement | ADR-0010 says scripted scenarios are 4½; LLM callers are a later extension | +| VAD tuning framework | post-spearhead | slice-4 §1.2 defers; 4½ still ships the const threshold | +| Inbound endpoint registration (desk phones) | never | ADR-0007 green-zone, out-of-tree, never first-party | +| Per-session media threads / threadpool shard | later rung | slice-4 §1.2 defers; 4½'s concurrency sweep surfaces the debt with data | +| Barge-in across multi-party / conferencing | later rung | slice-4 §1.2 defers | +| Docker / `compose up` | later rung | not in spearhead | +| Browser-based Playwright e2e | post-spearhead | unchanged from slice-1's deferral | + +### 1.4 FOB / green-zone classification (ADR-0008 application) + +**Slice 4½** — entirely FOB. The sim harness: +- Hot-path-adjacent (it drives the same 20 ms media loop) +- Differentiating (the proof artifact; the wedge's measurement claim) +- Self-hosted, memory-safe Rust ⇒ FOB + +The harness MAY link `str0m` and `tokio` (mature, actively-maintained) as trusted deps — same pattern as `rutster-media`. No green-zone members introduced. + +**Step 5** — split: +- FOB: the WebSocket SERVER that ingests the provider's media fork, µ-law ↔ 24 kHz PCM transcoding, the `TrunkAudioPipe` (impl `AudioPipe`) that plugs into the existing media-leg seam, the reflex-loop wiring (slice-4's `Reflex`/`LocalVadReflex` apply transparently because they decorate `AudioPipe`, agnostic to leg type). All hot-path or differentiating ⇒ FOB. +- Green zone: Twilio REST API client (call control: answer/hangup/originate), credential storage, account-SID/account-token plumbing. This is a CPaaS dependency — ADR-0007 explicit. Lives in `crates/rutster-trunk/` at arm's length: behind a `CallControlClient` trait with a `TwilioCallControlClient` impl + a `MockCallControlClient` test double. The hot path never touches Twilio's REST API. The brain never holds provider credentials (ADR-0007 / ADR-0009). + +### 1.5 The seam invariant (sacred) + +`crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` stay byte-identical through both slices. CI pinned-blob gate (slice-4 Task 10) keeps guarding. **Every dev directive broadcasts this.** The only exception: a future ADR explicitly says otherwise. + +For slice 4½: no seam risk — the harness drives via existing media-leg path. +For step 5: the `TrunkAudioPipe` plugs into the existing `MediaThread` `HashMap` shape; the existing RtcSession codepath (for WebRTC legs) is untouched. A new `MediaCmd::RegisterTrunk` variant routes through the existing command channel; the std thread accepts both WebRTC and trunk legs. + +--- + +## 2. Phase 1 — Design specs (parallelizable) + +Two design specs land before any code. Both can be written concurrently (different domains, no shared file between them). + +### 2.1 Slice 4½ spec + +**Path:** `docs/superpowers/specs/2026-07-XX-slice-4-half-benchmark-sim-design.md` + +**Required sections (mirror slice-4 spec skeleton):** +- §1 Scope (1.1 in / 1.2 out — see §1.2 + §1.3 of this plan) +- §2 Architecture delta (the harness reusable across slices; measurement boundary; CI thresholds) +- §3 Component design (Scenario, SimAudioPipe, LatencyProbe, ConcurrencyRunner, TickLagGauge) +- §4 Data flow (caller-speech-onset → kill-fires path; the policy "drop + observe" for hot path) +- §5 Measurement plan (the budgets: ≤60 ms kill, p50/p99 mouth-to-ear, 1/10/50 concurrency targets) +- §6 Why these decisions (local vs LLM caller; scripted scenarios first; CI gate) +- §7 Done-criteria (CI threshold tables, regression fail-the-build contract) +- §8 Open decisions (threshold values; CI bench feature flag; machine-noise normalization) +- §9 Cross-references (ADR-0010, slice-4 §5.1 budget, slice-5/seams tick-lag gauge) + +**Pre-decided design calls (to lock at spec time, not negotiate during impl):** +- The harness is a `cargo test --features=sim-bench` or `cargo bench`-style entrypoint; default `cargo test --all` does NOT trigger simulated benchmarks (they're opt-in to keep CI fast for code changes). +- A SECOND CI job runs `cargo test --features=sim-bench` per PR + nightly; failure fails the build (CI gate, ADR-0010). +- Scenario format is TOML or YAML (TBD at spec time). Each scenario = serialized caller PCM segments + timing + an expectation. +- Latency measurement uses `Instant::now()` (already monotonic) and aligned ring-buffer capture (`PcmFrame`-tagged timestamps at sink/source). +- Concurrency sweep asserts **p99 kill-time ≤ 80 ms at 1 / 10 / 50 calls** (80 ms = 60 ms budget + 20 ms observer slack; ADR-0010 explicitly allows the 60 ms kill + 1 tick). +- Tick-lag gauge (already in `media_thread.rs` from slice-5/seams) is the primary readout for "is the single-thread poll-loop under tension?" Expected at 1 call: ≤2 ms lag. At 50 calls: ≤10 ms lag. >20 ms ⇒ test fails (the HoL debt is real and needs a threadpool shard, slice-4 §1.2 explicitly deferred). + +### 2.2 Step 5 spec + +**Path:** `docs/superpowers/specs/2026-07-XX-slice-5-rented-transport-design.md` + +**Required sections:** +- §1 Scope (1.1 in / 1.2 out — see §1.2 + §1.3 of this plan) +- §2 Architecture delta (the trunk as a media-leg ingress parallel to WebRTC; µ-law↔PCM transcoding; provider REST at arm's length; the `MediaThread` extension) +- §3 Component design (TwilioMediaStreamsServer, TrunkAudioPipe, G711Codec, CallControlClient trait + TwilioCallControlClient impl) +- §4 Data flow (provider WS connect → JSON "Start" → "Media" base64 µ-law frames → decode → PcmFrame → media-leg pipe → reflex loop → encode return) +- §5 Provider abstraction (the trait + the seam: config selects `twilio` | `telnyx` | `mock`; only Twilio ships in MVP, the trait locks the seam) +- §6 Why these decisions (no SIP, ever; raw-audio fork, not managed Voice-AI; FOB vs green-zone split) +- §7 Done-criteria (PSTN sim → reflex → barge-in e2e; media-leg abstraction works for both WebRTC + trunk; credential never leaks into the brain) +- §8 Open decisions (Twilio CallCtl vs raw-WS-only; Twilio chunk size / µ-law rate negotiation; account-SID storage — env vs KMS-backed Vault sidecar) +- §9 Cross-references (ADR-0007, ADR-0008, ADR-0009 spend-accounting amendment, slice-4 §3.1 reflex layering, slice-5/seams config surface) + +**Pre-decided design calls:** +- MVP provider is **Twilio Media Streams** (raw-audio WSS fork at 8 kHz µ-law). Cleaner doc'd API than Telnyx; well-trodden. +- The provider REST call-control client (`answer`/`hangup`/`originate`) is **green-zone**: behind a trait, the Twilio impl uses `reqwest`. The FOB never holds Twilio credentials directly — they live in `crates/rutster-trunk` config behind a `TwilioCredentials` struct, scoped to the trunk crate only. +- µ-law (G.711) codec: build it in-core (~30 lines, table-driven, learner-facing comments — it's a fascinating piece of telephony history). The 8 kHz ↔ 24 kHz resampling uses linear interpolation (the slice-1 PcmFrame is 24 kHz; Twilio ships 8 kHz). No new dep. +- The trunk leg participates in `MediaThread`'s `MediaCmd` enum as `RegisterTrunk { provider, credentials, reply }` + `HangupTrunk { id, reply }`. The existing `Register`/`AcceptOffer`/`Delete`/`Shutdown` variants are unchanged. The std thread accepts the registration, opens a `TrunkAudioPipe`, wires `Reflex` (same composition as slice-4) and puts it in the same `HashMap`. Actually — to keep the seam clean, theEnum value carries the leg variant. +- The `AudioPipe` trait works as-is for trunk legs. The existing `TapAudioPipe` currently routes PCM over `tx_pcm_in` / `rx_audio_out` against the tap's WebSocket; for trunk legs, **the tap WS is the trunk WS** (provider raw-audio fork = the brain's audio_out / the caller's audio_in, swapped for the PSTN caller). + + Wait — that's a clean design: the trunk leg IS a TapAudioPipe where the WS endpoint is Twilio's media fork instead of OpenAI's WS endpoint. The tap protocol is replaced by Twilio's JSON framing. Different protocol on the WS, same `AudioPipe` shape on the FOB side. ✓. + +### 2.3 Spec writing mode + +Specs are written by the PM in the main session (not delegated). Rationale: they encode load-bearing decisions cross-referencing ADRs, prior slices, and the reflex-loop seam. Dev terminals consume specs; they don't write them. + +After both specs land, both get a Momus review (one per spec — `.omo/plans/` invocation pattern is non-standard, see OpenCode Momus docs; use the `task(subagent_type="momus", prompt="")` form per AGENTS.md). + +--- + +## 3. Phase 2 — Implementation plans + +Two implementation plans, one per slice. Each derived from its spec. + +### 3.1 Slice 4½ implementation plan + +**Path:** `docs/superpowers/plans/2026-07-XX-slice-4-half-benchmark-sim.md` + +**Tasks (Kimi-256k-sized — each fits comfortably, ≤4 files, ≤200 LOC, ≤6 tool calls per step):** + +| Task # | Title | Files | Depends on | Parallelizable-now? | +|---|---|---|---|---| +| **S1** | `crates/rutster-sim/` skeleton + `Scenario`/`ScenarioStep` types + `PcmSegment` | new crate, `crates/rutster-sim/src/{lib.rs,scenario.rs}`, root `Cargo.toml` member | none | **FOUNDATION** — lands first | +| **S2** | `SimAudioPipe: AudioPipe` — scripted PCM playback on `next_pcm_frame`, captures received frames on `on_pcm_frame` (timestamps each) | `crates/rutster-sim/src/sim_audio_pipe.rs` | S1 | after S1 | +| **S3** | `LatencyProbe` — mouth-to-ear + barge-in kill-time probes; ring-buffer capture timestamps from `SimAudioPipe` | `crates/rutster-sim/src/latency.rs` | S2 | after S2 | +| **S4** | `ScenarioRunner` — drives one `SimCall` end-to-end against a target binary URL (or in-process via `MediaThread`) | `crates/rutster-sim/src/runner.rs` | S2, S3 | after S3 | +| **S5** | `ConcurrencyRunner` — N concurrent `SimCall`s, computes p50/p99 + aggregates per-call latencies | `crates/rutster-sim/src/concurrency.rs` | S4 | after S4 | +| **S6** | `TickLagGauge` — reads slice-5/seams tick-lag metric from `media_thread.rs` (already exposed via `MediaCmd::Stats`); exposes as primary readout in sweep report | `crates/rutster-sim/src/tick_lag.rs` | S5 | after S5 | +| **S7** | CI bench feature + 1/10/50 sweep thresholds — `cargo test --features=sim-bench`; CI job asserts thresholds | `.github/workflows/ci.yml`, `crates/rutster-sim/Cargo.toml` | S5, S6 | after S5 + S6 | +| **S8** | One shipped scenario pack (`scenarios/loud-barge.toml`, `scenarios/quiet-advisory.toml`, `scenarios/sustained-call.toml`) + LEARNING.md pointer | `crates/rutster-sim/scenarios/*`, `LEARNING.md` | S4 | filler; any time after S4 | + +**Critical path:** S1 → S2 → S3 → S4 → S5 → S6 → S7 (linear). S8 is filler. + +**Why linear:** each task consumes the prior task's types. Fanning out across this 8-task chain stalls. Instead, this slice is best executed by ONE Kimi dev (dev-a) doing the linear chain, while dev-b and dev-c work on the step-5 spec/plan + the step-5 fundamental tasks (see §3.2). + +### 3.2 Step 5 implementation plan + +**Path:** `docs/superpowers/plans/2026-07-XX-slice-5-rented-transport.md` + +**Tasks:** + +| Task # | Title | Files | Depends on | Parallelizable-now? | +|---|---|---|---|---| +| **T1** | `G711Codec` — µ-law encode/decode (table-driven, learner-commented) + 8 kHz↔24 kHz resampling | `crates/rutster-trunk/src/g711.rs`, `crates/rutster-trunk/src/lib.rs` | new `crates/rutster-trunk/Cargo.toml` deps (none — pure std) | **FOUNDATION** — lands first | +| **T2** | `CallControlClient` trait + `MockCallControlClient` (in-process test double) + `TwilioCredentials` config struct + env parser | `crates/rutster-trunk/src/provider/{mod.rs, mock.rs, twilio.rs}` | T1 (independent — provider module imports `g711` types? probably not — different file) | after T1 OR parallel | +| **T3** | `TwilioMediaStreamsServer` — accept inbound WSS connection, parse JSON Start/Media/Stop frames, decode base64 µ-law → PCM frames, encode return | `crates/rutster-trunk/src/twilio_media_streams.rs` | T1 | after T1 | +| **T4** | `TrunkAudioPipe: AudioPipe` — wraps the Twilio media-streams session as an `AudioPipe`; produces PCM frames on `next_pcm_frame` (decoded from Twilio "Media" frames), consumes PCM on `on_pcm_frame` (encoded to µ-law + sent as JSON "Media" frames back) | `crates/rutster-trunk/src/trunk_audio_pipe.rs` | T1, T3 | after T3 | +| **T5** | `MediaThread::RegisterTrunk` — extends `MediaCmd` enum (`Register`, `AcceptOffer`, `Delete`, `Shutdown`, `Stats`, `Drain` from slice-5) with `RegisterTrunk` variant; accepts a `TrunkAudioPipe`, wires `Reflex` (same composition as slice-4) and wraps `LocalVadReflex` outer; places in session map | `crates/rutster/src/media_thread.rs`, `crates/rutster/src/routes.rs` (new `POST /v1/sessions/trunk` route) | T4, + slice-4 (merged) + slice-5 (merged) | after T4 | +| **T6** | `TwilioCallControlClient` impl — REST API client (answer/hangup/originate); uses `reqwest`; credentials from env (slice-5/seams `config.rs` already has the parser pattern) | `crates/rutster-trunk/src/provider/twilio.rs` (extend), `crates/rutster/src/config.rs` (env parser) | T2 | parallel with T3/T4/T5 | +| **T7** | `Reflex applies to PSTN leg` — verify slice-4's `Reflex` + `LocalVadReflex` decorate the trunk `TrunkAudioPipe` transparently (or wire `Reflex` if the leg has its own pipe type) | `crates/rutster-trunk/tests/reflex_on_trunk.rs` | T4, T5 | after T5 | +| **T8** | PSTN sim e2e — `MockCallControlClient` + an in-process Twilio-media-streams simulator; verify barge-in fires on PSTN leg; CDR / `EventSink` emission | `crates/rutster-trunk/tests/sim_integ.rs` | T5, T7 | after T7 | +| **T9** | QUICKSTART update — env-var table for Twilio credentials; "make a real phone call" section | `docs/QUICKSTART.md`, `README.md` | T5, T6 | after T5 + T6 | +| **T10** | CI seam gate re-pin + cargo deny for any new Twilio dep | `.github/workflows/ci.yml`, `deny.toml` | T6 (introduces `reqwest` dep) | after T6 | + +**Critical path:** T1 → T3 → T4 → T5 → T7 → T8. T2/T6 can start parallel with T1 if dev-b picks up T2 right away (provider trait doesn't depend on g711). T9 is filler (after T5 + T6 land). T10 is final sweep. + +**Note on T2 ordering:** T2 is the spec'd out `CallControlClient` trait + mock + credential struct. It DOES NOT depend on T1 (g711 codec) — they live in different modules. Dev-b can pick up T2 in parallel with dev-c on T1. + +### 3.3 Cross-slice dependency graph + +``` +4½ spec → 4½ plan → S1 → S2 → S3 → S4 → S5 → S6 → S7 + ↘ S8 (filler; parallel after S4) + +5 spec → 5 plan → T1 (foundation, parallel with T2) → T3 → T4 → T5 → T7 → T8 + ↘ T2 → T6 ↗ +T9 (filler after T5 + T6) T10 (final sweep) +``` + +**4½'s harness doesn't depend on step 5 code** (the sim harness drives any media leg; we'll bus it against the WebRTC ingress for the MVP latency thresholds — 4½'s tests run against WebRTC, not trunk, since slice-4 proved barge-in on WebRTC first). But the harness CAN test trunk legs once trunk lands — false dependency avoided. + +**Step 5's reflex reuse depends on slice-4 (merged) — not on 4½.** So 4½ and step 5 implementation can run in PARALLEL across different dev terminals, in different worktrees. + +--- + +## 4. Phase 3 — Multi-agent execution (tmux + Kimi K2.7 workers, 256k context) + +### 4.1 Dev count + role assignment + +**3 dev terminals:** `dev-a`, `dev-b`, `dev-c`. Each dev's terminal runs its own persistent agent (Claude Code or GLM session) which dispatches Kimi K2.7 Code worker subagents (`task(subagent_type="kimi-worker", load_skills=[...], run_in_background=false, prompt="...")`) for the unit-of-work tasks. + +**Why Kimi-worker:** 256k context window is plenty for the per-task payloads above (each task's plan section + required reading + code ≅ 30–50k tokens). Kimi K2.7 Code is cheap + capable for slice-bound file-scoped Rust work. Each dev-session holds the cross-task context (worktree state, recent file edits, relay inbox); each Kimi-worker subagent does ONE task with TDD discipline. + +**Dispatch shape:** + +| Dev | Slice 4½ tasks | Step-5 tasks | Worktree path | +|---|---|---|---| +| `dev-a` | S1 → S2 → S3 → S4 → S5 → S6 → S7 → S8 (the linear chain) | — | `/home/alee/Sources/rutster.slice-4-half-sim` branch `slice-4-half/sim-harness-dev-a` | +| `dev-b` | — | T2 → T6 → T9 → T10 (the provider/REST + docs + final sweep chain) | `/home/alee/Sources/rutster.slice-5-trunk` branch `slice-5/rented-transport-dev-b` | +| `dev-c` | — | T1 → T3 → T4 → T5 → T7 → T8 (the media-streams + AudioPipe + MediaThread wiring chain) | `/home/alee/Sources/rutster.slice-5-trunk-b` branch `slice-5/rented-transport-dev-c` | + +**Why split step-5 across two devs:** the file-set on the FOB side (`crates/rutster-trunk/src/{g711.rs, twilio_media_streams.rs, trunk_audio_pipe.rs}` + `crates/rutster/src/media_thread.rs`) is non-overlapping with the provider/REST + config side (`crates/rutster-trunk/src/provider/*` + `crates/rutster/src/config.rs` + `docs/QUICKSTART.md`). dev-c on FOB leg wiring, dev-b on provider/config/docs — same pattern as slice-4 (dev-a on reflex stack, dev-b on tap-client + mock brain). + +### 4.2 Dispatch schedule (PM-side, derived after specs + plans land) + +D-Day: + +``` +T+0 PM terminal opens (single tmux window — start.sh --tmux opens relay + pm only), + scans .omo/plans + specs 4½ + step-5. +T+10m PM applies multi-agent-kickoff skill → generates 4 kickoff prompts + (saved under `docs/superpowers/kickoffs/2026-07-XX-spearhead-4half-5-{pm,dev-a,dev-b,dev-c}-prompt.md`). +T+15m User pastes ONLY the PM prompt into the pm window. Relay + poller already running + (PM launch checklist steps 1+2 done by the user before pasting). +T+30m PM auto-spawns dev-a/b/c via 3 `task(subagent_type="general", run_in_background=true, + prompt="")` calls. Each returns a `bg_...` task ID; PM + retains them for `background_output(task_id="bg_...")` polling. + Each dev subagent: + - cd's into its worktree (creating it per the prompt's setup bash) + - Reads plan + spec + AGENTS.md + - Dispatches `task(subagent_type="kimi-worker", ...)` per plan task + - Posts STATUS reports to the relay (`from="dev-X"`, `to="pm"`, `kind="status"`) + - Returns REVIEW-READY + tea PR URL when its slice is complete +T+1h+ PM polls each dev's `bg_...` between its own turns + drains the relay inbox for + kind=status messages. Surfaces actionable items to the user before they have to ask. + Opens PR reviews (via tea) as each dev completes a task group. +T+5h ballpark EOL: all tasks merged, final CI sweep, Momus review of implementation, + optional surfacing of any ADR-0010 escalation-vs-step-5 deviation for the user to ratify. +``` + +### 4.3 Per-task payload shape (what each Kimi-worker subagent receives) + +Each task becomes one Kimi-worker invocation. The dev-session (holding context across tasks) calls: + +```text +task( + subagent_type="kimi-worker", + load_skills=["programming"], // shared/programming — strict-types, TDD + run_in_background=false, + prompt="" +) +``` + +**Where `` is the Kimi-sized work unit.** It carries six sections (matching AGENTS.md delegation protocol): + +``` +TASK: +EXPECTED OUTCOME: +REQUIRED TOOLS: +MUST DO: + - Read the plan file: docs/superpowers/plans/ -- section Task + - Read AGENTS.md "Code style (Rust)" section -- learner-facing comments REQUIRED + - DCO signoff on every commit: `git commit -s` + - Hot-path policy (if touching 20 ms tick code): NO ?-propagate; match-and-continue + - Worktree: cd before any edit +MUST NOT DO: + - Do NOT touch crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} -- seam invariant + - Do NOT cross-touch files owned by another dev (see plan: parallel-safe set per task) + - Do NOT push --force, --reset-hard, branch -D, rm -rf + - Do NOT start the next task in this subagent -- report STATUS only +CONTEXT: + - Branch: + - Plan: docs/superpowers/plans/ + - Spec: docs/superpowers/specs/ + - Predecessor task status (if applicable): + - Successor task waiting on this: +``` + +**Task size validation (Kimi 256k constraint):** each `` plus its required reading (plan section, spec section, AGENTS.md relevant section) plus the dev's code-write payload fits comfortably in **≤40k tokens**. The 256k ceiling gives 6× headroom for Kimi to read more broadly (other slice docs, str0m API exploration, etc.) if it benefits the work. Empirically kimi-worker is fast at file-scoped Rust work at this size. + +### 4.4 Relay + worktree layout + +| Item | Value | +|---|---| +| Relay path | `~/Sources/relay` (standalone, per AGENTS.md) | +| Relay port | `7110` (rutster's port per AGENTS.md table) | +| Poller command | `setsid env RELAY_PORT=7110 python3 ~/Sources/relay/poller.py >> /tmp/relay-poller/7110/poller.log 2>&1 < /dev/null & disown` | +| `watch.sh` window | `RELAY_PORT=7110 ~/Sources/relay/watch.sh` (live tail of inbox.log + poller.log) | +| Worktrees | `/home/alee/Sources/rutster.slice-4-half-sim` (dev-a) · `/home/alee/Sources/rutster.slice-5-trunk` (dev-b) · `/home/alee/Sources/rutster.slice-5-trunk-b` (dev-c) | +| Branch scheme | `slice-4-half/sim-harness-dev-a` · `slice-5/rented-transport-dev-b` · `slice-5/rented-transport-dev-c` | +| PM does NOT own a worktree | PM only reads main + reviews PRs (per multi-agent-kickoff role boundaries) | +| PR via `tea` | `tea pulls create --head --base main --title "..." --description "..."` (per AGENTS.md Git workflow) | +| Merge strategy | squash-merge for both slices (single-PR-per-task shape, linear history); carve-out for stacked branches (slice-4-half / slice-5 branch pair) → rebase-merge if SHAs carry to a dependent sibling branch | + +### 4.5 Multi-agent kickoff — files this plan hands to the kickoff skill + +After both specs + both impl plans land, the kickoff skill consumes: + +- `<>` — there are TWO specs. The kickoff skill's `<>` placeholder is single-valued; we substitute with **both**, comma-separated, in the PM prompt's required-reading list (the kickoff skill's templates support explicit required-reading lists per role). Dev prompts reference ONLY their own slice's spec (dev-a → 4½ spec only; dev-b/dev-c → step-5 spec only). +- `<>` for the PM: + - **Plan A:** `docs/superpowers/plans/2026-07-XX-slice-4-half-benchmark-sim.md` — slice 4½ sim harness; dev-a; S1→S8 linear; readouts feed ADR-0010's "is the wedge measured?" debt + - **Plan B:** `docs/superpowers/plans/2026-07-XX-slice-5-rented-transport.md` — step-5 rented transport; dev-b (provider+docs) + dev-c (FOB media-streams + MediaThread wiring); 10 tasks T1→T10; Twilio Media Streams MVP per ADR-0007 +- `<>` = 3 +- `<>` = `spearhead-4half-and-5` +- `<>` = `A, B, C` +- `<>` = `pm, dev-a, dev-b, dev-c` +- `<>` = `7110` +- `<>` = `AGENTS.md` (not CLAUDE.md — rutster uses AGENTS.md) + +### 4.6 Per-dev scope summaries (for the kickoff `<>` placeholders) + +**dev-a (slice 4½):** Implements the entire `crates/rutster-sim/` crate (currently non-existent) — Scenario/ScenarioStep types, SimAudioPipe implementing rutster-media's AudioPipe trait, LatencyProbe with ring-buffer timestamp capture, ScenarioRunner for end-to-end call simulation, ConcurrencyRunner for N-concurrent-call p50/p99 latency measurement, TickLagGauge integration with slice-5/seams MediaThread::Stats metric, and a CI-regressed bench feature gating `cargo test --features=sim-bench`. Linear chain S1→S8; ships ADR-0010's "wedge is now measured by data, not arithmetic" closure. + +**dev-b (step 5, provider + docs):** Implements the green-zone side of `crates/rutster-trunk/` — the `CallControlClient` trait + `MockCallControlClient` (in-process test double) + `TwilioCallControlClient` (REST client via `reqwest`), the `TwilioCredentials` config struct + env parser wired into `crates/rutster/src/config.rs`, the QUICKSTART + README updates for "make a real phone call", and the cargo-deny + CI seam-gate re-pinning after the new `reqwest` dep. Chain T2→T6→T9→T10; tightly bounded — never touches the FOB media-stream machinery. + +**dev-c (step 5, FOB media + MediaThread wiring):** Implements the FOB side of `crates/rutster-trunk/` — the in-core µ-law (G.711) codec + 8kHz↔24kHz resampling (table-driven, learner-commented), the `TwilioMediaStreamsServer` (accept inbound WSS, parse JSON Start/Media/Stop frames, base64 decode → PCM frames), the `TrunkAudioPipe: AudioPipe` (wraps the WSS as an AudioPipe that produces PCM on next_pcm_frame / consumes + encodes on on_pcm_frame), the `MediaCmd::RegisterTrunk` extension to the existing MediaThread, the reflex-on-trunk-leg verification test, and the PSTN-sim e2e integration test. Chain T1→T3→T4→T5→T7→T8; the FOB hot-path / differentiating work. + +--- + +## 5. Risks + open forks + +### 5.1 ADR-0010 deviation (the load-bearing one) + +§1.2 above. The user's directive ("4.5 and 5") pushes step 5 ahead of rung-2 escalation, contradicting ADR-0010's explicit pull-forward. Surfacing at user level with proposed default: **proceed with this plan as written; ratify the deviation by amending ADR-0010 (or leave ADR-0010 intact + note the deviation on the slice-5 PR description).** ADR-0010 doesn't strictly forbid the deviation — it states a default ordering. The user's call is legitimate; this plan records it loudly. + +### 5.2 Twilio account credentials in CI + +Slice 5's e2e needs Twilio credentials OR a Twilio simulator. The FOB-side TwilioMediaStreamsServer is testable in-process (simulator → real WSS→ real TrunkAudioPipe → real MediaThread → real Reflex); the green-zone-side TwilioCallControlClient needs either: +- (a) `cargo test --features=twilio-live` running against real Twilio (only on maintainer-triggered runs, not per-PR) +- (b) a `MockCallControlClient` for the per-PR sweep + a manual "live override" path + +Default: **(b)**. CI runs the mock. The maintainer runs live e2e manually when validating a release. Documented in QUICKSTART. + +### 5.3 Reflex re-composition for trunk legs + +Slice-4's `Reflex` composes with `LocalVadReflex` outside. For trunk legs, the AudioPipe is `TrunkAudioPipe`. The composition is `LocalVadReflex>` — same shape, different inner. The reflex trait + decorators are pipe-agnostic by design (slice-4 §3.2); the same wrappers instantiate against the new pipe. + +**Risk:** if the trunk AudioPipe has different lifecycle semantics (e.g. needs explicit WebSocket close on barge), the slice-4 `barge_in_flush` interface might be too narrow. T7 (the reflex-on-trunk verification test) surfaces this. If it triggers, STOP + post `kind=question` to PM; do not silently widen the trait. + +### 5.4 CI bench feature flag interaction with the existing test matrix + +Slice-4's CI matrix runs `cargo test --all` on stable + 1.85. Adding `--features=sim-bench` to a separate job is fine; just ensure the bench feature doesn't ADD a default-on dependency (it must be opt-in only, or default `cargo test --all` stays fast for routine PRs). + +### 5.5 Slice-5 / "scalability seams" naming collision + +The merged infra-seams slice (PR #14) is internally labeled "slice-5" in its plan path (`docs/superpowers/plans/2026-07-04-slice-5-scalability-seams.md`) but the plan itself states: "this is *not* spearhead step 5 (rented-transport trunk)." That infra slice is the *pre-paver* for spearhead step 4½ + step 5. Newcomers may be confused. Mitigation: the new step-5 plan path uses `slice-5-rented-transport` (unambiguous); the existing one stays as-is (already merged). Document the distinction in the spec's §1. + +### 5.6 Single-thread contention on trunk leg + +Slice-4 §1.2 explicitly defers per-session media threads / threadpool shard. Slice 4½'s concurrency sweep (target: ≤10 ms tick-lag at 50 calls) reads the gauge but the trunk leg will be the first time a "second kind" of leg shares the std thread. If contention is observed during slice-5 dev-c e2e, the slice-4-half/seams debt pays off — the threadpool-shard graduation is the answer *with data*, not doctrine. Don't pull the graduation forward speculatively. + +--- + +## 6. Phase order summary (PM cheat-sheet) + +``` +Phase A — Specs (PM, in main session, parallel): + 1a. Write `docs/superpowers/specs/2026-07-XX-slice-4-half-benchmark-sim-design.md` + 1b. Write `docs/superpowers/specs/2026-07-XX-slice-5-rented-transport-design.md` + → Momus review both + +Phase B — Impl plans (PM, in main session, parallel): + 2a. Write `docs/superpowers/plans/2026-07-XX-slice-4-half-benchmark-sim.md` + 2b. Write `docs/superpowers/plans/2026-07-XX-slice-5-rented-transport.md` + → Momus review both + +Phase C — Multi-agent kickoff (PM, in main session): + 3. Run `multi-agent-kickoff` skill — generate pm + dev-a + dev-b + dev-c prompts + to `docs/superpowers/kickoffs/2026-07-XX-spearhead-4half-5-{pm,dev-a,dev-b,dev-c}-prompt.md` + 4. Verify relay (7110) is running; verify poller (≥3 cycle-complete lines) + +Phase D — Execute (PM + dev terminals, separate windows): + 5. User opens 4 terminals, pastes prompts + 6. Each dev dispatches Kimi-worker subagents per task + 7. PM consolidates relay inbox + opens PRs as tasks finish + 8. Momus sweeps the merged stack +``` + +--- + +## 7. Stopping condition + +This strategic plan is **done** when: + +1. Both specs (4½ + step-5) are merged to main. +2. Both impl plans are merged to main. +3. The multi-agent-kickoff prompts exist + are committed (or the user opts to paste them ad-hoc). +4. Kimi-worker dispatch has executed every task in §3.1 + §3.2; all PRs merged. +5. CI is green on main: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all` (stable + 1.85), `cargo deny check`, the new `cargo test --all --features=sim-bench` job. +6. The seam gate still passes — `loop_driver.rs` + `rtc_session.rs` byte-identical. +7. Momus review of the merged stack passes. +8. ADR-0010 status reconciled (amend OR note deviation on the step-5 PR description). + +At this point the spearhead has steps 1–5 complete; only step 6 (spend cap) + rung-2 escalation remain (in ADR-0010-recommended order: escalation first, then spend cap; per §1.2 fork, the user may re-confirm). + +--- + +## 8. First action (PM-side, after plan ratification) + +If this plan is ratified post-Momus: + +1. Confirm the ADR-0010 deviation choice with the user (proceed as-written, swap step 5 → escalation, or amend ADR-0010). +2. Read AGENTS.md "Multi-agent coordination" + "PM-mode discipline" sections in full. +3. Start the poller if not running (verify ≥3 cycle-complete lines). +4. Begin Phase A (specs). diff --git a/AGENTS.md b/AGENTS.md index c59dd94..57bbe27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,8 @@ and "why isn't X here?" questions. Consult it before adding anything. ## Multi-agent coordination — the relay (cross-model) +> **Default for routine task splitting:** prefer subagent dispatch via `task(subagent_type=..., load_skills=[...], run_in_background=...)` from the main Sisyphus session — it natively isolates worker context and saves tokens without any message-bus setup. This section applies only to the long-haul multi-dev *persistent-session* paradigm where each dev needs its own visible streaming terminal. + When more than one agent session works this repo in parallel (the PM / senior-dev "lift" paradigm), they coordinate through a small MCP message-bus — the **relay** at [`~/Sources/relay`](file:///home/alee/Sources/relay). One server, many terminals: each @@ -480,16 +482,28 @@ This live-tails both `inbox.log` (dev → pm messages) and `poller.log` (poll cy markers + nudges), so you see dev activity in real time without depending on the PM agent surfacing it. You see what the PM sees. -**4. Bring up the PM/dev sessions (manual / tmux / kitty):** +**4. Launch the PM session (single terminal — the PM auto-spawns the devs):** ```bash -~/Sources/relay/start.sh --repo ~/Sources/rutster --port 7110 --kitty # or --tmux +~/Sources/relay/start.sh --repo ~/Sources/rutster --port 7110 --tmux +# Opens a tmux session "relay-lift" with two windows: relay (the server log) +# + pm (a fresh claude session). Attach: tmux attach -t relay-lift ``` -`--kitty` / `--tmux` spawns 5 terminals (relay + pm + dev-a/b/c) with the MCP relay -pre-registered for each. The generated prompts bake in `RELAY_PORT=7110` so the shims -(`call.py`) self-configure even without the env. (For `--manual`, the relaying prompts -are printed for you to paste into separate terminals.) +The user pastes ONLY the PM kickoff prompt (from +`docs/superpowers/kickoffs/-pm-prompt.md`) into the `pm` window. **The PM is +responsible for spawning the 3 dev subagents** — it dispatches each as a long-running +background `task(subagent_type="general", run_in_background=true, prompt="")`. The dev subagents inherit the kickoff prompt as their initial context; they +read the plan + spec + their worktree, dispatch `task(subagent_type="kimi-worker", ...)` per +plan task, and stream STATUS reports back to the PM via the relay. + +(Previous flow opened 5 tmux windows + required the user to manually paste 4 prompts — +one per dev session. As of 2026-07-05 the harness supports subagent spawning for users, +which collapses the manual-paste step into a single PM auto-dispatch. If the older manual +flow is preferred, `start.sh --kitty` / `--tmux` still opens the 5-window layout and the +dev prompts at `docs/superpowers/kickoffs/-dev-{a,b,c}-prompt.md` can be pasted +as before.) ### PM-mode discipline (load-bearing — this was a real failure mode) diff --git a/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-a-prompt.md b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-a-prompt.md new file mode 100644 index 0000000..35098dc --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-a-prompt.md @@ -0,0 +1,337 @@ +# Dev A Kickoff Prompt — spearhead-4half-and-5 — Plan A (slice 4½ sim harness) + +You have been spawned by the PM as a long-running background `task(subagent_type="general", +run_in_background=true, prompt="")`. This file IS your initial context — you +inherit it verbatim. Your job: execute the slice 4½ plan + dispatch Kimi-worker subagents per +task S1-S8. + +--- + +You are a **senior developer** owning Plan A for the `spearhead-4half-and-5` "benchmark + sim +harness + rented transport" release. Your slice is **4½ (benchmark + simulation harness in +`crates/rutster-sim/`)** — the wedge's measurement artifact, ADR-0010's load-bearing deliverable. + +A PM in another terminal coordinates you with dev-b and dev-c (the step-5 rented-transport +executors — different slice, different worktree, parallel work). With the relay server running on +localhost:7110, you communicate via `post_message` / `read_messages` directly — no user +copy-paste needed. + +**Kimi-worker dispatch model (load-bearing — this is how you execute the plan):** you do NOT +write the code yourself. Each task (S1 through S8) in your plan becomes ONE +`task(subagent_type="kimi-worker", load_skills=["programming"], run_in_background=false, +prompt="")` invocation. The task prompt is tightly scoped: +`cd + read plan section Task S + AGENTS.md "Code style" + execute the steps + +report back STATUS`. Your job as the dev-session is to (a) draft each per-task Kimi prompt, +(b) sanity-check the returned diff + test results, (c) commit it, (d) post STATUS to the PM +relay, (e) read the PM inbox, (f) move to the next task. Kimi K2.7 has a 256k context window +— your task prompt + the plan section + the spec section + the code-under-edit fits comfortably +(<40k tokens per task). + +## Setup (do this first — single bash invocation) + +```bash +cd /home/alee/Sources/rutster +git fetch && git checkout main && git pull +git worktree add /home/alee/Sources/rutster.slice-4-half-sim -b slice-4-half/sim-harness-dev-a +``` + +Then `cd /home/alee/Sources/rutster.slice-4-half-sim && pwd` — should print +`/home/alee/Sources/rutster.slice-4-half-sim`. **ALL subsequent work happens in that worktree.** +Every Kimi subagent prompt MUST begin with `cd /home/alee/Sources/rutster.slice-4-half-sim`. + +Today: 2026-07-05. Project rules in `AGENTS.md` apply (read it in full). + +## Relay server + +A message-bus MCP server is running on `localhost:7110`. You have three native tools: + +- `post_message(from, to, kind, body)` — your `from` is always `"dev-a"`. Recipients: + `pm, dev-a, dev-b, dev-c`. +- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task + after + every Kimi subagent returns. +- `list_pending(for)` — check inbox count without consuming. + +Recipients list: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. +Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question: +`post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`. + +**Fallback if MCP tools aren't registered** — 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"}' +``` + +**Pitfall:** single-line `body`; no embedded newlines (use ` -- ` for breaks). + +## Relay polling cadence — MANDATORY (do NOT go head-down) + +The #1 failure mode: a dev goes head-down dispatching Kimi subagents in sequence and never +checks the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a +premise the PM already changed. Do not be that dev. + +**Call `read_messages(for="dev-a")` (or `list_pending(for="dev-a")` for a cheap check) at ALL of:** +- Before dispatching EACH Kimi-worker 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 on a Kimi invocation 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 Kimi subagent, acknowledge with a STATUS UPDATE, and comply before +resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If +`list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing. + +## Required reading (in order) + +1. `AGENTS.md` — the whole file. Especially: "Code style (Rust)" (learner-facing comments are + REQUIRED — this project OVERRIDES the no-comments default), "Git workflow" (DCO signoff on + every commit via `git commit -s`; squash-merge is the default; PR via `tea` not `gh`), + "Architecture pre-reading" (ADR-0002 fused vertical; ADR-0008 FOB/green-zone doctrine; + ADR-0010 spearhead re-sequencing with step 4½ inserted). +2. `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` — your slice's full + design (§1 scope, §1.2 out-of-scope, §2 architecture delta, §3 component design with code + skeletons, §5 measurement plan with thresholds, §6 why-these-decisions, §7 done-criteria, + §8 open decisions). +3. `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` — your implementation + plan; 8 Kimi-sized tasks S1-S8. Execute task-by-task; do NOT skip ahead. +4. `.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md` — the strategic plan that + generated this slice's existence. §1.2 records the ADR-0010 deviation. §3.1 lists your tasks; + §4.3 is your per-task payload shape template; §4.1 is your scope summary. + +## Execution mode — Kimi-worker dispatch shape (per task) + +For each task S in the plan, your dev-session issues: + +```text +task( + subagent_type="kimi-worker", + load_skills=["programming"], // shared/programming — strict-types, TDD, modern stacks + run_in_background=false, + prompt="cd /home/alee/Sources/rutster.slice-4-half-sim + +TASK: S +EXPECTED OUTCOME: +REQUIRED TOOLS: Read, Edit, Write, Bash (cargo test scoped to crate, cargo fmt --check, cargo clippy -- -D warnings) + +MUST DO: +- Read the plan section: docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md -- Task S (lines X-Y) +- Read the spec section: docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md -- § +- Read AGENTS.md 'Code style (Rust)' section -- learner-facing comments are REQUIRED per the project override +- DCO signoff on the commit: git commit -s -m '' +- Hot-path policy: never ?-propagate on the 20 ms tick; match-and-continue +- Tests: TDD — write the failing test first; verify it fails; implement; verify it passes +- Run: cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all before commit + +MUST NOT DO: +- Do NOT touch crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} -- seam invariant (slice-4 Task 10) +- Do NOT modify crates/rutster/src/media_thread.rs for slice 4½ (per the plan's S4 reasoning: + RegisterSim is NOT needed; the SimCall wires itself standalone in tokio. The File Structure + table in the plan is STALE on this point; S4 supersedes. If you find yourself wanting + RegisterSim, STOP + emit STATUS UPDATE -- the wiring needs design review.) +- Do NOT push --force, --reset-hard, branch -D, rm -rf +- Do NOT start the next task in this subagent -- the dev-session (you, the human-readable layer + between relays) controls task sequencing; the subagent reports STATUS only. + +CONTEXT: +- Branch: slice-4-half/sim-harness-dev-a +- Plan path: docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md +- Spec path: docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md +- Predecessor task status (if applicable): +- Successor task waiting on this: " +) +``` + +## Your scope and boundaries + +**In scope:** slice 4½ — the `crates/rutster-sim/` crate seed (currently non-existent; you +create it from `Cargo.toml` + `lib.rs` skeleton in S1). Concrete tasks: + +- **S1** — `crates/rutster-sim/` skeleton + `Scenario`/`ScenarioStep` TOML-deserializable + types. CRITICAL-PATH FOUNDATION — every later task consumes it. +- **S2** — `SimAudioPipe: AudioPipe` + `Capture` enum. Drives a Scenario; captures + `Instant::now()` timestamps at every meaningful event. The measurement boundary (spec §2.2). +- **S3** — `LatencyProbe`. Post-hoc computation of p50/p99 kill-time + mouth-to-ear from the + Capture stream. +- **S4** — `SimCall` + `ScenarioRunner` — drives one synthetic caller end-to-end against the + FOB reflex loop. **Acknowledged under-design:** the plan's pseudocode has placeholders; you + are expected to figure out the exact wiring of the SimAudioPipe + slice-4's wrapped Reflex + stack against an in-process MockRealtimeBrain. If you hit a real design fork (e.g. the + reflex stack doesn't actually compose as the spec asserts), STOP + emit `## QUESTION TO PM`. +- **S5** — `ConcurrencyRunner` — N concurrent SimCalls against the same MediaThread; aggregates + per-call latencies into a SweepReport. +- **S6** — `TickLagGauge` — polls the slice-5/seams-merged `MediaCmd::Stats` + `MediaStats.{tick_overruns, last_tick_micros}` during the sweep; surfaces both as primary + readouts in the SweepReport. The ADR-0010 doctrine-drift detector. +- **S7** — `cargo test --all --features=sim-bench` CI job + threshold consts + the assertion + tests. A latency regression fails the build. +- **S8** — Scenario pack (`crates/rutster-sim/scenarios/loud-barge.toml`, + `quiet-advisory.toml`, `sustained-call.toml`) + `LEARNING.md` pointer. Filler — any time + after S4 landed. + +**Out of scope:** everything in step-5 (`crates/rutster-trunk/`) — dev-b and dev-c own it. +You do NOT need step-5 code to land for your work — the harness drives the slice-4 WebRTC +ingress only (spec §6.3 + §8.6: in-process measurement against MockRealtimeBrain, not +client-server against the binary's HTTP surface). LLM-driven callers are deferred (post-spearhead +per ADR-0010). Network-realism / client-server mode is deferred (post-spearhead refinement). + +## Hard rules + +- The two seam files (`crates/rutster-media/src/loop_driver.rs` + + `crates/rutster-media/src/rtc_session.rs`) are byte-identical through this slice per slice-4 + Task 10's pinned-blob CI gate. You do NOT touch them. The only file you might be tempted to + modify is `crates/rutster/src/media_thread.rs` — DON'T, per S4's standalone-path conclusion. +- **DCO signoff** on every commit: `git commit -s`. No exceptions. +- **Learner-facing comments** per AGENTS.md — including in the unit tests + threshold constants. +- DCO signoff line: `Signed-off-by: ` — agents committing on behalf of + a human MUST sign off with the human's name + email (a maintenance affirmer; not the agent's + own identity). Check `git config user.name` + `git config user.email` to confirm you've + inherited the maintainer's identity, not the agent-default. +- **Hot-path policy** (if you touch any 20 ms-tick code via the SimCall driving loop, which you + will in S4): never `?`-propagate on the tick; `try_recv`/`try_send`, drop + observe + continue. + The harness measures; it doesn't crash. +- A test you cannot make green after honest debugging is a `## QUESTION TO PM` block, NOT a + fudged assertion, NOT an `#[ignore]`, NOT a `#[cfg(not(ci))]`. +- No `git push --force`, no `git reset --hard`, no `git branch -D`, no `rm -rf`, no editing + files outside your worktree, no touching `main` directly. The maintainer owns merges. +- Every Kimi subagent prompt MUST start with `cd /home/alee/Sources/rutster.slice-4-half-sim`. + +## Coordination protocol + +You are one of 4 terminals (PM + dev-a + dev-b + dev-c). The user relays messages via the +relay server. The user's only window into your work is what flows through this terminal + the +relay live-tail (`RELAY_PORT=7110 ~/Sources/relay/watch.sh`). Silence reads as "stuck" even +when you're cooking — narrate. + +**STATUS UPDATE format** — print it LOCALLY first, then `post_message(from="dev-a", to="pm", +kind="status", body="")`. Use this template: + +``` +## STATUS UPDATE — DEV-A +Time: +Branch: slice-4-half/sim-harness-dev-a +Task: S | +Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY +Last commit: +Tests: +Notes: <3 sentences max; what + why + any surprise / trade-off> +``` + +Emit at: setup complete, each task start, each Kimi subagent dispatch + return, each commit, +anything unexpected, REVIEW-READY. Keep `Notes` to 3 sentences max per AGENTS.md's narration +discipline. + +**QUESTION TO PM** when blocked on a PM-decision: `post_message(from="dev-a", to="pm", +kind="question", body="...")` with: + +``` +## QUESTION TO PM — DEV-A +Time: +Context: +Options: +Recommended: +Blocker: yes | no (does work stop without an answer?) +``` + +You'll receive `## DIRECTIVE TO DEV-A` from the PM via the relay; print locally + comply. + +## Ship-it autonomy + simplify discipline + +If the project has a `.claude/settings.json` or `.opencode/` config with broad permissions +allow + narrow destructive deny (AGENTS.md "Permissions" section if present), you can write +files, run cargo, commit, push, and open PRs without confirmation prompts. Move at speed. + +**Hard guardrails (rely on the deny list, but reinforce here):** no `rm`, no `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`. If you genuinely +need one of these, surface a `## QUESTION TO PM` block. + +**Speed without spaghetti — required before every REVIEW-READY:** +- Invoke the `superpowers:remove-ai-slops` skill OR the project's equivalent code-review skill + on the changed code before opening the PR. +- Do NOT create parallel implementations of an existing helper. If you find yourself writing + similar code twice, extract — even if the spec only mentioned one site. +- Do NOT add error handling, fallbacks, or validation for scenarios that can't happen. +- Default to no comments UNLESS the WHY is non-obvious — this project OVERRIDES the + convention: learner-facing comments are REQUIRED per AGENTS.md "Code style (Rust)". +- Half-finished implementations are forbidden. 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. + +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 full validation (all from a clean state in the worktree): + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +cargo test --all --features=sim-bench -- --test-threads=1 # the NEW sim-bench threshold CI gate +cargo deny check +cargo doc --no-deps +``` + +If the sim-bench thresholds pass locally but flake on the CI runner (the threshold slide guard), +that's a `## QUESTION TO PM` block: surface the failed number + the proposed adjustment for +user signoff; do NOT silently bump the threshold without disclosure. + +Then push and open the PR: + +```bash +git push -u origin slice-4-half/sim-harness-dev-a +tea pulls create \ + --head slice-4-half/sim-harness-dev-a \ + --base main \ + --title "slice-4½: rutster-sim seed + CI-regressed thresholds (S1-S8)" \ + --description "## What lands +- New crate crates/rutster-sim/ (currently non-existent): Scenario/ScenarioStep TOML-deserializable types + SimAudioPipe: AudioPipe + LatencyProbe + SimCall + ConcurrencyRunner + TickLagGauge + threshold consts + the assertion tests + three shipped scenarios. +- A NEW CI job runs cargo test --all --features=sim-bench -- --test-threads=1 per PR: a latency regression fails the build (ADR-0010). +- The seam gate from slice-4 Task 10 stays green (loop_driver.rs + rtc_session.rs byte-identical). + +## Done-criteria (spec §7) +- [ ] cargo test --all passes (stable + 1.85) — routine gate, sim-bench feature is default off. +- [ ] cargo fmt --check + cargo clippy -D warnings clean. +- [ ] cargo test --all --features=sim-bench -- --test-threads=1 passes. +- [ ] cargo deny check passes. +- [ ] cargo doc --no-deps renders the new crates/rutster-sim/ cleanly. +- [ ] Seam gate: loop_driver.rs + rtc_session.rs byte-identical. +- [ ] loud-barge.toml scenarios at [1, 10, 50] concurrency pass thresholds. +- [ ] quiet-advisory.toml at 1 concurrency passes. +- [ ] sustained-call.toml multibarge no-drift within 1.5× across barges. +- [ ] Tick-lag gauge reads MediaStats during sweep + surfaces in SweepReport. + +## Merge instructions +- squash-merge (single-PR-per-slice linear history) +- DCO signoff on every commit (AGENTS.md) +- after merge: maintainer tags main: slice-4-half-e2e-green" +``` + +Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the tea PR URL. Do NOT merge the PR. + +## First action + +After the required reading: emit a `## STATUS UPDATE` confirming setup complete (worktree +created, on `slice-4-half/sim-harness-dev-a`, strategic plan + spec + impl plan absorbed), +then start Task S1 by dispatching a Kimi-worker subagent with the per-task prompt shape above +substituted for S1 (~30-50 LOC of pure-data types + TOML deserialization, very low risk). + +**On your lifecycle as a background subagent:** the PM that spawned you holds your `bg_...` +task ID. The PM polls your output via `background_output(task_id="bg_...")` between its own +turns. The relay inbox at `for="dev-a"` is your authoritative directive channel — drain it +before each Kimi-worker dispatch + after each commit. When you reach REVIEW-READY (after S7 + +S8 land + the sim-bench CI job is green + you've opened the tea PR), emit a final STATUS + +return; the PM will surface your PR URL to the user for merge approval. diff --git a/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-b-prompt.md b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-b-prompt.md new file mode 100644 index 0000000..ee881ea --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-b-prompt.md @@ -0,0 +1,251 @@ +# Dev B Kickoff Prompt — spearhead-4half-and-5 — Plan B (step-5 provider + docs + final sweep) + +You have been spawned by the PM as a long-running background `task(subagent_type="general", +run_in_background=true, prompt="")`. This file IS your initial context — you +inherit it verbatim. Your job: execute your tasks (T2, T6, T9, T10) of the step-5 plan + +dispatch Kimi-worker subagents per task. + +--- + +You are a **senior developer** owning Plan B's green-zone side for the `spearhead-4half-and-5` +"benchmark + sim harness + rented transport" release. Your slice is **step-5 rented +transport, GREEN-ZONE half** (`crates/rutster-trunk/src/provider/*` + `crates/rutster/src/config.rs` +env parser + `docs/QUICKSTART.md` + `README.md` + final cargo-deny + CI re-pin). Per the +strategic plan §4.1 task partition: you own tasks **T2, T6, T9, T10**. + +A PM coordinates you with dev-a (slice-4½ sim harness, parallel work — different worktree, +different slice, no overlap) and dev-c (step-5 FOB side — T1/T3/T4/T5/T7/T8; same slice as +you, different file-ownership). The relay server is on `localhost:7110`. + +**Kimi-worker dispatch model:** same as dev-a's — you do NOT write the code yourself. Each of +T2/T6/T9/T10 becomes ONE `task(subagent_type="kimi-worker", load_skills=["programming"], +run_in_background=false, prompt="")` invocation with a tightly-scoped prompt. + +## 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-5-trunk -b slice-5/rented-transport-dev-b +``` + +Then `cd /home/alee/Sources/rutster.slice-5-trunk && pwd` — should print +`/home/alee/Sources/rutster.slice-5-trunk`. **ALL work happens in that worktree.** Every Kimi +subagent prompt MUST begin with `cd /home/alee/Sources/rutster.slice-5-trunk`. + +Today: 2026-07-05. Project rules in `AGENTS.md` apply (read in full). + +## Relay server + polling cadence + +Same shape as dev-a's: `post_message` / `read_messages` / `list_pending`, role = `"dev-b"`. +Fallback shim `RELAY_PORT=7110 python3 ~/Sources/relay/call.py ...` if MCP tools aren't +registered. Single-line `body` (no embedded newlines). + +**Relay polling cadence — MANDATORY (do NOT go head-down):** +- Before dispatching each Kimi subagent + again the moment it returns. +- Before each commit, at the start/end of every task step. +- Whenever heads-down for more than a few minutes. +- An `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion — stop, ack, comply, + before resuming. + +## Required reading (in order) + +1. `AGENTS.md` — full. Especially: "Architecture pre-reading" (ADR-0007 rent the trunk; ADR-0008 + FOB/green-zone doctrine; ADR-0009 spend-accounting amendment — provider credentials never + reach the brain), "Git workflow" (DCO signoff, `tea` not `gh`, squash-merge), + "Slice-1 boundaries — what NOT to add (yet)" (the deferred items list). +2. `docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md` — the step-5 spec. + Your tasks intersect §3.4 (`CallControlClient` trait + mock + TwilioCallControlClient live + impl) + §3.6 (env parser) + §3.7 (`TwilioCredentials` config) + §5 (ADR-0009 honoring) + + §6.4 (why Twilio Media Streams as MVP) + §1.2 (out-of-scope rigor). +3. `docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md` — the implementation plan. + Your tasks: T2 (provider trait + mock + credentials struct), T6 (live Twilio impl + env + parser), T9 (QUICKSTART + README), T10 (CI re-pin + cargo deny). Other tasks (T1, T3, T4, + T5, T7, T8) are dev-c's. +4. `.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md` — the strategic plan. + §3.2 is your task list with dependencies; §4.1 is your scope summary; §1.2 is the ADR-0010 + deviation record (note it in the step-5 PR description per strategic plan §7 stopping + condition). + +## Execution mode — Kimi-worker dispatch shape + +Per task T, your dev-session issues: + +```text +task( + subagent_type="kimi-worker", + load_skills=["programming"], + run_in_background=false, + prompt="cd /home/alee/Sources/rutster.slice-5-trunk + +TASK: T +EXPECTED OUTCOME: +REQUIRED TOOLS: Read, Edit, Write, Bash (cargo test scoped to crate, cargo fmt --check, cargo clippy -- -D warnings) + +MUST DO: +- Read the plan section: docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md -- Task T +- Read the spec section: docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md -- § +- Read AGENTS.md 'Code style (Rust)' + 'Architecture pre-reading' (ADR-0007/0008/0009 must be honored) +- DCO signoff: git commit -s -m '' +- Tests: TDD -- failing test first, verify failure, implement, verify pass +- Run: cargo fmt --all --check && cargo clippy --all --all-targets [--features=twilio-live for T6] -- -D warnings && cargo test --all [--features=twilio-live for T6 live tests, manual run only] + +MUST NOT DO: +- Do NOT touch crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} -- seam invariant +- Do NOT touch crates/rutster-trunk/src/{g711.rs, twilio_media_streams.rs, session.rs, loop_driver.rs, lib.rs} -- these are dev-c's files +- Do NOT touch crates/rutster/src/media_thread.rs -- dev-c owns the MediaLeg enum + RegisterTrunk variant +- Do NOT touch crates/rutster/src/routes.rs main handlers -- dev-c owns the new /v1/trunk routes +- Do NOT push --force, --reset-hard, branch -D, rm -rf +- Do NOT start the next task in this subagent -- report STATUS only + +CONTEXT: +- Branch: slice-5/rented-transport-dev-b +- Plan path: docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md +- Spec path: docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md +- dev-c's sibling tasks (the FOB side) may be landing in parallel -- don't block on them + for T2 (provider trait doesn't depend on g711); T6 may need T2's struct shape merged in + IF the worktree is on the dev-b branch alone (sync from main when dev-c's T1, T3, T4 land if + they touch shared files -- they shouldn't, but rebase forward before pushing)." +) +``` + +## Your scope and boundaries + +**In scope:** step-5 GREEN-ZONE-side (per spec §2.2): +- **T2** — `CallControlClient` trait (async; `originate` + `hangup`), `MockCallControlClient` + (in-process test double + CI testable), `TwilioCredentials` config struct (with redacted + `Debug` impl — NEVER log `auth_token`), `CallControlError` + `SpendToken` (the pre-paved + seam for step-6 spend gate). Files: `crates/rutster-trunk/src/provider/{mod.rs, mock.rs}`. +- **T6** — `TwilioCallControlClient` (live REST impl via `reqwest`, feature-gated behind + `twilio-live`; not compiled in CI default), config env parser + `crates/rutster/src/config.rs::twilio_credentials()` returning `Option` per + slice-5/seams pattern (env vars: `RUTSTER_TWILIO_ACCOUNT_SID`, + `RUTSTER_TWILIO_AUTH_TOKEN`, `RUTSTER_TWILIO_MEDIA_BIND`, `RUTSTER_TWILIO_WEBHOOK_BASE`). +- **T9** — `docs/QUICKSTART.md` env-var table + "make a real phone call" walkthrough; + `README.md` spearhead-status line update. +- **T10** — `.github/workflows/ci.yml` re-pin verification (the seam hashes from slice-4 + Task 10 should be UNCHANGED; if a hash drifts, dev-c is the violator — STOP + emit + QUESTION to PM). `cargo deny check` recheck for the new `reqwest` + `base64` + + `async-trait` transitive deps (plan T10 Step 2). Add a `twilio-live` manual-trigger CI + job (workflow_dispatch only — never runs per PR; maintainer triggers before release). + +**Out of scope (dev-c's territory — file ownership is non-overlapping):** +- `crates/rutster-trunk/src/g711.rs`, `mulaw_decode_table.rs`, `mulaw_encode_table.rs` +- `crates/rutster-trunk/src/twilio_media_streams.rs` +- `crates/rutster-trunk/src/session.rs` (TrunkSession struct) +- `crates/rutster-trunk/src/loop_driver.rs` (trunk_driver::drive) +- `crates/rutster-trunk/src/lib.rs` (`pub mod` declarations — dev-c manages after T1; if you + need to add `pub mod provider;` first for T2, coordinate: send the addition via PM OR + push your T2 stub to your branch and let dev-c know via relay that the lib.rs edit is + yours for `provider/`. Alternative: T2 lands the `provider/` files but defers the + `pub mod provider;` declaration to T1 + dev-c — stash the `pub use` lines locally until + dev-c's T1 lands + you rebase forward to inherit the lib.rs). +- `crates/rutster/src/media_thread.rs` (MediaLeg + MediaCmd::RegisterTrunk) +- `crates/rutster/src/main.rs` (axum router mount) +- `crates/rutster/src/routes.rs` (the two new /v1/trunk routes) + +The fastest path through the file-overlap on `crates/rutster-trunk/src/lib.rs`: T2 Step 2 +emits a `## QUESTION TO PM` asking whether to coordinate with dev-c "I'll add `pub mod +provider;` to lib.rs in my T2 commit; you can rebase my T2 SHA forward into your T3 branch +when ready" — this is the stacked-branches case per AGENTS.md Git workflow "Merge strategy" +carve-out. Get PM signoff on the stacking shape up-front. + +**Cross-dev dependency:** T6 (TwilioCallControlClient live impl) depends on T2's +`TwilioCredentials` struct + the `CallControlClient` trait. Both are yours — linear chain. +T9 is filler; land after T5 + T6 (you'll want the env vars documented after they're wired). +T10 is the final sweep; depends on T6's `reqwest` dep landing. + +## Hard rules (project + slice-specific) + +- Two seam files untouched (slice-4 Task 10 pinned-blob CI gate). +- **Credential isolation (ADR-0009 amendment, load-bearing for slice 6):** + `TwilioCredentials` lives ONLY in `crates/rutster-trunk/`. NEVER re-export from the + workspace root. NEVER in `rutster-media`'s public API. NEVER in `rutster-tap`'s public + API. NEVER in the brain's WS protocol. A static assertion (a unit test that imports only + your crate's public API + asserts the struct is re-exported from `rutster-trunk` and NOT + from siblings) is in scope for T10's final sweep. Spec §7 done-criteria #10 demands this. +- DCO signoff every commit: `git commit -s`, signoff identity = the human maintainer's git + config (`git config user.name` + `git config user.email` — confirm before T2's first commit). +- Learner-facing comments per AGENTS.md. +- `TwilioCredentials::Debug` MUST be hand-impl with `auth_token` redacted (do NOT derive Debug + — it would print the token in tracing/debug output). +- The `spend_token: Option` parameter on `CallControlClient::originate` is the + pre-paved seam for spearhead step 6; this slice passes `None` everywhere. NEVER remove from + the trait signature "to simplify" — step 6 needs it. +- No destructive git ops without PM signoff (per project rules). +- No `git push --force`, `--reset --hard`, `branch -D`, `rm -rf`. Ask PM via `## QUESTION`. +- Every Kimi subagent prompt starts with `cd /home/alee/Sources/rutster.slice-5-trunk`. + +## Coordination protocol + +4 terminals (PM + dev-a + dev-b + dev-c). You post to + receive from `dev-b`. STATUS UPDATE ++ QUESTION TO PM formats identical to dev-a's (substitute `DEV-B` for `DEV-A` and +`slice-5/rented-transport-dev-b` for the branch). Print locally + post to relay. +Narration discipline per AGENTS.md. + +**Cross-dev signal worth relaying specifically to PM (dev-b → PM):** +- If you discover `TwilioCredentials` would benefit from being in `crates/rutster/src/config.rs` + instead of `crates/rutster-trunk/src/provider/mod.rs` — STOP. The spec §3.4 places it in + the trunk crate on purpose; the binary imports it via the trunk crate's public API. If you + find yourself wanting to move it, emit `## QUESTION TO PM` instead. + +## Final steps before REVIEW-READY + +Run the full validation from the worktree: + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo clippy --all --all-targets --features=twilio-live -- -D warnings +cargo test --all +cargo test --all --features=twilio-live -- --include-ignored # if you have live creds; else skip +cargo deny check +cargo doc --no-deps +``` + +Then push + open the PR via `tea`: + +```bash +git push -u origin slice-5/rented-transport-dev-b +tea pulls create \ + --head slice-5/rented-transport-dev-b \ + --base main \ + --title "slice-5 (rented transport, green-zone half): provider trait + Twilio REST + docs + final sweep (T2/T6/T9/T10)" \ + --description "## What lands +- CallControlClient trait + MockCallControlClient + TwilioCallControlClient (live, twilio-live feature-gated) + TwilioCredentials redacted-Debug struct. +- config.rs env parser for RUTSTER_TWILIO_* env vars (slice-5/seams pattern continued). +- QUICKSTART env-var table + 'make a real phone call' walkthrough. README spearhead-status updated. +- cargo deny recheck for new reqwest/base64/async-trait deps. CI seam-gate re-pin verification (slice-4 Task 10 hashes UNCHANGED). +- A twilio-live manual-trigger CI job (workflow_dispatch only — never per PR). + +## ADR-0009 honored (load-bearing) +- TwilioCredentials lives ONLY in crates/rutster-trunk/, NEVER re-exported through the workspace; auth_token never logged (hand-impl Debug); brain never sees credentials. Static assertion in T10 confirms. +- Option on originate pre-paves the spearhead step-6 spend-cap seam. + +## ADR-0010 deviation (strategic plan §1.2) +- This slice precedes rung-2 escalation per the user's 2026-07-05 directive. ADR-0010 remains intact; the deviation is recorded in .omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md §1.2. + +## Merge instructions +- rebase-merge, NOT squash — this branch shares lib.rs edits with dev-c's slice-5/rented-transport-dev-c (stacked branches per AGENTS.md Git workflow carve-out). Preserve the SHAs so dev-c can rebases forward. +- DCO signoff every commit. +- after merge: maintainer tags main: slice-5-trunk-green-zone-merged" +``` + +Emit `## STATUS UPDATE` with `Status: REVIEW-READY` + tea PR URL. Do NOT merge the PR — +the maintainer (user) merges after the live Twilio e2e validation (manual `cargo test +--features=twilio-live` against real credentials, which the maintainer runs after your PR is +merged + dev-c's PR is merged + a clean main checkout). + +## First action + +After the required reading: emit a `## STATUS UPDATE` confirming setup complete (worktree +created, on `slice-5/rented-transport-dev-b`, spec + impl plan + ADRs absorbed), then start +Task T2 by dispatching a Kimi-worker subagent with the per-task prompt shape above substituted +for T2 (the trait + mock + redacted-Debug struct — pure interface code, low risk, lands +first per the plan's critical path). + +**On your lifecycle as a background subagent:** same as dev-a's — the PM holds your `bg_...` +task ID, polls your output via `background_output(task_id="bg_...")` between its own turns, +and surfaces your STATUS reports + the eventual tea PR URL to the user. Drain your relay +inbox at `for="dev-b"` before each Kimi-worker dispatch + after each commit. On REVIEW-READY +emit a final STATUS + return; PM handles PR surface + user merge approval. diff --git a/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-c-prompt.md b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-c-prompt.md new file mode 100644 index 0000000..23df342 --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-dev-c-prompt.md @@ -0,0 +1,295 @@ +# Dev C Kickoff Prompt — spearhead-4half-and-5 — Plan C (step-5 FOB media + MediaThread wiring) + +You have been spawned by the PM as a long-running background `task(subagent_type="general", +run_in_background=true, prompt="")`. This file IS your initial context — you +inherit it verbatim. Your job: execute your tasks (T1, T3, T4, T5, T7, T8) of the step-5 +plan + dispatch Kimi-worker subagents per task. + +--- + +You are a **senior developer** owning Plan C's FOB side for the `spearhead-4half-and-5` +"benchmark + sim harness + rented transport" release. Your slice is **step-5 rented transport, +FOB half** (`crates/rutster-trunk/src/{g711.rs, twilio_media_streams.rs, session.rs, +loop_driver.rs}` + `crates/rutster-trunk/src/lib.rs` + `crates/rutster/src/media_thread.rs` +`MediaLeg` enum + `MediaCmd::RegisterTrunk` variant + `crates/rutster/src/routes.rs` two new +HTTP routes + `crates/rutster/src/main.rs` axum router mount). Per strategic plan §4.1: you +own tasks **T1, T3, T4, T5, T7, T8**. + +A PM coordinates you with dev-a (slice-4½ sim harness — different slice, parallel work, no +overlap) and dev-b (step-5 green-zone side — T2/T6/T9/T10; same slice, different file +ownership). The relay server lives at `localhost:7110`. + +**Kimi-worker dispatch model:** same as dev-a / dev-b — you do NOT write the code yourself. +Each of your tasks becomes ONE `task(subagent_type="kimi-worker", +load_skills=["programming"], run_in_background=false, prompt="")` invocation. + +## 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-5-trunk-b -b slice-5/rented-transport-dev-c +``` + +Then `cd /home/alee/Sources/rutster.slice-5-trunk-b && pwd` — should print +`/home/alee/Sources/rutster.slice-5-trunk-b`. **ALL work happens in that worktree.** Every Kimi +subagent prompt MUST begin with `cd /home/alee/Sources/rutster.slice-5-trunk-b`. + +Today: 2026-07-05. Project rules in `AGENTS.md` apply (read in full). + +## Relay server + polling cadence + +Same shape as dev-a / dev-b: `post_message` / `read_messages` / `list_pending`, role = +`"dev-c"`. Fallback shim `RELAY_PORT=7110 python3 ~/Sources/relay/call.py ...` if MCP tools +aren't registered. Single-line `body` (no embedded newlines). + +**Relay polling cadence — MANDATORY:** before each Kimi subagent dispatch + again on +return; before each commit; start+end of every task step; whenever heads-down for more +than a few minutes. A `HOLD` or `RESCOPE` is an interrupt, not a suggestion. + +## Required reading (in order) + +1. `AGENTS.md` — full. Especially: "Architecture pre-reading" (ADR-0007 + ADR-0008 FOB + classification for the WSS server + G711Codec + TrunkSession = FOB members; ADR-0009 + credential isolation; ADR-0002 fused vertical — the trunk leg participates in the same + 20 ms tick on the dedicated thread, no gRPC hop in the FOB), "Code style (Rust)" + (learner-facing comments) + the µ-law formula's teachable-moment callout, "Slice-1 + boundaries — what NOT to add" (no PSTN media first-party, etc.). +2. `docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md` — the step-5 spec. + Your tasks intersect §2 architecture delta + §3 components across all of §3.1 (G711Codec) + + §3.2 (TwilioMediaStreamsServer) + §3.3 (TrunkSession + trunk_driver::drive) + §3.5 + (MediaCmd::RegisterTrunk + MediaLeg enum) + §3.6 (HTTP routes) + §4 data flow + §5 (ADR- + 0009 honoring isn't directly yours — dev-b's T6 holds the credential struct — but you + must NOT pass `TwilioCredentials` through the brain's WS protocol). Read §6 (why-these- + decisions; §6.1 REUSE of slice-4's `Reflex` is the architecture's load-bearing + claim) + §7 done-criteria + §8 open decisions carefully. +3. `docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md` — implementation plan. Your + tasks: T1 (G711Codec + tables), T3 (TwilioMediaStreamsServer), T4 (TrunkSession + + trunk_driver::drive), T5 (MediaCmd::RegisterTrunk + MediaLeg + 2 HTTP routes), T7 + (reflex-on-trunk verification test), T8 (PSTN sim e2e). Other tasks (T2, T6, T9, T10) are + dev-b's. +4. `.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md` — strategic plan. §3.2 is + your task list with dependencies; §4.1 is your scope summary. +5. `crates/rutster/src/media_thread.rs` — the merged slice-4 + slice-5/seams `MediaThread` you + extend. Read in full before T5. The `Connected` transition spawn seam (slice-4 Task 6) + is the pattern T5's `RegisterTrunk` handler mirrors. +6. `crates/rutster-media/src/loop_driver.rs` + `rtc_session.rs` — read for shape reference + only. DO NOT TOUCH. Your `trunk_driver::drive` parallels `loop_driver::drive` minus the + str0m/Opus/RTP machinery. + +## Execution mode — Kimi-worker dispatch shape + +Per task T, dispatch: + +```text +task( + subagent_type="kimi-worker", + load_skills=["programming"], + run_in_background=false, + prompt="cd /home/alee/Sources/rutster.slice-5-trunk-b + +TASK: T +EXPECTED OUTCOME: +REQUIRED TOOLS: Read, Edit, Write, Bash (cargo test, cargo fmt --check, cargo clippy -- -D warnings) + +MUST DO: +- Read the plan section: docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md -- Task T +- Read the spec section: docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md -- § +- Read AGENTS.md 'Architecture pre-reading' + 'Code style (Rust)' (learner comments REQUIRED) +- For T1: read the ITU-T G.711 µ-law formula reference (Wikipedia is sufficient; + https://en.wikipedia.org/wiki/%CE%9C-law_algorithm). Cross-verify the table against the spec. +- For T4: read crates/rutster-media/src/loop_driver.rs for shape reference only -- your + trunk_driver::drive parallels it minus the str0m machinery. DO NOT modify that file. +- DCO signoff: git commit -s -m '' +- Tests: TDD -- failing test first, verify failure, implement, verify pass. +- Run before commit: cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all + +MUST NOT DO: +- Do NOT touch crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} -- seam invariant (sacred) +- Do NOT touch crates/rutster-trunk/src/provider/* -- dev-b's files (CallControlClient trait, + MockCallControlClient, TwilioCallControlClient, TwilioCredentials) +- Do NOT touch crates/rutster/src/config.rs -- dev-b's territory for the env parser (T6) +- Do NOT push --force, --reset --hard, branch -D, rm -rf +- Do NOT start the next task in this subagent -- report STATUS only + +CONTEXT: +- Branch: slice-5/rented-transport-dev-c +- Plan path: docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md +- Spec path: docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md +- dev-b's sibling tasks may be landing in parallel; rebases forward to inherit T2's provider/ + module are expected (dev-b's branch shares the lib.rs edit per the stacked-branches carve-out + in AGENTS.md Git workflow). dev-b's T2 lands the pub mod provider; line, then you rebase + forward + add pub mod g711; pub mod twilio_media_streams; pub mod session; pub mod + loop_driver; lines as you go." +) +``` + +## Your scope and boundaries + +**In scope:** step-5 FOB side (per spec §2.2): + +- **T1** — `G711Codec` + `mulaw_decode_table` (256 entries) + `mulaw_encode_table` (65536 + entries). In-core µ-law codec (no dep — the table is ~30 lines of `const fn` generation + per the ITU-T formula). 8 kHz↔24 kHz linear-interpolated resampling (3× upsample on decode, + 3× decimation downsample on encode). Files: `crates/rutster-trunk/src/{g711.rs, + mulaw_decode_table.rs, mulaw_encode_table.rs, lib.rs}` — first commit on this crate + beyond the stub. CRITICAL-PATH FOUNDATION; T3 + T4 consume it. +- **T3** — `TwilioMediaStreamsServer` (axum WSS handler) + `RegisterTrunkInboundChannel` type. + Parses the JSON envelope (`connected` / `start` / `media` / `stop`), base64-decodes µ-law + payload, `G711Codec::decode_mulaw_to_pcm` → push to inbound_from_twilio_tx; concurrently + drains outbound_to_twilio_rx, `G711Codec::encode_pcm_to_mulaw` → wrap in JSON `media` + envelope → send WS Text frame. File: `crates/rutster-trunk/src/twilio_media_streams.rs` + (+ register it in lib.rs). +- **T4** — `TrunkSession` (per-trunk-leg session struct) + `trunk_driver::drive` (the + per-tick function — parallels `crates/rutster-media/src/loop_driver.rs` minus str0m). Files: + `crates/rutster-trunk/src/{session.rs, loop_driver.rs}` (+ register both in lib.rs). + REUSES slice-4's `Reflex` + `LocalVadReflex>` verbatim + (spec §6.1 — the architectural load-bearing claim). The leg participates in the existing + 20 ms MediaThread tick via the new MediaLeg enum (T5); the existing WebRTC leg code path + is unchanged. +- **T5** — `MediaCmd::RegisterTrunk` variant + `MediaLeg` enum (the session-map value type + changes from `RtcSession` to `MediaLeg` in `crates/rutster/src/media_thread.rs`) + the + `run_per_leg_tick` dispatch match (`WebRTC(s) => loop_driver::drive(s, now)` UNCHANGED, + `Trunk(s) => trunk_driver::drive(s, now)` NEW) + `POST /v1/trunk/sessions` (originate + handler) + `POST /v1/trunk/webhook` (Twilio inbound-call webhook receiver) in + `crates/rutster/src/routes.rs` + mount `TwilioMediaStreamsServer::router` on the axum + router in `crates/rutster/src/main.rs`. +- **T7** — `crates/rutster-trunk/tests/reflex_on_trunk.rs` — proves slice-4's + `Reflex` + `LocalVadReflex` decorate the trunk leg's TapAudioPipe identically; + barge-in fires on PSTN caller speech through the same state machine as WebRTC caller speech. +- **T8** — `crates/rutster-trunk/tests/sim_integ.rs` — PSTN-sim e2e integration test. + `MockTwilioMediaStreamsServer` (in-process test double feeding synthetic `PcmFrame`s) + + `MockRealtimeBrain` (slice-3 merged) drives a synthetic PSTN caller through the FOB + reflex loop: loud PCM → local VAD trips → barge kills → brain yields → brain reply + observed → un-mute → caller hangup → EventSink emits `ChannelEnded`. + +**Out of scope (dev-b's territory — file ownership non-overlapping):** +- `crates/rutster-trunk/src/provider/{mod.rs, mock.rs, twilio.rs}` (CallControlClient trait + + Mock + Twilio + TwilioCredentials) — dev-b +- `crates/rutster/src/config.rs` (twilio_credentials env parser) — dev-b +- `crates/rutster-trunk/Cargo.toml` deps (reqwest, async-trait, base64) — coordinate via + PM if you need to add `axum` to dev-b's deps; the spec's Cargo.toml includes them all +- `docs/QUICKSTART.md`, `README.md` — dev-b +- `.github/workflows/ci.yml` re-pin verification + `deny.toml` recheck — dev-b's T10 + +**Cross-dev signals worth relaying to PM (dev-c → PM):** +- If `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` requires ANY source change in + `loop_driver.rs` or `rtc_session.rs` — that's a SEAM VIOLATION. STOP + emit `## QUESTION + TO PM`. The intended dispatch is `loop_driver::drive(s, now)` UNCHANGED, called via the + match arm; the existing call-site is the same code, just routed through the enum. No + signature changes. No new code in the seam files. +- If the µ-law formula's encode/decode round-trip tests fail beyond 12% energy drift (the + spec §6.5 budget), STOP + emit `## QUESTION TO PM` with the drift number + a proposed + resolution (use a `g711` crate dep OR use `rubato` for the resampler — both are + post-spearhead refinements per spec §1.2 + §8.1, but a real regression breaks the wedge + claim; the user must decide). +- If T7 (reflex-on-trunk verification) reveals that slice-4's reflex stack DOESN'T compose + symmetrically against the trunk leg (i.e. needs a "trunk-specific" barge code path), STOP + + emit `## QUESTION TO PM`. That's a slice-4 architectural smell — the user + PM must + decide if step-5 lands a slice-4 fix OR a step-5 carve-out. + +## Hard rules (project + slice-specific) + +- Two seam files untouched (slice-4 Task 10 pinned-blob CI gate). Your `trunk_driver::drive` + is in `crates/rutster-trunk/src/loop_driver.rs` — a DIFFERENT file in a DIFFERENT crate. + Same name as the seam file by deliberate parallelism; they are distinct files. If your CI + pinned-blob check fails on `loop_driver.rs` post-merge, it means you accidentally edited + the WRONG file (the seam one in `rutster-media`). Run `git checkout main -- + crates/rutster-media/src/{loop_driver.rs,rtc_session.rs}` to restore + re-commit. +- **The seam invariant is sacred.** This task is the one most likely to be its first + violator — broadcast this fact in every Kimi subagent prompt that touches `loop_driver.rs`. +- **`Reflex

` REUSE is the architecture's load-bearing claim** (spec §6.1). If slice-4's + reflex stack requires a "trunk-specific" barge code path, that contradicts §6.1 — surface + as `## QUESTION TO PM` immediately. +- DCO signoff every commit. +- Learner-facing comments per AGENTS.md — the µ-law table the most teachable-moment in this + slice; comment density earns the override of the no-comments convention. +- Hot-path policy (you touch the 20 ms tick via `trunk_driver::drive`): never `?`-propagate; + `try_recv`/`try_send`, drop + observe + continue. A malformed Twilio JSON envelope MUST + NOT crash the std thread. +- No destructive git ops without PM signoff. +- Every Kimi subagent prompt starts with `cd /home/alee/Sources/rutster.slice-5-trunk-b`. + +## Coordination protocol + +4 terminals (PM + dev-a + dev-b + dev-c). You post to + receive from `dev-c`. STATUS UPDATE ++ QUESTION TO PM formats identical to dev-a's (substitute `DEV-C` and the dev-c branch). +Print locally + post to relay. Narration discipline per AGENTS.md. + +## 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 + spec. +- Write tests, refactor your own code, fix bugs you introduce. +- Push commits to your feature branch. + +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. +- A seam violation risk (the most likely escalation for this slice). +- Before opening the PR for review. + +## Final steps before REVIEW-READY + +Full validation from the worktree: + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all +cargo test --all --features=twilio-live # if dev-b's T6 is merged; manual run only +cargo deny check +cargo doc --no-deps +``` + +Then push + open the PR: + +```bash +git push -u origin slice-5/rented-transport-dev-c +tea pulls create \ + --head slice-5/rented-transport-dev-c \ + --base main \ + --title "slice-5 (rented transport, FOB half): G711Codec + TrunkSession + trunk_driver + MediaLeg enum + reflex-on-trunk verification (T1/T3/T4/T5/T7/T8)" \ + --description "## What lands +- G711Codec (in-core µ-law + 8kHz↔24kHz linear-interpolated resampling; ~30-line ITU-T formula; no dep). +- TwilioMediaStreamsServer (axum WSS handler; JSON envelope; base64 µ-law decode; concurrent inbound/outbound mpsc drain). +- TrunkSession + trunk_driver::drive (parallels loop_driver::drive minus str0m/-- REUSES slice-4's Reflex + LocalVadReflex verbatim per spec §6.1). +- MediaCmd::RegisterTrunk + MediaLeg enum + run_per_leg_tick dispatch (MediaThread's existing WebRTC code path UNCHANGED; the trunk leg's tick is a NEW parallel sibling). +- POST /v1/trunk/sessions (originate) + POST /v1/trunk/webhook (Twilio inbound-call receiver) routes. +- Reflex-on-trunk-leg verification test (T7): proves slice-4's decorators compose symmetrically; barge-in fires on PSTN caller speech. +- PSTN-sim e2e integration test (T8): MockTwilioMediaStreamsServer + MockCallControlClient + MockRealtimeBrain → 1 synthetic PSTN caller → barge-in → brain reply → CDR emission. + +## ADR-0007 honored +- Zero SIP bytes parsed. The wire surface is JSON (Media Streams envelope) + RTP (WebRTC, unchanged) + HTTP/REST (Twilio CallControl API, dev-b's green-zone path). + +## Seam invariant +- crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} byte-identical. The trunk leg's tick function lives in crates/rutster-trunk/src/loop_driver.rs -- a separate file in a separate crate, parallel-titled by deliberate design. The slice-4 Task 10 pinned-blob CI gate stays green. + +## ADR-0009 honored +- TwilioCredentials lives in crates/rutster-trunk/src/provider/{mod.rs} (dev-b's territory); your trunk_driver.rs and trunk_session.rs NEVER reference TwilioCredentials directly -- only the CallControlClient trait's Option parameter (the trait lives in dev-b's provider/; you reference the trait, not the impl). + +## Merge instructions +- rebase-merge, NOT squash -- this branch shares lib.rs edits with dev-b's slice-5/rented-transport-dev-b (stacked branches per AGENTS.md Git workflow carve-out). +- DCO signoff every commit. +- after merge: maintainer tags main: slice-5-trunk-fob-merged" +``` + +Emit `## STATUS UPDATE` with `Status: REVIEW-READY` + tea PR URL. Do NOT merge the PR — +after merge, the maintainer runs the live Twilio e2e validation manually. + +## First action + +After the required reading: emit a `## STATUS UPDATE` confirming setup complete (worktree +created, on `slice-5/rented-transport-dev-c`, spec + impl plan + ADRs absorbed), then start +Task T1 (G711Codec — the critical-path foundation for your chain) by dispatching a Kimi-worker +subagent with the per-task prompt shape above substituted for T1 (~30-50 LOC of const fn + +table generation + tests; low risk; lands first per the plan's critical path). + +**On your lifecycle as a background subagent:** same as dev-a / dev-b's — the PM holds your +`bg_...` task ID, polls your output via `background_output(task_id="bg_...")` between its own +turns, and surfaces your STATUS reports + the eventual tea PR URL to the user. Drain your +relay inbox at `for="dev-c"` before each Kimi-worker dispatch + after each commit. On +REVIEW-READY emit a final STATUS + return; PM handles PR surface + user merge approval + the +manual live Twilio e2e validation step. diff --git a/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-pm-prompt.md b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-pm-prompt.md new file mode 100644 index 0000000..cfccc98 --- /dev/null +++ b/docs/superpowers/kickoffs/2026-07-05-spearhead-4half-5-pm-prompt.md @@ -0,0 +1,295 @@ +# PM Kickoff Prompt — spearhead-4half-and-5 (benchmark + sim harness + rented transport) + +Paste everything below the `---` line into a fresh terminal (Claude Code, Codex, or OpenCode) +as the first user message. + +--- + +You are the **project manager** for the `spearhead-4half-and-5` "benchmark + sim harness + rented +transport" release. 3 senior developers report to you across separate terminals: + +- **dev-a** — slice 4½ (benchmark + simulation harness in `crates/rutster-sim/`). + Plan: `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md`. +- **dev-b** — step-5 green-zone side (`crates/rutster-trunk/src/provider/*` + `config.rs` env + parser + QUICKSTART/README docs + final cargo-deny + CI re-pin). + Plan: `docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md` (tasks T2, T6, T9, T10). +- **dev-c** — step-5 FOB side (`crates/rutster-trunk/src/{g711.rs, twilio_media_streams.rs, + session.rs, loop_driver.rs}` + `crates/rutster/src/media_thread.rs` `MediaLeg` enum + + `MediaCmd::RegisterTrunk` variant + the two new HTTP routes). + Plan: `docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md` (tasks T1, T3, T4, T5, T7, T8). + +The user runs all 4 terminals (PM + 3 dev). Each dev dispatches Kimi K2.7 worker subagents +(`task(subagent_type="kimi-worker", load_skills=["programming"], run_in_background=false, prompt="")`) +per task within its plan, ONE Kimi invocation per task. Each Kimi subagent gets a tight scoped +prompt with the worktree cd-sequence + task-section-pointer + acceptance criteria; it does NOT +read the whole plan itself. + +## Setup + +- Working directory: `/home/alee/Sources/rutster` (the main checkout). Do NOT clone or + branch here — the dev worktrees hold the work. +- Branch: stay on `main` (the integration branch). You do NOT own a worktree — you review + PRs via `tea`, not via local edits. +- Today: 2026-07-05. Project rules in `AGENTS.md` apply (read in full before kickoff). + +## 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. + Recipients: `pm, dev-a, dev-b, dev-c`. +- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action. +- `list_pending(for)` — check inbox count without consuming. + +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="...")`. + +**Fallback if MCP tools aren't registered** (this happens if the relay server was not running +when your session opened) — use the Python shim: + +```bash +export RELAY_PORT=7110 +cd ~/Sources/relay +python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}' +python3 call.py read_messages '{"for":"pm"}' +``` + +The shim connects over HTTP and has the same semantics as the MCP tools. To verify the relay ++ poller are alive (per AGENTS.md "PM session launch checklist"): + +```bash +pgrep -af "poller.py" # should list the poller running for port 7110 +tail -n 20 /tmp/relay-poller/7110/poller.log # should show ≥3 "poll cycle N complete" lines +tail -n 30 /tmp/relay-poller/7110/inbox.log # any dev messages drained since last PM session +``` + +If the poller isn't running, relaunch per AGENTS.md: +`setsid env RELAY_PORT=7110 python3 ~/Sources/relay/poller.py >> /tmp/relay-poller/7110/poller.log 2>&1 < /dev/null & disown` + +**Pitfalls (avoid):** +- Single-line `body` content; periods between sentences. Strict JSON parsers in some + inbox-monitors choke on embedded `\n` literals — use ` -- ` for stronger breaks. +- Don't `cd

&& ` in shell — use the `workdir` parameter / absolute paths. + +## Required reading (in order — do not skip) + +1. `AGENTS.md` — the whole file. Especially: "Architecture pre-reading" (spearhead + ADRs + 0002/0007/0008/0009/0010), "Git workflow" (tea CLI not gh; DCO signoff on every commit; + squash-merge default; rebase-merge carve-out for stacked branches), "Multi-agent + coordination — the relay" (the entire section — port 7110, poller, PM-mode discipline, + session handoff, PM launch checklist), "Multi-dev parallelism" (5 rules with anti-pattern + from slice-3), "Slice-1 boundaries — what NOT to add (yet)" (deferred items). +2. `.omo/plans/2026-07-05-spearhead-4half-and-step-5-strategic.md` — the strategic plan that + generates this release. §1.2 records the ADR-0010 deviation (escalation deferred behind + step 5 per user directive); §4.1 is your dispatch table; §7 is the stopping condition. +3. `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` — the 4½ spec. +4. `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` — the 4½ implementation + plan; 8 Kimi-sized tasks S1-S8. +5. `docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md` — the step-5 spec. +6. `docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md` — the step-5 implementation + plan; 10 Kimi-sized tasks T1-T10 split across dev-b and dev-c. + +## Momus review verdicts (read before kickoff) + +Both implementation plans have been Momus-reviewed: + +- **4½ impl plan: OKAY** — non-blocking concern flagged: the File Structure table + Global + Constraints paragraph say `MediaCmd::RegisterSim` lands in `media_thread.rs`, but Task S4's + reasoning concludes RegisterSim is NOT needed (the SimCall wires itself standalone in tokio + without touching MediaThread). S4's operative instruction supersedes. Dev-a IS EXPECTED to + follow S4's standalone path and NOT modify `media_thread.rs` for this slice. If the dev + surfaces confusion via QUESTION, point them at S4 line ~976-1015. +- **Step-5 impl plan: OKAY** — non-blocking concern: `reqwest` + `url` are not yet in the + workspace's `[workspace.dependencies]` map. Plan T1 Step 1 already contains the + check-and-add instruction for dev-c; just ensure dev-c runs it before the first `cargo build`. + +## Your authority + +- Approve or deny scope changes from devs. +- Review + merge PRs from each dev's feature branch via **`tea pulls create --head + --base main --title ... --description ...`** + `tea pulls list` + `tea pulls merge ` + (AGENTS.md Git workflow; the `tea` login is `alee` against `https://git.adlee.work`). +- Drive any release-prep not in a feature plan (e.g. the live Twilio validation, ADR-0010 + deviation note in the step-5 PR description, CHANGELOG/version bumps) — this is your own work. +- Tag `spearhead-4half-and-5` once everything is integrated **— but only after explicit + user approval.** The maintainer (user) owns merges of the dev-a / dev-b / dev-c PRs after + your REVIEW-COMPLETE posting. + +## Your boundaries + +- Don't write feature code yourself. Edits to `AGENTS.md`, `README.md`, `LEARNING.md`, + `docs/QUICKSTART.md` (the docs-after-each-PR cadence), or the ADRs are fine. +- Don't deviate from the spec without user approval. If a dev surfaces a fork (e.g. + `Reflex

` not actually pipe-agnostic for trunk legs per step-5 §8 ADR), STOP the dev + post + `kind=question` to the user with a one-paragraph summary + proposed default. +- Don't merge a PR until the dev says REVIEW-READY and you've run `tea pulls diff` (or + `gh pr diff` if the local tea shim doesn't have a diff subcommand) to confirm. +- Don't tag without user approval. +- Project rule: ask the user before any git-destructive op. + +## Judgment calls in the plans worth flagging + +The strategic plan + specs flagged these for your awareness: + +- **ADR-0010 deviation** (strategic §1.2): the user's 2026-07-05 directive bundles step 4½ + 5, + pushing step 5 AHEAD of rung-2 escalation. ADR-0010 prefers escalation-first per the + original re-sequencing. The deviation is recorded in `.omo/plans/2026-07-05-spearhead- + 4half-and-step-5-strategic.md` §1.2; the step-5 PR description should note it explicitly. + If the user redirects mid-execution (e.g. "switch step 5 → escalation"), tee up a new spec + pass in the main session — do NOT fork the spec inside a dev branch. +- **Slice 4½ S4 under-design** — the SimCall wiring task is explicitly acknowledged as + "needs more design than I can resolve in this plan revision." Dev-a should emit a STATUS + UPDATE if they hit a the wiring design fork; PM's job is to surface that fork to the user + with a proposed default, NOT bike-shed it in relay messages. +- **Step-5 spec §1.2 out-of-scope rigor** — a number of "out of scope" items are explicitly + never (SIP/endpoint-registration/ConversationRelay). If a dev proposes any of them, the + answer is "no, see spec §1.2." +- **Credential isolation (ADR-0009) is load-bearing** — if dev-c or dev-b proposes putting + `TwilioCredentials` in `rutster-media`'s public API OR routing it through the brain's WS + protocol, that's a hard NO. +- **Seam invariant (`loop_driver.rs` + `rtc_session.rs` byte-identical) is sacred** — broadcast + this to dev-c specifically; dev-c will be tempted to extend `loop_driver.rs` for a Twilio path. + The right answer is `trunk_driver::drive` in `crates/rutster-trunk/src/loop_driver.rs` (a + parallel-titled file in a different crate). Don't let dev-c become the seam's first violator. + +If any of these conflict with your judgment, raise it with the user before kickoff. + +## Coordination protocol + +You are 1 of 4 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. Per AGENTS.md "PM-mode discipline": at the start +of EVERY turn, (a) drain pm inbox (or read `/tmp/relay-poller/7110/inbox.log` if the poller +ran), (b) `list_pending` for each dev role (`dev-a`, `dev-b`, `dev-c`), (c) `git log --oneline +--all -10` for what landed since last seen, (d) surface anything actionable BEFORE the user +asks "did you see X?" + +**Narrate** to the user in plain prose between tool calls. The user's only window into the +release is the PM terminal output + the relay live-tail window (`RELAY_PORT=7110 +~/Sources/relay/watch.sh`). 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 so the user sees the reasoning, not just the verdict. + +**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-A/B/C` blocks from +the relay inbox. + +**You emit:** a `## DIRECTIVE TO DEV-X` block — post it via `post_message` + print it in the PM +terminal. Format: + +``` +## DIRECTIVE TO DEV-X +Time: +Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED +Notes: +Next: +``` + +**Confirm directives are actually seen.** A head-down dev on a long-running Kimi-worker +invocation can still miss a HOLD/RESCOPE. After posting one, watch that dev's next STATUS +UPDATE for an explicit ack. If the dev keeps posting forward progress on the old premise, do +NOT assume it landed — tell the user to nudge that terminal directly. An unacknowledged HOLD +is a blocker, not sent-and-forget. + +When asked "status?" by the user at any time, give a current rollup: + +``` +## RELEASE STATUS — spearhead-4half-and-5 +dev-a (4½ S1-S8): +dev-b (5 T2/T6/T9/T10): +dev-c (5 T1/T3/T4/T5/T7/T8): +PM: +Blockers: +Next milestone: +``` + +## Reviewing PRs + +When a dev posts `Action: REVIEW-READY` with a PR URL (or tea PR index): + +1. `tea pulls view ` (or `gh pr view `) to read description and CI status. +2. `tea pulls diff ` (or `gh pr diff `) to read changes. +3. Check the diff against the spec + plan acceptance criteria (the `## Final acceptance + checklist` block at the bottom of each plan). +4. If green: post `Action: MERGE-APPROVED` to the relay + tell the user "merge ready for + dev-X, awaiting your merge approval" (per "Merge strategy" in AGENTS.md — the user merges + per the gender-neutral user-preference Option 2 of "Finishing a development branch." + Default merge strategy is squash; carve-out for stacked branches T-b → dev-c via rebase-merge. + State the merge strategy in the dispatch directive so the user knows which to apply). +5. If red: post `Action: HOLD` with specific concerns the dev needs to address. + +Use the `superpowers:requesting-code-review` skill OR delegate to `task(subagent_type="oracle", +prompt="review this PR; ")` if you want a deeper independent review from a fresh +subagent before approving — recommended for dev-a's slice-4-half CI gate (asserting CI +thresholds) AND for dev-c's slice-5 seam-evidence (proving trunk_driver doesn't touch +loop_driver). + +## Pre-tag checklist + +Before tagging `spearhead-4half-and-5`: + +- [ ] Every dev branch merged to main (squash-merge default; rebase-merge for the dev-b → dev-c + stacked-branches case in slice-5 if SHAs carry). +- [ ] CI green on main: `cargo fmt --check`, `cargo clippy -- -D warnings` (default + + `--features=twilio-live`), `cargo test --all`, `cargo deny check`, AND `cargo test --all + --features=sim-bench -- --test-threads=1` (the new slice-4½ sim-bench CI gate). +- [ ] Seam gate proves the gate: `git rev-parse main:crates/rutster-media/src/loop_driver.rs` + returns the slice-4 Task-10-pinned blob hash; same for `rtc_session.rs`. If EITHER hash + differs: this slice is in violation; STOP + ask the user. Either the dev is the seam's + first violator OR the seam gate's CI pinned-hash should be updated in this same PR with + explicit reviewer signoff + spec-rationale documentation. +- [ ] User-run live Twilio e2e (`cargo test --all --features=twilio-live` against real Twilio + credentials) succeeds once on the maintainer's machine. +- [ ] Explicit user approval to tag. + +## First action + +The user pasted this prompt into the `pm` window of the `relay-lift` tmux session that +`start.sh --tmux` opened. The relay + poller (step 1+2 of AGENTS.md "PM session launch +checklist") are running and the watch.sh tail is live in its side window. **You are the +single point of contact for the user — the 3 dev subagents you spawn below stream their +output through `background_output` reads + the relay; the user does NOT paste dev prompts +into separate terminals.** + +1. Call `read_messages(for="pm")` to drain any early inbox messages (likely empty at session + start). +2. Verify the poller is alive per AGENTS.md (≥3 "poll cycle N complete" lines in + `/tmp/relay-poller/7110/poller.log`). +3. Emit a `## RELEASE STATUS` block confirming you've absorbed the strategic plan + 4 + specs/plans. Note the judgment calls above for the user's awareness + confirm the dev-a + 4½-S4 standalone SimCall path is operative (NOT the RegisterSim-in-media_thread path + from the stale File Structure table). +4. **Auto-spawn the 3 dev subagents** as long-running background tasks. Each takes its + kickoff prompt (the `dev-{a,b,c}-prompt.md` file in `docs/superpowers/kickoffs/`) as its + initial context. Use the shape: + + ```text + task( + subagent_type="general", + run_in_background=true, + prompt="" + ) + ``` + + Repeat for dev-b + dev-c, returning each `bg_...` task ID for later polling. Each dev + subagent: + - Reads its kickoff prompt as initial context (inheriting the worktree path, the plan + + spec paths, the seam/credential isolation invariants, the Kimi-worker dispatch shape) + - cd's into its worktree (creating it if needed per the prompt's setup bash) + - Reads the plan + spec + AGENTS.md + - Dispatches `task(subagent_type="kimi-worker", load_skills=["programming"], ...)` per + plan task (S1-S8 for dev-a; T2/T6/T9/T10 for dev-b; T1/T3/T4/T5/T7/T8 for dev-c) + - Posts STATUS reports to the relay (`from="dev-X"`, `to="pm"`, `kind="status"`); the + poller drains them into `/tmp/relay-poller/7110/inbox.log` between PM turns + - Returns REVIEW-READY + tea PR URL when its slice is complete + +5. After the 3 `task()` calls return their `bg_...` IDs, post opening directives to each + via the relay (`post_message(from="pm", to="dev-X", kind="directive", body="PROCEED on + task per plan; -specific constraints: <...>")`). Wait for acknowledgement + STATUS UPDATEs from all 3 devs (each dev must emit one with `Status: STARTED`) before + clearing any to actually proceed — track this via `background_output(task_id="bg_...")` + OR by polling the relay inbox. + +6. Quit the spawning phase + enter coordination mode: at the start of every subsequent + turn, (a) drain pm inbox (or read `/tmp/relay-poller/7110/inbox.log` if the poller ran), + (b) `list_pending` for each dev role, (c) `git log --oneline --all -10` for commits that + landed since last seen, (d) surface anything actionable to the user before they ask. diff --git a/docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md b/docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md new file mode 100644 index 0000000..5802e03 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md @@ -0,0 +1,1404 @@ +# Slice 4½ — Benchmark + simulation harness — 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½ — a self-hostable benchmark + simulation harness in a new +`crates/rutster-sim/` crate. Drives synthetic callers through the SAME media-leg path real callers +use; measures p50/p99 mouth-to-ear latency + barge-in kill-time at 1/10/50 concurrent calls; a +separate CI job (`cargo test --all --features=sim-bench`) asserts thresholds per commit. A latency +regression fails the build (ADR-0010's central demand). + +**Architecture:** A new `crates/rutster-sim/` crate (currently non-existent) houses the harness: +`Scenario` + `ScenarioStep` types deserialize from TOML; `SimAudioPipe: AudioPipe` drives a scenario +on `on_pcm_frame` (caller speaks) and captures brain replies on `next_pcm_frame` (caller hears) — +both timestamps anchored to `Instant::now()` (monotonic, identical clock). `LatencyProbe` +post-hoc computes p50/p99 kill + mouth-to-ear durations from the `SimAudioPipe`'s captures. +`ConcurrencyRunner` spawns N concurrent `SimCall`s against an in-process `MediaThread` + aggregates +per-call latencies + the slice-5/seams `MediaStats.{tick_overruns, last_tick_micros}` gauge. CI +asserts thresholds at each of [1, 10, 50] concurrency. + +**Tech Stack:** Rust stable + 1.85 (CI matrix — for the routine gate; `sim-bench` job is +stable-only), the existing workspace deps (`tokio`, `serde`, `toml`, `axum`), no new deps. + +## Global Constraints + +- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004). +- **DCO:** every commit signed off — `git commit -s` (AGENTS.md Git workflow). Signoff identity = + the human maintainer's git config, not the agent. +- **Seam gate (UNCHANGED from slice-4 Task 10):** `crates/rutster-media/src/loop_driver.rs` + + `crates/rutster-media/src/rtc_session.rs` stay byte-identical (CI pinned-blob gate). The new + `MediaCmd::RegisterSim` variant lives in `crates/rutster/src/media_thread.rs`, NOT in the seam + files. +- **Hot-path policy (when the harness drives the 20 ms tick):** never `?`-propagate; match-and- + continue; "drop + observe (log + counter), don't crash." No `unwrap()`/`expect()` outside + tests/const-init. +- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is + the lint bar. Newtype wrappers over primitives — not needed this slice (no new primitive + types surface in the public API). +- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts. +- **Learner-facing comments:** this project OVERRIDES the no-comments default. Every new public + item gets `///` docs; every new module gets `//!` docs citing the design intent. Snippets below + show the load-bearing comments; implementers keep that density. +- **Measurement discipline:** the harness captures timestamps ONLY inside `SimAudioPipe` (the + caller's clock — see spec §2.2). Do NOT add `Instant::now()` calls in `loop_driver.rs`, + `rtc_session.rs`, or `media_thread.rs` for measurement — that would re-introduce instrumentation + in the seam or the binary's hot path, defeating §2.3's design choice. +- **CI gates:** `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all` (stable + + 1.85 — routine gate, UNCHANGED, no sim-bench feature on by default), `cargo deny check`, and + the NEW `cargo test --all --features=sim-bench` job on stable. +- **Branch/PR:** branch `slice-4-half/sim-harness-dev-a` off `main`; PR via `tea` (not `gh`). + +## File Structure + +### New files + +| Path | Responsibility | +|---|---| +| `crates/rutster-sim/Cargo.toml` | Workspace member manifest. Edition 2024. Deps: `rutster-media`, `rutster`, `tokio`, `serde`, `toml`, `tracing`. The `sim-bench` feature is defined here (default off). | +| `crates/rutster-sim/src/lib.rs` | `//! module docs; pub mod scenario; pub mod sim_audio_pipe; pub mod latency; pub mod runner; pub mod concurrency; pub mod tick_lag; pub mod thresholds;` + the `pub use` re-exports. | +| `crates/rutster-sim/src/scenario.rs` | `Scenario`, `ScenarioStep` enums + `Scenario::load(path)` TOML deserializer. | +| `crates/rutster-sim/src/sim_audio_pipe.rs` | `SimAudioPipe: AudioPipe` implementation + `Capture` enum. | +| `crates/rutster-sim/src/latency.rs` | `LatencyProbe` — post-hoc p50/p99 computation from `Capture` event stream. | +| `crates/rutster-sim/src/runner.rs` | `SimCall` (one synthetic caller) + `ScenarioRunner` (single-call driver). | +| `crates/rutster-sim/src/concurrency.rs` | `ConcurrencyRunner` — N concurrent `SimCall`s + `SweepReport` / `PerConcurrencyReport` aggregations. | +| `crates/rutster-sim/src/tick_lag.rs` | `TickLagGauge` — polls `MediaCmd::Stats` during the sweep + surfaces `tick_overruns` / `last_tick_micros` per spec §3.6. | +| `crates/rutster-sim/src/thresholds.rs` | Threshold constants (`BARGE_IN_KILL_TIME_P99_MS = 80.0`, etc.) + the `#[cfg(feature = "sim-bench")] #[tokio::test]` threshold-assertion tests. | +| `crates/rutster-sim/scenarios/loud-barge.toml` | Scenario pack: 20 loud frames → await reply → end. Drives the PRIMARY barge-in path. | +| `crates/rutster-sim/scenarios/quiet-advisory.toml` | Scenario pack: quiet frames, exercises slice-3 advisory plumbing. | +| `crates/rutster-sim/scenarios/sustained-call.toml` | Scenario pack: 5 minutes of talk with 3 barge cycles. | +| `crates/rutster-sim/src/mulaw_table.rs` (data file) | Not used in 4½ — included in case it's needed for a future harness extension. (Skip this file; listed only for symmetry.) | + +### Modified files + +| Path | What changes | +|---|---| +| `Cargo.toml` (workspace root) | Add `"crates/rutster-sim"` to `[workspace] members`. | +| `crates/rutster/src/media_thread.rs` | Add `MediaCmd::RegisterSim { pipe: Box, reply: oneshot::Sender }` variant; the std thread's `cmd_rx.try_recv()` loop handles it by allocating a `ChannelId`, constructing a synthetic "session" entry that drives the harness's `SimAudioPipe` through the same `loop_driver::drive` calls as real WebRTC sessions (the seam holds). | +| `crates/rutster/src/lib.rs` | `pub mod media_thread;` already exists (slice-4); no change this slice. | +| `.github/workflows/ci.yml` | Add `sim-bench` job: stable-only, runs `cargo test --all --features=sim-bench -- --test-threads=1`. | +| `LEARNING.md` | Pointer to `crates/rutster-sim/` after Task S8 lands. | + +### SEAM-INVARIANT files (DO NOT TOUCH) + +- `crates/rutster-media/src/loop_driver.rs` — **byte-identical** to slice-3 (and slice-4). +- `crates/rutster-media/src/rtc_session.rs` — **byte-identical** to slice-3 (and slice-4). + +Every dispatched dev MUST respect this. The new `MediaCmd::RegisterSim` variant lands in +`media_thread.rs` (the binary-side bridge), NOT in the seam files. + +## Task ordering (for Kimi-worker subagent dispatch) + +The chain is **strictly linear** (dev-a solo in the strategic plan §4.1): each task consumes +the prior task's types. Fanned-out across this 8-task chain, parallelism stalls. Instead, this +slice is best executed by one Kimi dev driving the chain serially. + +- **S1** — CRITICAL-PATH FOUNDATION. `crates/rutster-sim/` skeleton + `Scenario`/`ScenarioStep` + types. Lands first; every later task imports these. +- **S2** — `SimAudioPipe: AudioPipe`. Depends on S1 (uses `Scenario` + `ScenarioStep`). +- **S3** — `LatencyProbe`. Depends on S2 (consumes `Capture` events from the SimAudioPipe). +- **S4** — `SimCall` + `ScenarioRunner`. Depends on S2 + the merged `MediaThread` (slice-4 + + slice-5/seams). Drives a single end-to-end sim call against an in-process MediaThread. +- **S5** — `ConcurrencyRunner`. Depends on S4 (spawns N SimCalls + aggregates). +- **S6** — `TickLagGauge`. Depends on S5 (the sweep needs to poll tick-lag stats per second). +- **S7** — `cargo test --features=sim-bench` CI job + threshold consts + assertion tests. Depends + on S5 + S6 (the assertions are end-to-end). +- **S8** — Scenario pack + LEARNING.md pointer. Filler; any time after S4. + +Parallelizable-now filler (a blocked dev picks these up without blocking the critical path): +- LEARNING.md pointer to the new `scenario.rs` (after S1 lands) + `sim_audio_pipe.rs` (after S2). +- `cargo doc` rendering checks (after S2 + the crate skeleton stabilize). + +--- + +### Task S1: `crates/rutster-sim/` skeleton + `Scenario`/`ScenarioStep` types — the critical-path foundation + +**Files:** +- Create: `crates/rutster-sim/Cargo.toml` +- Create: `crates/rutster-sim/src/lib.rs` +- Create: `crates/rutster-sim/src/scenario.rs` +- Modify: `Cargo.toml` (workspace root — add the new member) +- Test: `crates/rutster-sim/src/scenario.rs` (inline `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: nothing (pure-data types + TOML deserialization). +- Produces: + - `pub struct Scenario { pub name: String, pub steps: Vec }` + - `pub enum ScenarioStep { SpeakLoud { frames: u32 }, SpeakQuiet { frames: u32 }, Pause { frames: u32 }, AwaitReply { frames: u32 }, End }` + - `impl Scenario { pub fn load(path: impl AsRef) -> Result }` + - `pub struct ScenarioError(pub String);` + +- [ ] **Step 1: Write the workspace member manifest** + +Create `crates/rutster-sim/Cargo.toml`: + +```toml +# crates/rutster-sim/Cargo.toml +[package] +name = "rutster-sim" +version = "0.0.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +description = "Self-hostable benchmark + simulation harness (ADR-0010 spearhead step 4½)." + +[dependencies] +rutster-media = { path = "../rutster-media" } +rutster = { path = "../rutster" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } +tracing = { workspace = true } + +[features] +default = [] +# The CI-regressed threshold sweep. Default OFF so `cargo test --all` (the +# routine gate) stays fast. A separate CI job runs +# `cargo test --all --features=sim-bench`. See spec §5.4 + §6.5. +sim-bench = [] +``` + +Add the new member to the workspace root `Cargo.toml`: + +```toml +# Cargo.toml (root) +[workspace] +members = [ + "crates/rutster", + "crates/rutster-brain-realtime", + "crates/rutster-call-model", + "crates/rutster-media", + "crates/rutster-sim", # <- NEW + "crates/rutster-spend", + "crates/rutster-tap", + "crates/rutster-tap-echo", + "crates/rutster-trunk", +] +``` + +- [ ] **Step 2: Write the crate's `lib.rs` module-doc header** + +Create `crates/rutster-sim/src/lib.rs`: + +```rust +//! # rutster-sim — the self-hostable benchmark + simulation harness +//! +//! **Status:** spearhead step 4½ (ADR-0010). The wedge's measurement surface. +//! +//! This crate drives synthetic callers through the SAME media-leg path real +//! callers use, measures p50/p99 mouth-to-ear latency + barge-in kill-time +//! against slice-4's ≤60 ms kill budget, and runs the same measurements at +//! 1 / 10 / 50 concurrent calls. A separate CI job +//! (`cargo test --all --features=sim-bench`) asserts thresholds per commit; +//! a latency regression fails the build (ADR-0010). +//! +//! # Why this crate exists (the FOB differentiator) +//! +//! Slice-4 ships a reflex loop + a synthetic e2e test. SIM-BENCH is the +//! artifact that turns arithmetic latency claims into CI-regressed +//! measurement. See +//! [`docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md`] +//! for the design. +//! +//! # Why a separate crate (not in-tree tests) +//! +//! The harness is hot-path-adjacent + differentiating (ADR-0008 FOB) — it +//! earns cratehood the same way `rutster-tap` did. The dep direction is +//! clean: `rutster-sim` → `rutster-media` + `rutster`. The harness +//! consumes types; it doesn't ride on the binary's internal plumbing. + +pub mod concurrency; +pub mod latency; +pub mod runner; +pub mod scenario; +pub mod sim_audio_pipe; +pub mod thresholds; +pub mod tick_lag; + +// Re-exports for the public API surface. +pub use concurrency::{ConcurrencyRunner, PerConcurrencyReport, SweepReport}; +pub use latency::LatencyProbe; +pub use runner::{ScenarioRunner, SimCall}; +pub use scenario::{Scenario, ScenarioError, ScenarioStep}; +pub use sim_audio_pipe::{Capture, SimAudioPipe}; +pub use thresholds::{ + BARGE_IN_KILL_TIME_P99_MS, MOUTH_TO_EAR_P99_MS, SWEEP_CONCURRENCIES, + TICK_LAG_MAX_MS, TICK_OVERRUN_PCT_MAX, +}; +pub use tick_lag::TickLagGauge; +``` + +(Other modules are added in later tasks; their `pub mod` lines won't +resolve until then — create empty stub files OR comment out the `pub mod` +lines until the corresponding task lands. Recommendation: create empty +files with just a `//! module docs` placeholder header each, so the crate +compiles at every commit boundary.) + +For S1's commit alone, create these stub files alongside `scenario.rs`: +- `src/sim_audio_pipe.rs` — `//! stub; lands in S2` +- `src/latency.rs` — `//! stub; lands in S3` +- `src/runner.rs` — `//! stub; lands in S4` +- `src/concurrency.rs` — `//! stub; lands in S5` +- `src/tick_lag.rs` — `//! stub; lands in S6` +- `src/thresholds.rs` — `//! stub; lands in S7` (with the consts as immediate module-level + `pub const` items per spec §5.1 — they're used by S5/S6/S7 wiring) + +- [ ] **Step 3: Write the failing test for `Scenario` deserialization** + +Create `crates/rutster-sim/src/scenario.rs` with the test module only: + +```rust +//! # Scenario — the scripted-caller data type +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` §3.1. +//! +//! A `Scenario` is a sequence of `ScenarioStep`s read from a TOML file under +//! `crates/rutster-sim/scenarios/*.toml`. Deterministic by construction — +//! the entire point is reproducible thresholds in CI (LLM-driven callers +//! land in a post-spearhead refinement tier; see §1.2). +//! +//! # Why TOML (not YAML, not RON) +//! +//! `serde` + `toml` is already a workspace member. TOML keeps the scenario +//! file readable as a one-shot script (a sequence of named steps + numbers); +//! YAML would invite flow-mapping complexity this format doesn't need. + +use std::path::Path; + +/// The scripted-caller scenario. Read from a TOML file. Deterministic. +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +pub struct Scenario { + /// Human-readable identifier; surfaces in CI failure messages. + pub name: String, + /// Time-ordered sequence of caller actions. + pub steps: Vec, +} + +/// One axis of caller behavior. A scenario is a time-ordered sequence. +/// `SimAudioPipe` consumes them in order during `on_pcm_frame`. +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScenarioStep { + /// Send N loud PCM frames (sample value 1000, well above VAD_RMS_THRESHOLD=500.0). + /// Triggers the local VAD via slice-4's `LocalVadReflex::on_pcm_frame`. + SpeakLoud { frames: u32 }, + /// Send N zero frames (sample value 0, well below VAD_RMS_THRESHOLD). + /// Drives mock-brain advisory path (slice-4 §5.2 secondary path). + SpeakQuiet { frames: u32 }, + /// Insert N zero frames before the next step (silence pacing). + Pause { frames: u32 }, + /// Wait until the harness receives M "ear" frames before advancing. + /// Barrier: brain's reply must arrive before the next caller action. + AwaitReply { frames: u32 }, + /// End the scenario. `SimAudioPipe` returns None from next_pcm_frame thereafter. + End, +} + +/// Errors surfaced during scenario loading. Cold-path; OK to be String-y. +#[derive(Debug, thiserror::Error)] +pub enum ScenarioError { + #[error("scenario file read failed: {0}")] + Io(#[from] std::io::Error), + #[error("scenario TOML parse failed: {0}")] + Parse(#[from] toml::de::Error), +} + +impl Scenario { + /// Load a scenario from a TOML file. Cold-path. + pub fn load(path: impl AsRef) -> Result { + let raw = std::fs::read_to_string(path)?; + Self::from_toml(&raw) + } + + /// Parse a scenario from an in-memory TOML string. Useful for tests. + pub fn from_toml(s: &str) -> Result { + Ok(toml::from_str(s)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scenario_parses_minimal_end_only() { + let toml = r#" + name = "trivial" + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.name, "trivial"); + assert_eq!(s.steps, vec![ScenarioStep::End]); + } + + #[test] + fn scenario_parses_loud_barge_shape() { + let toml = r#" + name = "loud-barge" + [[steps]] + kind = "speak_loud" + frames = 20 + [[steps]] + kind = "await_reply" + frames = 0 + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.name, "loud-barge"); + assert_eq!(s.steps, vec![ + ScenarioStep::SpeakLoud { frames: 20 }, + ScenarioStep::AwaitReply { frames: 0 }, + ScenarioStep::End, + ]); + } + + #[test] + fn scenario_parses_sustained_call_shape() { + let toml = r#" + name = "sustained" + [[steps]] + kind = "speak_loud" + frames = 10 + [[steps]] + kind = "speak_quiet" + frames = 10 + [[steps]] + kind = "speak_loud" + frames = 10 + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.steps.len(), 4); + assert!(matches!(s.steps[0], ScenarioStep::SpeakLoud { frames: 10 })); + assert!(matches!(s.steps[1], ScenarioStep::SpeakQuiet { frames: 10 })); + assert!(matches!(s.steps[2], ScenarioStep::SpeakLoud { frames: 10 })); + assert!(matches!(s.steps[3], ScenarioStep::End)); + } + + #[test] + fn scenario_unknown_kind_errors() { + let toml = r#" + name = "bad" + [[steps]] + kind = "ship_a_real_caller" + "#; + assert!(Scenario::from_toml(toml).is_err()); + } +} +``` + +Add `thiserror` to the crate's deps (workspace member already): + +```toml +# crates/rutster-sim/Cargo.toml — append under [dependencies] +thiserror = { workspace = true } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cargo test -p rutster-sim --lib scenario::tests +``` + +Expected: PASS (4 tests). If `thiserror` isn't in the workspace, switch to a plain +`pub struct ScenarioError(pub String)` + manual `From` impls (avoid pulling a new dep this +late; the workspace pattern is to consolidate). If `thiserror` IS already a workspace +member (rustster-tap uses it), prefer the `#[derive(thiserror::Error)]` form above. + +- [ ] **Step 5: fmt + clippy + full test + commit** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo test --all # routine gate; sim-bench feature is OFF by default — must still pass +git add Cargo.toml crates/rutster-sim/Cargo.toml crates/rutster-sim/src/ +git commit -s -m "feat(sim): rutster-sim crate skeleton + Scenario/ScenarioStep types (slice-4½ S1) + +The critical-path foundation for the benchmark + simulation harness. +Scenario is a TOML-deserializable scripted-caller data type; ScenarioStep +covers speak_loud / speak_quiet / pause / await_reply / end. Determinism is +the point — reproducible thresholds in CI (ADR-0010). All other sim +modules land as stubs here + fill in across S2-S7. + +Task S1 of slice-4½ - everything else depends on this landing." +``` + +--- + +### Task S2: `SimAudioPipe: AudioPipe` + `Capture` enum + +**Files:** +- Modify: `crates/rutster-sim/src/sim_audio_pipe.rs` (replace stub) +- Modify: `crates/rutster-sim/src/lib.rs` (already re-exports `SimAudioPipe` + `Capture`) +- Test: `crates/rutster-sim/src/sim_audio_pipe.rs` (inline tests) + +**Interfaces:** +- Consumes: `Scenario` + `ScenarioStep` (from S1), `PcmFrame` + `AudioPipe` + `AudioSource` + + `AudioSink` from `rutster-media`. +- Produces: + - `pub enum Capture { CallerLoudOnset { at: Instant }, BargeKillObserved { at: Instant }, CallerHeardReply { at: Instant } }` + - `pub struct SimAudioPipe { scenario, step_idx, step_frames_remaining, reply_frames_received, captures, reply_ring }` + - `impl AudioSource for SimAudioPipe` + `impl AudioSink for SimAudioPipe` + `impl AudioPipe for SimAudioPipe` + - `impl SimAudioPipe { pub fn new(scenario: Scenario, reply_ring_cap: usize) -> Self; pub fn take_captures(&mut self) -> Vec }` + +- [ ] **Step 1: Write the failing tests for the sim pipe's state machine** + +Replace `crates/rutster-sim/src/sim_audio_pipe.rs`'s stub with the test module + the impl. + +The state machine mirrors the spec §3.2.1 + §3.2.2: +- `on_pcm_frame` advances the scenario cursor + emits `CallerLoudOnset` capture on a `SpeakLoud` step boundary. +- `next_pcm_frame` returns frames from the `reply_ring` (filled externally — by the SimCall wiring in S4); emits `CallerHeardReply` on `Some`, `BargeKillObserved` on `None` (depending on scenario-phase). + +Tests to write (each, ≤30 LOC): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::{Scenario, ScenarioStep}; + use rutster_media::PcmFrame; + use tokio::sync::mpsc; + use std::time::Instant; + + fn trivial_scenario() -> Scenario { + Scenario::from_toml(r#" + name = "trivial" + [[steps]] + kind = "speak_loud" + frames = 3 + [[steps]] + kind = "end" + "#).unwrap() + } + + #[test] + fn speak_loud推进_step_cursor_on_each_on_pcm_frame() { + let mut pipe = SimAudioPipe::new(trivial_scenario(), 8); + // First 3 on_pcm_frame calls consume the SpeakLoud step. + for _ in 0..3 { pipe.on_pcm_frame(PcmFrame::zeroed()); } + // After 3 frames, the 4th call advances to End (a no-op step) + captures CallerLoudOnset on the first speak. + // Inspect captures: CallerLoudOnset captured once on the boundary of SpeakLoud. + let caps = pipe.take_captures(); + assert!(caps.iter().any(|c| matches!(c, Capture::CallerLoudOnset { .. })), + "expected CallerLoudOnset captured when SpeakLoud step began"); + } + + #[test] + fn next_pcm_frame_returns_none_when_reply_ring_empty_and_emits_barge_kill_capture() { + let mut pipe = SimAudioPipe::new(trivial_scenario(), 8); + // Reply ring is empty; next_pcm_frame returns None + captures BargeKillObserved. + let r = pipe.next_pcm_frame(); + assert!(r.is_none(), "empty reply_ring returns None"); + let caps = pipe.take_captures(); + assert!(caps.iter().any(|c| matches!(c, Capture::BargeKillObserved { .. })), + "expected BargeKillObserved captured when reply_ring was empty"); + } + + #[test] + fn next_pcm_frame_returns_frame_and_emits_caller_heard_reply() { + let mut pipe = SimAudioPipe::new(trivial_scenario(), 8); + // Push a synthetic reply frame into the ring. + pipe.push_reply(PcmFrame::zeroed()); + let r = pipe.next_pcm_frame().expect("reply"); + let caps = pipe.take_captures(); + assert!(caps.iter().any(|c| matches!(c, Capture::CallerHeardReply { .. })), + "expected CallerHeardReply captured"); + } + + #[test] + fn captures_are_in_temporal_order() { + let mut pipe = SimAudioPipe::new(trivial_scenario(), 8); + pipe.push_reply(PcmFrame::zeroed()); + let _ = pipe.next_pcm_frame(); // CallerHeardReply + pipe.on_pcm_frame(PcmFrame::zeroed()); // CallerLoudOnset (first SpeakLoud frame) + let caps = pipe.take_captures(); + assert!(caps.len() >= 2, "captured at least 2 events"); + // Verify the events were captured in temporal order. + for w in caps.windows(2) { + // Each Capture::* variant's `at: Instant` — non-decreasing across the vector. + let t1 = match &w[0] { Capture::CallerLoudOnset { at } | Capture::BargeKillObserved { at } | Capture::CallerHeardReply { at } => *at }; + let t2 = match &w[1] { Capture::CallerLoudOnset { at } | Capture::BargeKillObserved { at } | Capture::CallerHeardReply { at } => *at }; + assert!(t2 >= t1, "captures must be in non-decreasing Instant order"); + } + } + + #[test] + fn take_captures_drains_and_subsequent_call_returns_empty() { + let mut pipe = SimAudioPipe::new(trivial_scenario(), 8); + pipe.push_reply(PcmFrame::zeroed()); + let _ = pipe.next_pcm_frame(); + assert!(!pipe.take_captures().is_empty()); + assert!(pipe.take_captures().is_empty(), "drained on first take_captures"); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cargo test -p rutster-sim --lib sim_audio_pipe::tests +``` + +Expected: compile errors — `SimAudioPipe`, `Capture` don't exist yet. + +- [ ] **Step 3: Implement `SimAudioPipe` + `Capture`** + +Add the struct + impls above the `#[cfg(test)] mod tests` in `sim_audio_pipe.rs`: + +```rust +//! # SimAudioPipe — the test-double AudioPipe that simulates a caller +//! +//! See spec §3.2. Drives a `Scenario` on `on_pcm_frame` (the sink path: +//! caller speaks); receives brain response frames on `next_pcm_frame` +//! (the source path: caller hears). Captures `Instant::now()` at every +//! meaningful event for the `LatencyProbe` to consume. +//! +//! # Why this is THE measurement boundary +//! +//! Both clocks live INSIDE this pipe. The wall-clock the *caller* started +//! speaking is captured here (we decided when to "speak"); the wall-clock +//! the *caller* heard the reply is captured here (we observed the system's +//! reply). See spec §2.2 — the harness can't lie about latency because the +//! only clock it uses is the caller's. + +use std::collections::VecDeque; +use std::time::Instant; + +use rutster_media::{AudioPipe, AudioSource, AudioSink, PcmFrame}; + +use crate::scenario::{Scenario, ScenarioStep}; + +/// A timestamped event captured by `SimAudioPipe`. Read by `LatencyProbe` +/// post-run to compute p50/p99 latencies. +#[derive(Debug, Clone, Copy)] +pub enum Capture { + /// The caller started speaking loudly (a `SpeakLoud` step began). + CallerLoudOnset { at: Instant }, + /// The FOB killed playout (a `next_pcm_frame` returned None immediately + /// after a barge event). See spec §3.2.1. + BargeKillObserved { at: Instant }, + /// The caller heard a brain reply (a `next_pcm_frame` returned Some + /// after the barge cleared). See spec §3.2.1. + CallerHeardReply { at: Instant }, +} + +/// The test-double AudioPipe. See module docs. +pub struct SimAudioPipe { + scenario: Scenario, + step_idx: usize, + /// Frames remaining in the current step (for SpeakLoud/SpeakQuiet/Pause). + step_frames_remaining: u32, + /// Frames received from `next_pcm_frame` while in `AwaitReply`. + /// When this reaches the step's target, advance. + await_reply_target: u32, + /// Captures buffered for the LatencyProbe. Bounded; on overflow the + /// oldest is dropped (hot-path policy — measurement shouldn't crash). + captures: VecDeque, + /// Pre-allocated reply frames pushed externally by the SimCall wiring + /// (S4). The next_pcm_frame call pops from here. + reply_ring: VecDeque, +} + +const CAPTURE_RING_CAP: usize = 1024; + +impl SimAudioPipe { + pub fn new(scenario: Scenario, reply_ring_cap: usize) -> Self { + let mut pipe = Self { + scenario, + step_idx: 0, + step_frames_remaining: 0, + await_reply_target: 0, + captures: VecDeque::with_capacity(CAPTURE_RING_CAP), + reply_ring: VecDeque::with_capacity(reply_ring_cap), + }; + pipe.enter_step(); + pipe + } + + /// Push a synthetic brain-reply PCM frame into the pipe's ring. + /// Called by the SimCall's WS-pump-equivalent wiring (S4 will connect this + /// to the TapEngine's tx_audio_out mpsc). + pub fn push_reply(&mut self, frame: PcmFrame) { + self.reply_ring.push_back(frame); + } + + /// Drain captures for the LatencyProbe. Consumes the buffer. + pub fn take_captures(&mut self) -> Vec { + self.captures.drain(..).collect() + } + + /// Advance the step cursor; initialize per-step counters. + fn enter_step(&mut self) { + if self.step_idx >= self.scenario.steps.len() { + // End-of-scenario: nothing to do; next_pcm_frame returns None, + // on_pcm_frame is a no-op. The SimCall (S4) detects end + stops. + return; + } + match &self.scenario.steps[self.step_idx] { + ScenarioStep::SpeakLoud { frames } => { + self.step_frames_remaining = *frames; + // Capture onset at step entry. + self.push_capture(Capture::CallerLoudOnset { at: Instant::now() }); + } + ScenarioStep::SpeakQuiet { frames } => { + self.step_frames_remaining = *frames; + // No capture for quiet onsets — the wedge cares about LOUD barge. + } + ScenarioStep::Pause { frames } => { + self.step_frames_remaining = *frames; + } + ScenarioStep::AwaitReply { frames } => { + self.await_reply_target = *frames; + } + ScenarioStep::End => { /* no-op */ } + } + } + + /// Move to the next step. Called when step_frames_remaining reaches 0 + /// OR when await_reply_target is met. + fn advance_step(&mut self) { + self.step_idx += 1; + self.enter_step(); + } + + fn push_capture(&mut self, c: Capture) { + if self.captures.len() >= CAPTURE_RING_CAP { + // Drop oldest; bounded ring. + self.captures.pop_front(); + } + self.captures.push_back(c); + } + + /// What the current step's target is (for AwaitReply). + fn current_step_target(&self) -> u32 { self.await_reply_target } + fn is_in_await_reply_step(&self) -> bool { + matches!(self.scenario.steps.get(self.step_idx), Some(ScenarioStep::AwaitReply { .. })) + } +} + +impl AudioSource for SimAudioPipe { + fn next_pcm_frame(&mut self) -> Option { + match self.reply_ring.pop_front() { + Some(frame) => { + if self.is_in_await_reply_step() { + // Count this reply toward await_reply_target; advance if met. + // (await_reply_target counts "how many reply frames to receive + // before advancing" — used by the barrier semantics in spec §3.1.) + self.await_reply_target = self.await_reply_target.saturating_sub(1); + if self.await_reply_target == 0 { + self.advance_step(); + } + } + self.push_capture(Capture::CallerHeardReply { at: Instant::now() }); + Some(frame) + } + None => { + // Empty reply_ring: the reflex muted us (slice-4 §3.2 state machine). + // Capture BargeKillObserved — the LatencyProbe pairs this with the + // most recent CallerLoudOnset for the kill-time metric. + self.push_capture(Capture::BargeKillObserved { at: Instant::now() }); + None + } + } + } +} + +impl AudioSink for SimAudioPipe { + fn on_pcm_frame(&mut self, _frame: PcmFrame) { + // The caller "speaks." The scenario drives here: each on_pcm_frame + // call advances the current step's counter. + if self.step_idx >= self.scenario.steps.len() { + return; // post-End; no-op. + } + let advance = match &self.scenario.steps[self.step_idx] { + ScenarioStep::SpeakLoud { .. } | ScenarioStep::SpeakQuiet { .. } | ScenarioStep::Pause { .. } => { + self.step_frames_remaining = self.step_frames_remaining.saturating_sub(1); + self.step_frames_remaining == 0 + } + ScenarioStep::AwaitReply { .. } => false, // await_reply advances via next_pcm_frame + ScenarioStep::End => false, + }; + if advance { + self.advance_step(); + } + } +} + +impl AudioPipe for SimAudioPipe { + fn clear_playout_ring(&mut self) { self.reply_ring.clear(); } + fn barge_in_flush(&mut self) { self.reply_ring.clear(); } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cargo test -p rutster-sim --lib sim_audio_pipe::tests +cargo test -p rutster-sim --lib +``` + +Expected: PASS (S1's 4 tests + S2's 5 tests = 9 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-sim/src/sim_audio_pipe.rs crates/rutster-sim/src/lib.rs +git commit -s -m "feat(sim): SimAudioPipe + Capture enum (slice-4½ S2) + +The test-double AudioPipe that simulates a caller. Drives a Scenario on +on_pcm_frame (sink: caller speaks); receives brain replies on +next_pcm_frame (source: caller hears). Both timestamps anchored to +Instant::now() inside this pipe — the harness can't lie about latency +because the only clock it uses is the caller's (spec §2.2). + +LatencyProbe (S3) consumes the Capture stream post-run." +``` + +--- + +### Task S3: `LatencyProbe` + +**Files:** +- Modify: `crates/rutster-sim/src/latency.rs` (replace stub) +- Test: inline + +**Interfaces:** +- Consumes: `Capture` enum (from S2). +- Produces: `pub struct LatencyProbe { captures: Vec }`, with `pub fn from_captures`, `pub fn kill_times`, `pub fn mouth_to_ear_times`, `pub fn p50_kill_ms`, `pub fn p99_kill_ms`, `pub fn p50_mouth_to_ear_ms`, `pub fn p99_mouth_to_ear_ms`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::sim_audio_pipe::Capture; + use std::time::{Duration, Instant}; + + #[test] + fn kill_times_empty_for_no_captures() { + let p = LatencyProbe::from_captures(vec![]); + assert!(p.kill_times().is_empty()); + assert!(p.p99_kill_ms().is_nan()); // empty => NaN + } + + #[test] + fn kill_times_pairs_onset_with_next_barge_kill() { + let t0 = Instant::now(); + let captures = vec![ + Capture::CallerLoudOnset { at: t0 }, + Capture::BargeKillObserved { at: t0 + Duration::from_millis(50) }, + ]; + let p = LatencyProbe::from_captures(captures); + let kills = p.kill_times(); + assert_eq!(kills.len(), 1); + assert_eq!(kills[0].as_millis(), 50); + } + + #[test] + fn mouth_to_ear_times_pairs_onset_with_next_reply() { + let t0 = Instant::now(); + let captures = vec![ + Capture::CallerLoudOnset { at: t0 }, + Capture::CallerHeardReply { at: t0 + Duration::from_millis(200) }, + ]; + let p = LatencyProbe::from_captures(captures); + let m2e = p.mouth_to_ear_times(); + assert_eq!(m2e.len(), 1); + assert_eq!(m2e[0].as_millis(), 200); + } + + #[test] + fn p99_returns_higher_than_p50_with_outlier() { + let t0 = Instant::now(); + let mut captures = vec![]; + for ms in [50u64, 55, 60, 65, 200] { // 4 normal + 1 outlier + captures.push(Capture::CallerLoudOnset { at: t0 }); + captures.push(Capture::BargeKillObserved { at: t0 + Duration::from_millis(ms) }); + } + let p = LatencyProbe::from_captures(captures); + assert!(p.p99_kill_ms() > p.p50_kill_ms(), "p99 > p50 with outlier"); + assert!(p.p50_kill_ms() <= 65.0, "p50 = median"); + } +} +``` + +- [ ] **Step 2: Run to verify fail + Step 3: Implement + Step 4: Verify pass** + +The implementation mirrors spec §3.3 verbatim. Use `Vec`, pairings done via linear +scan with cursor-on-onset. Output: `Vec`. + +Function skeleton: + +```rust +//! # LatencyProbe — the post-hoc metric computer (spec §3.3) +//! +//! Consumes a vector of `Capture` events from a `SimAudioPipe` and +//! computes the two p50/p99 metrics the threshold gates assert against: +//! +//! - barge-in kill-time: caller-speech-onset → first `BargeKillObserved` +//! - mouth-to-ear: caller-speech-onset → next `CallerHeardReply` + +use std::time::{Duration, Instant}; + +use crate::sim_audio_pipe::Capture; + +pub struct LatencyProbe { + captures: Vec, +} + +impl LatencyProbe { + pub fn from_captures(captures: Vec) -> Self { + Self { captures } + } + + /// Barge-in kill-times: pair each `CallerLoudOnset` with the next + /// `BargeKillObserved` thereafter. Per-call measurement. + pub fn kill_times(&self) -> Vec { + let mut out = vec![]; + let mut last_onset: Option = None; + for c in &self.captures { + match c { + Capture::CallerLoudOnset { at } => last_onset = Some(*at), + Capture::BargeKillObserved { at } => { + if let Some(on) = last_onset.take() { + out.push(at.saturating_duration_since(on)); + } + // (Else: kill without prior onset — ignore; spray noise.) + } + Capture::CallerHeardReply { .. } => { /* irrelevant to kill metric */ } + } + } + out + } + + pub fn mouth_to_ear_times(&self) -> Vec { + let mut out = vec![]; + let mut last_onset: Option = None; + for c in &self.captures { + match c { + Capture::CallerLoudOnset { at } => last_onset = Some(*at), + Capture::CallerHeardReply { at } => { + if let Some(on) = last_onset.take() { + out.push(at.saturating_duration_since(on)); + } + } + Capture::BargeKillObserved { .. } => { /* irrelevant to m2e */ } + } + } + out + } + + pub fn p50_kill_ms(&self) -> f64 { percentile_ms(&self.kill_times(), 50) } + pub fn p99_kill_ms(&self) -> f64 { percentile_ms(&self.kill_times(), 99) } + pub fn p50_mouth_to_ear_ms(&self) -> f64 { percentile_ms(&self.mouth_to_ear_times(), 50) } + pub fn p99_mouth_to_ear_ms(&self) -> f64 { percentile_ms(&self.mouth_to_ear_times(), 99) } +} + +fn percentile_ms(durations: &[Duration], pct: u8) -> f64 { + if durations.is_empty() { return f64::NAN; } + let mut sorted: Vec = durations.iter().map(|d| d.as_millis()).collect(); + sorted.sort_unstable(); + let idx = ((sorted.len() as f64 - 1.0) * (pct as f64 / 100.0)).round() as usize; + sorted[idx] as f64 +} +``` + +- [ ] **Step 5: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git add crates/rutster-sim/src/latency.rs +git commit -s -m "feat(sim): LatencyProbe — p50/p99 kill + mouth-to-ear (slice-4½ S3) + +Pairs Capture::CallerLoudOnset with the next BargeKillObserved +(kill-time) and the next CallerHeardReply (mouth-to-ear). Outputs are +Duration vectors; p50/p99 helpers compute on the captured sample. The +threshold assertions in S7 read p99_kill_ms + p99_mouth_to_ear_ms." +``` + +--- + +### Task S4: `SimCall` + `ScenarioRunner` + +**Files:** +- Modify: `crates/rutster-sim/src/runner.rs` (replace stub) +- Test: inline + +**Interfaces:** +- Consumes: `SimAudioPipe` (S2) + `MediaThread` / `MediaCmd` (merged slice-4 + slice-5/seams). +- Produces: `pub struct SimCall { pipe, media_cmd_tx, ... }` + `impl SimCall { pub async fn run(self) -> LatencyProbe }` + `pub struct ScenarioRunner { ... }`. + +**Approach:** A `SimCall` registers a session via `MediaCmd::RegisterSim { pipe, reply }` +against the existing MediaThread (the harness DOES NOT stand up its own — it consumes the +binary's MediaThread). Then it drives the scenario + captures reply frames. The exact driving +mechanism: a tokio task that, per the SimAudioPipe's internal cursor, mpsc-pumps synthetic +caller PCM into the session's `on_pcm_frame` AND drains the session's `next_pcm_frame` output +back into the SimAudioPipe's `push_reply` channel. + +Wait — this is a wiring question that touches the binary-side wiring of the SimAudioPipe +into MediaThread::RegisterSim. The spec §3.5 says the harness sends `Box` via +the MediaCmd; the MediaThread inserts it as a synthetic session. The session's tick-driving +happens by the same `loop_driver::drive` machinery — but loop_driver::drive expects an +RtcSession (str0m). So the harness has to provide its OWN tick-driving path for the SimPipe. + +Hmm, this is the same fork the step-5 spec surfaced (§4.1 of step-5 spec) but for slice 4½. +Let me revisit: spec §3.5 says "The std thread's `run_media_thread` handles `RegisterSim` by +inserting a synthetic 'session' entry that drives the harness's `SimAudioPipe` through the +same `loop_driver::drive` calls as real WebRTC sessions." But loop_driver::drive takes a +`&mut RtcSession`, NOT a `&mut dyn AudioPipe`. So either the spec is wrong, OR `RegisterSim` +needs its own dispatch path. + +The cleanest fix (parallel to step-5's `MediaLeg` enum solution): introduce `MediaCmd::RegisterSim` +that wraps the SimAudioPipe in a SimSession struct whose `tick(now)` method is +`pipe.on_pcm_frame(synthetic_caller_frame); let _ = pipe.next_pcm_frame();` — but the synthetic +caller frame + reply routing is the SimCall's responsibility, not the SimSession's. + +Actually, the simplest approach the spec implies: `RegisterSim` is a TEST-only path. It doesn't +run through the str0m/loop_driver machinery at all — the MediaThread inserts the SimPipe into +a SEPARATE list (not the same HashMap) and ticks it via a direct call to +`pipe.on_pcm_frame()` + `pipe.next_pcm_frame()` per meta-tick. + +But the spec's intent is "the harness drives the SAME media-leg path real callers use" — +meaning the slice-4 Reflex<...> wrapper stack + the TapEngine spawn. To get THAT, the SimPipe +needs to plug into the session mapleton as a Box just like TapAudioPipe does +in slice-4 Task 6's composition site. + +This is too complex to resolve cleanly in a tactical plan revision. The pragmatic answer: the +SimCall wires it SELF — it constructs the TapAudioPipe + Reflex<...> + LocalVadReflex<...> +composition directly (REUSING slice-4's stack composition code from Task 6, just outside the +MediaThread), drives the wrapped pipe manually via a tight loop, captures brain replies +via mpsc from the TapEngine. + +This means `MediaCmd::RegisterSim` is NOT needed. The SimCall runs entirely in tokio: spawns +its own TapEngine against the MockRealtimeBrain URL, wraps the TapAudioPipe in the Reflex +stack (REUSED), drives the SimAudioPipe's on_pcm_frame (caller speech) by submitting frames +to the inner tap pipe's tx_pcm_in, and feeds brain replies back via the tap pipe's +tx_audio_out drain. + +This is simpler than RegisterSim — no MediaThread extension required, no seam change. + +Update the Spec §3.5 (and §1.1 mentions of RegisterSim) to reflect this decision. For the +plan, the harness wires itself standalone. + +- [ ] **Step 1: Read existing slice-4 Task 6 spawn-composition pattern** + +Read `crates/rutster/src/media_thread.rs` for the existing spawn_tap_engine composition +site (the `Connected` transition that wraps TapAudioPipe in Reflex + LocalVadReflex). The +SimCall mirrors this composition but in tokio, not on the std thread. + +- [ ] **Step 2: Implement `SimCall` + `ScenarioRunner`** + +Pseudocode for the wiring: + +```rust +//! # SimCall — one synthetic caller through the FOB reflex loop +//! +//! See spec §3.4. The SimCall stands up its own TapEngine (REUSED from +//! slice-2) + the slice-4 Reflex<...> composition in tokio, then drives +//! a `SimAudioPipe` against it. The measurement captures live inside +//! the SimAudioPipe (the caller's clock — spec §2.2). + +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use rutster_media::{PcmFrame, Reflex, LocalVadReflex, ReflexMetrics, AdvisoryEvent}; +use rutster_tap::TapAudioPipe; +use crate::scenario::Scenario; +use crate::sim_audio_pipe::SimAudioPipe; +use crate::latency::LatencyProbe; + +pub struct SimCall { + scenario: Scenario, + /// The brain's WS URL (e.g. MockRealtimeBrain URL or a real brain WS). + brain_url: url::Url, +} + +impl SimCall { + pub fn new(scenario: Scenario, brain_url: url::Url) -> Self { + Self { scenario, brain_url } + } + + /// Drive the scenario against the FOB reflex loop. Returns the + /// LatencyProbe with the captured timeline. + pub async fn run(self) -> LatencyProbe { + // 1. Construct the TapAudioPipe + spawn_tap_engine against brain_url. + // (Reuses slice-2/slice-4's spawn_tap_engine wiring; capture the + // returned TapConn; the advisory_tx/Reflex stack wires as slice-4 + // Task 5 + Task 6 composition.) + let (tap_pipe, tap_conn) /* = spawn_tap_engine(...) */; + let (advisory_tx, advisory_rx) = mpsc::channel::(16); + let metrics = ReflexMetrics::new(); + let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone()); + let wrapped_pipe = LocalVadReflex::new(reflex, advisory_tx); + + // 2. The SimAudioPipe drives the scenario's caller side; the wrapped_pipe + // is the FOB-brain side they interact with. + let mut sim_pipe = SimAudioPipe::new(self.scenario.clone(), 16); + + // 3. Drive loop: per 20 ms tick: + // a. If the SimPipe's scenario says "speak loud" → call wrapped_pipe + // .on_pcm_frame(PcmFrame::loud()) — that hits LocalVadReflex → + // Reflex → TapAudioPipe → tx_pcm_in → brain WS. + // b. Pull the wrapped_pipe's next_pcm_frame() output → push to + // sim_pipe.push_reply(frame) — that's the brain's reply landing + // at the caller's ear (the SimPipe's source path). + // c. (Sink path: the wrapped_pipe itself produces PCM frames the + // TapEngine sends to the brain. The SimPipe's reply_ring is + // filled manually with what the wrapped_pipe produces — that's + // the simulation of "what the FOB's media loop would have + // returned to the WebRTC peer.") + let tick = Duration::from_millis(20); + loop { + // Drive the sink: emit the next caller frame. + // (The SimPipe's on_pcm_frame is what determines caller behavior.) + // We bypass calling SimPipe::on_pcm_frame in this loop — + // its on_pcm_frame is the SINK path consumed INTERNALLY when + // the wrapped_pipe's on_pcm_frame routes back into it. + // + // Per spec §3.2.1: sim_pipe.on_pcm_frame is for the harness to + // feed caller-side signals. We use it to read scenario state: + // the SimPipe emits CallerLoudOnset on step transition; we don't + // need to actually push PcmFrames into it because its job is to + // TIME the scenario steps + capture timestamps. + + // For "speak loud" step: emit a loud PcmFrame to the wrapped_pipe. + if sim_pipe.current_step_is_speak_loud() { + wrapped_pipe.on_pcm_frame(PcmFrame::loud()); + } + + // Pull the FOB's outbound frame (if any) + feed back to the + // SimPipe's reply_ring. + if let Some(reply) = wrapped_pipe.next_pcm_frame() { + sim_pipe.push_reply(reply); + } + + // Drive the SimPipe's internal state advance (e.g. countdown + // step frames). + sim_pipe.tick(); + + // Termination: scenario reached End. + if sim_pipe.scenario_done() { break; } + + tokio::time::sleep(tick).await; + } + + // 4. Tear down + return the LatencyProbe. + let _ = tap_conn.close_tx.send(()); + tap_conn.join.abort(); + let captures = sim_pipe.take_captures(); + LatencyProbe::from_captures(captures) + } +} + +pub struct ScenarioRunner { + brain_url: url::Url, +} + +impl ScenarioRunner { + pub fn new(brain_url: url::Url) -> Self { Self { brain_url } } + + pub async fn run(&self, scenario: Scenario) -> LatencyProbe { + SimCall::new(scenario, self.brain_url.clone()).run().await + } +} +``` + +(The exact shape — particularly how the SimAudioPipe + the wrapped_slice-4 Reflex stack share +PcmFrames — needs more design than I can resolve in this plan revision. The dev-a implementing +this should: (a) trade off details once they read the slice-4 `media_thread.rs` Connected spawn +seam, (b) emit a STATUS UPDATE if they hit a design fork worth PM input. The KEY invariant is: +captured `Instant::now()` timestamps in SimAudioPipe, slicing through the harness, NOT in the +FOB itself.) + +- [ ] **Step 3: Write tests asserting the SimCall drives a scenario to completion against an + in-process MockRealtimeBrain + measures latency** + +```rust +#[tokio::test] +async fn sim_call_completes_trivial_scenario_against_mock_brain() { + // Stand up MockRealtimeBrain (slice-3 merged). + let mock = MockRealtimeBrain::start().await.unwrap(); + let scenario = Scenario::from_toml(r#" + name = "trivial" + [[steps]] + kind = "speak_loud" + frames = 3 + [[steps]] + kind = "end" + "#).unwrap(); + let probe = SimCall::new(scenario, mock.url()).run().await; + // Probe should have at least one kill_time capture. + let kills = probe.kill_times(); + assert!(!kills.is_empty(), "expected barge-in to fire on 3 loud frames"); +} +``` + +- [ ] **Step 4: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git add crates/rutster-sim/src/runner.rs +git commit -s -m "feat(sim): SimCall + ScenarioRunner — drives scenario against FOB reflex loop (slice-4½ S4) + +SimCall stands up its own TapEngine + composes slice-4's +Reflex + LocalVadReflex stack against it. Drives a SimAudioPipe +through the scenario: emit loud/quiet PcmFrames into the wrapped pipe's sink; +pull next_pcm_frame outputs + push into the SimAudioPipe's reply_ring. The +captured Instant::now() timestamps in the SimAudioPipe are the caller's clock +(spec §2.2) — the harness can't lie about latency." +``` + +--- + +### Task S5: `ConcurrencyRunner` + +**Files:** +- Modify: `crates/rutster-sim/src/concurrency.rs` +- Test: inline + +**Interfaces:** +- Consumes: `SimCall` (S4) + `LatencyProbe` (S3). +- Produces: `pub struct ConcurrencyRunner { ... }` with `pub async fn run(&self, scenario) -> SweepReport` + `pub struct SweepReport { per_concurrency: Vec }` + `pub struct PerConcurrencyReport { ... }`. + +- [ ] **Step 1: Implement the sweep driver** + +For each N ∈ `[1, 10, 50]`: + - Spawn N `SimCall`s against the SAME MockRealtimeBrain URL (or N mock brains — TBD; + prefer ONE mock brain for determinism; the mock is designed to handle multiple WS clients + in its accept loop). + - `tokio::join!`-style await all. + - Aggregate the per-call `LatencyProbe` results into a `PerConcurrencyReport`: + - Collect all kill_times + mouth_to_ear_times across all N calls. + - Compute p50/p99 over the merged sample. + - Return the `SweepReport`. + +Tick-lag gauge: S6 attaches; for S5 alone, the `PerConcurrencyReport` leaves +`max_tick_lag_micros` + `tick_overrun_pct` as 0 (the S6 integration fills them in). + +- [ ] **Step 2: Tests** — `concurrency_run_at_1_produces_report`, `concurrency_run_at_10_reports_10_calls`. + +- [ ] **Step 3: fmt + clippy + test + commit** — message + `feat(sim): ConcurrencyRunner — N concurrent SimCalls + SweepReport aggregation (slice-4½ S5)`. + +--- + +### Task S6: `TickLagGauge` + +**Files:** +- Modify: `crates/rutster-sim/src/tick_lag.rs` +- Test: inline + +**Interfaces:** +- Consumes: slice-5/seams `MediaCmd::Stats` (returns `MediaStats { tick_overruns, last_tick_micros }`). +- Produces: `pub struct TickLagGauge { stats_tx: mpsc::Sender, samples: Vec }` + + `pub struct TickLagSample { at: Instant, last_tick_micros: u64, tick_overruns_cumulative: u64 }` + + `pub fn poll_periodically(&mut self, period: Duration, stop_rx: oneshot::Receiver<()>)`. + +- [ ] **Step 1: Implement the gauge** + +The poll loop: spawn a tokio task that, every `period` (default 1 sec), sends `MediaCmd::Stats` +to the binary's MediaThread, awaits the `MediaStats` reply, pushes the sample into `samples`. +When the SimCall's run loop completes, signal `stop_rx` to terminate the gauge. + +After `run()` returns: drain `samples`; the PerConcurrencyReport's `max_tick_lag_micros` = max +sample's `last_tick_micros`; `tick_overrun_pct` = the differential between the first and last +sample's cumulative overruns, divided by total tick count (computed from the elapsed wallclock +duration × 100 ticks/sec). + +- [ ] **Step 2: Tests** — `gauge_polls_periodically`, `gauge_aggregates_max_tick_lag`, + `gauge_computes_overrun_pct`. + +- [ ] **Step 3: fmt + clippy + test + commit** — message + `feat(sim): TickLagGauge — reads slice-5/seams MediaCmd::Stats during sweep (slice-4½ S6)`. + +--- + +### Task S7: `cargo test --features=sim-bench` CI job + threshold constants + assertion tests + +**Files:** +- Modify: `crates/rutster-sim/src/thresholds.rs` +- Modify: `.github/workflows/ci.yml` +- Test: under `#[cfg(feature = "sim-bench")] #[tokio::test]` in `thresholds.rs` (the CI gate). + +**Interfaces:** +- Consumes: `ConcurrencyRunner` (S5) + `TickLagGauge` (S6). +- Produces: + - `pub const BARGE_IN_KILL_TIME_P99_MS: f64 = 80.0;` + - `pub const MOUTH_TO_EAR_P99_MS: f64 = 700.0;` + - `pub const TICK_LAG_MAX_MS: f64 = 10.0;` + - `pub const TICK_OVERRUN_PCT_MAX: f64 = 1.0;` + - `pub const SWEEP_CONCURRENCIES: &[usize] = &[1, 10, 50];` + - `#[cfg(feature = "sim-bench")] #[tokio::test]` tests asserting per-concurrency thresholds. + +- [ ] **Step 1: Write the threshold consts + the assertion test bodies** + +```rust +// crates/rutster-sim/src/thresholds.rs + +//! CI threshold constants + the assertion tests under `--features=sim-bench`. +//! See spec §5.1 + §5.5. A regression-failing threshold is a Rust `assert!`, +//! not a tracing metric — failure here is a red X on the PR (ADR-0010). + +pub const BARGE_IN_KILL_TIME_P99_MS: f64 = 80.0; +pub const MOUTH_TO_EAR_P99_MS: f64 = 700.0; +pub const TICK_LAG_MAX_MS: f64 = 10.0; +pub const TICK_OVERRUN_PCT_MAX: f64 = 1.0; +pub const SWEEP_CONCURRENCIES: &[usize] = &[1, 10, 50]; + +#[cfg(all(test, feature = "sim-bench"))] +mod bench_assertions { + use super::*; + use crate::concurrency::ConcurrencyRunner; + use crate::scenario::Scenario; + + #[tokio::test] + async fn loud_barge_at_each_concurrency_passes_thresholds() { + let scenario = Scenario::load("../scenarios/loud-barge.toml").unwrap(); + for &n in SWEEP_CONCURRENCIES { + let report = ConcurrencyRunner::in_process(n).run(scenario.clone()).await; + let row = report.per_concurrency.iter() + .find(|r| r.concurrency == n).expect("concurrency row"); + + assert!(row.p99_kill_ms <= BARGE_IN_KILL_TIME_P99_MS, + "p99 kill-time at N={}: {}ms > {}ms (budget overflow)", + n, row.p99_kill_ms, BARGE_IN_KILL_TIME_P99_MS); + assert!(row.p99_mouth_to_ear_ms <= MOUTH_TO_EAR_P99_MS, + "p99 mouth-to-ear at N={}: {}ms > {}ms", + n, row.p99_mouth_to_ear_ms, MOUTH_TO_EAR_P99_MS); + assert!((row.max_tick_lag_micros as f64) / 1000.0 <= TICK_LAG_MAX_MS, + "max tick-lag at N={}: {}us > {}ms", + n, row.max_tick_lag_micros, TICK_LAG_MAX_MS); + assert!(row.tick_overrun_pct <= TICK_OVERRUN_PCT_MAX, + "tick overrun % at N={}: {}% > {}%", + n, row.tick_overrun_pct, TICK_OVERRUN_PCT_MAX); + } + } + + #[tokio::test] + async fn quiet_advisory_at_1_concurrency_passes_thresholds() { + let scenario = Scenario::load("../scenarios/quiet-advisory.toml").unwrap(); + let report = ConcurrencyRunner::in_process(1).run(scenario).await; + let row = &report.per_concurrency[0]; + // Advisory-path: kill-time can be longer than local-VAD (brain round-trip ~300ms). + // Use a relaxed ceiling for advisory kills: + assert!(row.p99_kill_ms <= 400.0, // ~brain advisory latency + slack + "advisory kill-time {}ms > 400ms", row.p99_kill_ms); + } + + #[tokio::test] + async fn sustained_call_multibarge_does_not_drift() { + let scenario = Scenario::load("../scenarios/sustained-call.toml").unwrap(); + let report = ConcurrencyRunner::in_process(1).run(scenario).await; + let row = &report.per_concurrency[0]; + // 3-barge drift check: the per-barge kill_times should be within 1.5× + // of each other (anti-fatigue). + // (This test relies on the LatencyProbe surfacing per-barge captures in + // sequence; the assertion reads them in temporal order.) + // Implementation note: kill_times.len() should be >= 3; the third bar's + // kill-time should be <= 1.5× the first's. The exact bound is "drift detect" + // — defensible threshold per spec §7.9. + } +} +``` + +- [ ] **Step 2: Add the CI job to `.github/workflows/ci.yml`** + +```yaml + sim-bench: + name: sim-bench (stable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install libopus + run: sudo apt-get update && sudo apt-get install -y libopus-dev + - name: Run sim-bench threshold sweep + run: cargo test --all --features=sim-bench -- --test-threads=1 +``` + +`--test-threads=1` is load-bearing — concurrent sim-bench tests would contaminate each other's +`MediaStats` polling (the tick-lag gauge measures the SHARED media thread; concurrent runs of +the sweep would all see each other's load). + +- [ ] **Step 3: Run locally** `cargo test --all --features=sim-bench -- --test-threads=1` and + verify it passes. If a threshold fails: investigate whether it's an actual regression + OR a budget too-tight for the CI runner's variance. If the latter: surface as `question` + to PM (relay) with the failed number + the proposed adjustment; do NOT just bump it + silently. + +- [ ] **Step 4: fmt + clippy + test + commit**: +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings # note: clippy on sim-bench code paths needs --features=sim-bench +cargo clippy --all --all-targets --features=sim-bench -- -D warnings +cargo test --all +cargo test --all --features=sim-bench -- --test-threads=1 +git add crates/rutster-sim/src/thresholds.rs .github/workflows/ci.yml +git commit -s -m "ci(sim): sim-bench CI job + threshold consts + assertion tests (slice-4½ S7) + +cargo test --all --features=sim-bench runs the threshold-assertion sweep; +a separate CI job executes it on every PR + nightly. A latency regression +fails the build the same way a broken test does (ADR-0010). Single-threaded +test execution to avoid MediaStats cross-contamination across concurrent runs." +``` + +--- + +### Task S8: Scenario pack + LEARNING.md pointer (filler task: any time after S4) + +**Files:** +- Create: `crates/rutster-sim/scenarios/loud-barge.toml` +- Create: `crates/rutster-sim/scenarios/quiet-advisory.toml` +- Create: `crates/rutster-sim/scenarios/sustained-call.toml` +- Modify: `LEARNING.md` + +- [ ] **Step 1: Author the three scenario TOMLs** + +Each scenario asserts a different property of the FOB reflex loop. Per spec §5.3. + +- [ ] **Step 2: Add a LEARNING.md pointer** + +```markdown +## Slice 4½ (benchmark + simulation harness) + +To learn how the harness measures latency without lying to itself: +- ([`crates/rutster-sim/src/sim_audio_pipe.rs`](crates/rutster-sim/src/sim_audio_pipe.rs)) + — the AudioPipe that IS the caller; captures both clocks. +- ([`crates/rutster-sim/src/latency.rs`](crates/rutster-sim/src/latency.rs)) + — the post-hoc p50/p99 computer. +- ([`crates/rutster-sim/src/concurrency.rs`](crates/rutster-sim/src/concurrency.rs)) + — the 1/10/50 sweep + doctrine-drift detector for the timing-thread debt. +``` + +- [ ] **Step 3: fmt + test + commit**: +```bash +cargo fmt --all --check && cargo test --all +git add crates/rutster-sim/scenarios/ LEARNING.md +git commit -s -m "docs(sim): scenario pack + LEARNING.md pointers (slice-4½ S8) + +Three shipped scenarios assert distinct FOB reflex properties: loud-barge +(primary VAD path), quiet-advisory (secondary brain-advisory path), and +sustained-call (multi-barge anti-fatigue). LEARNING.md indexes the new +crate's measurement-discipline curriculum." +``` + +--- + +## Final acceptance checklist + +After all 8 tasks merge: +- [ ] `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all`, + `cargo deny check` all clean (stable + 1.85). +- [ ] `cargo test --all --features=sim-bench` clean on stable. +- [ ] Seam gate unchanged: `loop_driver.rs` + `rtc_session.rs` byte-identical (CI pinned-blob). +- [ ] `cargo doc --no-deps` renders the new `crates/rutster-sim/` modules cleanly. +- [ ] All 3 shipped scenarios pass their threshold assertions across 1/10/50 concurrency. +- [ ] PR opened via `tea pulls create --head slice-4-half/sim-harness-dev-a --base main --title + "slice-4½: benchmark + sim harness — rutster-sim seed + CI-regressed thresholds (S1-S8)" + --description ""`. +- [ ] Do NOT merge the PR — the maintainer (user) merges after reviewing the sim-bench CI run's + numbers on the runner. diff --git a/docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md b/docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md new file mode 100644 index 0000000..f01b796 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-slice-5-rented-transport.md @@ -0,0 +1,1078 @@ +# Slice 5 — Rented transport: a real phone number via Twilio Media Streams — 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 5 — a real phone number with no first-party SIP stack. Twilio +Media Streams forks the PSTN audio over WSS to rutster; call control is Twilio's REST API +(green-zone, the brain never sees credentials). The PSTN leg enters the same FOB reflex loop +WebRTC legs use — same `Reflex` + `LocalVadReflex` composition (slice-4 REUSED +unchanged), same `MediaThread` tick loop, same barge-in semantics. + +**Architecture:** `crates/rutster-trunk/` (currently a stub; ADR-0007 pre-paved the boundary) +fills in with: `G711Codec` (in-core µ-law encode/decode + 8 kHz↔24 kHz linear-interpolated +resampling), `TwilioMediaStreamsServer` (axum WSS endpoint), `TrunkSession` + a parallel +`trunk_driver::drive` function (in the trunk crate, NOT in `crates/rutster-media/src/loop_driver.rs` +— the seam holds), `CallControlClient` trait + `MockCallControlClient` + (feature-gated) +`TwilioCallControlClient`. The binary-side `MediaThread` gains a `MediaLeg` enum +(`WebRTC(RtcSession) | Trunk(TrunkSession)`) + a new `MediaCmd::RegisterTrunk` variant; the +tick loop dispatches via the enum. + +**Tech Stack:** Rust stable + 1.85 (CI matrix), the existing workspace, plus `async-trait` + +`reqwest` (already workspace members per slice-2's TapClient WS pattern; reused). No other new deps. + +## Global Constraints + +- **License:** GPL-3.0-or-later on every crate manifest (ADR-0004). +- **DCO:** every commit signed off — `git commit -s` (AGENTS.md Git workflow). +- **Seam gate (UNCHANGED):** `crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` + stay byte-identical (CI pinned-blob from slice-4 Task 10 continuously enforced). The new trunk + driver lives in `crates/rutster-trunk/src/loop_driver.rs` — a parallel-titled file in a different + crate. The `MediaLeg` match-arm that calls `loop_driver::drive(s, now)` for WebRTC legs is one + new line in `crates/rutster/src/media_thread.rs` (the existing call to `loop_driver::drive` + routes through it); the `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` arm is its sibling. +- **Hot-path policy:** never `?`-propagate on the 20 ms tick; match-and-continue; + "drop + observe." No `unwrap()`/`expect()` outside tests/const-init. +- **Credential isolation (ADR-0009, load-bearing):** `TwilioCredentials` lives ONLY in + `crates/rutster-trunk/`. NOT re-exported through the workspace. NOT in `rutster-media`'s + public API. NOT in `rutster-tap`'s public API. NOT in the brain's WS protocol. The brain + never sees account SID, auth_token, REST endpoint URL, OR CallSid (CallSid is operational + correlation ID for the FOB's logs only). +- **Code style:** `cargo fmt` is the single whitespace source of truth. `clippy -D warnings` is + the lint bar. Newtype wrappers per AGENTS.md where applicable (none intrinsically needed + this slice — `CallSid: String` is operational correlation, not a type-safety boundary). +- **Naming:** `snake_case` fns/vars/modules; `PascalCase` types; `UPPER_SNAKE_CASE` consts. +- **Learner-facing comments:** per AGENTS.md code style. µ-law codec comments TEACH the + companding formula — this is exactly the kind of telephony-history the project's "learns Rust + from this codebase" goal benefits from. +- **CI gates:** `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (with + AND without `--features=twilio-live`), `cargo test --all` (default-features off — Mock runs + in CI), `cargo test --all --features=twilio-live` (LOCAL ONLY — maintainer runs when validating + a release), `cargo deny check`. +- **Branch/PR:** branches `slice-5/rented-transport-dev-b` (T2+T6+T9+T10) + `slice-5/rented-transport-dev-c` + (T1+T3+T4+T5+T7+T8) per strategic plan §4.1; PR via `tea`. + +## File Structure + +### New files + +| Path | Responsibility | +|---|---| +| `crates/rutster-trunk/src/g711.rs` | `G711Codec` — µ-law encode/decode tables + 8 kHz↔24 kHz linear-interpolated resampling. Generates `mulaw_decode_table.rs` + `mulaw_encode_table.rs` as build-time `include!`s OR as compile-time-computed `const` arrays. | +| `crates/rutster-trunk/src/mulaw_decode_table.rs` | 256-element `[i16; 256]` decode table, generated from the ITU-T G.711 formula. (Author writes the generator snippet in a comment; the table itself is the compiled output.) | +| `crates/rutster-trunk/src/mulaw_encode_table.rs` | 65536-element `[u8; 65536]` encode table, same generator pattern. | +| `crates/rutster-trunk/src/twilio_media_streams.rs` | `TwilioMediaStreamsServer` — axum WS handler accepting Twilio Media Streams forks; parses JSON envelope (`connected` / `start` / `media` / `stop`); ferries decoded PCM frames into a per-call mpsc + drains the outbound mpsc to send back. | +| `crates/rutster-trunk/src/session.rs` | `TrunkSession` — the per-trunk-leg session struct (parallels slice-1's `RtcSession` minus str0m/Opus/UDP). | +| `crates/rutster-trunk/src/loop_driver.rs` | `trunk_driver::drive(&mut TrunkSession, now) -> Option` — the trunk-leg tick function. Parallels `crates/rutster-media/src/loop_driver.rs` minus the str0m/Opus/RTP machinery. | +| `crates/rutster-trunk/src/provider/mod.rs` | `CallControlClient` trait + `CallControlError` + `SpendToken` placeholder (pre-paved seam for step 6). | +| `crates/rutster-trunk/src/provider/mock.rs` | `MockCallControlClient` — in-process test double; CI gate uses this. | +| `crates/rutster-trunk/src/provider/twilio.rs` | `TwilioCallControlClient` — REST client via reqwest (feature-gated behind `twilio-live`; NOT compiled in CI default). | +| `crates/rutster-trunk/tests/reflex_on_trunk.rs` | Reflex-on-trunk-leg verification test (T7): proves `Reflex` + `LocalVadReflex` (slice-4, REUSED) decorate the trunk leg's TapAudioPipe identically to a WebRTC leg. Barge-in fires. | +| `crates/rutster-trunk/tests/sim_integ.rs` | PSTN sim e2e integration test (T8): `MockCallControlClient` + `MockTwilioMediaStreamsServer` → MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → CDR/EventSink emission. | + +### Modified files + +| Path | What changes | +|---|---| +| `crates/rutster-trunk/Cargo.toml` | Add deps: `rutster-media`, `rutster`, `rutster-call-model`, `rutster-tap`, `tokio`, `axum`, `reqwest`, `async-trait`, `serde`, `serde_json`, `tracing`, `thiserror`, `base64`, `url`. Add features: `default = []`, `twilio-live = []` (gates `provider/twilio.rs`). | +| `crates/rutster-trunk/src/lib.rs` | Replace stub doc-comment-only with `pub mod` declarations + re-exports per spec §3. | +| `crates/rutster/src/media_thread.rs` | Add `MediaLeg` enum + `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` variant + the std-thread match-arm dispatch: `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` (UNCHANGED) vs `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` (NEW). The session map's value type changes from `RtcSession` to `MediaLeg`. | +| `crates/rutster/src/main.rs` | Construct + mount `TwilioMediaStreamsServer::router(register_tx)` on the existing axum router. Routes `/twilio/media-stream` (WS), `POST /v1/trunk/sessions`, `POST /v1/trunk/webhook` (T5). | +| `crates/rutster/src/routes.rs` | Add the `POST /v1/trunk/sessions` (originate) + `POST /v1/trunk/webhook` (Twilio callback receiver) handlers. Handlers route via `CallControlClient` (green-zone). | +| `crates/rutster/src/config.rs` | Add `pub fn twilio_credentials() -> Option` env parser per slice-5/seams pattern (`RUTSTER_TWILIO_ACCOUNT_SID`, `RUTSTER_TWILIO_AUTH_TOKEN`, `RUTSTER_TWILIO_MEDIA_BIND`, `RUTSTER_TWILIO_WEBHOOK_BASE`). | +| `crates/rutster/src/lib.rs` | `pub mod twilio_credentials;` re-export OR fold into existing `config`. (Prefer: keep `TwilioCredentials` in `rutster-trunk` + import via `crates/rutster/src/config.rs::twilio_credentials()`.) | +| `.github/workflows/ci.yml` | Add a `twilio-live` job (runs locally only, NOT on every PR — gated by a manual trigger). The routine CI job stays unchanged (no `--features=twilio-live`). | +| `docs/QUICKSTART.md` | Add env-var table for `RUTSTER_TWILIO_*` env vars + a "make a real phone call" walkthrough (with `--features=twilio-live`). | +| `README.md` | Update the spearhead status line: slice-1–4 + 4½ + slice-5 all green; "make a real phone call via Twilio Media Streams" linked from the Quickstart. | +| `deny.toml` | Add `reqwest`'s transitive deps to the allowlist if `cargo deny check` flags any (most are already admitted via existing transitive deps). | + +### SEAM-INVARIANT files (DO NOT TOUCH) + +- `crates/rutster-media/src/loop_driver.rs` — byte-identical, all tasks. +- `crates/rutster-media/src/rtc_session.rs` — byte-identical, all tasks. + +## Task ordering (for Kimi-worker subagent dispatch) + +Per strategic plan §3.2 + §4.1: **dev-c owns the FOB media-streams chain (T1→T3→T4→T5→T7→T8)**, +**dev-b owns the green-zone / provider + docs + final sweep chain (T2→T6→T9→T10)**. Both +devs work in their own worktrees + branches. dev-b can start T2 in parallel with dev-c on T1 +— the provider trait doesn't depend on the codec's internals (separate modules). + +- **T1** — CRITICAL-PATH FOUNDATION for dev-c. `G711Codec` + decode/encode tables. Lands first + on the dev-c chain; T3 + T4 consume it. NOT parallelizable across devs (single owner). +- **T2** — FOUNDATION for dev-b. `CallControlClient` trait + `MockCallControlClient` + + `TwilioCredentials` + env parser. PARALLEL with T1 (different module). +- **T3** — depends on T1 (consumes `G711Codec`). `TwilioMediaStreamsServer`. +- **T4** — depends on T3 (consumes the WSS server's mpsc wiring). `TrunkSession` + + `trunk_driver::drive`. +- **T5** — depends on T4 (TrunkSession is real) + T2 (CallControlClient for the route handlers). + `MediaCmd::RegisterTrunk` + `MediaLeg` enum + the two new HTTP routes. +- **T6** — depends on T2. `TwilioCallControlClient` (live REST impl behind `twilio-live` feature) + + config env parser. +- **T7** — depends on T4 + T5. Reflex-on-trunk-leg verification test. +- **T8** — depends on T5 + T6 + T7. PSTN-sim e2e integration test. +- **T9** — filler after T5 + T6 land. QUICKSTART + README updates. +- **T10** — final sweep depends on T6 (the `reqwest` dep). Cargo-deny + CI gate. + +Parallelizable-now filler: LEARNING.md pointers (after T1 + T4 land), `cargo doc` rendering +checks (after the crate skeleton stabilizes). + +--- + +### Task T1: `G711Codec` + µ-law decode/encode tables — the dev-c critical-path foundation + +**Files:** +- Create: `crates/rutster-trunk/Cargo.toml` (rewrite the stub — add deps) +- Create: `crates/rutster-trunk/src/g711.rs` +- Create: `crates/rutster-trunk/src/mulaw_decode_table.rs` +- Create: `crates/rutster-trunk/src/mulaw_encode_table.rs` +- Modify: `crates/rutster-trunk/src/lib.rs` (add `pub mod g711;`) +- Test: inline `#[cfg(test)] mod tests` in `g711.rs` + +**Interfaces:** +- Consumes: `rutster_media::PcmFrame` (the 24 kHz canonical format from slice-1). +- Produces: + - `pub struct G711Codec;` (no state — pure static-method form for MVP) + - `pub fn G711Codec::decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame` + - `pub fn G711Codec::encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec` + +- [ ] **Step 1: Author the `Cargo.toml` with deps** + +```toml +# crates/rutster-trunk/Cargo.toml +[package] +name = "rutster-trunk" +version = "0.0.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)." + +[dependencies] +rutster-media = { path = "../rutster-media" } +rutster-call-model = { path = "../rutster-call-model" } +rutster-tap = { path = "../rutster-tap" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } +axum = { workspace = true } +async-trait = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +base64 = { workspace = true } +url = { workspace = true } + +[features] +default = [] +# The live Twilio CallControlClient is feature-gated; CI default-features-off +# keeps the routine gate clean (Mock runs in CI). Maintainer runs +# `cargo test --features=twilio-live` + the e2e suite when validating a release. +twilio-live = [] +``` + +(If `reqwest`/`base64`/`async-trait` aren't yet workspace deps in root `Cargo.toml`, add them +under `[workspace.dependencies]` mirroring slice-2's TapClient wiring. The dev-c +implementer should check first: `grep '^reqwest\|^base64\|^async-trait' Cargo.toml`.) + +- [ ] **Step 2: Author the two lookup tables** + +`crates/rutster-trunk/src/mulaw_decode_table.rs`: + +```rust +//! 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T G.711 +//! standard piecewise-linear decoding formula. See `g711.rs` for the +//! formula's explanation. Hand-verified against the ITU-T spec tables. +//! +//! The table is the actual compiled output of: +//! fn mulaw_to_linear(u: u8) -> i16 { /* G.711 formula */ } +//! Run once at maintainer time; pasted here as a const array. +//! +//! # Generated-by-note +//! +//! To regenerate: `cargo test --features=regen-tables` (hypothetical; +//! the table was authored once and is versioned as a constant — there is +//! no regen task in MVP). + +#[rustfmt::skip] +pub static MULAW_TO_LINEAR: [i16; 256] = [ + /* generated table - 256 entries */ + // FIXME (implementer): paste the 256 i16 values from the G.711 standard + // table. Authoritative source: ITU-T G.711 (1972, reaffirmed 2000). + // The formula: bias=0x84, clip=32635; let sign = u & 0x80; let exponent = (u >> 4) & 0x07; + // let mantissa = u & 0x0F; let sample = ((mantissa << 3) + bias) << exponent; + // sample = if sample > clip { clip } else { sample }; + // return if sign != 0 { -(sample) } else { sample } as i16; + // (Implementer note: write a one-shot test that produces the table; the + // values ARE the table. The test becomes the documentation.) + 0, 0, /* ... 254 more entries ... */ 0, +]; +``` + +Actually — the simpler path: write a `const fn decode_one(u: u8) -> i16` per the formula + +build the table at compile time. That's cleaner + doesn't require regenerating a literal +list. **Recommended path: use a `const fn` + `const` array initialization.** + +```rust +// crates/rutster-trunk/src/g711.rs (table approach via const fn) + +/// The ITU-T G.711 µ-law to linear decoding formula. Const fn so the +/// table is computed at compile time (zero runtime cost). +const fn mulaw_decode_one(u: u8) -> i16 { + let bias: i32 = 0x84; + let clip: i32 = 32635; + let sign = (u & 0x80) != 0; + let exponent = ((u >> 4) & 0x07) as i32; + let mantissa = (u & 0x0F) as i32; + let mut sample = ((mantissa << 3) + bias) << exponent; + if sample > clip { sample = clip; } + if sign { (-(sample)) as i16 } else { sample as i16 } +} + +pub static MULAW_TO_LINEAR: [i16; 256] = { + let mut t = [0i16; 256]; + let mut i = 0; + while i < 256 { + t[i] = mulaw_decode_one(i as u8); + i += 1; + } + t +}; +``` + +For the inverse direction (linear→µ-law), the table is 65536 elements = 64 KB binary size. Author +the encode table as `const fn`: + +```rust +const fn linear_to_mulaw_one(s: i16) -> u8 { + // ITU-T G.711's reference encode formula. See ITU-T G.711 §2.2. + // Pseudocode (from the spec, adapted to Rust): + let bias: i32 = 0x84; + let clip: i32 = 32635; + let s = if s < 0 { -s.max(-(clip + 1)) } else { s.min(clip) }; + let s_bias = s + bias; + let sign = if s < 0 { 0x80 } else { 0x00 }; + // Find the highest set bit position (encoded as exponent). + let exponent = (s_bias.leading_zeros() as i32).wrapping_sub(1).max(0).min(7); + let mantissa = ((s_bias >> (exponent + 3)) & 0x0F) as u8; + let u = !(sign as u8 | ((exponent as u8) << 4) | mantissa); + u +} +``` + +(The formula above is a sketch — the implementer MUST cross-verify against the ITU-T G.711 +spec's reference C code, which is widely available. The correctness test in Step 5 +cross-checks encode(decode(x)) == x for every byte value.) + +- [ ] **Step 3: Author `g711.rs` with `decode_mulaw_to_pcm` + `encode_pcm_to_mulaw`** + +The bodies mirror spec §3.1 verbatim — 3× linear upsample + 3× decimation downsample. Don't +invent new code. + +- [ ] **Step 4: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use rutster_media::PcmFrame; + + #[test] + fn decode_then_encode_round_trips_a_loud_signal() { + // Hand-craft a 24 kHz PcmFrame with a loud sample → encode to µ-law → + // decode back → assert energy is preserved within 12% (µ-law's intrinsic + // quantization error floor). + let mut frame = PcmFrame::zeroed(); + for s in frame.samples.iter_mut() { *s = 10_000; } + let mulaw = G711Codec::encode_pcm_to_mulaw(&frame); + assert_eq!(mulaw.len(), 160); + let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw); + // Energy invariant: decoded RMS should be within 12% of the original. + let orig_rms = rms(&frame.samples); + let dec_rms = rms(&decoded.samples); + let drift = (dec_rms - orig_rms).abs() / orig_rms.max(1.0); + assert!(drift <= 0.12, "µ-law round-trip energy drift {}% > 12%", drift * 100.0); + } + + #[test] + fn decode_160_byte_frame_yields_480_samples() { + let mulaw = vec![0u8; 160]; // 160 µ-law bytes (silence) + let frame = G711Codec::decode_mulaw_to_pcm(&mulaw); + assert_eq!(frame.samples.len(), 480); + } + + fn rms(samples: &[i16]) -> f64 { + let sum_sq: u64 = samples.iter().map(|&s| (s as i64 * s as i64) as u64).sum(); + (sum_sq as f64 / samples.len() as f64).sqrt() + } +} +``` + +- [ ] **Step 5: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git add crates/rutster-trunk/Cargo.toml crates/rutster-trunk/src/{g711.rs,mulaw_decode_table.rs,mulaw_encode_table.rs,lib.rs} +git commit -s -m "feat(trunk): G711Codec — µ-law encode/decode + 8kHz↔24kHz linear-interpolated resampling (slice-5 T1) + +In-core ~30-line table-driven codec (no dep). The ITU-T G.711 µ-law +companding formula is a piece of telephony history worth teaching (AGENTS.md +learner-facing comment mandate). 3× linear upsample on decode; 3× decimation +downsample on encode. The resampler artifacts are below the barge-in trigger +threshold (LocalVadReflex only needs RMS energy); rubato lands in a post- +spearhead refinement if a downstream consumer needs better (spec §6.6). + +Task T1 of slice-5 — T3 (TwilioMediaStreamsServer) consumes this codec." +``` + +--- + +### Task T2: `CallControlClient` trait + `MockCallControlClient` + `TwilioCredentials` config + +**Files:** +- Create: `crates/rutster-trunk/src/provider/mod.rs` +- Create: `crates/rutster-trunk/src/provider/mock.rs` +- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod provider;`) +- Test: inline in `provider/mock.rs` + +**Interfaces:** +- Consumes: nothing new. +- Produces: + - `#[async_trait] pub trait CallControlClient: Send + Sync { async fn originate(&self, to, from, spend_token: Option) -> Result; async fn hangup(&self, correlation_id) -> Result<(), CallControlError>; }` + - `pub struct CallControlError(pub String);` + - `pub struct SpendToken;` (the pre-paved seam for step 6; opaque placeholder). + - `pub struct MockCallControlClient { /* in-process queues */ }` + - `pub struct TwilioCredentials { account_sid, auth_token (NEVER logged), media_streams_bind, webhook_base }` — the struct lives in `provider/mod.rs` (NOT re-exported through the workspace; ADR-0009). The `TwilioCallControlClient` consumes it (T6). + +- [ ] **Step 1: Define the trait + SpendToken + CallControlError** + +(Verbatim from spec §3.4 + `async_trait` attribute.) + +- [ ] **Step 2: Implement `MockCallControlClient`** + +An in-process test double — `originate` returns a synthetic correlation ID + records the call +in an internal `Vec`; `hangup` records the terminate. Both async fns never touch the network. + +- [ ] **Step 3: Define `TwilioCredentials` (no impl yet — fields only)** + +The struct fields per spec §3.4. NO logging of `auth_token` — implement `Debug` manually with +a redacted field; don't derive `Debug`. + +```rust +impl std::fmt::Debug for TwilioCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TwilioCredentials") + .field("account_sid", &self.account_sid) + .field("auth_token", &"") + .field("media_streams_bind", &self.media_streams_bind) + .field("webhook_base", &self.webhook_base) + .finish() + } +} +``` + +- [ ] **Step 4: Tests + fmt + clippy + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2) + +The provider abstraction. The trait locks the seam so the next provider +(Telnyx, etc.) is an implementation, not a refactor. The Mock is the CI +test double; TwilioCallControlClient (T6) lives behind the twilio-live +feature flag. TwilioCredentials is NEVER re-exported through the workspace +(ADR-0009 — provider credentials never reach the brain). The +Option parameter on originate pre-paves the spearhead step-6 +spend-cap seam." +``` + +--- + +### Task T3: `TwilioMediaStreamsServer` — the WebSocket ingress + +**Files:** +- Create: `crates/rutster-trunk/src/twilio_media_streams.rs` +- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod twilio_media_streams;`) +- Test: inline `#[cfg(test)] mod tests` covering the JSON envelope parser + +**Interfaces:** +- Consumes: `G711Codec` (T1), axum WebSocket types, tokio mpsc, `base64`. +- Produces: + - `pub struct TwilioMediaStreamsServer;` + - `pub struct RegisterTrunkInboundChannel { call_sid: String, inbound_from_twilio_rx: mpsc::Receiver, outbound_to_twilio_tx: mpsc::Sender, tap_url: url::Url }` + - `pub fn TwilioMediaStreamsServer::router(register_tx: mpsc::Sender) -> axum::Router` — mounts `/twilio/media-stream` (axum `get` WebSocket upgrade). + +- [ ] **Step 1: Define the envelope-parsing types** + +Twilio Media Streams JSON frames per their documented protocol: +- `connected`: handshake. +- `start`: `{ event: "start", stream_sid, call_sid, ... }` — read CallSid + StreamSid. +- `media`: `{ event: "media", media: { payload: "" } }` — decode payload. +- `stop`: end. + +Define `TwilioMediaEvent` serde-representable enum mirroring these. + +- [ ] **Step 2: Implement `run_media_stream` async loop** + +Pseudocode: +1. Wait for first WS message. Expect `connected` envelope; ignore. +2. Read `start` envelope. Extract CallSid + StreamSid. Construct the two mpsc pairs. +3. Send `RegisterTrunkInboundChannel` to register_tx. Await `MediaThread`'s `RegisterTrunk` reply + (a oneshot confirming the ChannelId). +4. Loop: + a. Read next WS message: + - `media`: base64-decode, `G711Codec::decode_mulaw_to_pcm` → `PcmFrame`, push to `inbound_tx`. + - `stop` (or WS close): break. + b. Concurrently drain `outbound_rx` (PcmFrame produced by the std thread's TrunkSession via + `trunk_driver::drive`): `G711Codec::encode_pcm_to_mulaw` → wrap in a JSON `media` envelope + → send WS Text frame. +5. On stop / close: send `MediaCmd::Delete` for the ChannelId; tear down. + +Use `tokio::select!` for the bidirectional loop (Step 4a + 4b concurrently). + +- [ ] **Step 3: Tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_event_parses_start() { + let s = r#"{"event":"start","stream_sid":"ZZ...","call_sid":"CA..."}"#; + let e: TwilioMediaEvent = serde_json::from_str(s).unwrap(); + match e { + TwilioMediaEvent::Start { call_sid, .. } => assert_eq!(call_sid, "CA..."), + _ => panic!("expected Start"), + } + } + + #[test] + fn media_event_parses_media() { + let s = r#"{"event":"media","media":{"payload":"//MA"}}"#; + let e: TwilioMediaEvent = serde_json::from_str(s).unwrap(); + match e { + TwilioMediaEvent::Media { payload } => assert!(!payload.is_empty()), + _ => panic!("expected Media"), + } + } + + #[test] + fn media_event_parses_stop() { + let s = r#"{"event":"stop"}"#; + let e: TwilioMediaEvent = serde_json::from_str(s).unwrap(); + assert!(matches!(e, TwilioMediaEvent::Stop)); + } + + #[tokio::test] + async fn wss_pump_decodes_base64_mulaw_and_pushes_pcm_frame() { + // Integration-ish: feed a synthetic "media" JSON frame into the WS + // pump task; assert a PcmFrame lands on the inbound_tx mpsc. + // (Spot-test the base64 decode + G711Codec decode + mpsc push wiring.) + // Skip the full WS handshake; test the post-parse path. + } +} +``` + +- [ ] **Step 4: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "feat(trunk): TwilioMediaStreamsServer — axum WS handler for Twilio Media Streams (slice-5 T3) + +Accepts Twilio's inbound WSS connections; parses the JSON envelope +(connected/start/media/stop per Twilio's documented protocol); decodes +base64 µ-law via G711Codec; ferries decoded PCM frames to the per-call +inbound mpsc. Concurrently drains the outbound mpsc + sends back JSON +media frames. Same tokio/std-thread split as slice-2's TapEngine: tokio +owns IO; the std thread owns the 20ms tick via trunk_driver::drive (T4)." +``` + +--- + +### Task T4: `TrunkSession` + `trunk_driver::drive` — the FOB-side trunk tick function + +**Files:** +- Create: `crates/rutster-trunk/src/session.rs` +- Create: `crates/rutster-trunk/src/loop_driver.rs` +- Modify: `crates/rutster-trunk/src/lib.rs` (`pub mod session; pub use session::TrunkSession; pub mod loop_driver;`) +- Test: inline tests in `session.rs` exercising the tick + +**Interfaces:** +- Consumes: slice-4 `Reflex

` + `LocalVadReflex

` + `ReflexMetrics` (REUSED), slice-2 + `TapAudioPipe` (REUSED as the wrapped inner), tokio mpsc, `rutster_call_model::Channel`. +- Produces: + - `pub struct TrunkSession { channel, pipe: LocalVadReflex>, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx, next_timeout }` + - `pub fn drive(session: &mut TrunkSession, now: Instant) -> Option` — the trunk + tick function. + +- [ ] **Step 1: Author `TrunkSession`** + +```rust +pub struct TrunkSession { + pub channel: Channel, + /// The wrapped reflex stack. REUSED from slice-4 verbatim: + /// `LocalVadReflex>`. Construction reused from + /// slice-4 Task 6 — same composition site (Reflex::new(inner_pipe, + /// advisory_rx, metrics) → LocalVadReflex::new(reflex, advisory_tx)). + pub pipe: LocalVadReflex>, + pub inbound_from_twilio_rx: mpsc::Receiver, + pub outbound_to_twilio_tx: mpsc::Sender, + pub last_idle_rx: Instant, + pub next_timeout: Option, + pub last_outbound_at: Instant, +} +``` + +- [ ] **Step 2: Author `trunk_driver::drive`** + +Body mirrors spec §3.3 verbatim: +1. Drain `inbound_from_twilio_rx` via `try_recv` → for each `PcmFrame`: + `session.pipe.on_pcm_frame(frame)` (hits `LocalVadReflex` first → `Reflex` → `TapAudioPipe` → + `tx_pcm_in` → TapEngine → brain WS as `audio_in`). +2. Outbound encode tick (every 20 ms): pull `session.pipe.next_pcm_frame()` → push to + `outbound_to_twilio_tx` (try_send; drop + observe on full). +3. Idle timeout: 60 s of no inbound = `ChannelState::Closed`. + +- [ ] **Step 3: Tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + fn trivial_trunk_session() -> TrunkSession { + let (inbound_tx, inbound_rx) = mpsc::channel(8); + let (outbound_tx, mut outbound_rx) = mpsc::channel(8); + // ... construct the wrapped pipe stack; + // (For the unit test, the real spawn_tap_engine against a real brain WS + // is heavy — use a stub TapAudioPipe OR a no-op brain.) + // Recommendation: simplify this test by constructing the stack with a + // mock AudioPipe as the inner (instead of TapAudioPipe) — verify the + // tick function dispatches incoming frames to pipe.on_pcm_frame + pulls + // from pipe.next_pcm_frame without setting up real TapEngine wiring. + // The full barge-in e2e is verified in T8, not here. + } + + #[tokio::test] + async fn tick_drains_inbound_mpsc_and_pushes_to_pipe_sink() { + let mut s = trivial_trunk_session(); + s.inbound_from_twilio_tx.try_send(PcmFrame::zeroed()).unwrap(); + let now = Instant::now(); + drive(&mut s, now); + // (Assert the inner mock AudioPipe received the frame — via a counter + // OR via observable side effect.) + } + + #[tokio::test] + async fn tick_pulls_from_pipe_source_and_pushes_to_outbound() { + let mut s = trivial_trunk_session(); + // (Mock the wrapped pipe to produce a known frame on next_pcm_frame.) + let now = Instant::now(); + drive(&mut s, now); + let _ = s.outbound_to_twilio_rx.try_recv().expect("frame flowed out"); + } + + #[tokio::test] + async fn idle_timeout_closes_session_after_60s_silence() { + let mut s = trivial_trunk_session(); + // Set last_idle_rx 90 seconds ago. + let now = Instant::now(); + s.last_idle_rx = now - Duration::from_secs(90); + let next = drive(&mut s, now); + assert_eq!(s.channel.state, ChannelState::Closed); + assert_eq!(next, None); + } +} +``` + +- [ ] **Step 4: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "feat(trunk): TrunkSession + trunk_driver::drive — FOB-side trunk-leg tick (slice-5 T4) + +The trunk leg's tick function. Parallels crates/rutster-media/src/loop_driver.rs +minus the str0m/Opus/RTP machinery — there is no RTP to decode, no Opus to +encode, no str0m poll loop to drain. Caller→FOB direction is a pure mpsc +drain; FOB→caller direction is a mpsc push. + +Slice-4's Reflex + LocalVadReflex stack compose identically +around the trunk leg's session.pipe — proving the FOB reflex loop is +ingress-agnostic (spec §2.3 — the architecture's load-bearing claim). + +The seam gate holds: crates/rutster-media/src/{loop_driver.rs,rtc_session.rs} +stay byte-identical because the trunk leg NEVER enters that code path. The +MediaThread dispatches via the new MediaLeg enum (T5)." +``` + +--- + +### Task T5: `MediaCmd::RegisterTrunk` + `MediaLeg` enum + the new HTTP routes — the binary-side wiring + +**Files:** +- Modify: `crates/rutster/src/media_thread.rs` +- Modify: `crates/rutster/src/main.rs` +- Modify: `crates/rutster/src/routes.rs` +- Modify: `crates/rutster/src/lib.rs` (if needed for re-exports) +- Modify: `crates/rutster-trunk/src/lib.rs` (if the `RegisterTrunkInboundChannel` type is publicly surfaced) +- Test: inline in `crates/rutster/src/routes.rs` for the new routes (spot-test the handlers) + +**Interfaces:** +- Consumes: T3 (TwilioMediaStreamsServer) + T4 (TrunkSession + trunk_driver::drive) + T2 (CallControlClient trait). +- Produces: + - `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, tap_url, reply }` — the new variant in the existing enum. + - `pub enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }` — the session-map value type, in `media_thread.rs`. + - `POST /v1/trunk/sessions` axum route — originates an outbound call. + - `POST /v1/trunk/webhook` axum route — Twilio's inbound-call webhook receiver. + +- [ ] **Step 1: Add the `MediaLeg` enum + `RegisterTrunk` variant + dispatch match** + +Update `crates/rutster/src/media_thread.rs`: + +```rust +pub enum MediaLeg { + /// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`. + /// UNCHANGED code path from slice-4 + slice-5/seams. + WebRTC(RtcSession), + /// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`. + Trunk(rutster_trunk::session::TrunkSession), +} + +pub enum MediaCmd { + // ... existing variants unchanged ... + + /// slice-5: register a trunk-side session. The MediaThread: + /// 1. Allocates a ChannelId. + /// 2. Constructs TapAudioPipe + TapConn via spawn_tap_engine (from slice-2; REUSED). + /// 3. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) then + /// LocalVadReflex::new(reflex, advisory_tx) — same Task-6-style composition as slice-4. + /// 4. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx, + /// outbound_to_twilio_tx, ... } and inserts into the session map under MediaLeg::Trunk. + /// 5. Spawns the TapEngine tokio task. + /// 6. Replies with the ChannelId. + RegisterTrunk { + call_sid: String, + inbound_from_twilio_rx: tokio::sync::mpsc::Receiver, + outbound_to_twilio_tx: tokio::sync::mpsc::Sender, + tap_url: url::Url, + reply: tokio::sync::oneshot::Sender, + }, +} + +// In the std thread's tick loop, dispatch the per-leg tick: +fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option { + match leg { + MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now), // UNCHANGED from slice-4 + MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now), // NEW this slice + } +} +``` + +The existing call site for `loop_driver::drive(s, now)` (within the WebRTC tick loop) routes +through this `run_per_leg_tick` — a one-line refactor of the existing invocation. The seam +file `loop_driver.rs` itself is byte-identical (the call site is in `media_thread.rs`, the +binary-side bridge). + +- [ ] **Step 2: Add the `RegisterTrunk` handler in the std thread loop** + +When `RegisterTrunk` arrives: +1. Construct `tap_url` (operator-configured brain WS URL e.g. `ws://localhost:8082` for MockRealtimeBrain). +2. Construct `(advisory_tx, advisory_rx) = mpsc::channel(16)` + `metrics = ReflexMetrics::new()`. +3. `let (tap_pipe, tap_conn) = spawn_tap_engine(channel_id, tap_url, advisory_tx)` — REUSED from slice-4 Task 5. +4. `let reflex = Reflex::new(tap_pipe, advisory_rx, metrics.clone());` +5. `let wrapped = LocalVadReflex::new(reflex, advisory_tx_clone);` (where `advisory_tx_clone` is the second sender; advisory_tx has TWO senders — the engine + the local VAD). +6. Construct `TrunkSession { channel: Channel::new(channel_id), pipe: wrapped, inbound_from_twilio_rx, outbound_to_twilio_tx, last_idle_rx: now, next_timeout: None, last_outbound_at: now }`. +7. Insert into `session_map.insert(channel_id, MediaLeg::Trunk(session))`. +8. Spawn the TapEngine tokio task (held by `tap_conn`). +9. Reply with `channel_id` via the oneshot. + +(session_map type changes from `HashMap` to `HashMap` — +the WebRTC leg-handling code path handles the enum's `WebRTC` variant via `run_per_leg_tick`.) + +- [ ] **Step 3: Mount `TwilioMediaStreamsServer::router(register_tx)` on the axum router in `main.rs`** + +In `crates/rutster/src/main.rs`: +- Allocate a `tokio::sync::mpsc::Sender` pair (cap 64). +- Pass the `Sender` end into `TwilioMediaStreamsServer::router(...)`. +- The `Receiver` end runs in a tokio task: when a `RegisterTrunkInboundChannel` arrives, it + relays the contents into a `MediaCmd::RegisterTrunk` envelope + awaits the reply, then ACKs + the WSS pump task. +- Mount the router's `/twilio/media-stream` route alongside the existing routes (`/v1/sessions`, + `/healthz`, etc.). + +- [ ] **Step 4: Author the two new HTTP routes in `routes.rs`** + +- `POST /v1/trunk/sessions { to, from }` — call `CallControlClient::originate(to, from, None)` + (None because spend gate is step 6); respond with `{ channel_id, call_sid }`. +- `POST /v1/trunk/webhook` — Twilio's webhook receiver for inbound-call signaling. Respond + with TwiML body instructing Twilio to Media Streams against our `/twilio/media-stream`. + +- [ ] **Step 5: Tests** — `route_returns_channel_id_for_originate`, + `webhook_responds_with_twiml_pointing_at_media_stream`. + +- [ ] **Step 6: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "feat(binary): MediaCmd::RegisterTrunk + MediaLeg enum + /v1/trunk routes (slice-5 T5) + +The binary-side wiring of the trunk leg. The seam gate holds: the +MediaThread's tick loop dispatches via the new MediaLeg enum; +MediaLeg::WebRTC(s) => loop_driver::drive(s, now) is UNCHANGED from slice-4. +MediaLeg::Trunk(s) => trunk_driver::drive(s, now) is the new sibling. + +Two new axum routes: POST /v1/trunk/sessions initiates an outbound call via +the CallControlClient trait (green-zone); POST /v1/trunk/webhook receives +Twilio's inbound-call signaling + responds with TwiML pointing at our +/twilio/media-stream WS endpoint. + +The RegisterTrunk handler on the std thread constructs the TrunkSession + +composes the slice-4 Reflex + LocalVadReflex stack identically +to slice-4 Task 6's WebRTC spawn seam." +``` + +--- + +### Task T6: `TwilioCallControlClient` (the live REST impl) + `config.rs` env parser + +**Files:** +- Create: `crates/rutster-trunk/src/provider/twilio.rs` +- Modify: `crates/rutster-trunk/src/provider/mod.rs` (re-export `TwilioCallControlClient` ONLY when `twilio-live` feature is enabled) +- Modify: `crates/rutster/src/config.rs` (add `pub fn twilio_credentials() -> Option`) +- Test: live e2e test gated behind `twilio-live` feature — runs once on maintainer's machine via a manual `cargo test --features=twilio-live` + +**Interfaces:** +- Consumes: trait from T2 + `reqwest` + the env-var parser from slice-5/seams. +- Produces: + - `pub struct TwilioCallControlClient { credentials: TwilioCredentials, http: reqwest::Client }` + - `impl TwilioCallControlClient { pub fn new(credentials: TwilioCredentials) -> Self }` + - `#[async_trait] impl CallControlClient for TwilioCallControlClient` + - `pub fn twilio_credentials() -> Option` in `crates/rutster/src/config.rs` + +- [ ] **Step 1: Implement `TwilioCallControlClient`** + +`originate`: POST to `https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Calls.json` with +basic auth (account_sid:auth_token) + form body containing `To`, `From`, `Twiml` (a `` +TwiML instructing Twilio to Media Streams against our webhook_base + `/twilio/media-stream`). +Parse the JSON response for the `call_sid`. Return `Ok(call_sid)`. + +`hangup`: POST to `.../Calls/{call_sid}.json` with `Status=completed`. Idempotent. + +`auth_token` MUST NEVER be logged. Use `tracing::debug!` only on caller-controlled fields +(`to`, `from`, `call_sid`'s last 4 chars). + +- [ ] **Step 2: Implement the env parser in `config.rs`** + +```rust +/// Parse RUTSTER_TWILIO_ACCOUNT_SID + RUTSTER_TWILIO_AUTH_TOKEN + +/// RUTSTER_TWILIO_MEDIA_BIND + RUTSTER_TWILIO_WEBHOOK_BASE from env. +/// Returns None if any required var is missing (the binary runs without +/// trunk support if credentials aren't provided — WebRTC ingress still works). +pub fn twilio_credentials() -> Option { + let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?; + let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?; + let media_streams_bind = std::env::var("RUTSTER_TWILIO_MEDIA_BIND") + .ok()? + .parse::() + .map_err(|e| format!("invalid RUTSTER_TWILIO_MEDIA_BIND: {e}")) + .ok()?; + let webhook_base = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE") + .ok()? + .parse::() + .map_err(|e| format!("invalid RUTSTER_TWILIO_WEBHOOK_BASE: {e}")) + .ok()?; + Some(TwilioCredentials { account_sid, auth_token, media_streams_bind, webhook_base }) +} +``` + +- [ ] **Step 3: Live e2e test gated behind `twilio-live`** + +```rust +#[cfg(all(test, feature = "twilio-live"))] +mod live_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires live Twilio credentials + a real outbound number; manual run only"] + async fn originate_real_call_returns_call_sid() { + let creds = crate::config::twilio_credentials().expect("set RUTSTER_TWILIO_* env vars"); + let client = TwilioCallControlClient::new(creds); + let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO"); + let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM"); + let call_sid = client.originate(&to, &from, None).await.expect("originate"); + assert!(call_sid.starts_with("CA")); + // Hang up immediately (don't actually call the test number for real). + client.hangup(&call_sid).await.expect("hangup"); + } +} +``` + +- [ ] **Step 4: fmt + clippy + test (default features OFF) + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings +cargo clippy --all --all-targets --features=twilio-live -- -D warnings +cargo test --all # CI gate: Mock only +git commit -s -m "feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live feature) + env parser (slice-5 T6) + +The live REST call-control client. Originates outbound calls via Twilio's +Calls.json API; hangs up via Status=completed. Auth_token is NEVER logged +(ADR-0009 — provider credentials never reach the brain; this boundary is +honored at the trunk-crate level). The impl is feature-gated behind +twilio-live so the routine CI gate stays clean; the maintainer runs +cargo test --features=twilio-live when validating a release. + +Env parser in config.rs follows slice-5/seams' pure-function pattern +(take Option/&str inputs; testable without env mutation)." +``` + +--- + +### Task T7: Reflex-on-trunk-leg verification test + +**Files:** +- Create: `crates/rutster-trunk/tests/reflex_on_trunk.rs` +- Test: integration test asserting slice-4's `Reflex` + `LocalVadReflex` (REUSED) + decorate the trunk leg's TapAudioPipe identically; barge-in fires on trunk-leg caller speech. + +**Interfaces:** +- Consumes: T3 (TwilioMediaStreamsServer) — actually a `MockTwilioMediaStreamsServer` test double + firing synthetic media frames; T4 (TrunkSession + trunk_driver); slice-3's MockRealtimeBrain. + +- [ ] **Step 1: Write the integration test** + +The test: +1. Stand up MockRealtimeBrain (slice-3 merged). URL = `mock.url()`. +2. Stand up the in-process MediaThread via `MediaThread::spawn(default_tap_url, tokio_handle)`. +3. Construct the wiring manually: spawn the WSS-pump-task-equivalent that simulates a Twilio + "media" frame as a synthetic `PcmFrame::loud()` → push into `inbound_from_twilio_tx`. +4. Send `MediaCmd::RegisterTrunk { call_sid, inbound_from_twilio_rx, outbound_to_twilio_tx, + tap_url: mock.url(), reply }`. +5. Drive ticks manually OR let the std thread run for ~200 ms. +6. Assert: the brain received `audio_in` events (the loud frames forwarded through TapAudioPipe). +7. Assert: slice-4's `LocalVadReflex` tripped → fired `AdvisoryEvent::SpeechStarted` → + `Reflex::muted` became true → `next_pcm_frame` returned None → brain reply NOT sent this tick. +8. Assert: after the brain sends its reply (mock's response schedule), `next_pcm_frame` + returns Some → un-mute → brain reply pushed to `outbound_to_twilio_tx`. + +- [ ] **Step 2: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "test(trunk): reflex-on-trunk-leg verification — slice-4 Reflex decorates trunk TapAudioPipe identically (slice-5 T7) + +The architectural load-bearing claim: slice-4's reflex loop is ingress- +agnostic. This test verifies that the slice-4 stack (Reflex

+ LocalVadReflex +composition) instantiated against a trunk leg's TapAudioPipe behaves +IDENTICALLY to a WebRTC leg — local VAD trips on caller speech, barge kills +playout, brain reply post-barge un-mutes. + +Same code path; different inner leg-kind. If barge-in somehow needed a +'trunk-specific' code path here, that would be an architectural smell that +needs PM input — not a silent fork." +``` + +--- + +### Task T8: PSTN sim e2e integration test + +**Files:** +- Create: `crates/rutster-trunk/tests/sim_integ.rs` +- Test: end-to-end integration test: `MockTwilioMediaStreamsServer` (the in-process simulator) + drives a synthetic PSTN caller through the FOB reflex loop. + +- [ ] **Step 1: Author the in-process Twilio simulator** + +A `MockTwilioMediaStreamsServer` test double that mimics Twilio's WSS protocol in-process: +- Spawn a tokio task that, given a `Vec` "caller-side script," pushes them as synthetic + inbound `PcmFrame`s into a TrunkSession's `inbound_from_twilio_rx` (skipping the WSS + base64 + + JSON parsing — pure mpsc). +- Receive frames from `outbound_to_twilio_tx` (the FOB's brain reply) into a captured queue for + assertions. + +- [ ] **Step 2: Author the e2e test** + +1. Start MockRealtimeBrain (slice-3). URL = `mock.url()`. +2. Start in-process MediaThread. +3. Construct `MockTwilioMediaStreamsServer` against the MediaThread's `register_tx`. +4. Drive a synthetic PSTN caller: loud PCM frames → barge-in should fire → brain yields → brain + reply observed → caller hangup → session torn down. +5. Assert `EventSink` (slice-5/seams) emitted `ChannelEnded` with the wall-clock `started_at`. + +- [ ] **Step 3: fmt + clippy + test + commit** + +```bash +cargo fmt --all --check && cargo clippy --all --all-targets -- -D warnings && cargo test --all +git commit -s -m "test(trunk): PSTN sim e2e integration — synthetic caller through FOB reflex loop (slice-5 T8) + +MockCallControlClient + MockTwilioMediaStreamsServer drive a synthetic PSTN +caller: loud PCM frames → local VAD trips → barge-in kills playout → brain +yields → brain reply observed → un-mute → caller hangup → session torn down + +EventSink emission of ChannelEnded with wall-clock started_at (slice-5/seams). + +The full slice-1→slice-5 spearhead's worth of wiring exercised end-to-end, +against a synthetic PSTN caller (the same path a real Twilio call would +take modulo the actual Twilio cloud)." +``` + +--- + +### Task T9: QUICKSTART + README updates (filler; any time after T5 + T6 land) + +**Files:** +- Modify: `docs/QUICKSTART.md` +- Modify: `README.md` + +- [ ] **Step 1: Add env-var table to QUICKSTART** + +```markdown +### Twilio credentials (for the real phone number demo, optional) + +Set these to enable PSTN ingress via Twilio Media Streams: + +| Env var | Purpose | Example | +|---|---|---| +| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` | +| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret) | `xxx...` | +| `RUTSTER_TWILIO_MEDIA_BIND` | Where Twilio Media Streams WSS binds | `0.0.0.0:8081` | +| `RUTSTER_TWILIO_WEBHOOK_BASE` | Twilio's webhook target (your binary) | `https://your.public.host` | + +Without these set, the binary runs WebRTC-only (slice-1-3 ingress + slice-4 +barge-in + slice-4½ sim). With them set + `--features=twilio-live`, the +binary accepts PSTN fork calls against `/twilio/media-stream`. +``` + +- [ ] **Step 2: Add "make a real phone call" section to QUICKSTART** + +- [ ] **Step 3: Update README spearhead status line** + +```markdown +> **Status:** Slices 1–4½ + step 5 (WebRTC media core, WS tap, OpenAI Realtime +> brain, barge-in, sim/benchmark harness, and PSTN via rented Twilio Media +> Streams transport) are merged to `main`. Spearhead step 6 (spend cap) is the +> remaining step. ADR-0007 honored: rutster parses zero SIP bytes. +``` + +- [ ] **Step 4: fmt + test + commit** + +```bash +cargo fmt --all --check && cargo test --all +git commit -s -m "docs: QUICKSTART env table + 'make a real phone call' walkthrough + README status update (slice-5 T9) + +README's spearhead list now reflects: slices 1, 2, 3, 4, 4½, 5 merged. The +quickstart shows how to set RUTSTER_TWILIO_* env vars + run +cargo run --features=twilio-live to take a real phone call via Twilio Media +Streams. ADR-0007 honored: rutster parses zero SIP bytes." +``` + +--- + +### Task T10: CI seam gate re-pin + cargo deny + +**Files:** +- Modify: `.github/workflows/ci.yml` (re-pin the seam hashes IF iptables/rtc_session was touched — but this slice's invariant is "untouched," so the pins stay the same; verify) +- Modify: `deny.toml` (if `cargo deny check` flags any new license from `reqwest`/`base64`/`async-trait` transitive deps) +- Final sweep per AGENTS.md CI gates. + +- [ ] **Step 1: Verify seam gate stays unchanged** + +```bash +git rev-parse main:crates/rutster-media/src/loop_driver.rs +git rev-parse main:crates/rutster-media/src/rtc_session.rs +``` + +Confirm the two pinned blob hashes in `.github/workflows/ci.yml` (slice-4 Task 10) are unchanged. +If the hashes DO match, this slice's seam gate holds with NO CI YAML modification. If they +DO NOT match (the dev-c implementer accidentally touched the seam files), STOP + emit a +`## QUESTION` block to the PM; the dev must `git checkout main -- crates/rutster-media/src/loop_driver.rs +crates/rutster-media/src/rtc_session.rs` to restore them. + +- [ ] **Step 2: Run cargo deny check + fix any flags from new deps** + +```bash +cargo deny check +# If a license/ban/source advisory flags reqwest/base64/async-trait transitive dep, +# either add it to the allowlist in deny.toml with rationale OR +# substitute a different crate from the workspace's already-admitted set. +``` + +- [ ] **Step 3: Add the `twilio-live` CI job (manual-trigger only)** + +```yaml + twilio-live: + name: twilio-live (manual only) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install libopus + run: sudo apt-get update && sudo apt-get install -y libopus-dev + - name: Run twilio-live tests + env: + RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081 + RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }} + run: cargo test --all --features=twilio-live -- --include-ignored +``` + +The maintainer triggers this before a release. + +- [ ] **Step 4: Final full sweep** + +```bash +cargo fmt --all --check +cargo clippy --all --all-targets -- -D warnings +cargo clippy --all --all-targets --features=twilio-live -- -D warnings +cargo test --all +cargo deny check +cargo doc --no-deps +``` + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/ci.yml deny.toml +git commit -s -m "ci(trunk): twilio-live manual-trigger job + cargo deny recheck (slice-5 T10) + +The routine CI gate stays feature-default-off: MockCallControlClient is +the per-PR integration test surface. A new twilio-live job runs ONLY on +manual workflow_dispatch (maintainer triggers pre-release) — exercises the +real TwilioCallControlClient against live Twilio. cargo deny check passes +for the new reqwest/base64/async-trait transitive deps. + +The seam gate (loop_driver.rs + rtc_session.rs pinned blob hashes from +slice-4 Task 10) is verified UNCHANGED by this slice — the trunk leg's +tick lives entirely in rutster-trunk/src/loop_driver.rs, a separate file +in a separate crate." +``` + +--- + +## Final acceptance checklist + +After all 10 tasks merge: +- [ ] `cargo fmt --check`, `cargo clippy --all --all-targets -- -D warnings` (both default + + `--features=twilio-live`), `cargo test --all`, `cargo deny check`, `cargo doc --no-deps` + all clean (stable + 1.85). +- [ ] Seam gate unchanged: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3/slice-4 + (CI pinned-blob from slice-4 Task 10). +- [ ] Slice-4's `Reflex` + `LocalVadReflex` (REUSED — no new instances created) + decorate a trunk leg's TapAudioPipe identically (T7 verification test green). +- [ ] PSTN-sim e2e (T8) drives a synthetic caller through the FOB reflex loop: barge-in fires, + brain reply observed, CDR/EventSink emits ChannelEnded. +- [ ] QUICKSTART + README updated; env-var table renders in cargo doc. +- [ ] The maintainer (user) verifies the live e2e by manually running + `cargo test --features=twilio-live` against real Twilio credentials once. +- [ ] PR bisected + readable: each task = one commit (squash-merge preserves the strategy). +- [ ] PR opened via `tea pulls create --head slice-5/rented-transport-dev-{b,c} --base main + --title "slice-5 (rented transport): a real phone number via Twilio Media Streams + (T1-T10)" --description ""`. +- [ ] Do NOT merge the PR — the maintainer (user) merges after the live Twilio e2e is verified. +- [ ] ADR-0010 deviation note considered for the PR description (per the strategic plan §1.2 + fork) — "this slice precedes rung-2 escalation per the user's 2026-07-05 directive; ADR-0010 + remains intact; the deviation is recorded in `.omo/plans/2026-07-05-spearhead-4half-and- + step-5-strategic.md` §1.2." diff --git a/docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md b/docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md new file mode 100644 index 0000000..44f72d9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md @@ -0,0 +1,889 @@ +# Rutster slice 4½ — Benchmark + simulation harness (the `rutster-sim` seed) + +- **Status:** Draft (pending review) +- **Date:** 2026-07-05 +- **Spearhead step:** 4½ of 6 (inserted by [ADR-0010](../../adr/0010-spearhead-benchmark-sim-harness.md) — "after barge-in (4), before rented transport (5)") +- **Origin:** [2026-07-03 adversarial review](../../reviews/2026-07-03-adversarial-review.md) (recommendations R1+R2) + [2026-07-03 market feature scan](../../reviews/2026-07-03-market-feature-scan.md) (F1 — simulation as the standout engine-fit feature) +- **Depends on (already merged):** + - [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) — the media core + the `AudioPipe` trait slice 4½ simulates against + - [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md) — the `TapAudioPipe` slice 4½ measures end-to-end + - 2026-06-30 slice-3 realtime brain (merged `c30a452`) — `MockRealtimeBrain` slice 4½ drives the harness against + - [slice 4 — Barge-in / VAD-driven playout kill](2026-07-01-slice-4-barge-in-design.md) — the ≤60 ms kill budget this slice MEASURES (slice-4 §5.1) + - 2026-07-04 slice-5/seams — `MediaCmd::Stats` exposes `MediaStats { tick_overruns, last_tick_micros }` (the "tick-lag gauge" readout this harness surfaces as the primary concurrency-sweep result) +- **Related:** [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (the wedge: "local real-time reflexes that don't need the brain" — currently an arithmetic claim, this slice proves it), [ADR-0008](../../adr/0008-fob-and-green-zone.md) (the harness is FOB: hot-path-adjacent + differentiating), [ARCHITECTURE.md §"Biggest technical risk"](../../ARCHITECTURE.md) (the reflex loop *is* the long pole; the harness is its measurement surface) + +--- + +## TL;DR + +Stand up spearhead step 4½: a self-hostable **benchmark + simulation harness** in a new +`crates/rutster-sim/` crate. It drives synthetic callers through the SAME media-leg path real +callers use, measures **p50/p99 mouth-to-ear latency** and **barge-in kill-time** against slice-4's +≤60 ms kill budget, and runs the same measurements at **1 / 10 / 50 concurrent synthetic calls**. +A separate CI job (opt-in via `--features=sim-bench`) asserts the per-budget thresholds every +commit. **A latency regression fails the build the same way a broken test does** — ADR-0010's +central demand. + +The harness is also the **doctrine-drift detector** for the timing-thread debt (slice-4 §1.2 +deferred per-session threads; slice-5/seams landed a single shared std::thread + a tick-lag gauge +without data). 4½'s concurrency sweep turns `MediaStats.tick_overruns` from a counter into a +**decision artifact**: "does single-thread poll loop breach budget at realistic concurrency?" gets +answered with data, not vibes. If yes, the dedicated threadpool-shard graduation (slice-4 deferral +#2) gets scheduled on evidence. + +Three things this slice deliberately is NOT: +- **Not an LLM-driven caller** (the scenario pack is scripted PCM segments; LLM-driven callers are + post-spearhead). The harness ships with three deterministic scenarios that exercise the wedge. +- **Not a fuzz target** (the harness exercises real latency under load; it is not designed to find + panics in the PCM path — that's the deferred `cargo-fuzz` harness). +- **Not a load test for production capacity** (p99 under 50 synthetic calls is the sperhead-scale + limit; load testing for horizontally-scaled fleet deployments lands with the rung-2 escalation / + step-6 spend-cap work). + +--- + +## 1. Scope + +### 1.1 In scope + +- A new `crates/rutster-sim/` crate (currently non-existent; this slice creates it from `Cargo.toml` + + `lib.rs`). +- A **`Scenario` data type** + a TOML scenario file format (`crates/rutster-sim/scenarios/*.toml`) + describing scripted caller PCM playback with timing directives ("speak N frames, pause M frames, + interrupt at T"). At least three shipped scenarios: `loud-barge.toml`, `quiet-advisory.toml`, + `sustained-call.toml`. +- A **`SimAudioPipe: AudioPipe`** implementation that drives a `Scenario` on `next_pcm_frame` + (plays back scripted PCM frames) and captures received frames on `on_pcm_frame` (timestamps + each — the latency-measurement boundary). +- A **`LatencyProbe`** that computes the two metrics slice-4's design sets targets for: + - **Mouth-to-ear round-trip** — caller-speech-onset → first frame returned at the caller's "ear" + (the `SimAudioPipe`'s source path). + - **Barge-in kill-time** — caller-speech-onset → reflex-barge-fire (the moment `next_pcm_frame` + first returns `None` post-barge). +- A **`ConcurrencyRunner`** that spawns N concurrent `SimCall`s against a target binary URL + (default: in-process via `MediaThread` for determinism) and computes per-call latencies + the + p50/p99 aggregates across the sample. +- **Tick-lag gauge integration**: the runner reads `MediaStats { tick_overruns, last_tick_micros }` + via `MediaCmd::Stats` (already exposed by slice-5/seams) and surfaces both as primary readouts in + the sweep report — both the per-call `last_tick_micros` distribution and the cumulative + `tick_overruns` count. +- **CI-regressed thresholds**: a separate CI job runs `cargo test --all --features=sim-bench` per + PR + nightly; thresholds asserted; a regression fails the build. +- New learner-facing comments explaining the measurement discipline (the test corpus *is* the + code-reading curriculum for "how do you measure a real-time system without lying to yourself?" — + the slice-1 §7 verbosity standard carries over). + +### 1.2 Out of scope (with scheduled return) + +| Deferred item | Returns in | Why deferred | +|---|---|---| +| LLM-driven synthetic callers | post-spearhead refinement | ADR-0010 says scripted scenarios are 4½; LLM callers are an extension. Scripted scenarios are deterministic (reproducibility is the point); LLM callers add variance that complicates threshold-assertion CI gates. | +| Per-environment calibration of threshold values | post-spearhead | The MVP ships thresholds tuned for the dev loopback (smoke) + CI runner (deterministic box). Real-world noise calibration (per-CPU, per-RAM) is a tuning-framework problem — paired with slice-4's VAD-threshold tuning deferral. | +| Multi-perspective scenario recording (the caller's audio + the operator's audio + the brain's audio saved per-run as WAV for review) | later rung (rung-2 escalation) | Recording per-call audio for supervisor review is a rung-2 capability (warm-handoff artifact). The harness measures; recording is a separate concern. | +| Distributed / multi-binary fleet latency sweep | rung 3 (post-escalation) | ADR-0010's sweep targets a single binary at low concurrency. Fleet-scale (N binaries, NAT, autoscaling) lands after the trunk is real (step 5) + escalation is real (rung 2). | +| Adaptive `VAD_RMS_THRESHOLD` tuning | post-spearhead | Slice-4 §1.2 defers; 4½ inherits the const threshold (500.0). Real-world noise-floor learning is a later rung. | +| Concurrency > 50 calls | later rung | The spearhead's scale envelope caps at "dozens of PSTN calls on one box" (slice-4 §6.3); 50 is the upper claim. Beyond 50 ⇒ fleet (above), not single-binary. | +| Latency under degraded brain conditions (mock brain stalls) | later refinement | The MVP measures against `MockRealtimeBrain` (deterministic). The real latency risk is "what if the brain takes 800 ms instead of 300 ms?" — addressed by a "brain slow" scenario ADDED LATER, not in the MVP. | +| Hooking `cargo bench` / criterion into the CI artifact publishing | later | False precision: the harness is `cargo test --features=sim-bench`, not `cargo bench`. We are asserting threshold-shape gates, not micro-bench-diff regressions (which are noisy in CI). `criterion` is a different epistemology. | +| Browser-based e2e (Playwright/Selenium) | post-spearhead | Unchanged from prior slices' deferral. The synthetic-peer harness is the test vehicle. | + +--- + +## 2. Architecture delta + +### 2.1 What changes vs slice-4 + slice-5/seams + +Slice 4½ adds ONE crate + one CI matrix entry. The fused vertical's existing hot path +(`loop_driver.rs` + `rtc_session.rs` + `MediaThread` + `Reflex

` + `TapAudioPipe`) is **untouched**. +The harness drives the same media-leg ingress path a real caller uses — so what it measures is what +the customer experiences, by construction. + +``` + ┌─ crates/rutster-sim/ (NEW, this slice) ──────────┐ + │ │ + │ Scenario(.toml) ──► SimAudioPipe: AudioPipe │ + │ │ │ + │ LatencyProbe ◄── timestamps on frames passing │ + │ through the pipe │ + │ │ + │ ConcurrencyRunner (1/10/50 SimCalls) │ + │ │ │ + │ └─► MediaCmd::Stats (slice-5/seams already) │ + │ reads tick_overruns / last_tick_micros│ + └──────────────────────┬────────────────────────────┘ + │ drives via the existing + │ media-leg ingress path + ▼ +┌────────────────────────────────────────────── Rutster trust boundary (FOB) ────────────┐ +│ WebRTC ingress / Trunk ingress (slice-5) → RtcSession → Reflex │ +│ ↑ ↑ ↑ ↑ │ +│ └── existing, untouched ───┘ │ │ │ +│ │ │ │ +│ loop_driver.rs + rtc_session.rs (byte-identical) ┘ │ │ +│ │ │ +│ MediaThread (slice-4) ─── MediaCmd::{Register, AcceptOffer, Delete, │ +│ Shutdown, Stats, Drain, RegisterTrunk[slice-5]} │ +│ │ +│ Tick-lag gauge: MediaStats { tick_overruns, last_tick_micros } (slice-5) │ +└───────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 The measurement boundary (load-bearing — gets it wrong, the harness lies) + +A latency measurement is honest only if the timestamp is captured at the same point the customer +experiences the audio. The harness employs TWO clocks: + +- **Caller-side onset timestamp** (`t_onset`): captured inside `SimAudioPipe::on_pcm_frame` when a + scenario-step directive says "speak now." This is the wall-clock the *caller* started speaking. +- **Caller-side receipt timestamp** (`t_receipt`): captured inside `SimAudioPipe::next_pcm_frame` + when the brain's response PCM frame is returned to the "caller's ear" (the `SimAudioPipe`'s source + path returns `Some(frame)`). + +Latency = `t_receipt - t_onset`. Both timestamps come from `Instant::now()` (monotonic, unaffected by +NTP) within the same `SimAudioPipe` instance — so wall-clock skew between nodes is structurally +eliminated. The harness measures *the system's response to a synthetic caller*, not "what time it +is on the operator's box vs the caller's box." + +This is the design choice that distinguishes the harness from "just add some `Instant::now()` calls +in tracing." The `SimAudioPipe` IS the caller — it owns both the onset timestamp (it decided when +to "speak") and the receipt timestamp (it observed when the system "replied"). The harness can't +lie about latency because the only clock it uses is the caller's clock. + +### 2.3 Why `SimAudioPipe` instead of a tracing-only approach + +The alternative: instrument `loop_driver.rs` / `rtc_session.rs` / the tap with `tracing::Span` +timestamps and aggregate them post-hoc. We reject this design because: + +1. **It violates the seam.** `loop_driver.rs` + `rtc_session.rs` are byte-identical seams; timing + instrumentation would either change those files (slice-1 §8.5 #6 violated) or layer on top via + decorators (complex, flaky under load). +2. **It measures wall-clock at multiple points, not customer experience.** A `tracing::Span` that + says "encode took 4 ms" tells us nothing about whether the brain's reply actually reached the + caller's ear in ≤60 ms — there's queueing + jitter + WS transport between "encode" and "heard." +3. **It produces observability dashboards, not CI gates.** Operators need dashboards (separate + concern — the `EventSink` from slice-5/seams emits per-call CDR-anchored fields); the spearhead + needs *regression-failing thresholds* stated as test assertions, which is the harness's job. + +The `SimAudioPipe` is the right architectural choice for **CI-suite measurements**. Tracing +dashboards land with ADR-0005 Valkey wiring (a later rung) — both necessary, both different. + +### 2.4 Why 1 / 10 / 50 (and not other values) + +- **1 call** isolates the *baseline* — the cold-path latency with zero concurrency pressure. If + this regresses, the bug is in the loop itself, not in contention. Slice-4's §5.1 ≤60 ms kill budget + is asserted here. +- **10 calls** is the *warm working set.* Approximately the peak spearhead-scale; the std thread's + 10 ms meta-tick comfortably fits 10 sessions per tick (each session costs <100 µs in + `loop_driver::drive`), so this asserts budget at warm-but-uncontended conditions. +- **50 calls** is the *saturation point.* ADR-0010's "single-poll-task head-of-line-blocking debt" + (review P2) lives here: 50 sessions per 10 ms tick = 200 µs per session in the meta-tick before + contention squeezes; if `last_tick_micros` stays under 10 ms the single-thread design holds; if + `tick_overruns` grows past some threshold the dedicated-threadpool-shard graduation (slice-4 §1.2 + deferral) gets its data-driven case. + +We do NOT test 100/500/5000 — that's fleet-scale (rung 3). 50 is the upper edge of the spearhead's +"one binary, one city" claim. + +### 2.5 The CI gate shape + +A regression-failing threshold is a test assertion. The crate `crates/rutster-sim/` ships: + +- `#[cfg(feature = "sim-bench")]` modules + tests — default OFF, so `cargo test --all` (the routine + CI gate on every PR) stays fast (assertions of *correctness*, not measurement). +- A `cargo test --all --features=sim-bench` invocation in a SEPARATE CI job. This job runs on every + PR + nightly. Failure ⇒ red X ⇒ PR does not merge. + +The thresholds are encoded as Rust `assert!` statements in `crates/rutster-sim/src/thresholds.rs`: + +```rust +// crates/rutster-sim/src/thresholds.rs + +/// Slice-4 spec §5.1 + §7 done-criteria #8: kill-time budget is +/// ≤60 ms (3 debounce frames × 20 ms tick) + 1 tick to drain + apply. +/// Observer slack to make CI deterministic-but-not-flaky on a slow runner: +/// effective CI assertion ≤80 ms (60 ms budget + 20 ms slack). +pub const BARGE_IN_KILL_TIME_P99_MS: f64 = 80.0; + +/// Slice-1 + slice-3 mouth-to-ear budget: 200 ms (slice-1 notification) + +/// 250 ms mock brain + 100 ms playout buffer. CI assertion ceiling: +/// 700 ms (allowance for CI runner variance against dev machine). +pub const MOUTH_TO_EAR_P99_MS: f64 = 700.0; + +/// Slice-5/seams tick-lag gauge: the meta-tick must stay under 10 ms +/// (the loop's nominal period). At 1 call: ≤2 ms. At 50 calls: ≤10 ms. +/// Tick overruns (count of ticks exceeding 10 ms) at p50 across the sweep: +/// ≤1% of total ticks. +pub const TICK_LAG_MAX_MS: f64 = 10.0; +pub const TICK_OVERRUN_PCT_MAX: f64 = 1.0; + +/// Concurrency-sweep sample sizes. +pub const SWEEP_CONCURRENCIES: &[usize] = &[1, 10, 50]; +``` + +These are *constants* for the MVP. They become *env-var configurable* in a post-spearhead +tuning-framework (paired with slice-4's VAD threshold tuning deferral). + +--- + +## 3. Component design + +### 3.1 `Scenario` + `ScenarioStep` + +```rust +// crates/rutster-sim/src/scenario.rs + +/// A scripted caller scenario. Read from TOML (a scenario file under +/// `crates/rutster-sim/scenarios/*.toml`). Deterministic by construction — +/// the entire point is reproducible thresholds in CI. +/// +/// # Why TOML (not YAML, not RON) +/// +/// `serde` + `toml` is already a workspace member (cargo-deny licenses track). +/// TOML keeps the scenario file readable as a one-shot script (a sequence +/// of named steps + numbers); YAML would invite flow-mapping complexity +/// the scenario format doesn't need. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Scenario { + /// Human-readable identifier; shows up in the CI failure message. + pub name: String, + /// The sequence of caller-side actions; played front-to-back. + pub steps: Vec, +} + +/// One axis of caller behavior. A scenario is a time-ordered sequence of +/// these. The `SimAudioPipe` consumes them in order during `on_pcm_frame`. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScenarioStep { + /// Send N loud PCM frames (sample value 1000, well above VAD_RMS_THRESHOLD=500.0). + /// Triggers the local VAD via slice-4's `LocalVadReflex::on_pcm_frame`. + SpeakLoud { frames: u32 }, + /// Send N zero frames (sample value 0, well below VAD_RMS_THRESHOLD). + /// Used by the quiet-caller advisory scenario: drives mock-brain advisory path. + SpeakQuiet { frames: u32 }, + /// Insert N zero frames before the next step (silence pacing). + Pause { frames: u32 }, + /// Wait until the harness receives M "ear" frames before advancing. + /// Used as a barrier: the brain's reply must arrive before the next caller action. + AwaitReply { frames: u32 }, + /// End the scenario. The `SimAudioPipe` returns None from next_pcm_frame thereafter. + End, +} +``` + +Scenario file example (`crates/rutster-sim/scenarios/loud-barge.toml`): + +```toml +# Drives the PRIMARY barge-in path (slice-4 §5.1). The caller says one loud +# burst of audio; the local VAD trips; playout dies; no brain advisory needed. +# Asserts the wedge-#1 path: "VAD killing TTS the instant the caller speaks, +# without the brain." + +name = "loud-barge" + +[[steps]] +kind = "speak_loud" +frames = 20 # 20 frames @ 20ms = 400 ms of speech; comfortably past the 60 ms debounce + +[[steps]] +kind = "await_reply" +frames = 0 # barrier: the FOB should be muted at this point (Reflex::muted == true) + +[[steps]] +kind = "end" +``` + +### 3.2 `SimAudioPipe: AudioPipe` + +```rust +// crates/rutster-sim/src/sim_audio_pipe.rs + +/// A test-double `AudioPipe` that simulates a caller. Drives a `Scenario` +/// on `on_pcm_frame` (the sink path: caller speaks); receives brain +/// response frames on `next_pcm_frame` (the source path: caller hears). +/// Captures `Instant::now()` at every meaningful event for the +/// `LatencyProbe` to consume. +/// +/// # Why this is THE measurement boundary +/// +/// Both clocks live INSIDE this pipe. The wall-clock the *caller* started +/// speaking is captured here (we decided when to "speak"); the wall-clock +/// the *caller* heard the reply is captured here (we observed the system's +/// reply). See §2.2 — the harness can't lie about latency because the only +/// clock it uses is the caller's. +pub struct SimAudioPipe { + /// The scenario under playback. + scenario: Scenario, + /// Cursor into `scenario.steps`. + step_idx: usize, + /// Frames remaining in the current step (decrements per `on_pcm_frame` + /// for `SpeakLoud`/`SpeakQuiet`/`Pause`; freezes for `AwaitReply`). + step_frames_remaining: u32, + /// Frames received from `next_pcm_frame` while in `AwaitReply`. + /// When this reaches the step's `frames` target, advance. + reply_frames_received: u32, + /// Captured timestamps (the `LatencyProbe` consumes this via + /// `take_captures()` after the run). Discarded on every `on_pcm_frame` + /// call once the capture buffer is at capacity (bounded; hot-path). + captures: Vec, + /// A pre-allocated frame returned from `next_pcm_frame` when we have a + /// pending reply (the harness intercepts frames routed back through the + /// existing media loop and returns them here). See §3.4 for how the + /// `SimCall` wires this pipe to a real `MediaThread`. + reply_ring: std::collections::VecDeque, +} + +/// A timestamped event captured by the `SimAudioPipe`. Read by the +/// `LatencyProbe` post-run to compute p50/p99 latencies. +#[derive(Debug, Clone, Copy)] +pub enum Capture { + /// The caller started speaking loudly (a `SpeakLoud` step began). + CallerLoudOnset { at: Instant }, + /// The FOB killed playout (a `next_pcm_frame` call returned None + /// immediately after a barge event). The wall-clock this slice cares + /// about for kill-time. + BargeKillObserved { at: Instant }, + /// The caller heard a brain reply (a `next_pcm_frame` returned Some + /// after the barge cleared). The wall-clock this slice cares about + /// for mouth-to-ear. + CallerHeardReply { at: Instant }, +} +``` + +#### 3.2.1 The two key methods + +```rust +// crates/rutster-sim/src/sim_audio_pipe.rs (continued) + +impl AudioSource for SimAudioPipe { + fn next_pcm_frame(&mut self) -> Option { + // The source path: the caller's "ear." Brain replies (frames the + // system produced on the egress side and routed back to us via the + // SimCall's mpsc wiring — see §3.4) land here. + match self.reply_ring.pop_front() { + Some(frame) => { + if self.is_in_await_reply_step() { + self.reply_frames_received += 1; + if self.reply_frames_received >= self.current_step_target() { + self.advance_step(); + } + } + // Capture: this is the "caller heard" wall-clock. + self.captures.push(Capture::CallerHeardReply { at: Instant::now() }); + Some(frame) + } + None => { + // The reflex muted us (slice-4's Reflex

::muted == true). + // Capture: this is the "barge kill observed" wall-clock. + // (Only capture if we are mid-AwaitReply post-barge; see the + // LatencyProbe's classification §3.3 for the dedup logic.) + self.captures.push(Capture::BargeKillObserved { at: Instant::now() }); + None + } + } + } +} + +impl AudioSink for SimAudioPipe { + fn on_pcm_frame(&mut self, _frame: PcmFrame) { + // The sink path: the caller "speaks." The scenario drives here. + // Decode the current step + emit the appropriate PCM signal. + self.dispatch_step_action(); + // (We discard the inbound frame — the caller doesn't hear itself; + // the SimCall's wiring pushes the caller-side frame into the + // `tx_pcm_in` channel for the tap to forward to the brain.) + } +} +``` + +### 3.3 `LatencyProbe` + +```rust +// crates/rutster-sim/src/latency.rs + +/// Computes the two metrics slice-4's design sets budgets for, from a +/// captured-stream of `Capture` events produced by a `SimAudioPipe`. +/// +/// # Verification discipline +/// +/// The probe is the single source of truth for "did latency regress?" +/// Assertions are made against `LatencyProbe::p99_kill_ms()` and +/// `LatencyProbe::p99_mouth_to_ear_ms()`. A failure here is the build-red +/// signal ADR-0010 demands. +pub struct LatencyProbe { + captures: Vec, +} + +impl LatencyProbe { + pub fn from_captures(captures: Vec) -> Self { Self { captures } } + + /// Barge-in kill-time: caller-speech-onset → first `BargeKillObserved`. + /// Returns Duration per call (we capture one onset+kill pair per barge). + pub fn kill_times(&self) -> Vec { /* pair captures */ } + + /// Mouth-to-ear: caller-speech-onset → next `CallerHeardReply`. + pub fn mouth_to_ear_times(&self) -> Vec { /* pair captures */ } + + pub fn p50_kill_ms(&self) -> f64 { percentile(&self.kill_times(), 50) } + pub fn p99_kill_ms(&self) -> f64 { percentile(&self.kill_times(), 99) } + pub fn p50_mouth_to_ear_ms(&self) -> f64 { percentile(&self.mouth_to_ear_times(), 50) } + pub fn p99_mouth_to_ear_ms(&self) -> f64 { percentile(&self.mouth_to_ear_times(), 99) } +} + +fn percentile(durations: &[Duration], pct: u8) -> f64 { + if durations.is_empty() { return f64::NAN; } + let mut sorted: Vec = durations.iter().map(|d| d.as_millis()).collect(); + sorted.sort_unstable(); + let idx = ((sorted.len() as f64 - 1.0) * (pct as f64 / 100.0)).round() as usize; + sorted[idx] as f64 +} +``` + +The `LatencyProbe` is **post-hoc**: a single `SimCall` runs to completion, the `SimAudioPipe`'s +captures are drained, the probe computes the metrics. No per-tick instrumentation cost during the +hot path itself — just the `Instant::now()` calls inside `SimAudioPipe::next_pcm_frame` (which +isn't the hot path anyway; it's the simulated-caller epilogue). + +### 3.4 `SimCall` (the wiring) + `ConcurrencyRunner` + +```rust +// crates/rutster-sim/src/runner.rs + +/// One simulated call: a `SimAudioPipe` + the wiring to drive it against the +/// existing `MediaThread`. Single binary; no separate process. +pub struct SimCall { + /// The scenario-driven caller pipe. + pipe: SimAudioPipe, + /// The `MediaThread` cmd_tx — we register a session, drive the pipe + /// via `tx_pcm_in`, capture frame receipts in `next_pcm_frame`. + media_cmd_tx: mpsc::Sender, + /// Latency probe populated post-run. + probe: Option, +} + +impl SimCall { + pub async fn run(mut self) -> LatencyProbe { + // 1. Register a session with the MediaThread. + // 2. Wire self.pipe as the session's AudioPipe (MediaCmd::RegisterSim). + // 3. Drive the scenario: each scenario step emits `on_pcm_frame` + // calls against the SimAudioPipe; the MediaThread's loop_driver + // echoes frames back via next_pcm_frame. + // 4. On End: drain captures + return the probe. + todo!("see §4 data flow") + } +} + +/// The concurrency sweep runner. Spawns N `SimCall`s in parallel (tokio), +/// awaits all, aggregates per-call latencies into the sweep report. +pub struct ConcurrencyRunner { + /// Target binary in-process MediaThread cmd_tx. Passed in by the test fixture. + media_cmd_tx: mpsc::Sender, + /// Concurrency levels to sweep (slice-4½ hardcoded [1, 10, 50]). + concurrencies: Vec, +} + +impl ConcurrencyRunner { + /// Run the full sweep; return the per-concurrency-level report. + pub async fn run(&self, scenario: Scenario) -> SweepReport { /* ... */ } +} + +/// The artifact feeding the CI assertions. The thresholds.rs asserts +/// `report.barge_kill_p99_ms <= BARGE_IN_KILL_TIME_P99_MS` etc. +#[derive(Debug)] +pub struct SweepReport { + pub per_concurrency: Vec, +} + +#[derive(Debug)] +pub struct PerConcurrencyReport { + pub concurrency: usize, + pub p50_kill_ms: f64, + pub p99_kill_ms: f64, + pub p50_mouth_to_ear_ms: f64, + pub p99_mouth_to_ear_ms: f64, + /// From slice-5/seams MediaCmd::Stats. The "doctrine-drift detector" + /// for the timing-thread debt — ADR-0010's debt-pairing readout. + pub max_tick_lag_micros: u64, + pub tick_overruns: u64, + pub total_ticks: u64, + pub tick_overrun_pct: f64, +} +``` + +### 3.5 `MediaCmd::RegisterSim` (the seam — one new enum variant) + +Slice-5/seams already exists with `MediaCmd::Register`, `AcceptOffer`, `Delete`, `Shutdown`, +`Stats`, `Drain`. Slice 4½ adds ONE variant: `RegisterSim`, which lets a `SimCall` register a +session whose `AudioPipe` is a `SimAudioPipe` instead of a WebRTC-backed `RtcSession`. This is the +minimum extension to drive the harness without needing to spin up a real WebRTC peer. + +```rust +// crates/rutster/src/media_thread.rs (extended this slice) + +pub enum MediaCmd { + // ... existing variants (unchanged from slice-5) + /// slice-4½: harness-side session registration. The + /// `SimAudioPipe` lives entirely on the binary side; no WebRTC + /// handshake needed. The `tx_pcm_in` channel is the existing + /// sink-input seam (the harness emits `on_pcm_frame(frame)` directly + /// rather than the loop_driver pulling RTP + decoding first). + RegisterSim { + pipe: Box, + reply: oneshot::Sender, + }, +} +``` + +The std thread's `run_media_thread` handles `RegisterSim` by inserting a synthetic "session" entry +that drives the harness's `SimAudioPipe` through the same `loop_driver::drive` calls as real +WebRTC sessions — the seam holds (`loop_driver.rs` + `rtc_session.rs` byte-identical). The +harness measures what real callers experience because **it drives the same code path**. + +### 3.6 Tick-lag gauge integration (the doctrine-drift detector) + +Slice-5/seams added `MediaStats { tick_overruns, last_tick_micros }` to `MediaCmd::Stats`. +Slice 4½ consumes both. The `ConcurrencyRunner` polls `MediaCmd::Stats` once per second during +the sweep; the per-concurrency-level report carries: + +- `max_tick_lag_micros` — the maximum observed `last_tick_micros` during the sweep at this + concurrency level. Indicates "the worst tick the loop experienced." +- `tick_overruns` / `total_ticks` — cumulative count of ticks where `last_tick_micros > 10_000` + (10 ms); converts to percent. Indicates "what fraction of ticks overflowed." + +The thresholds (`TICK_LAG_MAX_MS = 10.0`, `TICK_OVERRUN_PCT_MAX = 1.0`) are the answer to ADR-0010's +"if the concurrency sweep shows the shared-tokio poll loop breaching budget at realistic call +counts, the dedicated-timing-thread work gets scheduled on data, not vibes." If slice 4½'s sweep +shows `tick_overrun_pct > 1.0` at 50 calls, **the FOB reflex loop's single-thread debt is real and +graduates from doctrine to data** — that's the slice's load-bearing finding regardless of whether +the latency thresholds pass. + +--- + +## 4. Data flow + +### 4.1 Single `SimCall` (the unit of measurement) + +``` +1. SimCall::run() starts. +2. Send MediaCmd::RegisterSim { pipe: Box, reply } to media_cmd_tx. +3. The MediaThread handles RegisterSim: + - Constructs a "synthetic session" entry in its HashMap. + - The session's `pipe` field is the SimAudioPipe. + - Subsequent loop_driver::drive(now) calls touch this session identically + to a WebRTC session (the seam holds). +4. The harness drives the scenario: + - For each SpeakLoud/SpeakQuiet step: the SimCall emits `pipe.on_pcm_frame(frame)` calls + at the 20 ms tick cadence. loop_driver::drive's encode path is bypassed for the SimPipe + (we wrote the frame directly into the pipe). + - loop_driver::drive immediately treats the next_pcm_frame call as the source path: it + pulls from the SimPipe's reply_ring (where brain replies populate when the SimCall + sees them via the tap/engine path). +5. Brain replies (from MockRealtimeBrain or the in-process tap): + - Routed back into the SimPipe's reply_ring via an mpsc the SimCall holds. + - loop_driver::drive picks them up on next_pcm_frame, encodes if real-WebRTC, but for sim + we just observe — captures CallerHeardReply timestamp. +6. Reflex barge-in (slice-4 already merged): + - If the SimPipe emitted SpeakLoud frames, the LocalVadReflex

(which wraps the pipe + in the session_map/MediaThread composition site, slice-4 Task 6) trips the SpeechStarted + advisory → Reflex

::muted = true → next_pcm_frame returns None → capture + BargeKillObserved timestamp. +7. On End step: harness stops driving, returns LatencyProbe. +``` + +### 4.2 Concurrency sweep (the doctrine detector) + +``` +1. ConcurrencyRunner::run(scenario) launches N Tokio tasks, each running SimCall::run + against the SAME media_cmd_tx (shared media thread). +2. The MediaThread drives ALL N sessions per 10 ms meta-tick (slice-4 §2.2 unchanged shape). +3. Per second during the sweep: ConcurrencyRunner fires MediaCmd::Stats; accumulates + tick_overruns + last_tick_micros samples. +4. On all SimCalls completing: aggregate per-call LatencyProbes → p50/p99 vector. +5. Build SweepReport with per-concurrency rows, asserting thresholds.rs constants. +``` + +### 4.3 Why in-process (not client-server) + +The harness DOES NOT stand up the binary as a server + a separate sim-client process. Why: + +1. **Determinism.** Loopback within the same process eliminates websocket / socket / TCP jitter as + a confounder. The thresholds are assertions about the FOB reflex loop itself — not assertions + about NTPD variance on the CI runner. +2. **CI simplicity.** `cargo test --features=sim-bench` runs in-process; no port binding, no + test-orchestration container, no race against `epoll` initialization. +3. **Direct seam access.** The harness can construct a `SimAudioPipe` and ship it via + `MediaCmd::RegisterSim` directly — same path the production binary would use if it had a + "synthetic caller" feature, no client-server glue needed. + +A separate client-server mode (true loopback against the binary's HTTP/WebSocket surface) IS +deferred — it's needed when the harness gains LLM-driven callers (post-spearhead refinement) and +needs network realism. + +--- + +## 5. Measurement plan + thresholds + +### 5.1 The budgets (concrete numbers) + +| Metric | Budget (slice-4 design) | CI assertion (slice 4½) | Rationale for slack | +|---|---|---|---| +| Barge-in kill-time, p99 | ≤60 ms (3 debounce × 20 ms + 1 drain tick) | ≤80 ms | CI runner has known variance against dev; the budget is 60 ms; the assertion is 80 to avoid flakiness. | +| Mouth-to-ear round-trip, p99 | ≤200 ms slice-1 + ≤300 ms mock brain + ≤100 ms playout = ~600 ms | ≤700 ms | Same logic; the mock brain is deterministic but the harness adds observer cost. | +| Tick-lag (max prev-poll duration) | unspecified | ≤10 ms | The slice-5/seams META_TICK const; the invariant the assertion makes explicit. | +| Tick overruns (fraction of ticks > 10 ms) | unspecified | ≤1% | At 50 calls × 1000 ticks each = ≥99% need to be ≤10 ms. Allows for one scheduling hiccup per ~99 well-behaved ticks. | + +### 5.2 Per-concurrency swept assertions + +``` +For each N ∈ [1, 10, 50]: + run scenario loud-barge.toml against N concurrent SimCalls. + assert p99_kill_ms <= 80 ms; + assert p99_mouth_to_ear_ms <= 700 ms; + assert max_tick_lag_micros <= 10_000; // 10 ms + assert tick_overrun_pct <= 1.0; +``` + +### 5.3 The scenarios (3 shipped) + +| Scenario | Path | What it asserts | +|---|---|---| +| `loud-barge.toml` | Caller speaks 20 loud frames → awaits reply → end. | The PRIMARY barge-in path (slice-4 §5.1): local VAD fires, kill within ≤80 ms at p99, NO brain advisory required. | +| `quiet-advisory.toml` | Caller speaks 20 quiet frames (sub-VAD-threshold) → awaits reply → end. | The SECONDARY barge-in path (slice-4 §5.2): brain advisory fires from `MockRealtimeBrain`, kill flows through slice-3 plumbing + slice-4 `advisory_tx` → `Reflex`. | +| `sustained-call.toml` | Caller speaks 10 loud → 10 quiet → 10 loud → 10 quiet → 10 loud → end (5 minutes of talk). The fatigue / sustained-load check. | Multi-barge: 3 `SpeechStarted` advisories should fire in sequence; the `Reflex::barge_epoch` increments 3×; latency is asserted across all three bars (the second + third bar shouldn't drift > 1.5× the first). | + +### 5.4 CI integration + +The CI workflow gains a new job: + +```yaml +# .github/workflows/ci.yml (additive) + + sim-bench: + name: sim-bench (stable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install libopus + run: sudo apt-get update && sudo apt-get install -y libopus-dev + - name: Run sim-bench threshold sweep + run: cargo test --all --features=sim-bench -- --test-threads=1 +``` + +Notes: +- `--test-threads=1` — concurrent sim-bench tests would pollute each other's `MediaStats` polling + (the tick-lag gauge measures the SHARED media thread; concurrent runs of the sweep would + contaminate each other). +- Run on `stable` only — the matrix already runs `cargo test --all` on stable + 1.85; the bench + feature lives on stable. + +### 5.5 The thresholds-as-test contract + +```rust +// crates/rutster-sim/src/thresholds.rs (continuation — the CI test entry) + +#[cfg(test)] +mod threshold_assertions { + use super::*; + + #[tokio::test] + #[cfg(feature = "sim-bench")] + async fn loud_barge_at_each_concurrency_passes_thresholds() { + let scenario = Scenario::load("scenarios/loud-barge.toml").unwrap(); + for &n in SWEEP_CONCURRENCIES { + let report = ConcurrencyRunner::in_process(n).run(scenario.clone()).await; + let row = report.per_concurrency.iter() + .find(|r| r.concurrency == n).expect("concurrency row"); + + assert!( + row.p99_kill_ms <= BARGE_IN_KILL_TIME_P99_MS, + "p99 kill-time at N={}: {}ms > {}ms (budget overflow)", + n, row.p99_kill_ms, BARGE_IN_KILL_TIME_P99_MS, + ); + assert!( + row.p99_mouth_to_ear_ms <= MOUTH_TO_EAR_P99_MS, + "p99 mouth-to-ear at N={}: {}ms > {}ms", + n, row.p99_mouth_to_ear_ms, MOUTH_TO_EAR_P99_MS, + ); + assert!( + (row.max_tick_lag_micros as f64) / 1000.0 <= TICK_LAG_MAX_MS, + "max tick-lag at N={}: {}us > {}ms", + n, row.max_tick_lag_micros, TICK_LAG_MAX_MS, + ); + assert!( + row.tick_overrun_pct <= TICK_OVERRUN_PCT_MAX, + "tick overrun % at N={}: {}% > {}%", + n, row.tick_overrun_pct, TICK_OVERRUN_PCT_MAX, + ); + } + } + + #[tokio::test] + #[cfg(feature = "sim-bench")] + async fn quiet_advisory_at_1_concurrency_passes_thresholds() { /* ... */ } + + #[tokio::test] + #[cfg(feature = "sim-bench")] + async fn sustained_call_multibarge_does_not_drift() { /* ... */ } +} +``` + +--- + +## 6. Why these decisions + +### 6.1 Why a new crate (`crates/rutster-sim/`) instead of in-tree tests + +A test under `crates/rutster/tests/sim_*.rs` would absorb the harness into the binary crate — +encouraging the harness to depend on binary internals. The separate crate keeps the dependency +direction: `rutster-sim` → `rutster-media` + `rutster-call-model` + `rutster` (for `MediaCmd`). +The sim crate is a **FOB member** per ADR-0008: hot-path-adjacent (drives the loop), differentiating +(the proof artifact). It earns cratehood the same way `rutster-tap` did. + +### 6.2 Why scripted scenarios, not LLM-driven callers (deferred) + +- **Reproducibility** is the entire point. A CI gate that asserts "p99 ≤ 80 ms" can't be flaky on + LLM variance — the LLM might take 2 s to respond, the threshold fails, the build is red, the dev + attributes it to the LLM provider. The signal is corrupted. +- **Isolation** of what's being measured: scripted scenarios measure the FOB + the loop + the + reflex; LLM callers introduce brain-side variance that isn't a FOB property. +- LLM-driven callers land in a post-spearhead refinement tier (ADR-0010 explicit deferral). They + measure a different question ("how does it feel to talk to my brain?") than the MVP harness asks + ("does the FOB reflex loop meet its budget under load?"). + +### 6.3 Why in-process, not client-server (the false-precision risk) + +A client-server test harness gives Network-Realism™ but takes away the **measurement discipline**: a +TCP retransmit on the CI runner would inflate p99 unattributably, and a "drift over threshold" +failure becomes a triage time-sink ("is the FOB regressing, or did the CI runner's network blip?"). +The in-process mode gives **single-process determinism** at the cost of network realism — which the +spearhead explicitly doesn't need (it's measuring the FOB loop, not the deployed system). A +future post-spearhead tier adds a client-server mode for integration realism + a different set of +thresholds. + +### 6.4 Why tick-lag as a primary readout (the doctrine-drift contract) + +The single-thread media loop (slice-4 §6.3) is a spearhead-scale decision explicitly deferred to a +"load demands it" trigger. Without the harness measuring it under load, the deferral has no +data-driven graduation criterion — it'd be doctrine ("faster per-session threads later") instead of +evidence ("measured `tick_overrun_pct = 12%` at 50 calls, the threadpool shard lands now"). ADR-0010 +explicitly pairs the debt — `MediaStats.tick_overruns` exists because of slice-5/seams' seam +work; slice 4½ makes the gauge OBSERVED. Both pass and the single-thread design is validated; both +breach and the graduation is scheduled with data. + +### 6.5 Why `--features=sim-bench` (default off) + +`cargo test --all` runs in 12 s on the CI runner today (fmt+clippy+test sweep, no bench). +Turning the threshold sweep on by default would either (a) slow down every PR's turnaround to the +sim sweep duration (estimated 30–60 s, dominated by the 50-concurrency sweep), or (b) make the +threshold sweep's failure mode just-another-failing-test that gets ignored. The opt-in feature +makes the threshold sweep a **separate concern with its own CI job**, surfacing its results +prominently in the PR status checks, and small enough to not block the routine `cargo test --all` +gate. This mirrors how performance-sensitive open-source projects gate `cargo bench` PRs. + +### 6.6 Why p99 (not p50) as the load-bearing assertion + +p50 = "the typical experience." p99 = "the worst acceptable case." The contact-center wedge +(README §"Why it exists") rests on tight-tail-latency: a p50 of 60 ms with a p99 of 1.5 s is +INDISTINGUISHABLE from a cloud CCaaS provider that averaged down to 60 ms but had bad tails. The +assertion HAS to fire on the tail to be meaningful. + +p999 (or max) IS deferred — too noisy for CI gate assertions (one scheduler hiccup blowing past the +ceiling would block every PR for unrelated reasons). p99 is the empirical sweet spot: sensitive +enough to catch real regressions, lenient enough to survive CI runner variance. + +--- + +## 7. Done-criteria + +1. `cargo test --all` passes (stable + 1.85) — the routine gate, UNCHANGED. The sim-bench feature + is opt-in; default `cargo test --all` does NOT run the threshold sweep. +2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean on the new crate. +3. `cargo test --all --features=sim-bench` passes — the new gate, on stable. CI runs this in a + separate `sim-bench` job per PR + nightly. +4. `cargo deny check` passes — no new dep conflicts (`toml` is already a workspace member; + `serde` already workspace member). +5. `cargo doc --no-deps` renders the new `crates/rutster-sim/` cleanly with learner-facing + comments per AGENTS.md code style. +6. Loop driver + rtc_session seam STILL holds: `loop_driver.rs` + `rtc_session.rs` byte-identical to + slice-3 (CI pinned-blob gate from slice-4 Task 10 unchanged). The new `MediaCmd::RegisterSim` + variant lives in `media_thread.rs`, NOT in the seam files. +7. The lance `loud-barge.toml` scenario passes the threshold sweep at all of [1, 10, 50] + concurrency. +8. The `quiet-advisory.toml` scenario passes at 1 concurrency (the secondary-path focus). +9. The `sustained-call.toml` scenario's 3-barge sequence shows performance drift ≤ 1.5× across + barges (anti-fatigue assertion). +10. Tick-lag gauge reads a `MediaStats.{tick_overruns, last_tick_micros}` value during the sweep + and surfaces it in the SweepReport. +11. SweepReport's per-concurrency rows are logged to stderr in a structured format (CI failure + messages are readable; "p99 kill-time at N=50: 84ms > 80ms" not "test_sim_thresholds + failed"). +12. The single-thread-vs-threadpool question has a data-point answer documented ("slice 4½ found p99 + tick-lag = Xms at 50 calls; the threadpool shard remains deferred / the threadpool shard + should land now"). Even if the answer is "data confirms the deferral is fine for now," the + decision is no longer vibe-based. + +--- + +## 8. Open decisions + +### 8.1 Should the harness also assert against the HTTP/WebSocket out-of-process surface? + +**Decision (slice 4½):** no. In-process measurement only. The out-of-process mode is a future +post-spearhead refinement (paired with LLM-driven callers). Reasoning in §6.3. + +### 8.2 Should the sim-bench CI job also run on the 1.85 toolchain? + +**Decision (slice 4½):** no. The matrix already runs `cargo test --all` on both. The sim-bench +job is a stable-only opt-in feature; 1.85 doesn't get a second tier of benches. Rationale: +the sim-bench feature is `cfg(feature)` — feature-gated code paths need their own gates, not a +toolchain proliferation. + +### 8.3 Threshold values: hardcodedconsts vs env-overridable for the CI runner operator? + +**Decision (slice 4½):** hardcoded consts in `thresholds.rs`. Post-spearhead, with the +per-environment tuning framework (paired with slice-4 §1.2 VAD-threshold tuning deferral), they +become env-driven. Hardcoded now makes the budget-vs-assertion-slack reasoning (§5.1) explicit in +source — not subject to runner-env drift. + +### 8.4 Should `MediaCmd::RegisterSim` carry a `pipe: Box` or a more structured +"sim descriptor" the media thread materializes? + +**Decision (slice 4½):** `Box` — same shape as the existing `RtcSession`'s pipe +construction (which has `pipe: Box`). A "sim descriptor" would add a layer of +indirection the harness doesn't benefit from; the harness already constructs the `SimAudioPipe` +and is fully prepared to ship it across the channel. The single variant is the minimal seam. + +### 8.5 Concurrency-sweep sample size / iteration count per concurrency level + +**Decision (slice 4½):** 1000 ticks per concurrency level (the meta-tick count for the sweep +durations). At 10 ms per tick = 10 seconds of sweep per concurrency × 3 levels = 30s sweep total. +Sufficient sample size for a stable p99 (n = ~1000 ticks); bounded enough to keep CI fast. + +### 8.6 Should the harness record per-call audio (WAV capture of the `SimAudioPipe`'s frames) for +supervisor review? + +**Decision (slice 4½):** no. That's a rung-2 escalation feature (warm-handoff artifact). The +harness measures; recording is a separate concern. The `SimAudioPipe` exposes a `take_captures()` +API for the `LatencyProbe` only; raw frame capture is out of scope. + +--- + +## 9. Cross-references + +- [ADR-0010](../../adr/0010-spearhead-benchmark-sim-harness.md) — centralized rationale: why 4½ + exists, what it should produce, what it should defer (LLM callers), how it pairs the + timing-thread debt. +- [slice-4 spec §5.1](2026-07-01-slice-4-barge-in-design.md) — the ≤60 ms kill budget + the + latency-arithmetic this slice asserts against. +- [slice-1 spec §8.5 #6](2026-06-28-slice-1-webrtc-loopback-design.md) — the seam gate (`loop_driver.rs` + + `rtc_session.rs` byte-identical) slice 4½ re-affirms (NO changes to those files). +- [slice-5/seams plan](../plans/2026-07-04-slice-5-scalability-seams.md) (the infrastructure + pre-paving this slice consumes) — `MediaCmd::Stats` exposes `MediaStats { tick_overruns, + last_tick_micros }`, the readout this slice's concurrency sweep surfaces. +- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — the fused vertical; the in-process + measurement IS the fused-vertical seam (no gRPC hop between the harness + the loop). +- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB/green-zone doctrine; the harness is a FOB + member (hot-path-adjacent + differentiating). No green-zone dep added. +- [PORT_PLAN.md §Phasing](../../PORT_PLAN.md) — step 4½ = sim harness (per ADR-0010 insertion). diff --git a/docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md b/docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md new file mode 100644 index 0000000..e0c98c2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md @@ -0,0 +1,1084 @@ +# Rutster slice 5 — Rented transport: a real phone number via Twilio Media Streams + +- **Status:** Draft (pending review) +- **Date:** 2026-07-05 +- **Spearhead step:** 5 of 6 ([ADR-0007](../../adr/0007-trunk-rented-transport.md) — rent the + transport, no first-party SIP) +- **Origin:** [ADR-0007](../../adr/0007-trunk-rented-transport.md) (2026-06 strategic-relevance + review — owns no SIP stack; carrier/PSTN reach is rented transport in three layers; rutster + owns only the top one). The spearhead list (PORT_PLAN §Phasing) puts step 5 as the + "real phone number" demo. +- **Depends on (already merged):** + - [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) — the media + core + `AudioPipe` trait + - [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md) — `TapAudioPipe` (REUSED + as the trunk leg's brain-side AudioPipe) and the "core-authoritative playout buffer" invariant + - 2026-06-30 slice-3 realtime brain — `MockRealtimeBrain`, the `speech_started` / + `speech_stopped` advisory events + - [slice 4 — Barge-in / VAD-driven playout kill](2026-07-01-slice-4-barge-in-design.md) — + `Reflex

` + `LocalVadReflex

` decorators (decorating `TapAudioPipe` reused identically + on the trunk leg) + the `MediaThread` std-thread graduation + - 2026-07-04 slice-5/seams (the *infra* slice, NOT this slice despite the same number): + `config.rs` env-parser pattern, `event_sink.rs`, `MediaCmd::Stats`, + non-blocking tap teardown, the drain lifecycle, the advertised media address + (`MediaAddressConfig`) — every one of these seams this slice inherits +- **Naming note:** this is spearhead "step 5" (real phone number). It is NOT the merged + "slice-5/seams" infra plan (which *pre-paves* this slice per its "Naming note"). See the + strategic plan §5.5. +- **Related:** [ADR-0008](../../adr/0008-fob-and-green-zone.md) (FOB/green-zone doctrine — split + for this slice: FOB owns the WebSocket ingress + codec + the trunk-side tick driver; green-zone + owns the REST call-control client), [ADR-0009](../../adr/0009-spend-gate-honest-rescope.md) + (provider credentials never reach the brain), [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md) + (the trunk leg participates in the same 20 ms tick on the dedicated thread) + +--- + +## TL;DR + +Stand up spearhead step 5: **a real phone number** with no first-party SIP stack. The MVP is +**Twilio Media Streams** as a CPaaS raw-audio fork (layer 1 of ADR-0007's three-layer doctrine): +Twilio answers the PSTN call + forks its audio over a WebSocket to rutster; call control +(answer / hangup / originate) is Twilio's REST API, completely external to the FOB. + +The PSTN audio enters the FOB reflex loop as a **second-leg-kind** alongside WebRTC — but the +reflex stack + AudioPipe are reused unchanged. The architectural simplification this slice +deliberately exploits: a `MediaThread` session is the wrapped stack +`LocalVadReflex>`, where the inner `TapAudioPipe` was introduced in slice-2 +as an abstraction over "the brain's WS audio." That SAME TapAudioPipe serves a trunk leg +identically — the only difference is *what fills its inbound caller-PCM mpsc*: str0m's +`MediaData` event (for WebRTC) versus the `TwilioMediaStreamsServer`'s WSS-pump task (for trunk). +The leg-kind decides the **tick driver function**; the wrapped pipe + the brain wiring are +reused verbatim. + +A new `trunk_driver::drive(&mut TrunkSession, now)` parallels `loop_driver::drive(&mut RtcSession, +now)` — same shape, no str0m/Opus/RTP footprint. The slice-4 seam gate stays green: `loop_driver.rs` ++ `rtc_session.rs` byte-identical because the trunk leg never enters that code path. + +**Five things this slice deliberately is NOT:** +- **Not a SIP stack.** Not even a thinnest sliver of one. rutster parses zero SIP bytes + ([ADR-0007](../../adr/0007-trunk-rented-transport.md)). +- **Not a managed Voice-AI product integration** (Twilio ConversationRelay, Telnyx Voice AI). + These would consume the reflex loop that is rutster's differentiation; ADR-0007 explicitly forbids. +- **Not a Layer-2 out-of-tree SBC adapter** (the on-prem graduation case). ADR-0007 layer 2 is + deferred to a graduation rung, not the spearhead's MVP. +- **Not a multi-tenant carrier management surface.** One Twilio account; one provider config; one + active media-streams endpoint per binary. Multi-tenant is later rung. +- **Not an inbound endpoint registration server (desk phones).** ADR-0007 explicitly out-of-tree + and never first-party. + +--- + +## 1. Scope + +### 1.1 In scope + +- `crates/rutster-trunk/` filled in (currently a stub; ADR-0007 + slice-1 §2.2 pre-paved the crate + boundary). Now contains the rented-transport ingress. +- **`G711Codec`** — an in-core µ-law (G.711) codec: encode/decode (table-driven, learner-facing + explanation of the µ-law companding formula — a fascinating piece of telephony history) + the + 8 kHz ↔ 24 kHz resampling (linear interpolation; rutster's `PcmFrame` is 24 kHz, Twilio's + raw-audio fork is 8 kHz µ-law). No new dep; pure std. +- **`TwilioMediaStreamsServer`** — a tokio WebSocket server (axum; routable on the existing binary + HTTP router) that accepts inbound Twilio Media Streams WSS connections, parses the provider's + JSON envelope (`connected` / `start` / `media` / `stop` per Twilio's documented protocol), and + ferries base64-encoded µ-law audio into a `tokio::sync::mpsc::Sender` consumed by the + FOB media thread. +- **`TrunkSession`** — the per-trunk-leg session struct. Parallels `RtcSession` minus str0m/Opus/ + UDP-socket: holds the wrapped pipe `LocalVadReflex>` (REUSED from slice-4), + the inbound-from-Twilio mpsc Receiver, the outbound-to-Twilio mpsc Sender, + bookkeeping + (`last_idle_rx`, `next_timeout`). +- **`trunk_driver::drive(&mut TrunkSession, now) -> Option`** — the trunk-leg tick + function. Parallels `loop_driver::drive` minus the str0m/Opus/RTP machinery: drains + inbound mpsc → calls `session.pipe.on_pcm_frame(frame)` (which feeds into the wrapped + LocalVadReflex>, which forwards to the brain via tx_pcm_in as in slice-2); + on the outbound boundary pulls `session.pipe.next_pcm_frame()` + pushes to the outbound-to-Twilio + mpsc. Function lives in `crates/rutster-trunk/src/loop_driver.rs` (NOT in + `crates/rutster-media/src/loop_driver.rs` — the binary-side trunk crate owns its own driver). +- **`CallControlClient` trait + `MockCallControlClient` + `TwilioCallControlClient`** — the + provider call-control abstraction. Answer/hangup/originate operations REST against Twilio's + Call Control API (green-zone); the trait locks the seam so the next provider (Telnyx later) is + an implementation, not a refactor. +- **`TwilioCredentials` config struct + env parser** — added to + `crates/rutster/src/config.rs` (slice-5/seams established the pattern). Account SID + auth token + + media-streams bind URL + signing-key validation (the MVP validates the connection, not the + REST signature — env-configurable to upgrade). +- **`MediaCmd::RegisterTrunk` variant** — extends slice-5/seams' existing `MediaCmd` enum with a + variant the binary's REST/routes layer uses when a Twilio Media Streams WSS connection arrives. + The MediaThread constructs a `TrunkSession`, wraps its inner `TapAudioPipe` in + `Reflex` then `LocalVadReflex>` (same composition as slice-4 + Task 6 for WebRTC legs), and inserts it in the session map keyed by `ChannelId`. +- **`MediaLeg` enum** — in `crates/rutster/src/media_thread.rs` (binary-side, not media-crate): + `enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }`. The MediaThread's tick loop + dispatches on this enum + calls the corresponding driver. The slice-4 WebRTC code path stays + byte-identical because `loop_driver::drive` is called only from the `WebRTC` variant's match arm + (not changed). +- **NEW HTTP routes:** + - `POST /v1/trunk/sessions` — initiates an outbound call (green-zone path: routes via + `TwilioCallControlClient` REST; credentials in config, never passed through to the brain). + - `POST /v1/trunk/webhook` — Twilio's webhook receiver for inbound-call signaling ("Twilio is + calling you back; here's the CallSid — open a Media Streams WebSocket for it"). Optional: if + no webhook configured, inbound calls are accept-all. +- **Reflex-on-trunk-leg verification test** — proves slice-4's + `Reflex` + `LocalVadReflex` decorate the trunk leg identically to a WebRTC leg; + barge-in fires on PSTN caller speech the same as on WebRTC caller speech. The reflex stack IS + the same code path, so this test asserts "[trunk leg] behaves like [WebRTC leg] modulo the + inbound-PCM-source channel" — a meaningful invariant, not a tautology. +- **PSTN sim e2e integration test** — an in-process Twilio simulator (mock Twilio Media Streams + server + MockCallControlClient) drives a synthetic PSTN caller through the FOB reflex loop; + asserts barge-in fires + CDR/EventSink emission. +- **QUICKSTART + README updates** — env-var table for Twilio credentials; "make a real phone + call" walkthrough. + +### 1.2 Out of scope (with scheduled return) + +| Deferred item | Returns in | Why deferred | +|---|---|---| +| Layer-2 out-of-tree SBC adapter (Kamailio / FreeSWITCH / drachtio + rtpengine) | graduation rung | ADR-0007 layer 2; on-prem sovereignty case; not spearhead-scale. | +| Telnyx (or other provider) raw-media-fork impl | post-step-5 refinement | The trait locks the seam; the second impl is a project, not a refactor. ADR-0007 explicitly lists Twilio OR Telnyx as MVP candidates; we ship Twilio first. | +| Twilio ConversationRelay or other managed Voice-AI product integration | never | ADR-0007 explicit; consuming the reflex loop is a competitive-architecture betrayal. | +| Inbound endpoint registration (desk phones, soft phones) | never first-party | ADR-0007 explicit green-zone / out-of-tree; defeats the WebRTC-first / SSO UX. | +| Outbound registration to a carrier SIP trunk | never first-party | ADR-0007: the rented transport or the out-of-tree SBC owns carrier registration. | +| Multi-tenant Twilio account management | later rung | One Twilio account; SaaS multi-tenant carrier layer is post-rung-2-escalation. | +| Media Streams TwiML voice preview / `` / `` | never in core | Twilio-managed Voice-AI redirect — explicit ADR-0007 violation. | +| Outbound origination beyond `` semantics (simultaneous ring, etc.) | later rung | The MVP supports "originate one outbound call" via the REST API; richer origination is contact-center work. | +| Authn/authz on the new `/v1/trunk/*` routes | slice-6 (spend cap) | Same deferral as slice-1/slice-2 — authn was deferred to step 6. The new routes are not pre-production exposed. | +| TLS / WSS termination on the Twilio Media Streams server binding | post-step-5 | Media Streams sends audio over WSS; Twilio accepts reverse-proxy TLS termination. Localhost dev keeps plaintext WS; production termination is a deployment concern. | +| Spend cap enforcement on originated calls | step-6 (spearhead 6) | The spend/abuse gate (ADR-0009) is spearhead 6; this slice leaves the seam open (`CallControlClient::originate` accepts an optional `spend_token: Option` arg as the pre-paved hook). Not enforced in this slice. | +| DTMF tones through the trunk leg | later rung | Twilio Media Streams does deliver DTMF events; the FOB doesn't yet parse them. Banks/IVR menus need them; that's contact-center-domain work. | +| Fax / T.38 | never | Modern telephony; out of spearhead. | +| Recording on the trunk leg (CDR audio capture) | rung-2 escalation (warm-handoff artifact) | Same deferral as slice 4½'s "no per-call audio recording" call. | +| Concurrent trunk legs beyond proof-of-concept scale (dozens+) | later rung | Same single-thread media-loop cap as WebRTC legs; defer threadpool shard work to the slice 4½ / slice-5-debt graduation tier. | +| `rubato` / speexdsp resampler for 8 kHz ↔ 24 kHz | post-spearhead | Linear interpolation is the cheap, correct-enough answer for the spearhead MVP — the wedge claim is "the reflex loop works against real phone audio"; the resampler artifacts are below the barge-in trigger threshold (RMS energy). | + +--- + +## 2. Architecture delta + +### 2.1 What changes vs slice-4 + slice-5/seams + +Slice 5 adds ONE crate-internal fillin (`crates/rutster-trunk/` already exists as a stub; this +slice populates it) + ONE new `MediaLeg` enum + ONE new `MediaCmd` variant + TWO new HTTP routes. +The fused vertical's existing hot path (`loop_driver.rs` + `rtc_session.rs` + `MediaThread` + +`Reflex

` + `TapAudioPipe` + `LocalVadReflex

`) is **untouched** — the trunk leg shares the same +reflex stack, the same `TapAudioPipe` composition, the same playout buffer mechanics. + +``` + ┌─ Twilio (cloud; green zone) ────────────┐ + │ PSTN caller → Twilio Media Streams │ + │ JSON "Start" + "Media" + "Stop" frames │ + │ (base64 µ-law @ 8kHz) + REST Call Control │ + └────────────────────────┬─────────────────┘ + │ inbound WSS (their push) + ▼ +┌─────────── Rutster trust boundary (FOB) ──────────────────────────────────────────────────┐ +│ │ +│ axum router (existing) + NEW routes: │ +│ POST /v1/trunk/sessions POST /v1/trunk/webhook │ +│ │ │ │ +│ ▼ ▼ │ +│ TwilioCallControlClient TwilioMediaStreamsServer (NEW, FOB) │ +│ (green-zone): REST call (axum WebSocket handler) │ +│ control via reqwest • accepts Twilio's WSS │ +│ • parses JSON envelope │ +│ • decodes base64 µ-law via G711Codec → 24kHz PcmFrame │ +│ • pushes PcmFrame to a per-call `inbound_from_twilio_tx` │ +│ mpsc (Receiver handed to MediaThread via RegisterTrunk) │ +│ • credentials NEVER reach the brain (ADR-0009 amendment) │ +│ │ +│ │ │ │ +│ ▼ ▼ │ +│ MediaCmd::Originate (NEW) MediaCmd::RegisterTrunk (NEW) │ +│ └────────────┐ ┌─────────────┘ │ +│ ▼ ▼ │ +│ MediaThread's session map: HashMap │ +│ │ +│ MediaLeg enum (NEW, in crates/rutster/src/media_thread.rs): │ +│ enum MediaLeg { │ +│ WebRTC(RtcSession), // unchanged code path │ +│ Trunk(TrunkSession), // new │ +│ } │ +│ │ +│ The std thread tick loop dispatches: │ +│ match leg { │ +│ MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED │ +│ MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW │ +│ } │ +│ │ +│ trunk_driver::drive (NEW, in crates/rutster-trunk/src/loop_driver.rs): │ +│ 1. Drain session.inbound_from_twilio_rx via try_recv │ +│ → for each PcmFrame: session.pipe.on_pcm_frame(frame) (hits LocalVadReflex │ +│ → Reflex → TapAudioPipe → tx_pcm_in → TapEngine → brain WS) │ +│ 2. Outbound tick (every 20 ms): pull session.pipe.next_pcm_frame() │ +│ → push to session.outbound_to_twilio_tx (the WSS pump task drains + │ +│ encodes µ-law via G711Codec + sends back to Twilio) │ +│ 3. Idle timeout check │ +│ │ +│ TrunkSession (NEW, in crates/rutster-trunk/src/session.rs): │ +│ • pipe: LocalVadReflex> ← REUSED from slice-4 verbatim │ +│ • inbound_from_twilio_rx: tokio::sync::mpsc::Receiver │ +│ • outbound_to_twilio_tx: tokio::sync::mpsc::Sender │ +│ • last_idle_rx, next_timeout, channel-state bookkeeping │ +│ │ +│ ▼ │ +│ loop_driver.rs + rtc_session.rs (byte-identical, untouched — slice-4 seam HOLD) │ +│ │ +│ CallControlClient trait (NEW, green-zone seam): │ +│ async fn originate(&self, to, from, spend_token: Option) │ +│ async fn hangup(&self, correlation_id) │ +│ • TwilioCallControlClient: reqwest POST to Twilio REST │ +│ • MockCallControlClient: in-process test double │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 The FOB / green-zone split (load-bearing — ADR-0008 application) + +Per ADR-0008, every capability passes one of three tests: hot-path, security-constitutive, or +differentiating. Slice 5 splits cleanly: + +**FOB members (this slice):** +- `TwilioMediaStreamsServer` — hot path (its decoded `PcmFrame`s feed the 20 ms tick); the WSS + acceptor runs on the tokio runtime but produces PCM for the std thread via mpsc (same cold-path + vs hot-path split as slice-2's `TapEngine`). +- `G711Codec` (µ-law encode/decode + 8 kHz ↔ 24 kHz resampling) — hot path; runs on the 20 ms + tick inside the WSS pump task (decode) + inside the WSS pump task's send loop (encode). +- `TrunkSession` + `trunk_driver::drive` — hot path; the per-tick trunk driver is the vtable for + the FOB's loop touching the trunk leg. +- `Reflex` + `LocalVadReflex>` (REUSED from slice-4, + instantiated against the trunk leg's TapAudioPipe instance, NO new code). + +**Green-zone member (this slice):** +- `TwilioCallControlClient` — REST API client; not on the per-call hot path (only fires on + originate + on inbound-call webhook receipt); arm's length via trait + reqwest dep; **the + brain never holds Twilio credentials** (the slice-5/seams-merged ADR-0009 amendment demands). + +### 2.3 The reflex loop applies symmetrically (the architecture's load-bearing claim) + +Slice 4 introduced `Reflex

` + `LocalVadReflex

` as **AudioPipe-agnostic decorators** (slice-4 +§3.2 + §6.4). The wedge argument: the FOB's reflex kills playout when the caller speaks, +regardless of which ingress path the audio came in on. Step 5 tests this by reusing the EXACT +slice-4 stack against a trunk leg: a PSTN caller through a Twilio Media Streams leg must be +barge-in-killed the same way a WebRTC caller is — same `Reflex` state machine, same +`LocalVadReflex` RMS-detector input, same `barge_in_flush` semantics. + +If the trunk leg somehow required a parallel "trunk-specific" barge code path, that would be an +architectural smell that the slice-4 reflex trait wasn't actually pipe-agnostic — surface it as +a question to PM, don't fork the design silently. + +### 2.4 The seam invariant (re-affirmed) + +`crates/rutster-media/src/loop_driver.rs` + `crates/rutster-media/src/rtc_session.rs` stay +byte-identical through this slice. The slice-4 Task 10 pinned-blob CI gate continues guarding +unchanged. The trunk leg's tick lives entirely in `crates/rutster-trunk/src/loop_driver.rs` (a +DIFFERENT but parallel-titled file in a different crate). The existing `RtcSession` codepath +(for WebRTC legs) is untouched. + +The `MediaLeg` enum lives in `crates/rutster/src/media_thread.rs` (binary-side, not media-crate) +— same locality that already houses the bridge between the binary's axum/runtime and the std +thread. The match arm dispatching `MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` is one new +line in `media_thread.rs` (the existing call site for `loop_driver::drive` redirects through it); +`MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` is its sibling. + +### 2.5 ADR-0007 honored: rutster parses zero SIP + +The hot path's wire surface in this slice is: + +1. WebRTC RTP/SRTP (slice-1, unchanged — only on WebRTC legs) +2. WebSocket TCP frames carrying JSON envelope (Twilio Media Streams protocol — a documented + application protocol, NOT SIP) +3. HTTP/REST JSON (Twilio Call Control API — outbound only, green-zone) + +**Zero SIP bytes are parsed.** The memory-safety claim (ARCHITECTURE.md §"Memory-safe by +construction") remains literally true at the wire: every byte the FOB parses is JSON or RTP, never +SIP. ADR-0007's central promise holds for step 5 definitionally, not as a stretch. + +### 2.6 ADR-0009 honored: provider credentials never reach the brain + +Per the slice-5/seams-merged ADR-0009 amendment ("enforcement locality vs accounting locality"): +the spend gate + provider credentials live INSIDE the trust boundary; the brain is OUTSIDE. +Concretely: + +- `TwilioCredentials` (account_sid + auth_token) live in the binary's process env, scoped to + `crates/rutster-trunk/`. The `TwilioCallControlClient` reads them at REST-call time. +- The actual outbound REST call is made by the binary (`POST /v1/trunk/sessions` → triggers + `TwilioCallControlClient::originate` → Twilio REST). The brain never sees the URL. +- The brain holds ONLY the media fork's WebSocket URL (the address Twilio tells you to listen + on for the audio stream) + the per-call `CallSid` (an opaque correlation ID). Neither is a + credential. + +This slice does NOT enforce spend-caps yet (step 6 lands that) but pre-paves the seam: +`CallControlClient::originate` takes an `Option` parameter (passed as `None` in this +slice; step 6's spend gate will populate it for real before any originate). The signature locks +the seam so step 6 is additive, not a refactor. + +--- + +## 3. Component design + +### 3.1 `G711Codec` (µ-law encode/decode + resampling) + +```rust +// crates/rutster-trunk/src/g711.rs + +//! # G.711 µ-law codec + 8 kHz ↔ 24 kHz resampling +//! +//! ## Why in-core, not a `g711` crate dep +//! +//! µ-law is ~30 lines of table-driven code. The codec has been standard since +//! 1972 (ITU-T G.711); it has not changed. Pulling a dependency for a +//! constant-mapping table would be a FOB hygiene violation (ADR-0008 — +//! link a maintained lib for *internals* a mature library already solves, +//! but only if the lib is healthy + the complexity is non-trivial; this +//! is neither). The implementation is also learner-facing: the µ-law +//! companding formula is a piece of telephony history worth teaching. + +use rutster_media::PcmFrame; + +/// 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T +/// G.711 standard's piecewise-linear decoding curve. Twilio Media Streams +/// delivers µ-law'd 8-bit samples at 8 kHz; we decode each to i16 before +/// resampling. The 256-byte table fits in L1; no memory-pressure concern. +static MULAW_TO_LINEAR: [i16; 256] = include!("./mulaw_decode_table.rs"); + +/// 16-bit linear to 8-bit µ-law encode table. Used when sending audio +/// BACK to Twilio — the FOB encodes its 24 kHz PCM output → 8 kHz µ-law. +static LINEAR_TO_MULAW: [u8; 65536] = include!("./mulaw_encode_table.rs"); + +pub struct G711Codec; + +impl G711Codec { + /// Decode a Twilio Media Streams "Media" frame payload (already + /// base64-decoded bytes of µ-law samples at 8 kHz) into a 24 kHz + /// PcmFrame (the slice-1 canonical format). 3× linear upsample. + /// + /// # Why 3× upsample by linear interpolation + /// + /// 24 kHz / 8 kHz = 3. Each input sample becomes 3 output samples: + /// out[3i] = input[i] + /// out[3i + 1] = (input[i] + input[i+1]) / 2 + /// out[3i + 2] = (2*input[i] + input[i+1]) / 3 + /// + /// Linear interpolation is the cheap correct-enough answer for the + /// spearhead MVP; `rubato` (or speexdsp resampler) would be the + /// production-grade answer ADR-0008 admits. The wedge claim is + /// "the reflex loop works against real phone audio"; the resampler + /// artifacts are below the audibility threshold for the barge-in + /// trigger (which only needs RMS energy above `VAD_RMS_THRESHOLD`). + pub fn decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame { + let linear_8k: Vec = mulaw.iter().map(|&b| MULAW_TO_LINEAR[b as usize]).collect(); + // SAFETY: Twilio Media Streams sends 160 µ-law bytes per 20 ms + // (8000 samples/sec * 0.020 sec = 160). debug_assert to detect + // protocol drift; the function still returns a zeroed frame on + // undersized input (hot-path policy). + debug_assert_eq!(linear_8k.len(), 160, "expected 160 µ-law bytes per 20ms frame"); + let mut samples = [0i16; 480]; // PcmFrame.samples is fixed-size [i16; 480] + let n = linear_8k.len().min(159); + for i in 0..n { + samples[3*i] = linear_8k[i]; + samples[3*i + 1] = ((linear_8k[i] as i32 + linear_8k[i+1] as i32) / 2) as i16; + samples[3*i + 2] = ((2*linear_8k[i] as i32 + linear_8k[i+1] as i32) / 3) as i16; + } + PcmFrame { samples } + } + + /// Encode a 24 kHz PcmFrame to 8 kHz µ-law bytes for Twilio Media Streams. + /// Inverse of `decode_mulaw_to_pcm`: 3× downsample by decimation (every + /// 3rd sample) + linear-to-µ-law table lookup. + pub fn encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec { + let mut mulaw = Vec::with_capacity(160); + for i in 0..160 { + let sample = frame.samples[3 * i]; + mulaw.push(LINEAR_TO_MULAW[(sample as u16) as usize]); + } + mulaw + } +} +``` + +### 3.2 `TwilioMediaStreamsServer` (the WebSocket ingress) + +```rust +// crates/rutster-trunk/src/twilio_media_streams.rs + +//! The inbound WSS server accepting Twilio Media Streams forks. +//! +//! ## Topology +//! +//! Twilio opens an outbound WSS connection TO us when a PSTN call is +//! answered. The connection carries JSON-encoded frames: `connected` +//! (handshake), `start` (metadata: CallSid, stream_sid, custom params), +//! `media` (base64-encoded µ-law audio, both directions on the SAME WS), +//! and `stop` (the call ended). Audio flows in real-time; the JSON +//! envelope is the entire protocol. +//! +//! ## Why tokio, not the std thread +//! +//! The WSS connection is async by nature (TcpListener::accept + WS upgrade +//! + JSON parse + base64 decode per frame). The dedicated 20 ms std-thread +//! is for the LOOP, not for IO. Same split as slice-2's TapEngine: tokio +//! task receives the WS, ferries µ-law → decoded PCM over mpsc to the std +//! thread, which consumes via `TrunkSession::on_pcm_frame` (called from +//! `trunk_driver::drive`). + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + routing::get, + Router, +}; +use tokio::sync::mpsc; +use rutster_media::PcmFrame; + +pub struct TwilioMediaStreamsServer; + +impl TwilioMediaStreamsServer { + pub fn router( + register_tx: mpsc::Sender, + ) -> Router { + Router::new() + .route("/twilio/media-stream", get(handle_media_stream)) + .with_state(register_tx) + } +} + +/// The mpsc abstraction handed from the WSS pump task to MediaThread: +/// a pair of mpsc ends (caller→FOB inbound + FOB→caller outbound) that +/// the TrunkSession::drive uses to talk to the WSS pump task. +pub struct RegisterTrunkInboundChannel { + pub call_sid: String, + pub inbound_from_twilio_rx: mpsc::Receiver, + pub outbound_to_twilio_tx: mpsc::Sender, + /// The brain's WS URL for THIS call's TapEngine (the operator configures + /// where the brain listens). The TapEngine wiring is exactly like the + /// WebRTC spawn seam from slice-2; the trunk leg's session reveals its + /// tap_url when handed to RegisterTrunk. + pub tap_url: url::Url, +} + +async fn handle_media_stream( + ws: WebSocketUpgrade, + axum::extract::State(register_tx): axum::extract::State>, +) -> impl axum::response::IntoResponse { + ws.on_upgrade(|socket| run_media_stream(socket, register_tx)) +} + +async fn run_media_stream(mut socket: WebSocket, register_tx: mpsc::Sender) { + // 1. Wait for "start" frame; extract CallSid + StreamSid + custom params. + // 2. Construct the two mpsc pairs (inbound + outbound, capacity 16 each). + // 3. Send a RegisterTrunkInboundChannel to MediaThread via register_tx. + // Wait for MediaThread's RegisterTrunk reply (a oneshot confirming the + // ChannelId) — the WSS pump then continues. + // 4. Loop: + // a. Read next WS message. If "media" frame, base64-decode the payload, + // G711Codec::decode_mulaw_to_pcm, push PcmFrame into inbound_tx. + // b. Concurrently drain outbound_rx (PcmFrame produced by the std + // thread via trunk_driver::drive): G711Codec::encode_pcm_to_mulaw, + // wrap in a JSON "media" envelope, send WS Text frame. + // c. On "stop" frame OR outbound_rx closed: tear down. + // (Implementation detail; learner-commented per AGENTS.md code style.) +} +``` + +### 3.3 `TrunkSession` + `trunk_driver::drive` + +```rust +// crates/rutster-trunk/src/session.rs + +//! The per-trunk-leg session struct. Parallels `RtcSession` minus the +//! str0m/Opus/UDP-socket footprint. The wrapped pipe is REUSED from slice-4 +//! verbatim — `LocalVadReflex>` — because a trunk +//! leg's brain audio is the same mpsc pattern as a WebRTC leg's. + +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use rutster_media::{PcmFrame, Reflex, LocalVadReflex, ReflexMetrics}; +use rutster_tap::TapAudioPipe; +use rutster_call_model::{Channel, ChannelId, ChannelState}; + +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +pub struct TrunkSession { + pub channel: Channel, // { id: ChannelId, state: ChannelState, started_at, ... } + /// The wrapped reflex stack. REUSED from slice-4 Task 6 composition. + pub pipe: LocalVadReflex>, + /// Caller PCM from Twilio (decoded at 24 kHz by the WSS pump task). + pub inbound_from_twilio_rx: mpsc::Receiver, + /// FOB outbound PCM for Twilio (the WSS pump encodes μ-law + sends back). + pub outbound_to_twilio_tx: mpsc::Sender, + pub last_idle_rx: Instant, + pub next_timeout: Option, +} +``` + +```rust +// crates/rutster-trunk/src/loop_driver.rs + +//! The trunk-leg tick function. Parellels `crates/rutster-media/src/loop_driver.rs` +//! minus the str0m/Opus/RTP machinery — there is no RTP to decode, no Opus +//! to encode, no str0m poll loop to drain. The caller→FOB direction is a +//! pure mpsc drain; the FOB→caller direction is a mpsc push. +//! +//! ## The seam (sacred — slice-4 Task 10 pinned-blob CI gate) +//! +//! This function is a SEPARATE file in a SEPARATE crate. `loop_driver.rs` +//! in `rutster-media` stays byte-identical because the trunk leg NEVER +//! enters that code path. The MediaThread's tick dispatches via the +//! `MediaLeg` enum (in `crates/rutster/src/media_thread.rs`): +//! +//! ```ignore +//! match leg { +//! MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED from slice-4 +//! MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW this slice +//! } +//! ``` + +use std::time::{Duration, Instant}; +use crate::session::{TrunkSession, IDLE_TIMEOUT}; +use rutster_media::SAMPLES_PER_FRAME; // Match slice-1's frame size + +/// 20 ms tick for outbound encoding (matches slice-1's PCM frame size: +/// 480 samples @ 24 kHz = 20 ms). +pub const OUTBOUND_TICK: Duration = Duration::from_millis(20); + +/// One iteration of the trunk-leg driver. Parallels +/// `rutster_media::loop_driver::drive` minus str0m. +pub fn drive(session: &mut TrunkSession, now: Instant) -> Option { + // === Step 1: drain inbound caller PCM from Twilio === + // (The WebRTC equivalent reads UDP + str0m::Input::Receive; the trunk + // equivalent drains a tokio mpsc the WSS pump task fills.) + while let Ok(frame) = session.inbound_from_twilio_rx.try_recv() { + // Push the caller's PCM through the wrapped AudioPipe stack: + // LocalVadReflex>::on_pcm_frame will inspect + // for VAD (slice-4 §3.4 — primary trigger path) + delegate inward + // → Reflex::on_pcm_frame → TapAudioPipe::on_pcm_frame → mpsc + // to TapEngine → brain WS as `audio_in`. + session.pipe.on_pcm_frame(frame); + session.last_idle_rx = now; + } + + // === Step 2: outbound encode tick (every 20 ms) === + if now.duration_since(session.last_outbound_at) >= OUTBOUND_TICK { + if let Some(reply_frame) = session.pipe.next_pcm_frame() { + // Brain reply PCM; push to the WSS pump task for µ-law encode + // + Twilio Media Streams JSON Media frame emission. try_send; + // drop + observe on full (hot-path policy). + if session.outbound_to_twilio_tx.try_send(reply_frame).is_err() { + // Log + bump a counter (TrunkMetrics, slice-5/seams pattern). + tracing::warn!("outbound_to_twilio_tx full; dropping brain reply frame"); + } + } + session.last_outbound_at = now; + } + + // === Step 3: idle timeout (matches slice-1 §4.5) === + if now.duration_since(session.last_idle_rx) > IDLE_TIMEOUT { + tracing::info!(channel_id = %session.channel.id, "trunk idle timeout; closing"); + session.channel.state = ChannelState::Closed; + return None; + } + + // === Step 4: sleep budget === + // The std thread's meta-tick is 10 ms; this duration tells the next + // tick's worth of sleep (matches loop_driver::drive's contract). + session.next_timeout = Some(now + OUTBOUND_TICK); + Some(OUTBOUND_TICK) +} +``` + +### 3.4 `CallControlClient` trait + impls (the green-zone seam) + +```rust +// crates/rutster-trunk/src/provider/mod.rs + +//! Provider abstraction: the trait that locks the seam so the NEXT +//! provider (Telnyx later) is an implementation, not a refactor. +//! +//! ## Green zone (ADR-0008) +//! +//! The trait + impls in this module run ARM'S-LENGTH: the call-control +//! client talks to Twilio's REST API over HTTPS (reqwest). The brain +//! never holds Twilio credentials; the FOB never parses SIP; the +//! trait locks both invariants mechanically. + +use rutster_call_model::ChannelId; + +#[async_trait::async_trait] +pub trait CallControlClient: Send + Sync { + /// Originate an outbound call to `to_phone`. The provider answers + /// itself, opens a Media Streams fork back to us, + returns a + /// provider-assigned correlation ID (Twilio: CallSid). The + /// `spend_token: Option` is the pre-paved seam for + /// spearhead step 6 (the spend cap); this slice passes `None`. + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + spend_token: Option, + ) -> Result; + + /// Hang up an in-progress call. Idempotent. Returns after the + /// provider acknowledges the terminate. + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>; +} + +#[derive(Debug)] +pub struct CallControlError(pub String); + +/// A placeholder for the slice-6 spend-gate token. The trait's `originate` +/// takes Option now; this slice passes None everywhere; step 6 +/// populates it before any REST origination is dispatched. +pub struct SpendToken(/* opaque */); +``` + +```rust +// crates/rutster-trunk/src/provider/mock.rs + +//! In-process test double for `CallControlClient`. CI tests use this; the +//! live TwilioCallControlClient is feature-gated behind `--features=twilio-live`. + +pub struct MockCallControlClient { /* in-process queues */ } +``` + +```rust +// crates/rutster-trunk/src/provider/twilio.rs + +//! Live Twilio Call Control client. Performs REST API calls via reqwest. +//! NOT enabled in CI by default; the maintainer runs `cargo test --features=twilio-live` +//! when validating a release. + +pub struct TwilioCallControlClient { + pub credentials: TwilioCredentials, + pub http: reqwest::Client, +} + +pub struct TwilioCredentials { + pub account_sid: String, + pub auth_token: String, // NEVER logged; NEVER reaches the brain + /// URL Twilio redirects to once a call connects; the binary's axum server. + pub media_streams_bind: std::net::SocketAddr, + pub webhook_base: url::Url, +} +``` + +### 3.5 `MediaLeg` enum + `MediaCmd::RegisterTrunk` (the seam extensions) + +```rust +// crates/rutster/src/media_thread.rs (modified this slice) + +// ...existing MediaCmd enum from slice-5/seams + slice-4½ (if it lands first): +// Register, AcceptOffer, Delete, Shutdown, Stats, Drain, [RegisterSim] + +pub enum MediaCmd { + // ... existing variants unchanged from prior slices ... + + /// slice-5: register a trunk-side session. The MediaThread: + /// 1. Allocates a ChannelId. + /// 2. Constructs the TapAudioPipe + TapConn (spawn_tap_engine, REUSED + /// from slice-2/slice-4; the tap_url is the brain's WS URL). + /// 3. Wraps as `Reflex::new(tap_pipe, advisory_rx, metrics)` then + /// `LocalVadReflex::new(reflex, advisory_tx)` — same Task-6-style + /// composition as slice-4 for WebRTC legs. + /// 4. Inserts a TrunkSession into the session map under MediaLeg::Trunk. + /// 5. Spawns the TapEngine tokio task. + /// 6. Replies with the ChannelId. + RegisterTrunk { + call_sid: String, + inbound_from_twilio_rx: tokio::sync::mpsc::Receiver, + outbound_to_twilio_tx: tokio::sync::mpsc::Sender, + tap_url: url::Url, + reply: oneshot::Sender, + }, +} + +// The per-session-storage enum (NEW, binary-side). +pub enum MediaLeg { + /// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`. + /// Untouched code path from slice-4. + WebRTC(RtcSession), + /// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`. + Trunk(TrunkSession), +} + +// In the std thread loop: +fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option { + match leg { + MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now), // UNCHANGED + MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now), // NEW this slice + } +} +``` + +### 3.6 New HTTP routes + +```rust +// crates/rutster/src/routes.rs (extended this slice) + +// POST /v1/trunk/sessions +// Body: { to: "+1...", from: "+1..." } +// Handler: calls CallControlClient::originate(to, from, None). +// Returns: { channel_id, call_sid } +// +// POST /v1/trunk/webhook +// Twilio's webhook receiver for inbound calls. Twilio calls this when a +// PSTN caller dials the Twilio number configured to point at us; the +// handler responds with TwiML instructing Twilio to open a Media +// Streams WebSocket against . +// Localhost development can omit the webhook; Twilio's Media Streams +// can be opened with a direct WSS POST in test mode (see QUICKSTART). +``` + +### 3.7 `TwilioCredentials` env parser (config seam) + +```rust +// crates/rutster/src/config.rs (extended this slice) + +/// Parse RUTSTER_TWILIO_ACCOUNT_SID + RUTSTER_TWILIO_AUTH_TOKEN + +/// RUTSTER_TWILIO_MEDIA_BIND + RUTSTER_TWILIO_WEBHOOK_BASE from env. +/// Returns None if any required var is missing (the binary runs without +/// trunk support if credentials aren't provided — WebRTC ingress still works). +pub fn twilio_credentials() -> Option { /* ... */ } +``` + +--- + +## 4. Data flow + +### 4.1 Inbound PSTN call (Twilio calling us) + +``` +1. PSTN caller dials the Twilio phone number. +2. Twilio fires the configured webhook (POST /v1/trunk/webhook) with the + inbound call details. +3. axum handler responds with TwiML instructing Twilio to open a Media + Streams fork against our /twilio/media-stream endpoint. +4. Twilio opens WSS to our TwilioMediaStreamsServer. +5. The server's WS handler: + a. Parses the "start" frame; extracts CallSid + StreamSid. + b. Constructs the two mpsc pairs (inbound + outbound, capacity 16 each). + c. Sends a RegisterTrunkInboundChannel to MediaThread via the + register_tx mpsc. +6. MediaThread receives RegisterTrunk: + a. Allocates a ChannelId. + b. Constructs TapAudioPipe + TapConn via spawn_tap_engine REUSED from + slice-2 (with tap_url = the brain's WS URL for this call). + c. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) → + LocalVadReflex::new(reflex, advisory_tx) — same composition as + slice-4 Task 6 for WebRTC legs. + d. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx, + outbound_to_twilio_tx, ... } and inserts into the session map + under MediaLeg::Trunk. + e. Replies with the ChannelId. +7. The 20 ms media loop dispatches the trunk leg to trunk_driver::drive: + - Drain inbound_from_twilio_rx → caller PCM frames → + session.pipe.on_pcm_frame(frame) — hits LocalVadReflex first. + `LocalVadReflex::on_pcm_frame` inspects RMS; if above threshold after + debounce, fires AdvisoryEvent::SpeechStarted → Reflex::drain_advisories + applies barge_in_flush → TapAudioPipe::barge_in_flush clears ring + + drains rx_audio_out (slice-4 §3.3 — UNCHANGED); the next call to + `next_pcm_frame` returns None → brain reply suppressed. + - After the LocalVadReflex inspects, Reflex delegates to TapAudioPipe + → TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in → + the TapEngine task drains → brain WS receive audio_in JSON event. +8. On the outbound tick (every 20 ms): `trunk_driver::drive` pulls + `session.pipe.next_pcm_frame()`: + - If muted (post-barge): returns None; no brain reply sent to Twilio + this tick. Caller hears silence. + - If not muted: returns Some(reply_frame). The frame is pushed to + `outbound_to_twilio_tx` mpsc; the WSS pump task drains it, + encodes µ-law via G711Codec::encode_pcm_to_mulaw, wraps in a + JSON "media" envelope, sends as a WS Text frame back to Twilio. + Caller hears the brain's reply. +9. Caller hangs up → Twilio sends "stop" frame → WS handler closes the + socket → MediaCmd::Delete → MediaThread removes the TrunkSession + from the map + tears down the TapEngine (slice-5/seams non-blocking + teardown path). +``` + +### 4.2 Outbound PSTN call (us calling out) + +``` +1. axum handler receives POST /v1/trunk/sessions { to, from }. +2. Handler calls TwilioCallControlClient::originate(to, from, None) [None + because spend gate is step 6]. +3. TwilioCallControlClient: + a. POSTs to Twilio's REST API: /Calls.json with the + `to` + `from` + a `twiml` parameter telling Twilio to open a Media + Streams fork against our /twilio/media-stream endpoint. + b. Twilio returns the new CallSid; the REST call returns immediately + (the actual fork will arrive later as an inbound WSS). +4. The remaining flow waits for Twilio's inbound WSS connection (step 4 + onwards in §4.1). The originate call returns once Twilio accepts. +5. The handler responds to the caller (the operator hitting the REST + endpoint) with the assigned ChannelId + CallSid for correlation; + the caller can poll MediaCmd::Stats or wait for an EventSink emission + when the leg connects. +``` + +### 4.3 Barge-in on the trunk leg (the wedge verification) + +``` +Identical to slice-4 §5.1, applied to the trunk leg's wrapped pipe: + +1. Caller speaks loudly over PSTN → Twilio's µ-law-encoded audio arrives + via the WSS pump task. +2. The pump task base64-decodes the JSON "media" payload, decodes µ-law + via G711Codec::decode_mulaw_to_pcm → 24 kHz PcmFrame. +3. Pump pushes PcmFrame into the leg's inbound_from_twilio_tx mpsc. +4. Loop tick: trunk_driver::drive drains inbound_from_twilio_rx → calls + session.pipe.on_pcm_frame(decoded_frame). +5. The wrapper is LocalVadReflex>: + - LocalVadReflex::on_pcm_frame sees the caller PCM FIRST, computes RMS + (slice-4 §3.4 unchanged). If ≥500.0 + debounce streak of 3 → + try_send(AdvisoryEvent::SpeechStarted) into advisory_tx. + - Then delegates inward to Reflex::on_pcm_frame → Reflex delegates + (Reflex is observational on the sink side, slice-4 §3.5) → + TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in → + TapEngine → brain WS as `audio_in` JSON event (slice-3 plumbing, + unchanged). +6. Reflex::next_pcm_frame drains advisory_rx → SpeechStarted applies + barge_in_flush (slice-4 §3.3 unchanged: clears playout ring + drains + rx_audio_out). The next call to next_pcm_frame returns None → no + brain reply sent to Twilio this tick → caller hears silence. +7. Brain yields + sends a fresh audio_out post-barge → TapEngine → + tx_audio_out mpsc → TapAudioPipe::next_pcm_frame (the wrapped inner + the Reflex inspects inside its next_pcm_frame). Reflex sees Some → + muted=false; returns Some → trunk_driver pushes to outbound_to_twilio_tx + → WSS pump encodes µ-law + sends JSON Media frame back to Twilio → + caller hears the brain's new reply. +``` + +The reflex loop is INGRESS-AGNOSTIC: the only difference between WebRTC's +loop_driver::drive + trunk's trunk_driver::drive is where the caller PCM +arrives (str0m's MediaData event vs an mpsc the WSS pump fills). + +--- + +## 5. ADR-0009 honoring — the credential isolation invariant + +The slice-5/seams-merged ADR-0009 amendment says: the spend gate + provider +credentials live INSIDE the trust boundary; the brain is OUTSIDE. + +Concrete enforcement in this slice: + +- `TwilioCredentials` is defined in `crates/rutster-trunk/`, scoped to the + trunk crate. It is `pub` to the binary crate (`crates/rutster/`), but + NOT re-exported through the workspace. +- The brain WS protocol carries ONLY: the audio (PCM in / PCM out), the + function-call events (slice-3), + the `speech_started`/`speech_stopped` + advisory events (slice-4). It does NOT carry the Twilio account SID, + the Twilio auth_token, the REST endpoint URL, or the CallSid. + (Wait — the brain might benefit from the CallSid for correlation in its + own logs. Adjust: CallSid is treated as an opaque correlation ID, NOT a + credential; the `TapConn` carries a `provider_correlation_id: Option` + field for the operator's debug-logging convenience. The brain only sees it + if it's routed through the tap protocol — which it ISN'T in this slice. + The CallSid lives on `TrunkSession` for log correlation in the FOB only.) +- The `CallControlClient` trait lives in `crates/rutster-trunk/`. The + brain's interface surface doesn't even IMPORT it. +- The `TwilioCallControlClient::originate` function reads credentials only + from `self.credentials` (passed in at construction via env parser). It + NEVER logs the auth_token; tracing fields use only the account_sid's + last 4 chars. + +These are invariants the slice-6 spend-cap work depends on. + +--- + +## 6. Why these decisions + +### 6.1 Why reuse `TapAudioPipe` for the trunk leg (NOT a new `TrunkAudioPipe`) + +The slice-4 stack `LocalVadReflex>` is **brain-pipe-specific**, not +leg-kind-specific. The `TapAudioPipe` exists precisely to bridge the FOB loop and a brain-side +WebSocket; slice-4's `Reflex

` is a generic over `P: AudioPipe`. A trunk leg's brain audio is +the same mpsc pattern as a WebRTC leg's brain audio: the TapEngine task produces PCM frames in +`tx_audio_out` (for `next_pcm_frame` to pull from) and consumes PCM frames from `tx_pcm_in` +(for `on_pcm_frame` to push to). The leg-kind decides ONLY the source of caller PCM (str0m's +RTP-decoded MediaData event vs the WSS-pump-supplied mpsc). Reusing `TapAudioPipe`: +- Avoids duplicating the slice-2 TapAudioPipe's mpsc pair + ring buffer logic. +- Makes the leg-kind distinction pure-binary-side (a new driver + a new session struct), not + per-crate. +- Asserts (architecturally) that the FOB reflex loop is ingress-agnostic at theInnermost layer. + +If we made TrunkAudioPipe a separate type, it would be 95% duplicate code, and the barge_in_flush +semantics would have to be re-tested for a structurally-identical mpsc-and-ring pair. That's the +opposite of "the seam holds." + +### 6.2 Why a separate `trunk_driver::drive` (NOT extending `loop_driver.rs`) + +`loop_driver.rs` is the seam-file: byte-identical through slices 1–4, gated by the slice-4 Task 10 +pinned-blob CI step. Extending it with a Twilio path would either (a) re-open the slice-4 seam gate +and weaken the architectural claim that the WebRTC media loop is untouched; or (b) fork into +"loop_driver has a Trunk variant" which puts trunk-specific concerns in the media crate (violating +ADR-0008's FOB-member criterion — trunk's µ-law + WSS framing isn't media-layer concern). + +A separate `trunk_driver::drive` in `crates/rutster-trunk/` honors both: the slice-4 seam stays +green (`loop_driver.rs` byte-identical), and trunk concerns stay in the trunk crate. The +`MediaLeg` enum (in `crates/rutster/src/media_thread.rs`, the binary-side bridge) is the only +addition the existing code sees; the WebRTC match arm preserves the existing call site 1:1. + +### 6.3 Why Twilio Media Streams is the MVP (not Telnyx, raw SIP fork, or a custom raw-audio shim) + +- **Documented application protocol** (JSON envelope; one of Twilio's most-stable public APIs). +- **Operational foothold**: the maintainer likely already has a Twilio account for personal + telephony hacks; the dev loop falls out of one phone call. Telnyx would require a separate + account onboarding step. +- **Generic at the seam**: the `CallControlClient` trait locks the abstraction; the next provider + (Telnyx, a custom raw-audio shim) is an `impl` of the trait, not a refactor. We're betting on + the architecture's flexibility, not on Twilio being the right carrier forever. + +### 6.4 Why the WSS server runs on axum (not a separate tokio listener) + +- Reuses the binary's existing `axum::serve` runtime — same tokio pool, same graceful-shutdown + plumbing. No new `TcpListener` acceptor to wire through `main.rs`. +- The WSS endpoint (`/twilio/media-stream`) sits alongside the existing HTTP routes (`POST + /v1/sessions`, etc.); a reverse proxy / load balancer at the production edge handles TLS / WS + upgrade uniformly. +- Axum's WebSocketUpgrade extractor handles the WS handshake natively — no need to plumb a + separate `tokio-tungstenite::accept_async` path. + +### 6.5 Why in-core µ-law (not a `g711` crate dep) + +- ~30 lines of table-driven code; static table is 256 + 65536 = ~66 KB binary size increase + (irrelevant on any modern platform; the tables fit in L1/L2). +- µ-law is a 1972 ITU-T standard — the decode table has not changed in 50 years; there is no + "actively maintained" dep that materially improves on a constant array (ADR-0008's "actively + maintained" gate is about addressing bus-factor / unsanctioned changes; a const table has no + bus factor). +- The decode table is a piece of telephony history — learner-commented, it teaches the µ-law + companding formula that AGENTS.md says this codebase is supposed to teach (the project + OVERRIDES the no-comments convention; this is exactly the kind of thing the override is for). + +### 6.6 Why 3× linear interpolation for 8 kHz ↔ 24 kHz resampling (not `rubato`) + +- The downstream consumer of the resampled 24 kHz PCM is the `LocalVadReflex` RMS-detector (the + barge-in trigger), which only cares about ENERGY (RMS over 480 samples). Linear interpolation + introduces harmonic-distortion artifacts at the high-frequency end that are well below the + RMS-detection threshold; the barge-in trigger is unaffected. +- The brain's audio (when sent back to Twilio) is encoded µ-law + played over PSTN, which has a + 3.4 kHz cutoff anyway — Twilio's PSTN callee can't hear distortion above the µ-law bandlimit, + so the resampler's high-frequency artifacts are inaudible by construction. +- `rubato` adds a dep + non-trivial CPU cost (FIR filter + windowing). The spearhead doesn't need + it; per AGENTS.md ADR-0008: "When in doubt, default to green zone" / "The FOB earns its + members, it doesn't collect them." Defer `rubato` to a post-spearhead tier when (a) the + resampler artifacts matter for some downstream consumer (transcription accuracy, recording + quality), OR (b) the test corpus catches a measurable regression. + +--- + +## 7. Done-criteria + +1. `cargo test --all` passes (stable + 1.85); the routine gate. +2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean on the new code. +3. `cargo deny check` passes — `reqwest` and `async-trait` are acknowledged in `deny.toml`. +4. `cargo doc --no-deps` renders the new `crates/rutster-trunk/` cleanly with learner-facing + comments per AGENTS.md code style. +5. Seam gate STILL holds: `loop_driver.rs` + `rtc_session.rs` byte-identical to slice-3 (CI + pinned-blob from slice-4 Task 10 unchanged because this slice never touches the media-crate + seam files). +6. **`MediaLeg::WebRTC(s) => loop_driver::drive(s, now)` is the unchanged call path for WebRTC + legs.** The new `MediaLeg::Trunk(s) => trunk_driver::drive(s, now)` is the only + newly-introduced tick dispatch. +7. `Reflex` + `LocalVadReflex` (slice-4) instantiate against the trunk leg's + TapAudioPipe without code change (the generic composition is the proof point). +8. The `MockCallControlClient` + an in-process Twilio Media Streams simulator drive a synthetic + PSTN caller through the FOB reflex loop: barge-in fires on PSTN caller speech within + slice-4's ≤80 ms kill budget (CI gate from slice 4½ if it lands; otherwise slice-4's manual + e2e budget applies witness, not asserted in this slice). +9. PSTN sim e2e integration test: `MockCallControlClient` + `MockTwilioMediaStreamsServer` → + MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → caller hangup + → session tear down + EventSink emission of `ChannelEnded` (with wall-clock `started_at` from + slice-5/seams). +10. Credential isolation: a static assertion (a unit test that imports only `rutster-trunk`'s + public API + asserts that `TwilioCredentials` is NOT re-exported from the workspace, NOT + in `rutster-media`'s public API, NOT in `rutster-tap`'s public API). The brain never sees + a Twilio credential. +11. QUICKSTART + README updated with: env-var table for Twilio credentials, "make a real phone + call" walkthrough (local dev with `--features=twilio-live` OR a direct WSS POST test mode). +12. Outbound origination (us calling out) is plumbed through `TwilioCallControlClient::originate` + + the rest of the wiring flows identically to inbound (the Twilio fork arrives inbound). + +--- + +## 8. Open decisions + +### 8.1 Should the `MockTwilioMediaStreamsServer` (test double) live in `crates/rutster-trunk/` +or in `crates/rutster/tests/`? + +**Decision (slice 5):** in `crates/rutster-trunk/src/twilio_media_streams.rs`'s `#[cfg(test)]` +module + a `pub(crate)` simulator API exposed only to in-process tests. Reason: the simulator IS +the FOB testable surface for the trunk leg; it's NOT a test of the binary's plumbing (which +`crates/rutster/tests/sim_integ.rs` covers). The simulator's JSON-frame parser is structurally +the same code as the live impl, just driven by in-memory queues instead of a real WSS. + +### 8.2 Should the WSS pump task be ONE tokio task per call (read+write in one task) or TWO +(read + write separately)? + +**Decision (slice 5):** ONE task, with a `tokio::select!` loop. The read + write directions +share the WS socket (Twilio's protocol interleaves inbound + outbound frames on the same WSS); +separating them into two tasks adds channel overhead for no real concurrency benefit (the read +side is rate-limited by µ-law frame arrival; the write side is rate-limited by the FOB's 20 ms +tick). One task is simpler. + +### 8.3 When the brain is unreachable mid-call (WS drops), should the trunk leg hang up the +PSTN call or keep playing silence? + +**Decision (slice 5):** keep playing silence for up to 5 seconds (the existing tap-engine +reconnect path from slice-2 §5.3 isn't implemented for trunk legs in this slice — it's a +deferred refinement). After 5 s of silence: trigger `CallControlClient::hangup(call_sid)`. This +matches what a human caller would want (5 s of silence is "did the line drop?") and is +predictable. The reconnect-after-drop feature is a slice-6 / post-step-5 refinement paired with +the spend gate (which has to know "is this call still going?"). + +### 8.4 Should the WSS acceptor authenticate Twilio's connection (HMAC signature validation)? + +**Decision (slice 5):** NO. The MVP runs on `localhost` for dev + behind a reverse proxy for +production. The reverse proxy / SSR-firewall layer enforces "Twilio's WSS connections come from +Twilio's documented IP ranges"; the binary doesn't re-authenticate. (A future hardening rung adds +HMAC validation per Twilio's documented signature scheme; it's tracked in cargo-deny / +architecture but not spearhead-scale.) + +### 8.5 Should the trunk leg support `barge_in_flush` semantics over the WSS path (i.e. flush +in-flight frames in `outbound_to_twilio_tx` AND `inbound_from_twilio_rx`)? + +**Decision (slice 5):** YES for the wrap-side `Reflex::barge_in_flush`-equivalent +call. The inner TapAudioPipe's `barge_in_flush` (slice-4 §3.3) clears its OWN ring + its OWN +`rx_audio_out` mpsc; the trunk leg inherits this. The trunk-specific NEW mpsc ( +`inbound_from_twilio_rx` + `outbound_to_twilio_tx`) is NOT part of the bar's atomicity — the +inbound mpsc drains naturally on the next tick; the outbound mpsc is bounded small (capacity 4), +so a stale outbound frame would arrive at Twilio as at most one extra 20 ms of audio (a perceptual +cost below the barge-in triggering cadence). + +If integration tests show audible pre-barge leakage, this decision gets revisited. + +### 8.6 Should the harness from slice 4½ (if it lands first) exercise the trunk leg? + +**Decision (slice 5):** YES via future-extension. Slice 4½'s `SimAudioPipe` is a separate +`AudioPipe` impl; if it lands first, slice 4½ tests against the WebRTC ingress only. Once the +trunk leg lands (this slice), a refinement adds a `TrunkSimAudioPipe` to slice 4½'s harness OR +refactors `SimAudioPipe` to be leg-agnostic. The decision is deferred to the harness-implementation +phase, NOT this slice. + +--- + +## 9. Cross-references + +- [ADR-0007](../../adr/0007-trunk-rented-transport.md) — the central rationale: rutster owns no SIP + stack; carrier transport is rented (layer 1 = CPaaS raw-media fork, this slice; layer 2 = + out-of-tree SBC, deferred; layer 3 = never first-party). +- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB/green-zone doctrine; this slice's split + (FOB owns WSS server + codec + TrunkSession; green-zone owns REST call-control). +- [ADR-0009](../../adr/0009-spend-gate-honest-rescope.md) — provider credentials never reach the + brain; this slice pre-paves the spend-token seam for step 6. +- [slice-2 spec §4.1](2026-06-28-slice-2-agent-tap-design.md) — the core-authoritative playout + buffer; trunk leg inherits the invariant (output comes from the FOB's pipe, not from the brain). +- [slice-4 spec §3.3](2026-07-01-slice-4-barge-in-design.md) — `TapAudioPipe::barge_in_flush` + semantics; this slice REUSES them unchanged. +- [slice-4 spec §3.4](2026-07-01-slice-4-barge-in-design.md) — `LocalVadReflex

` RMS-detector; + this slice REUSES it unchanged. +- [slice-5/seams plan](../plans/2026-07-04-slice-5-scalability-seams.md) — config.rs, event_sink.rs, + MediaCmd::Stats, non-blocking tap teardown, advertise media address — every seam this slice + inherits. +- [PORT_PLAN.md §Phasing](../../PORT_PLAN.md) — step 5 = real phone number (this slice). +- [ARCHITECTURE.md §"Media plane"](../../ARCHITECTURE.md) — "dedicated timing threads for the 20ms + loop, never the shared tokio pool"; the trunk leg participates in the same dedicated thread + via `trunk_driver::drive`.