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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user