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]

View File

@@ -3705,6 +3705,11 @@ def _served_profile_needs_no_service() -> bool:
# Not served (yet): a named profile still gets no service of its own — same rule and text
# as `gateway install`, so `hermes -p X setup` cannot grow a fleet member the verb refuses.
return _named_profile_refused_under_multiplexer()
from hermes_cli.profiles import profile_is_standalone
if profile_is_standalone(get_hermes_home()):
from gateway.host_attach import standalone_rescan_message
print_info(standalone_rescan_message(_current_profile_name()))
return True
print_success(
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
)
@@ -3731,6 +3736,12 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
try:
suffix = _current_profile_name()
from hermes_constants import profile_name_for_home
from hermes_cli.profiles import profile_is_standalone
# A profile that authored gateway.standalone: true opted out of the host multiplexer: it is
# allowed a gateway of its own without --force. Only a RUNNING host record that still lists
# it (the host has not rescanned since the key was set) is refused with the rescan remedy.
standalone = (profile_name_for_home(get_hermes_home()) not in (None, "default")
and profile_is_standalone(get_hermes_home()))
# A unit/plist/task already registered for this home was installed with --force: that fleet
# member (and the supervisor relaunching it, whose ExecStart carries no --force) is not NEW.
new_standalone = (profile_name_for_home(get_hermes_home()) not in (None, "default")
@@ -3739,6 +3750,12 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
return False
owner = _served_by_another_host_gateway()
served = owner is not None or named_profile_served_by_running_multiplexer()
if standalone:
if not served:
return False
from gateway.host_attach import standalone_rescan_message
print_error(standalone_rescan_message(suffix))
return True
if not served and not new_standalone:
return False
@@ -3769,6 +3786,11 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
print()
print(" A separate per-profile gateway (for a fleet split across UNIX users or a")
print(f" HERMES_HOME outside profiles/) needs --force: hermes -p {suffix} gateway install --force")
print()
from hermes_constants import display_hermes_home
print(" Or opt this profile out of the host gateway for good: set")
print(f" gateway.standalone: true in {display_hermes_home(get_hermes_home())}/config.yaml.")
print(" Wait for the host gateway to rescan (<=30s), or send its rescan-profiles control verb.")
return True
@@ -4996,10 +5018,14 @@ def _cmd_status(args):
full = getattr(args, "full", False)
system = getattr(args, "system", False)
snapshot = get_gateway_runtime_snapshot(system=system)
from hermes_cli.profiles import get_active_profile_name
from hermes_cli.profiles import get_active_profile_name, profile_is_standalone
active_standalone = ((get_active_profile_name() or "default") != "default"
and profile_is_standalone(get_hermes_home()))
if active_standalone:
print("standalone by config (gateway.standalone: true)")
_windows_service_installed = is_windows() and _gw_windows().is_installed()
if not snapshot.running and named_profile_served_by_running_multiplexer():
if not active_standalone and not snapshot.running and named_profile_served_by_running_multiplexer():
# Satellite profile: the default multiplexer is the live inbound process for it.
print("✓ Gateway is running via the default-profile multiplexer")
print(" Manage it from the default profile: hermes gateway status")
@@ -5035,6 +5061,20 @@ def _cmd_status(args):
_print_duplicate_credential_warnings()
_print_other_profiles_gateway_status()
_print_standalone_by_config()
def _print_standalone_by_config() -> None:
"""Default-profile status: name the profiles that opted out of the host multiplexer by config,
so the served set the host gateway reports is not mistaken for the installed roster."""
from hermes_cli.profiles import get_active_profile_name, profiles_to_serve
if (get_active_profile_name() or "default") != "default":
return
roster = {name for name, _home in profiles_to_serve(True, include_standalone=True)}
served = {name for name, _home in profiles_to_serve(True)}
names = sorted(roster - served - {"default"})
if names:
print(f"standalone by config: {', '.join(names)}")
def _cmd_list(args):

View File

@@ -114,6 +114,9 @@ class MigrationPlan:
interrupted: bool = False
blockers: list[str] = field(default_factory=list)
notices: list[str] = field(default_factory=list)
# Profiles that authored `gateway.standalone: true`: they keep their own gateway and are neither
# a blocker nor a fold target — the plan names them so the operator knows they were left alone.
standalone_by_config: tuple[str, ...] = ()
@property
def secondaries(self) -> list[ProfileGateway]:
@@ -160,6 +163,7 @@ class MigrationPlan:
return {
"default_home": str(self.default_home),
"profiles": [p.to_dict() for p in self.profiles],
"standalone_by_config": list(self.standalone_by_config),
"multiplex_flag_on": self.multiplex_flag_on,
"live_served": self.live_served,
"already_multiplexed": self.already_multiplexed,
@@ -557,6 +561,11 @@ def build_migration_plan() -> MigrationPlan:
live_served=recorded_served_profiles(default_home),
manifest=_read_manifest(default_home),
)
from hermes_cli.profiles import profiles_to_serve
foldable = {name for name, _home in _profile_homes()}
plan.standalone_by_config = tuple(
name for name, _home in profiles_to_serve(True, include_standalone=True)
if name != "default" and name not in foldable)
plan.interrupted = plan.multiplex_flag_on and _manifest_not_yet_served(plan.manifest, plan.live_served)
if len(plan.profiles) < 2:
plan.notices.append("Only one profile exists: nothing to multiplex.")
@@ -609,6 +618,9 @@ def format_plan(plan: MigrationPlan, *, dry_run: bool) -> list[str]:
lines = [head, f" default home: {plan.default_home}", "", " profile gateway pid service"]
for p in plan.profiles:
lines.append(f" {p.name:<12} {str(p.pid or '-'):<13} {p.service_label()}")
if plan.standalone_by_config:
lines.append(f" Standalone by config (gateway.standalone: true), left alone: "
f"{', '.join(plan.standalone_by_config)}")
lines.append("")
if plan.already_multiplexed:
lines.append(" ✓ The default gateway is already multiplexing"

View File

@@ -26,6 +26,7 @@ from typing import Optional
logger = logging.getLogger(__name__)
SINGLE_PROFILE_REASON = "only one profile exists (nothing to multiplex)"
STANDALONE_PROFILE_REASON = "this profile is standalone (gateway.standalone: true); it serves only itself"
#: ``gateway.multiplex_profiles: false`` is no longer an opt-out from the one-gateway-per-host
#: topology; it parses, it is reported, and it is ignored.
@@ -90,6 +91,22 @@ class MultiplexDecision:
reason: str = ""
def _standalone_launcher() -> bool:
from hermes_constants import get_hermes_home, profile_name_for_home
from hermes_cli.profiles import profile_is_standalone
home = get_hermes_home()
return profile_name_for_home(home) not in (None, "default") and profile_is_standalone(home)
def standalone_launcher_decision(config) -> Optional[MultiplexDecision]:
"""The per-profile opt-out also binds callers supplying an explicit GatewayConfig."""
if not _standalone_launcher():
return None
config.multiplex_profiles = False
return MultiplexDecision(False, "guard", STANDALONE_PROFILE_REASON)
def implicit_multiplex_blocker() -> Optional[str]:
"""Why THIS process must not multiplex right now, or None when it may.
@@ -109,6 +126,8 @@ def implicit_multiplex_blocker() -> Optional[str]:
verb built on "the default's multiplexer" blind to the process actually serving the host.
"""
from hermes_cli.profiles import profiles_to_serve
if _standalone_launcher():
return STANDALONE_PROFILE_REASON
# Cheap and first: a single-profile install has nothing to multiplex, and the fail-closed secret
# scope the multiplexer arms buys it nothing. (Also keeps every embedded/test runner off the
# service-manager probes below.) Create a second profile and restart to start serving it.
@@ -141,6 +160,9 @@ def resolve_multiplex_mode(config) -> MultiplexDecision:
says why, and it converges by itself once ``hermes gateway migrate --multiplex`` has run.
"""
current = getattr(config, "multiplex_profiles", None)
standalone = standalone_launcher_decision(config)
if standalone is not None:
return standalone
if current:
return MultiplexDecision(True, "config")
retired_opt_out = current is False
@@ -177,8 +199,7 @@ def log_multiplex_decision(decision: MultiplexDecision) -> None:
logger.info("Single-profile install: gateway.multiplex_profiles unset, serving the default profile only.")
elif decision.source == "guard":
logger.warning(
"This gateway stays standalone: %s. It serves the default profile only; the host "
"converges once that is resolved.",
"This gateway stays standalone: %s. It serves only the launching profile.",
decision.reason)
elif decision.source == "default":
logger.info("Serving every profile on this host (gateway.multiplex_profiles unset; default on).")

View File

@@ -234,7 +234,7 @@ def enabled_declarations(home: Path) -> list[PythonDeclaration]:
def dependency_homes() -> list[Path]:
"""Every home whose plugins share this venv: the default home plus live named profiles."""
from hermes_cli.profiles import profiles_to_serve
return [home for _name, home in profiles_to_serve(multiplex=True)]
return [home for _name, home in profiles_to_serve(multiplex=True, include_standalone=True)]
def union_specs(declarations: Iterable[PythonDeclaration]) -> list[str]:

View File

@@ -944,19 +944,92 @@ def list_profiles(*, lazy_skill_count: bool = False) -> List[ProfileInfo]:
return profiles
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
#: One signature/result per home: the webhook and
#: api-server callers run :func:`profiles_to_serve` per inbound request, so the reader
#: must not re-parse a profile's config.yaml every time.
_STANDALONE_MEMO: Dict[str, Tuple[Optional[tuple], Optional[bool]]] = {}
_STANDALONE_WARNED = False
_STANDALONE_DEFAULT_WARNING = (
"gateway.standalone is ignored on the default profile: it is the host gateway")
def profile_is_standalone(home: Path) -> bool:
"""Does ``home``'s own config.yaml opt this profile out of the host multiplexer
(``gateway.standalone: true``)? Memoised by file signature. The DEFAULT profile is
never standalone — it IS the host — and warns once per process if the key is set there."""
global _STANDALONE_WARNED
from yaml import YAMLError
from utils import file_signature
home = Path(home)
cfg_path = home / "config.yaml"
key = str(home)
try:
signature = file_signature(cfg_path.stat())
except FileNotFoundError:
signature = None
except OSError as exc:
signature = ("stat-error", exc.errno)
if _STANDALONE_MEMO.get(key) != (signature, False):
logger.warning("Cannot read gateway.standalone from %s (%s); treating as not standalone",
cfg_path, type(exc).__name__)
_STANDALONE_MEMO[key] = (signature, False)
return False
cached = _STANDALONE_MEMO.get(key)
if cached is not None and cached[0] == signature and cached[1] is not None:
return cached[1]
value = None
if signature is not None:
from hermes_cli.config import read_user_config_raw
try:
cfg = read_user_config_raw(cfg_path) or {}
except (YAMLError, OSError, UnicodeError) as exc:
if cached is None or cached[0] != signature:
logger.warning("Cannot read gateway.standalone from %s (%s); treating as not standalone",
cfg_path, type(exc).__name__)
# Access can recover without changing the signature. None retains the
# warning receipt without caching a transient failure as config.
_STANDALONE_MEMO[key] = (signature, False if isinstance(exc, YAMLError) else None)
return False
if isinstance(cfg.get("gateway"), dict):
value = cfg["gateway"].get("standalone")
result = _standalone_truthy(value)
if home == _get_default_hermes_home():
if result and not _STANDALONE_WARNED:
logger.warning(_STANDALONE_DEFAULT_WARNING)
_STANDALONE_WARNED = True
result = False
_STANDALONE_MEMO[key] = (signature, result)
return result
def _standalone_truthy(value: object) -> bool:
"""``gateway.standalone`` truthiness via the shared bool parser; only the
``gateway:`` section's ``standalone`` key is read (no top-level alias)."""
from gateway.config import _bool_token
if isinstance(value, str):
return _bool_token(value) is True
return bool(value)
def profiles_to_serve(multiplex: bool, *, include_standalone: bool = False) -> List[Tuple[str, Path]]:
"""``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint
for "which profiles does the inbound gateway handle".
``multiplex=False``: exactly one entry for the *active* profile (byte-for-byte the
historical single-profile behavior; name is ``"default"`` or the named profile's id).
``multiplex=True``: default plus every live named profile under ``profiles/`` (tombstoned
profiles skipped). Pure directory read: never creates a profile dir (#94590)."""
profiles skipped). Pure directory read: never creates a profile dir (#94590).
Named profiles that authored ``gateway.standalone: true`` are skipped because they opted
out of the host multiplexer; callers enumerating INSTALLED profiles pass ``include_standalone=True``."""
active = get_active_profile_name() or "default"
if not multiplex:
return [(active, get_profile_dir(active))]
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
serve.extend((entry.name, entry) for entry in _iter_named_profile_dirs())
serve.extend((entry.name, entry) for entry in _iter_named_profile_dirs()
if include_standalone or not profile_is_standalone(entry))
return serve

View File

@@ -990,7 +990,7 @@ def _record_attested_cold_start_profiles(token: dict, running_profiles: set) ->
from hermes_cli.profiles import get_active_profile_name, profiles_to_serve
active = get_active_profile_name() or "default"
cold: dict[str, str] = {}
for name, home in profiles_to_serve(multiplex=True):
for name, home in profiles_to_serve(multiplex=True, include_standalone=True):
if name in running_profiles or (name == active and token.get("cold_start_if_installed")):
continue
generation = gateway_windows.attested_death_generation([], home=Path(home))

View File

@@ -214,7 +214,7 @@ def _profile_targets(log_label: str) -> List[Tuple[str, Path]]:
fan-out that only needs name/path (#114041)."""
from hermes_cli import profiles as profiles_mod
try:
targets = list(profiles_mod.profiles_to_serve(multiplex=True))
targets = list(profiles_mod.profiles_to_serve(multiplex=True, include_standalone=True))
except Exception:
_log.exception("%s: profile enumeration failed", log_label)
targets = []

View File

@@ -87,7 +87,7 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
try:
return [
{"name": name, "path": str(home), "is_default": name == "default"}
for name, home in profiles_mod.profiles_to_serve(multiplex=True)]
for name, home in profiles_mod.profiles_to_serve(multiplex=True, include_standalone=True)]
except Exception:
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
return _fallback_profile_dicts(profiles_mod)

View File

@@ -148,7 +148,7 @@ def _collect_profile_gateway_topology() -> Dict[str, Any]:
try:
from hermes_cli.profiles import _check_gateway_running, profiles_to_serve
from gateway.status import read_runtime_status
homes = profiles_to_serve(True)
homes = profiles_to_serve(True, include_standalone=True)
except Exception:
_log.debug("profile/gateway topology enumeration failed", exc_info=True)
return {"profiles": [], "gateway_mode": "unknown", "gateways": [], "profile_platforms": {}}
@@ -539,11 +539,20 @@ def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[s
if not requested or requested.lower() in {"current", "default"}:
return None
served = _profile_is_multiplexed(requested)
from hermes_cli.profiles import profile_is_standalone
from hermes_cli.web_server_profiles import _resolve_profile_dir
profile_dir = _resolve_profile_dir(requested)
standalone = profile_is_standalone(profile_dir)
if standalone:
# The profile opted out of the host multiplexer, so its own gateway is the answer now: only a
# host record that still lists it (the host started before the key was set) is refused.
if not served:
return None
from gateway.host_attach import standalone_rescan_message
return standalone_rescan_message(requested)
if not served and verb != "start":
return None
from hermes_cli.profiles import _check_gateway_running
from hermes_cli.web_server_profiles import _resolve_profile_dir
profile_dir = _resolve_profile_dir(requested)
if _check_gateway_running(profile_dir):
return None
if served:

View File

@@ -0,0 +1,141 @@
"""A configured standalone profile may coexist, but never double-bind a served profile."""
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from gateway import host_attach, host_rendezvous as hr
def test_boot_notice_only_labels_configured_standalone_profiles(standalone_home, monkeypatch, caplog):
from gateway.run import _log_standalone_profiles_at_boot
from hermes_cli import profiles
root, solo = standalone_home
member = root / "profiles" / "member"
member.mkdir()
(member / "config.yaml").write_text("{}\n")
monkeypatch.setattr(profiles, "profiles_to_serve", lambda *a, **kw: [("solo", solo), ("member", member)])
runner = SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True), served_profile_names=lambda: ["default"])
with caplog.at_level("INFO"):
_log_standalone_profiles_at_boot(runner)
messages = [r.getMessage() for r in caplog.records if "not served by this gateway" in r.getMessage()]
assert len(messages) == 1 and "'solo'" in messages[0]
def broken_roster(*a, **kw):
raise OSError("unreadable roster")
monkeypatch.setattr(profiles, "profiles_to_serve", broken_roster)
_log_standalone_profiles_at_boot(runner)
assert any(r.levelname == "WARNING" and "boot notice failed" in r.message for r in caplog.records)
@pytest.fixture
def standalone_home(tmp_path, monkeypatch):
root = tmp_path / "hermes"
home = root / "profiles" / "solo"
home.mkdir(parents=True)
(home / "config.yaml").write_text("gateway:\n standalone: true\n")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
from hermes_cli import profiles
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: root)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: root / "profiles")
return root, home
@pytest.mark.parametrize("served,known", [(False, True), (True, True), (False, False)])
def test_standalone_attach_requires_known_unserved_profile(standalone_home, monkeypatch, served, known):
root, home = standalone_home
owner = host_attach.HostGateway(os.getpid() + 1, root,
("default", "solo") if served else ("default",),
served_known=known)
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
requests = []
monkeypatch.setattr(host_attach, "request_serve_profile", lambda *a, **kw: requests.append(a))
decision = host_attach.decide(home)
if known and not served:
assert decision.outcome == host_attach.START
else:
assert decision.outcome == host_attach.REFUSE
assert decision.transient
if served:
assert "rescan-profiles" in decision.message
assert "30s" in decision.message
assert requests == [], "an opted-out profile must never ask the host to serve it"
@pytest.mark.linux_only
@pytest.mark.parametrize("served,known", [(False, True), (True, True), (False, False)])
def test_standalone_lock_loser_requires_known_unserved_profile(
standalone_home, monkeypatch, capsys, served, known,
):
import fcntl
from gateway import run
root, home = standalone_home
owner = host_attach.HostGateway(os.getpid() + 1, root,
("default", "solo") if served else ("default",),
served_known=known)
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
monkeypatch.setattr(run, "get_hermes_home", lambda: home)
monkeypatch.setattr(host_attach, "request_serve_profile", lambda *a, **kw: None)
hr.ensure_host_state_dir()
with open(hr.lock_path(hr.ROLE_GATEWAY), "a+") as handle:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
if known and not served:
run._claim_host_gateway_role()
else:
with pytest.raises(SystemExit) as exc:
run._claim_host_gateway_role()
assert exc.value.code == 75
if served:
assert "rescan-profiles" in capsys.readouterr().out
@pytest.mark.linux_only
@pytest.mark.parametrize("host_name", ["default", "member"])
def test_standalone_owner_cannot_hide_a_live_multiplexer(standalone_home, monkeypatch, host_name):
"""A starts first, host starts beside A, then B opts out before the host rescans."""
import fcntl
from gateway import run, status
root, home = standalone_home
first = root / "profiles" / "first"
first.mkdir()
(first / "config.yaml").write_text("gateway:\n standalone: true\n")
host_home = root if host_name == "default" else root / "profiles" / host_name
host_home.mkdir(exist_ok=True)
(host_home / "config.yaml").write_text("{}\n")
owner = host_attach.HostGateway(os.getpid() + 1, first, ("first",), standalone=True)
host_pid = os.getpid() + 2
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
monkeypatch.setattr(status, "live_gateway_pid_for_home",
lambda h: host_pid if Path(h) == host_home else None)
identity = {"pid": host_pid, "hermes_home": str(host_home), "profile": host_name,
"multiplex": True, "served_profiles": ["default", "solo", host_name]}
monkeypatch.setattr(host_attach, "_identify", lambda h: identity if h == host_home else None)
monkeypatch.setattr(run, "get_hermes_home", lambda: home)
hr.ensure_host_state_dir()
with open(hr.lock_path(hr.ROLE_GATEWAY), "a+") as handle:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
for answer in (identity, None, {**identity, "pid": host_pid + 1}):
monkeypatch.setattr(host_attach, "_identify", lambda h: answer)
decision = host_attach.decide(home)
assert decision.outcome == host_attach.REFUSE
assert decision.transient
with pytest.raises(SystemExit) as exc:
run._claim_host_gateway_role()
assert exc.value.code == 75
run._claim_host_gateway_role(force=True)
# The original lock owner may exit without stopping the multiplexer.
with monkeypatch.context() as m:
m.setattr(host_attach, "host_gateway", lambda **kw: None)
assert host_attach.decide(home).outcome == host_attach.REFUSE
# Once the live host confirms removal, both entry points permit coexistence.
identity["served_profiles"] = [host_name]
monkeypatch.setattr(host_attach, "_identify", lambda h: identity)
assert host_attach.decide(home).outcome == host_attach.START
run._claim_host_gateway_role()

