mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-11 16:57:40 -04:00
* feat(realtime): EOU-driven semantic_vad turn detection Add a `semantic_vad` turn-detection mode to the realtime API that feeds the transcription model live and decides "the user finished speaking" from the `<EOU>` end-of-utterance token rather than from silence alone. When EOU fires the turn commits immediately (~0.3s); otherwise it falls back to an eagerness-scaled silence threshold (low/med/high = 8/4/2s). Plumbing, bottom to top: - proto: `AudioTranscriptionLive` bidirectional RPC (config-first oneof, mono float PCM @16k, ready-ack / Unimplemented degrade signal) plus `TranscriptResult.eou` for the unary retranscribe gate. - pkg/grpc: client/server/base/embed scaffolding for the bidi stream, modeled on AudioTransformStream; release stream conns on terminal Recv. - parakeet-cpp: live transcription RPC with per-C-call engine locking (one live stream per turn, finalize+free at commit); bump parakeet.cpp to ABI v5 — incremental StreamingMel (no more quadratic per-feed mel recompute that delayed EOU on long turns) and the <EOU>/<EOB> split; strip the literal <EOU>/<EOB> from offline text and set Eou. - core/backend: LiveTranscriptionSession wrapper + pipeline `turn_detection:` config block (type/eagerness/retranscribe). - realtime: semantic_vad integration — live input captions streamed as transcription deltas while the user speaks, EOU-immediate commit with eagerness fallback, optional retranscribe gate (batch re-decode must also end in <EOU> to confirm), clause synthesis off the LLM token callback, and per-turn live-transcription / model_load telemetry. - UI: show the realtime pipeline components as a vertical list. Docs and tests included; opt-in via the pipeline YAML or per-session `session.update`. Non-streaming STT backends degrade to silence-only. Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Write] [Bash] Assisted-by: Claude Code:claude-fable-5 [Read] [Edit] [Bash] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(realtime): explicit formally-verified state machines + parakeet streaming driver The realtime API had several implicit state machines whose state was inferred from scattered booleans, channels, and five separate mutexes, leaving illegal/inconsistent states reachable. Make them explicit and keep the implementation in step with a formal design; rework the parakeet streaming backend along the same lines. Realtime state machines (M1-M5). Each is a sealed sum-type State/Event/Effect with a total, pure Next(state,event)->(state,[]effect) behind a single-writer Coordinator: M1 conncoord connection lifecycle: VAD toggle + once-only teardown (replaces vadServerStarted + a `done` channel closed from two sites). M2 turncoord turn detection: collapses speechStarted and the live-stream "turn open" flag into one state, so discardTurn can no longer desync them and suppress the next onset. M3 respcoord response coordination: serializes the dual-writer start/cancel so at most one response is live; one response.done per response.create. M4 compactcoord conversation compaction: single-flight (replaces the `compacting atomic.Bool` CAS). M5 ttscoord TTS pipeline: open->closing->closed, idempotent wait(), rejects enqueue-after-close (was a silent drop). The Coordinator/Sink/Next plumbing — only the sealed types and Next differed per machine — is extracted once into core/http/endpoints/openai/coordinator as a generic Coordinator[S,E,F]; each machine keeps its public API via type aliases, so no sink, call-site, or test moved. Hierarchy. session_lifecycle.fizz models M1 as the parent region with its children (M2/M3/M4) as one statechart and asserts ChildrenDieWithParent (conn torn => all children terminal, none start after teardown). respcoord and compactcoord gain an absorbing Terminated state + Shutdown event; conncoord's teardown drives the children terminal. This closes a compaction teardown gap: a fire-and-forget compaction could outlive a torn session — compactionSink now takes a session-scoped cancellable context + WaitGroup and joins the in-flight summarize+evict on shutdown. Formal verification. formal-verification/ holds one authoritative FizzBee spec per machine plus the composition spec, each with an always-assertion and a documented one-line edit that makes the checker fail (verified non-vacuous). scripts/realtime-conformance.sh is fail-closed: all Go conformance suites under -race AND a model-check of every .fizz spec; a missing FizzBee is a hard error (only the loud REALTIME_CONFORMANCE_SKIP_FIZZBEE=1 bypasses it, never in CI). FizzBee is pinned by sha256 and installed via scripts/install-fizzbee.sh into .tools/ (gitignored). Wired as make test-realtime-conformance, a CI workflow, and a pre-commit path filter. Go conformance tests are Ginkgo/Gomega (per the repo's forbidigo lint): transition tables + fixed-seed property walks + concurrent/-race specs, no rapid dependency. Design map: docs/design/realtime-state-machines.md. Parakeet streaming backend. The same treatment applied to the parakeet-cpp streaming paths: - AudioTranscriptionStream returns codes.Unimplemented for non-streaming models instead of decoding offline and emitting it as one delta + final. A client that asked for streaming learns the model cannot stream rather than receiving a batch result shaped like a stream. New grpcerrors.StreamTranscriptionUnsupported carries that signal; the HTTP /v1/audio/transcriptions stream path surfaces it as an SSE error event. Mirrors AudioTranscriptionLive, which already did this. - utteranceBoundary (boundary.go): a single definition of the end-of-utterance latch, replacing three open-coded finalEou toggles. Modelled as a two-valued type so illegal states are unrepresentable. - Shared decode driver (driver.go): streamFeedResult (one per-feed event) + feedChunk (hides the ABI v4 JSON vs text-only split) + feedSlices + flushTail. The feed loop is written once. - AudioTranscriptionLive becomes a bidi adapter: it streams the per-feed {delta,eou,eob,words} the realtime turn detector consumes and a terminal FinalResult carrying only Text. Segments/duration/eou are offline-only and no longer produced (nor read) on the live path; liveTraceState drops the terminal eou and keeps the per-feed eou_events count. - AudioTranscriptionStream + streamJSON merge into one driver-based function; streamSegmenter is generalized to the unified event with a text-only fallback that preserves the legacy (no-words) library's per-utterance segmentation. Verified: build/vet/gofumpt clean, golangci-lint 0 issues, all coordinator and parakeet packages under -race, the fail-closed conformance gate green, and make test-realtime (12 e2e WS+WebRTC). Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
604 lines
39 KiB
Markdown
604 lines
39 KiB
Markdown
# Realtime API state machines — map & re-architecture research
|
||
|
||
Status: research / design (compaction phase). No code changes implied yet.
|
||
|
||
The realtime API (`core/http/endpoints/openai/realtime*.go`) grew feature-by-feature
|
||
(server_vad → semantic_vad/EOU, streaming pipeline, tool turns, compaction, voice
|
||
gate, sound detection, WebRTC). The result is several **implicit** state machines
|
||
whose states and transitions are scattered across goroutine-local variables, shared
|
||
`Session`/`Conversation` fields under five different mutexes, raw channels, and
|
||
`context` cancellation. State is *inferred* from variable combinations rather than
|
||
*stored*; several illegal/inconsistent states are reachable.
|
||
|
||
This document (1) inventories the implicit machines, (2) catalogues the cross-cutting
|
||
failure modes, (3) researches how to re-implement them explicitly and verifiably, and
|
||
(4) lists the invariants a correct implementation must guarantee.
|
||
|
||
All line numbers are against the current `feat/realtime-semantic-vad-eou` branch and
|
||
will drift; treat them as anchors.
|
||
|
||
---
|
||
|
||
## Part 1 — Inventory of the implicit state machines
|
||
|
||
There is **no `state`/`status` field anywhere** in `Session` or `Conversation`. Every
|
||
machine below is reconstructed from variable combinations.
|
||
|
||
### M1. Connection / transport lifecycle
|
||
|
||
Two transports implement one `Transport` interface; their lifecycles differ sharply.
|
||
|
||
- **WebSocket** (`realtime_transport_ws.go`): essentially stateless — a `*websocket.Conn`
|
||
plus a write `sync.Mutex`. No send queue, no send goroutine, no closed flag. "Closed"
|
||
= `ReadEvent` returns an error.
|
||
- **WebRTC** (`realtime_transport_webrtc.go`): an explicit-ish machine built from raw
|
||
channels — `dcReady` (closed by `dcDone sync.OnceFunc`), `closed` (closed by
|
||
`closeDone sync.OnceFunc` from *either* `OnConnectionStateChange` or `Close()`),
|
||
`flushed`, `sessionCh` (cap 1), `inEvents`/`outEvents` (cap 256), plus a `sendLoop`
|
||
goroutine and RTP counters under `rtpMu`.
|
||
|
||
Conceptual states (`connecting → data-channel-open → session-created → active →
|
||
closing → closed`) are **not stored**; the only persisted membership state is the
|
||
`sessions[sessionID]` map entry (exists `realtime.go:631`→`:1009`). `session-created`
|
||
and `session-updated` are *events*, not states.
|
||
|
||
Teardown order (`realtime.go:989-1010`): `cancelActiveResponse` → `close(decodeDone)`
|
||
→ `close(done)` (if VAD running) → `close(soundWindowDone)` → `wg.Wait()` →
|
||
`delete(sessions,…)`. Then, WebRTC only, `defer transport.Close()` → `closeDone()` →
|
||
`<-flushed` → `pc.Close()`.
|
||
|
||
### M2. Audio-input / turn-detection (server_vad + semantic_vad + EOU)
|
||
|
||
One `handleVAD` goroutine (`realtime.go:1322`) on a 300 ms ticker. Mode is
|
||
**re-evaluated every tick** under `sessionLock` (`:1350-1357`) so it can flip mid-turn.
|
||
|
||
- **server_vad** states are encoded by the goroutine-local `speechStarted bool`
|
||
(`:1337`) plus silence *measured* (not timed) as `audioLength - segEndTime >
|
||
silenceThreshold` recomputed each tick (`:1461`). States: idle → inspecting →
|
||
speech-detected → awaiting-commit → committing → transcribing/responding.
|
||
"Holdback" is a byte count (`noSpeechHoldbackSec*rate*2`), not a timer.
|
||
- **semantic_vad** adds the `liveTurnState` struct (`realtime_semantic_vad.go`):
|
||
`live` (nil = closed), `unavailable` (sticky degrade → behaves as server_vad),
|
||
`eouAtSec`, `parts`, `itemID` (allocated at turn open so captions can stream),
|
||
`deltasSent`. Extra states: closed, open/streaming-ASR, EOU-pending, EOU-fallback
|
||
(dynamic silence threshold 0 s when EOU pending, else eagerness 8/4/2 s),
|
||
retranscribe-gate, EOU-rejected, finished, discarded.
|
||
The one cross-goroutine edge: the backend recv callback pushes onto `events`
|
||
(buffered 64, **non-blocking — drops on overflow**, `:116-117`); `drainEvents`
|
||
reads it on the tick.
|
||
- **Voice gate** (`realtime_voicegate.go`) runs *inside* the commit goroutine:
|
||
resolving → authorized/rejected, with a sticky `voiceVerified` (under `gateMu`) for
|
||
`when:first`.
|
||
|
||
### M3. Response lifecycle (+ synchronous tool-turn recursion)
|
||
|
||
A response is "active" iff `Session.activeResponseDone` is non-nil and unclosed
|
||
(`responseMu`, `:172`). One goroutine owns it; its lifetime == that channel's. State
|
||
is observable only through the `response.*` event stream and `ItemStatus*` on the
|
||
assistant item. Logical states: idle → starting → generating-text →
|
||
generating-audio → tool-call-pending → tool-executing → awaiting-next-tool-turn →
|
||
cancelling → done(completed|cancelled) | failed.
|
||
|
||
- Cancellation is **cooperative at discrete checkpoints** (`ctx.Err()` at
|
||
`:2172,2364,2394`, `realtime_stream.go:193,202,241,259`).
|
||
- The tool loop is **synchronous recursion on the same goroutine**, bounded by
|
||
`maxAssistantToolTurns = 10`; each level mints a fresh `responseID` and emits a full
|
||
`response.created … response.done{Completed}` cycle — so one user turn can emit
|
||
*several* `response.done{Completed}` events under different IDs.
|
||
- Terminal events are **not exactly-once**: failed paths `return` with no
|
||
`response.done`; cancelled paths emit `done{Cancelled}`; the completed terminal is
|
||
unconditional at the tail of `emitToolCallItems`.
|
||
|
||
### M4. Conversation / compaction
|
||
|
||
`Conversation`: `Items` + `Memory` (rolling summary) under `Lock`; `compacting
|
||
atomic.Bool`. States: normal ↔ compacting. Compaction (`realtime_compaction.go`)
|
||
snapshots overflow under `Lock`, summarizes **unlocked**, re-locks and commits guarded
|
||
by an optimistic head-`prefixMatches` check. It is launched **only by turn-0
|
||
`triggerResponse`** (`:1963`), off the response path — so a long agentic turn
|
||
(recursion calls `triggerResponseAtTurn` directly) can append many tool items and
|
||
**never compact** until the next user turn (compaction starvation).
|
||
|
||
### M5. Streaming sub-machines (transcription, chunker, TTS)
|
||
|
||
Backend LLM/TTS/transcription streams are **synchronous callback recv loops on the
|
||
caller's goroutine** — no internal goroutines/channels. The only true concurrent FSM is:
|
||
|
||
- **TTS pipeline** (`realtime_tts_pipeline.go`): one worker goroutine, an **unbounded**
|
||
mutex-guarded `queue`, a coalesced `wake` chan (cap 1), a `closed` flag, a `done`
|
||
chan closed once by the worker's `defer`, a lock-free `failed atomic.Bool`, and
|
||
worker-owned `audio`/`firstErr` that are safe to read only after `wait()` joins via
|
||
`done`. Idempotent `wait()`; deferred `wait()` backstop guarantees no worker leak.
|
||
- **Chunker** (`realtime_chunker.go`): a pure single-buffer FSM (buffering ↔ emitting,
|
||
`flush` = hard boundary). **No concurrency guard** — correctness depends entirely on
|
||
`push`/`flush` being called from one goroutine (the LLM recv loop). On cancel the
|
||
flush is skipped, so the buffered partial clause is intentionally dropped.
|
||
- **Transcription** (`realtime_transcription.go`): stateless straight-line function;
|
||
"streaming" is just repeated synchronous callbacks.
|
||
|
||
---
|
||
|
||
## Part 2 — Cross-cutting failure modes (why it's a mess)
|
||
|
||
1. **Shared mutable `Session` config with inconsistent locking (the core problem).**
|
||
`updateSession`/`updateTransSession` mutate `Voice`, `Instructions`, `Tools`,
|
||
`OutputModalities`, `ModelConfig`, **`ModelInterface`**, sample rates, and the
|
||
shared `InputAudioTranscription` pointer under `sessionLock`. But in-flight
|
||
response/speech/transcription goroutines read those same fields **without any
|
||
lock** (`realtime_speech.go:72-79`, `realtime_stream.go:228`, semantic_vad
|
||
`:110`). Reloading `ModelInterface` mid-response is a data race against a running
|
||
Predict/TTS/Transcribe, and the swapped-out model is dropped without Close.
|
||
`sessionLock` actually guards the *global `sessions` map*; it only mutually excludes
|
||
the handful of other sites that happen to also take it (handleVAD tick, the commit
|
||
branch). Response goroutines never take it.
|
||
|
||
2. **Two writers of the active-response pair.** `startResponse`/`cancelActiveResponse`
|
||
are called from both the main read loop (`:836,973,981,990`) **and** the VAD
|
||
goroutine (barge-in `:1429`, end-of-speech `:1543`). `responseMu` guards only the
|
||
field swap; the `<-done` wait is outside the lock. A read-loop `ResponseCreate`
|
||
racing a VAD `speech_stopped` can have both read the same prior pair, both
|
||
overwrite, and briefly leave **two live response goroutines** both appending to
|
||
`conv.Items`. The "never overlapping" guarantee holds only under the unstated
|
||
assumption that responses are driven from a single goroutine — which is false.
|
||
|
||
3. **State is inferred, not stored.** Whether a response is active, whether a turn is
|
||
open, whether audio is being buffered — all are derived from combinations of
|
||
booleans, nil-checks, channel state, and `context` error. No single source of truth;
|
||
no place to assert an invariant.
|
||
|
||
4. **Reachable inconsistent states.** e.g. after a semantic-VAD `discardTurn`,
|
||
`speechStarted` stays true while `lts` is closed, so they disagree and the next
|
||
onset suppresses `SpeechStarted`. Mid-stream cancel leaves the client having seen
|
||
`output_item.added`/`content_part.added` with no matching `…done`. `events`-channel
|
||
overflow silently drops an EOU, degrading EOU-pending to the 2–8 s fallback.
|
||
|
||
5. **Lifecycle/ownership gaps.** `decodeOpusLoop` is a bare `go` (not in `wg`) and can
|
||
run after `delete(sessions,…)`. `handleIncomingAudioTrack` (pion `OnTrack`
|
||
goroutine) has **no shutdown signal** — it appends to `OpusFrames` until `ReadRTP`
|
||
errors, unjoined by `wg`. WebRTC `outEvents` enqueued before the DC opens are lost
|
||
on early failure.
|
||
|
||
6. **The `done`-channel/`vadServerStarted` toggle dance.** A single `done` local
|
||
(`:655`) is reassigned to a fresh channel on each VAD start (`:662`) and closed at
|
||
toggle-off (`:670`) and teardown (`:999`). Safe today only because one goroutine
|
||
owns it — one variable name meaning different channels over time is a structural
|
||
fragility, not an explicit lifecycle.
|
||
|
||
---
|
||
|
||
## Part 3 — Research: explicit, verifiable re-implementation
|
||
|
||
The goal the user stated: **transitions cannot lead to an inconsistent state, and we
|
||
can verify that.** Four layered techniques, from architecture down to runtime.
|
||
|
||
### 3.1 Architecture: single-writer session actor (share by communicating)
|
||
|
||
The root cause of (1) and (2) is *shared mutable state across goroutines*. The most
|
||
effective, idiomatic-Go fix is to give each session **one owning goroutine** that holds
|
||
all session state with **no locks**, and have every other goroutine communicate with it
|
||
over channels:
|
||
|
||
```
|
||
┌────────── inbound events ──────────┐
|
||
transport ─┤ client events (ReadEvent) │
|
||
VAD ─┤ vad: speech_started/stopped, EOU ├─► session actor ──► outbound
|
||
model I/O ─┤ llm/tts/asr results, errors │ (owns ALL state, events
|
||
timers ─┤ ticks, deadlines │ single goroutine)
|
||
└────────────────────────────────────┘
|
||
```
|
||
|
||
- All state mutation happens in one place; `sessionLock`, `responseMu`, `gateMu`,
|
||
`AudioBufferLock`, `OpusFramesLock`, `Conversation.Lock` collapse into "the actor owns
|
||
it." Worker goroutines (Predict/TTS/ASR, opus decode, RTP read) become **stateless
|
||
effects** that take an immutable snapshot in and send results back as events.
|
||
- `ModelInterface` reload becomes an event the actor sequences relative to responses
|
||
(e.g. drain/cancel the active response first), eliminating the mid-call swap race.
|
||
- Cancellation stays `context`-based but the actor is the only thing that starts/stops
|
||
responses, killing the dual-writer race (2).
|
||
|
||
This is the actor / CSP model. It does not by itself prove correctness — that's what
|
||
3.2–3.4 add — but it makes the state *centralized and explicit*, which is the
|
||
precondition for verification.
|
||
|
||
### 3.2 Make illegal states unrepresentable (type-level)
|
||
|
||
Inside the actor, model each machine as an explicit state with a **pure transition
|
||
function** `next(state, event) (state, []effect, error)`:
|
||
|
||
- Represent states as a Go **sealed sum type** (interface with an unexported marker
|
||
method, one struct per state carrying only that state's data) so e.g. `EOU-pending`
|
||
data cannot be accessed while `Closed`. This is the Go equivalent of an ADT and is the
|
||
single biggest lever for "inconsistent state unrepresentable."
|
||
- The transition function is **total and pure** (no I/O, no goroutines): it returns the
|
||
next state plus a list of *effects* (send event, start Predict, arm timer) that the
|
||
actor executes. Pure transition functions are trivially unit-testable and
|
||
property-testable.
|
||
- An unexpected `(state, event)` pair returns an explicit error / stays put and logs —
|
||
never a silent half-transition.
|
||
|
||
The four machines are **hierarchical** (a statechart): Connection ⊃ Turn(M2) and
|
||
Response(M3) ⊃ Tool-turn; Conversation(M4) and the TTS sub-machine(M5) are largely
|
||
orthogonal regions. Model them as nested states rather than one flat enum.
|
||
|
||
Library options (all guard *logic*, none give concurrency safety — that's 3.1's job):
|
||
- `qmuntal/stateless` — declarative, hierarchical, guard/entry/exit actions; closest fit.
|
||
- `looplab/fsm` — simpler, flat, event-callback based.
|
||
- Hand-rolled transition tables — most control, no dep; recommended here given the
|
||
hierarchy and the desire to keep transitions auditable. `go.mod` currently pulls no
|
||
FSM lib.
|
||
|
||
### 3.3 Design-time formal verification (prove the protocol)
|
||
|
||
Before/while coding, model the *protocol* (not the Go) in a model checker to prove the
|
||
hard concurrency properties exhaustively:
|
||
|
||
- **FizzBee** (the adopted tool) to specify the actor's event/state space and check: no
|
||
two concurrent active responses; barge-in + ResponseCancel + speech_stopped
|
||
interleavings never deadlock or drop a turn; every `response.created` is eventually
|
||
followed by exactly one terminal; teardown joins all goroutines. The
|
||
cancel/startResponse/barge-in interplay (failure mode 2) is exactly the kind of
|
||
liveness/safety property model checkers exist for.
|
||
- Keep the spec small and focused on the M2↔M3 boundary (turn detection ↔ response),
|
||
which is where the real races live.
|
||
|
||
### 3.4 Implementation-time & runtime verification
|
||
|
||
- **Exhaustive table-driven transition tests**: since transitions are a pure function,
|
||
enumerate `(state × event)` and assert the result for every cell, including the
|
||
illegal cells (assert they error / no-op). This is the practical stand-in for a proof
|
||
that "no transition leads to inconsistent state."
|
||
- **Property-based testing**: feed random event sequences into the actor and assert
|
||
global invariants hold after every step (Part 4). This catches reachable-bad-state
|
||
bugs the example tests miss. (Implemented as Ginkgo/Gomega seeded random-walk specs
|
||
— see Part 6.2 for why not `rapid`.)
|
||
- **Race detector under load**: run the property tests with `-race`; with 3.1 there
|
||
should be *zero* shared mutable state, so `-race` cleanliness becomes a meaningful
|
||
signal rather than noise.
|
||
- **Runtime invariant assertions + structured transition logging**: log every
|
||
`state --event--> state` with the session ID; assert invariants in dev builds.
|
||
Replace today's silent degradations (dropped EOU, suppressed SpeechStarted) with
|
||
explicit, observable transitions.
|
||
|
||
### 3.5 Recommended path for LocalAI
|
||
|
||
1. Specify the M2↔M3 protocol in FizzBee; nail the cancel/barge-in invariants.
|
||
2. Introduce a per-session actor (3.1) that owns existing state behind the current
|
||
`Transport` interface — incremental, keeps the event types.
|
||
3. Replace each implicit machine with an explicit sealed-state transition function
|
||
(3.2), one at a time: Response first (highest-risk dual-writer), then Turn/VAD, then
|
||
Connection, then leave TTS/Chunker/Compaction (already mostly self-contained) for
|
||
last.
|
||
4. Land the table-driven + property-based test suites alongside each machine; gate on
|
||
`-race`.
|
||
|
||
---
|
||
|
||
## Part 4 — Invariants a correct implementation must guarantee
|
||
|
||
These are the "cannot reach inconsistent state" properties to encode as assertions,
|
||
property-test oracles, and FizzBee invariants:
|
||
|
||
1. **At most one active response per session** at any instant (no overlapping response
|
||
goroutines; no two appenders to `conv.Items` from response logic).
|
||
2. **Exactly one terminal per `response.created`**: every emitted `response.created` is
|
||
followed by exactly one of `response.done{completed|cancelled}` or a defined failure
|
||
terminal — never zero, never two. (Decide whether agentic tool turns are one
|
||
response or many; make it explicit either way.)
|
||
3. **No `response.*` content events after that response's terminal.** No
|
||
`output_item.added`/`content_part.added` without a matching `…done` (even on cancel).
|
||
4. **Turn/response coupling**: `speechStarted` ⟺ a live turn is open; barge-in cancels
|
||
the active response *before* a new turn's commit starts.
|
||
5. **No config field is read by a worker while being mutated** (reload is sequenced
|
||
against in-flight work; a response uses an immutable snapshot of model/voice/tools).
|
||
6. **Audio buffer monotonic & consistent**: commit/clear/append/VAD-drop never lose or
|
||
double-consume bytes; `clear` resets *all* turn state (including `lts`).
|
||
7. **No dropped control events**: an EOU/Final is never silently lost (no overflow-drop
|
||
on a bounded channel that changes turn outcome).
|
||
8. **Clean teardown**: every spawned goroutine (incl. `decodeOpusLoop`,
|
||
`handleIncomingAudioTrack`) is signalled and joined before the session is deleted; no
|
||
sends after transport close.
|
||
9. **Compaction safety & liveness**: compaction never races a reader into a torn
|
||
`Items`; and it actually runs when the trigger is exceeded, including inside long
|
||
agentic turns.
|
||
10. **Idempotent close**: every channel/resource closed exactly once on every path.
|
||
|
||
---
|
||
|
||
## Implementation status
|
||
|
||
- **M3 (response coordination) — first vertical slice landed.** Explicit machine in
|
||
`core/http/endpoints/openai/respcoord/` (sealed `State`/`Event`/`Effect` sum types, a
|
||
total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega
|
||
seeded-property + concurrent conformance tests (green under `-race`); a deterministic
|
||
characterization test pinning the legacy dual-writer race. Authoritative spec:
|
||
`formal-verification/response_lifecycle.fizz`. Gate:
|
||
`scripts/realtime-conformance.sh` (Go layer always; FizzBee when pinned) wired as
|
||
`make test-realtime-conformance` and `.github/workflows/realtime-conformance.yml`. See
|
||
`formal-verification/README.md`.
|
||
- **Gate is fail-closed and pinned (done).** `fizzbee.sha256` pins all four platforms;
|
||
the gate hard-fails without FizzBee; CI installs+caches the verified binary with no skip;
|
||
pre-commit runs the gate on `respcoord/**` or `formal-verification/**` changes.
|
||
- **M3 wired into the live session (done).** `realtime_respcoord.go` adds `responseSink`
|
||
(the `respcoord.Coordinator` + a goroutine-spawning effect sink) to `Session`. The legacy
|
||
`startResponse`/`cancelActiveResponse` and the dual-writer `activeResponse*`/`responseMu`
|
||
fields are gone; all six call sites (manual commit, `response.create`, VAD speech-stopped,
|
||
`response.cancel`, barge-in, teardown) route through it. Barge-in/cancel are now
|
||
non-blocking (removes the legacy ~300 ms VAD stall); teardown stops input goroutines, then
|
||
cancels + `wait()`s all response goroutines before deleting the session. `EmitTerminal` is
|
||
a no-op for now (the response body still emits its own `response.done`) — coordination is
|
||
fixed without changing wire behavior. Verified: builds, `go vet` clean, all 300 openai
|
||
specs pass under `-race`, and `make test-realtime` (the mock-backend realtime e2e suite,
|
||
12 specs over WS + WebRTC) passes.
|
||
- **Single authoritative terminal + populated Output/Usage (done).** One
|
||
`response.created` and one `response.done` per `response.create`, even across the
|
||
server-side agentic tool loop (which is now internal turns of one response, not one
|
||
terminal each). A `liveResponse` accumulator threads through
|
||
`triggerResponse`→`triggerResponseAtTurn`→`emitToolCallItems`/`streamLLMResponse`,
|
||
collecting output items as they complete and summing token usage; `triggerResponse`
|
||
emits the one terminal (completed/cancelled; failed still emits none, matching legacy)
|
||
with `Output` + `Usage` filled in (both were always empty before). Verified: 301 openai
|
||
specs under `-race` (incl. a new `triggerResponse` terminal test) + `make test-realtime`.
|
||
Design note: emission is hoisted to `triggerResponse` (the body owns it) rather than the
|
||
coordinator's `EmitTerminal` effect — at cancel/supersede time the coordinator doesn't
|
||
yet have the body's partial Output, so the body, which does, is the natural emitter. The
|
||
coordinator still guarantees one body run per `response.create`, so "exactly one terminal"
|
||
holds transitively; `EmitTerminal` remains the spec's logical marker (no-op in the sink).
|
||
- **M2 (turn detection) — model + spec landed AND wired into the live session.**
|
||
Explicit machine in `core/http/endpoints/openai/turncoord/` (sealed `State` =
|
||
`Idle | Speaking{Turn}`, `Event` = `Onset | Silence | Abort{Reason}`, `Effect` =
|
||
`BargeIn | OpenTurn | EmitSpeechStarted | EmitSpeechStopped | CommitTurn |
|
||
DiscardTurn`, a total pure `Next`, a single-writer `Coordinator`);
|
||
transition-table + Ginkgo/Gomega seeded-property + concurrent conformance tests
|
||
(green under `-race`). The fix it encodes: "speech detected" and "a turn is open"
|
||
— the two legacy variables (`speechStarted` and `lts.open()`) that a `discardTurn`
|
||
could desync (failure mode 4) — become ONE state, so the next-onset suppression
|
||
bug is unrepresentable. Authoritative spec:
|
||
`formal-verification/turn_lifecycle.fizz`, with an `always assertion Coupled`
|
||
(speech ⟺ turn-open), verified non-vacuous (deleting `self.speech = 0` in `Abort`
|
||
makes the checker report `Coupled` violated). The gate
|
||
(`scripts/realtime-conformance.sh`, pre-commit, CI) covers `turncoord` and the
|
||
spec. **Wired (done):** `realtime_turncoord.go` adds `turnSink` (the
|
||
`turncoord.Coordinator` + a loop-local effect sink) to `handleVAD`. The legacy
|
||
`speechStarted` bool is gone; onset/no-speech-clear/commit/teardown route through
|
||
`coord.Apply(Onset|Abort{NoSpeech}|Silence|Abort{Teardown})`. The turn id is
|
||
minted at onset and carried by the coordinator to the committed event (so it
|
||
matches the live captions); `liveTurnState.openTurn` now takes that id instead of
|
||
minting its own. A semantic→server mode switch mid-turn is deliberately NOT an
|
||
abort (it only drops the orphaned live stream and lets the turn continue under
|
||
server_vad), so it stays inline. Verified: builds, `go vet`/`gofmt`/golangci-lint
|
||
clean, all openai specs under `-race`, and `make test-realtime` (12 e2e specs over
|
||
WS + WebRTC) pass.
|
||
- **M1 (connection lifecycle) — model + spec landed AND wired.** Explicit machine
|
||
in `core/http/endpoints/openai/conncoord/` (sealed `State` = `Live{VADRunning} |
|
||
Torn`, `Event` = `SetVAD | Close`, `Effect` = `StartVAD | StopVAD | Teardown`, a
|
||
total pure `Next`, a single-writer `Coordinator`); transition-table +
|
||
Ginkgo/Gomega seeded-property + concurrent conformance tests (green under
|
||
`-race`). It replaces the legacy `vadServerStarted` bool + the `done` channel
|
||
reassigned on every turn-detection toggle and closed from two sites (failure
|
||
mode 6): the coordinator owns whether the VAD goroutine runs, so its done channel
|
||
is closed exactly once and never resurrected after teardown; `Close` moves to
|
||
`Torn`, which absorbs every later event so teardown runs exactly once even from
|
||
multiple exit paths (invariants #8, #10). Spec:
|
||
`formal-verification/conn_lifecycle.fizz` (`always assertion TeardownOnce` +
|
||
`NoRunAfterTorn`), verified non-vacuous (deleting `self.torn = 1` in `Close`
|
||
fails `TeardownOnce`). **Wired (done):** `realtime_conncoord.go` adds `connSink`;
|
||
the handler's setup/`toggleVAD`/teardown now route through
|
||
`conn.setVAD(...)`/`conn.close()`; the `done`/`vadServerStarted` locals and the
|
||
manual ordered-teardown block are gone (the Teardown effect performs that
|
||
sequence). Verified: builds, vet/gofmt/golangci-lint clean, openai specs under
|
||
`-race`, `make test-realtime` (12 e2e WS+WebRTC), full conformance gate green
|
||
(3 Go packages + 3 fizz specs PASSED).
|
||
- **M4 (conversation compaction) — model + spec landed AND wired.** Explicit
|
||
machine in `core/http/endpoints/openai/compactcoord/` (sealed `State` =
|
||
`Idle | Running`, `Event` = `Trigger | Finished`, `Effect` = `StartCompaction`,
|
||
a total pure `Next`, a single-writer `Coordinator`); transition-table +
|
||
Ginkgo/Gomega seeded-property + concurrent (effect-spawns-work-reports-Finished)
|
||
conformance tests (green under `-race`). It makes the legacy `compacting
|
||
atomic.Bool` single-flight guard explicit: a `Trigger` while `Running` is dropped
|
||
(not superseded — compaction is idempotent work on the same overflow), so at most
|
||
one summarize+evict runs per conversation (invariant #9). Spec:
|
||
`formal-verification/compaction.fizz` (`always assertion SingleFlight`), verified
|
||
non-vacuous (deleting the `if self.active == 0` guard fails `SingleFlight`).
|
||
**Wired (done):** `realtime_compactcoord.go` adds `compactionSink`; the
|
||
`Conversation.compacting atomic.Bool` is replaced by `Conversation.compaction
|
||
*compactionSink` (built at conversation creation with the summarize+evict run
|
||
closure); `maybeCompact` now calls `conv.compaction.trigger()`. The summarizer
|
||
resolution + `compact()` stay in the sink's spawned goroutine (off the response
|
||
path); `compact()` itself (snapshot/summarize-unlocked/optimistic-commit) is
|
||
unchanged. Verified: builds, vet/gofmt/golangci-lint clean, openai specs under
|
||
`-race`, `make test-realtime` (12 e2e), full conformance gate green (4 Go
|
||
packages + 4 fizz specs PASSED).
|
||
- **M5 (TTS pipeline lifecycle) — model + spec landed AND wired.** Explicit
|
||
machine in `core/http/endpoints/openai/ttscoord/` (sealed `State` =
|
||
`Open | Closing | Closed`, `Event` = `Close | WorkerExited`, `Effect` = `Wake`, a
|
||
total pure `Next`, a single-writer `Coordinator`); transition-table +
|
||
Ginkgo/Gomega seeded-property + two-writer conformance tests (green under
|
||
`-race`). It is a genuine two-writer machine (producer `Close` from `wait()` vs
|
||
worker `WorkerExited`); it makes the legacy `closed bool` lifecycle explicit and
|
||
monotonic, fixes the latent enqueue-after-close silent drop (enqueue is now gated
|
||
on `Open`), and guarantees idempotent `wait()` (one wake / one worker join). The
|
||
poison `failed` latch stays a lock-free `atomic.Bool` (orthogonal, read per
|
||
clause on the worker's hot path). Spec: `formal-verification/tts_pipeline.fizz`
|
||
(`always assertion WakeOnce` + `Monotonic`), verified non-vacuous (deleting the
|
||
`if self.phase == 0` guard in `Close` fails `WakeOnce`). **Wired (done):**
|
||
`realtime_tts_pipeline.go`'s `ttsPipeline` embeds the coordinator (and is its
|
||
effect sink — `Wake` → `signal()`); `closed bool` is gone; the worker checks
|
||
`closing()` and raises `WorkerExited` on drain, `enqueue` rejects once not
|
||
`Open`, `wait()` raises `Close`. The wake/done channel mechanics are unchanged.
|
||
Verified: builds, vet/gofmt/golangci-lint clean, openai specs under `-race`,
|
||
`make test-realtime` (12 e2e), full conformance gate green (5 Go packages + 5
|
||
fizz specs PASSED).
|
||
- **All five mapped machines (M1–M5) are now explicit, wired, and verified.** The
|
||
realtime-conformance gate model-checks all `.fizz` specs and runs all five Go
|
||
conformance suites under `-race`, fail-closed.
|
||
- **The machines form a hierarchy, and that relationship is now modeled and
|
||
enforced.** M1 (connection) is the parent region; when it tears down, every child
|
||
must be terminal. Previously this was only an imperative side effect of
|
||
`conncoord`'s teardown ordering, with a real gap (M4 compaction was
|
||
fire-and-forget and could outlive the torn session). Now:
|
||
- `formal-verification/session_lifecycle.fizz` is a **composition spec** that
|
||
models conn + its direct children (vad/M2, resp/M3, compaction/M4) as one
|
||
statechart and asserts `ChildrenDieWithParent` (conn torn ⟹ all children
|
||
terminal) plus "no child starts after teardown". Its non-vacuity reproduces the
|
||
exact M4 gap (drop the compaction-terminate line → assertion fails).
|
||
- `respcoord` (M3) and `compactcoord` (M4) gained an absorbing **`Terminated`**
|
||
state + a `Shutdown` event, so a response/compaction cannot start after
|
||
teardown (structural "no resurrection").
|
||
- `conncoord`'s `Teardown` effect now explicitly drives the children terminal:
|
||
stop+join the VAD goroutine (M2), `respSink.shutdown()` (M3 → Terminated, joins
|
||
response goroutines and their M5 pipelines), and `compaction.shutdown()` for
|
||
every conversation (M4: cancel the in-flight summary via a session-scoped
|
||
context, then join — **closing the gap**). `compact` now takes a `context` so
|
||
teardown can bound the join. M2's terminal is realized by the goroutine join and
|
||
M5's by its existing `Closed`; the persistent coordinators (M3/M4) carry the
|
||
explicit `Terminated` state.
|
||
|
||
## Part 5 — Library vs hand-rolled (Go ecosystem, verified 2026-06)
|
||
|
||
Researched against live GitHub/pkg.go.dev data. **Verdict: hand-roll a typed transition
|
||
table over sealed sum-type states for the per-connection machines.** No Go library gives
|
||
the two properties we most want — *compile-time-illegal states* and a *pure
|
||
`next(state,event)->(state,[]effect,error)`*; every library models states as
|
||
`string`/`int`/`any` and fires side-effecting callbacks mid-transition. And since the
|
||
actor (Part 3.1) drives everything from one goroutine, the libraries' main value-add —
|
||
internal locking — is dead weight.
|
||
|
||
Library landscape:
|
||
|
||
| Option | Stars / status | Hierarchy | Typed states | Illegal-transition | Viz | Fit |
|
||
|---|---|---|---|---|---|---|
|
||
| **hand-rolled table + sealed sum types** | — | DIY (parent field / nested switch) | **yes** (sealed iface) | explicit `default:` | ~30 LOC Mermaid emitter | **best** |
|
||
| **qmuntal/stateless** (port of .NET Stateless) | 1.36k, v1.8.0 2026-02, maintained | yes (substates, guards, entry/exit, internal/ignored) | `any` | `error` + `OnUnhandledTrigger` + `PermittedTriggers` | DOT | best library fallback if hierarchy grows |
|
||
| **looplab/fsm** | 3.4k, v1.0.3 2025-05, maintained | flat | strings | typed errors | **DOT+Mermaid** | only for flat machines wanting free diagrams |
|
||
| cocoonspace/fsm | 89, dormant 2021 | flat | int | `bool` no-op | — | lock-free but dead; DIY beats it |
|
||
| true Harel statecharts (gstate, statechartx) | ≤10, <1yr, single-author | parallel+history | varies | varies | varies | only if we truly need parallel regions; unproven |
|
||
| Temporal / Cadence | large, maintained | n/a | n/a | n/a | n/a | **overkill** — external cluster+DB, durable replay, wrong latency class |
|
||
|
||
Decision: hand-roll; keep **qmuntal/stateless** as the fallback if one machine grows deep
|
||
hierarchy/guards faster than we want to hand-maintain (its `error`-on-illegal-trigger and
|
||
`PermittedTriggers()` are the most useful library features for our "reject illegal
|
||
transitions" requirement, at the cost of `any`-typed states). Add a tiny Mermaid emitter
|
||
over the hand-rolled table so we keep the visualization the libraries advertise.
|
||
|
||
## Part 6 — Formal design tied to code, and making it authoritative
|
||
|
||
The user requirement: the formal design is **authoritative** — a coding agent should be
|
||
unable to silently change implementation behavior without it being caught against the
|
||
spec; the default path is "update the spec and re-verify," not "edit the code and ignore
|
||
the spec." This is a *conformance + enforcement* problem, in three layers.
|
||
|
||
### 6.1 The source of truth & design-time check
|
||
|
||
Write the concurrency-critical core — the **M2↔M3 boundary** (turn detection ↔ response:
|
||
barge-in, ResponseCancel, speech_stopped, the dual-writer race) — as a **FizzBee** spec
|
||
and **model-check it in CI**. Keep the spec small and focused on M2↔M3; that is where the
|
||
real safety/liveness properties (Part 4 invariants 1–4) live. (FizzBee is the adopted
|
||
model checker — see Part 6.4.)
|
||
|
||
### 6.2 The conformance bridge (code ↔ spec)
|
||
|
||
The honest finding: design-time model checking is well-supported; the *Go conformance
|
||
bridge is thin everywhere* and needs per-spec glue. Two layers, adopted together:
|
||
|
||
1. **FizzBee MBT** — the authoritative layer. The `.fizz` spec is model-checked, and
|
||
`fizz mbt-scaffold --lang go` generates Go interfaces + a `go test` harness; you
|
||
implement adapters mapping model actions→code and `StateGetter`→state. Conformance
|
||
runs as plain `go test` — the cleanest CI fit. Risk: pre-1.0, essentially one
|
||
maintainer (pin a version + sha256, vendor examples).
|
||
2. **Ginkgo/Gomega seeded property tests** — the Go-native floor. A small Go model
|
||
(the test's `open`/`registered` shadow) is the oracle; a fixed-seed random walk
|
||
drives random event sequences against the `Coordinator`, asserting the Part-4
|
||
invariants after each step / per seed. It checks the *implementation* against a Go
|
||
oracle — it complements, but does not replace, the FizzBee check of the *design*.
|
||
(We originally specced `pgregory.net/rapid` here for its `(*T).Repeat` driver and
|
||
automatic shrinking, but LocalAI mandates Ginkgo/Gomega for all tests — its
|
||
`forbidigo` lint forbids stdlib `testing` assertions — and `rapid.Check` needs a
|
||
concrete `*testing.T`/`*rapid.T` that cannot run inside a Ginkgo `It`. Rather than
|
||
weaken the lint gate with an exclusion, the property layer is hand-rolled seeded
|
||
walks: fixed seeds make every failure reproducible, at the cost of `rapid`'s
|
||
automatic shrinking. `rapid` is consequently not a direct dependency.)
|
||
|
||
These compose: model-check the design (6.1) for "the design is right"; conformance-test
|
||
the code (6.2) for "the code matches the design." Add `go test -race` (with `-cpu=1,2,4`,
|
||
repeated runs) over the stateful tests for interleaving-bug discovery, and Go native
|
||
fuzzing over the *same* harness for coverage-guided sequence exploration + a committable
|
||
regression corpus. (`testing/quick` is frozen — do not use.)
|
||
|
||
There is no viable single-source-of-truth codegen (one spec compiled into both the runtime
|
||
Go and the model) for retrofitting existing Go — the candidates are research-grade and
|
||
greenfield-only. Our practical substitute is the CI gate below plus a single Go transition
|
||
table that emits both the diagram and the test action set.
|
||
|
||
### 6.3 Enforcement — making the design un-ignorable for agents
|
||
|
||
Structural enforcement, leveraging this repo's existing non-bypassable gate culture
|
||
(pre-commit + monotonic ratchets; `--no-verify` is forbidden, baselines never lowered):
|
||
|
||
1. **Add a `realtime-conformance` gate** to the pre-commit/CI pipeline that runs (a) the
|
||
model check (6.1) and (b) the conformance bridge (6.2). A behavior change that does not
|
||
conform turns the gate **red**; the only green paths are *make the code conform* or
|
||
*update the spec* — and updating the spec re-triggers the model check, so an illegal
|
||
design is rejected too. This is the actual mechanism that makes "update the design and
|
||
verify" the default rather than optional.
|
||
2. **Treat the spec as a ratchet artifact** like coverage: the gate must not be weakened,
|
||
the spec not deleted, the build tag not silently disabled.
|
||
3. **Write an `.agents/realtime-state-machines.md` guide** (indexed from `CLAUDE.md`)
|
||
stating the spec is the source of truth: change the spec first, re-run the gate, then
|
||
implement. The doc is secondary; the gate is what enforces it.
|
||
|
||
### 6.4 Decided stack
|
||
|
||
- **Implementation:** hand-rolled sealed-state transition functions + single-writer actor
|
||
(Parts 3.1–3.2).
|
||
- **Design-time + conformance:** **FizzBee** (decided). `.fizz` spec is model-checked, and
|
||
`fizz`'s Go MBT generator (`mbt/generator/templates/go` → interfaces/adapters/test;
|
||
driven via a gRPC plugin in `mbt/lib/go`) produces a `go test` conformance harness
|
||
whose adapters map model actions → our actor and `StateGetter` → our state. Go is a
|
||
first-class MBT target (Go + Rust are the only two). Verified 2026-06: Apache-2.0,
|
||
v0.5.2, prebuilt linux/macos×x86/arm binaries, ships Claude Code skills
|
||
(`/fizz-spec|check|debug|mbt`) for the spec-authoring loop.
|
||
- **Go-native layer:** **Ginkgo/Gomega seeded property tests** run alongside — they
|
||
check the *implementation*, complementing (not substituting for) the FizzBee check
|
||
of the *design*. Skipping FizzBee is NOT "degrading to the Go layer": the design
|
||
authority would be gone. The gate is therefore **fail-closed** (see Enforcement).
|
||
(Originally specced as `rapid`; switched to Ginkgo/Gomega to satisfy LocalAI's
|
||
Ginkgo-only `forbidigo` lint without weakening that gate — see Part 6.2.)
|
||
- **Enforcement:** the `realtime-conformance` pre-commit/CI gate + `.agents/` guide
|
||
(Part 6.3).
|
||
|
||
FizzBee risk mitigations (decided):
|
||
- The gate is **fail-closed**: a missing FizzBee is a hard failure, never a silent skip.
|
||
The only bypass is the explicit, loud `REALTIME_CONFORMANCE_SKIP_FIZZBEE=1` (local
|
||
only; CI never sets it; pre-commit runs the gate on any `respcoord/**` or
|
||
`formal-verification/**` change so a pure `.fizz` edit still re-verifies).
|
||
- CI **pins the FizzBee release binary by version + sha256** (`formal-verification/fizzbee.sha256`,
|
||
all four platforms, digests from the GitHub release; installer verifies before extract,
|
||
CI caches it). Not go-gettable: `pkg/modelchecker` imports the Bazel-internal `fizz/proto`
|
||
with no committed `.pb.go`, so a plain `go get` won't build — hence the pinned binary.
|
||
- Keep the `.fizz` model **portable** (no exotic features) so it stays re-expressible in
|
||
another model checker if FizzBee is ever abandoned — lock-in is at the tooling layer
|
||
only, not the design.
|
||
|
||
## Open questions (decide before implementing)
|
||
|
||
- **Scope of the actor refactor**: full single-writer per session, or incrementally
|
||
migrate one machine at a time behind the existing locks? (Suggest: M3 response
|
||
coordination first — it has the load-bearing dual-writer bug.)
|
||
|
||
Resolved: **FSM library vs hand-rolled** → hand-rolled sealed-state tables,
|
||
qmuntal/stateless fallback (Part 5). **Conformance bridge** → FizzBee (model-check + Go
|
||
MBT) with a Ginkgo/Gomega seeded-property Go-native floor as hedge (Part 6.4). **Single-source-of-truth codegen**
|
||
(PGo/MPCal) → not viable (research-grade, greenfield-only); substitute is the CI
|
||
conformance gate (Part 6.3).
|
||
|
||
**Agentic turn semantics** → invariant #2 is **one `response.done` per `response.create`**
|
||
(OpenAI-faithful); the server-side `AssistantExecutor` tool loop becomes internal
|
||
sub-states of a single response rather than emitting one terminal per turn. Verified safe
|
||
in-tree: the current `response.done` carries only `{id, object, status}` (`Output`/`Usage`
|
||
never populated), the React UI (`Talk.jsx:330`) reads only `status`, every unit test
|
||
already asserts `ResponseDone == 1` for tool turns, no test expects multiplicity, and the
|
||
server-side recursion is untested. Collapsing also fixes a latent "Listening…" flicker
|
||
mid-agentic-loop. The client-driven tool loop (fresh `response.create` per round-trip)
|
||
legitimately keeps one terminal each — unaffected. Follow-up: actually populate `Output` +
|
||
`Usage` in the single terminal (currently always empty).
|