From 63d024dc45156d1ab0067a135dab776ce5dd6328 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 11:40:51 +0000 Subject: [PATCH 1/8] feat(sidebar): make the visible-session window size configurable CLI_VISIBLE_SESSION_LIMIT is hardcoded to 20. That constant also bounds how many delegated subagent children can render at once, since a child only nests when its parent row is in the same payload, so operators running wide delegate_task fan-outs cannot see all children without editing the source. Read it from HERMES_WEBUI_VISIBLE_SESSION_LIMIT via the existing _env_int helper, keeping 20 as the default. --- api/models.py | 6 ++++- tests/test_visible_session_limit_env.py | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/test_visible_session_limit_env.py diff --git a/api/models.py b/api/models.py index 84a91b9c..99226cb4 100644 --- a/api/models.py +++ b/api/models.py @@ -48,7 +48,11 @@ from api.agent_sessions import ( from api.process_event_utils import stamp_message_source logger = logging.getLogger(__name__) -CLI_VISIBLE_SESSION_LIMIT = 20 +# Size of the interactive sidebar recency window. Also bounds how many +# delegated subagent children can be rendered at once, since a child only +# nests when it wins a slot in this window. Override with +# HERMES_WEBUI_VISIBLE_SESSION_LIMIT. +CLI_VISIBLE_SESSION_LIMIT = _cfg._env_int("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20) # How many messageful cron sessions to surface in the project-chip layer. # Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay # addressable even when many newer non-cron sessions dominate the default diff --git a/tests/test_visible_session_limit_env.py b/tests/test_visible_session_limit_env.py new file mode 100644 index 00000000..178465f5 --- /dev/null +++ b/tests/test_visible_session_limit_env.py @@ -0,0 +1,35 @@ +"""CLI_VISIBLE_SESSION_LIMIT is overridable via HERMES_WEBUI_VISIBLE_SESSION_LIMIT. + +The sidebar recency window also bounds how many delegated subagent children can +render at once, since a child only nests when its row wins a slot in the same +payload. Operators running wide fan-outs need to raise it without editing code. +""" + +import importlib + + +def _reload_limit(monkeypatch, value): + if value is None: + monkeypatch.delenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", raising=False) + else: + monkeypatch.setenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", value) + import api.models as models + + return importlib.reload(models).CLI_VISIBLE_SESSION_LIMIT + + +def test_defaults_to_20_when_unset(monkeypatch): + assert _reload_limit(monkeypatch, None) == 20 + + +def test_env_override_raises_the_window(monkeypatch): + assert _reload_limit(monkeypatch, "64") == 64 + + +def test_invalid_value_falls_back_to_default(monkeypatch): + assert _reload_limit(monkeypatch, "bogus") == 20 + + +def test_zero_and_negative_fall_back_to_default(monkeypatch): + assert _reload_limit(monkeypatch, "0") == 20 + assert _reload_limit(monkeypatch, "-5") == 20 From 899b89e1d3456de78a3bf92efd03158230bc4553 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 18:39:47 +0000 Subject: [PATCH 2/8] docs+test: document HERMES_WEBUI_VISIBLE_SESSION_LIMIT; probe it in a subprocess - README env table + .env.example gain the new setting (default 20, fallback semantics), pinned by test_setting_is_documented. - tests/test_visible_session_limit_env.py no longer importlib.reload()s api.models in the shared pytest process (which recreated its module-level locks/caches/classes behind other modules' backs); each case now imports api.models in a fresh interpreter with a scrubbed env, matching test_issue3283_profiles_config_import_order. --- .env.example | 5 ++ README.md | 1 + tests/test_visible_session_limit_env.py | 72 ++++++++++++++++++------- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 3a3d4c13..2682308f 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,11 @@ # Extra sources for the notes drawer (see docs for the expected format). # HERMES_WEBUI_EXTERNAL_NOTES_SOURCES= +# Sidebar recency window: how many recent sessions are listed (default: 20). +# Also caps how many delegated subagent children can nest at once; raise it +# for wide fan-outs. Invalid or non-positive values fall back to 20. +# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20 + # Directory scanned for WebUI plugins (default: ~/.hermes/plugins). # HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins diff --git a/README.md b/README.md index 7e73b9f1..3edf0f7b 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,7 @@ Full list of environment variables: | `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | +| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | | `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access | diff --git a/tests/test_visible_session_limit_env.py b/tests/test_visible_session_limit_env.py index 178465f5..e1fc0f32 100644 --- a/tests/test_visible_session_limit_env.py +++ b/tests/test_visible_session_limit_env.py @@ -3,33 +3,69 @@ The sidebar recency window also bounds how many delegated subagent children can render at once, since a child only nests when its row wins a slot in the same payload. Operators running wide fan-outs need to raise it without editing code. + +The constant is bound once at import time, so each case imports ``api.models`` +in a fresh subprocess (same pattern as test_issue3283_profiles_config_import_order) +rather than ``importlib.reload``-ing it in the shared test process — reloading +would recreate the module's locks, caches, and classes while other modules keep +references to the old objects. """ -import importlib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +_PROBE = """ +import api.models +print(api.models.CLI_VISIBLE_SESSION_LIMIT) +""" -def _reload_limit(monkeypatch, value): - if value is None: - monkeypatch.delenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", raising=False) - else: - monkeypatch.setenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", value) - import api.models as models - - return importlib.reload(models).CLI_VISIBLE_SESSION_LIMIT +def _limit_in_fresh_interpreter(tmp_path, value): + home = tmp_path / "home" + home.mkdir() + env = os.environ.copy() + for key in list(env): + if key.startswith("HERMES_WEBUI_") or key in ("HERMES_HOME", "HERMES_BASE_HOME"): + env.pop(key) + env["HOME"] = str(home) + env["PYTHONPATH"] = str(REPO_ROOT) + if value is not None: + env["HERMES_WEBUI_VISIBLE_SESSION_LIMIT"] = value + proc = subprocess.run( + [sys.executable, "-c", _PROBE], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return int(proc.stdout.strip()) -def test_defaults_to_20_when_unset(monkeypatch): - assert _reload_limit(monkeypatch, None) == 20 +def test_defaults_to_20_when_unset(tmp_path): + assert _limit_in_fresh_interpreter(tmp_path, None) == 20 -def test_env_override_raises_the_window(monkeypatch): - assert _reload_limit(monkeypatch, "64") == 64 +def test_env_override_raises_the_window(tmp_path): + assert _limit_in_fresh_interpreter(tmp_path, "64") == 64 -def test_invalid_value_falls_back_to_default(monkeypatch): - assert _reload_limit(monkeypatch, "bogus") == 20 +@pytest.mark.parametrize("value", ["bogus", "0", "-5"]) +def test_invalid_or_nonpositive_falls_back_to_default(tmp_path, value): + assert _limit_in_fresh_interpreter(tmp_path, value) == 20 -def test_zero_and_negative_fall_back_to_default(monkeypatch): - assert _reload_limit(monkeypatch, "0") == 20 - assert _reload_limit(monkeypatch, "-5") == 20 +def test_setting_is_documented(): + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8") + assert "| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` |" in readme + assert "# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20" in env_example From a26bc45f3c946449c0de96c02b6e44237b769d9a Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 22 Sep 2026 17:03:15 +0800 Subject: [PATCH 3/8] fix(session-stream): stop the hidden-tab poll on 404/410 so deleted sessions don't loop forever (#7299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path When a tab is hidden, Hermes WebUI replaces the persistent per-session SSE connection with a lightweight ``GET /api/session/status`` poll every six seconds. If the tracked session has been deleted or no longer exists in the active state directory, the endpoint returns ``404`` — but the client previously converted every non-2xx response to ``null`` and kept the interval alive forever. This produces an invisible stream of permanent 404 requests from stale background tabs (one tab generates ~10 requests per minute when browser timer throttling allows it; multiple stale tabs multiply the noise). The fix narrows the stop condition to the truly terminal responses (``404 Not Found`` and ``410 Gone``) and calls ``_stopHiddenActiveStreamPoll()`` so the interval and the bound ``_sessionStreamHiddenPollSid`` are cleared together. Transient failures (5xx, rate-limit, network error) keep polling because they do not necessarily mean the session is gone; a later tick may catch a server-initiated turn starting while the tab remains hidden. ## What Changed ``static/messages.js`` — ``_startHiddenActiveStreamPoll()``'s fetch chain now branches on ``r.status`` before the ``r.ok ? r.json() : null`` fallback: ```js fetch(...) .then(r => { // #7299: 404 Not Found / 410 Gone are TERMINAL for this // session-owned poll. ... only the missing // session itself is terminal. if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) { _stopHiddenActiveStreamPoll(); return null; } return r.ok ? r.json() : null; }) .then(d => { ... }); ``` The pre-existing ``r.ok ? r.json() : null`` fallback is preserved for any non-2xx, non-terminal response, so a 5xx / rate-limit / network error still produces ``null`` and the next tick re-fires. A regression that broadened the stop path to all non-2xx would re-introduce the original bug in the opposite direction (the poll would die on a single 503 and never recover). ## Why It Matters A stale background tab with a deleted session could hold an interval running for hours, firing a 404 every six seconds. The user sees nothing because the tab is hidden; the server sees inbound requests that are guaranteed to fail; the request log fills with noise that an operator has to triage. The fix is mechanical — the server already returns the right status code, the client just needs to honor it. ## Verification - ``tests/test_issue7299_hidden_poll_stops_on_404.py`` (new, 5 tests): - **404 stops the poll** — source-shape check on the response handler; a silent reversion to the pre-fix "convert all non-2xx to null and keep polling" form fails the suite. - **410 stops the poll** — same shape, separate code branch so a future refactor cannot satisfy the 404 check with a single OR expression. - **bound sid guards the stop** — a stale fetch's response landing after a session switch must not tear down the *new* session's poll. - **5xx / rate-limit / network errors keep polling** — the ``r.ok ? r.json() : null`` fallback must remain. - **6000ms interval + immediate-fire preserved** — the fix is in the response handling, not the cadence. - ``tests/test_hidden_tab_server_initiated_turn.py`` — pre-existing tests for the hidden-tab poll (8 tests). The 4 that use ``MESSAGES_JS[start:start + N]`` slicing had their window widened (2400/3200 → 8000) to cover the new 404/410 branch. - 33 tests pass across the related session/stream/hidden files (``test_hidden_tab_server_initiated_turn``, ``test_issue7299_hidden_poll_stops_on_404``, ``test_issue3718_live_models_custom_probe``, ``test_issue6419_pending_user_reconnect_order``, ``test_session_search_bfcache_822``) — no pre-existing test is broken. ## Contract Change None. The server already returned the right status code (``404`` for a missing session); the fix is purely about the client honoring it. No new public API, no breaking change to the SSE lifecycle, no change to the visible-tab path. ## Risks - A regression that broadens the stop path to all non-2xx would re-introduce the original bug in the opposite direction (the poll would die on a single 503 and never recover). The new ``test_hidden_poll_preserves_transient_failure_retry`` pins this contract. - The 404/410 stop path checks the bound ``_sessionStreamHiddenPollSid === sid`` before stopping, so a stale fetch's response landing after a session switch cannot tear down the *new* session's poll. The new ``test_hidden_poll_404_stop_guards_on_bound_sid`` pins this contract. ## Related - #7299 — original issue with the wire evidence and the expected-vs-actual contract. --- static/messages.js | 17 +- .../test_hidden_tab_server_initiated_turn.py | 6 +- ...test_issue7299_hidden_poll_stops_on_404.py | 152 ++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue7299_hidden_poll_stops_on_404.py diff --git a/static/messages.js b/static/messages.js index e7bcb73c..979d5f9d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8128,7 +8128,22 @@ function _startHiddenActiveStreamPoll(sid) { if (S.activeStreamId) return; // already rendering; wait it out try { fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'}) - .then(r => r.ok ? r.json() : null) + .then(r => { + // #7299: 404 Not Found / 410 Gone are TERMINAL for this + // session-owned poll. The session has been deleted or no + // longer exists in the active state directory, so further + // polls are guaranteed to fail. Stop the poll immediately + // to avoid the infinite 404 loop on stale background tabs + // (one tab could fire ~10 such requests per minute; multiple + // tabs multiply the noise). Transient failures (5xx, rate + // limit, network error) keep polling — only the missing + // session itself is terminal. + if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) { + _stopHiddenActiveStreamPoll(); + return null; + } + return r.ok ? r.json() : null; + }) .then(d => { if (!d || _sessionStreamHiddenPollSid !== sid) return; const streamId = d.active_stream_id; diff --git a/tests/test_hidden_tab_server_initiated_turn.py b/tests/test_hidden_tab_server_initiated_turn.py index cad1e959..ceab3ccc 100644 --- a/tests/test_hidden_tab_server_initiated_turn.py +++ b/tests/test_hidden_tab_server_initiated_turn.py @@ -89,7 +89,9 @@ def test_hidden_poll_hits_session_status_and_attaches_as_replay(): """ start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") assert start != -1 - body = MESSAGES_JS[start:start + 2400] + # #7299 added a 404/410 terminal-stop branch to the response handler. + # The window must cover the original fetch chain + the new branch. + body = MESSAGES_JS[start:start + 8000] assert "api/session/status?session_id=" in body assert "d.active_stream_id" in body # attaches as replay (recovered=true) — turn is already mid-flight @@ -174,7 +176,7 @@ def test_poll_stops_only_when_attach_succeeds(): assert start != -1, "_startHiddenActiveStreamPoll signature not found" # Window 2400: the multi-pane follow-up adds an explanatory comment block # before the attach call, pushing it past a narrower slice. - body = MESSAGES_JS[start:start + 3200] + body = MESSAGES_JS[start:start + 8000] assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body # Stop the poll only on a true attach (the false branch keeps polling within # the bounded-retry budget rather than stopping). diff --git a/tests/test_issue7299_hidden_poll_stops_on_404.py b/tests/test_issue7299_hidden_poll_stops_on_404.py new file mode 100644 index 00000000..96ea376c --- /dev/null +++ b/tests/test_issue7299_hidden_poll_stops_on_404.py @@ -0,0 +1,152 @@ +"""Regression tests for issue #7299 — hidden-tab active-stream poll +keeps retrying deleted sessions forever, generating an invisible stream +of permanent 404 requests from stale background tabs. + +When a tab is hidden, Hermes WebUI replaces the persistent per-session +SSE connection with a lightweight ``GET /api/session/status`` poll +every six seconds. If the tracked session has been deleted or no +longer exists in the active state directory, the endpoint returns +``404``, but the client previously converted every non-2xx response to +``null`` and kept the interval alive forever — one tab generates up to +ten requests per minute whenever browser timer throttling allows it. + +The fix: in ``_startHiddenActiveStreamPoll``'s fetch chain, treat +``404 Not Found`` and ``410 Gone`` as **terminal** for the +session-owned poll — call ``_stopHiddenActiveStreamPoll()`` so the +interval and the bound ``_sessionStreamHiddenPollSid`` are cleared +together. Transient failures (5xx, rate-limit, network error) keep +polling because they do not necessarily mean the session is gone; a +later tick may catch a server-initiated turn starting while the tab +remains hidden. +""" +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Source-shape: the fetch chain in ``_startHiddenActiveStreamPoll`` must +# gate the terminal 404/410 path through ``_stopHiddenActiveStreamPoll`` +# and the bound sid check. A silent reversion to the pre-fix +# "convert all non-2xx to null and keep polling" form fails the suite. +# --------------------------------------------------------------------------- + + +def test_hidden_poll_stops_on_404_response() -> None: + """The fetch chain must call ``_stopHiddenActiveStreamPoll`` on a 404. + + A 404 from ``/api/session/status`` means the session has been + deleted or no longer exists in the active state directory. The + pre-fix code converted every non-2xx to ``null`` and never + stopped the interval — so the same 404 fired every six seconds + for the lifetime of the hidden tab. Pin the contract that a 404 + now tears down the poll. + """ + # The check is anchored on the hidden-poll function so a future + # refactor cannot satisfy it with an unrelated status-404 + # handler in a different code path. + start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") + assert start != -1 + # 3.6 KB is enough to cover the fetch → first .then → second .then + # block, even after the #7299 fix added the 404/410 terminal + # branch. The hidden poll is one of the larger functions in the + # file, so we take a wide window to be resilient to a small + # reformat. + body = MESSAGES_JS[start:start + 8000] + assert "r.status === 404" in body, ( + "the hidden poll must check r.status === 404 in the fetch " + "response so a deleted session no longer triggers an " + "infinite 404 loop on a stale background tab" + ) + assert "_stopHiddenActiveStreamPoll" in body, ( + "the hidden poll must call _stopHiddenActiveStreamPoll when " + "the response is terminal (404/410) so the interval is " + "cleared instead of left running" + ) + + +def test_hidden_poll_stops_on_410_response() -> None: + """The fetch chain must also call ``_stopHiddenActiveStreamPoll`` on 410. + + Some session-state endpoints use ``410 Gone`` (rather than + ``404 Not Found``) for "this session id is permanently gone". + Both are terminal for the hidden poll — the pre-fix code treated + them identically, so the fix must handle both. + """ + start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") + body = MESSAGES_JS[start:start + 8000] + assert "r.status === 410" in body, ( + "the hidden poll must check r.status === 410 in the fetch " + "response so a 'gone' session also tears down the poll, " + "matching the 404 contract" + ) + + +def test_hidden_poll_404_stop_guards_on_bound_sid() -> None: + """The 404/410 stop path must check the bound ``_sessionStreamHiddenPollSid``. + + The same fetch chain is invoked on every tick of a 6-second + interval. A race can let a stale fetch's response land after + the user switched sessions — in that case ``_sessionStreamHiddenPollSid`` + no longer matches the fetched ``sid``, and the stop must NOT + tear down the *new* session's poll. Pin the contract. + """ + start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") + body = MESSAGES_JS[start:start + 8000] + # The 404/410 branch must check the bound sid before stopping + # — pinned as a string match because the JS module is large + # and we want a regression to show up at a glance in CI. + assert "_sessionStreamHiddenPollSid === sid" in body, ( + "the 404/410 stop path must guard on " + "_sessionStreamHiddenPollSid === sid so a stale fetch's " + "response cannot tear down a different session's poll" + ) + + +def test_hidden_poll_preserves_transient_failure_retry() -> None: + """5xx / rate-limit / network errors MUST keep polling (not stop). + + The fix only narrows the stop path to terminal 404/410. Transient + failures (5xx, 429, network error) still produce ``null`` and + the next tick re-fires. A regression that broadens the stop + path to all non-2xx would re-introduce the original bug in + the opposite direction — the poll would die on a single + 503 and never recover. + """ + start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") + body = MESSAGES_JS[start:start + 8000] + # The pre-fix ``r.ok ? r.json() : null`` fallback must still be + # present for any non-ok / non-terminal response. + assert "r.ok ? r.json() : null" in body, ( + "the 5xx / rate-limit / network-error path must still " + "produce null and let the next tick re-fire; only 404/410 " + "should tear down the poll" + ) + + +def test_hidden_poll_starts_interval_unchanged() -> None: + """The interval is unchanged (still 6000 ms). + + The fix is purely about the response handling, not the + cadence. A regression that doubles the interval (or removes + the immediate-fire) breaks the original contract — a turn + already running when the tab goes hidden must be caught + without waiting a full interval. + """ + start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)") + body = MESSAGES_JS[start:start + 8000] + assert "setInterval(tick, 6000)" in body, ( + "the hidden poll must keep the 6000ms interval; the fix is " + "in the response handling, not the cadence" + ) + # The immediate-fire call (no full interval wait) must also + # still be present so a turn already running when the tab goes + # hidden is caught without waiting a full interval. + assert "tick();" in body, ( + "the immediate-fire tick() call must be preserved so a " + "turn already running when the tab goes hidden is caught " + "without waiting a full interval" + ) From 5c24458dffc23ddebf0c2caec25e9618d9cc7386 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Tue, 22 Sep 2026 17:50:56 +0000 Subject: [PATCH 4/8] fix(sidebar): derive the route session cap from the shared limit The env override never reached /api/sessions because the route kept a separate hard-coded cap of 20. Resolve the window in api.config before profile init, clamp it at 200, and refuse a profile .env override. --- .env.example | 2 + README.md | 2 +- api/config.py | 15 +++++++ api/models.py | 6 +-- api/profiles.py | 7 +++- api/routes.py | 13 ++++-- tests/test_visible_session_limit_env.py | 54 +++++++++++++++++++++++-- 7 files changed, 86 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 2682308f..a96b87f5 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,8 @@ # Sidebar recency window: how many recent sessions are listed (default: 20). # Also caps how many delegated subagent children can nest at once; raise it # for wide fan-outs. Invalid or non-positive values fall back to 20. +# Values above 200 are clamped. Resolved before profile init, so a profile +# .env cannot override this server-wide bound. # HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20 # Directory scanned for WebUI plugins (default: ~/.hermes/plugins). diff --git a/README.md b/README.md index 3edf0f7b..76429aea 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ Full list of environment variables: | `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | -| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default | +| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile `.env` cannot override it | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | | `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access | diff --git a/api/config.py b/api/config.py index 979ce3f7..dc933c86 100644 --- a/api/config.py +++ b/api/config.py @@ -68,6 +68,21 @@ def _env_int(name: str, default: int, *, minimum: int = 1) -> int: return default return value if value >= minimum else default + +def _env_int_clamped(name: str, default: int, *, minimum: int = 1, maximum: int) -> int: + """Like ``_env_int``, then clamp a valid override to ``maximum``.""" + value = _env_int(name, default, minimum=minimum) + if not str(os.getenv(name) or "").strip(): + return value + return min(value, maximum) + + +# Sidebar recency window. Resolved here, before profile init, so a profile +# .env cannot override a server-wide resource bound. Clamped at 200. +CLI_VISIBLE_SESSION_LIMIT = _env_int_clamped( + "HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20, maximum=200, +) + # ── TLS/HTTPS config (optional, env-overridable) ──────────────────────────── TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None diff --git a/api/models.py b/api/models.py index 99226cb4..5330947d 100644 --- a/api/models.py +++ b/api/models.py @@ -50,9 +50,9 @@ from api.process_event_utils import stamp_message_source logger = logging.getLogger(__name__) # Size of the interactive sidebar recency window. Also bounds how many # delegated subagent children can be rendered at once, since a child only -# nests when it wins a slot in this window. Override with -# HERMES_WEBUI_VISIBLE_SESSION_LIMIT. -CLI_VISIBLE_SESSION_LIMIT = _cfg._env_int("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20) +# nests when it wins a slot in this window. Resolved in api.config before +# profile init; override with HERMES_WEBUI_VISIBLE_SESSION_LIMIT (clamped 1–200). +CLI_VISIBLE_SESSION_LIMIT = _cfg.CLI_VISIBLE_SESSION_LIMIT # How many messageful cron sessions to surface in the project-chip layer. # Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay # addressable even when many newer non-cron sessions dominate the default diff --git a/api/profiles.py b/api/profiles.py index 78f18735..58ff0143 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -198,7 +198,11 @@ def _unwrap_profile_home_to_base(home: Path) -> Path: # are operator/deployment-level postures, not per-profile toggles. Letting a # profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user # escape isolation (#4589). -_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'}) +_PROTECTED_ENV_KEYS = frozenset({ + 'HERMES_WEBUI_ISOLATED_PROFILE', + # Server-wide sidebar window; a profile .env must not widen the per-request query. + 'HERMES_WEBUI_VISIBLE_SESSION_LIMIT', +}) def _isolated_profile_opt_in() -> bool: @@ -934,6 +938,7 @@ _BLOCKED_RUNTIME_ENV_KEYS = { # #4589: operator/deployment isolation posture — never overridable by a # profile's own env on any runtime/gateway-parity path. 'HERMES_WEBUI_ISOLATED_PROFILE', + 'HERMES_WEBUI_VISIBLE_SESSION_LIMIT', } diff --git a/api/routes.py b/api/routes.py index 0e159784..3fd48b0e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2521,8 +2521,8 @@ def _build_session_list_cache_payload( ) if show_cli_sessions: diag_stage("cli_cap") - archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP) - visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP) + archived_scoped = _cap_recent_cli_sessions(archived_scoped) + visible_scoped = _cap_recent_cli_sessions(visible_scoped) if visible_only: archived_scoped = [ s for s in archived_scoped if _session_has_server_visible_messages(s) @@ -10278,11 +10278,16 @@ def _dedupe_cli_sidebar_sessions_for_api( return _include_project_hidden_background_sidebar_sessions(candidates, visible) -CLI_VISIBLE_SESSION_CAP = 20 +def _cli_visible_session_cap() -> int: + """Shared sidebar window, not a second hard-coded 20.""" + from api.config import CLI_VISIBLE_SESSION_LIMIT + return CLI_VISIBLE_SESSION_LIMIT -def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]: +def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) -> list[dict]: """Keep only the most recent CLI-visible sessions after filtering.""" + if cli_cap is None: + cli_cap = _cli_visible_session_cap() if cli_cap <= 0: return sessions kept = [] diff --git a/tests/test_visible_session_limit_env.py b/tests/test_visible_session_limit_env.py index e1fc0f32..e2e6e1ab 100644 --- a/tests/test_visible_session_limit_env.py +++ b/tests/test_visible_session_limit_env.py @@ -9,6 +9,10 @@ in a fresh subprocess (same pattern as test_issue3283_profiles_config_import_ord rather than ``importlib.reload``-ing it in the shared test process — reloading would recreate the module's locks, caches, and classes while other modules keep references to the old objects. + +The route cap must come from that same value. A profile ``.env`` must not +override it: the constant is resolved in ``api.config`` before profile init, +and the key is in ``api.profiles._PROTECTED_ENV_KEYS``. """ import os @@ -22,11 +26,28 @@ REPO_ROOT = Path(__file__).resolve().parents[1] _PROBE = """ import api.models +import api.routes print(api.models.CLI_VISIBLE_SESSION_LIMIT) +print(api.routes._cli_visible_session_cap()) +rows = [{"source": "cli", "title": "cli", "updated_at": i} for i in range(30)] +print(len(api.routes._cap_recent_cli_sessions(rows))) +""" + +_PROFILE_PROBE = """ +import os +from pathlib import Path +import api.config +import api.profiles +home = Path(os.environ["HERMES_HOME"]) +(home / ".env").write_text("HERMES_WEBUI_VISIBLE_SESSION_LIMIT=7\\n", encoding="utf-8") +api.profiles._reload_dotenv(home) +print("PROTECTED", "HERMES_WEBUI_VISIBLE_SESSION_LIMIT" in api.profiles._PROTECTED_ENV_KEYS) +print("ENV", os.environ.get("HERMES_WEBUI_VISIBLE_SESSION_LIMIT")) +print("LIMIT", api.config.CLI_VISIBLE_SESSION_LIMIT) """ -def _limit_in_fresh_interpreter(tmp_path, value): +def _run_probe(tmp_path, probe, value): home = tmp_path / "home" home.mkdir() env = os.environ.copy() @@ -34,21 +55,26 @@ def _limit_in_fresh_interpreter(tmp_path, value): if key.startswith("HERMES_WEBUI_") or key in ("HERMES_HOME", "HERMES_BASE_HOME"): env.pop(key) env["HOME"] = str(home) + env["HERMES_HOME"] = str(home) env["PYTHONPATH"] = str(REPO_ROOT) if value is not None: env["HERMES_WEBUI_VISIBLE_SESSION_LIMIT"] = value proc = subprocess.run( - [sys.executable, "-c", _PROBE], + [sys.executable, "-c", probe], cwd=REPO_ROOT, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=60, + timeout=90, check=False, ) assert proc.returncode == 0, proc.stderr - return int(proc.stdout.strip()) + return proc.stdout.strip().splitlines() + + +def _limit_in_fresh_interpreter(tmp_path, value): + return int(_run_probe(tmp_path, _PROBE, value)[0]) def test_defaults_to_20_when_unset(tmp_path): @@ -64,8 +90,28 @@ def test_invalid_or_nonpositive_falls_back_to_default(tmp_path, value): assert _limit_in_fresh_interpreter(tmp_path, value) == 20 +def test_values_above_200_are_clamped(tmp_path): + assert _limit_in_fresh_interpreter(tmp_path, "500") == 200 + + +def test_route_cap_follows_the_shared_value(tmp_path): + lines = _run_probe(tmp_path, _PROBE, "8") + assert lines[0] == "8" + assert lines[1] == "8" + assert lines[2] == "8" + + +def test_profile_env_cannot_override_visible_session_limit(tmp_path): + lines = _run_probe(tmp_path, _PROFILE_PROBE, "40") + assert lines[0] == "PROTECTED True" + assert lines[1] == "ENV 40" + assert lines[2] == "LIMIT 40" + + def test_setting_is_documented(): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8") assert "| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` |" in readme assert "# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20" in env_example + assert "200" in readme + assert "clamped" in env_example From d5349a169d596a80ffbc6675bbd97dd2c389e057 Mon Sep 17 00:00:00 2001 From: Muhammad Usama Date: Tue, 22 Sep 2026 23:05:22 +0500 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20resolve=20#7520=20=E2=80=94=20remap?= =?UTF-8?q?=20dead=20zai/glm-4.5-flash=20to=20z-ai/glm-4.5-air=20in=20Open?= =?UTF-8?q?Router=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/onboarding.py | 4 +++- tests/test_onboarding_mvp.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/api/onboarding.py b/api/onboarding.py index f09651c4..d2be6d50 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -71,7 +71,9 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "anthropic/claude-sonnet-4.6", "requires_base_url": False, "models": [ - {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]} + {"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air (free)"} + if model["id"] == "zai/glm-4.5-flash" + else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]} for model in _FALLBACK_MODELS ], "category": "easy_start", diff --git a/tests/test_onboarding_mvp.py b/tests/test_onboarding_mvp.py index 664fe220..f15a9663 100644 --- a/tests/test_onboarding_mvp.py +++ b/tests/test_onboarding_mvp.py @@ -249,3 +249,37 @@ def test_onboarding_setup_rejects_api_key_with_newline(): ) assert status == 400 assert "newline" in data["error"].lower() + + +# ── Regression: #7520 ──────────────────────────────────────────────────────── + +def test_openrouter_wizard_no_dead_zai_ids(): + """OpenRouter wizard must not offer zai/glm-4.5-flash — OpenRouter does not + serve it. The free-tier equivalent is z-ai/glm-4.5-air. (#7520)""" + from api.onboarding import _SUPPORTED_PROVIDER_SETUPS + + or_setup = _SUPPORTED_PROVIDER_SETUPS["openrouter"] + or_ids = {m["id"] for m in or_setup["models"]} + + # Dead id must not appear in the OpenRouter projection + assert "zai/glm-4.5-flash" not in or_ids, ( + "OpenRouter wizard still offers dead id zai/glm-4.5-flash" + ) + # The remapped replacement must be present + assert "z-ai/glm-4.5-air" in or_ids, ( + "OpenRouter wizard missing remapped z-ai/glm-4.5-air" + ) + + +def test_zai_native_still_has_glm_4_5_flash(): + """The native Z.AI onboarding setup must still offer glm-4.5-flash — + the remapping is OpenRouter-only.""" + from api.onboarding import _SUPPORTED_PROVIDER_SETUPS + + zai_setup = _SUPPORTED_PROVIDER_SETUPS["zai"] + zai_ids = {m["id"] for m in zai_setup["models"]} + + assert "glm-4.5-flash" in zai_ids, ( + "Native Z.AI setup lost glm-4.5-flash — the remapping must be " + "OpenRouter-only, not touching _FALLBACK_MODELS" + ) From e3c21a386c96551534f782abbdfd60bc82d9d206 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 23:56:38 +0000 Subject: [PATCH 6/8] fix(#7520): drop the '(free)' label from z-ai/glm-4.5-air and make the regression test non-vacuous OpenRouter bills z-ai/glm-4.5-air as a paid model (0.13/0.85 per 1M), so the wizard must not label it free. Since #7514 the projection offers the dead id as z-ai/glm-4.5-flash, not zai/glm-4.5-flash, so the original assertion passed on master; assert the z-ai spelling (fails pre-fix, passes post-fix). --- api/onboarding.py | 2 +- tests/test_onboarding_mvp.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/api/onboarding.py b/api/onboarding.py index d2be6d50..44ecabfe 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -71,7 +71,7 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "anthropic/claude-sonnet-4.6", "requires_base_url": False, "models": [ - {"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air (free)"} + {"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air"} if model["id"] == "zai/glm-4.5-flash" else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]} for model in _FALLBACK_MODELS diff --git a/tests/test_onboarding_mvp.py b/tests/test_onboarding_mvp.py index f15a9663..983e8f22 100644 --- a/tests/test_onboarding_mvp.py +++ b/tests/test_onboarding_mvp.py @@ -261,14 +261,19 @@ def test_openrouter_wizard_no_dead_zai_ids(): or_setup = _SUPPORTED_PROVIDER_SETUPS["openrouter"] or_ids = {m["id"] for m in or_setup["models"]} - # Dead id must not appear in the OpenRouter projection - assert "zai/glm-4.5-flash" not in or_ids, ( - "OpenRouter wizard still offers dead id zai/glm-4.5-flash" - ) - # The remapped replacement must be present - assert "z-ai/glm-4.5-air" in or_ids, ( - "OpenRouter wizard missing remapped z-ai/glm-4.5-air" + # Dead id must not appear in the OpenRouter projection. Since #7514 the + # projection maps zai/ -> z-ai/, so the id actually offered (and dead on + # OpenRouter) is z-ai/glm-4.5-flash; check both spellings so this test + # fails on the pre-fix code rather than passing vacuously. + assert "z-ai/glm-4.5-flash" not in or_ids, ( + "OpenRouter wizard still offers dead id z-ai/glm-4.5-flash" ) + assert "zai/glm-4.5-flash" not in or_ids + # The remapped replacement must be present, and must not be labelled + # "free": OpenRouter bills z-ai/glm-4.5-air as a paid model. + air = [m for m in or_setup["models"] if m["id"] == "z-ai/glm-4.5-air"] + assert air, "OpenRouter wizard missing remapped z-ai/glm-4.5-air" + assert "free" not in air[0]["label"].lower() def test_zai_native_still_has_glm_4_5_flash(): From 090728c41dd9c0d475b1db01ece2823fb09baebb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 23 Sep 2026 00:13:11 +0000 Subject: [PATCH 7/8] test(#7520): teach the #7514 projection tests the one glm-4.5-flash remap slot The #7514 tests pinned a pure namespace projection (same ids modulo zai/ -> z-ai/, same labels). #7520 intentionally serves z-ai/glm-4.5-air in the glm-4.5-flash slot because OpenRouter does not carry glm-4.5-flash. Encode that single explicit remap in the reference translation; keep order, length and every other label pinned. --- ...test_issue7514_openrouter_zai_namespace.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/test_issue7514_openrouter_zai_namespace.py b/tests/test_issue7514_openrouter_zai_namespace.py index 837ece43..91965c95 100644 --- a/tests/test_issue7514_openrouter_zai_namespace.py +++ b/tests/test_issue7514_openrouter_zai_namespace.py @@ -34,12 +34,24 @@ def _ids(provider_id: str) -> list[str]: def _expected_openrouter_id(model_id: str) -> str: - """Independent reference implementation of the namespace translation.""" + """Independent reference implementation of the namespace translation. + + One Z.AI model has no OpenRouter counterpart: ``glm-4.5-flash`` is not in + OpenRouter's catalog, so #7520 serves Z.AI's nearest light model + ``z-ai/glm-4.5-air`` in its place (same slot, so order is unchanged). + """ + if model_id in _OPENROUTER_REMAPS: + return _OPENROUTER_REMAPS[model_id] if model_id.startswith("zai/"): return "z-ai/" + model_id[len("zai/") :] return model_id +# #7520: fallback ids that OpenRouter does not serve, mapped to the id the +# wizard offers instead. Keep this list tiny and explicit. +_OPENROUTER_REMAPS = {"zai/glm-4.5-flash": "z-ai/glm-4.5-air"} + + def test_openrouter_setup_never_serves_direct_zai_namespace(): served = [model_id for model_id in _ids("openrouter") if model_id.startswith("zai/")] assert served == [], ( @@ -66,14 +78,19 @@ def test_openrouter_setup_exposes_z_ai_models(): def test_openrouter_projection_is_namespace_only(): - """The OpenRouter list stays a 1:1, order-preserving projection.""" + """The OpenRouter list stays a 1:1, order-preserving projection. + + Labels are preserved for every entry except the #7520 remap slots, whose + label names the model actually served. + """ fallback = config._FALLBACK_MODELS openrouter = _models("openrouter") assert len(openrouter) == len(fallback) for source, projected in zip(fallback, openrouter, strict=True): - assert projected["label"] == source["label"] assert projected["id"] == _expected_openrouter_id(source["id"]) + if source["id"] not in _OPENROUTER_REMAPS: + assert projected["label"] == source["label"] def test_direct_zai_setup_keeps_direct_provider_ids(): From 34feb50c5b4d260efbdf5e09a09b5740bd4435dd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 23 Sep 2026 00:44:29 +0000 Subject: [PATCH 8/8] docs(changelog): #7631 #7716 #7734 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 178ce85f..8962c4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +### Added + +- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets + how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent + children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to + 20, values above 200 are clamped, and it is resolved before profile init so a profile `.env` cannot + override it. (#7631 by @carlotestor) + ### Performance - **Opening a session while its task is still running is much faster.** Rebuilding the live @@ -16,6 +24,15 @@ ### Fixed +- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the + background, its stream poll kept asking for a session every few seconds after it was deleted or + archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network + errors keep retrying, a late `404` for one session cannot stop another session's poll, and making + the tab visible again reopens the live stream. (#7299, #7716 by @happy5318) +- **The OpenRouter setup no longer offers a model OpenRouter doesn't serve.** Onboarding offered + `z-ai/glm-4.5-flash`, which is not in OpenRouter's catalog, so picking it failed on the first + message. That slot now offers `z-ai/glm-4.5-air`, Z.AI's nearest light model. The direct Z.AI + setup still offers GLM-4.5 Flash. (#7520, #7734 by @MuhammadUsamaMX) - **Opening the sidebar can no longer stall the agent's writes to `state.db`.** Several WebUI paths that only read the agent's `state.db` could quietly become writers. The read-only opener fell back to a writable connection when `mode=ro` failed. The session listing self-healed a