View File

@@ -80,6 +80,37 @@ def _served_record(home):
return json.loads((home / "gateway_state.json").read_text(encoding="utf-8")).get("served_profiles")
@pytest.mark.asyncio
async def test_opt_out_rescans_and_opt_in_waits_for_own_gateway_to_stop(tmp_path, monkeypatch, caplog):
runner, home = _runner(tmp_path, monkeypatch)
solo = _mkprofile(home, "solo", "DISCORD_BOT_TOKEN=solo-token\n")
own_pids = {}
monkeypatch.setattr("gateway.status.live_gateway_pid_for_home", lambda h: own_pids.get(h))
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
await runner._start_secondary_profile_adapters()
adapter = runner._profile_adapters["solo"][Platform.DISCORD]
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
result = await runner.reconcile_served_profiles()
assert result["removed"] == ["solo"]
assert adapter.disconnected
assert _served_record(home) == ["default"]
own_pids[solo] = 12345
(solo / "config.yaml").write_text("gateway:\n standalone: false\n")
for _ in range(2):
result = await runner.reconcile_served_profiles()
assert result["added"] == []
assert result["served_profiles"] == ["default"]
assert runner._started.count("solo") == 1
assert len([r for r in caplog.records if "still runs its own gateway" in r.message]) == 1
own_pids.clear()
result = await runner.reconcile_served_profiles()
assert result["added"] == ["solo"]
assert _served_record(home) == ["default", "solo"]
assert runner._started.count("solo") == 2
@pytest.mark.asyncio
async def test_created_then_credentialed_profile_is_served_without_restart(tmp_path, monkeypatch):
runner, home = _runner(tmp_path, monkeypatch)

