Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions backend/tests/test_set_mode_busy.py
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 21 additions & 1 deletion backend/tmux_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion frontend/lib/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 });

Expand Down