Slice 2 — The agent tap: splicing the brain seam #2

Merged
alee merged 12 commits from slice-2-agent-tap into main 2026-06-28 22:35:30 +00:00

12 Commits

Author SHA1 Message Date
opencode controller
b2aaf149b0 fix(slice-2): widen outer close-bound + strengthen reconnect-test assertion
Some checks failed
CI / clippy (pull_request) Failing after 43s
CI / test (1.85) (pull_request) Failing after 39s
CI / test (stable) (pull_request) Failing after 42s
CI / deny (pull_request) Failing after 39s
CI / fmt (pull_request) Failing after 10m45s
- session_map.rs: AppState::close outer timeout bumped 500ms → 750ms
  so the inner close-arm bound (500ms in tap_client.rs) has room to
  finish `ws.close(None).await` cleanly before the abort fallback
  fires. Outer > inner per final-fixes re-review Important #1.
- tap_integration.rs: reconnect-path test now asserts the resumed
  `next_pcm_frame()` returns the FRESH marker (samples[0] == 9, not
  the stale samples[0] == 7), actively witnessing the §5.3 step 4
  "no stale bleed-through" contract. Pre-kill seeding strengthened
  to ≥2 frames so step 7's silence-after-flush assertion is non-
  vacuous. Per final-fixes re-review Important #2.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.3 step 4.
2026-06-28 18:21:52 -04:00
opencode controller
0d666b5ba7 fix(slice-2): §5.1 teardown seq + §5.3 playout flush + §8.4 reconnect test + §7.2 index.html
Code-review findings from the slice-2 final whole-branch review:

- tap_client.rs: close arm sends session_end (not bye) per §5.2, awaits
  brain bye with bounded 500ms timeout, then ws.close(). Promotes the
  Task-7-documented Minor to Important's required spec compliance.
- session_map.rs: AppState::close() now clears channel.tap = None before
  Closing->Closed, sends close signal, bounded-awaits engine task for the
  teardown handshake (500ms cap), aborts as fallback. Per §5.1 step 5.
- pcm.rs + tap_audio_pipe.rs: AudioPipe trait gains clear_playout_ring()
  (default no-op for EchoAudioPipe); TapAudioPipe clears the VecDeque.
  RtcSession::clear_playout_ring delegates. Per §5.3 step 4 playout-on-
  disconnect-flush contract.
- tap_engine.rs: spawn_tap_engine creates + shares a flush_tx mpsc
  channel; run_engine_loop signals flush after each failed pump loop
  (before backoff); TapConn.flush_rx is drained by drive_all_sessions
  to call RtcSession::clear_playout_ring.
- tests/tap_integration.rs: new reconnect-path test asserts Channel
  stays Connected, metrics.reconnect_attempts increments, playout
  returns None during outage, playout resumes after EchoServer
  restart with fresh frames (no stale bleed-through). Per §8.4.
- static/index.html: one static line acknowledging external-brain
  routing per §7.2 (defer real-time tap status to step-3 GUI).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.2, §5.3 step 4, §7.2, §8.4.
2026-06-28 18:17:31 -04:00
opencode controller
411276bfde test(slice-2): integration test + Python echo brain + LEARNING pointers
- Integration test: smoke test against in-process EchoServer (hello
  handshake round-trip). Full end-to-end + reconnect-path tests adapt
  to slice-1's existing integration-test harness.
- examples/echo_brain/: Python reference brain (~80 lines, websockets lib).
  README documents runtime posture + why a Python brain complements the
  Rust one (language-agnosticism vs wire-types-reusability).
- LEARNING.md: 5 new pointers (mpsc/oneshot, VecDeque ring, async WS,
  Box<dyn Trait> field widening, zero-sized marker newtype).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §8.4, §8.5 #7.
2026-06-28 17:52:16 -04:00
opencode controller
84fc30591e fix(binary): spec-exact backoff staircase + remove dead Closing branch
- tap_engine.rs: Backoff::next_delay now matches spec §4.3 exactly:
  250ms -> 500ms -> 1s -> 2s -> 5s cap (no intermediate 4s step from
  naive doubling). Test asserts the spec enumeration including the
  "stays at 5s forever" cap-after-step-5 invariant.
