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".
113 lines
5.2 KiB
Python
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
|