fix(change-watcher): probe profile liveness once per profiles/ mtime + TTL, not on every 2 s pairing tick

#114041 §2: hermes-change-watcher re-stats the profile trees independently of
list_profiles(). The cost was _pairing_sig: every 2 s it iterated profiles/
and ran named_profile_is_live on each entry (is_dir + up to 6 identity
markers x is_file/is_symlink + tombstone exists ~= 14 stats per profile), then
listed each root. That also covers the §3 "profile liveness checks reached
from the watcher" contributor.

The set of live profiles only moves when a profile dir is added/removed
(which bumps the profiles/ dir mtime) or a marker/tombstone lands inside an
existing dir. _pairing_roots caches the roots on (home, profiles/ mtime_ns)
with a 30 s TTL: per tick the watcher now does one stat of profiles/ plus
the ledger iterdir on the known roots, so pairing latency is unchanged for
every existing profile; a brand-new profile is picked up on the next tick
via the mtime bump, and a marker-only change within 30 s. No new event,
interval or config surface.

Test: two ticks -> one liveness probe while the ledger write is still
broadcast; a new profile dir (mtime bump) re-probes and its ledger fires.
Red on the previous change_watcher.py (liveness ran on both ticks).
This commit is contained in:
teknium1
2026-09-18 03:39:30 -07:00
committed by Teknium
parent 47b58634c3
commit 1eb5cd6124
2 changed files with 59 additions and 9 deletions

View File

@@ -26,6 +26,7 @@ def watcher_home(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_change_checked_at", {})
monkeypatch.setattr(server, "_change_broadcast_at", {})
monkeypatch.setattr(server, "_bot_relay_outbox_seen", 0)
monkeypatch.setattr(server, "_pairing_roots_cache", None, raising=False)
events = []
monkeypatch.setattr(
@@ -125,6 +126,38 @@ def test_pairing_signal_follows_a_profile_store(watcher_home):
assert ("pairing.changed", {}) in events
def test_pairing_probe_reuses_live_profile_roots_until_the_profile_set_moves(watcher_home, monkeypatch):
"""The per-profile liveness probe (~14 stats each) runs once per profiles/ mtime + TTL, not
on every 2 s tick (#114041 §2); ledger writes under known roots are still seen each tick,
and a newly created profile is picked up because creating it bumps the parent's mtime."""
import hermes_constants
home, events = watcher_home
live_calls = []
real_live = hermes_constants.named_profile_is_live
monkeypatch.setattr(hermes_constants, "named_profile_is_live",
lambda p: live_calls.append(p.name) or real_live(p))
def _profile(name):
(home / "profiles" / name / "platforms" / "pairing").mkdir(parents=True)
(home / "profiles" / name / "config.yaml").write_text("{}\n", encoding="utf-8")
_profile("work")
server._broadcast_watched_changes(now=0.0)
(home / "profiles" / "work" / "platforms" / "pairing" / "telegram-pending.json").write_text("{}", encoding="utf-8")
server._broadcast_watched_changes(now=10.0)
assert events == [("pairing.changed", {})]
assert live_calls == ["work"] # second tick reused the cached roots, still saw the ledger
_profile("play")
os.utime(home / "profiles", ns=(0, 10**18)) # deterministic parent-mtime bump
server._broadcast_watched_changes(now=20.0)
(home / "profiles" / "play" / "platforms" / "pairing" / "discord-approved.json").write_text("{}", encoding="utf-8")
server._broadcast_watched_changes(now=30.0)
assert events == [("pairing.changed", {})] * 2
assert sorted(live_calls) == ["play", "work", "work"]
def test_rate_limit_churn_does_not_broadcast_pairing_changed(watcher_home):
"""_rate_limits.json moves on every unauthorized DM, including ones that
produce no new row — signalling on it would refetch for nothing."""

View File

@@ -133,16 +133,8 @@ def _pairing_sig():
"""Newest mtime across every profile's pairing ledgers (legacy ``pairing/`` and
``platforms/pairing/``): the gateway process writes pending codes, so the files are the only
shared signal (a pairing request moves nothing in gateway_state.json)."""
home = _watcher_home()
roots = [home / "pairing", home / "platforms" / "pairing"]
from hermes_constants import named_profile_is_live
with contextlib.suppress(OSError):
for profile_dir in (home / "profiles").iterdir():
if named_profile_is_live(profile_dir):
roots += [profile_dir / "pairing", profile_dir / "platforms" / "pairing"]
entries = []
for root in roots:
for root in _pairing_roots(_watcher_home()):
with contextlib.suppress(OSError):
# Only the ledgers: _rate_limits.json moves on every unauthorized DM.
entries += [
@@ -150,6 +142,31 @@ def _pairing_sig():
return _newest_mtime_ns(entries)
# Live-profile pairing roots, cached on (home, ``profiles/`` dir mtime) with a TTL. The liveness
# probe costs ~14 stats per profile; on the 2 s tick that was the watcher's share of the idle
# profile-tree burn (#114041 §2/§3). The profile SET only moves when a dir is added or removed —
# which bumps the parent's mtime — while a marker/tombstone landing inside one is caught by the TTL.
_PAIRING_ROOTS_TTL_S = 30.0
_pairing_roots_cache: tuple[Path, int | None, float, list] | None = None
def _pairing_roots(home: Path) -> list:
global _pairing_roots_cache
profiles_dir = home / "profiles"
dir_mtime, now = _watcher_mtime_ns(profiles_dir), time.monotonic()
cached = _pairing_roots_cache
if cached is not None and cached[0] == home and cached[1] == dir_mtime and now - cached[2] < _PAIRING_ROOTS_TTL_S:
return cached[3]
from hermes_constants import named_profile_is_live
roots = [home / "pairing", home / "platforms" / "pairing"]
with contextlib.suppress(OSError):
for profile_dir in profiles_dir.iterdir():
if named_profile_is_live(profile_dir):
roots += [profile_dir / "pairing", profile_dir / "platforms" / "pairing"]
_pairing_roots_cache = (home, dir_mtime, now, roots)
return roots
# Newest outbox-envelope mtime EVER seen (monotone): a drain empties the outbox,
# and falling back to None would fire a spurious pending event after every drain.
_bot_relay_outbox_seen = 0