Files
hermes-agent/gateway/host_attach.py
kshitijk4poor fa4714ca6a refactor(gateway): trim the --replace ownership prose and reuse _migrate_command
One statement of the ownership rule (module doc) instead of three; the
lock-refusal docstring keeps only why --replace is not offered. The
record-less-holder note names the real cause (a holder this process cannot
interrogate), and the start-beside INFO line and the lock refusal take the
migrate command from MIGRATE_COMMAND / _migrate_command() like their
siblings.
2026-09-24 13:58:24 +05:30

395 lines
18 KiB
Python

"""Is there ONE live host gateway, and does it already serve this profile?
Multiplex-only (Teknium ruling): exactly one MULTIPLEXING ``hermes gateway run`` per host, serving
every profile; standalone per-profile gateways coexist until that migration is forced (#109417).
The lifecycle verbs therefore answer a different question than they used to — not "does THIS home
hold a ``gateway.pid``?" but "is the host process live, and is this profile in its served set?" —
and when it is not, they ask that process to serve the profile instead of starting a second one.
Five outcomes, in order:
* ``ATTACH`` — a live host gateway already serves this profile. Nothing to start; exit 0.
* ``RESCAN``→ATTACH — it does not serve it yet: ask it to reconcile ``profiles/`` now (control
socket ``rescan-profiles``) and attach once the answer includes us.
* ``REPLACE_HOST`` — ``--replace`` targets the host process when it serves this profile, whichever
home launched it. An owner that has not published its served set yet is targeted too, but the
ownership guard (``run._replace_target_belongs_to_other_profile``) can then only prove it from
THIS home's pid record, so from another home the replace is refused (exit 1).
* ``REFUSE`` — a live MULTIPLEXING gateway exists and cannot be made to serve this profile.
Never start a second one silently.
* ``START`` — no live owner, or the owner answers ``multiplex: False``: it is another
profile's standalone gateway (the documented one-process-per-profile topology), not a
multiplexer that excluded us, so this profile runs its own gateway beside it as it always did.
**The attach channel is the OWNER's control socket, never ours.** Ordering matters: the owner
publishes its rendezvous record when it claims its PID file and binds its control socket a moment
later (``gateway/run.py``: claim → socket), so for a short window the record exists and the channel
does not. A reader that took "no socket" for "no owner" would start exactly the second gateway this
module prevents — but a reader that took the RECORD's word for the served set is worse: the
claim-time record is published before the process knows what it will serve, so a supervised unit
for a profile nobody serves would stand down forever. Hence the split:
* the record proves an OWNER exists (PID + createTime), and that alone never yields ATTACH;
* the served set comes ONLY from a live ``identify`` answer, waited for a bounded
:data:`ATTACH_CHANNEL_WAIT_S`;
* owner present + served set unknown is a TRANSIENT verdict — do not start, do not park.
Nothing here depends on the *calling* process having started anything.
"""
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
#: How long a caller waits for the owner's control socket after seeing its record (see module doc).
ATTACH_CHANNEL_WAIT_S = 5.0
_CHANNEL_POLL_S = 0.25
START = "start"
ATTACH = "attach"
REFUSE = "refuse"
REPLACE_HOST = "replace-host"
def _normalize(name: str) -> str:
try:
from hermes_cli.profiles import normalize_profile_name
return normalize_profile_name(name or "default")
except Exception:
return (name or "default").strip().lower()
def profile_name_for_home(home: Path | str) -> str:
"""Profile a home belongs to; the root/default home is ``'default'`` (not ``None``)."""
from gateway.status import _profile_name_for_home
return _profile_name_for_home(Path(home)) or "default"
@dataclass(frozen=True)
class HostGateway:
"""The one live host gateway: who it is, where it was launched from, what it serves."""
pid: int
home: Path
profiles: tuple[str, ...]
#: False when the owner has not answered ``identify`` yet: an owner exists, but which profiles
#: it serves is UNKNOWN. Never conflate that with "serves nothing" — see the module doc.
served_known: bool = True
#: True once the owner has said ``multiplex: False``: a per-profile gateway that cannot be asked
#: to serve anyone else — see ``START`` in the module doc.
standalone: bool = False
def serves(self, profile: str) -> bool:
if not self.served_known:
return False
wanted = _normalize(profile)
return any(_normalize(p) == wanted for p in self.profiles)
@property
def profile_label(self) -> str:
return profile_name_for_home(self.home)
def describe(self) -> str:
if not self.served_known:
served = "not published yet (its control socket has not answered)"
else:
served = ", ".join(self.profiles) if self.profiles else "nothing"
return f"PID {self.pid} (launched by profile '{self.profile_label}'; serves: {served})"
def _record_home(record) -> Path:
"""Home the owner was launched from. Records written before the field existed fall back to the
default root — the home every pre-record multiplexer ran under."""
from hermes_constants import get_default_hermes_root
return Path(record.home) if getattr(record, "home", "") else Path(get_default_hermes_root())
def _identify(home: Path) -> Optional[dict]:
try:
from gateway.control_socket import identify_gateway
return identify_gateway(home)
except Exception:
logger.debug("host gateway identify failed for %s", home, exc_info=True)
return None
def _served_from_identity(identity: dict) -> tuple[str, ...]:
"""Served set from a live ``identify``. A STANDALONE gateway publishes no ``served_profiles``;
it serves its own profile and nothing else, which is not the same as "unknown"."""
served = identity.get("served_profiles")
if isinstance(served, list) and served:
return tuple(str(p) for p in served)
return (str(identity.get("profile") or "default"),)
def _identity_matches(identity, record, home: Path) -> bool:
"""Is this ``identify`` answer really the record's owner?
PID alone is not enough: a record naming an arbitrary home makes us dial whatever listens
there, so the answer must also agree about the home it was launched from.
"""
if not isinstance(identity, dict) or identity.get("pid") != record.pid:
return False
reported = identity.get("hermes_home")
if not reported:
return True # older gateway: PID + a socket keyed by this home is all it can prove
try:
from gateway.status import _same_hermes_home
return bool(_same_hermes_home(Path(str(reported)), home))
except Exception:
return str(reported) == str(home)
#: A CLI invocation asks this question once per profile (``gateway status`` across N profiles,
#: doctor, the lifecycle guards); a gateway PROCESS asks it for the life of the process, so the
#: memo is time-bounded rather than permanent. Writes invalidate it eagerly.
HOST_GATEWAY_CACHE_TTL_S = 2.0
_cached_probe: Optional[tuple[float, Optional[HostGateway]]] = None
def invalidate_host_gateway_cache() -> None:
"""Forget the memoized probe (called by ``host_rendezvous`` on every record write)."""
global _cached_probe
_cached_probe = None
def _probe_host_gateway(wait_for_channel: float) -> Optional[HostGateway]:
from gateway import host_rendezvous as hr
record = hr.read_record(hr.ROLE_GATEWAY)
if record is None:
return None
# Liveness BEFORE the dial. A record we cannot prove live must not make us open a socket at an
# address it chose; proving the PID first is also what keeps a stale record from naming a peer.
if not hr.liveness_is_proven(record):
return None
home = _record_home(record)
deadline = time.monotonic() + max(0.0, wait_for_channel)
while True:
identity = _identify(home)
if _identity_matches(identity, record, home):
return HostGateway(record.pid, home, _served_from_identity(identity))
if time.monotonic() >= deadline:
break
time.sleep(_CHANNEL_POLL_S)
# An owner exists and has not answered: the served set is UNKNOWN, never the record's word.
return HostGateway(record.pid, home, (), served_known=False)
def host_gateway(*, wait_for_channel: float = 0.0) -> Optional[HostGateway]:
"""The one live host gateway, or ``None``.
The served set comes from the owner's control socket and nowhere else; a record with no live
answer behind it yields ``served_known=False`` — an owner whose served set nobody knows yet.
"""
global _cached_probe
now = time.monotonic()
if wait_for_channel <= 0 and _cached_probe is not None and now - _cached_probe[0] < HOST_GATEWAY_CACHE_TTL_S:
return _cached_probe[1]
result = _probe_host_gateway(wait_for_channel)
_cached_probe = (time.monotonic(), result)
return result
def host_gateway_serving(profile: str, *, wait_for_channel: float = 0.0) -> Optional[HostGateway]:
"""The host gateway when it is live AND serves ``profile`` — true for ``default`` too."""
gateway = host_gateway(wait_for_channel=wait_for_channel)
return gateway if gateway is not None and gateway.serves(profile) else None
def request_serve_profile(profile: str, *, timeout: float = 8.0,
owner: Optional[HostGateway] = None) -> Optional[HostGateway]:
"""Ask the live host gateway to reconcile ``profiles/`` now; return it once it serves
``profile``. ``None`` when nobody answered or a multiplexer's roster still excludes the profile.
An owner that answers ``multiplex: False`` comes back flagged ``standalone``: it cannot take the
profile, and it is not a multiplexer that refused — the caller runs beside it, as before."""
gateway = owner if owner is not None else host_gateway(wait_for_channel=ATTACH_CHANNEL_WAIT_S)
if gateway is None or gateway.serves(profile):
return gateway
try:
from gateway.control_socket import rescan_gateway_profiles
answer = rescan_gateway_profiles(gateway.home, timeout=timeout)
except Exception:
logger.debug("host gateway rescan failed", exc_info=True)
return None
if not isinstance(answer, dict):
return None
served = answer.get("served_profiles")
rescanned = HostGateway(
gateway.pid, gateway.home,
tuple(str(p) for p in served) if isinstance(served, list) else (),
standalone=answer.get("multiplex") is False)
return rescanned if rescanned.standalone or rescanned.serves(profile) else None
@dataclass(frozen=True)
class HostAttachDecision:
outcome: str
message: str
owner: Optional[HostGateway] = None
#: True when the verdict is a RUNTIME observation ("someone else serves me right now", "the
#: owner has not answered yet") rather than a config-derived permanent refusal. A supervisor
#: must RETRY a transient verdict; parking the unit on one strands the profile forever.
transient: bool = False
def attach_message(gateway: HostGateway, profile: str) -> str:
return (
f"✓ The host gateway already serves profile '{profile}' — nothing to start.\n"
f" {gateway.describe()}\n"
f" One gateway per host serves every profile; manage it with "
f"`hermes -p {gateway.profile_label} gateway restart`.")
def _unknown_served_message(gateway: HostGateway, profile: str) -> str:
return (
f"⏳ A gateway already owns this host and has not published its served set yet.\n"
f" {gateway.describe()}\n"
f" Whether it will serve profile '{profile}' is unknown, so starting a second gateway\n"
f" now could double-bind this profile's platforms. Nothing was started; this is a\n"
f" transient state and a service supervisor will retry.\n"
f" Take the host over (only from the home that launched it): hermes gateway run --replace\n"
f" Start anyway: hermes gateway run --force")
def _refuse_message(gateway: HostGateway, profile: str) -> str:
from hermes_cli.gateway_migrate import MIGRATE_COMMAND
return (
f"❌ A gateway already owns this host and will not serve profile '{profile}'.\n"
f" {gateway.describe()}\n"
f" Exactly one gateway per host serves every profile, so starting a second one\n"
f" would double-bind this profile's platforms.\n"
f" Fold this profile into it: {MIGRATE_COMMAND}\n"
f" Or start one anyway: hermes gateway run --force\n"
f" (--replace only replaces an owner that serves this profile, so it would not take this one over.)")
def standalone_rescan_message(profile: str) -> str:
return (
f"The host gateway still serves profile '{profile}'; gateway.standalone is not live yet. "
"Wait for the host gateway to rescan (<=30s), or send the rescan-profiles control verb "
"to the host gateway before starting this profile's gateway.")
def _coexisting_gateways(owner: Optional[HostGateway]):
"""A standalone lock owner can hide a multiplexer launched beside it.
Use the existing per-home liveness and control channels, not the single host
record, to ask every running profile gateway what it actually serves.
"""
from gateway.status import live_gateway_pid_for_home
from hermes_cli.profiles import profiles_to_serve
seen = {os.getpid()}
if owner is not None:
seen.add(owner.pid)
yield owner
for _name, home in profiles_to_serve(True, include_standalone=True, include_parked=True):
pid = live_gateway_pid_for_home(home)
if pid is None or pid in seen:
continue
seen.add(pid)
peer = HostGateway(pid, home, (), served_known=False)
identity = _identify(home)
if isinstance(identity, dict) and _identity_matches(identity, peer, home):
peer = HostGateway(pid, home, _served_from_identity(identity),
standalone=identity.get("multiplex") is False)
yield peer
def standalone_attach_decision(our_home: Path, owner: Optional[HostGateway]) -> Optional[HostAttachDecision]:
"""An opt-out permits coexistence only after every live gateway confirms we are unserved.
Shared by the initial attach check and the lock-losing race check.
"""
from hermes_cli.profiles import profile_is_standalone
if not profile_is_standalone(our_home):
return None
profile = profile_name_for_home(our_home)
for peer in _coexisting_gateways(owner):
if not peer.served_known:
return HostAttachDecision(REFUSE, _unknown_served_message(peer, profile), peer, transient=True)
if peer.serves(profile):
return HostAttachDecision(REFUSE, standalone_rescan_message(profile), peer, transient=True)
logger.info("Profile '%s' is standalone by config; starting beside the host multiplexer", profile)
return HostAttachDecision(START, "", owner)
def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
"""Attach, rescan-then-attach, replace or refuse; configured standalone profiles may coexist.
Never raises: a broken probe degrades to ``START``, i.e. exactly the pre-rendezvous behaviour.
"""
profile = profile_name_for_home(our_home)
try:
gateway = host_gateway()
except Exception:
logger.debug("host gateway probe failed; starting as before", exc_info=True)
return HostAttachDecision(START, "")
if gateway is None or gateway.pid == os.getpid():
return standalone_attach_decision(our_home, None) or HostAttachDecision(START, "")
if replace and (gateway.serves(profile) or not gateway.served_known):
# An owner known not to serve us is another profile's gateway: replacing it is always refused
# (_replace_target_belongs_to_other_profile fails closed) and the gateway exits, so on a
# one-process-per-profile fleet, whose generated units all carry --replace, every unit but
# the lock holder respawn-storms. Such an owner takes the non-replace path below instead.
return HostAttachDecision(REPLACE_HOST, "", gateway)
if gateway.served_known:
standalone = standalone_attach_decision(our_home, gateway)
if standalone is not None:
return standalone
if gateway.serves(profile):
return HostAttachDecision(ATTACH, attach_message(gateway, profile), gateway, transient=True)
if not gateway.served_known:
# Give the owner its bounded window to answer before judging it: during the boot race the
# record lands a moment before the control socket binds.
waited = host_gateway(wait_for_channel=ATTACH_CHANNEL_WAIT_S)
if waited is None:
return HostAttachDecision(START, "")
gateway = waited
standalone = standalone_attach_decision(our_home, gateway)
if standalone is not None:
return standalone
if gateway.serves(profile):
return HostAttachDecision(ATTACH, attach_message(gateway, profile), gateway, transient=True)
try:
attached = request_serve_profile(profile, owner=gateway)
except Exception:
logger.debug("host gateway rescan request failed", exc_info=True)
attached = None
if attached is not None and attached.serves(profile):
return HostAttachDecision(ATTACH, attach_message(attached, profile), attached, transient=True)
if attached is not None and attached.standalone:
# One-process-per-profile fleet: the owner is another profile's standalone gateway. Refusing
# here exits 78, which every supervisor treats as permanent — on a launchd fleet that parked
# every unit but the first to claim the host lock. Start beside it. decide() runs twice per
# start (CLI guard + start_gateway), so this is INFO; the host-lock claim in run.py logs the
# one WARNING with the `gateway migrate --multiplex` converge hint.
from hermes_cli.gateway_migrate import MIGRATE_COMMAND
logger.info(
"Another profile's standalone gateway owns this host (%s); starting profile '%s' beside it. "
"Fold every profile onto one gateway with: %s",
attached.describe(), profile, MIGRATE_COMMAND)
return HostAttachDecision(START, "")
if not gateway.served_known:
# The owner never answered, so we know only that it exists. ATTACH here (on the record's
# word) parked a supervised unit against a served set nobody had committed to yet.
return HostAttachDecision(
REFUSE, _unknown_served_message(gateway, profile), gateway, transient=True)
return HostAttachDecision(REFUSE, _refuse_message(gateway, profile), gateway)