Files
hermes-agent/tui_gateway/profile_roster_cache.py
teknium1 20fc19be8f fix(tui-gateway): an empty -wal sidecar created by our own read-only open does not count as a store change
The first read-only SessionDB open of a store that never had a WAL file creates a 0-byte state.db-wal; with it in the signature the second poll always missed. An empty WAL has no frames, so it is excluded from the store signature.
2026-09-20 18:45:07 -07:00

103 lines
4.8 KiB
Python

"""Memos for ``profiles.list``'s per-profile fields, each keyed on the file(s) it derives from.
The Bots roster polls ``profiles.list`` every 5s PER CONNECTION, and two groups of fields on every
row are recomputed from disk each time even though nothing has changed:
- the **session fields** (``last_session`` / ``worker_session`` / ``canonical_session``) — a
read-only ``state.db`` open plus a listing query plus the canonical-title lookup, per profile;
- the **ui_meta fields** (``ui_meta`` / ``ui_meta_revisions``) — a second parse of that profile's
``profile.yaml``, which the listing body already parsed once for description/display_name.
Both are pure functions of the files they read, so while those files have not moved there is
nothing to recompute: an idle fleet re-derived identical rows every five seconds for every bot.
Signatures carry each file's ``(mtime_ns, size)`` so a write landing inside one mtime tick still
invalidates; ``profile.yaml`` also carries its inode, because the atomic writers rename a temp file
into place. A profile missing the file a memo keys on is never cached — reading it costs nothing,
and one written later must be picked up.
Only DERIVED values live here. The raw readers keep their uncached contract: ``_read_profile_yaml``
is also used by the ui_meta CAS writer, which reads that document, mutates it and writes it back, and
a stale read there would overwrite a newer file.
This is a module of its own because ``methods_profiles``'s bodies are rebound onto ``server.py``'s
globals (``method_ctx.bind_module``), which COPIES module-level dicts rather than sharing them — a
cache declared there would be written to one copy and read from another.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Callable, Optional
# Each keyed by resolved profile path. One entry per profile the roster has painted; a removed
# profile leaves one stale entry, so a memo is dropped whole once it grows past any plausible fleet.
_SESSION_CACHE: dict[str, tuple[tuple, dict]] = {}
_UI_META_CACHE: dict[str, tuple[tuple, dict]] = {}
_MAX_ENTRIES = 512
_STORE_FILES = ("state.db", "state.db-wal")
def _file_parts(path: Path, *, with_inode: bool = False) -> Optional[tuple]:
try:
stat = path.stat()
except OSError:
return None
return (path.name, stat.st_mtime_ns, stat.st_size) + ((stat.st_ino,) if with_inode else ())
def _cached(cache: dict, key: str, signature: Optional[tuple], compute: Callable[[], dict],
copier: Callable[[dict], dict]) -> dict[str, Any]:
"""``compute()``'s fields, reused while *signature* holds. No signature means no cache."""
if signature is None:
return compute()
hit = cache.get(key)
if hit is not None and hit[0] == signature:
return copier(hit[1])
fields = compute()
if len(cache) >= _MAX_ENTRIES:
cache.clear()
cache[key] = (signature, copier(fields))
return fields
def store_signature(profile_path: "str | Path") -> Optional[tuple]:
"""``(name, mtime_ns, size)`` per session-store file, or None when the profile has no store.
The pair the change watcher already trusts for ``sessions.changed``.
"""
base = Path(profile_path)
parts = [p for p in (_file_parts(base / name) for name in _STORE_FILES) if p is not None]
# Our own read-only open creates an EMPTY -wal sidecar on a store that never had one; it
# carries no frames, so it must not read as "the store moved" on the very next poll.
return tuple(p for p in parts if not (p[0].endswith("-wal") and p[2] == 0)) or None
def profile_yaml_signature(profile_dir: "str | Path") -> Optional[tuple]:
"""``(name, mtime_ns, size, inode)`` of the profile's ``profile.yaml``, or None when absent."""
return _file_parts(Path(profile_dir) / "profile.yaml", with_inode=True)
def cached_session_fields(profile_path: "str | Path", compute: Callable[[], dict]) -> dict[str, Any]:
"""``compute()``'s fields, reused while the profile's session store has not moved."""
return _cached(_SESSION_CACHE, str(profile_path), store_signature(profile_path), compute, dict)
def cached_ui_meta_fields(profile_dir: "str | Path", compute: Callable[[], dict]) -> dict[str, Any]:
"""``compute()``'s fields, reused while the profile's ``profile.yaml`` has not changed.
Copied deeply: ``ui_meta`` is a nested mapping the caller hands to a client.
"""
return _cached(_UI_META_CACHE, str(profile_dir), profile_yaml_signature(profile_dir),
compute, copy.deepcopy)
def invalidate(profile_path: "str | Path | None" = None) -> None:
"""Drop one profile's memos, or all of them. For tests and for a caller that knows better."""
for cache in (_SESSION_CACHE, _UI_META_CACHE):
if profile_path is None:
cache.clear()
else:
cache.pop(str(profile_path), None)