From fdd49a372c39e0a47cfb9bc92c5b2a83386cbcd5 Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Wed, 23 Sep 2026 15:07:54 +0900 Subject: [PATCH] fix(dashboard): a served profile is not a gateway of its own Gateway liveness reports a profile the multiplexer serves as running on the multiplexer's PID (446f6f79a8). The dashboard read that answer as "this profile runs its own gateway" in two places. multiplexed_profile_refusal returned None for every served profile, so while a multiplexer was live (the only time it matters) start/stop were never refused and a restart was never rewritten to the multiplexer. The /api/status topology listed one phantom gateway per served profile beside the host. _has_own_gateway() answers the actual question: a live gateway whose PID is not the multiplexer serving the profile. The existing lifecycle test stubbed _check_gateway_running to False, which is what hid it; it now runs on the real liveness. --- hermes_cli/web_server_gateway.py | 25 ++++++++++++++++--- .../test_gateway_multiplex_served_record.py | 19 ++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/hermes_cli/web_server_gateway.py b/hermes_cli/web_server_gateway.py index c475aeb2b8..fafb4beb6c 100644 --- a/hermes_cli/web_server_gateway.py +++ b/hermes_cli/web_server_gateway.py @@ -158,7 +158,9 @@ def _collect_profile_gateway_topology() -> Dict[str, Any]: multiplex = False for name, home in homes: try: - if not _check_gateway_running(home): + # A served profile's liveness is the multiplexer's: listing it here showed one phantom + # gateway per served profile beside the host. + if not (_check_gateway_running(home) if name == "default" else _has_own_gateway(home)): continue except Exception: continue @@ -528,6 +530,24 @@ def _profile_is_multiplexed(profile: str) -> bool: return named_profile_served_by_running_multiplexer(profile) +def _has_own_gateway(profile_dir: Path) -> bool: + """A live gateway of the profile's OWN (a ``--force``-started separate one), not the multiplexer that + serves it. Gateway liveness reports a served profile as running on the multiplexer's PID (#97120), + so reading liveness alone made every served profile look self-hosted and the refusal below never + fired while a multiplexer was live, which is the only time it is needed.""" + from gateway.status import get_running_pid, multiplexer_liveness_for_profile, resolve_gateway_liveness + from hermes_cli.profiles import _check_gateway_running + if not _check_gateway_running(profile_dir): + return False + served = multiplexer_liveness_for_profile(profile_dir) + if served is None: + return True + liveness = resolve_gateway_liveness( + profile_dir=profile_dir, use_cache=False, + pid_probe=lambda path: get_running_pid(path, cleanup_stale=False)) + return liveness.running and liveness.pid != served[0] + + 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 @@ -552,8 +572,7 @@ def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[s return standalone_rescan_message(requested) if not served and verb != "start": return None - from hermes_cli.profiles import _check_gateway_running - if _check_gateway_running(profile_dir): + if _has_own_gateway(profile_dir): return None if served: return (f"The default gateway already serves profile '{requested}' as a multiplexer; " diff --git a/tests/hermes_cli/test_gateway_multiplex_served_record.py b/tests/hermes_cli/test_gateway_multiplex_served_record.py index ac63a429f2..f325d31379 100644 --- a/tests/hermes_cli/test_gateway_multiplex_served_record.py +++ b/tests/hermes_cli/test_gateway_multiplex_served_record.py @@ -227,9 +227,10 @@ 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.""" - from hermes_cli import profiles as profiles_mod + from hermes_cli import web_server_gateway from hermes_cli.web_server_gateway import _gateway_subcommand, _profile_action_environment, multiplexed_profile_refusal - monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda home: False) + # No stub: a served profile's liveness answers "running" on the MULTIPLEXER's pid, and that must + # not read as a gateway of its own (stubbing it False hid exactly that). # This process's own HERMES_HOME is coder's; the restart child must still run under the DEFAULT # home (the multiplexer's) — a bare `gateway restart` here would inherit coder's home and exit 78. restart = _gateway_subcommand("coder", "restart") @@ -239,7 +240,7 @@ def test_dashboard_lifecycle_verbs_target_the_multiplexer(served_root, monkeypat 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. - monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda home: True) + monkeypatch.setattr(web_server_gateway, "_has_own_gateway", lambda profile_dir: True) assert _gateway_subcommand("coder", "restart") == ["-p", "coder", "gateway", "restart"] assert multiplexed_profile_refusal("coder", "stop") is None @@ -260,10 +261,8 @@ def test_the_multiplexer_restart_names_the_root_even_under_a_sticky_active_profi action log says restarted and the multiplexer never was.""" from pathlib import Path - from hermes_cli import profiles as profiles_mod from hermes_cli.main import _apply_profile_override from hermes_cli.web_server_gateway import _gateway_subcommand, _profile_action_environment - monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda home: False) monkeypatch.setenv("HERMES_HOME", str(served_root)) # the dashboard runs as the default profile (served_root / "active_profile").write_text("coder") monkeypatch.setattr(Path, "home", lambda: served_root.parent) @@ -277,3 +276,13 @@ def test_the_multiplexer_restart_names_the_root_even_under_a_sticky_active_profi monkeypatch.setattr("sys.argv", ["hermes", *restart]) _apply_profile_override() # what the spawned child does first assert os.environ["HERMES_HOME"] == str(served_root) + + +def test_the_topology_lists_a_served_profile_under_the_multiplexer_not_as_its_own_gateway(served_root, monkeypatch): + """`/api/status` topology: a served profile's liveness is the multiplexer's, so it is one gateway + serving both, not a second gateway entry for `coder` beside it.""" + from hermes_cli.web_server_gateway import _collect_profile_gateway_topology + monkeypatch.setenv("HERMES_HOME", str(served_root)) + topology = _collect_profile_gateway_topology() + assert [g["profile"] for g in topology["gateways"]] == ["default"] + assert "coder" in topology["gateways"][0]["served_profiles"]