Conversation
…tead of stalling A received voice note reached the automatic STT pipeline but had no bound: _transcribe_one_clip awaited asyncio.to_thread(transcribe_audio, ...) with no timeout, and the only start marker was logged at DEBUG. A backend that never returns (local whisper wedged in model load / CUDA init, a stalled provider socket) therefore left the inbound turn — and the platform typing indicator — running forever with no reply and no diagnostic, which is exactly the NousResearch#105315 report: voice cached, typing forever, nothing after 'Cached user voice'. - bound each blocking STT call with asyncio.wait_for (stt.timeout_seconds, default 180s) and log an ERROR naming the stage that wedged - on timeout emit the existing neutral 'could not be transcribed' note so the agent still gets a turn; never stack the local fallback behind a timed-out call - log the start of automatic transcription at INFO and its failure at WARNING - a *raising* configured provider no longer skips the local-STT recovery - log unexpected transcription errors with the file path and a traceback
Related: #74051 (open, bounds the same STT await with a 45s configurable deadline) and its salvage #78196 (STT/TTS reliability slice). Competing fixes for #105315; this PR additionally raises the transcription log lines to INFO/ERROR and routes raised exceptions into the local-STT fallback. Maintainer should pick one deadline mechanism. |
|
Exact-head KEEP on #107738 head 769267c. KEEP surfacing/logging automatic voice STT failures on the inbound path so Telegram voice notes no longer sit on "typing…" forever after CHECK: failure path still returns a user-visible error (not only a log line). CHECK: successful STT path unchanged. CHECK: config example docs in |
What Problem This Solves
Reported in #105315: a Telegram voice note is downloaded and cached, the assistant shows
"typing…" forever, no reply ever arrives, and the gateway logs stop right after
Cached user voice at …— nothing at INFO level explains what happened. Manualtranscription of the same file from the CLI works in seconds.
The voice actually reaches the automatic STT pipeline (the reporter's
inbound message … msg=''line is the voice turn); what it reaches is a path that cannot report failure.Two independent defects in
gateway/run_inbound.py:The automatic STT call had no bound.
_transcribe_one_clipawaitedasyncio.to_thread(transcribe_audio, path, None, "gateway")(gateway/run_inbound.py:1934on
main) with no timeout, and the same for the local fallback (:1936). A backend thatnever returns — local whisper/Faster-Whisper wedged in model load or CUDA init, a stalled
provider socket — leaves the inbound turn awaiting that future forever. The platform's
typing task is driven by the event loop, which is still healthy, so the loop watchdog
never fires and the indicator is refreshed indefinitely. Nothing is raised, so the
[voice message could not be transcribed automatically; …]note that every failure branchemits was never produced, and no reply was ever generated.
The one log line that would have distinguished "STT is wedged" from "STT never ran"
was DEBUG.
logger.debug("Transcribing user voice: %s", path)(:1987) is invisible inthe reporter's default INFO logs, which is exactly the ambiguity raised in the issue
thread. The "transcription failed" line (
:1941) was INFO too, with no traceback, so evena fast failure was easy to miss.
A third, smaller defect:
_transcribe_one_cliponly tried the local-STT recovery when theconfigured provider returned
success=False. If the provider raised (e.g. CUDA libsfailing to dlopen, a network exception), the exception escaped to the caller's handler at
:1994and the local fallback the code was written to provide never ran.The fix
_bounded_stt_call(new) awaits each blocking STT call off-loop underasyncio.wait_for(..., timeout=stt.timeout_seconds), default 180s, and logs an explicitERRORnaming the stage, the timeout and the file ("…did not return within 180.0s … a wedgedlocal model load / CUDA init is the usual cause"). On timeout the clip gets the existing
neutral
_untranscribed_audio_notemarker, so the agent still gets a turn and the user gets areply instead of an endless typing indicator.
wait_forcan only abandonthe future, not the worker thread, so stacking the local fallback on top would just pile a
second wedged call behind the first (and, with the local provider configured, behind the same
model lock).
(
Auto-transcribing inbound voice: <path> (timeout 180s)), and the final failure line atWARNING — so an operator can tell "wedged" from "never called" from a default log.
WARNING "Configured STT raised for …") and the local fallback still runs, i.e. the recovery path actually recovers.(
Transcription error for <path>: …,exc_info=True).stt.timeout_seconds(documented incli-config.yaml.example)for slow first-time model loads on weak GPUs; missing/unparsable/zero values keep the 180s
default.
Partial fix for #105315 — the closing keyword is intentionally not used here (this PR does not close issue #105315): the gateway-side
silent stall is fixed and reproduced locally, but the reporter's wedged Faster-Whisper/CUDA
call itself needs their Windows/GTX 1650 Ti host to confirm (see the evidence boundary). What
this PR guarantees on any host: the turn can no longer hang behind STT unnoticed, and the logs
now say which stage wedged.
Evidence
No Telegram, no real audio, no STT model: the fake download/STT fixtures drive
GatewayRunner._enrich_message_with_transcription(the single choke point every automaticinbound-voice path routes through — the pending/interrupt
_transcribe_and_echo_pending_voiceand the dispatch-time
_enrich_inbound_voiceboth end here).New test file
tests/gateway/test_voice_stt_silent_failure_105315.py, run withHERMES_PYTHON=… bash scripts/run_tests.sh tests/gateway/test_voice_stt_silent_failure_105315.py(and directly per-file with the repo venv):
test_hanging_stt_call_is_bounded_and_marked→asyncio.exceptions.TimeoutErrorfrom thetest's own 8s guard: a fake
transcribe_audiothat blocks forever (the stand-in for thewedged CUDA call) means
_enrich_message_with_transcriptionnever returns — thetyping-forever stall, reproduced without a GPU.
test_auto_transcription_start_is_logged_at_info→ captured records[]: nothing at INFO.test_raising_provider_still_recovers_with_local_stt→ recordedERROR gateway.run:run_inbound.py:1995 Transcription error: cuda lib load failedand anempty transcript list: the exception skipped the local fallback.
stt.timeout_secondsparsing test); the 3 behaviour tests alone went from3 failed in 9.22sto3 passed in 1.44s— the same hang now costs one bounded wait andyields the neutral marker instead of an 8s test timeout.
byte-identical afterwards):
timeout=timeout_s→test_hanging_stt_call_is_bounded_and_markedFAILED(1 failed, 2 passed)
test_raising_provider_still_recovers_with_local_sttFAILED (1 failed, 2 passed)
test_auto_transcription_start_is_logged_at_infoFAILED (1 failed, 2 passed)
TZ=UTC PYTHONHASHSEED=0):tests/gateway/test_telegram_voice_v0_regressions.py3 passed,tests/gateway/test_telegram_audio_vs_voice.py2 passed,tests/gateway/test_stt_config.py4 passed,
tests/gateway/test_stt_transcript_echo_config.py2 passed,tests/gateway/test_mixed_attachment_routing.py4 passed,tests/gateway/test_feishu_voice_message_type.py1 passed,tests/gateway/test_busy_session_ack.py11 passed,tests/gateway/test_run_progress_interrupt.py3 passed,tests/gateway/relay/test_wire_voice_media.py19 passed,tests/gateway/relay/test_relay_media.py8 passed,tests/tools/test_transcription.py16 passed,
tests/tools/test_stt_idle_unload.py17 passed,tests/gateway/test_voice_command.py70 passed, 7 skipped (one PCM→WAV test hit its own 10sffmpeg subprocess timeout on a cold ffmpeg start in the first run; the whole file passes in
21.54s on the warm re-run and the test is unrelated to this change).
Evidence boundary. The machine used here has no NVIDIA GPU, so no claim is made about
running Faster-Whisper/large-v3-turbo, CUDA init or any real STT model — the wedged call is
simulated with a blocking fake. What is proved locally is the gateway behaviour: with an STT
backend that never returns,
mainproduces zero diagnostics, zero user-visible output and aninbound turn that never completes; with this change the turn completes, the failure is logged at
ERROR with the stage that wedged, and the user still gets a reply. Confirming why the
reporter's local model hangs (and that 180s is the right cap for a 4GB GTX 1650 Ti cold load)
still needs their host;
stt.timeout_secondsexists to tune that without a code change.