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.
102 lines
4.6 KiB
Python
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
|