fix: stop set_mode from freezing the voice agent on a busy session#48
Open
nithiink wants to merge 1 commit into
Open
fix: stop set_mode from freezing the voice agent on a busy session#48nithiink wants to merge 1 commit into
nithiink wants to merge 1 commit into
Conversation
set_mode acquired the session turn lock with a blind `async with`, but advance() holds that lock for the entire turn (up to ADVANCE_HARD_TIMEOUT_S = 600s). So switching a session's mode while Claude was mid-work parked the set_mode call — and the whole voice agent with it — for up to minutes: the frontend awaits /api/tools/execute with no timeout and only sends Gemini a functionResponse once it returns, so the model stays in the tool-call state and re-issues the same set_mode on each new utterance, compounding the stall. Observed in the wild as ~40-68s of unresponsiveness, sometimes ending in a real voice disconnect/reconnect. Backend: set_mode now waits at most SET_MODE_LOCK_TIMEOUT_S (2s) for the turn lock and otherwise returns a "session is busy" message. A running session can't change mode mid-turn and has no pending prompt to auto-approve, so the important cases (idle / parked-on-a-permission-prompt) are unaffected — those release the lock immediately. Frontend: cap a single tool execution at TOOL_EXEC_TIMEOUT_MS (15s) via an AbortController and return an error functionResponse on timeout, so no slow backend tool can freeze the voice turn. Adds test_set_mode_busy.py covering both the fail-fast and normal paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Switching a Claude session's permission mode (e.g. "switch to auto mode", "switch back to normal") while that session is mid-work freezes the entire voice agent for tens of seconds — observed in the debug logs as ~40–68s of unresponsiveness, sometimes ending in a real voice disconnect/reconnect.
Root cause — a turn-lock deadlock with no timeout
set_mode(tmux_runner.py) is the only session-mutating tool awaited synchronously, and it grabbed the session turn lock with a blindasync with s._turn_lock:.advance()holds that same lock for the entire turn, bounded only byADVANCE_HARD_TIMEOUT_S = 600s. So when a session is actively working, the lock is held for as long as the turn runs.execute_tooljustawait dispatch_tool(...), and the frontendrunFunctionCalldoes a bareawait fetch("/api/tools/execute")and only sends Gemini thefunctionResponseafter the fetch resolves.functionResponse, the model stays in the tool-call state and re-issues the sameset_modeon each new user utterance, piling up more hung calls. The agent can't speak until the turn finally releases the lock.By contrast,
tell_claude/answer/slash are fire-and-forget (start_advanceetc. spawn a background task and return"working"immediately), which is why onlyset_modeexhibits this.Fix
Backend —
set_modenow waits at mostSET_MODE_LOCK_TIMEOUT_S(default 2s, env-overridable) for the turn lock, and otherwise returns a "session is busy — mode unchanged" message instead of blocking. A running session can't change mode mid-turn and has no pending prompt to auto-approve, so the cases that matter are unaffected:Frontend —
runFunctionCallnow caps a single tool execution atTOOL_EXEC_TIMEOUT_MS(15s) via anAbortControllerand returns an errorfunctionResponseon timeout, so no slow backend tool can freeze the voice turn (defense in depth).Tests
Adds
backend/tests/test_set_mode_busy.py:Full backend suite passes (42 tests);
tsc --noEmitclean on the frontend.🤖 Generated with Claude Code