From c713c6efc20d2e3b62b35e14304bcbc15252607b Mon Sep 17 00:00:00 2001 From: carlotestor Date: Wed, 23 Sep 2026 18:29:11 +0000 Subject: [PATCH 1/5] fix(console): stop git-info 404s, profile-mismatch 409 poll loops and the wasted pwa-startup preload - /api/git-info falls back to state.db metadata for sessions without a WebUI sidecar (CLI, delegated subagents), mirroring /api/list, instead of 404ing. - Approval and clarify fallback pollers stop on session_profile_mismatch 409 instead of re-requesting every 1.5s/3s; live cards stay standing. - Drop for pwa-startup.js: the preload scanner resolves it before the dynamic is written, so on /session/ it fetches /session/static/... while the script loads /static/..., a wasted duplicate request plus a 'preloaded but not used' warning on every load. --- api/routes.py | 15 +- static/index.html | 1 - static/messages.js | 14 +- ...test_console_noise_git_info_and_pollers.py | 211 ++++++++++++++++++ tests/test_pwa_manifest_sw.py | 4 +- 5 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 tests/test_console_noise_git_info_and_pollers.py diff --git a/api/routes.py b/api/routes.py index 06539bc3..a0af1861 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14572,13 +14572,22 @@ def handle_get(handler, parsed) -> bool: if not sid: return bad(handler, "session_id required") try: - s = get_session(sid) + workspace = get_session(sid).workspace except KeyError: - return bad(handler, "Session not found", 404) + # state.db-only sessions (CLI, delegated subagents): same fallback as /api/list. + cli_meta = _lookup_cli_session_metadata(sid) + if not cli_meta: + return bad(handler, "Session not found", 404) + if not cli_meta.get("workspace"): + return j(handler, {"git": None}) + try: + workspace = resolve_trusted_workspace(cli_meta["workspace"]) + except (FileNotFoundError, ValueError): + return j(handler, {"git": None}) from api.workspace_git import GitWorkspaceError, git_status try: - status = git_status(Path(s.workspace)) + status = git_status(Path(workspace)) except GitWorkspaceError as e: return _git_bad(handler, e) totals = status.get("totals") or {} diff --git a/static/index.html b/static/index.html index b95528e8..cd122687 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,6 @@ - diff --git a/static/messages.js b/static/messages.js index dbd13588..7760b0ac 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8060,7 +8060,13 @@ function _startApprovalFallbackPoll(sid) { stopApprovalPollingForSession(sid); } } - } catch(e) { /* ignore poll errors */ } + } catch(e) { + // Another profile owns this session now (e.g. a different tab switched the shared + // profile cookie): every further poll would 409, so stop and leave the card as-is. + if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)) { + stopApprovalPollingForSession(sid); + } + } finally { _approvalFallbackPollInFlight = false; } }; _approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness @@ -9204,6 +9210,12 @@ function _startClarifyFallbackPoll(sid) { currentSessionId: currentSid, message: msg, }; + // Profile-mismatch 409: the session is owned by another profile under the current + // cookie, so polling can never succeed. Stop quietly; the live card stays standing. + if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) { + stopClarifyPollingForSession(sid); + return; + } // A 404 from the active session domain is a STALE-SESSION signal — e.g. // the old profile's session still polling briefly after a profile switch, // or a session deleted server-side — NOT a missing clarify endpoint. Stop diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py new file mode 100644 index 00000000..aa8e7034 --- /dev/null +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -0,0 +1,211 @@ +"""Console-noise regressions: git-info 404 on state.db-only sessions, pollers +that keep 409ing after a profile-cookie flip, and the unused pwa-startup preload.""" +import io +import json +import shutil +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +import api.routes as routes + +REPO = Path(__file__).resolve().parent.parent +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8") + + +class _Handler: + def __init__(self): + self.wfile = io.BytesIO() + self.headers = {} + self.status = None + + def send_response(self, status): + self.status = status + + def send_header(self, *_a): + pass + + def end_headers(self): + pass + + def payload(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def _git_info(sid): + h = _Handler() + routes.handle_get(h, urlparse(f"/api/git-info?session_id={sid}")) + return h.status, h.payload() + + +@pytest.fixture +def git_repo(tmp_path): + if not shutil.which("git"): + pytest.skip("git not installed") + run = lambda *a: subprocess.run(["git", "-C", str(tmp_path), *a], check=True, capture_output=True) + run("init", "-q", "-b", "main") + (tmp_path / "a.txt").write_text("a") + run("add", "a.txt") + run("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init") + (tmp_path / "b.txt").write_text("b") + return tmp_path + + +def _state_db_only(monkeypatch, sid, row): + def _missing(*_a, **_kw): + raise KeyError(sid) + + monkeypatch.setattr(routes, "get_session", _missing) + monkeypatch.setattr(routes, "get_cli_sessions", lambda *_a, **_kw: [row] if row else []) + + +def test_git_info_serves_state_db_only_session(monkeypatch, git_repo): + sid = "20260923_175914_e47592" + _state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": str(git_repo)}) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda p, *_a, **_kw: Path(p)) + + status, body = _git_info(sid) + + assert status == 200, body + assert body["git"]["is_git"] is True + assert body["git"]["branch"] == "main" + assert body["git"]["untracked"] == 1 + + +def test_git_info_untrusted_state_db_workspace_is_not_inspected(monkeypatch, git_repo): + sid = "20260923_175914_e9d4bd" + _state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": str(git_repo)}) + + def _untrusted(*_a, **_kw): + raise ValueError("outside trusted roots") + + monkeypatch.setattr(routes, "resolve_trusted_workspace", _untrusted) + assert _git_info(sid) == (200, {"git": None}) + + +def test_git_info_state_db_session_without_workspace_has_no_badge(monkeypatch): + sid = "20260923_000000_abcdef" + _state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": ""}) + monkeypatch.setattr( + routes, "resolve_trusted_workspace", + lambda *_a, **_kw: pytest.fail("must not fall back to the default workspace"), + ) + assert _git_info(sid) == (200, {"git": None}) + + +def test_git_info_unknown_session_still_404s(monkeypatch): + _state_db_only(monkeypatch, "nope", None) + status, body = _git_info("nope") + assert status == 404 and body["error"] == "Session not found" + + +# ── frontend pollers, driven through node with the real functions ───────────── + +def _extract_fn(src, name): + start = src.index(f"function {name}(") + brace = src.index("{", src.index(")", start)) + depth, i = 1, brace + 1 + while depth: + depth += {"{": 1, "}": -1}.get(src[i], 0) + i += 1 + return src[start:i] + + +_HARNESS = r""" +var S = {session: {session_id: 'sid1'}, busy: false}; +var calls = {api: 0, warn: 0, hide: 0}; +var _approvalPollTimer = null, _approvalEventSource = null, _approvalSSEHealthTimer = null; +var _approvalFallbackPollInFlight = false, _approvalPollingSessionId = 'sid1'; +var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTimer = null; +var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false; +var _approvalPendingBySession = new Map(); +function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; } +function _hideApprovalCardIfOwner(){ calls.hide++; } +function _hideClarifyCardIfOwner(){ calls.hide++; } +function _clearApprovalPendingForSession(){} +function _clearClarifyPendingForSession(){} +function showApprovalForSession(){} +function showClarifyForSession(){} +function setComposerStatus(){} +function showToast(){} +console.warn = function(){ calls.warn++; }; +var timers = []; +setInterval = function(fn){ timers.push(fn); return timers.length; }; +clearInterval = function(){}; +async function api(){ + calls.api++; + var e = new Error('Session belongs to a different profile'); + e.status = STATUS; + e.body = JSON.stringify(BODY); + throw e; +} +""" + + +def _run_poller(start_fn, status, body): + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + fns = [ + _extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"), + _extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"), + _extract_fn(MESSAGES_JS, "stopApprovalPolling"), + _extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"), + _extract_fn(MESSAGES_JS, "stopClarifyPolling"), + ] + script = ( + _HARNESS.replace("STATUS", str(status)).replace("BODY", json.dumps(body)) + + "\n".join(fns) + + f""" +(async () => {{ + {start_fn}('sid1'); + await new Promise(r => setTimeout(r, 0)); + const pollingAfterFirst = {'_approvalPollingSessionId' if 'Approval' in start_fn else '_clarifyPollingSessionId'}; + process.stdout.write(JSON.stringify({{calls, pollingAfterFirst}})); +}})(); +""" + ) + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +_MISMATCH = {"error": "Session belongs to a different profile", "code": "session_profile_mismatch", + "session_id": "sid1", "profile": "deepseek"} + + +def test_approval_poll_stops_on_profile_mismatch_409(): + r = _run_poller("_startApprovalFallbackPoll", 409, _MISMATCH) + assert r["calls"]["api"] == 1 + assert r["pollingAfterFirst"] is None, "approval poller must stop after a session_profile_mismatch 409" + assert r["calls"]["hide"] == 0, "a live approval card must stay standing" + + +def test_approval_poll_keeps_running_on_other_errors(): + r = _run_poller("_startApprovalFallbackPoll", 500, {"error": "boom"}) + assert r["pollingAfterFirst"] == "sid1" + + +def test_clarify_poll_stops_quietly_on_profile_mismatch_409(): + r = _run_poller("_startClarifyFallbackPoll", 409, _MISMATCH) + assert r["calls"]["api"] == 1 + assert r["pollingAfterFirst"] is None, "clarify poller must stop after a session_profile_mismatch 409" + assert r["calls"]["warn"] == 0, "expected profile mismatch must not log '[clarify] pending poll failed'" + assert r["calls"]["hide"] == 0, "a live clarify card must stay standing" + + +def test_clarify_poll_still_warns_on_unexpected_errors(): + r = _run_poller("_startClarifyFallbackPoll", 500, {"error": "boom"}) + assert r["calls"]["warn"] == 1 + assert r["pollingAfterFirst"] == "sid1" + + +def test_pwa_startup_is_not_preloaded_next_to_its_own_blocking_script(): + assert '' in INDEX_HTML + assert 'rel="preload" href="static/pwa-startup.js' not in INDEX_HTML diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 0f537d2b..0eb9b0db 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -293,13 +293,11 @@ class TestIndexHtmlIntegration: are present on first paint. """ src = INDEX.read_text(encoding="utf-8") - preload_pos = src.find('href="static/pwa-startup.js?v=__WEBUI_VERSION__"') script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"') ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__') - assert preload_pos != -1, "index.html must preload the PWA startup helper" assert script_pos != -1, "index.html must load the PWA startup helper" assert ui_pos != -1, "index.html must load the main UI bundle" - assert preload_pos < ui_pos and script_pos < ui_pos, ( + assert script_pos < ui_pos, ( "pwa-startup.js must run before ui.js so standalone/offline classes " "are available before the app shell paints" ) From fbb0843acf13a4cf520231eaf3e93da0a6d2772c Mon Sep 17 00:00:00 2001 From: carlotestor Date: Wed, 23 Sep 2026 19:11:25 +0000 Subject: [PATCH 2/5] fix(console): stale profile-mismatch response must not stop the replacement poller Each approval/clarify fallback poller captures its own interval handle; a 409 (or finally) from a request issued by a replaced poller no longer stops or clears the in-flight guard of the poller that superseded it. --- static/messages.js | 17 ++++--- ...test_console_noise_git_info_and_pollers.py | 47 +++++++++++++++++++ tests/test_performance_polling_hardening.py | 4 +- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/static/messages.js b/static/messages.js index 7760b0ac..bee24ef8 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8063,13 +8063,15 @@ function _startApprovalFallbackPoll(sid) { } catch(e) { // Another profile owns this session now (e.g. a different tab switched the shared // profile cookie): every further poll would 409, so stop and leave the card as-is. - if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)) { - stopApprovalPollingForSession(sid); + // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. + if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e) + && _approvalPollTimer === pollTimer) { + stopApprovalPolling(); } } - finally { _approvalFallbackPollInFlight = false; } + finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; } }; - _approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness + const pollTimer = _approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness _tick(); } @@ -9212,8 +9214,9 @@ function _startClarifyFallbackPoll(sid) { }; // Profile-mismatch 409: the session is owned by another profile under the current // cookie, so polling can never succeed. Stop quietly; the live card stays standing. + // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) { - stopClarifyPollingForSession(sid); + if (_clarifyFallbackTimer === pollTimer) stopClarifyPolling(); return; } // A 404 from the active session domain is a STALE-SESSION signal — e.g. @@ -9264,10 +9267,10 @@ function _startClarifyFallbackPoll(sid) { console.warn("[clarify] pending poll failed", logDetails); } } finally { - _clarifyFallbackPollInFlight = false; + if (_clarifyFallbackTimer === pollTimer) _clarifyFallbackPollInFlight = false; } }; - _clarifyFallbackTimer = setInterval(_tick, 3000); + const pollTimer = _clarifyFallbackTimer = setInterval(_tick, 3000); _tick(); } diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py index aa8e7034..cb36a92f 100644 --- a/tests/test_console_noise_git_info_and_pollers.py +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -209,3 +209,50 @@ def test_clarify_poll_still_warns_on_unexpected_errors(): def test_pwa_startup_is_not_preloaded_next_to_its_own_blocking_script(): assert '' in INDEX_HTML assert 'rel="preload" href="static/pwa-startup.js' not in INDEX_HTML + + +def _run_stale_replacement(start_fn, stop_fn, polling_var): + """Poller A's request is in flight; a same-session refresh replaces it with + poller B; then A's request fails with a profile-mismatch 409.""" + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + fns = [ + _extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"), + _extract_fn(MESSAGES_JS, start_fn), + _extract_fn(MESSAGES_JS, stop_fn), + ] + harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH)) + harness = harness.replace("async function api(){", "var pending = [];\nasync function api(){\n if (pending) return new Promise((_, rej) => pending.push(rej));") + script = harness + "\n".join(fns) + f""" +(async () => {{ + {start_fn}('sid1'); // poller A: first request hangs + {stop_fn}(); // same-session refresh + {polling_var} = 'sid1'; // startApprovalPolling() sets this before the fallback poll + {start_fn}('sid1'); // poller B: its request hangs too + const timerB = {"_approvalPollTimer" if "Approval" in start_fn else "_clarifyFallbackTimer"}; + const e = new Error('Session belongs to a different profile'); + e.status = 409; e.body = JSON.stringify({json.dumps(_MISMATCH)}); + pending[0](e); // late 409 for poller A + await new Promise(r => setTimeout(r, 0)); + process.stdout.write(JSON.stringify({{ + polling: {polling_var}, + timerAlive: {"_approvalPollTimer" if "Approval" in start_fn else "_clarifyFallbackTimer"} === timerB, + inFlight: {"_approvalFallbackPollInFlight" if "Approval" in start_fn else "_clarifyFallbackPollInFlight"}, + }})); +}})(); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +@pytest.mark.parametrize("start_fn,stop_fn,polling_var", [ + ("_startApprovalFallbackPoll", "stopApprovalPolling", "_approvalPollingSessionId"), + ("_startClarifyFallbackPoll", "stopClarifyPolling", "_clarifyPollingSessionId"), +]) +def test_stale_profile_mismatch_does_not_stop_replacement_poller(start_fn, stop_fn, polling_var): + r = _run_stale_replacement(start_fn, stop_fn, polling_var) + assert r["polling"] == "sid1", "a late 409 from a replaced poller must not stop its successor" + assert r["timerAlive"] is True + assert r["inFlight"] is True, "the successor's in-flight guard must not be cleared by the stale request" diff --git a/tests/test_performance_polling_hardening.py b/tests/test_performance_polling_hardening.py index 61851288..051464a5 100644 --- a/tests/test_performance_polling_hardening.py +++ b/tests/test_performance_polling_hardening.py @@ -34,11 +34,11 @@ def test_approval_and_clarify_fallback_polls_do_not_overlap(): assert "let _approvalFallbackPollInFlight = false" in src assert "if (_approvalFallbackPollInFlight) return" in src assert "_approvalFallbackPollInFlight = true" in src - assert "finally { _approvalFallbackPollInFlight = false; }" in src + assert "finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }" in src assert "_approvalFallbackPollInFlight = false;\n _approvalPollingSessionId = null;" in src assert "let _clarifyFallbackPollInFlight = false" in src assert "if (_clarifyFallbackPollInFlight) return" in src assert "_clarifyFallbackPollInFlight = true" in src - assert "finally {\n _clarifyFallbackPollInFlight = false;\n }" in src + assert "finally {\n if (_clarifyFallbackTimer === pollTimer) _clarifyFallbackPollInFlight = false;\n }" in src assert "_clarifyFallbackPollInFlight = false;\n _clarifyPollingSessionId = null;" in src From d6bf9900c6104fb33e61de7191c140e99939cef2 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Wed, 23 Sep 2026 19:28:24 +0000 Subject: [PATCH 3/5] docs: document /api/git-info state.db fallback and poller 409 stop --- ARCHITECTURE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ce017cf..494cd93e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1411,7 +1411,11 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). /api/chat/stream ?stream_id=X -> SSE stream. Long-lived. Emits token/tool/ approval/done/error events. /api/chat/stream/status ?stream_id=X -> {"active": true/false, "stream_id": X} - /api/approval/pending ?session_id=X -> {"pending": entry_or_null} + /api/approval/pending ?session_id=X -> {"pending": entry_or_null}. The approval/clarify + fallback pollers stop on a 409 session_profile_mismatch. + /api/git-info ?session_id=X -> {"git": status_or_null}. State.db-only sessions + (CLI, subagents) use their stored workspace if it resolves via + resolve_trusted_workspace; missing/untrusted -> {"git": null}. /api/approval/inject_test ?session_id=X&pattern_key=K&command=C -> test-only endpoint. Injects a pending approval entry into the server process. /api/file/raw ?session_id=X&path=P -> raw file bytes with correct MIME type. From 6ebd12c251bc2fca274ed49f577654557b688319 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Thu, 24 Sep 2026 14:55:27 +0000 Subject: [PATCH 4/5] fix(console): re-arm profile-paused approval/clarify pollers on focus/visibility --- static/messages.js | 25 +++++- ...test_console_noise_git_info_and_pollers.py | 79 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/static/messages.js b/static/messages.js index 3f32f009..ff496d19 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8137,8 +8137,11 @@ function startApprovalPolling(sid) { let _approvalEventSource = null; let _approvalSSEHealthTimer = null; let _approvalPollingSessionId = null; +// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility. +let _approvalProfilePausedSessionId = null; function _startApprovalFallbackPoll(sid) { + _approvalProfilePausedSessionId = null; // Run one tick immediately so a session already blocked on a pending approval // shows its card instantly (the removed SSE 'initial' event used to do this); // then poll on the 1500ms cadence. (#3913 SHOULD-FIX) @@ -8170,6 +8173,7 @@ function _startApprovalFallbackPoll(sid) { if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e) && _approvalPollTimer === pollTimer) { stopApprovalPolling(); + _approvalProfilePausedSessionId = sid; } } finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; } @@ -9300,6 +9304,7 @@ var _clarifyFallbackTimer = null; var _clarifyHealthTimer = null; let _clarifyFallbackPollInFlight = false; let _clarifyPollingSessionId = null; +let _clarifyProfilePausedSessionId = null; function startClarifyPolling(sid) { stopClarifyPolling(); @@ -9322,6 +9327,7 @@ function startClarifyPolling(sid) { function _startClarifyFallbackPoll(sid) { _clarifyPollingSessionId = sid || null; + _clarifyProfilePausedSessionId = null; // Run one tick immediately so a session already blocked on a pending clarify // shows its card instantly (the removed SSE 'initial' event used to do this); // then poll on the 3000ms cadence. (#3913 SHOULD-FIX) @@ -9356,7 +9362,10 @@ function _startClarifyFallbackPoll(sid) { // cookie, so polling can never succeed. Stop quietly; the live card stays standing. // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) { - if (_clarifyFallbackTimer === pollTimer) stopClarifyPolling(); + if (_clarifyFallbackTimer === pollTimer) { + stopClarifyPolling(); + _clarifyProfilePausedSessionId = sid; + } return; } // A 404 from the active session domain is a STALE-SESSION signal — e.g. @@ -9427,6 +9436,20 @@ function stopClarifyPolling() { _clarifyPollingSessionId = null; } +// Another tab may have switched the shared profile cookie back: re-arm pollers that a +// profile-mismatch 409 paused while their session is still open. A still-mismatched +// profile just 409s once and pauses them again. +function _resumeProfilePausedPromptPollers() { + if (typeof document !== 'undefined' && document.hidden) return; + const current = (S.session && S.session.session_id) || null; + const approvalSid = _approvalProfilePausedSessionId; + const clarifySid = _clarifyProfilePausedSessionId; + if (approvalSid && approvalSid === current && !_approvalPollTimer) startApprovalPolling(approvalSid); + if (clarifySid && clarifySid === current && !_clarifyFallbackTimer) startClarifyPolling(clarifySid); +} +document.addEventListener('visibilitychange', _resumeProfilePausedPromptPollers); +window.addEventListener('focus', _resumeProfilePausedPromptPollers); + // ── Notifications and Sound ────────────────────────────────────────────────── function _completionNotificationPreviewText(lastAssistantMessage, options){ diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py index cb36a92f..6132a49f 100644 --- a/tests/test_console_noise_git_info_and_pollers.py +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -123,6 +123,9 @@ var _approvalFallbackPollInFlight = false, _approvalPollingSessionId = 'sid1'; var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTimer = null; var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false; var _approvalPendingBySession = new Map(); +var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null; +function _approvalPromptGeneration(){ return 0; } +function _clarifyPromptGeneration(){ return 0; } function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; } function _hideApprovalCardIfOwner(){ calls.hide++; } function _hideClarifyCardIfOwner(){ calls.hide++; } @@ -256,3 +259,79 @@ def test_stale_profile_mismatch_does_not_stop_replacement_poller(start_fn, stop_ assert r["polling"] == "sid1", "a late 409 from a replaced poller must not stop its successor" assert r["timerAlive"] is True assert r["inFlight"] is True, "the successor's in-flight guard must not be cleared by the stale request" + + +def _run_profile_round_trip(kind): + """Mismatch 409 pauses the poller; the cookie comes back, focus returns, and + the pending prompt for the still-open session must be fetched and shown.""" + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + approval = kind == "approval" + fns = [ + _extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"), + _extract_fn(MESSAGES_JS, "startApprovalPolling"), + _extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"), + _extract_fn(MESSAGES_JS, "stopApprovalPolling"), + _extract_fn(MESSAGES_JS, "startClarifyPolling"), + _extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"), + _extract_fn(MESSAGES_JS, "stopClarifyPolling"), + _extract_fn(MESSAGES_JS, "_resumeProfilePausedPromptPollers"), + ] + harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH)) + harness = harness.replace("async function api(){", "var mismatch = true;\nasync function api(){\n if (!mismatch) { calls.api++; return {pending: {id: 'p1'}}; }") + harness = harness.replace("function showApprovalForSession(){}", "function showApprovalForSession(){ calls.shown = (calls.shown||0) + 1; }") + harness = harness.replace("function showClarifyForSession(){}", "function showClarifyForSession(){ calls.shown = (calls.shown||0) + 1; }") + start = "startApprovalPolling" if approval else "startClarifyPolling" + timer = "_approvalPollTimer" if approval else "_clarifyFallbackTimer" + script = harness + "\n".join(fns) + f""" +const tick = () => new Promise(r => setTimeout(r, 0)); +(async () => {{ + {start}('sid1'); + await tick(); + const stoppedOnMismatch = {timer} === null; + _resumeProfilePausedPromptPollers(); // focus while still mismatched + await tick(); + const stoppedAgain = {timer} === null; + const apiWhileMismatched = calls.api; + mismatch = false; // other tab switched the cookie back + _resumeProfilePausedPromptPollers(); // focus/visibility returns + await tick(); + process.stdout.write(JSON.stringify({{stoppedOnMismatch, stoppedAgain, apiWhileMismatched, + api: calls.api, shown: calls.shown || 0, running: {timer} !== null}})); +}})(); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +@pytest.mark.parametrize("kind", ["approval", "clarify"]) +def test_profile_round_trip_rearms_paused_poller_and_shows_pending(kind): + r = _run_profile_round_trip(kind) + assert r["stoppedOnMismatch"] is True + assert r["stoppedAgain"] is True, "a still-mismatched profile must stop the re-armed poller again" + assert r["apiWhileMismatched"] == 2 + assert r["api"] == 3, "the poller must fetch again once the profile is back" + assert r["shown"] == 1, "the pending card must be shown after the profile round-trip" + assert r["running"] is True + + +def test_resume_does_not_rearm_for_a_different_open_session(): + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + script = _HARNESS.replace("STATUS", "409").replace("BODY", "{}") + _extract_fn( + MESSAGES_JS, "_resumeProfilePausedPromptPollers") + """ +var started = 0; +function startApprovalPolling(){ started++; } +function startClarifyPolling(){ started++; } +_approvalProfilePausedSessionId = 'old'; _clarifyProfilePausedSessionId = 'old'; +_resumeProfilePausedPromptPollers(); +process.stdout.write(String(started)); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + assert out.stdout == "0" From 075f95aaf2b470958f4ca39f11d181043c912a8d Mon Sep 17 00:00:00 2001 From: carlotestor Date: Thu, 24 Sep 2026 16:53:27 +0000 Subject: [PATCH 5/5] fix(console): retry a late profile-mismatch 409 once when focus returned mid-request --- static/messages.js | 15 +++-- ...test_console_noise_git_info_and_pollers.py | 66 +++++++++++++++++++ tests/test_prompt_notification_lifecycle.py | 1 + 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/static/messages.js b/static/messages.js index ff496d19..daa2fdd1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8139,6 +8139,9 @@ let _approvalSSEHealthTimer = null; let _approvalPollingSessionId = null; // Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility. let _approvalProfilePausedSessionId = null; +// Bumped on every focus/visibility return. A mismatch 409 for a request that began before +// the latest return may predate a cookie switch-back, so it earns one retry before pausing. +let _promptPollerFocusEpoch = 0; function _startApprovalFallbackPoll(sid) { _approvalProfilePausedSessionId = null; @@ -8151,6 +8154,7 @@ function _startApprovalFallbackPoll(sid) { } if (_approvalFallbackPollInFlight) return; _approvalFallbackPollInFlight = true; + const focusEpoch = _promptPollerFocusEpoch; try { const generation = _approvalPromptGeneration(sid); const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false}); @@ -8172,8 +8176,9 @@ function _startApprovalFallbackPoll(sid) { // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e) && _approvalPollTimer === pollTimer) { - stopApprovalPolling(); - _approvalProfilePausedSessionId = sid; + // Focus returned mid-request: the cookie may be back, so retry once (after finally). + if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick); + else { stopApprovalPolling(); _approvalProfilePausedSessionId = sid; } } } finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; } @@ -9337,6 +9342,7 @@ function _startClarifyFallbackPoll(sid) { } if (_clarifyFallbackPollInFlight) return; _clarifyFallbackPollInFlight = true; + const focusEpoch = _promptPollerFocusEpoch; try { const generation = _clarifyPromptGeneration(sid); const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false}); @@ -9363,8 +9369,8 @@ function _startClarifyFallbackPoll(sid) { // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) { if (_clarifyFallbackTimer === pollTimer) { - stopClarifyPolling(); - _clarifyProfilePausedSessionId = sid; + if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick); + else { stopClarifyPolling(); _clarifyProfilePausedSessionId = sid; } } return; } @@ -9441,6 +9447,7 @@ function stopClarifyPolling() { // profile just 409s once and pauses them again. function _resumeProfilePausedPromptPollers() { if (typeof document !== 'undefined' && document.hidden) return; + _promptPollerFocusEpoch++; const current = (S.session && S.session.session_id) || null; const approvalSid = _approvalProfilePausedSessionId; const clarifySid = _clarifyProfilePausedSessionId; diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py index 6132a49f..fa289434 100644 --- a/tests/test_console_noise_git_info_and_pollers.py +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -124,6 +124,7 @@ var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTime var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false; var _approvalPendingBySession = new Map(); var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null; +var _promptPollerFocusEpoch = 0; function _approvalPromptGeneration(){ return 0; } function _clarifyPromptGeneration(){ return 0; } function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; } @@ -335,3 +336,68 @@ process.stdout.write(String(started)); out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) assert out.returncode == 0, out.stderr assert out.stdout == "0" + + +def _run_late_409_after_focus(kind, cookie_back): + """The request starts under the old profile, focus returns while it is in flight, + then the old-profile 409 lands. The poller must retry once under the current + cookie instead of pausing for good; it pauses only if the retry also mismatches.""" + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + approval = kind == "approval" + fns = [ + _extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"), + _extract_fn(MESSAGES_JS, "startApprovalPolling"), + _extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"), + _extract_fn(MESSAGES_JS, "stopApprovalPolling"), + _extract_fn(MESSAGES_JS, "startClarifyPolling"), + _extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"), + _extract_fn(MESSAGES_JS, "stopClarifyPolling"), + _extract_fn(MESSAGES_JS, "_resumeProfilePausedPromptPollers"), + ] + harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH)) + harness = harness.replace("async function api(){", """var mismatch = true, releaseFirst = null; +async function api(){ + if (!releaseFirst) { await new Promise(r => { releaseFirst = r; }); return _mismatchThrow(); } + if (!mismatch) { calls.api++; return {pending: {id: 'p1'}}; } + return _mismatchThrow(); +} +function _mismatchThrow(){""") + harness = harness.replace("function showApprovalForSession(){}", "function showApprovalForSession(){ calls.shown = (calls.shown||0) + 1; }") + harness = harness.replace("function showClarifyForSession(){}", "function showClarifyForSession(){ calls.shown = (calls.shown||0) + 1; }") + start = "startApprovalPolling" if approval else "startClarifyPolling" + timer = "_approvalPollTimer" if approval else "_clarifyFallbackTimer" + script = harness + "\n".join(fns) + f""" +const tick = () => new Promise(r => setTimeout(r, 0)); +(async () => {{ + {start}('sid1'); + await tick(); // first request is in flight under the old cookie + mismatch = {'false' if cookie_back else 'true'}; // other tab switches the cookie (back or not) + _resumeProfilePausedPromptPollers(); // focus returns: nothing paused yet, so nothing to restart + releaseFirst(); // the late old-profile 409 lands + for (let i = 0; i < 5; i++) await tick(); + process.stdout.write(JSON.stringify({{api: calls.api, shown: calls.shown || 0, + running: {timer} !== null}})); +}})(); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +@pytest.mark.parametrize("kind", ["approval", "clarify"]) +def test_late_409_after_focus_retries_once_and_shows_pending(kind): + r = _run_late_409_after_focus(kind, cookie_back=True) + assert r["api"] == 2, "a 409 for a request that predates focus must be retried once" + assert r["shown"] == 1, "the pending prompt must be shown after the retry" + assert r["running"] is True, "polling must keep running after the successful retry" + + +@pytest.mark.parametrize("kind", ["approval", "clarify"]) +def test_late_409_after_focus_pauses_when_retry_still_mismatches(kind): + r = _run_late_409_after_focus(kind, cookie_back=False) + assert r["shown"] == 0 + assert r["running"] is False, "a retry that still mismatches must pause, not loop" diff --git a/tests/test_prompt_notification_lifecycle.py b/tests/test_prompt_notification_lifecycle.py index 137e145e..6257e45a 100644 --- a/tests/test_prompt_notification_lifecycle.py +++ b/tests/test_prompt_notification_lifecycle.py @@ -24,6 +24,7 @@ const S = {busy:true,session:{session_id:'s'}}; const _approvalPromptBelongsToActiveSession=()=>false; let _approvalSessionId = 's', _approvalCurrentId = 'id'; let _approvalFallbackPollInFlight=false, _approvalPollTimer; +let _promptPollerFocusEpoch=0; let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false; const _promptActiveSessionId=()=> 's'; const _approvalPollingSessionMissingOrMismatched=()=>false;