From 3079ae5116d0bbb13f426a50ed4b3d1c304f639a Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:33:34 -0500 Subject: [PATCH 1/2] refactor(tui): extract change watcher into tui_gateway/change_watcher (server.py god-file slice R2) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- tests/tui_gateway/test_change_watcher_seam.py | 169 +++++++++++ tui_gateway/change_watcher.py | 280 ++++++++++++++++++ tui_gateway/server.py | 273 ++--------------- 3 files changed, 476 insertions(+), 246 deletions(-) create mode 100644 tests/tui_gateway/test_change_watcher_seam.py create mode 100644 tui_gateway/change_watcher.py diff --git a/tests/tui_gateway/test_change_watcher_seam.py b/tests/tui_gateway/test_change_watcher_seam.py new file mode 100644 index 000000000000..363badd914dc --- /dev/null +++ b/tests/tui_gateway/test_change_watcher_seam.py @@ -0,0 +1,169 @@ +"""Seam tests for the R2-S1 extraction: change watcher → tui_gateway/change_watcher.py. + +Proves the extraction's load-bearing seam (consensus R2 §4, tests T1–T8): +- re-export identity: every moved name on ``tui_gateway.server`` IS the object + in ``tui_gateway.change_watcher``; +- patch-inertness: state replaced via ``monkeypatch.setattr(server, ...)`` / + ``monkeypatch.setitem(server._CHANGE_WATCHES, ...)`` is read through the + server module object at call time by the moved code; +- import-order cycle permutation: change_watcher-first and server-first both + import clean; +- aggressive behavior: file create/modify/delete lifecycle and a broken-probe + error path never kill the pass. +""" + +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from tui_gateway import change_watcher, server + +MOVED_NAMES = ( + "resolve_skin", + "_skin_sig", + "_note_skin_broadcast", + "_broadcast_skin_if_changed", + "_watcher_home", + "_pet_sig", + "_pet_changed_payload", + "_cron_sig", + "_sessions_sig", + "_platforms_sig", + "_pairing_sig", + "_broadcast_watched_changes", + "_ensure_skin_watcher", +) + + +@pytest.fixture() +def watcher_home(tmp_path, monkeypatch): + (tmp_path / "config.yaml").write_text("display: {}\n") + (tmp_path / "cron").mkdir() + + monkeypatch.setattr(server, "_hermes_home", str(tmp_path)) + monkeypatch.setattr(server, "_cfg_cache", None) + monkeypatch.setattr(server, "_change_sigs", {}) + monkeypatch.setattr(server, "_change_checked_at", {}) + monkeypatch.setattr(server, "_change_broadcast_at", {}) + # No broadcast floor for the lifecycle tests: raw signature moves. + monkeypatch.setattr(server, "_CHANGE_BROADCAST_FLOOR_S", {}) + + events = [] + monkeypatch.setattr( + server, + "_broadcast_global_event", + lambda ev, payload=None: events.append((ev, payload)), + ) + return tmp_path, events + + +def test_reexport_identity_per_moved_name(): + """T1: every re-exported name on server is the moved object (identity).""" + for name in MOVED_NAMES: + assert getattr(server, name) is getattr(change_watcher, name), name + + +def test_global_state_placement(): + """`_skin_watcher_started` moved with the cluster (global-bound, unpatched); + `_last_skin_sig` stayed server-owned (test_protocol patches it on server).""" + assert change_watcher._skin_watcher_started is False + assert not hasattr(server, "_skin_watcher_started") + assert server._last_skin_sig is None + assert not hasattr(change_watcher, "_last_skin_sig") + + +def test_patch_liveness_setattr_seen_by_moved_code(watcher_home): + """T2: setattr(server, "_change_sigs", {...}) is read at call time. + + The moved ``_broadcast_watched_changes`` must seed the NEW dict we + installed on the server module — a stale import-time binding would seed a + dead dict and this assertion would fail. + """ + home, events = watcher_home + (home / "cron" / "jobs.json").write_text("[]") + (home / "state.db").write_text("x") + + server._broadcast_watched_changes(now=0.0) + + assert server._change_sigs # seeded into the freshly setattr'd dict + assert events == [] + + +def test_setitem_identity_broken_probe_never_kills_pass(watcher_home): + """T3 + error path: setitem on server._CHANGE_WATCHES is seen by identity, + and a raising probe is skipped while a healthy probe still broadcasts.""" + home, events = watcher_home + server._broadcast_watched_changes(now=0.0) + + def _boom(): + raise RuntimeError("probe exploded") + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setitem( + server._CHANGE_WATCHES, + "cron.changed", + (1.0, _boom, lambda: {}), + ) + try: + (home / "state.db").write_text("x") + server._broadcast_watched_changes(now=10.0) + + assert ("sessions.changed", {}) in events + assert not [e for e in events if e[0] == "cron.changed"] + finally: + monkeypatch.undo() + + +def test_hermes_home_patch_visibility(watcher_home): + """T4: _hermes_home patched on server is what the moved probes see.""" + home, _ = watcher_home + assert change_watcher._watcher_home() == Path(str(home)) + # The skin signature must resolve against the patched home too. + assert change_watcher._skin_sig()[0] == "default" + assert change_watcher._skin_sig()[1] is None # no skins dir → no mtime + + +def test_file_create_modify_delete_lifecycle(watcher_home): + """Aggressive: state.db create → broadcast, modify → broadcast, + delete → broadcast (signature returns to None and must move again).""" + home, events = watcher_home + db = home / "state.db" + server._broadcast_watched_changes(now=0.0) # seed: absent → None + + db.write_text("x") # create + server._broadcast_watched_changes(now=10.0) + assert ("sessions.changed", {}) in events + events.clear() + + time.sleep(0.02) # NTFS mtime granularity + db.write_text("xy") # modify + server._broadcast_watched_changes(now=11.0) + assert ("sessions.changed", {}) in events + events.clear() + + db.unlink() # delete + server._broadcast_watched_changes(now=12.0) + assert ("sessions.changed", {}) in events + + +def test_import_order_cycle_permutation(): + """T8: change_watcher-first and server-first both import clean, and the + external pins (entry, ws) still import.""" + repo_root = Path(__file__).resolve().parents[2] + probes = ( + "import tui_gateway.change_watcher; import tui_gateway.server", + "import tui_gateway.server; import tui_gateway.change_watcher", + "import tui_gateway.server; import tui_gateway.entry; import tui_gateway.ws", + ) + for probe in probes: + proc = subprocess.run( + [sys.executable, "-c", probe], + cwd=str(repo_root), + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, f"{probe!r} failed:\n{proc.stdout}\n{proc.stderr}" diff --git a/tui_gateway/change_watcher.py b/tui_gateway/change_watcher.py new file mode 100644 index 000000000000..18c8ec1c4182 --- /dev/null +++ b/tui_gateway/change_watcher.py @@ -0,0 +1,280 @@ +"""Change watcher for the tui_gateway server. + +Extracted from :mod:`tui_gateway.server` (god-file slice R2-S1, epic #78647 / +#78630). Owns the process's one change watcher: cheap on-disk signature probes +that broadcast ``skin.changed`` / ``pet.changed`` / ``cron.changed`` / +``sessions.changed`` / ``platforms.changed`` / ``pairing.changed`` global +events so a skin Hermes activates, a pet ``/pet`` adopts, a cron the scheduler +fires, or a messaging turn another process writes goes live on every surface +within a couple seconds. + +Seam contract +------------- +Shared server state stays in :mod:`tui_gateway.server` and is read through the +module object at call time (patch-inert for the test suite): the registry +(``server._CHANGE_WATCHES``, ``server._CHANGE_BROADCAST_FLOOR_S``, +``server._change_sigs``, ``server._change_checked_at``, +``server._change_broadcast_at``, ``server._last_skin_sig``) plus +``server._hermes_home``, ``server._cfg_cache``, ``server._load_cfg``, +``server._broadcast_global_event``, ``server._pet_active_selection`` and +``server._pet_sheet_revision``. Cluster-mate reads that tests patch on the +server module (``server._skin_sig``, ``server.resolve_skin``) go through the +module object at call time for the same reason. + +``server`` is imported at the END of this module (not the top) on purpose: +``tui_gateway.server`` re-exports this module's names at module level, so a +top-of-file ``from tui_gateway import server`` would deadlock the +change_watcher-first import order. Importing at the end keeps both orders +clean: this module's functions only touch ``server`` at call time. +""" + +import threading +import time +from pathlib import Path + +from hermes_constants import get_hermes_home_override + +def resolve_skin() -> dict: + try: + from hermes_cli.skin_engine import init_skin_from_config, get_active_skin + + init_skin_from_config(server._load_cfg()) + skin = get_active_skin() + return { + "name": skin.name, + "colors": skin.colors, + # Paired palettes: the TUI detects the terminal's polarity and + # prefers the matching hand-tuned block over adapting `colors`. + "light_colors": skin.light_colors, + "dark_colors": skin.dark_colors, + "branding": skin.branding, + "banner_logo": skin.banner_logo, + "banner_hero": skin.banner_hero, + "tool_prefix": skin.tool_prefix, + "help_header": (skin.branding or {}).get("help_header", ""), + } + except Exception: + return {} + + +def _skin_sig() -> tuple[str, float | None]: + """(active skin name, its user-file mtime). Built-ins have no file, so only + their name moves; a user skin's mtime lets an in-place color edit repaint too.""" + name = str((server._load_cfg().get("display") or {}).get("skin") or "default") + override = get_hermes_home_override() + home = override if isinstance(override, str) and override else server._hermes_home + try: + mtime: float | None = (Path(home) / "skins" / f"{name}.yaml").stat().st_mtime + except OSError: + mtime = None + return name, mtime + + +def _note_skin_broadcast() -> None: + """Sync the reconcile baseline after the /skin RPC emits, so the per-tool + check doesn't re-broadcast the skin /skin just applied.""" + try: + server._last_skin_sig = server._skin_sig() + except Exception: + pass + + +def _broadcast_skin_if_changed() -> None: + """Emit ``skin.changed`` when the active skin moved — the agent switched it + (``hermes config set display.skin``) OR edited the active skin's colors in + place ("I don't like that coral" → tweak the YAML). + + Routes through the SAME live path as ``/skin`` so every surface (TUI + desktop) + repaints, no slash command. The signature check is a dict lookup + one stat, + so polling it is ~free. + """ + try: + sig = server._skin_sig() + except Exception: + return + if sig == server._last_skin_sig: + return + server._last_skin_sig = sig + try: + server._broadcast_global_event("skin.changed", server.resolve_skin()) + except Exception: + pass + + +def _watcher_home() -> Path: + """Active profile home for the change watcher's signature probes.""" + override = get_hermes_home_override() + return Path(override if isinstance(override, str) and override else server._hermes_home) + + +def _pet_sig() -> tuple: + """(slug, spritesheet revision, scale) of the active pet — ("off",) when none. + + Cheap by construction: config comes from the mtime-cached ``_load_cfg`` and + the sheet revision is one stat. Moves when ``/pet`` (de)activates a pet, the + hatch flow rebuilds a sheet, or the scale changes.""" + display = server._load_cfg().get("display") or {} + pet_cfg = display.get("pet") if isinstance(display.get("pet"), dict) else {} + if not pet_cfg or not pet_cfg.get("enabled"): + return ("off",) + try: + enabled, pet, scale = server._pet_active_selection() + if not enabled or pet is None or not pet.exists: + return ("off",) + return (pet.slug, server._pet_sheet_revision(pet.spritesheet), scale) + except Exception: # noqa: BLE001 - cosmetic, never break the watcher + return ("off",) + + +def _pet_changed_payload() -> dict: + """``pet.info.meta``-shaped payload for ``pet.changed`` — enough for the + renderer to decide whether the heavy sprite payload needs a refetch.""" + try: + enabled, pet, scale = server._pet_active_selection() + if not enabled or pet is None or not pet.exists: + return {"enabled": False} + return { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "scale": scale, + "spritesheetRevision": server._pet_sheet_revision(pet.spritesheet), + } + except Exception: # noqa: BLE001 - cosmetic, never break the watcher + return {"enabled": False} + + +def _cron_sig(): + """mtime of the profile's cron/jobs.json — moves on create/edit/pause/ + remove AND on scheduler tick bookkeeping (last_run/next_run).""" + try: + return (_watcher_home() / "cron" / "jobs.json").stat().st_mtime_ns + except OSError: + return None + + +def _sessions_sig(): + """Newest mtime across state.db and its WAL — the cross-process change + signal. Messaging-gateway turns and cron runs are written by OTHER + processes that never touch this gateway's transports; the shared SQLite + file is the one thing they all move (#58671).""" + home = _watcher_home() + sig = None + for name in ("state.db", "state.db-wal"): + try: + mtime = (home / name).stat().st_mtime_ns + except OSError: + continue + sig = mtime if sig is None else max(sig, mtime) + return sig + + +def _platforms_sig(): + """mtime of gateway_state.json — the messaging gateway process persists + platform connect/disconnect/health there, so its movement is the + "connection status changed" signal for the Messaging page.""" + try: + return (_watcher_home() / "gateway_state.json").stat().st_mtime_ns + except OSError: + return None + + +def _pairing_sig(): + """Newest mtime across every profile's pairing store. + + An unknown DMer's pending code is written by the messaging gateway — a + DIFFERENT process that never touches this gateway's transports — so the + files are the only shared signal. ``platforms.changed`` cannot stand in + for this: it tracks connect/disconnect/health, and a pairing request + moves nothing in gateway_state.json. + """ + home = _watcher_home() + sig = None + # Global store (legacy `pairing/` and consolidated `platforms/pairing/`) + # plus every named profile's own — the Messaging page can be scoped to any + # of them, and a request landing in a profile store must still tick. + roots = [home / "pairing", home / "platforms" / "pairing"] + try: + for profile_dir in (home / "profiles").iterdir(): + roots.append(profile_dir / "pairing") + roots.append(profile_dir / "platforms" / "pairing") + except OSError: + pass + + for root in roots: + try: + entries = list(root.iterdir()) + except OSError: + continue + for entry in entries: + # Only the pending/approved ledgers — _rate_limits.json moves on + # every unauthorized DM, including ones that produce no new row. + if not entry.name.endswith(("-pending.json", "-approved.json")): + continue + try: + mtime = entry.stat().st_mtime_ns + except OSError: + continue + sig = mtime if sig is None else max(sig, mtime) + return sig + + +def _broadcast_watched_changes(now: float | None = None) -> None: + """One pass over ``_CHANGE_WATCHES``: recompute due signatures, broadcast + the events whose signature moved. First sighting seeds silently so a + gateway boot never fires a spurious refresh storm.""" + now = time.monotonic() if now is None else now + for event, (interval, sig_fn, payload_fn) in server._CHANGE_WATCHES.items(): + if now - server._change_checked_at.get(event, -interval) < interval: + continue + server._change_checked_at[event] = now + try: + sig = sig_fn() + except Exception: # noqa: BLE001 - a broken probe must not kill the loop + continue + if event not in server._change_sigs: + server._change_sigs[event] = sig + continue + if sig == server._change_sigs[event]: + continue + floor = server._CHANGE_BROADCAST_FLOOR_S.get(event, 0.0) + if floor and now - server._change_broadcast_at.get(event, -floor) < floor: + # Floored: leave the old signature in place so the change re-fires + # once the window opens (the trailing edge of the burst). + continue + server._change_sigs[event] = sig + server._change_broadcast_at[event] = now + try: + server._broadcast_global_event(event, payload_fn()) + except Exception: # noqa: BLE001 + pass + + +_skin_watcher_started = False + + +def _ensure_skin_watcher() -> None: + """Watch cheap on-disk signatures and broadcast change events — so a skin + Hermes activates, a pet ``/pet`` adopts, a cron the scheduler fires, or a + messaging turn another process writes goes live on every surface within a + couple seconds, on its own, with no client-side poll in the loop. + Idempotent; started at gateway.ready. (Named for its original skin-only + duty; it is the process's one change watcher.)""" + global _skin_watcher_started + if _skin_watcher_started: + return + _skin_watcher_started = True + _note_skin_broadcast() # seed the baseline so only a real change repaints + + def _loop() -> None: + while True: + time.sleep(0.5) + _broadcast_skin_if_changed() + _broadcast_watched_changes() + + threading.Thread(target=_loop, name="hermes-change-watcher", daemon=True).start() + + +# Imported last: see module docstring ("Seam contract") for why a top-of-file +# import would deadlock the change_watcher-first import order. +from tui_gateway import server # noqa: E402 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a36a539408b1..a3717887884f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3179,197 +3179,26 @@ def _clear_pending(sid: str | None = None) -> None: # ── Agent factory ──────────────────────────────────────────────────── -def resolve_skin() -> dict: - try: - from hermes_cli.skin_engine import init_skin_from_config, get_active_skin - - init_skin_from_config(_load_cfg()) - skin = get_active_skin() - return { - "name": skin.name, - "colors": skin.colors, - # Paired palettes: the TUI detects the terminal's polarity and - # prefers the matching hand-tuned block over adapting `colors`. - "light_colors": skin.light_colors, - "dark_colors": skin.dark_colors, - "branding": skin.branding, - "banner_logo": skin.banner_logo, - "banner_hero": skin.banner_hero, - "tool_prefix": skin.tool_prefix, - "help_header": (skin.branding or {}).get("help_header", ""), - } - except Exception: - return {} - - -# Signature of the last skin broadcast: (name, active user-file mtime). Lets the -# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a -# live color edit to the active skin — and nothing else. -_last_skin_sig: tuple[str, float | None] | None = None - - -def _skin_sig() -> tuple[str, float | None]: - """(active skin name, its user-file mtime). Built-ins have no file, so only - their name moves; a user skin's mtime lets an in-place color edit repaint too.""" - name = str((_load_cfg().get("display") or {}).get("skin") or "default") - override = get_hermes_home_override() - home = override if isinstance(override, str) and override else _hermes_home - try: - mtime: float | None = (Path(home) / "skins" / f"{name}.yaml").stat().st_mtime - except OSError: - mtime = None - return name, mtime - - -def _note_skin_broadcast() -> None: - """Sync the reconcile baseline after the /skin RPC emits, so the per-tool - check doesn't re-broadcast the skin /skin just applied.""" - global _last_skin_sig - try: - _last_skin_sig = _skin_sig() - except Exception: - pass - - -def _broadcast_skin_if_changed() -> None: - """Emit ``skin.changed`` when the active skin moved — the agent switched it - (``hermes config set display.skin``) OR edited the active skin's colors in - place ("I don't like that coral" → tweak the YAML). - - Routes through the SAME live path as ``/skin`` so every surface (TUI + desktop) - repaints, no slash command. The signature check is a dict lookup + one stat, - so polling it is ~free. - """ - global _last_skin_sig - try: - sig = _skin_sig() - except Exception: - return - if sig == _last_skin_sig: - return - _last_skin_sig = sig - try: - _broadcast_global_event("skin.changed", resolve_skin()) - except Exception: - pass - - -def _watcher_home() -> Path: - """Active profile home for the change watcher's signature probes.""" - override = get_hermes_home_override() - return Path(override if isinstance(override, str) and override else _hermes_home) - - -def _pet_sig() -> tuple: - """(slug, spritesheet revision, scale) of the active pet — ("off",) when none. - - Cheap by construction: config comes from the mtime-cached ``_load_cfg`` and - the sheet revision is one stat. Moves when ``/pet`` (de)activates a pet, the - hatch flow rebuilds a sheet, or the scale changes.""" - display = _load_cfg().get("display") or {} - pet_cfg = display.get("pet") if isinstance(display.get("pet"), dict) else {} - if not pet_cfg or not pet_cfg.get("enabled"): - return ("off",) - try: - enabled, pet, scale = _pet_active_selection() - if not enabled or pet is None or not pet.exists: - return ("off",) - return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale) - except Exception: # noqa: BLE001 - cosmetic, never break the watcher - return ("off",) - - -def _pet_changed_payload() -> dict: - """``pet.info.meta``-shaped payload for ``pet.changed`` — enough for the - renderer to decide whether the heavy sprite payload needs a refetch.""" - try: - enabled, pet, scale = _pet_active_selection() - if not enabled or pet is None or not pet.exists: - return {"enabled": False} - return { - "enabled": True, - "slug": pet.slug, - "displayName": pet.display_name, - "scale": scale, - "spritesheetRevision": _pet_sheet_revision(pet.spritesheet), - } - except Exception: # noqa: BLE001 - cosmetic, never break the watcher - return {"enabled": False} - - -def _cron_sig(): - """mtime of the profile's cron/jobs.json — moves on create/edit/pause/ - remove AND on scheduler tick bookkeeping (last_run/next_run).""" - try: - return (_watcher_home() / "cron" / "jobs.json").stat().st_mtime_ns - except OSError: - return None - - -def _sessions_sig(): - """Newest mtime across state.db and its WAL — the cross-process change - signal. Messaging-gateway turns and cron runs are written by OTHER - processes that never touch this gateway's transports; the shared SQLite - file is the one thing they all move (#58671).""" - home = _watcher_home() - sig = None - for name in ("state.db", "state.db-wal"): - try: - mtime = (home / name).stat().st_mtime_ns - except OSError: - continue - sig = mtime if sig is None else max(sig, mtime) - return sig - - -def _platforms_sig(): - """mtime of gateway_state.json — the messaging gateway process persists - platform connect/disconnect/health there, so its movement is the - "connection status changed" signal for the Messaging page.""" - try: - return (_watcher_home() / "gateway_state.json").stat().st_mtime_ns - except OSError: - return None - - -def _pairing_sig(): - """Newest mtime across every profile's pairing store. - - An unknown DMer's pending code is written by the messaging gateway — a - DIFFERENT process that never touches this gateway's transports — so the - files are the only shared signal. ``platforms.changed`` cannot stand in - for this: it tracks connect/disconnect/health, and a pairing request - moves nothing in gateway_state.json. - """ - home = _watcher_home() - sig = None - # Global store (legacy `pairing/` and consolidated `platforms/pairing/`) - # plus every named profile's own — the Messaging page can be scoped to any - # of them, and a request landing in a profile store must still tick. - roots = [home / "pairing", home / "platforms" / "pairing"] - try: - for profile_dir in (home / "profiles").iterdir(): - roots.append(profile_dir / "pairing") - roots.append(profile_dir / "platforms" / "pairing") - except OSError: - pass - - for root in roots: - try: - entries = list(root.iterdir()) - except OSError: - continue - for entry in entries: - # Only the pending/approved ledgers — _rate_limits.json moves on - # every unauthorized DM, including ones that produce no new row. - if not entry.name.endswith(("-pending.json", "-approved.json")): - continue - try: - mtime = entry.stat().st_mtime_ns - except OSError: - continue - sig = mtime if sig is None else max(sig, mtime) - return sig +# Change watcher extracted to tui_gateway/change_watcher.py (god-file slice +# R2-S1). The names are re-exported here so every existing import site +# (entry.py, ws.py, the R5 config.set path, the test suite) keeps resolving +# them off this module. The moved functions read the registry state below and +# other server deps through this module object at call time (patch-inert). +from tui_gateway.change_watcher import ( # noqa: E402,F401 + resolve_skin, + _skin_sig, + _note_skin_broadcast, + _broadcast_skin_if_changed, + _watcher_home, + _pet_sig, + _pet_changed_payload, + _cron_sig, + _sessions_sig, + _platforms_sig, + _pairing_sig, + _broadcast_watched_changes, + _ensure_skin_watcher, +) # Watched change signals: event → (check interval, signature fn, payload fn). @@ -3394,61 +3223,13 @@ def _pairing_sig(): _change_checked_at: dict[str, float] = {} _change_broadcast_at: dict[str, float] = {} - -def _broadcast_watched_changes(now: float | None = None) -> None: - """One pass over ``_CHANGE_WATCHES``: recompute due signatures, broadcast - the events whose signature moved. First sighting seeds silently so a - gateway boot never fires a spurious refresh storm.""" - now = time.monotonic() if now is None else now - for event, (interval, sig_fn, payload_fn) in _CHANGE_WATCHES.items(): - if now - _change_checked_at.get(event, -interval) < interval: - continue - _change_checked_at[event] = now - try: - sig = sig_fn() - except Exception: # noqa: BLE001 - a broken probe must not kill the loop - continue - if event not in _change_sigs: - _change_sigs[event] = sig - continue - if sig == _change_sigs[event]: - continue - floor = _CHANGE_BROADCAST_FLOOR_S.get(event, 0.0) - if floor and now - _change_broadcast_at.get(event, -floor) < floor: - # Floored: leave the old signature in place so the change re-fires - # once the window opens (the trailing edge of the burst). - continue - _change_sigs[event] = sig - _change_broadcast_at[event] = now - try: - _broadcast_global_event(event, payload_fn()) - except Exception: # noqa: BLE001 - pass - - -_skin_watcher_started = False - - -def _ensure_skin_watcher() -> None: - """Watch cheap on-disk signatures and broadcast change events — so a skin - Hermes activates, a pet ``/pet`` adopts, a cron the scheduler fires, or a - messaging turn another process writes goes live on every surface within a - couple seconds, on its own, with no client-side poll in the loop. - Idempotent; started at gateway.ready. (Named for its original skin-only - duty; it is the process's one change watcher.)""" - global _skin_watcher_started - if _skin_watcher_started: - return - _skin_watcher_started = True - _note_skin_broadcast() # seed the baseline so only a real change repaints - - def _loop() -> None: - while True: - time.sleep(0.5) - _broadcast_skin_if_changed() - _broadcast_watched_changes() - - threading.Thread(target=_loop, name="hermes-change-watcher", daemon=True).start() +# Signature of the last skin broadcast: (name, active user-file mtime). Lets the +# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a +# live color edit to the active skin — and nothing else. Stays here (not in +# change_watcher.py) because test_protocol.py patches it via +# monkeypatch.setattr(server, "_last_skin_sig", ...) — the moved skin watcher +# reads/writes it through this module object at call time. +_last_skin_sig: tuple[str, float | None] | None = None def _resolve_model() -> str: From f47d98a7dd28d7a729c45ad993568f27b17ec696 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:40:16 -0500 Subject: [PATCH 2/2] fix(tui): resolve live server watcher seam Signed-off-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com> --- tui_gateway/change_watcher.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tui_gateway/change_watcher.py b/tui_gateway/change_watcher.py index 18c8ec1c4182..bd3a5a8f0573 100644 --- a/tui_gateway/change_watcher.py +++ b/tui_gateway/change_watcher.py @@ -21,20 +21,28 @@ server module (``server._skin_sig``, ``server.resolve_skin``) go through the module object at call time for the same reason. -``server`` is imported at the END of this module (not the top) on purpose: +``server`` is resolved from the import registry at call time on purpose: ``tui_gateway.server`` re-exports this module's names at module level, so a -top-of-file ``from tui_gateway import server`` would deadlock the -change_watcher-first import order. Importing at the end keeps both orders -clean: this module's functions only touch ``server`` at call time. +top-of-file import would cycle. A module captured during that cycle can also +become stale when an importer temporarily replaces dependencies in +``sys.modules``; resolving afresh preserves the live server monkeypatch seam. """ +import importlib import threading import time from pathlib import Path from hermes_constants import get_hermes_home_override + +def _server_module(): + """Return the live server module without retaining a cyclic import binding.""" + return importlib.import_module("tui_gateway.server") + + def resolve_skin() -> dict: + server = _server_module() try: from hermes_cli.skin_engine import init_skin_from_config, get_active_skin @@ -60,6 +68,7 @@ def resolve_skin() -> dict: def _skin_sig() -> tuple[str, float | None]: """(active skin name, its user-file mtime). Built-ins have no file, so only their name moves; a user skin's mtime lets an in-place color edit repaint too.""" + server = _server_module() name = str((server._load_cfg().get("display") or {}).get("skin") or "default") override = get_hermes_home_override() home = override if isinstance(override, str) and override else server._hermes_home @@ -73,6 +82,7 @@ def _skin_sig() -> tuple[str, float | None]: def _note_skin_broadcast() -> None: """Sync the reconcile baseline after the /skin RPC emits, so the per-tool check doesn't re-broadcast the skin /skin just applied.""" + server = _server_module() try: server._last_skin_sig = server._skin_sig() except Exception: @@ -88,6 +98,7 @@ def _broadcast_skin_if_changed() -> None: repaints, no slash command. The signature check is a dict lookup + one stat, so polling it is ~free. """ + server = _server_module() try: sig = server._skin_sig() except Exception: @@ -103,6 +114,7 @@ def _broadcast_skin_if_changed() -> None: def _watcher_home() -> Path: """Active profile home for the change watcher's signature probes.""" + server = _server_module() override = get_hermes_home_override() return Path(override if isinstance(override, str) and override else server._hermes_home) @@ -113,6 +125,7 @@ def _pet_sig() -> tuple: Cheap by construction: config comes from the mtime-cached ``_load_cfg`` and the sheet revision is one stat. Moves when ``/pet`` (de)activates a pet, the hatch flow rebuilds a sheet, or the scale changes.""" + server = _server_module() display = server._load_cfg().get("display") or {} pet_cfg = display.get("pet") if isinstance(display.get("pet"), dict) else {} if not pet_cfg or not pet_cfg.get("enabled"): @@ -129,6 +142,7 @@ def _pet_sig() -> tuple: def _pet_changed_payload() -> dict: """``pet.info.meta``-shaped payload for ``pet.changed`` — enough for the renderer to decide whether the heavy sprite payload needs a refetch.""" + server = _server_module() try: enabled, pet, scale = server._pet_active_selection() if not enabled or pet is None or not pet.exists: @@ -223,6 +237,7 @@ def _broadcast_watched_changes(now: float | None = None) -> None: """One pass over ``_CHANGE_WATCHES``: recompute due signatures, broadcast the events whose signature moved. First sighting seeds silently so a gateway boot never fires a spurious refresh storm.""" + server = _server_module() now = time.monotonic() if now is None else now for event, (interval, sig_fn, payload_fn) in server._CHANGE_WATCHES.items(): if now - server._change_checked_at.get(event, -interval) < interval: @@ -273,8 +288,3 @@ def _loop() -> None: _broadcast_watched_changes() threading.Thread(target=_loop, name="hermes-change-watcher", daemon=True).start() - - -# Imported last: see module docstring ("Seam contract") for why a top-of-file -# import would deadlock the change_watcher-first import order. -from tui_gateway import server # noqa: E402