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
This commit is contained in:
operator-main
2026-10-04 19:02:17 +00:00
parent 0822960810
commit 8d4bfa7053
+34 -6
View File
@@ -55,7 +55,22 @@ def ev(ws, expr, await_p=False):
"params": {"expression": expr, "returnByValue": True, "params": {"expression": expr, "returnByValue": True,
"awaitPromise": await_p}, "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", {}) res = resp.get("result", {})
if res.get("subtype") == "error": if res.get("subtype") == "error":
raise RuntimeError("JS error: %s" % str(res.get("description"))[:200]) 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); } if (nav.getAttribute('aria-current') !== 'page') { nav.click(); await sleep(3000); }
const panelOpen = () => !!document.querySelector('[data-testid="hatch-chat-compose"]'); const panelOpen = () => !!document.querySelector('[data-testid="hatch-chat-compose"]');
if (!panelOpen()) { 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'; if (!sw) return 'NO_SWITCHER';
sw.click(); await sleep(2500); sw.click(); await sleep(2500);
if (!panelOpen()) return 'PANEL_CLOSED'; if (!panelOpen()) return 'PANEL_CLOSED';
@@ -250,12 +271,19 @@ def main(argv):
agent, op = argv[1], argv[2] agent, op = argv[1], argv[2]
ws = None ws = None
try: try:
ws = connect(agent) # CDP evaluate can resolve null when the page is mid-navigation
# CDP evaluate can rarely resolve null when the page is mid-navigation; # (agent actively using browser). Reconnect fresh on each retry
# one retry covers the flake (observed 2026-10-04). # 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 data, last_err = None, None
for attempt in range(2): for attempt in range(3):
try: try:
if ws is not None:
try:
ws.close()
except Exception:
pass
ws = connect(agent)
if op == "threads": if op == "threads":
data = op_threads(ws) data = op_threads(ws)
ok = isinstance(data, dict) and isinstance( ok = isinstance(data, dict) and isinstance(