Files
hermes-agent/gateway/host_topology.py
teknium1 ea0c2b820b fix(host-topology): a createTime-null record is not a live gateway, and the restart we print must run
Review findings on this PR.

- `_from_host_record` accepted a record with `createTime: null`: `_same_incarnation` treats a
  missing create_time as "matches", so `liveness_is_proven()` blessed whatever process happens to
  own that PID today. A record pointing at an unrelated `sleep 60` made `cron status` print
  "the host gateway (PID ...) serving profiles default, served". Require a recorded create_time.
- `cron status`'s host-record rung printed `hermes gateway restart`, which exits 78 for exactly
  its audience (a served NAMED profile); use the `--profile default` form both rungs now share.
- doctor's WORST state (supervision slots exist, nothing owns the gateway role) emitted a warning
  but appended no issue, while the strictly less severe LEGACY case did.
- Drop three defensive wrappers in `host_topology`: a try/except around an always-importable
  `normalize_profile_name` that silently degraded to a DIFFERENT normalisation, a blanket except
  per rung, and a swallowed `get_active_profile_name`.
- `get_systemd_unit_path`, `_legacy_unit_search_paths`, the doctor host-unit probe and the
  profile-delete service cleanup hardcoded `~/.config/systemd/user`, so on a host that moves
  $XDG_CONFIG_HOME the unit probe false-negatived -- the very bug this PR fixes survived there.
  One `user_systemd_unit_dir()` helper honours the spec default.

Tests: the two "Scheduler host: the host gateway" assertions were prefixes of BOTH rungs, so the
rungs were indistinguishable and the host-record rung was never exercised (which is how the
restart-command defect shipped green). Both now assert the full line, plus a served-host-record
case. Rendezvous isolation uses `tmp_path` instead of a leaked `tempfile.mkdtemp()` and also
covers `test_gateway_multiplex_served_record.py`, which read the real host record; the
`tests/conftest.py` hook in #118097 supersedes all of them once it lands.
2026-09-21 05:06:45 -07:00

102 lines
4.6 KiB
Python

"""Who owns the gateway role on THIS host, and which profiles does that one process serve?
Multiplex-only (Teknium ruling): exactly ONE ``hermes gateway run`` per host, multiplexing every
profile. Every reporting surface (doctor, ``cron status``, ``claw``, the dashboard liveness ladder)
used to ask a per-PROFILE question instead — "does *my* profile own a gateway process?" — and a
profile that is SERVED by the host gateway answered "no". That produced three user-visible lies:
"not running", "Per-profile gateways: 0/3 up", and a silently skipped destructive-action warning.
:func:`host_gateway_topology` answers the real question once, from
:mod:`gateway.host_rendezvous` (the authoritative host record the winning process publishes),
falling back to the default home's recorded ``served_profiles`` for a gateway that predates the
record. ``default`` is just another served profile here, never a special owner.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
def _normalized(name: Optional[str]) -> str:
if not name:
return ""
# Late import: ``hermes_cli.profiles`` imports gateway modules back.
from hermes_cli.profiles import normalize_profile_name
return normalize_profile_name(name)
@dataclass(frozen=True)
class HostGatewayTopology:
"""The single gateway process on this host plus the profile roster it multiplexes.
``source`` names the rung that answered (``host_record`` / ``served_record``) — for logs and
tests only; never branch product behaviour on it.
"""
pid: int
profiles: tuple[str, ...]
source: str
def serves(self, profile_name: Optional[str]) -> bool:
"""True when this host process ticks/serves ``profile_name`` (``default`` included)."""
wanted = _normalized(profile_name)
return bool(wanted) and wanted in {_normalized(p) for p in self.profiles}
def describe(self) -> str:
"""``the host gateway (PID 42) serving profiles default, coder`` — one shared phrasing so
doctor, cron status and the state.db holder lines cannot drift apart."""
roster = ", ".join(self.profiles) if self.profiles else "an unknown profile set"
return f"the host gateway (PID {self.pid}) serving profiles {roster}"
def _from_host_record() -> Optional[HostGatewayTopology]:
from gateway import host_rendezvous as hr
record = hr.read_record(hr.ROLE_GATEWAY) # already drops stale/PID-reused records
if record is None or not record.pid:
return None
# A record whose (pid, createTime) cannot be POSITIVELY matched is a candidate, never an owner
# (``host_rendezvous`` contract): reporting an unprovable record as the live host gateway would
# turn a leftover record into a permanent "running" lie on every surface. A record with no
# recorded createTime is exactly that — ``_same_incarnation`` treats ``None`` as "matches", so
# liveness_is_proven() would bless ANY process that happens to hold the recorded PID today.
if record.create_time is None or not hr.liveness_is_proven(record):
return None
return HostGatewayTopology(pid=int(record.pid), profiles=tuple(record.profiles), source="host_record")
def _from_served_record() -> Optional[HostGatewayTopology]:
"""A gateway started before the host record existed still publishes ``served_profiles`` into
the default home's ``gateway_state.json``; that plus a proven-live PID is the same fact."""
from hermes_cli.gateway_multiplex_served import live_default_gateway_pid, recorded_served_profiles
pid = live_default_gateway_pid()
if pid is None:
return None
served = recorded_served_profiles() or []
roster = ["default"] + [str(p) for p in served if _normalized(p) != "default"]
return HostGatewayTopology(pid=int(pid), profiles=tuple(roster), source="served_record")
def host_gateway_topology() -> Optional[HostGatewayTopology]:
"""The one live host gateway and its served profiles, or None when no gateway owns the role."""
for rung in (_from_host_record, _from_served_record):
topology = rung()
if topology is not None:
return topology
return None
def host_gateway_serving(profile_name: Optional[str] = None) -> Optional[HostGatewayTopology]:
"""The host gateway when it serves ``profile_name`` (default: the active profile), else None."""
topology = host_gateway_topology()
if topology is None:
return None
if profile_name is None:
from hermes_cli.profiles import get_active_profile_name
profile_name = get_active_profile_name()
return topology if topology.serves(profile_name) else None