Skip to content

fix: stop set_mode from freezing the voice agent on a busy session#48

Open
nithiink wants to merge 1 commit into
mainfrom
fix/set-mode-turn-lock-deadlock
Open

fix: stop set_mode from freezing the voice agent on a busy session#48
nithiink wants to merge 1 commit into
mainfrom
fix/set-mode-turn-lock-deadlock

Conversation

@nithiink

Copy link
Copy Markdown
Owner

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

  1. set_mode (tmux_runner.py) is the only session-mutating tool awaited synchronously, and it grabbed the session turn lock with a blind async with s._turn_lock:.
  2. advance() holds that same lock for the entire turn, bounded only by ADVANCE_HARD_TIMEOUT_S = 600s. So when a session is actively working, the lock is held for as long as the turn runs.
  3. Nothing upstream has a timeout: execute_tool just await dispatch_tool(...), and the frontend runFunctionCall does a bare await fetch("/api/tools/execute") and only sends Gemini the functionResponse after the fetch resolves.
  4. With no functionResponse, the model stays in the tool-call state and re-issues the same set_mode on 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_advance etc. spawn a background task and return "working" immediately), which is why only set_mode exhibits this.

Fix

Backendset_mode now waits at most SET_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:

  • idle session → lock free → switches immediately
  • parked on a permission prompt (the "allow that and switch to auto" path) → session isn't mid-turn, lock free → switches immediately and still auto-approves

FrontendrunFunctionCall now caps a single tool execution at TOOL_EXEC_TIMEOUT_MS (15s) via an AbortController and returns an error functionResponse on timeout, so no slow backend tool can freeze the voice turn (defense in depth).

Tests

Adds backend/tests/test_set_mode_busy.py:

  • busy session → fails fast with the "busy" message, mode unchanged, lock never stolen
  • idle session → switches normally and releases the lock

Full backend suite passes (42 tests); tsc --noEmit clean on the frontend.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant