From 963935f0abfdcfc869b558b200ea8e3a2ba26968 Mon Sep 17 00:00:00 2001 From: Nithiin Kathiresan Date: Tue, 16 Jun 2026 16:21:01 +0530 Subject: [PATCH] fix: stop set_mode from freezing the voice agent on a busy session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/test_set_mode_busy.py | 69 +++++++++++++++++++++++++++++ backend/tmux_runner.py | 22 ++++++++- frontend/lib/gemini.ts | 17 ++++++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_set_mode_busy.py diff --git a/backend/tests/test_set_mode_busy.py b/backend/tests/test_set_mode_busy.py new file mode 100644 index 0000000..0cc3228 --- /dev/null +++ b/backend/tests/test_set_mode_busy.py @@ -0,0 +1,69 @@ +"""Regression tests for set_mode not hanging on a busy session. + +A session that's mid-turn holds its _turn_lock for the whole turn (up to +ADVANCE_HARD_TIMEOUT_S). set_mode used to `async with s._turn_lock` blindly, so +switching mode while Claude was working parked the call — and with it the voice +agent — for up to minutes. It now fails fast with the lock busy instead: + + python -m unittest discover -s backend/tests +""" +import asyncio +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from tmux_runner import TmuxClaudeRunner, _TmuxSession # noqa: E402 + + +def _runner_with_session(): + r = TmuxClaudeRunner(default_model="opus") + r.SET_MODE_LOCK_TIMEOUT_S = 0.05 # keep the test fast + s = _TmuxSession("aaaaaaaa-0000-0000-0000-000000000000", "/tmp", "opus") + r._sessions[s.handle] = s + return r, s + + +def _patch_terminal(r, target): + """Stub the tmux/terminal I/O so a free-lock switch runs without a real CLI; + _detect_mode echoes the target so the switch is a deterministic no-op.""" + async def _noop_tmux(*a, **k): + return "" + + async def _alive(_s): + return True + + async def _capture(_s): + return "" + + r._tmux = _noop_tmux + r._alive = _alive + r._capture = _capture + r._detect_mode = lambda _pane: target + r._write_mode = lambda _s: None + + +class SetModeBusy(unittest.IsolatedAsyncioTestCase): + async def test_busy_session_fails_fast_without_changing_mode(self): + r, s = _runner_with_session() + await s._turn_lock.acquire() # simulate an in-progress turn holding it + try: + out = await asyncio.wait_for(r.set_mode(s.handle, "auto"), timeout=1.0) + self.assertIn("busy", out.lower()) + self.assertEqual(s.mode, "default") # unchanged + self.assertTrue(s._turn_lock.locked()) # still ours; set_mode didn't steal it + finally: + s._turn_lock.release() + + async def test_idle_session_switches_and_releases_lock(self): + r, s = _runner_with_session() + _patch_terminal(r, "auto") + out = await r.set_mode(s.handle, "auto") + self.assertEqual(out, "auto") + self.assertEqual(s.mode, "auto") + self.assertFalse(s._turn_lock.locked()) # released on the way out + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tmux_runner.py b/backend/tmux_runner.py index a2b5938..89f4e23 100644 --- a/backend/tmux_runner.py +++ b/backend/tmux_runner.py @@ -531,6 +531,13 @@ def _detect_question_index(self, s: _TmuxSession, pane: str) -> int: # hang the voice agent forever. ADVANCE_HARD_TIMEOUT_S = float(os.getenv("VC_ADVANCE_TIMEOUT_S", "600")) + # How long set_mode will wait for the turn lock before giving up. A session + # that's mid-turn holds that lock for the whole turn (up to + # ADVANCE_HARD_TIMEOUT_S), and it can't change mode mid-turn anyway, so we + # fail fast rather than hang the voice agent. Idle/parked sessions release + # the lock immediately, so the common case is unaffected. + SET_MODE_LOCK_TIMEOUT_S = float(os.getenv("VC_SET_MODE_LOCK_TIMEOUT_S", "2")) + async def advance(self, handle: str, message: str) -> AdvanceResult: s = self._get(handle) async with s._turn_lock: @@ -968,7 +975,18 @@ async def set_mode(self, handle: str, mode: str) -> str: "a plan-approval dialog is open — answer it instead " "(e.g. 'auto mode' to approve the plan and auto-apply, or " "'manually approve edits')") - async with s._turn_lock: # don't toggle mid-turn + # Cycling Shift+Tab must not interleave with a turn's keystrokes, hence + # the turn lock. But don't block on it: a busy session holds the lock for + # the whole turn, so a blind `async with` would freeze the voice agent + # for minutes. Fail fast instead — a running session can't switch mode + # mid-turn, and has no pending prompt to auto-approve. + try: + await asyncio.wait_for( + s._turn_lock.acquire(), timeout=self.SET_MODE_LOCK_TIMEOUT_S) + except asyncio.TimeoutError: + return (f"Session is busy working — mode unchanged (still {s.mode}). " + "Claude's mode can't switch mid-turn; try again once it's idle.") + try: if not await self._alive(s): raise ValueError("session is not running") current = self._detect_mode(await self._capture(s)) @@ -979,6 +997,8 @@ async def set_mode(self, handle: str, mode: str) -> str: await asyncio.sleep(0.2) s.mode = self._detect_mode(await self._capture(s)) self._write_mode(s) # keep the hook's view of the mode in sync + finally: + s._turn_lock.release() # The mode file only affects FUTURE tool calls — a PreToolUse hook # already parked on a permission prompt keeps waiting for its decision # file. If the new mode would have auto-approved that tool, approve it diff --git a/frontend/lib/gemini.ts b/frontend/lib/gemini.ts index f9e5f84..c8e2f47 100644 --- a/frontend/lib/gemini.ts +++ b/frontend/lib/gemini.ts @@ -35,6 +35,12 @@ const OUTPUT_RATE = 24000; // silence so the server doesn't treat the stream as idle and drop the session. const KEEPALIVE_MS = 10000; +// Upper bound on a single tool call. The model can't speak again until it gets +// a functionResponse, so a backend tool that stalls (e.g. set_mode parked on a +// busy session's turn lock) would otherwise freeze the voice agent. On timeout +// we abort and return an error response so the model unblocks and can narrate. +const TOOL_EXEC_TIMEOUT_MS = 15000; + // AudioWorklet that converts mic Float32 frames to 16-bit PCM and reports RMS // (used to drive the "hearing" orb state). Loaded via a Blob URL so we don't // ship a separate static asset. @@ -522,18 +528,27 @@ export class GeminiSession implements VoiceSession { let result: unknown; let ok = true; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), TOOL_EXEC_TIMEOUT_MS); try { const r = await fetch("/api/tools/execute", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ name: fc.name, arguments: args }), + signal: ctl.signal, }); const out = await r.json(); result = out.result ?? out; ok = !!out.ok; } catch (e: any) { ok = false; - result = { error: e?.message || String(e) }; + result = { + error: ctl.signal.aborted + ? `${fc.name} timed out — it may still be running on the server` + : e?.message || String(e), + }; + } finally { + clearTimeout(timer); } emit({ type: "tool_call", name: fc.name, arguments: args, result, ok });