From 8d4bfa705352d7fb0767a4ef628285f7ca917094 Mon Sep 17 00:00:00 2001 From: operator-main Date: Sun, 4 Oct 2026 19:02:17 +0000 Subject: [PATCH] Fix transient NO_SWITCHER / CDP race in box-chat-cdp.py Three fixes for flaky main-chat reads (pip/opm): 1. ENSURE: retry chat-switcher lookup 4x with 2s waits instead of immediate NO_SWITCHER (React may still be rendering). 2. ev(): drain CDP events until matching command id arrives; previously the first recv() could grab a browser event instead of our evaluate response, returning None. 3. main(): reconnect fresh websocket on each retry (3 attempts); reusing a stale ws after page navigation gave dead JS contexts. Verified: 7/8 reads succeed across all 4 agents; the 1 failure was THREAD_NOT_FOUND while opm was actively in a side chat, self-healed on next attempt. Session: sidechat/chromebox-ops --- bin/box-chat-cdp.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/bin/box-chat-cdp.py b/bin/box-chat-cdp.py index 16cdd18..122d2ad 100755 --- a/bin/box-chat-cdp.py +++ b/bin/box-chat-cdp.py @@ -55,7 +55,22 @@ def ev(ws, expr, await_p=False): "params": {"expression": expr, "returnByValue": True, "awaitPromise": await_p}, })) - resp = json.loads(ws.recv()) + # Drain CDP events until we get our command response (id 1). + # The browser can emit events (Runtime.executionContextCreated, etc.) + # at any time; taking the first recv() blindly returns None on a + # busy page (observed as transient NO_SWITCHER / "unexpected + # messages payload" on pip/opm 2026-10-04). + resp = None + drained = 0 + for _ in range(50): + raw = ws.recv() + resp = json.loads(raw) + if resp.get("id") == 1: + break + drained += 1 + else: + raise RuntimeError("CDP: no response to Runtime.evaluate (drained %d)" % drained) + pass # drained count available in `drained` if needed res = resp.get("result", {}) if res.get("subtype") == "error": raise RuntimeError("JS error: %s" % str(res.get("description"))[:200]) @@ -79,7 +94,13 @@ ENSURE = """(async () => { if (nav.getAttribute('aria-current') !== 'page') { nav.click(); await sleep(3000); } const panelOpen = () => !!document.querySelector('[data-testid="hatch-chat-compose"]'); if (!panelOpen()) { - const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); + // Retry: the switcher may not be rendered yet if the SPA is still + // settling (observed transient NO_SWITCHER on pip/opm 2026-10-04). + let sw = null; + for (let k = 0; k < 4 && !sw; k++) { + sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); + if (!sw) await sleep(2000); + } if (!sw) return 'NO_SWITCHER'; sw.click(); await sleep(2500); if (!panelOpen()) return 'PANEL_CLOSED'; @@ -250,12 +271,19 @@ def main(argv): agent, op = argv[1], argv[2] ws = None try: - ws = connect(agent) - # CDP evaluate can rarely resolve null when the page is mid-navigation; - # one retry covers the flake (observed 2026-10-04). + # CDP evaluate can resolve null when the page is mid-navigation + # (agent actively using browser). Reconnect fresh on each retry + # so we attach to the current page, not a stale JS context. + # Observed 2026-10-04: opm's browser navigates during reads. data, last_err = None, None - for attempt in range(2): + for attempt in range(3): try: + if ws is not None: + try: + ws.close() + except Exception: + pass + ws = connect(agent) if op == "threads": data = op_threads(ws) ok = isinstance(data, dict) and isinstance(