mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The Desktop asks profiles.list of the selected bot over its one host-scoped socket. Since the unified gateway, only that bot's row carried session fields: every other served profile was listed by name alone, so on a fleet every row but one showed a hollow draft dot and no age, although the same socket may already open each of those bots' sessions. Each served sibling a host-scoped connection routes to now carries the same canonical_session / last_session, read from that sibling's own authority with the connection's subject and capabilities. The sibling set is snapshotted on the owner loop, and no connection is built for it. A profile-bound ticket, a retiring sibling, a row listed under a name that routes to another profile, and a sibling whose read fails for any reason stay metadata-only; a failing sibling is warned once, not on every poll. The owner-or-operator rule applies per profile. Each row is read on one connection that never reopens a closed store (SessionDB._read_ctx(reopen=False)), so a roster poll racing an unserve cannot reopen a writer into a home being deleted. last_session skips hidden rows again, as the pre-unified roster did, and the rows are reused while a profile's store has not moved, on the signature the pre-unified roster cache used (#117257), keyed by reader. The adopted-binding predicate from the previous commit becomes one SQL predicate for both lookups. AuthorityConnection.host_scoped names the gate _sibling_for applies. Fixes #139227 Refs #134889 (RT-24), #139161 (cherry picked from commit 714da8acbcafc41a0e6ebce0e490eaf5ad0e72a0)
121 lines
5.8 KiB
Python
121 lines
5.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
|
|
|
|
from utils import mtime_is_racy
|
|
|
|
# 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]] = {}
|
|
# The canonical gateway's rows also depend on who reads them (its owner filter): keyed by
|
|
# (profile path, reader).
|
|
_READER_SESSION_CACHE: dict[tuple, 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``. None too while a file's
|
|
mtime is fresh enough that a same-tick, same-size WAL rewrite could still hide behind it.
|
|
"""
|
|
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.
|
|
parts = [p for p in parts if not (p[0].endswith("-wal") and p[2] == 0)]
|
|
if any(mtime_is_racy(p[1]) for p in parts):
|
|
return None
|
|
return tuple(parts) 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_reader_session_fields(profile_path: str | Path, reader: tuple,
|
|
compute: Callable[[], dict]) -> dict[str, Any]:
|
|
"""``cached_session_fields`` for fields that also depend on *reader*, a hashable principal key."""
|
|
return _cached(_READER_SESSION_CACHE, (str(profile_path), reader), 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)
|
|
for key in [key for key in _READER_SESSION_CACHE if profile_path is None or key[0] == str(profile_path)]:
|
|
_READER_SESSION_CACHE.pop(key, None)
|