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.
This commit is contained in:
John Paul Soliva
2026-09-23 15:07:54 +09:00
committed by Teknium
parent 35c0ea3dff
commit fdd49a372c
2 changed files with 36 additions and 8 deletions

View File

@@ -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; "

View File

@@ -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"]