feat(gateway): gateway.standalone opts a named profile out of the host multiplexer

A named profile that authors `gateway.standalone: true` in its own config.yaml
runs its own gateway again, the pre-multiplex topology, while the default
gateway keeps serving every other profile. Topology becomes something the
operator authors per profile instead of something the box infers from boot
state, which is what a fleet running per-profile gateways lost when
`gateway.multiplex_profiles: false` was retired.

Changed
- hermes_cli/profiles.py: `profile_is_standalone(home)` reads the profile's
  own config.yaml (memo by file signature, tolerant of malformed yaml, always
  False for the default profile with one warning). `profiles_to_serve()`
  excludes standalone profiles; roster callers that mean "every installed
  profile" (plugin deps, Windows update, launch policy, dashboard listing and
  topology) pass `include_standalone=True`.
- gateway/host_attach.py: `standalone_attach_decision` starts a standalone
  profile's gateway beside the host multiplexer once every live gateway
  confirms it does not serve that profile; refuses with a rescan message while
  one still does. Used by the initial attach check and the lock-losing race.
- hermes_cli/gateway_multiplex_mode.py: a standalone launcher never becomes
  the host multiplexer (`STANDALONE_PROFILE_REASON`), including callers that
  supply an explicit GatewayConfig.
- hermes_cli/gateway.py, web_server_gateway.py: `hermes -p X gateway
  install/start/run` proceeds without --force for a standalone profile; the
  refusal text for other profiles points at the opt-out; status shows
  "standalone (gateway.standalone: true)" and the default lists skipped
  profiles.
- gateway/run_profile_reconcile.py: the host does not re-adopt a profile whose
  own gateway is live (removing the key while it runs no longer double-binds).
- hermes_cli/gateway_migrate.py: standalone profiles are neither blocker nor
  fold target; the plan lists them as "standalone by config".
- gateway/run.py: one INFO line per standalone profile at host boot.

Tests: two-home E2E through real loaders and resolve_multiplex_mode, decide()
with fake host records for both arms, lock-losing branch, reconcile guard,
migrate plan, refusal predicate both ways, topology, memo and malformed-yaml
contracts. All red on base.
This commit is contained in:
Victor Kyriazakos
2026-09-23 00:32:07 +00:00
committed by Teknium
parent 550d74c62f
commit 0238c9d740
24 changed files with 803 additions and 21 deletions

View File

@@ -272,8 +272,60 @@ def _refuse_message(gateway: HostGateway, profile: str) -> str:
f" Or start one anyway: hermes gateway run --force")
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):
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 — never a second gateway beside a multiplexer.
"""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.
"""
@@ -284,11 +336,15 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
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 HostAttachDecision(START, "")
return standalone_attach_decision(our_home, None) or HostAttachDecision(START, "")
if replace:
# --replace is explicit authority over the host role; the target is the host process,
# whichever home launched it.
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:
@@ -298,6 +354,9 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
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:

View File

