From 1a64fc68ddb0d9366aca6c0758c447dd883041d8 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:50:03 -0500 Subject: [PATCH 01/10] fix(cron): scope unread badges to active profile --- api/routes.py | 13 ++- static/panels.js | 9 ++ ...est_issue5960_cron_unread_profile_scope.py | 96 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue5960_cron_unread_profile_scope.py diff --git a/api/routes.py b/api/routes.py index 999b8caa..868217ed 100644 --- a/api/routes.py +++ b/api/routes.py @@ -19902,8 +19902,19 @@ def _handle_cron_recent(handler, parsed): since = 0.0 try: from cron.jobs import list_jobs + from api.profiles import ( + cron_profile_context_for_home, + get_active_profile_name, + get_hermes_home_for_profile, + ) - jobs = list_jobs(include_disabled=True) + active_profile = get_active_profile_name() or "default" + active_home = get_hermes_home_for_profile(active_profile) + # Match the default Tasks list scope explicitly. The recent-completion + # endpoint feeds an actionable unread badge, so returning jobs from a + # different profile can strand an ID that has no visible row to open. + with cron_profile_context_for_home(active_home): + jobs = list_jobs(include_disabled=True) completions = [] for job in jobs: job_id = str(job.get("id", "") or "") diff --git a/static/panels.js b/static/panels.js index 2477863e..99453376 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7004,6 +7004,9 @@ async function switchToProfile(name) { if (_switchGen !== _profileSwitchGeneration) return false; S.activeProfile = data.active || name; S.activeProfileIsDefault = !!data.is_default; + if (typeof _resetCronUnreadForProfileSwitch === 'function') { + _resetCronUnreadForProfileSwitch(); + } const targetActiveProfile = S.activeProfile || 'default'; let sessionProfileMatchesTarget = true; if (!sessionInProgress && S.session) { @@ -12617,6 +12620,12 @@ let _cronPollTimer=null; let _cronUnreadCount=0; const _cronNewJobIds=new Set(); // track which job IDs had new completions (unread) +function _resetCronUnreadForProfileSwitch(){ + _cronNewJobIds.clear(); + _cronPollSince=Date.now()/1000; + updateCronBadge(); +} + // Auto-refresh the cron list when a job is created from chat or any external source. // The chat path dispatches this event when the agent response mentions cron creation. window.addEventListener('hermes:cron_created', () => { diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py new file mode 100644 index 00000000..054a30c4 --- /dev/null +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -0,0 +1,96 @@ +"""Regression coverage for cross-profile cron unread badges (#5960).""" + +from __future__ import annotations + +import io +import json +import sys +import types +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + + +class _JSONHandler: + def __init__(self): + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def test_recent_completions_are_read_from_the_active_profile_home(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + entered_homes = [] + + @contextmanager + def profile_context(home): + entered_homes.append(home) + yield + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + + def list_jobs(include_disabled=True): + assert entered_homes == ["/profiles/vops"] + return [ + { + "id": "vops-job", + "name": "Vops job", + "last_run_at": 20, + "last_status": "success", + } + ] + + cron_jobs.list_jobs = list_jobs + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "vops") + monkeypatch.setattr( + profiles, "get_hermes_home_for_profile", lambda name: f"/profiles/{name}" + ) + monkeypatch.setattr(profiles, "cron_profile_context_for_home", profile_context) + monkeypatch.setattr(routes, "_latest_cron_session_info_for_jobs", lambda *args: {}) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=10")) + + assert handler.status == 200 + assert entered_homes == ["/profiles/vops"] + assert [row["job_id"] for row in _payload(handler)["completions"]] == ["vops-job"] + + +def test_successful_profile_switch_resets_unread_cron_state(): + switch_start = PANELS_JS.index("async function switchToProfile(name) {") + switch_end = PANELS_JS.index("// ── Cron completion alerts", switch_start) + switch_body = PANELS_JS[switch_start:switch_end] + + state_update = switch_body.index("S.activeProfile = data.active || name;") + reset_call = switch_body.index("_resetCronUnreadForProfileSwitch();") + assert reset_call > state_update + + reset_start = PANELS_JS.index("function _resetCronUnreadForProfileSwitch(){") + reset_end = PANELS_JS.index("\n}", reset_start) + reset_body = PANELS_JS[reset_start:reset_end] + assert "_cronNewJobIds.clear();" in reset_body + assert "_cronPollSince=Date.now()/1000;" in reset_body + assert "updateCronBadge();" in reset_body From 67e527fd74f92dff4084418d4873899512d9d27a Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:57:40 -0500 Subject: [PATCH 02/10] fix(cron): guard profile-switch polling races --- api/routes.py | 13 +- static/panels.js | 4 + tests/test_issue3460_cron_session_unread.py | 1 + ...est_issue5960_cron_unread_profile_scope.py | 133 +++++++++--------- 4 files changed, 75 insertions(+), 76 deletions(-) diff --git a/api/routes.py b/api/routes.py index 868217ed..999b8caa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -19902,19 +19902,8 @@ def _handle_cron_recent(handler, parsed): since = 0.0 try: from cron.jobs import list_jobs - from api.profiles import ( - cron_profile_context_for_home, - get_active_profile_name, - get_hermes_home_for_profile, - ) - active_profile = get_active_profile_name() or "default" - active_home = get_hermes_home_for_profile(active_profile) - # Match the default Tasks list scope explicitly. The recent-completion - # endpoint feeds an actionable unread badge, so returning jobs from a - # different profile can strand an ID that has no visible row to open. - with cron_profile_context_for_home(active_home): - jobs = list_jobs(include_disabled=True) + jobs = list_jobs(include_disabled=True) completions = [] for job in jobs: job_id = str(job.get("id", "") or "") diff --git a/static/panels.js b/static/panels.js index 99453376..ee70350c 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12618,9 +12618,11 @@ async function disableAuth(){ let _cronPollSince=Date.now()/1000; // track from page load let _cronPollTimer=null; let _cronUnreadCount=0; +let _cronPollGeneration=0; const _cronNewJobIds=new Set(); // track which job IDs had new completions (unread) function _resetCronUnreadForProfileSwitch(){ + _cronPollGeneration++; _cronNewJobIds.clear(); _cronPollSince=Date.now()/1000; updateCronBadge(); @@ -12637,7 +12639,9 @@ function startCronPolling(){ _cronPollTimer=setInterval(async()=>{ if(document.hidden) return; // don't poll when tab is in background try{ + const pollGeneration=_cronPollGeneration; const data=await api(`/api/crons/recent?since=${_cronPollSince}`); + if(pollGeneration!==_cronPollGeneration) return; if(data.completions&&data.completions.length>0){ for(const c of data.completions){ if(c.toast_notifications !== false){ diff --git a/tests/test_issue3460_cron_session_unread.py b/tests/test_issue3460_cron_session_unread.py index 47bd99fa..4a2ccbb3 100644 --- a/tests/test_issue3460_cron_session_unread.py +++ b/tests/test_issue3460_cron_session_unread.py @@ -486,6 +486,7 @@ function extractFunc(name) {{ let _cronPollSince = 10; let _cronPollTimer = null; let _cronUnreadCount = 0; +let _cronPollGeneration = 0; const _cronNewJobIds = new Set(); const markCalls = []; const toastCalls = []; diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index 054a30c4..e21f26c0 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -2,81 +2,45 @@ from __future__ import annotations -import io import json -import sys -import types -from contextlib import contextmanager +import shutil +import subprocess from pathlib import Path -from types import SimpleNamespace + +import pytest ROOT = Path(__file__).resolve().parents[1] PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") +NODE = shutil.which("node") -class _JSONHandler: - def __init__(self): - self.status = None - self.response_headers = [] - self.wfile = io.BytesIO() - - def send_response(self, status): - self.status = status - - def send_header(self, key, value): - self.response_headers.append((key, value)) - - def end_headers(self): - pass +def _extract_function(source: str, name: str) -> str: + start = source.index(f"function {name}(") + brace = source.index("{", start) + depth = 1 + pos = brace + 1 + while depth and pos < len(source): + if source[pos] == "{": + depth += 1 + elif source[pos] == "}": + depth -= 1 + pos += 1 + assert depth == 0 + return source[start:pos] -def _payload(handler): - return json.loads(handler.wfile.getvalue().decode("utf-8")) +def test_recent_handler_reuses_dispatcher_cron_context_without_nesting(): + dispatch_start = ROUTES_PY.index('if parsed.path == "/api/crons/recent":') + dispatch_end = ROUTES_PY.index('if parsed.path == "/api/crons/status":', dispatch_start) + dispatch = ROUTES_PY[dispatch_start:dispatch_end] + handler_start = ROUTES_PY.index("def _handle_cron_recent(") + handler_end = ROUTES_PY.index("\ndef ", handler_start + 1) + handler = ROUTES_PY[handler_start:handler_end] - -def test_recent_completions_are_read_from_the_active_profile_home(monkeypatch): - import api.profiles as profiles - import api.routes as routes - - entered_homes = [] - - @contextmanager - def profile_context(home): - entered_homes.append(home) - yield - - cron_pkg = types.ModuleType("cron") - cron_pkg.__path__ = [] - cron_jobs = types.ModuleType("cron.jobs") - - def list_jobs(include_disabled=True): - assert entered_homes == ["/profiles/vops"] - return [ - { - "id": "vops-job", - "name": "Vops job", - "last_run_at": 20, - "last_status": "success", - } - ] - - cron_jobs.list_jobs = list_jobs - monkeypatch.setitem(sys.modules, "cron", cron_pkg) - monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) - monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "vops") - monkeypatch.setattr( - profiles, "get_hermes_home_for_profile", lambda name: f"/profiles/{name}" - ) - monkeypatch.setattr(profiles, "cron_profile_context_for_home", profile_context) - monkeypatch.setattr(routes, "_latest_cron_session_info_for_jobs", lambda *args: {}) - - handler = _JSONHandler() - routes._handle_cron_recent(handler, SimpleNamespace(query="since=10")) - - assert handler.status == 200 - assert entered_homes == ["/profiles/vops"] - assert [row["job_id"] for row in _payload(handler)["completions"]] == ["vops-job"] + assert "with cron_profile_context():" in dispatch + assert "cron_profile_context_for_home" not in handler def test_successful_profile_switch_resets_unread_cron_state(): @@ -91,6 +55,47 @@ def test_successful_profile_switch_resets_unread_cron_state(): reset_start = PANELS_JS.index("function _resetCronUnreadForProfileSwitch(){") reset_end = PANELS_JS.index("\n}", reset_start) reset_body = PANELS_JS[reset_start:reset_end] + assert "_cronPollGeneration++;" in reset_body assert "_cronNewJobIds.clear();" in reset_body assert "_cronPollSince=Date.now()/1000;" in reset_body assert "updateCronBadge();" in reset_body + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_poll_started_before_switch_cannot_recreate_unread_state(): + polling = _extract_function(PANELS_JS, "startCronPolling") + reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch") + script = f""" +let _cronPollSince=10; +let _cronPollTimer=null; +let _cronUnreadCount=0; +let _cronPollGeneration=0; +const _cronNewJobIds=new Set(); +let intervalCallback=null; +let resolveApi; +global.document={{hidden:false}}; +global.setInterval=(callback)=>{{ intervalCallback=callback; return 1; }}; +global.api=()=>new Promise(resolve=>{{ resolveApi=resolve; }}); +global.showToast=()=>{{}}; +global.t=(key)=>key; +global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +{polling} +{reset} +startCronPolling(); +(async()=>{{ + const stalePoll=intervalCallback(); + _resetCronUnreadForProfileSwitch(); + resolveApi({{completions:[{{job_id:'old-profile-job',completed_at:20}}]}}); + await stalePoll; + process.stdout.write(JSON.stringify({{ + unread:Array.from(_cronNewJobIds), + count:_cronUnreadCount, + generation:_cronPollGeneration, + }})); +}})().catch(error=>{{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state == {"unread": [], "count": 0, "generation": 1} From a8ef99b83088e024962f99c805e448f5419a461c Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:07:51 -0500 Subject: [PATCH 03/10] =?UTF-8?q?no-mistakes(review):=20Reset=20cron=20unr?= =?UTF-8?q?ead=20state=20on=20alternate=20profile=20switches=20?= =?UTF-8?q?=F0=9F=94=A7=F0=9F=9F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/sessions.js | 3 ++ ...est_issue5960_cron_unread_profile_scope.py | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index 5863cf9f..4dd28e8c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1438,6 +1438,9 @@ async function _switchProfileForSessionLoad(profile){ const data=await api('/api/profile/switch',{method:'POST',body:JSON.stringify({name}),timeoutToast:false}); S.activeProfile=data.active||name; S.activeProfileIsDefault=!!data.is_default; + if(typeof _resetCronUnreadForProfileSwitch==='function'){ + _resetCronUnreadForProfileSwitch(); + } if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); else localStorage.removeItem('hermes-webui-model'); if(data.default_model) window._defaultModel=data.default_model; diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index e21f26c0..7bbbbae4 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -12,12 +12,15 @@ import pytest ROOT = Path(__file__).resolve().parents[1] PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") NODE = shutil.which("node") def _extract_function(source: str, name: str) -> str: start = source.index(f"function {name}(") + if source[max(0, start - 6) : start] == "async ": + start -= 6 brace = source.index("{", start) depth = 1 pos = brace + 1 @@ -61,6 +64,43 @@ def test_successful_profile_switch_resets_unread_cron_state(): assert "updateCronBadge();" in reset_body +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_load_profile_switch_resets_unread_cron_state(): + switch = _extract_function(SESSIONS_JS, "_switchProfileForSessionLoad") + reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch") + script = f""" +let _cronPollSince=10; +let _cronUnreadCount=1; +let _cronPollGeneration=0; +const _cronNewJobIds=new Set(['old-profile-job']); +global.S={{activeProfile:'default'}}; +global.api=async()=>({{active:'alternate',is_default:false}}); +global.localStorage={{removeItem(){{}}}}; +global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +{reset} +{switch} +(async()=>{{ + await _switchProfileForSessionLoad('alternate'); + process.stdout.write(JSON.stringify({{ + profile:S.activeProfile, + unread:Array.from(_cronNewJobIds), + count:_cronUnreadCount, + generation:_cronPollGeneration, + }})); +}})().catch(error=>{{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state == { + "profile": "alternate", + "unread": [], + "count": 0, + "generation": 1, + } + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_poll_started_before_switch_cannot_recreate_unread_state(): polling = _extract_function(PANELS_JS, "startCronPolling") From 2844a5ad316ac6d0c20e52fab05af81f9346543f Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:12:22 -0500 Subject: [PATCH 04/10] =?UTF-8?q?no-mistakes(document):=20Document=20profi?= =?UTF-8?q?le-scoped=20cron=20unread=20alerts=20=F0=9F=94=A7=F0=9F=9F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e085875..1247d78b 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup - Token usage display toggle (off by default, also via `/usage` command) - Control Center always opens on the Conversation tab; resets on close - Unsaved changes guard -- discard/save prompt when closing with unpersisted changes -- Cron completion alerts -- toast notifications and unread badge on Tasks tab +- Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar - Background agent error alerts -- banner when a non-active session encounters an error ### Slash commands From 991b884ed2f85904ed1a11d0b7b8dd0d48d18b26 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:26:43 +0000 Subject: [PATCH 05/10] fix(cron): clear persisted unread markers on profile switch Gate review on #5975: sticky all-profile sidebars still showed old-profile cron dots because hermes-session-completion-unread was not profile-scoped. Tag cron markers with source+profile, drop inactive-profile cron markers in the shared switch reset (both paths), and leave ordinary chat unread alone. --- static/panels.js | 12 +- static/sessions.js | 43 +++++- ...est_issue5960_cron_unread_profile_scope.py | 142 ++++++++++++++++++ 3 files changed, 192 insertions(+), 5 deletions(-) diff --git a/static/panels.js b/static/panels.js index ee70350c..29d43c24 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12625,6 +12625,12 @@ function _resetCronUnreadForProfileSwitch(){ _cronPollGeneration++; _cronNewJobIds.clear(); _cronPollSince=Date.now()/1000; + // Clear persisted cron sidebar markers from the profile we left. Non-cron + // completion unread stays intact (#5960 gate: sticky all-profile leak). + if(typeof _clearCronSessionCompletionUnreadForInactiveProfiles==='function'){ + const activeProfile=(typeof S!=='undefined'&&S&&S.activeProfile)||'default'; + _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile); + } updateCronBadge(); } @@ -12650,7 +12656,11 @@ function startCronPolling(){ _cronPollSince=Math.max(_cronPollSince,c.completed_at); if(c.job_id) _cronNewJobIds.add(String(c.job_id)); if(c.session_id && typeof _markSessionCompletionUnreadIfBackground === 'function'){ - _markSessionCompletionUnreadIfBackground(c.session_id, c.message_count); + const activeProfile=(typeof S!=='undefined'&&S&&S.activeProfile)||'default'; + _markSessionCompletionUnreadIfBackground(c.session_id, c.message_count, { + source:'cron', + profile:activeProfile, + }); } } // _cronUnreadCount is derived from _cronNewJobIds.size in updateCronBadge. diff --git a/static/sessions.js b/static/sessions.js index 4dd28e8c..43ebb4ba 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -505,15 +505,24 @@ function _saveSessionCompletionUnread() { } } -function _markSessionCompletionUnread(sid, messageCount = 0) { +function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) { if (!sid) return; const unread = _getSessionCompletionUnread(); const count = Number.isFinite(messageCount) ? Number(messageCount) : 0; - unread[sid] = {message_count: count, completed_at: Date.now()}; + const entry = {message_count: count, completed_at: Date.now()}; + // Cron markers carry source+profile so profile switches can clear only that + // cross-profile leak without wiping ordinary chat completion unread (#5960). + if (meta && typeof meta === 'object' && !Array.isArray(meta)) { + if (meta.source) entry.source = String(meta.source); + if (typeof meta.profile === 'string' && meta.profile.trim()) { + entry.profile = meta.profile.trim(); + } + } + unread[sid] = entry; _saveSessionCompletionUnread(); } -function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) { +function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta = null) { if (!sid) return false; let count = Number.isFinite(messageCount) ? Number(messageCount) : NaN; if (!Number.isFinite(count)) { @@ -527,7 +536,7 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) { if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); return false; } - _markSessionCompletionUnread(sid, count); + _markSessionCompletionUnread(sid, count, meta); if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); return true; } @@ -540,6 +549,32 @@ function _clearSessionCompletionUnread(sid) { _saveSessionCompletionUnread(); } +// Drop persisted cron unread dots that belong to inactive profiles. Ordinary +// (non-cron) completion markers stay put — sticky all-profile sidebars still +// need those. Called from the shared profile-switch reset in panels.js. +function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { + const active = (typeof activeProfile === 'string' && activeProfile.trim()) + ? activeProfile.trim() + : 'default'; + const unread = _getSessionCompletionUnread(); + let changed = false; + for (const sid of Object.keys(unread)) { + const marker = unread[sid]; + if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue; + if (marker.source !== 'cron') continue; + const origin = (typeof marker.profile === 'string' && marker.profile.trim()) + ? marker.profile.trim() + : ''; + if (!origin || origin === active) continue; + delete unread[sid]; + changed = true; + } + if (!changed) return false; + _saveSessionCompletionUnread(); + if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); + return true; +} + function _clearSessionViewedCount(sid) { if (!sid) return; const counts = _getSessionViewedCounts(); diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index 7bbbbae4..b5503238 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -61,6 +61,7 @@ def test_successful_profile_switch_resets_unread_cron_state(): assert "_cronPollGeneration++;" in reset_body assert "_cronNewJobIds.clear();" in reset_body assert "_cronPollSince=Date.now()/1000;" in reset_body + assert "_clearCronSessionCompletionUnreadForInactiveProfiles" in reset_body assert "updateCronBadge();" in reset_body @@ -77,6 +78,7 @@ global.S={{activeProfile:'default'}}; global.api=async()=>({{active:'alternate',is_default:false}}); global.localStorage={{removeItem(){{}}}}; global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +function _clearCronSessionCompletionUnreadForInactiveProfiles(){{}} {reset} {switch} (async()=>{{ @@ -119,6 +121,7 @@ global.api=()=>new Promise(resolve=>{{ resolveApi=resolve; }}); global.showToast=()=>{{}}; global.t=(key)=>key; global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +function _clearCronSessionCompletionUnreadForInactiveProfiles(){{}} {polling} {reset} startCronPolling(); @@ -139,3 +142,142 @@ startCronPolling(); ) state = json.loads(result.stdout) assert state == {"unread": [], "count": 0, "generation": 1} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_profile_switch_clears_persisted_old_profile_cron_markers_only(): + """Gate #5975: sticky sidebar must drop old-profile cron dots, keep non-cron.""" + mark = _extract_function(SESSIONS_JS, "_markSessionCompletionUnread") + get_unread = _extract_function(SESSIONS_JS, "_getSessionCompletionUnread") + save_unread = _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread") + clear_inactive = _extract_function( + SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles" + ) + has_unread = _extract_function(SESSIONS_JS, "_hasUnreadForSession") + has_marker = _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread") + reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch") + switch = _extract_function(SESSIONS_JS, "_switchProfileForSessionLoad") + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +let _cronPollSince=10; +let _cronUnreadCount=0; +let _cronPollGeneration=0; +const _cronNewJobIds=new Set(['old-cron-job']); +let renders=0; +global.S={{activeProfile:'profile-a'}}; +global.api=async()=>({{active:'profile-b',is_default:false}}); +global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +global.renderSessionListFromCache=()=>{{ renders+=1; }}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _saveSessionViewedCounts(){{}} +function _setSessionViewedCount(sid, count){{ + _sessionViewedCounts[sid]=Number(count)||0; +}} +function _clearSessionCompletionUnread(sid){{ + const unread=_getSessionCompletionUnread(); + if(!Object.prototype.hasOwnProperty.call(unread, sid)) return; + delete unread[sid]; + _saveSessionCompletionUnread(); +}} +{get_unread} +{save_unread} +{mark} +{has_marker} +{has_unread} +{clear_inactive} +{reset} +{switch} +(async()=>{{ + _markSessionCompletionUnread('old-cron-session', 4, {{source:'cron', profile:'profile-a'}}); + _markSessionCompletionUnread('chat-session', 9); // ordinary completion — keep + _markSessionCompletionUnread('new-cron-session', 2, {{source:'cron', profile:'profile-b'}}); + const before={{ + oldCron:_hasUnreadForSession({{session_id:'old-cron-session'}}), + chat:_hasUnreadForSession({{session_id:'chat-session'}}), + newCron:_hasUnreadForSession({{session_id:'new-cron-session'}}), + }}; + await _switchProfileForSessionLoad('profile-b'); + const after={{ + oldCron:_hasUnreadForSession({{session_id:'old-cron-session'}}), + chat:_hasUnreadForSession({{session_id:'chat-session'}}), + newCron:_hasUnreadForSession({{session_id:'new-cron-session'}}), + badgeJobs:Array.from(_cronNewJobIds), + badgeCount:_cronUnreadCount, + generation:_cronPollGeneration, + renders, + persisted:JSON.parse(store['hermes-session-completion-unread']), + }}; + process.stdout.write(JSON.stringify({{before, after}})); +}})().catch(error=>{{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["before"] == {"oldCron": True, "chat": True, "newCron": True} + assert state["after"]["oldCron"] is False + assert state["after"]["chat"] is True + assert state["after"]["newCron"] is True + assert state["after"]["badgeJobs"] == [] + assert state["after"]["badgeCount"] == 0 + assert state["after"]["generation"] == 1 + assert state["after"]["renders"] >= 1 + assert "old-cron-session" not in state["after"]["persisted"] + assert "chat-session" in state["after"]["persisted"] + assert "new-cron-session" in state["after"]["persisted"] + assert state["after"]["persisted"]["new-cron-session"]["source"] == "cron" + assert state["after"]["persisted"]["new-cron-session"]["profile"] == "profile-b" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_cron_poll_tags_persisted_markers_with_active_profile(): + polling = _extract_function(PANELS_JS, "startCronPolling") + script = f""" +let _cronPollSince=10; +let _cronPollTimer=null; +let _cronUnreadCount=0; +let _cronPollGeneration=0; +const _cronNewJobIds=new Set(); +const markCalls=[]; +let intervalCallback=null; +global.document={{hidden:false}}; +global.S={{activeProfile:'profile-a'}}; +global.setInterval=(callback)=>{{ intervalCallback=callback; return 1; }}; +global.api=async()=>({{ + completions:[{{ + job_id:'job-a', + session_id:'cron-session-a', + message_count:3, + completed_at:20, + toast_notifications:false, + }}] +}}); +global.showToast=()=>{{}}; +global.t=(key)=>key; +global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +function _markSessionCompletionUnreadIfBackground(sid, count, meta){{ + markCalls.push([sid, count, meta]); +}} +{polling} +startCronPolling(); +(async()=>{{ + await intervalCallback(); + process.stdout.write(JSON.stringify({{markCalls, unreadJobs:Array.from(_cronNewJobIds)}})); +}})().catch(error=>{{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["unreadJobs"] == ["job-a"] + assert state["markCalls"] == [ + ["cron-session-a", 3, {"source": "cron", "profile": "profile-a"}] + ] From 4c5a21cef37540ba602e2719c1e3561d6053b4ba Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:16:51 -0500 Subject: [PATCH 06/10] fix(cron): close untagged persisted unread leak + root-alias over-clear Address hermes-webui #5975 re-gate: tag session-list completion markers for cron rows, resolve/migrate legacy untagged markers from sidebar metadata, and use default/renamed-root profile equivalence when clearing inactive-profile cron dots so current-root markers stay unread. --- static/sessions.js | 100 ++++++++- ...est_issue5960_cron_unread_profile_scope.py | 205 +++++++++++++++++- 2 files changed, 295 insertions(+), 10 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 43ebb4ba..b9c6d274 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -549,6 +549,88 @@ function _clearSessionCompletionUnread(sid) { _saveSessionCompletionUnread(); } +// True when a session row is a cron-origin session for unread-dot scoping. +function _isCronSessionForUnread(session) { + if (!session) return false; + const key = (typeof _sourceKeyForSession === 'function') + ? _sourceKeyForSession(session) + : String( + session.raw_source + || session.source_tag + || session.source + || session.session_source + || '' + ).toLowerCase(); + if (key === 'cron') return true; + return String(session.session_source || '').toLowerCase() === 'cron'; +} + +// Build {source, profile} for a cron session row; null for ordinary chat. +function _cronCompletionUnreadMetaForSession(session) { + if (!_isCronSessionForUnread(session)) return null; + const fromRow = (session && typeof session.profile === 'string' && session.profile.trim()) + ? session.profile.trim() + : ''; + const active = (typeof S !== 'undefined' && S && typeof S.activeProfile === 'string' && S.activeProfile.trim()) + ? S.activeProfile.trim() + : 'default'; + return {source: 'cron', profile: fromRow || active}; +} + +// Resolve whether a persisted marker is cron and which profile owns it. +// Untagged/legacy markers are migrated from the sidebar session row when known. +function _resolveCronCompletionMarkerOrigin(sid, marker) { + let isCron = !!(marker && marker.source === 'cron'); + let profile = (marker && typeof marker.profile === 'string' && marker.profile.trim()) + ? marker.profile.trim() + : ''; + let session = null; + if (Array.isArray(_allSessions)) { + session = _allSessions.find((s) => s && s.session_id === sid) || null; + } + if (!session && typeof _sessionListSnapshotById !== 'undefined' + && _sessionListSnapshotById && typeof _sessionListSnapshotById.get === 'function') { + // Snapshot alone lacks source/profile; keep null. + session = null; + } + if (session) { + if (!isCron && _isCronSessionForUnread(session)) isCron = true; + if (!profile) { + const sp = (typeof session.profile === 'string' && session.profile.trim()) + ? session.profile.trim() + : ''; + if (sp) profile = sp; + } + } + // Persist migration so later switches don't re-resolve from a cleared list. + if (marker && isCron) { + if (marker.source !== 'cron') marker.source = 'cron'; + if (profile && marker.profile !== profile) marker.profile = profile; + } + return {isCron, profile: profile || ''}; +} + +// default/renamed-root equivalence for cron-marker ownership (mirrors server +// _profiles_match enough for the active surface: literal 'default' ↔ root). +function _cronMarkerProfileMatchesActive(origin, activeProfile) { + const originName = (typeof origin === 'string' && origin.trim()) ? origin.trim() : ''; + const activeName = (typeof activeProfile === 'string' && activeProfile.trim()) + ? activeProfile.trim() + : 'default'; + if (!originName) return false; + if (originName === activeName) return true; + if (typeof _profileMatchesActiveProfile === 'function' + && _profileMatchesActiveProfile(originName, activeName)) { + return true; + } + // Reverse alias: marker tagged with renamed-root name while active is 'default' + // (or vice versa already handled above) when the current surface is the root. + if (typeof S !== 'undefined' && S && S.activeProfileIsDefault) { + if (originName === 'default' || activeName === 'default') return true; + } + return false; +} + // Drop persisted cron unread dots that belong to inactive profiles. Ordinary // (non-cron) completion markers stay put — sticky all-profile sidebars still // need those. Called from the shared profile-switch reset in panels.js. @@ -561,11 +643,12 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { for (const sid of Object.keys(unread)) { const marker = unread[sid]; if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue; - if (marker.source !== 'cron') continue; - const origin = (typeof marker.profile === 'string' && marker.profile.trim()) - ? marker.profile.trim() - : ''; - if (!origin || origin === active) continue; + const resolved = _resolveCronCompletionMarkerOrigin(sid, marker); + if (!resolved.isCron) continue; + // Only clear when we know the owning profile AND it is not the active one + // (incl. default/renamed-root equivalence). Untagged + unresolvable stays. + if (!resolved.profile) continue; + if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue; delete unread[sid]; changed = true; } @@ -1084,7 +1167,12 @@ function _markPollingCompletionUnreadTransitions(sessions) { const completedPersistedObservedStream = Boolean(observedStreaming && !isStreaming); if (completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) { if (!_isSessionActivelyViewedForList(sid)) { - _markSessionCompletionUnread(sid, s.message_count); + // Tag cron session-list markers with source+profile so profile-switch + // reset can clear only inactive-profile cron dots (#5960 / #5975 re-gate). + const meta = (typeof _cronCompletionUnreadMetaForSession === 'function') + ? _cronCompletionUnreadMetaForSession(s) + : null; + _markSessionCompletionUnread(sid, s.message_count, meta); } else { // Sync viewed count so we don't flag stale unread on tab switch (#3020) _setSessionViewedCount(sid, messageCount); diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index b5503238..8d638cca 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -150,8 +150,18 @@ def test_profile_switch_clears_persisted_old_profile_cron_markers_only(): mark = _extract_function(SESSIONS_JS, "_markSessionCompletionUnread") get_unread = _extract_function(SESSIONS_JS, "_getSessionCompletionUnread") save_unread = _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread") - clear_inactive = _extract_function( - SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles" + clear_helpers = "\n".join( + [ + _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), + _extract_function(SESSIONS_JS, "_sourceKeyForSession"), + _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), + _extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"), + _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"), + _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"), + _extract_function( + SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles" + ), + ] ) has_unread = _extract_function(SESSIONS_JS, "_hasUnreadForSession") has_marker = _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread") @@ -172,7 +182,8 @@ let _cronUnreadCount=0; let _cronPollGeneration=0; const _cronNewJobIds=new Set(['old-cron-job']); let renders=0; -global.S={{activeProfile:'profile-a'}}; +global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}}; +global._allSessions=[]; global.api=async()=>({{active:'profile-b',is_default:false}}); global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; global.renderSessionListFromCache=()=>{{ renders+=1; }}; @@ -192,7 +203,7 @@ function _clearSessionCompletionUnread(sid){{ {mark} {has_marker} {has_unread} -{clear_inactive} +{clear_helpers} {reset} {switch} (async()=>{{ @@ -281,3 +292,189 @@ startCronPolling(); assert state["markCalls"] == [ ["cron-session-a", 3, {"source": "cron", "profile": "profile-a"}] ] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_list_path_tags_cron_markers_with_source_and_profile(): + """Re-gate #5975: _markPollingCompletionUnreadTransitions must tag cron rows.""" + mark_poll = _extract_function(SESSIONS_JS, "_markPollingCompletionUnreadTransitions") + is_cron = _extract_function(SESSIONS_JS, "_isCronSessionForUnread") + meta_fn = _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession") + source_key = _extract_function(SESSIONS_JS, "_sourceKeyForSession") + script = f""" +const markCalls=[]; +global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}}; +global._allSessions=[]; +global._sessionListSnapshotById=new Map(); +global._sessionStreamingById=new Map(); +global._sessionListSourceById=new Map(); +global._allSessionsScope=null; +function _getSessionObservedStreaming(){{ return {{}}; }} +function _isSessionEffectivelyStreaming(){{ return false; }} +function _hasPendingUserMessageSignal(){{ return false; }} +function _isSessionActivelyViewedForList(){{ return false; }} +function _rememberSessionListSource(){{}} +function _rememberObservedStreamingSession(){{}} +function _forgetObservedStreamingSession(){{}} +function _setSessionViewedCount(){{}} +function _markSessionCompletionUnread(sid, count, meta){{ + markCalls.push([sid, count, meta||null]); +}} +{source_key} +{is_cron} +{meta_fn} +{mark_poll} +const sessions=[ + {{ + session_id:'cron-from-list', + message_count:2, + last_message_at:20, + source_tag:'cron', + profile:'profile-a', + is_streaming:false, + }}, + {{ + session_id:'chat-from-list', + message_count:5, + last_message_at:30, + source_tag:'webui', + profile:'profile-a', + is_streaming:false, + }}, +]; +// Pretend both previously streaming so completion transition fires. +_sessionStreamingById.set('cron-from-list', true); +_sessionStreamingById.set('chat-from-list', true); +_sessionListSnapshotById.set('cron-from-list', {{message_count:1, last_message_at:10}}); +_sessionListSnapshotById.set('chat-from-list', {{message_count:4, last_message_at:10}}); +_markPollingCompletionUnreadTransitions(sessions); +process.stdout.write(JSON.stringify({{markCalls}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + by_sid = {row[0]: row for row in state["markCalls"]} + assert "cron-from-list" in by_sid + assert by_sid["cron-from-list"][2] == {"source": "cron", "profile": "profile-a"} + assert "chat-from-list" in by_sid + assert by_sid["chat-from-list"][2] is None + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_legacy_untagged_cron_marker_cleared_via_sidebar_metadata(): + """Re-gate #5975: untagged markers resolve from sidebar session source/profile.""" + helpers = "\n".join( + [ + _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), + _extract_function(SESSIONS_JS, "_sourceKeyForSession"), + _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), + _extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"), + _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"), + _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{ + 'legacy-cron':{{message_count:2, completed_at:1}}, + 'chat':{{message_count:4, completed_at:1}}, +}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +global.S={{activeProfile:'profile-b',activeProfileIsDefault:false}}; +global._allSessions=[ + {{session_id:'legacy-cron', source_tag:'cron', profile:'profile-a', message_count:2}}, + {{session_id:'chat', source_tag:'webui', profile:'profile-a', message_count:4}}, +]; +global.renderSessionListFromCache=()=>{{}}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +{helpers} +const before={{ + legacy:_hasUnreadForSession({{session_id:'legacy-cron'}}), + chat:_hasUnreadForSession({{session_id:'chat'}}), +}}; +_clearCronSessionCompletionUnreadForInactiveProfiles('profile-b'); +const after={{ + legacy:_hasUnreadForSession({{session_id:'legacy-cron'}}), + chat:_hasUnreadForSession({{session_id:'chat'}}), + persisted:JSON.parse(store['hermes-session-completion-unread']), +}}; +process.stdout.write(JSON.stringify({{before, after}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["before"] == {"legacy": True, "chat": True} + assert state["after"]["legacy"] is False + assert state["after"]["chat"] is True + assert "legacy-cron" not in state["after"]["persisted"] + assert "chat" in state["after"]["persisted"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_root_alias_keeps_current_profile_cron_marker(): + """Re-gate #5975: default/renamed-root must not erase current-root cron dots.""" + helpers = "\n".join( + [ + _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), + _extract_function(SESSIONS_JS, "_sourceKeyForSession"), + _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), + _extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"), + _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"), + _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{ + 'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}}, + 'other-cron':{{message_count:1, completed_at:1, source:'cron', profile:'other'}}, +}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +// Renamed root profile is active. +global.S={{activeProfile:'kinni',activeProfileIsDefault:true}}; +global._allSessions=[]; +global.renderSessionListFromCache=()=>{{}}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +{helpers} +_clearCronSessionCompletionUnreadForInactiveProfiles('kinni'); +const persisted=JSON.parse(store['hermes-session-completion-unread']); +process.stdout.write(JSON.stringify({{ + rootKept:_hasUnreadForSession({{session_id:'root-cron'}}), + otherCleared:!_hasUnreadForSession({{session_id:'other-cron'}}), + persisted, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["rootKept"] is True + assert state["otherCleared"] is True + assert "root-cron" in state["persisted"] + assert "other-cron" not in state["persisted"] From b17e76d171535e724e4cbf907f76d0e3cdfc41b1 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:22:15 -0500 Subject: [PATCH 07/10] test: allow optional meta arg in polling unread mark assertion CI shard 2 failed test_issue856 structural check that required the exact two-arg _markSessionCompletionUnread call; #5975 now passes cron meta. --- tests/test_issue856_background_completion_unread.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index f8e10ecc..9844e6ad 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -159,7 +159,8 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): assert "wasStreaming === true && !isStreaming" in transition_block, ( "polling fallback must only fire on an observed streaming -> stopped transition" ) - assert "_markSessionCompletionUnread(sid, s.message_count);" in transition_block + # #5960/#5975: third arg may carry cron source+profile meta; still mark unread. + assert "_markSessionCompletionUnread(sid, s.message_count" in transition_block assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block assert "const _streamingPollMs = 30000;" in SESSIONS_JS assert "_applySessionListPayload(sessData,projData);" in refresh_block From 617c1f451df9f1edad619e708d4147306545ac0a Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:30:54 -0500 Subject: [PATCH 08/10] fix(cron): drop stale pre-switch session-list completion marks Greptile #5975 P1: capture _cronPollGeneration before /api/sessions await and re-check in _applySessionListPayload before completion-unread marking so a delayed profile-A response cannot remount cron dots after switch to B. Also skip inactive-profile cron marks in single-profile mode. Tests cover the race, positive path, and collateral call-shape contracts. --- static/sessions.js | 44 +++- .../test_firefox_sidebar_scroll_stability.py | 4 +- ...est_issue5960_cron_unread_profile_scope.py | 246 ++++++++++++++++++ ...t_issue856_background_completion_unread.py | 4 +- tests/test_session_sidebar_resilience.py | 2 +- 5 files changed, 290 insertions(+), 10 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index b9c6d274..d43910a6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1172,7 +1172,21 @@ function _markPollingCompletionUnreadTransitions(sessions) { const meta = (typeof _cronCompletionUnreadMetaForSession === 'function') ? _cronCompletionUnreadMetaForSession(s) : null; - _markSessionCompletionUnread(sid, s.message_count, meta); + // Defense: never re-create a cron unread for a non-active profile while + // the sidebar is single-profile (stale pre-switch payloads). + const allProfilesOn = (typeof _showAllProfiles !== 'undefined' && !!_showAllProfiles); + if ( + meta + && meta.source === 'cron' + && meta.profile + && !allProfilesOn + && typeof _cronMarkerProfileMatchesActive === 'function' + && !_cronMarkerProfileMatchesActive(meta.profile, (typeof S !== 'undefined' && S && S.activeProfile) || 'default') + ) { + // Skip mark for inactive-profile cron row. + } else { + _markSessionCompletionUnread(sid, s.message_count, meta); + } } else { // Sync viewed count so we don't flag stale unread on tab switch (#3020) _setSessionViewedCount(sid, messageCount); @@ -5039,7 +5053,11 @@ function _schedulePendingSessionListApply(){ const payload=_pendingSessionListPayload; _pendingSessionListPayload=null; if(payload.gen!==_renderSessionListGen) return; - _applySessionListPayload(payload.sessData,payload.projData); + // Profile switch may have bumped unread gen after the list gen check + // window; still drop completion-marking for the stale pre-switch payload. + _applySessionListPayload(payload.sessData,payload.projData,{ + unreadGen:payload.unreadGen, + }); }, Math.max(120, SESSION_LIST_INTERACTION_IDLE_MS)); } @@ -5109,10 +5127,11 @@ function _sessionListRenderSignature(){ ]); }catch(_){ return null; } } -function _applySessionListPayload(sessData, projData){ +function _applySessionListPayload(sessData, projData, opts){ // Server's other_profile_count tells us how many sessions exist outside the // active profile so the "Show N from other profiles" toggle can render // without a second round-trip. Stashed on the module for renderSessionListFromCache. + const applyOpts = (opts && typeof opts === 'object') ? opts : {}; _otherProfileCount = sessData.other_profile_count || 0; _archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0); _archivedCliCount = Number(sessData.archived_cli_count ?? 0); @@ -5176,7 +5195,16 @@ function _applySessionListPayload(sessData, projData){ const _hadSessionListLoadError = !!_sessionListLoadError; _sessionListLoadError = null; _sessionListHasLoadedOnce = true; - _markPollingCompletionUnreadTransitions(_allSessions); + // Greptile #5975 P1: a /api/sessions request started under profile A can + // finish after a switch to B already cleared A's cron markers. The list gen + // check can already have passed (TOCTOU) or a deferred apply can land later. + // Re-validate the profile-switch unread generation (shared with cron poll + // reset via _cronPollGeneration) immediately before marking completions. + const expectedUnreadGen = applyOpts.unreadGen; + const currentUnreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0; + if (typeof expectedUnreadGen !== 'number' || expectedUnreadGen === currentUnreadGen) { + _markPollingCompletionUnreadTransitions(_allSessions); + } const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s)); if (isStreaming) { startStreamingPoll(); @@ -5315,6 +5343,10 @@ function _renderSessionListLoadErrorNote(){ async function _runRenderSessionListRefresh(opts, _gen){ const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting); if(!deferWhileInteracting) _pendingSessionListPayload=null; + // Capture profile-switch unread generation BEFORE the await so a switch + // mid-flight (which increments _cronPollGeneration) invalidates completion + // marking for this response even if list gen checks already passed. + const unreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0; try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const sessionListQS = _sessionListQueryString(); @@ -5343,11 +5375,11 @@ async function _runRenderSessionListRefresh(opts, _gen){ // renderSessionList(), so that render's payload is the first allowed to paint. if (_profileSwitchListEmbargo) return; if(deferWhileInteracting&&_isSessionListUserInteracting()){ - _pendingSessionListPayload={gen:_gen,sessData,projData}; + _pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen}; _schedulePendingSessionListApply(); return; } - _applySessionListPayload(sessData,projData); + _applySessionListPayload(sessData,projData,{unreadGen}); }catch(e){ if (_gen !== _renderSessionListGen) return; // #4671: same embargo guard as the success path — a mid-switch /api/sessions that diff --git a/tests/test_firefox_sidebar_scroll_stability.py b/tests/test_firefox_sidebar_scroll_stability.py index 281b01dc..d99e3ba4 100644 --- a/tests/test_firefox_sidebar_scroll_stability.py +++ b/tests/test_firefox_sidebar_scroll_stability.py @@ -33,9 +33,9 @@ def test_polling_payloads_are_deferred_while_user_scrolls_sidebar(): assert "async function _runRenderSessionListRefresh(opts, _gen)" in refresh_block assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in refresh_block assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" in refresh_block - assert "_pendingSessionListPayload={gen:_gen,sessData,projData};" in refresh_block + assert "_pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen};" in refresh_block assert "_schedulePendingSessionListApply();" in refresh_block - assert "_applySessionListPayload(sessData,projData);" in refresh_block + assert "_applySessionListPayload(sessData,projData,{unreadGen});" in refresh_block assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block, ( "deferring sidebar refreshes must preserve background-completion unread semantics" ) diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index 8d638cca..35d1514e 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -301,9 +301,12 @@ def test_session_list_path_tags_cron_markers_with_source_and_profile(): is_cron = _extract_function(SESSIONS_JS, "_isCronSessionForUnread") meta_fn = _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession") source_key = _extract_function(SESSIONS_JS, "_sourceKeyForSession") + match_fn = _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive") + profile_match = _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile") script = f""" const markCalls=[]; global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}}; +let _showAllProfiles=false; global._allSessions=[]; global._sessionListSnapshotById=new Map(); global._sessionStreamingById=new Map(); @@ -323,6 +326,8 @@ function _markSessionCompletionUnread(sid, count, meta){{ {source_key} {is_cron} {meta_fn} +{profile_match} +{match_fn} {mark_poll} const sessions=[ {{ @@ -478,3 +483,244 @@ process.stdout.write(JSON.stringify({{ assert state["otherCleared"] is True assert "root-cron" in state["persisted"] assert "other-cron" not in state["persisted"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_stale_pre_switch_session_list_does_not_recreate_cron_markers(): + """Greptile #5975 P1: delayed /api/sessions after profile switch must not remount cron dots.""" + # Re-read sources so this test always sees the latest shipped functions. + sessions_js = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + panels_js = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + apply_fn = _extract_function(sessions_js, "_applySessionListPayload") + mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions") + helpers = "\n".join( + [ + _extract_function(sessions_js, "_isCronSessionForUnread"), + _extract_function(sessions_js, "_sourceKeyForSession"), + _extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"), + _extract_function(sessions_js, "_resolveCronCompletionMarkerOrigin"), + _extract_function(sessions_js, "_cronMarkerProfileMatchesActive"), + _extract_function(sessions_js, "_profileMatchesActiveProfile"), + _extract_function(sessions_js, "_getSessionCompletionUnread"), + _extract_function(sessions_js, "_saveSessionCompletionUnread"), + _extract_function(sessions_js, "_markSessionCompletionUnread"), + _extract_function(sessions_js, "_hasSessionCompletionUnread"), + _extract_function(sessions_js, "_hasUnreadForSession"), + _extract_function(sessions_js, "_clearCronSessionCompletionUnreadForInactiveProfiles"), + _extract_function(panels_js, "_resetCronUnreadForProfileSwitch"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +let _cronPollGeneration=0; +let _cronPollSince=10; +let _cronUnreadCount=0; +const _cronNewJobIds=new Set(['old-job']); +let _allSessions=[]; +let _allSessionsScope=null; +let _sidebarReferenceSessions=[]; +let _otherProfileCount=0; +let _archivedWebuiCount=0; +let _archivedCliCount=0; +let _serverWebuiSessionCount=null; +let _serverCliSessionCount=null; +let _serverTimeDelta=0; +let _serverTz=null; +let _sessionListLoadError=null; +let _sessionListHasLoadedOnce=false; +let _sessionListFirstRenderAnimated=true; +let _showAllProfiles=false; +const _optimisticallyRemovedSessionIds=new Set(); +const _sessionStreamingById=new Map([['old-cron', true]]); +const _sessionListSnapshotById=new Map([['old-cron', {{message_count:1, last_message_at:10}}]]); +const _sessionListSourceById=new Map(); +global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}}; +global.renderSessionListFromCache=()=>{{}}; +global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +function _getSessionObservedStreaming(){{ return {{}}; }} +function _isSessionEffectivelyStreaming(){{ return false; }} +function _hasPendingUserMessageSignal(){{ return false; }} +function _isSessionActivelyViewedForList(){{ return false; }} +function _rememberSessionListSource(){{}} +function _rememberObservedStreamingSession(){{}} +function _forgetObservedStreamingSession(){{}} +function _reconcileActiveSessionIdleStateFromList(){{}} +function _mergeOptimisticFirstTurnSessions(s){{ return s; }} +function _recordSessionProfileCount(){{}} +function _syncSessionAttentionSoundState(){{}} +function _pruneLineageReportCacheToVisibleSessions(){{}} +function _requestedSessionSidebarSource(){{ return 'webui'; }} +function _sessionListExcludeHiddenEnabled(){{ return false; }} +function startStreamingPoll(){{}} +function stopStreamingPoll(){{}} +function ensureSessionTimeRefreshPoll(){{}} +function ensureActiveSessionExternalRefreshPoll(){{}} +function animateNextSessionListRefresh(){{}} +function ensureSessionEventsSSE(){{}} +function _activeSessionIdForSidebar(){{ return null; }} +function _sessionListRenderSignature(){{ return 'sig'; }} +function _purgeStaleInflightEntries(){{}} +let _sessionListSkeletonActive=false; +let _sessionListRefreshAnimationPending=false; +let _lastSessionListRenderSig=null; +let _renamingSid=null; +let _sessionActionMenu=null; +let _allProjects=[]; +{helpers} +{mark_poll} +{apply_fn} +// Snapshot generation under profile A (request starts). +const unreadGenAtStart=_cronPollGeneration; +// Profile switch to B: bumps unread gen + clears markers. +global.S.activeProfile='profile-b'; +_resetCronUnreadForProfileSwitch(); +// Delayed pre-switch /api/sessions payload (profile A cron still streaming→done). +const sessData={{ + sessions:[{{ + session_id:'old-cron', + message_count:2, + last_message_at:20, + source_tag:'cron', + profile:'profile-a', + is_streaming:false, + }}], + active_profile:'profile-a', + other_profile_count:0, +}}; +_applySessionListPayload(sessData, {{projects:[]}}, {{unreadGen:unreadGenAtStart}}); +const persisted=JSON.parse(store['hermes-session-completion-unread']||'{{}}'); +process.stdout.write(JSON.stringify({{ + generation:_cronPollGeneration, + badgeJobs:Array.from(_cronNewJobIds), + oldCronMarked:Object.prototype.hasOwnProperty.call(persisted,'old-cron'), + persisted, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["generation"] == 1 + assert state["badgeJobs"] == [] + assert state["oldCronMarked"] is False + assert "old-cron" not in state["persisted"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_fresh_session_list_still_marks_when_unread_gen_matches(): + """Sanity: matching unreadGen still allows completion marks after switch.""" + sessions_js = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + apply_fn = _extract_function(sessions_js, "_applySessionListPayload") + mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions") + helpers = "\n".join( + [ + _extract_function(sessions_js, "_isCronSessionForUnread"), + _extract_function(sessions_js, "_sourceKeyForSession"), + _extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"), + _extract_function(sessions_js, "_cronMarkerProfileMatchesActive"), + _extract_function(sessions_js, "_profileMatchesActiveProfile"), + _extract_function(sessions_js, "_getSessionCompletionUnread"), + _extract_function(sessions_js, "_saveSessionCompletionUnread"), + _extract_function(sessions_js, "_markSessionCompletionUnread"), + _extract_function(sessions_js, "_hasSessionCompletionUnread"), + _extract_function(sessions_js, "_hasUnreadForSession"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +let _cronPollGeneration=3; +let _allSessions=[]; +let _allSessionsScope=null; +let _sidebarReferenceSessions=[]; +let _otherProfileCount=0; +let _archivedWebuiCount=0; +let _archivedCliCount=0; +let _serverWebuiSessionCount=null; +let _serverCliSessionCount=null; +let _serverTimeDelta=0; +let _serverTz=null; +let _sessionListLoadError=null; +let _sessionListHasLoadedOnce=false; +let _sessionListFirstRenderAnimated=true; +let _showAllProfiles=false; +const _optimisticallyRemovedSessionIds=new Set(); +const _sessionStreamingById=new Map([['new-cron', true]]); +const _sessionListSnapshotById=new Map([['new-cron', {{message_count:1, last_message_at:10}}]]); +const _sessionListSourceById=new Map(); +global.S={{activeProfile:'profile-b',activeProfileIsDefault:false}}; +global.renderSessionListFromCache=()=>{{}}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +function _getSessionObservedStreaming(){{ return {{}}; }} +function _isSessionEffectivelyStreaming(){{ return false; }} +function _hasPendingUserMessageSignal(){{ return false; }} +function _isSessionActivelyViewedForList(){{ return false; }} +function _rememberSessionListSource(){{}} +function _rememberObservedStreamingSession(){{}} +function _forgetObservedStreamingSession(){{}} +function _reconcileActiveSessionIdleStateFromList(){{}} +function _mergeOptimisticFirstTurnSessions(s){{ return s; }} +function _recordSessionProfileCount(){{}} +function _syncSessionAttentionSoundState(){{}} +function _pruneLineageReportCacheToVisibleSessions(){{}} +function _requestedSessionSidebarSource(){{ return 'webui'; }} +function _sessionListExcludeHiddenEnabled(){{ return false; }} +function startStreamingPoll(){{}} +function stopStreamingPoll(){{}} +function ensureSessionTimeRefreshPoll(){{}} +function ensureActiveSessionExternalRefreshPoll(){{}} +function animateNextSessionListRefresh(){{}} +function ensureSessionEventsSSE(){{}} +function _sessionListRenderSignature(){{ return 'sig'; }} +function _purgeStaleInflightEntries(){{}} +let _sessionListSkeletonActive=false; +let _sessionListRefreshAnimationPending=false; +let _lastSessionListRenderSig=null; +let _renamingSid=null; +let _sessionActionMenu=null; +let _allProjects=[]; +{helpers} +{mark_poll} +{apply_fn} +_applySessionListPayload({{ + sessions:[{{ + session_id:'new-cron', + message_count:2, + last_message_at:20, + source_tag:'cron', + profile:'profile-b', + is_streaming:false, + }}], + active_profile:'profile-b', +}}, {{projects:[]}}, {{unreadGen:3}}); +const persisted=JSON.parse(store['hermes-session-completion-unread']||'{{}}'); +process.stdout.write(JSON.stringify({{ + marked:Object.prototype.hasOwnProperty.call(persisted,'new-cron'), + meta:persisted['new-cron']||null, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["marked"] is True + assert state["meta"]["source"] == "cron" + assert state["meta"]["profile"] == "profile-b" diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 9844e6ad..656dddea 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -163,7 +163,9 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): assert "_markSessionCompletionUnread(sid, s.message_count" in transition_block assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block assert "const _streamingPollMs = 30000;" in SESSIONS_JS - assert "_applySessionListPayload(sessData,projData);" in refresh_block + # Greptile #5975: apply carries unreadGen so stale pre-switch lists skip mark. + assert "_applySessionListPayload(sessData,projData,{unreadGen});" in refresh_block + assert "unreadGen" in refresh_block assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, ( "the streaming poll fallback must stay active for the same server-confirmed " diff --git a/tests/test_session_sidebar_resilience.py b/tests/test_session_sidebar_resilience.py index 7c4caa62..2233d27d 100644 --- a/tests/test_session_sidebar_resilience.py +++ b/tests/test_session_sidebar_resilience.py @@ -53,7 +53,7 @@ def test_sessions_and_projects_load_independently_so_projects_failure_cannot_bla assert "return await api('/api/projects' + projectQS,{timeoutToast:false});" in helper assert "console.warn('renderProjectsList',projectError);" in helper assert "const projData = await projectPromise;" in helper - assert "_applySessionListPayload(sessData,projData)" in block + assert "_applySessionListPayload(sessData,projData,{unreadGen})" in block def test_sessions_api_always_retries_transient_upstream_statuses_and_boot_keeps_longer_timeout(): From 30e32cb11e26f1268aa21bcaf85f19396036bd1e Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:26:25 -0500 Subject: [PATCH 09/10] fix(cron): restrict reverse root-alias match to proven root names The round-3 equivalence shortcut accepted any origin when the active profile was literal 'default', so another profile's cron marker was treated as active-owned and never cleared on switch (reverse under-clear). Match now requires the origin name itself to resolve to the root profile: the literal 'default' alias, or a roster entry flagged is_default. Unknown names fall back to exact-name semantics. Adds profile-a -> default regressions covering tagged and legacy-untagged old-profile markers (cleared) alongside current-root, renamed-root-alias, and non-cron markers (kept), plus a no-roster fail-closed case. --- static/sessions.js | 25 +++- ...est_issue5960_cron_unread_profile_scope.py | 141 ++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index d43910a6..4f9e9fb6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -610,6 +610,19 @@ function _resolveCronCompletionMarkerOrigin(sid, marker) { return {isCron, profile: profile || ''}; } +// A profile name provably resolving to the root profile: the literal +// 'default' alias, or a roster entry flagged is_default (renamed root). +// Unknown names fail closed — exact-name matching still applies to them. +function _cronProfileNameIsRootAlias(name) { + if (name === 'default') return true; + if (typeof _profilesCache !== 'undefined' && _profilesCache + && Array.isArray(_profilesCache.profiles)) { + const entry = _profilesCache.profiles.find((p) => p && p.name === name); + if (entry && entry.is_default) return true; + } + return false; +} + // default/renamed-root equivalence for cron-marker ownership (mirrors server // _profiles_match enough for the active surface: literal 'default' ↔ root). function _cronMarkerProfileMatchesActive(origin, activeProfile) { @@ -623,10 +636,14 @@ function _cronMarkerProfileMatchesActive(origin, activeProfile) { && _profileMatchesActiveProfile(originName, activeName)) { return true; } - // Reverse alias: marker tagged with renamed-root name while active is 'default' - // (or vice versa already handled above) when the current surface is the root. - if (typeof S !== 'undefined' && S && S.activeProfileIsDefault) { - if (originName === 'default' || activeName === 'default') return true; + // Reverse alias: marker tagged with the renamed-root name while the active + // root surface reports a different alias. Match only when the origin name + // ITSELF provably resolves to the root — never "active is default → match + // all", which under-cleared other profiles' markers on switch to 'default'. + if (typeof S !== 'undefined' && S && S.activeProfileIsDefault + && typeof _cronProfileNameIsRootAlias === 'function' + && _cronProfileNameIsRootAlias(originName)) { + return true; } return false; } diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index 35d1514e..f98b4d76 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -485,6 +485,147 @@ process.stdout.write(JSON.stringify({{ assert "other-cron" not in state["persisted"] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_switch_to_literal_default_clears_other_profile_cron_markers(): + """Re-gate #5975 r3: active literal 'default' must not match every origin. + + The reverse alias may only keep a marker whose origin name itself provably + resolves to the root profile — tagged AND legacy-untagged old-profile cron + markers must clear when switching to 'default'. + """ + helpers = "\n".join( + [ + _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), + _extract_function(SESSIONS_JS, "_sourceKeyForSession"), + _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), + _extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"), + _extract_function(SESSIONS_JS, "_cronProfileNameIsRootAlias"), + _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"), + _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{ + 'tagged-old-cron':{{message_count:3, completed_at:1, source:'cron', profile:'profile-a'}}, + 'legacy-old-cron':{{message_count:2, completed_at:1}}, + 'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}}, + 'renamed-root-cron':{{message_count:1, completed_at:1, source:'cron', profile:'kinni'}}, + 'plain-chat':{{message_count:5, completed_at:1}}, +}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +// Root profile is active under its literal 'default' name. +global.S={{activeProfile:'default',activeProfileIsDefault:true}}; +// Sidebar metadata resolves the legacy-untagged marker to profile-a cron; +// 'plain-chat' stays an ordinary completion (no cron source anywhere). +global._allSessions=[ + {{session_id:'legacy-old-cron', source:'cron', profile:'profile-a'}}, + {{session_id:'plain-chat', source:'chat', profile:'profile-a'}}, +]; +// Roster proves 'kinni' is a root alias; 'profile-a' is not. +global._profilesCache={{profiles:[ + {{name:'kinni', is_default:true}}, + {{name:'profile-a', is_default:false}}, +]}}; +global.renderSessionListFromCache=()=>{{}}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +{helpers} +_clearCronSessionCompletionUnreadForInactiveProfiles('default'); +const persisted=JSON.parse(store['hermes-session-completion-unread']); +process.stdout.write(JSON.stringify({{ + taggedCleared:!_hasUnreadForSession({{session_id:'tagged-old-cron'}}), + legacyCleared:!_hasUnreadForSession({{session_id:'legacy-old-cron'}}), + rootKept:_hasUnreadForSession({{session_id:'root-cron'}}), + renamedRootKept:_hasUnreadForSession({{session_id:'renamed-root-cron'}}), + chatKept:_hasUnreadForSession({{session_id:'plain-chat'}}), + persisted, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["taggedCleared"] is True + assert state["legacyCleared"] is True + assert state["rootKept"] is True + assert state["renamedRootKept"] is True + assert state["chatKept"] is True + assert "tagged-old-cron" not in state["persisted"] + assert "legacy-old-cron" not in state["persisted"] + assert "root-cron" in state["persisted"] + assert "renamed-root-cron" in state["persisted"] + assert "plain-chat" in state["persisted"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_switch_to_literal_default_without_roster_fails_closed_on_unknown_names(): + """Re-gate #5975 r3: with no roster, an unknown origin under active 'default' + gets exact-name semantics (cleared), and literal-'default' markers stay.""" + helpers = "\n".join( + [ + _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), + _extract_function(SESSIONS_JS, "_sourceKeyForSession"), + _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), + _extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"), + _extract_function(SESSIONS_JS, "_cronProfileNameIsRootAlias"), + _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"), + _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + ] + ) + script = f""" +const store={{'hermes-session-completion-unread':JSON.stringify({{ + 'other-cron':{{message_count:1, completed_at:1, source:'cron', profile:'profile-a'}}, + 'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}}, +}})}}; +global.localStorage={{ + getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, + setItem:(key,value)=>{{ store[key]=String(value); }}, + removeItem:(key)=>{{ delete store[key]; }}, +}}; +let _sessionCompletionUnread=null; +let _sessionViewedCounts={{}}; +const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread'; +global.S={{activeProfile:'default',activeProfileIsDefault:true}}; +global._allSessions=[]; +global.renderSessionListFromCache=()=>{{}}; +function _getSessionViewedCounts(){{ return _sessionViewedCounts; }} +function _setSessionViewedCount(){{}} +{helpers} +_clearCronSessionCompletionUnreadForInactiveProfiles('default'); +const persisted=JSON.parse(store['hermes-session-completion-unread']); +process.stdout.write(JSON.stringify({{ + otherCleared:!_hasUnreadForSession({{session_id:'other-cron'}}), + rootKept:_hasUnreadForSession({{session_id:'root-cron'}}), + persisted, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30 + ) + state = json.loads(result.stdout) + assert state["otherCleared"] is True + assert state["rootKept"] is True + assert "other-cron" not in state["persisted"] + assert "root-cron" in state["persisted"] + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_stale_pre_switch_session_list_does_not_recreate_cron_markers(): """Greptile #5975 P1: delayed /api/sessions after profile switch must not remount cron dots.""" From a9cf45f411d796a11b394f42ae47f65ac1b4c716 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 12 Jul 2026 19:27:05 +0000 Subject: [PATCH 10/10] Release exp: cron unread scoped to active profile (#5975, closes #5960). Thanks @jbbottoms. 4-round convergence: source+profile marker tagging, clear-on-switch incl legacy/untagged, stale-poll rejection, default<->renamed-root alias equivalence (no over/under-clear). Codex SAFE round 4 (58 tests), focused suite 13/13. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1183dd..cb61aad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Fixed +- **Cron unread ("Tasks") dots are now scoped to the active profile — no more ghost dots after switching.** In multi-profile setups, the session-sidebar unread dot and the Tasks badge could keep showing a cron completion from a *different* profile after you switched, and the count could resurrect from a stale poll. Cron completion markers now carry their source + owning profile, are cleared for the profile you switch away from (including legacy/untagged markers), and a previous-profile poll response can no longer restore a stale unread. Non-cron completion unread and the current profile's own cron unread are preserved; default ↔ renamed-root aliases are treated as the same profile so a root cron marker is neither wrongly cleared nor wrongly kept. Thanks @jbbottoms. (#5975, #5960) + - **The Docker "manual update" banner instruction is now localized.** On Docker/no-git deployments the update banner shows the manual update command; that guidance string was hardcoded in English. It now goes through the i18n system, so it renders in the active language (English fallback where a translation isn't present yet). Thanks @jbbottoms. (#5973, #5959) - **Cross-platform test suite stabilized.** The profile skill-statistics coverage test now selects a genuinely-incompatible platform per-OS (so the incompatibility assertion holds on macOS as well as Linux), and eight pytest class-scoped instance-fixture deprecation warnings were removed by switching to state-free module fixtures. Test-only; no product code changes. Thanks @tahabakhit. (#5970)