Commit daa159d
fix(voice): L2 — VoicePipeline reliability (review sweep) (#172)
Nine findings from the codec_voice.py review. The voice pipeline is the live
WebSocket loop, so each change is behavior-preserving on the happy path and
guarded on the failure paths.
1. WebSocket send-after-close (CRITICAL)
_safe_send_bytes / _safe_send_json + a self._ws_alive flag flipped False on
disconnect (in _audio_receiver). _speak and the crew callback now no-op on a
dead socket instead of raising — a client disconnect during a multi-minute
crew no longer surfaces an exception out of the detached callback.
2. Fire-and-forget tasks (CRITICAL/HIGH)
warmup_llm is now stored (self._warmup_task), gets an exception-logging
done_callback, and is cancelled in run()'s finally (was orphaned → GC-able
mid-flight + un-retrieved-future warnings). The two screen-overlay
run_in_executor futures (orphaned + deprecated get_event_loop()) became a
guarded _spawn_detached() — Popen is already non-blocking.
3. Double save_to_memory (HIGH)
Both run()'s finally AND routes/websocket.py's finally call it → every voice
turn was written to memory.db twice. Now idempotent via a _memory_saved flag.
4. _resolve_voice_option_choice longest-match (HIGH)
The exact-substring loop returned the FIRST matching label, so "yes" beat
"yes and notify" → mis-routed a non-strict multi-option answer. Now prefers
the longest match. (Strict consent is unaffected — it bypasses this resolver.)
5. Echo-cooldown dropped barge-in audio (HIGH)
feed_audio returned None unconditionally during the post-TTS cooldown, so a
user already mid-utterance lost their leading words ("...end my email"). Now
only a NEW speech start is suppressed; in-progress capture continues.
6. Unbounded audio_buffer (MEDIUM)
Continuous mic noise above the VAD threshold kept last_speech_time fresh, so
the silence gate never fired and the buffer grew without bound (~32 KB/s).
Force-flush at MAX_UTTERANCE_BYTES (config: vad.max_utterance_seconds, 30s).
7. utterance_queue head-of-line block (MEDIUM)
await queue.put() on the maxsize=3 queue blocked the receiver while the
pipeline was slow — stalling interrupt/ping control frames. New
_enqueue_utterance() is non-blocking and drops the oldest on overflow.
8. _stream_qwen error sentinel pollution (MEDIUM)
"Sorry, I had a processing error." was appended to self.messages + saved to
memory, so the LLM "saw" a fake apology it never reasoned. A _stream_error
flag now skips persisting it (the user still HEARS it).
9. _announced_question_ids premature mark (HIGH)
The poll marked a question announced BEFORE the announce ran; a failed/closed
announce left the user answering an unheard question. Now the caller marks it
only after a successful announce, so a failure retries next poll.
Test surface: tests/test_voice_reliability_l2.py (10 tests) + updated
test_voice_ask_user::test_poll_skips_already_announced_question for the new
mark-on-success contract. Existing 13 voice-pipeline + 29 ask-user tests pass.
Full suite: 2,065 passed / 77 skipped. ruff clean.
Co-authored-by: Mickael Farina <farina.mickael@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>1 parent 929c79c commit daa159d
3 files changed
Lines changed: 324 additions & 35 deletions
0 commit comments