- session_map.rs: removed the dead 'Closing && tap.is_some()' branch
  from drive_all_sessions. AppState::close already handles teardown
  inline (fires close_tx, aborts task, clears field, advances
  state, removes entry). The branch was never exercised and
  pre-paved a "future peer-initiated close" path -- both AGENTS.md
  anti-patterns ("don't pre-pave the wrong pattern").

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3 step 4 + §5.1 step 3.
2026-06-28 17:46:50 -04:00
opencode controller
6bc6f6426a feat(binary): wire TapEngine into session lifecycle (spec §5.1, §7)
- tap_engine.rs: spawn_tap_engine(session_id, tap_url) — dials the brain,
  runs TapClient with bounded exponential backoff (250ms→5s cap, infinite
  retries). Cold-path network I/O on tokio's pool, NOT a timing thread
  (spec §4.3 deviation boundary).
- session_map.rs: +tap_registry (DashMap<ChannelId, TapConn>); +default_tap_url.
  drive_all_sessions observes Connected+tap.is_none() → spawn TapEngine, set
  channel.tap = Some(TapHandle). Observes Closing+tap.is_some() → fire close
  oneshot, abort task, clear tap BEFORE state advances to Closed.
- routes.rs: POST /v1/sessions accepts optional {"tap_url":...} body;
  resolve_tap_url validates ws:// 127.0.0.1/localhost, rejects wss:// + non-
  loopback ws:// + bad schemes with 400 Bad Request (fail-fast per spec §4.4).
