diff --git a/gateway/host_attach.py b/gateway/host_attach.py index a33a472c5a..85c34af3b0 100644 --- a/gateway/host_attach.py +++ b/gateway/host_attach.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index a38c9ce122..3f334263cc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/hermes_cli/gateway_profile_lifecycle.py b/hermes_cli/gateway_profile_lifecycle.py index 440a91716d..c0380690b5 100644 --- a/hermes_cli/gateway_profile_lifecycle.py +++ b/hermes_cli/gateway_profile_lifecycle.py @@ -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): diff --git a/hermes_cli/web_server_gateway.py b/hermes_cli/web_server_gateway.py index 83fb52db69..6f11807fdc 100644 --- a/hermes_cli/web_server_gateway.py +++ b/hermes_cli/web_server_gateway.py @@ -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 `` 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 diff --git a/tests/gateway/test_multiplex_hot_serve.py b/tests/gateway/test_multiplex_hot_serve.py index 5f06bc317e..84a8ba3b07 100644 --- a/tests/gateway/test_multiplex_hot_serve.py +++ b/tests/gateway/test_multiplex_hot_serve.py @@ -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): diff --git a/tests/hermes_cli/test_gateway_multiplex_lifecycle.py b/tests/hermes_cli/test_gateway_multiplex_lifecycle.py index 6e846d8311..9153e5a5d8 100644 --- a/tests/hermes_cli/test_gateway_multiplex_lifecycle.py +++ b/tests/hermes_cli/test_gateway_multiplex_lifecycle.py @@ -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]) diff --git a/tests/hermes_cli/test_gateway_multiplex_served_record.py b/tests/hermes_cli/test_gateway_multiplex_served_record.py index 809282a993..e22d35e97c 100644 --- a/tests/hermes_cli/test_gateway_multiplex_served_record.py +++ b/tests/hermes_cli/test_gateway_multiplex_served_record.py @@ -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.