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;