fix(gateway): parking composes with gateway.standalone and the dashboard Stop/Start twin

gateway.standalone wins over the parked marker: profile_lifecycle() returns
False for an opted-out profile, so `-p X gateway stop|start` keeps addressing
X's own gateway process and never writes gateway.parked, even while a stale
host record still lists X. Every installed-roster caller now threads both
kwargs (`include_standalone=True, include_parked=True`): the host-attach peer
walk and the standalone boot notice were reading the roster without parked
profiles.

Dashboard twin (#119886 class): `/api/gateway/stop?profile=X` on a served
profile no longer answers 409 — the spawned `hermes -p X gateway stop` parks
it; `/api/gateway/start` on a parked profile is allowed while a host
multiplexer is live (the child unparks it) and still refused when nothing can
serve it. Tests trimmed to the invariants: the marker-appearing case was a
subset of the boot-and-reconcile test.
This commit is contained in:
teknium1
2026-09-23 05:49:22 -07:00
committed by Teknium
parent 85d28d343a
commit f6ce02eafa
7 changed files with 42 additions and 26 deletions

View File

@@ -292,7 +292,7 @@ def _coexisting_gateways(owner: Optional[HostGateway]):
if owner is not None:
seen.add(owner.pid)
yield owner
for _name, home in profiles_to_serve(True, include_standalone=True):
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

View File

@@ -5507,7 +5507,7 @@ def _log_standalone_profiles_at_boot(runner) -> None:
from hermes_cli.profiles import profiles_to_serve, profile_is_standalone
from hermes_cli.gateway_multiplex_mode import STANDALONE_DEPRECATION_NOTICE
served = set(runner.served_profile_names())
for name, home in profiles_to_serve(True, include_standalone=True):
for name, home in profiles_to_serve(True, include_standalone=True, include_parked=True):
if name != "default" and name not in served and profile_is_standalone(home):
logger.warning("profile '%s' is standalone (gateway.standalone: true); not served by "
"this gateway. %s", name, STANDALONE_DEPRECATION_NOTICE)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from hermes_constants import get_hermes_home, get_default_hermes_root
from hermes_cli.profiles import parked_marker_path, profile_is_parked, profiles_to_serve
from hermes_cli.profiles import parked_marker_path, profile_is_parked, profile_is_standalone, profiles_to_serve
def _confirmed(answer, key, name):
@@ -24,6 +24,10 @@ def profile_lifecycle(command: str, args) -> bool:
if not name or name == "default" or getattr(args, "all", False) or getattr(args, "force", False):
return False
home = get_hermes_home()
if profile_is_standalone(home):
# gateway.standalone wins: the host never serves (or parks) an opted-out profile, so its
# verbs keep addressing its own gateway process even while a stale host record lists it.
return False
marker = parked_marker_path(home)
if command == "start":
if not profile_is_parked(home):

View File

@@ -554,16 +554,17 @@ def _has_own_gateway(profile_dir: Path) -> bool:
def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[str]:
"""Refusal text for ``gateway start``/``stop`` on a named profile with no gateway of its own (a
``--force``-started separate one is managed normally), else None. ``stop`` is refused only when the
live default multiplexer serves the profile; ``start`` is refused for every named profile — one
host gateway serves every profile, so a new per-profile gateway is never the answer (the CLI twin
``_named_profile_refused_under_multiplexer`` exits 78 into an action log nobody reads while the UI
shows the verb as done)."""
``--force``-started separate one is managed normally), else None. A profile the live host
multiplexer serves is parked by ``stop`` and a parked one is unparked by ``start`` (the spawned
``hermes -p X gateway <verb>`` runs ``gateway_profile_lifecycle``), so neither is refused;
``start`` on an unparked named profile is — one host gateway serves every profile, so a new
per-profile gateway is never the answer (the CLI twin ``_named_profile_refused_under_multiplexer``
exits 78 into an action log nobody reads while the UI shows the verb as done)."""
requested = _own_profile_selector(profile) or ""
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.profiles import profile_is_parked, 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)
@@ -574,11 +575,17 @@ def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[s
return None
from gateway.host_attach import standalone_rescan_message
return standalone_rescan_message(requested)
if verb == "start" and profile_is_parked(profile_dir):
from gateway.host_attach import host_gateway
if host_gateway() is not None:
return None # a live host unparks it; with no host the refusal below still applies
if not served and verb != "start":
return None
if _has_own_gateway(profile_dir):
return None
if served:
if verb == "stop":
return None # parks the profile inside the host
return (f"The default gateway already serves profile '{requested}' as a multiplexer; "
f"{verb} it from the default profile instead of a separate gateway for this profile.")
from hermes_cli.gateway_migrate import _installed_services

View File

@@ -161,19 +161,6 @@ async def test_profile_control_verbs_round_trip_and_refusals(tmp_path, monkeypat
assert runner._profile_adapters["worker"][Platform.DISCORD].token.endswith("new-worker-token\n")
@pytest.mark.asyncio
async def test_marker_appearing_unserves_on_reconcile(tmp_path, monkeypatch):
runner, home = _runner(tmp_path, monkeypatch)
secondary = _mkprofile(home, "worker", "DISCORD_BOT_TOKEN=worker-token\n")
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
await runner._start_secondary_profile_adapters()
adapter = runner._profile_adapters["worker"][Platform.DISCORD]
(secondary / "gateway.parked").touch()
assert (await runner.reconcile_served_profiles())["removed"] == ["worker"]
assert adapter.disconnected
assert _served_record(home) == ["default"]
@pytest.mark.linux_only
@pytest.mark.asyncio
async def test_profile_lifecycle_over_real_control_socket(tmp_path, monkeypatch):

View File

@@ -78,6 +78,12 @@ def test_cli_lifecycle_orders_marker_before_socket(homes, monkeypatch, capsys, v
monkeypatch.setattr(control_socket, 'request_serve_profile_hot', serve, raising=False)
if verb == 'start':
marker.touch()
# Precedence: a standalone profile is never parked, even while a stale host record lists it.
from hermes_cli.gateway_profile_lifecycle import profile_lifecycle
(secondary / 'config.yaml').write_text('gateway: {standalone: true}\n')
assert profile_lifecycle(verb, SimpleNamespace()) is False and calls == []
assert marker.exists() is (verb == 'start')
(secondary / 'config.yaml').write_text('model: {default: worker-model}\n')
getattr(gw, '_cmd_' + verb)(SimpleNamespace())
assert calls == {'stop': ['unserve'], 'start': ['serve'], 'restart': ['unserve', 'serve']}[verb]
assert marker.exists() is (verb == 'stop')
@@ -115,16 +121,25 @@ def test_parked_profile_keeps_implicit_host_multiplexed(homes, monkeypatch):
assert resolve_multiplex_mode(config).enabled
def test_dashboard_exposes_parked_profile(homes, monkeypatch):
def test_dashboard_exposes_parked_profile_and_start_unparks_it(homes, monkeypatch):
from types import SimpleNamespace
from fastapi.testclient import TestClient
from gateway import host_attach
from hermes_cli import web_server, profiles
_, secondary = homes
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
root, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setattr(profiles, '_check_gateway_running', lambda home: False)
with TestClient(web_server.app) as client:
response = client.get('/api/status')
assert response.status_code == 200
assert response.json()['parked_profiles'] == ['worker']
# The Start button: refused while no host can unpark it, allowed (spawns `-p worker gateway start`)
# once the host multiplexer is live; a parked profile is not served, so this is not the served path.
monkeypatch.setattr(host_attach, 'host_gateway', lambda: None)
assert multiplexed_profile_refusal('worker', 'start')
monkeypatch.setattr(host_attach, 'host_gateway', lambda: SimpleNamespace(home=root, pid=1))
assert multiplexed_profile_refusal('worker', 'start') is None
@pytest.mark.parametrize('host_running', [False, True])

View File

@@ -236,7 +236,8 @@ def test_dashboard_liveness_ladder_reports_served_profile_running(served_root):
def test_dashboard_lifecycle_verbs_target_the_multiplexer(served_root, monkeypatch):
"""`gateway restart` for a served profile restarts the multiplexer (a `-p X` child only exits 78 into
the action log); `start`/`stop` refuse; a profile with its own gateway is managed normally."""
the action log); `stop` parks, `start` refuses while unparked; a profile with its own gateway is
managed normally."""
from hermes_cli import web_server_gateway
from hermes_cli.web_server_gateway import _gateway_subcommand, _profile_action_environment, multiplexed_profile_refusal
# No stub: a served profile's liveness answers "running" on the MULTIPLEXER's pid, and that must
@@ -246,7 +247,9 @@ def test_dashboard_lifecycle_verbs_target_the_multiplexer(served_root, monkeypat
restart = _gateway_subcommand("coder", "restart")
assert restart[-2:] == ["gateway", "restart"] and "coder" not in restart
assert _profile_action_environment(restart)["HERMES_HOME"] == str(served_root)
assert multiplexed_profile_refusal("coder", "stop") and multiplexed_profile_refusal("coder", "start")
assert multiplexed_profile_refusal("coder", "stop") is None # parks via `hermes -p coder gateway stop`
assert multiplexed_profile_refusal("coder", "start")
assert _gateway_subcommand("coder", "stop") == ["-p", "coder", "gateway", "stop"]
assert _gateway_subcommand("other", "restart") == ["-p", "other", "gateway", "restart"]
assert multiplexed_profile_refusal("other", "stop") is None
# coder started its own gateway with --force: it is that gateway the verbs address.