mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-14 19:50:17 +00:00
3ca1188f64
## Release v0.51.256 — Release HX (stage-r4) Performance — bound WebUI memory growth & idle CPU on large installs. ### Fixed | Issue | Author | Fix | |-------|--------|-----| | #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. | ### Rebase + review notes - Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved). - The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests: 1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project. 2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent. ### Gate - Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run) - ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP** Co-authored-by: nesquena <nesquena@users.noreply.github.com>
361 lines
16 KiB
Python
361 lines
16 KiB
Python
"""
|
|
Hermes Web UI -- Gateway session watcher.
|
|
|
|
Background daemon thread that polls state.db every 5 seconds for changes
|
|
to gateway sessions (telegram, discord, slack, etc.). When changes are
|
|
detected, it pushes notifications to all subscribed SSE clients.
|
|
|
|
This enables real-time session list updates in the sidebar without
|
|
requiring any changes to hermes-agent.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
|
|
from api.config import HOME
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── State hash tracking ─────────────────────────────────────────────────────
|
|
|
|
def _snapshot_hash(sessions: list) -> str:
|
|
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
|
key = '|'.join(
|
|
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
|
for s in sorted(sessions, key=lambda x: x['session_id'])
|
|
)
|
|
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
|
|
|
|
|
|
# Sources excluded from the WebUI sidebar projection. Must match the default
|
|
# ``exclude_sources`` used by ``read_importable_agent_session_rows`` so the
|
|
# cheap change-detection scan below sees exactly the same row set as the
|
|
# expensive projection (otherwise cron message churn would defeat the gate).
|
|
_WATCHER_EXCLUDED_SOURCES = ("cron", "webui")
|
|
|
|
|
|
def _cheap_change_fingerprint(db_path: Path) -> str | None:
|
|
"""Compute a cheap change-detection fingerprint without the messages JOIN.
|
|
|
|
The expensive projection (``read_importable_agent_session_rows``) runs a CTE
|
|
plus a per-session ``MAX(messages.timestamp)`` aggregation over an oversampled
|
|
candidate set every poll. On a large ``state.db`` (hundreds of sessions, tens
|
|
of thousands of messages) that is ~10x the cost of a single ``sessions``-table
|
|
scan, and the watcher runs it forever on a 5s timer even when nothing changed
|
|
(issue #3506).
|
|
|
|
This computes a fingerprint from a ``sessions``-table-only scan (no messages
|
|
JOIN), scoped to the same non-cron/webui rows as the projection. To guarantee
|
|
it never skips a change the projection would reflect, it hashes **every
|
|
sessions-table column the projection reads or uses for visibility/collapse**
|
|
-- not just the columns surfaced to the sidebar. That matters because the
|
|
projection collapses compression lineage and hides/shows rows based on
|
|
``parent_session_id`` / ``ended_at`` / ``end_reason`` / ``source``, so a change
|
|
to one of those alters *which rows* appear even when no displayed field on a
|
|
given row moved.
|
|
|
|
The one projection input that does not live in the ``sessions`` table is the
|
|
per-session message aggregate (``COUNT`` / ``MAX(messages.timestamp)`` ->
|
|
``last_activity``). That is fully proxied by ``sessions.message_count``: the
|
|
agent's state layer bumps ``message_count`` on every appended message and
|
|
rewrites it to the absolute count on truncate/rewind/compaction, so a message
|
|
insert or delete (the only events that can move ``MAX(timestamp)``) always
|
|
changes ``message_count``. The fingerprint is therefore a strict superset of
|
|
the projection's change surface (it also fires on out-of-order inserts that
|
|
would not raise ``MAX(timestamp)``).
|
|
|
|
Returns the fingerprint string, or ``None`` on any error / a pre-source
|
|
schema so the caller falls back to running the expensive projection rather
|
|
than risk skipping a change.
|
|
"""
|
|
# Columns the projection reads from the ``sessions`` table. ``id``/``source``
|
|
# are always present (``source`` is required for the projection to run at
|
|
# all); the rest are optional on older agent schemas and filtered below.
|
|
_PROJECTION_SESSION_COLS = (
|
|
'id', 'source', 'session_source', 'title', 'model', 'message_count',
|
|
'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived',
|
|
'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key',
|
|
'origin_chat_id', 'origin_user_id', 'platform',
|
|
)
|
|
try:
|
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("PRAGMA table_info(sessions)")
|
|
cols = {row[1] for row in cur.fetchall()}
|
|
if 'source' not in cols:
|
|
return None
|
|
selectable = [c for c in _PROJECTION_SESSION_COLS if c in cols]
|
|
placeholders = ", ".join("?" for _ in _WATCHER_EXCLUDED_SOURCES)
|
|
cur.execute(
|
|
f"SELECT {', '.join(selectable)} FROM sessions "
|
|
f"WHERE source IS NOT NULL AND source NOT IN ({placeholders}) "
|
|
f"ORDER BY id",
|
|
list(_WATCHER_EXCLUDED_SOURCES),
|
|
)
|
|
h = hashlib.md5(usedforsecurity=False)
|
|
for row in cur.fetchall():
|
|
h.update(repr(row).encode('utf-8', 'replace'))
|
|
h.update(b'\x1e')
|
|
# A same-count transcript rewrite (SessionDB.replace_messages used by
|
|
# /retry, /undo, /compress) deletes + reinserts messages with new
|
|
# timestamps but can leave sessions.message_count unchanged — so the
|
|
# sessions-only scan above would miss it and the watcher would skip a
|
|
# projection whose last_activity (MAX(messages.timestamp)) actually
|
|
# moved. Fold in a PER-SESSION message aggregate, scoped to the same
|
|
# non-excluded sessions as the projection. It must be per-session
|
|
# (grouped), NOT a single global MAX: rewriting an OLDER, non-newest
|
|
# session moves that session's last_activity but not the global max,
|
|
# so a global aggregate would still miss it (#3536 review round 2).
|
|
# cron/webui churn is excluded by the JOIN filter so it still does
|
|
# NOT trigger a re-projection. This is one GROUP BY over the already-
|
|
# filtered set — far cheaper than the projection's oversampled
|
|
# correlated CTE — so it preserves the cheap-fingerprint property.
|
|
if 'messages' in {r[0] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}:
|
|
try:
|
|
msg_rows = conn.execute(
|
|
"SELECT s.id, COUNT(m.id), "
|
|
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END), "
|
|
"COALESCE(MAX(m.timestamp), 0) "
|
|
"FROM sessions s LEFT JOIN messages m ON m.session_id = s.id "
|
|
f"WHERE s.source IS NOT NULL AND s.source NOT IN ({placeholders}) "
|
|
"GROUP BY s.id ORDER BY s.id",
|
|
list(_WATCHER_EXCLUDED_SOURCES),
|
|
).fetchall()
|
|
for mrow in msg_rows:
|
|
h.update(repr(mrow).encode('utf-8', 'replace'))
|
|
h.update(b'\x1e')
|
|
except sqlite3.Error:
|
|
# messages table shape unknown → don't trust the fingerprint;
|
|
# signal the caller to run the full projection.
|
|
return None
|
|
return h.hexdigest()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
|
|
|
|
def _get_state_db_path() -> Path:
|
|
"""Resolve state.db path for the active profile."""
|
|
try:
|
|
from api.profiles import get_active_hermes_home
|
|
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
|
except Exception:
|
|
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
|
return hermes_home / 'state.db'
|
|
|
|
|
|
def _get_agent_sessions_from_db() -> list:
|
|
"""Read all non-webui sessions from state.db.
|
|
Returns list of session dicts, or empty list on any error.
|
|
"""
|
|
db_path = _get_state_db_path()
|
|
if not db_path.exists():
|
|
return []
|
|
|
|
try:
|
|
sessions = []
|
|
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
|
|
sessions.append({
|
|
'session_id': row['id'],
|
|
'title': row['title'] or 'Agent Session',
|
|
'model': row['model'] or None,
|
|
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
|
'created_at': row['started_at'],
|
|
'updated_at': row['last_activity'] or row['started_at'],
|
|
'source': row['source'] or 'cli',
|
|
'raw_source': row.get('raw_source'),
|
|
'session_source': row.get('session_source'),
|
|
'source_label': row.get('source_label'),
|
|
})
|
|
return sessions
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# ── GatewayWatcher ──────────────────────────────────────────────────────────
|
|
|
|
class GatewayWatcher:
|
|
"""Background thread that polls state.db for agent session changes.
|
|
|
|
Usage:
|
|
watcher = GatewayWatcher()
|
|
watcher.start()
|
|
q = watcher.subscribe()
|
|
# ... receive change events via q.get() ...
|
|
watcher.unsubscribe(q)
|
|
watcher.stop()
|
|
"""
|
|
|
|
POLL_INTERVAL = 5 # seconds between polls
|
|
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
|
|
|
|
def __init__(self):
|
|
self._subscribers: list[queue.Queue] = []
|
|
self._sub_lock = threading.Lock()
|
|
self._stop_event = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._last_hash: str = ''
|
|
self._last_sessions: list = []
|
|
# Cheap sessions-only fingerprint from the previous poll. When it is
|
|
# unchanged we skip the expensive messages-JOIN projection entirely
|
|
# (issue #3506). Empty string forces the first poll to run the full read.
|
|
self._last_cheap_fp: str = ''
|
|
|
|
def start(self):
|
|
"""Start the watcher daemon thread."""
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop_event.clear()
|
|
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
|
|
self._thread.start()
|
|
|
|
def is_alive(self) -> bool:
|
|
"""Return True when the poll thread is running.
|
|
|
|
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
|
|
the live SSE handler to detect a watcher instance whose poll thread
|
|
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
|
|
use this to decide whether to return 503 and trigger the client-side
|
|
polling fallback, instead of handing out an SSE connection that would
|
|
never emit events.
|
|
"""
|
|
t = self._thread
|
|
return t is not None and t.is_alive()
|
|
|
|
def stop(self):
|
|
"""Stop the watcher thread."""
|
|
self._stop_event.set()
|
|
# Wake up any subscribers
|
|
with self._sub_lock:
|
|
for q in self._subscribers:
|
|
try:
|
|
q.put(None) # sentinel
|
|
except Exception:
|
|
logger.debug("Failed to send sentinel to subscriber")
|
|
if self._thread:
|
|
self._thread.join(timeout=3)
|
|
self._thread = None
|
|
|
|
def subscribe(self) -> queue.Queue:
|
|
"""Subscribe to change events. Returns a queue.Queue.
|
|
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
|
A None sentinel means the watcher is stopping.
|
|
"""
|
|
q = queue.Queue(maxsize=10)
|
|
with self._sub_lock:
|
|
self._subscribers.append(q)
|
|
return q
|
|
|
|
def unsubscribe(self, q: queue.Queue):
|
|
"""Remove a subscriber queue."""
|
|
with self._sub_lock:
|
|
try:
|
|
self._subscribers.remove(q)
|
|
except ValueError:
|
|
pass
|
|
|
|
def _notify_subscribers(self, sessions: list):
|
|
"""Push change event to all subscribers."""
|
|
event = {
|
|
'type': 'sessions_changed',
|
|
'sessions': sessions,
|
|
}
|
|
with self._sub_lock:
|
|
dead = []
|
|
for q in self._subscribers:
|
|
try:
|
|
q.put_nowait(event)
|
|
except queue.Full:
|
|
dead.append(q) # remove slow consumers
|
|
except Exception:
|
|
dead.append(q)
|
|
for q in dead:
|
|
try:
|
|
self._subscribers.remove(q)
|
|
except ValueError:
|
|
pass
|
|
# Send a None sentinel so the SSE handler unblocks, closes,
|
|
# and lets the browser's EventSource auto-reconnect.
|
|
try:
|
|
q.put_nowait(None)
|
|
except Exception:
|
|
logger.debug("Failed to send sentinel to dead subscriber")
|
|
|
|
def _poll_loop(self):
|
|
"""Main polling loop. Runs in a daemon thread."""
|
|
while not self._stop_event.is_set():
|
|
try:
|
|
# Phase 1: cheap sessions-only fingerprint. The expensive
|
|
# messages-JOIN projection (_get_agent_sessions_from_db) only
|
|
# runs when this fingerprint actually changes, so an idle server
|
|
# with a large state.db stops re-aggregating tens of thousands
|
|
# of message rows every 5 seconds (issue #3506). A None
|
|
# fingerprint (error / unreadable db) forces the full read so we
|
|
# never silently skip a real change.
|
|
db_path = _get_state_db_path()
|
|
cheap_fp = _cheap_change_fingerprint(db_path) if db_path.exists() else ''
|
|
if cheap_fp is not None and cheap_fp == self._last_cheap_fp:
|
|
# Nothing changed in the sidebar-visible session set; skip
|
|
# the expensive projection and the notify entirely.
|
|
pass
|
|
else:
|
|
# Phase 2: only now pay for the full projection.
|
|
sessions = _get_agent_sessions_from_db()
|
|
current_hash = _snapshot_hash(sessions)
|
|
if cheap_fp is not None:
|
|
self._last_cheap_fp = cheap_fp
|
|
|
|
if current_hash != self._last_hash:
|
|
self._last_hash = current_hash
|
|
self._last_sessions = sessions
|
|
self._notify_subscribers(sessions)
|
|
except Exception:
|
|
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
|
|
|
# Sleep in small increments so we can stop promptly
|
|
for _ in range(self.POLL_INTERVAL * 10):
|
|
if self._stop_event.is_set():
|
|
return
|
|
time.sleep(0.1)
|
|
|
|
|
|
# ── Module-level singleton ─────────────────────────────────────────────────
|
|
|
|
_watcher: GatewayWatcher | None = None
|
|
_watcher_lock = threading.Lock()
|
|
|
|
|
|
def start_watcher():
|
|
"""Start the global gateway watcher (idempotent)."""
|
|
global _watcher
|
|
with _watcher_lock:
|
|
if _watcher is None:
|
|
_watcher = GatewayWatcher()
|
|
_watcher.start()
|
|
|
|
|
|
def stop_watcher():
|
|
"""Stop the global gateway watcher."""
|
|
global _watcher
|
|
with _watcher_lock:
|
|
if _watcher is not None:
|
|
_watcher.stop()
|
|
_watcher = None
|
|
|
|
|
|
def get_watcher() -> GatewayWatcher | None:
|
|
"""Get the global watcher instance (or None if not started)."""
|
|
with _watcher_lock:
|
|
return _watcher
|