@@ -3482,9 +3482,14 @@ class GatewayRunner(
# With multiplex_profiles on, load under the default profile secret scope so bot tokens in its
# .env resolve as secondary profiles' do; explicit config= injection (tests) is left untouched.
# See #64674.
# An injected config (tests, ``gateway run --config``) is taken verbatim: an unset flag there
# stays None (= standalone); only the loaded path runs the boot-time default-on guard.
# Injected configs keep their mode (including None), except the launching profile's
# standalone opt-out: --config must not turn that profile into a host multiplexer.
self.config = config if config is not None else load_gateway_config_for_runner()
if config is not None:
from hermes_cli.gateway_multiplex_mode import standalone_launcher_decision, log_multiplex_decision
decision = standalone_launcher_decision(self.config)
if decision is not None:
log_multiplex_decision(decision)
# Multiplexer flag flips agent.secret_scope.get_secret() to fail-closed on unscoped credential
# reads, so a missed migration crashes loudly instead of leaking a cross-profile value.
try:
@@ -5361,6 +5366,15 @@ def _claim_host_gateway_role(force: bool = False) -> None:
"""
from gateway import host_rendezvous as hr
# The host lock can be free after a standalone owner exits while a coexisting
# multiplexer remains live. Its per-home channel still governs our opt-out.
if not force:
from gateway.host_attach import REFUSE, standalone_attach_decision
decision = standalone_attach_decision(get_hermes_home(), None)
if decision is not None and decision.outcome == REFUSE:
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
print(decision.message)
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
try:
outcome, error = hr.claim_host_lock(hr.ROLE_GATEWAY)
if outcome is hr.HostLockOutcome.ACQUIRED:
@@ -5394,6 +5408,22 @@ def _claim_host_gateway_role(force: bool = False) -> None:
logger.warning("--force: starting a second gateway although %s owns this host.",
hr.describe(owner) if owner else "another process")
return
from gateway.host_attach import (
ATTACH_CHANNEL_WAIT_S, START, host_gateway, standalone_attach_decision,
)
from hermes_cli.profiles import profile_is_standalone
if profile_is_standalone(get_hermes_home()):
# Recheck after losing the atomic lock: the pre-lock served set may be stale.
live_owner = host_gateway(wait_for_channel=ATTACH_CHANNEL_WAIT_S)
if live_owner is not None:
decision = standalone_attach_decision(get_hermes_home(), live_owner)
if decision is not None:
if decision.outcome == START:
return
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
print(decision.message)
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
_refuse_second_host_gateway(owner)
if _owner_is_standalone():
# COMPOSITION with #118236: `host_attach.decide` sent us here with START precisely because
# the owner is another profile's STANDALONE gateway and will never serve us. Refusing now
@@ -5454,6 +5484,25 @@ def _refuse_second_host_gateway(owner) -> None:
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
def _log_standalone_profiles_at_boot(runner) -> None:
"""One INFO line per standalone profile when the MULTIPLEXER takes its served set at boot.
The host gateway silently omits an opted-out profile from its served set; without this line an
operator reading the boot log cannot tell "not created yet" from "excluded by config".
"""
try:
if not getattr(runner.config, "multiplex_profiles", False):
return
from hermes_cli.profiles import profiles_to_serve, profile_is_standalone
served = set(runner.served_profile_names())
for name, home in profiles_to_serve(True, include_standalone=True):
if name != "default" and name not in served and profile_is_standalone(home):
logger.info("profile '%s' is standalone (gateway.standalone: true); not served by "
"this gateway", name)
except Exception:
logger.warning("standalone-profile boot notice failed", exc_info=True)
def _refresh_host_gateway_record(runner) -> None:
"""Republish the host record with the SETTLED served set, now that the channel answers.
@@ -5810,6 +5859,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
_control_server = await _start_gateway_start_control_socket(runner)
# Now the attach channel answers: republish the host record with the settled served set.
_refresh_host_gateway_record(runner)
_log_standalone_profiles_at_boot(runner)
def _lifecycle_record_startup() -> None:
# Report if the previous life died uncleanly (SIGKILL / OOM / VM death), then claim the

View File

@@ -48,6 +48,7 @@ class GatewayProfileReconcileMixin:
_served_profile_homes: Optional[Dict[str, "Path"]] = None
_served_profile_signatures: Optional[Dict[str, tuple]] = None
_profile_reconcile_lock: Optional[asyncio.Lock] = None
_profile_own_gateway_warned: Optional[set[str]] = None
# ── state helpers ─────────────────────────────────────────────────────────────────────────────
@@ -105,6 +106,20 @@ class GatewayProfileReconcileMixin:
active = getattr(self, "_primary_profile_name", None) or "default"
current = {str(name): Path(home) for name, home in _multiplex_profile_homes(self.config)}
known = dict(self._served_profile_homes or {})
from gateway.status import live_gateway_pid_for_home
blocked = set()
warned = self._profile_own_gateway_warned or set()
for name in list(current):
if name == active or name in known:
continue
if live_gateway_pid_for_home(current[name]) is not None:
blocked.add(name)
if name not in warned:
logger.warning("[MULTIPLEX] Profile '%s' still runs its own gateway; "
"stop it before the host can serve this profile", name)
del current[name]
self._profile_own_gateway_warned = blocked
sigs = self._served_profile_signatures or {}
added = [n for n in current if n not in known and n != active]
removed = [n for n in known if n not in current and n != active]