View File

@@ -0,0 +1,35 @@
"""The runner boundary honours a profile opt-out even for explicit --config inputs."""
import asyncio
from pathlib import Path
import pytest
@pytest.mark.parametrize("flag", [None, False, True])
def test_injected_config_cannot_multiplex_a_standalone_home(tmp_path, monkeypatch, flag):
from agent.secret_scope import is_multiplex_active, set_multiplex_active
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner, _profile_runtime_scope
monkeypatch.setattr(Path, "home", lambda: tmp_path)
root = tmp_path / ".hermes"
solo = root / "profiles" / "solo"
solo.mkdir(parents=True)
(root / "config.yaml").write_text("{}\n")
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
monkeypatch.setenv("HERMES_HOME", str(root))
previous = is_multiplex_active()
try:
for home in (solo, root, solo):
with _profile_runtime_scope(home):
config = GatewayConfig(multiplex_profiles=flag, sessions_dir=home / "sessions")
runner = GatewayRunner(config)
try:
assert runner.config.multiplex_profiles is (False if home == solo else flag)
assert is_multiplex_active() is (False if home == solo else bool(flag))
finally:
if runner._session_db is not None:
asyncio.run(runner._session_db.close())
finally:
set_multiplex_active(previous)

View File

@@ -28,7 +28,7 @@ class CronProfileEnumerationTests(unittest.TestCase):
):
result = _web_server_cron._cron_profile_dicts()
lightweight.assert_called_once_with(multiplex=True)
lightweight.assert_called_once_with(multiplex=True, include_standalone=True)
self.assertEqual([item["name"] for item in result], ["default", "coder-01"])
self.assertTrue(result[0]["is_default"])
self.assertFalse(result[1]["is_default"])

