[eric] browser: ride out a momentary dashboard WS drop instead of failing the run under load

This commit is contained in:
ciregenz
2026-06-07 17:37:19 -07:00
parent 39f35a1ef0
commit a588645a96
3 changed files with 36 additions and 10 deletions
+6 -4
View File
@@ -76,7 +76,7 @@ from backend.apps.agents.browser.browser_schema import (
SYSTEM_PROMPT,
)
from backend.apps.agents.core.models import AgentSession, ApprovalRequest, Message
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.core.ws_manager import ws_manager, _await_reconnect
from backend.apps.tools_lib.tools_lib import load_builtin_permissions
logger = logging.getLogger(__name__)
@@ -2032,9 +2032,11 @@ async def run_browser_agents(
# No dashboard renderer means every browser command is dead on arrival;
# failing here saves the 2-5 LLM turns a sub burns narrating timeouts at a
# corpse before card-gone detection trips.
if not ws_manager.global_connections:
logger.warning("[browser-agent] dispatch refused: no dashboard connected")
# corpse before card-gone detection trips. But a CPU-starved renderer can
# briefly drop its WS then auto-reconnect, so wait (capped) for it to come
# back before refusing, turning a load blip into a pause, not a failed run.
if not ws_manager.global_connections and not await _await_reconnect(lambda: bool(ws_manager.global_connections)):
logger.warning("[browser-agent] dispatch refused: no dashboard after reconnect wait")
return [{
"summary": (
"Error: no dashboard window is connected, so browser tools cannot run. "
+23 -3
View File
@@ -12,13 +12,33 @@ logger = logging.getLogger(__name__)
# operate on an already-loaded page and should be quick; navigation legitimately
# loads the network so it gets a longer leash. Was a flat 30s, which let one
# wedged page spin for ~20 minutes across retries.
_BROWSER_CMD_TIMEOUT_DEFAULT = 12.0
_BROWSER_CMD_TIMEOUT_DEFAULT = 15.0 # modest load headroom; still "short" so a wedged tab fails fast
_BROWSER_CMD_TIMEOUTS = {
"navigate": 20.0, # a real page load can be slow
"navigate": 25.0, # a real page load can be slow (more leash under load)
"replay_route": 20.0, # an API fetch can be slow
"wait": 12.0, # smart-wait already caps itself well under this
}
_BROWSER_CMD_REBROADCAST_S = 3.0
# A CPU-starved renderer can briefly drop its WS (a missed heartbeat) and the
# frontend auto-reconnects a beat later; bridge that gap instead of hard-failing
# a live run into it. Short enough that a genuinely-closed window still fails
# quickly (and no LLM turns are ever burned waiting); long enough to ride out a
# reconnect even on a loaded machine.
_WS_RECONNECT_WAIT_S = 8.0
async def _await_reconnect(has_conn) -> bool:
"""Poll up to _WS_RECONNECT_WAIT_S for a dashboard socket to (re)appear.
`has_conn` is a 0-arg callable returning truthy when connected."""
if has_conn():
return True
waited = 0.0
while waited < _WS_RECONNECT_WAIT_S:
await asyncio.sleep(0.5)
waited += 0.5
if has_conn():
return True
return bool(has_conn())
class ConnectionManager:
@@ -240,7 +260,7 @@ class ConnectionManager:
self, request_id: str, action: str, browser_id: str, params: dict, tab_id: str = ""
) -> dict:
"""Send a browser command to the frontend and wait for the result."""
if not self.global_connections:
if not self.global_connections and not await _await_reconnect(lambda: bool(self.global_connections)):
return {"error": "No dashboard is connected. Open the dashboard to use browser tools."}
loop = asyncio.get_event_loop()
+7 -3
View File
@@ -94,12 +94,16 @@ def test_text_normalizes_to_message_without_phone_number():
assert "message r10-os" in _normalize_for_classifier(count)
def test_dispatch_refused_instantly_when_no_dashboard_connected():
def test_dispatch_refused_when_no_dashboard_connected(monkeypatch):
import asyncio
from backend.apps.agents.browser.browser_agent import run_browser_agents
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.core import ws_manager as wsm
assert not ws_manager.global_connections
# Dispatch now waits briefly for a momentary WS drop to reconnect; with a
# genuinely-closed window that wait just elapses and it still refuses without
# dispatching an agent or burning a turn. Zero the wait so the test is instant.
monkeypatch.setattr(wsm, "_WS_RECONNECT_WAIT_S", 0.0)
assert not wsm.ws_manager.global_connections
results = asyncio.run(run_browser_agents(tasks=[{"task": "go to example.com"}], model="sonnet"))
assert len(results) == 1
assert results[0]["summary"].startswith("Error: no dashboard window is connected")