Voice Agent Goes Silent Mid-Call? Debugging 6 Pipeline Bugs

Voice agent silence stems from six pipeline bugs and a provider stall, not the model.
A developer spent a week debugging why his voice agent stopped responding mid-call, finding six pipeline-level bugs and one provider stall—none involving the model itself. The common pattern: a pipeline component gave a confident but wrong answer to "is the call still in progress?" and the code believed it. Issues ranged from a watcher crashing on incoming calls and accessibility-tree blind spots, to reversed hang-up signals, audio deadlocks, self-sustaining loops, macOS suppressing calls during screen sharing, and four-minute provider stalls. Fixes follow three rules—fail open, single holder, and process alive doesn't mean working—plus a five-minute self-healing routine.

A developer ran into a problem where their voice agent suddenly stopped responding mid-call, with users reporting "I heard you, but you didn't respond." After a week of investigation, he found six pipeline-level bugs and one provider stall issue—none of which involved the model itself getting dumber. The common pattern in every case: some pipeline component gave a confident but wrong answer to "is the call still in progress?" and the code chose to believe it.
Double Failure of Detection and Monitoring
The Watcher Process Died Right When the Call Connected
The watcher process launched the voice loop via a hardcoded .venv interpreter path, but the directory had long since been renamed to venv. The stale path caused Popen to throw a FileNotFoundError, and because the exception occurred inside the watcher itself, it killed the watcher outright—right at the moment of an incoming call. The call connected, but no process was started, and no log was left explaining why.
The fix was deliberately boring: resolve the interpreter path from a candidate list, probe each with an import test, fall back to sys.executable, and wrap the spawn process to log failures. The process responsible for watching for failures shouldn't die first itself.
Popen is a class in Python's standard library subprocess module used to launch child processes. It takes an argument list where the first element is typically the executable path and subsequent elements are command-line arguments passed to that program. If the specified path doesn't exist or isn't executable, Popen throws a FileNotFoundError or PermissionError at construction time. In this scenario, the watcher process launches the voice loop subprocess via Popen, and its hardcoded interpreter path points to a directory that has been renamed, so every launch attempt fails. The key point is that this exception occurs in the watcher's own code path and isn't caught, causing the watcher process itself to crash rather than simply logging a launch failure and continuing to run.
Detection Blind Spots and Watchdog Misjudgments
This Mac has no camera, and FaceTime's in-call UI is drawn in a location unreachable by the accessibility tree: zero AX nodes, with the window itself only showing "Connect Camera." Across an entire 448-second call, the accessibility probe consistently returned idle. The watchdog read idle, concluded the call had ended, and closed an active session at around 14 seconds.
The alternative signal came from the audio side: during a call, the loop holds two virtual audio devices, both of which read IsRunningSomewhere = True, and False when the call ends. The watchdog rules changed accordingly: an explicit ended closes immediately, four consecutive abnormal readings trigger a hang-up, and uncertain readings keep the session alive.
There was a similar issue one level deeper: the watcher used to close on a single unknown, but detection briefly flickers to unknown during a historical database replication race, and one flicker could kill a call. Now it requires six consecutive unknown readings (about three seconds) to trigger. Group calls also need their own path, since they never emit ringing and go straight to connected.
IsRunningSomewhere is a property of the AudioDevice object in the macOS Core Audio framework that indicates whether an audio device is being used by any process in the system in any way (including input or output). It returns a boolean: True means the device is currently occupied by at least one process, False means it's completely idle. Unlike checking audio streams process by process, this property aggregates device usage status at the system level, so it doesn't depend on the accessibility tree or UI visibility. In this article, the loop holds two virtual audio devices during a call, IsRunningSomewhere stays stably True, and it becomes False after the call ends, making it a more reliable active-call signal than the accessibility probe.
Hang-Up Signals and Audio Deadlocks
The Hang-Up Signal Was Reversed
While a call is in progress, CallHistory has no record row; the row with the final duration only appears after the call completes. The old check read duration == 0 as "still in call," which is wrong in both directions—a failed dial leaves 0.0 there forever.
The reliable signal is a snapshot: record the latest Z_PK at session start, and any newer row means the call has ended. This is the only honest hang-up source on this machine, because the process holding the audio device can never detect the end of its own call.
Audio Hung but the Process Was Still Alive
sounddevice deadlocked inside OpenAndSetupOneAudioUnit, waiting on mach_msg. sample <pid> showed the exact frame, and a bare InputStream hung the same way. sudo killall coreaudiod cleared the stall because the daemon restarts on demand and device opening returned to normal.
Days later the problem recurred, and coreaudiod was innocent. The real holder was audiomxd, in R state for 29 days since boot, at about 94% CPU, with load averages between 48 and 88 and zero idle. Killing that daemon restored audio immediately. The rule was updated: when audio opening hangs, read process state first before restarting the obvious daemon.
mach_msg is an inter-process communication (IPC) primitive provided by the Mach microkernel in the macOS kernel; user space enters the kernel via the mach_msg_trap system call to send and receive messages. When a thread calls mach_msg waiting for a message, it enters an uninterruptible wait state, appearing in sample or spindump output as a stack frame stuck on mach_msg_trap or mach_msg. The audio framework (Core Audio) makes heavy use of Mach messages internally to synchronize device state between clients and the coreaudiod daemon, so a mach_msg wait during audio device opening typically means the client is waiting for a response from the daemon, which may be deadlocked or overloaded. The sample <pid> command captures a call stack snapshot of the target process, allowing you to pinpoint the specific blocking point.
The Loop Sustained the Fault Itself
A stuck loop held a virtual audio device, detection read "connected," and the watchdog started another loop, with two loops fighting over one device. This wedge survived a coreaudiod restart, so it looked like a hardware problem.
The evidence came from two comparison runs: after stopping the watchdog and killing stale processes, audio opened immediately; with them running, every open hung. The resulting rule: one loop per call, zero loops when idle, enforced from both sides, plus a watchdog that kills duplicate loops during calls and stale loops when there's no call.
System-Level and Provider Issues
Incoming Calls That Never Rang
On top of all this was a failure the agent code couldn't reach. With a screen sharing session active, macOS suppressed incoming calls before they rang. The log said it explicitly: outcome: suppressed; reason: display shared. A notification setting—notifications while mirroring or sharing a display—was off. After turning it on, all 48 incoming calls within five minutes were allowed, with zero suppressions. The agent wasn't missing calls; the operating system never let the calls exist.
The Provider Added Four Minutes of Silence
After the pipeline fixes, one live call was still quiet for four minutes. The logs showed the outline: dispatched at 20:19:03, first tool output at 20:23:07, with the tool itself taking 3.1 seconds. It happened again on the next turn: dispatched at 20:23:18, completed at 20:27:31, with about 11.6 seconds of work surrounded by 4 minutes and 13 seconds of dead silence. The client had no timeout at all, so a congested provider queue looked exactly like a hang.
The fix was a 35-second socket read timeout on every brain call. Streaming deltas keep resetting the timeout, which bounds dead silence without cutting off a model that's genuinely thinking. Behind that is a backup model that receives the same conversation, measured at 1.9 seconds through one call path and 1.8 seconds through another. Another limit bit me during reconnection: the greeting carried 2,500 characters of call tail, triggering Context append text must not exceed 500 tokens three times, each failing silently. It's now capped at 900 characters.
A socket read timeout is a maximum time limit set on recv or read operations during network I/O. If no data is received within that time, the underlying call throws a timeout exception (such as socket.timeout in Python) instead of blocking indefinitely. For streaming responses (such as SSE or chunked transfer), the timer resets with each received data chunk, so the timeout bounds the duration of "consecutive no-data" rather than the total request duration. In this article, setting a 35-second socket read timeout on every brain call both allows timely failure and fallback to the backup model when provider queue congestion causes long periods of no output, and avoids interrupting a slow model that's genuinely streaming tokens.
Rules Drawn from This
Fail open. Uncertain readings keep the session alive; only explicit endings close it. A watchdog that treats "I can't tell" as "it's over" will kill active calls on any machine with natural detection blind spots—and most machines have them.
Single holder. Exactly one process holds the audio device per call, zero when idle. Every duplicate is a future feedback loop.
Process alive doesn't mean working. A stuck daemon keeps its PID, memory, and state, then stops at the first line of actual work. Test the thing itself, not the process table. The health script now opens an audio device on every run, and its silence is the signal that everything's fine.
The self-healing routine runs every five minutes with five tasks: kill duplicates during calls, kill stuck loops so the watcher respawns them, kill stale loops when there's no call, restart the audio daemon when device opening hangs, and kick the watcher when it's down. It stays silent unless it takes action.
What to Check First
If your agent suddenly goes silent, don't start with the model. Start with the last place that reported "what happened" and ask it: what does it print when it doesn't know?
Which watchdog in your stack is reporting confidence it hasn't earned?
One-line Summary
Voice agent silence stems from six pipeline bugs and a provider stall, not the model getting dumber.
Paragraph Summary
A developer spent a week investigating why his voice agent suddenly stopped responding mid-call, finding six pipeline-level bugs and one provider stall issue—none of which were the model's fault. The common pattern: some pipeline component gave a confident but wrong answer to "is the call still in progress?" and the code chose to believe it. Issues included: the watcher crashing on incoming calls due to a stale hardcoded path; accessibility tree detection blind spots causing the watchdog to misjudge and close active sessions; the hang-up signal being reversed; audio device opening deadlocks; stuck loops sustaining the fault themselves; macOS suppressing incoming call notifications during screen sharing; and provider congestion causing four minutes of silence. The fixes follow three rules—fail open, single holder, process alive doesn't mean working—backed by a self-healing routine that runs every five minutes.
Related articles

Chain of Thought Isn't Just for AI: How Humans Can Use CoT to Improve Thinking Quality
Chain of Thought isn't just a prompt trick for large models. Starting from a Hacker News discussion, this article analyzes why CoT works for human thinking too, breaks down actionable ways to transfer it, and highlights the key differences between human CoT and machine CoT.
Cloudflare Teams Up with OpenAI: Closi…
Cloudflare Teams Up with OpenAI: Closing the Loop from Vulnerability Discovery to Defense with AI
Cloudflare uses OpenAI Daybreak to investigate security vulnerabilities and generate reviewable remediation plans, while developers use Codex to boost coding efficiency and quality.

Orca Multi-Agent Orchestration in Practice: A Guide to Parallel Collaboration with Claude and Codex
Orca is Stable AI's free open-source agent workbench supporting 34 agents like Claude Code and Codex in parallel. Learn its Git worktree isolation, multi-model orchestration, and mobile management.