View File

@@ -880,3 +880,20 @@ def test_windows_is_migratable_and_only_s6_is_refused(monkeypatch):
reason = gm._host_supports_migration()
assert reason is not None and "Restart the container" in reason
assert "nothing on this host was changed" in reason
def test_standalone_profile_is_listed_left_alone_and_not_a_fold_target(fleet):
"""A profile that opts itself out via `gateway.standalone: true` keeps its own gateway: it is
neither a blocker nor a fold target — the plan names it under standalone_by_config instead."""
root = fleet.root
(root / "profiles" / "ops" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
plan = gm.build_migration_plan()
assert "ops" not in [p.name for p in plan.profiles]
assert "ops" not in [p.name for p in plan.standalone_secondaries]
assert "coder" in [p.name for p in plan.standalone_secondaries]
assert plan.standalone_by_config == ("ops",)
payload = json.loads(json.dumps(plan.to_dict()))
assert payload["standalone_by_config"] == list(plan.standalone_by_config)
assert "ops" not in [p["profile"] for p in payload["profiles"]]
lines = gm.format_plan(plan, dry_run=True)
assert any("Standalone by config (gateway.standalone: true), left alone: ops" in line for line in lines)

View File

@@ -94,6 +94,36 @@ def test_preflight_blocker_and_single_profile_keep_the_unset_default_standalone(
assert decision == mode.MultiplexDecision(False, "guard", mode.SINGLE_PROFILE_REASON)
def test_standalone_named_profile_leaves_nothing_to_multiplex(fleet):
"""A sole named profile that opts itself out via `gateway.standalone: true` is not
served, so the host still has a single profile to serve: guard, not multiplex."""
root, _services, _pids = fleet
shutil.rmtree(root / "profiles/ops") # exactly one named profile remains
(root / "profiles/coder/config.yaml").write_text("gateway:\n standalone: true\n")
decision = mode.resolve_multiplex_mode(load_gateway_config())
assert decision == mode.MultiplexDecision(False, "guard", mode.SINGLE_PROFILE_REASON)
@pytest.mark.parametrize("flag", ["", " multiplex_profiles: true\n", " multiplex_profiles: false\n"])
def test_standalone_launcher_never_multiplexes_other_profiles(fleet, flag):
from gateway.run import _profile_runtime_scope
root, _services, _pids = fleet
solo = root / "profiles/coder"
(solo / "config.yaml").write_text("gateway:\n standalone: true\n" + flag)
# Real loader + runtime scopes: A -> B -> A, without borrowing the launch home's config.
for home in (solo, root, solo):
with _profile_runtime_scope(home):
cfg = load_gateway_config()
decision = mode.resolve_multiplex_mode(cfg)
if home == solo:
assert decision.enabled is False
assert cfg.multiplex_profiles is False
assert "this profile is standalone" in decision.reason
else:
assert decision.enabled is True
def test_explicit_true_is_never_second_guessed_and_explicit_false_is_retired(fleet, monkeypatch):
root, _services, pids = fleet
pids["coder"] = 4101

View File

@@ -0,0 +1,98 @@
"""``gateway.standalone`` and the multiplexer refusal: ``_named_profile_refused_under_multiplexer``.
A profile that authored ``gateway.standalone: true`` runs (or may run) its own gateway without
``--force``; the only refusal left is a RUNNING host record that still lists it (the host gateway
has not rescanned since the key was set) — and that refusal names the rescan instead of ``--force``.
"""
from __future__ import annotations
import io
from contextlib import redirect_stdout
import pytest
import hermes_constants
@pytest.fixture
def standalone_home(tmp_path, monkeypatch):
root = tmp_path / "hermes"
home = root / "profiles" / "coder"
home.mkdir(parents=True)
(root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
(home / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
from hermes_cli import gateway as gw
# The probe seams live on the hermes_cli.gateway facade, like the other refusal tests.
monkeypatch.setattr(gw, "_is_service_installed", lambda: False)
monkeypatch.setattr(gw, "_served_by_another_host_gateway", lambda name=None: None)
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: False)
return gw, home
def _refusal(gw) -> tuple[bool, str]:
buf = io.StringIO()
with redirect_stdout(buf):
refused = gw._named_profile_refused_under_multiplexer()
return refused, buf.getvalue()
def test_standalone_named_home_is_not_refused_without_force(standalone_home):
gw, _home = standalone_home
refused, out = _refusal(gw)
assert refused is False
assert out == ""
def test_standalone_named_home_still_served_by_host_record_is_refused_with_rescan(standalone_home, monkeypatch):
gw, _home = standalone_home
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: True)
refused, out = _refusal(gw)
assert refused is True
assert "still serves profile 'coder'" in out
assert "gateway.standalone" in out
assert "rescan-profiles" in out and "30s" in out
assert "--force" not in out
def test_non_standalone_refusal_names_the_opt_out(standalone_home):
gw, home = standalone_home
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
refused, out = _refusal(gw)
assert refused is True
assert "Or opt this profile out of the host gateway for good" in out
assert "gateway.standalone: true" in out
def test_setup_stale_host_record_names_rescan(standalone_home, monkeypatch, capsys):
gw, _home = standalone_home
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: True)
assert gw._served_profile_needs_no_service() is True
assert "rescan-profiles" in capsys.readouterr().out
def test_dashboard_standalone_refusal_resolves_once_and_preserves_invalid_profile(standalone_home, monkeypatch):
from fastapi import HTTPException
from hermes_cli import web_server_gateway as web
from hermes_cli import web_server_profiles
_gw, home = standalone_home
resolved = []
def resolve(name):
resolved.append(name)
if name != "coder":
raise HTTPException(status_code=404, detail="Unknown profile")
return home
monkeypatch.setattr(web_server_profiles, "_resolve_profile_dir", resolve)
monkeypatch.setattr(web, "_profile_is_multiplexed", lambda name: True)
assert "rescan-profiles" in web.multiplexed_profile_refusal("coder", "start")
assert resolved == ["coder"]
monkeypatch.setattr(web, "_profile_is_multiplexed", lambda name: False)
assert web.multiplexed_profile_refusal("coder", "start") is None
with pytest.raises(HTTPException) as exc:
web.multiplexed_profile_refusal("missing", "stop")
assert exc.value.status_code == 404

View File

@@ -84,3 +84,38 @@ def test_served_named_profile_reports_running_without_default_pid_file(monkeypat
beta = next(p for p in list_profiles() if p.name == "beta")
assert beta.gateway_running is True
assert _run_status().startswith("✓ Gateway is running via the default-profile multiplexer")
def test_standalone_profile_status_reports_standalone_by_config(monkeypatch, tmp_path):
"""`hermes -p X gateway status` on a standalone X says so and never claims the multiplexer."""
_fake_multiplexer(monkeypatch, tmp_path, multiplex=True)
(tmp_path / "profiles" / "beta" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
from hermes_cli import gateway as gw
buf = io.StringIO()
with redirect_stdout(buf):
gw._gateway_command_inner(
SimpleNamespace(gateway_command="status", deep=False, full=False, system=False)
)
out = buf.getvalue()
assert "standalone by config (gateway.standalone: true)" in out
assert "via the default-profile multiplexer" not in out
def test_default_status_lists_standalone_profiles(monkeypatch, tmp_path):
"""The default (host) profile's status names the profiles that opted out by config."""
import hermes_constants
(tmp_path / "profiles" / "beta").mkdir(parents=True)
(tmp_path / "profiles" / "beta" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
(tmp_path / "config.yaml").write_text("gateway:\n multiplex_profiles: true\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
from hermes_cli import gateway as gw
buf = io.StringIO()
with redirect_stdout(buf):
gw._gateway_command_inner(
SimpleNamespace(gateway_command="status", deep=False, full=False, system=False)
)
assert "standalone by config: beta" in buf.getvalue()

View File

@@ -1642,6 +1642,95 @@ class TestProfilesToServe:
assert serve["default"] == _get_default_hermes_home()
assert serve["coder"] == get_profile_dir("coder")
# ------------------------------------------------------------------
# gateway.standalone: authored opt-out of the host multiplexer
# ------------------------------------------------------------------
def test_standalone_profile_excluded_unless_included(self, profile_env):
"""A named profile that sets `gateway.standalone: true` is not served by the
host multiplexer, but callers that enumerate INSTALLED profiles still see it."""
create_profile("solo", no_alias=True)
create_profile("member", no_alias=True)
(get_profile_dir("solo") / "config.yaml").write_text("gateway:\n standalone: true\n")
serve = dict(profiles_to_serve(multiplex=True))
assert set(serve) == {"default", "member"}
served_all = dict(profiles_to_serve(multiplex=True, include_standalone=True))
assert set(served_all) == {"default", "solo", "member"}
def test_default_profile_with_key_still_served_with_one_warning(self, profile_env, caplog):
"""The default profile IS the host: the key is ignored (still served, never
standalone) with exactly one warning per process."""
profiles._STANDALONE_WARNED = False
default_home = _get_default_hermes_home()
(default_home / "config.yaml").write_text("gateway:\n standalone: true\n")
caplog.clear()
with caplog.at_level("WARNING", logger="hermes_cli.profiles"):
serve = dict(profiles_to_serve(multiplex=True))
assert profiles.profile_is_standalone(default_home) is False
assert profiles.profile_is_standalone(default_home) is False
assert list(serve) == ["default"]
assert serve["default"] == default_home
assert len([r for r in caplog.records if "ignored on the default profile" in r.message]) == 1
@pytest.mark.parametrize("content", ["gateway: [", "[]\n", "null\n", "", "gateway: false\n"])
def test_standalone_malformed_config_does_not_break_roster(self, profile_env, caplog, content):
create_profile("solo", no_alias=True)
home = get_profile_dir("solo")
(home / "config.yaml").write_text(content)
for _ in range(2):
assert profiles.profile_is_standalone(home) is False
assert "solo" in dict(profiles_to_serve(True))
warnings = [r for r in caplog.records if "Cannot read gateway.standalone" in r.message]
assert len(warnings) == (1 if content == "gateway: [" else 0)
@pytest.mark.parametrize("failure_at", ["stat", "read", "decode"])
def test_standalone_io_failure_is_bounded_and_recovers(self, profile_env, monkeypatch, caplog, failure_at):
from hermes_cli import config
create_profile("solo", no_alias=True)
home = get_profile_dir("solo")
cfg = home / "config.yaml"
cfg.write_text("gateway:\n standalone: true\n")
real_stat = Path.stat
def denied(path, *args, **kwargs):
if path == cfg:
raise PermissionError("denied")
return real_stat(path, *args, **kwargs)
def unreadable(*args, **kwargs):
if failure_at == "decode":
raise UnicodeError("decode failed")
raise PermissionError("denied")
with monkeypatch.context() as m:
if failure_at == "stat":
m.setattr(Path, "stat", denied)
else:
m.setattr(config, "read_user_config_raw", unreadable)
assert profiles.profile_is_standalone(home) is False
assert profiles.profile_is_standalone(home) is False
assert len([r for r in caplog.records if "Cannot read gateway.standalone" in r.message]) == 1
# Restoring access does not change mtime/size/inode; a read failure is not config.
assert profiles.profile_is_standalone(home) is True
def test_standalone_answer_is_per_home_and_memo_invalidates_on_replacement(self, profile_env):
"""A->B->A: signatures never cross homes; atomic replacement invalidates the memo."""
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
alpha, beta = get_profile_dir("alpha"), get_profile_dir("beta")
(alpha / "config.yaml").write_text("gateway:\n standalone: true\n")
assert profiles.profile_is_standalone(alpha) is True
assert profiles.profile_is_standalone(beta) is False
assert profiles.profile_is_standalone(alpha) is True # memo hit, still True
cfg = alpha / "config.yaml"
replacement = alpha / "replacement.yaml"
replacement.write_text("gateway:\n standalone: false\n")
replacement.replace(cfg)
assert profiles.profile_is_standalone(alpha) is False
assert profiles.profile_is_standalone(beta) is False
assert profiles.profile_is_standalone(alpha) is False
# ---------------------------------------------------------------------------
# resolve_profile_env spelling preservation (#82581 junction follow-up)

View File

@@ -30,6 +30,17 @@ def isolated_profiles(tmp_path, monkeypatch):
return {"default": default_home, "worker_alpha": worker_home}
def test_standalone_jobs_remain_discoverable_for_dashboard_management(isolated_profiles):
home = isolated_profiles["worker_alpha"]
(home / "config.yaml").write_text("gateway:\n standalone: true\n")
job = _web_server_cron._call_cron_for_profile(
"worker_alpha", "create_job", prompt="local job", schedule="every 1h",
)
assert isinstance(job, dict)
assert "worker_alpha" in {p["name"] for p in _web_server_cron._cron_profile_dicts()}
assert _web_server_cron._find_cron_job_profile(job["id"]) == "worker_alpha"
def _drain_queue(q):
values = []
while True:

View File

@@ -5,6 +5,8 @@ added to ``/api/status``: profile enumeration, single vs multiplex vs multiple
gateway detection, and per-platform port resolution.
"""
from pathlib import Path
import pytest
from hermes_cli import web_server
@@ -57,7 +59,7 @@ def _patch_topology(monkeypatch, homes, running, runtimes):
import hermes_cli.profiles as profiles_mod
import gateway.status as status_mod
monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex: homes)
monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex, **kw: homes)
monkeypatch.setattr(
profiles_mod, "_check_gateway_running",
lambda home: next(n for n, h in homes if h == home) in running,
@@ -69,6 +71,20 @@ def _patch_topology(monkeypatch, homes, running, runtimes):
class TestCollectProfileGatewayTopology:
def test_running_standalone_profile_is_in_topology(self, tmp_path, monkeypatch):
from hermes_cli import profiles
root = tmp_path / ".hermes"
solo = root / "profiles" / "solo"
solo.mkdir(parents=True)
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr(profiles, "_check_gateway_running", lambda home: home == solo)
topo = _collect_profile_gateway_topology()
assert "solo" in topo["profiles"]
assert [g["profile"] for g in topo["gateways"]] == ["solo"]
def test_no_gateways_running(self, tmp_path, monkeypatch):
homes = [("default", tmp_path / "d"), ("coder", tmp_path / "c")]
_patch_topology(monkeypatch, homes, running=set(), runtimes={})

View File

@@ -62,7 +62,7 @@ def _servable_profile_homes() -> set:
from hermes_constants import named_profile_has_servable_identity
from hermes_cli.profiles import profiles_to_serve
homes = {Path(home).resolve() for name, home in profiles_to_serve(multiplex=True)
homes = {Path(home).resolve() for name, home in profiles_to_serve(multiplex=True, include_standalone=True)
if name == "default" or named_profile_has_servable_identity(home)}
homes.add(Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes").resolve())
return homes