- main.rs: reads RUTSTER_TAP_URL env (default ws://127.0.0.1:8081/echo).
- rtc_session.rs (rutster-media): pipe field changed from EchoAudioPipe to
  Box<dyn AudioPipe + Send> — the seam test (§8.5 #6):
  loop_driver's call sites (sink.on_pcm_frame / source.next_pcm_frame)
  are byte-identical; only the field type widens. RtcSession::new keeps
  EchoAudioPipe default (slice-1 tests unchanged); binary uses
  RtcSession::set_pipe(tap_audio_pipe) to swap on the Connected transition.
- 4 unit tests for tap_url validation (loopback accepted, non-loopback
  rejected, wss rejected fail-fast, default fallback).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3, §4.4, §5.1, §7.
2026-06-28 17:39:52 -04:00
opencode controller
e0616fe42d feat(call-model): +tap field on Channel + TapHandle marker (spec §5.2, §6)
- pub struct TapHandle(()) — zero-sized marker (Option<TapHandle> compiles
  to a bool, no allocation). The live tap connection lives in the binary's
  DashMap<ChannelId, TapConn>, keyed by the channel's existing ChannelId
  (== wire session_id per spec §5.3).
- Channel grows pub tap: Option<TapHandle>. None until Connected, set on
  Connected, cleared before state advances to Closed.
- State invariant: tap and ChannelState are tied (Connected+None=bug).
- Slice-1 backwards compatible: new field is Option<...>;

Multi-tap-per-channel is the future trigger for a TapId newtype (YAGNI).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.2, §6.
2026-06-28 17:24:11 -04:00
opencode controller
237a1388a4 feat(tap-echo): Rust reference echo brain + test server (spec §2.3)
- start_echo_server(addr): in-process WS server for integration tests;
  returns EchoHandle { shutdown, join, addr }.
- echo_one_connection: the per-connection echo loop — hello handshake,
  audio_in → audio_out (same PCM), bye/session_end graceful close.
- Reuses rutster-tap's protocol types — the wire-types-reusable contract
  test (spec §2.3).
- Stateless across reconnects (spec §5.3) — every hello starts fresh.
- Standalone binary: binds ws://127.0.0.1:8081, runs forever.
- 1 unit test exercises full hello/ack/audio_in/audio_out/bye over a
  TCP loopback pair.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §2.3, §5.3.
2026-06-28 17:20:00 -04:00
opencode controller
1bb5b7203c feat(tap): TapClient WSS pump loop (spec §4.2)
- run_tap_client: drives a connected WebSocketStream with a tokio::select!
  over rx_pcm_in (inbound PCM → audio_in WS frame) and ws.next() (brain
  frame → audio_out mpsc or control handling).
- Handshake: send hello, await brain hello (bounded 2s timeout).
- seq gap detection (log + count, never drop on gap — spec §3.1).
- Hot-path errors (encode/decode failures, send/recv failures) are logged
  + counted; TapClientError is returned only on graceful close, hello
  timeout, or WS errors. The TapEngine (Task 7) decides reconnect policy.
- Pure-helper unit test (elapsed_ms); full pump behavior is exercised
  against the in-process EchoServer in the Task 8 integration test.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.2.
2026-06-28 14:25:15 -04:00
opencode controller
51d5e6b01e feat(tap): TapAudioPipe + TapMetrics (spec §4.1)
- TapMetrics: atomic counters (drop + observe per slice-1 §3.8).
- TapAudioPipe: AudioSource + AudioSink impl over mpsc + VecDeque ring.
  Drop-oldest on overflow (lowest-latency-correct); silence on underflow.
- TAP_PLAYOUT_FRAMES = 5 (100ms @ 20ms/frame; tunable constant per spec §4.1).
- 6 unit tests cover underflow, overflow drops-oldest, sink-full drops,
  disconnected engine, frame round-trip, empty ring.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.1.
2026-06-28 14:18:57 -04:00
opencode controller
5b931d447b feat(tap): versioned JSON event wire protocol (spec §3)
- protocol.rs: Envelope + FrameKind + Payload types, serde-derived.
- Explicit little-endian PCM codec (to_le_bytes / from_le_bytes) —
  spec §3, §9. No host-endian silent hazard.
- encode_* helpers for hello, audio_in, audio_out, session_end, bye, error.
- decode_envelope validates protocol version + samples count; unknown
  type values decode to DecodedPayload::Unknown (log + count + drop
  per spec §3.4).
- 10 unit tests cover round-trips, LE byte order, sample-count
  mismatch, unknown-type drop, and version mismatch.

Spec ref: 2026-06-28-slice-2-agent-tap-design.md §3.
2026-06-28 14:12:48 -04:00
opencode controller
0cccf77faf build(slice-2): workspace deps + rutster-tap-echo skeleton
- Add tokio-tungstenite 0.24, futures-util 0.3, url 2, base64 0.22 to
  [workspace.dependencies] (spec §8.1).
- Add crates/rutster-tap-echo as 7th workspace member (spec §2).
- Transition crates/rutster-tap/Cargo.toml from stub to real manifest
  with the deps Tasks 2-4 will need (rutster-media, tokio, tokio-tungstenite,
  serde, serde_json, base64, url, thiserror, tracing).
- Skeleton lib.rs (compile test) + main.rs (placeholder fn main) for
  rutster-tap-echo; Task 5 fills in the echo logic.
- Verify cargo deny check passes against the new transitive dep tree.

Spec ref: docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md §2, §8.1.
2026-06-28 14:01:12 -04:00
opencode controller
22d3f03b8c plan(slice-2): agent tap implementation plan (spec §1.1)
8 TDD tasks, 53 checkbox steps, sequentially dependent:
1. Workspace deps + rutster-tap-echo skeleton (build foundation)
2. Tap wire protocol (spec §3) — JSON envelope, explicit LE PCM codec
3. TapMetrics + TapAudioPipe (spec §4.1) — the seam object
4. TapClient WSS pump loop (spec §4.2) — async WS pump + seq gap detection
5. Rust echo brain crate (spec §2.3) — stereotype binary + in-process EchoServer
6. Call-model TapHandle + Channel field (spec §5.2, §6)
7. Binary wiring — TapEngine + session_map + routes + main (spec §5.1, §7)
8. Integration test + Python echo brain + LEARNING.md (spec §8.4, §8.5 #7)

Plan self-reviewed: no placeholders; TapHandle::new visibility fixed
(pub(crate) → pub); spawn_tap_engine return shape fixed (returns
(TapAudioPipe, TapConn) tuple, not TapConn holding the pipe's mpsc).

Spec ref: 2026-06-28-slice-2-agent-tap-design.md (commit f83bca9).
2026-06-28 13:54:57 -04:00