fix: setup gateway skips the standalone service for a multiplex-served profile

`hermes -p <profile> setup gateway` (and `hermes setup` / `hermes import`) reach the
service step through `ensure_gateway_service`, which only knew "is THIS profile's
unit running" — a satellite served by the default multiplexer has no unit of its
own, so the step printed "Installing the gateway background service ..." and
registered a launchd plist / systemd unit that the #97120 start guard then refused,
leaving a stray dead service the user had to find and remove by hand (#111958).

Route both setup surfaces through one shared predicate: `_served_profile_needs_no_service`
wraps `named_profile_served_by_running_multiplexer` (the same probe `profile create`,
cron liveness and the run/start/install guards use), prints the "already served"
note and returns True so `ensure_gateway_service` and the `hermes gateway setup`
wizard (#111962's hunk) skip the install. Default profile and non-multiplex hosts
are unchanged.

Adds the invariant for the `ensure_gateway_service` path (served → no install,
unserved → still installs). Docs: multi-profile-gateways.md names the skipped step.

Co-authored-by: kvnloo <7121943+kvnloo@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-15 11:49:29 -07:00
committed by Teknium
parent edca3c2653
commit 71aa0d635e
3 changed files with 49 additions and 5 deletions
+19 -5
View File
@@ -2562,6 +2562,8 @@ def ensure_gateway_service(context: str = "setup") -> bool:
try:
if _is_service_running():
return True
if _served_profile_needs_no_service():
return True
if not _is_service_installed():
if supports_systemd and has_conflicting_systemd_units():
# Both units would fight over bot tokens; don't pile a fresh install onto a conflicted state.
@@ -4471,6 +4473,22 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None)
return False
def _served_profile_needs_no_service() -> bool:
"""Print the "already served" note and return True when a setup flow must not install a standalone
service: a live multiplexing default gateway already serves this named profile, so the unit/plist it
would register can only sit dead (the start guard refuses it) or double-bind its platforms.
Shared by ``hermes setup gateway`` / ``hermes setup`` / ``hermes import`` (``ensure_gateway_service``)
and the ``hermes gateway setup`` wizard. See #111958."""
if not named_profile_served_by_running_multiplexer():
return False
print_success(
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
)
print_info(" (served now by the running multiplexed gateway — add its bot token and it connects)")
print_info(" No standalone gateway service was installed or started.")
return True
def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
"""Print the served-profile refusal and return True when a named-profile gateway must not start:
a multiplexing default gateway already serves it (a second one would double-bind its platforms: two
@@ -5783,11 +5801,7 @@ def _wizard_post_setup() -> None:
"""Offer to install/start/restart the gateway once at least one platform has progress."""
print()
print(color("─" * 58, Colors.DIM))
if named_profile_served_by_running_multiplexer():
print_success(
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
)
print_info("No standalone gateway service was installed or started.")
if _served_profile_needs_no_service():
return
service_installed = _is_service_installed()
service_running = _is_service_running()
@@ -96,6 +96,29 @@ def test_setup_wizard_skips_service_install_for_profile_served_by_multiplexer(
assert "already served by the default multiplexer" in capsys.readouterr().out
def test_setup_gateway_service_step_skips_install_for_served_profile(served_root, monkeypatch, capsys):
"""``hermes -p <profile> setup gateway`` (and ``hermes setup`` / ``hermes import``) reach the service
step through ``ensure_gateway_service``: a served profile gets the multiplexer note and no unit/plist,
while a profile the live record does not list is still installed (#111958)."""
import hermes_cli.gateway as gw
calls: list[str] = []
monkeypatch.setattr(gw, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gw, "_is_service_running", lambda: False)
monkeypatch.setattr(gw, "_is_service_installed", lambda: False)
monkeypatch.setattr(gw, "has_conflicting_systemd_units", lambda: False)
monkeypatch.setattr(gw, "systemd_install", lambda **kwargs: calls.append("install"))
monkeypatch.setattr(gw, "systemd_start", lambda *args, **kwargs: calls.append("start"))
assert gw.ensure_gateway_service(context="setup") is True
assert calls == []
assert "already served by the default multiplexer" in capsys.readouterr().out
monkeypatch.setenv("HERMES_HOME", str(served_root / "profiles" / "other")) # not in the live record
assert gw.ensure_gateway_service(context="setup") is True
assert calls == ["install", "start"]
def test_recycled_pid_does_not_lend_a_stale_record_its_served_profiles(served_root):
"""A stale default record whose PID now belongs to an unrelated process (start time differs, command
line is not a gateway's) must not make its ``served_profiles`` authoritative: bare PID existence
@@ -147,6 +147,13 @@ default home's `gateway_state.json`), so it stays correct when the multiplexer w
enabled only through `GATEWAY_MULTIPLEX_PROFILES` in the default profile's
environment, or when profiles were added after the gateway started.
The setup flows follow the same rule: `hermes -p coder setup gateway`, `hermes -p coder setup`,
`hermes -p coder gateway setup` and `hermes -p coder import` configure the profile's bots but
skip the "install the gateway background service" step for a served profile, printing
*"Profile 'coder' is already served by the default multiplexer"* instead of registering a
stray unit or plist that could only sit dead. Add the bot token and the running multiplexer
picks it up.
The multiplexer is the single inbound process; a second profile gateway would
double-bind that profile's platforms. Pass `--force` (accepted by `run`, `start`,
`install` and `restart`) only if you deliberately want a separate process for that