Files
hermes-agent/gateway/host_topology.py
teknium1 304cf75bbd fix(review): a second tenant's host gateway never attaches, refuses, or reports as ours (#121352)
The PR filtered a foreign tenant root only in hermes_cli.gateway.host_multiplexer_serving; the
seam `gateway run` actually goes through had no cross-tenant path, so tenant B's gateway still
never started: host_attach.decide() answered ATTACH (exit 0, nothing running) on tenant A's
record "serving default", and run._claim_host_gateway_role lost the per-OS-user host lock to A
and refused 75 forever (a race B can never win).

* gateway/host_attach.py: one predicate, launched_by_other_tenant(owner_home, our_home), used by
  decide() (foreign owner -> START), by the lock-loss branch of _claim_host_gateway_role (start
  beside it, WARNING), by host_multiplexer_serving (replaces its inline copy) and by
  host_topology._from_host_record (doctor / cron status / dashboard ladder no longer report a
  foreign tenant's process as this tenant's host gateway).
* gateway/host_topology.py + gateway/status.py: HostGatewayTopology carries the launch home, and
  multiplexer_liveness_for_profile reads the multiplexer's gateway_state.json from THAT home, so a
  named-hosted multiplexer no longer projects a stale standalone record from the default root.
* hermes_cli/gateway_multiplex_mode.py: recorded_standalone_warning_lines gates on gateway_state
  + live PID only; a paused/wedged-heartbeat live gateway still warns.
* docs: `hermes -p X gateway restart` on a parked profile with no live gateway behaves as start.

Tests: one regression per finding (decide()+lock loss; topology home+tenant filter), both red on
the PR head. tests/gateway/test_host_gateway_lock_refusal.py publishes its record from the
process home instead of an arbitrary tmp dir (the home was never consulted before; assertions
unchanged). test_gateway_multiplex_mode case 3 flips to "stale heartbeat + live PID still warns".
2026-09-28 13:30:57 -07:00

113 lines
5.2 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 pathlib import Path
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
#: Home the process was launched from (its ``gateway_state.json`` lives there); None when the
#: rung cannot tell, which readers resolve to the default root.
home: Optional[Path] = None
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
from gateway.host_attach import launched_by_other_tenant, record_home
from hermes_constants import get_hermes_home
# Another tenant root's gateway is a name collision, not this tenant's host process (#121352).
if launched_by_other_tenant(record.home, get_hermes_home()):
return None
return HostGatewayTopology(pid=int(record.pid), profiles=tuple(record.profiles), source="host_record",
home=record_home(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