diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index bb2dd192..73956dc9 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -562,6 +562,12 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str scope_flag = " --system" if system else "" if unit_path.exists() and not force: + if not systemd_unit_is_current(system=system): + print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}") + refresh_systemd_unit_if_needed(system=system) + subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True) + print(f"✓ {_service_scope_label(system).capitalize()} service definition updated") + return print(f"Service already installed at: {unit_path}") print("Use --force to reinstall") return @@ -787,6 +793,11 @@ def launchd_install(force: bool = False): plist_path = get_launchd_plist_path() if plist_path.exists() and not force: + if not launchd_plist_is_current(): + print(f"↻ Repairing outdated launchd service at: {plist_path}") + refresh_launchd_plist_if_needed() + print("✓ Service definition updated") + return print(f"Service already installed at: {plist_path}") print("Use --force to reinstall") return @@ -816,7 +827,15 @@ def launchd_uninstall(): def launchd_start(): refresh_launchd_plist_if_needed() - subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + plist_path = get_launchd_plist_path() + try: + subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + except subprocess.CalledProcessError as e: + if e.returncode != 3 or not plist_path.exists(): + raise + print("↻ launchd job was unloaded; reloading service definition") + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) print("✓ Service started") def launchd_stop(): @@ -824,22 +843,36 @@ def launchd_stop(): print("✓ Service stopped") def launchd_restart(): - refresh_launchd_plist_if_needed() - launchd_stop() + try: + launchd_stop() + except subprocess.CalledProcessError as e: + if e.returncode != 3: + raise + print("↻ launchd job was unloaded; skipping stop") launchd_start() def launchd_status(deep: bool = False): + plist_path = get_launchd_plist_path() result = subprocess.run( ["launchctl", "list", "ai.hermes.gateway"], capture_output=True, text=True ) + + print(f"Launchd plist: {plist_path}") + if launchd_plist_is_current(): + print("✓ Service definition matches the current Hermes install") + else: + print("⚠ Service definition is stale relative to the current Hermes install") + print(" Run: hermes gateway start") if result.returncode == 0: print("✓ Gateway service is loaded") print(result.stdout) else: print("✗ Gateway service is not loaded") + print(" Service definition exists locally but launchd has not loaded it.") + print(" Run: hermes gateway start") if deep: log_file = get_hermes_home() / "logs" / "gateway.log" @@ -1555,14 +1588,17 @@ def gateway_command(args): # Try service first, fall back to killing and restarting service_available = False system = getattr(args, 'system', False) + service_configured = False if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + service_configured = True try: systemd_restart(system=system) service_available = True except subprocess.CalledProcessError: pass elif is_macos() and get_launchd_plist_path().exists(): + service_configured = True try: launchd_restart() service_available = True @@ -1586,6 +1622,13 @@ def gateway_command(args): print(" hermes gateway restart") return + if service_configured: + print() + print("✗ Gateway service restart failed.") + print(" The service definition exists, but the service manager did not recover it.") + print(" Fix the service, then retry: hermes gateway start") + sys.exit(1) + # Manual restart: kill existing processes killed = kill_gateway_processes() if killed: diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index eeccf0c1..67277cbe 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -7,6 +7,29 @@ import hermes_cli.gateway as gateway_cli class TestSystemdServiceRefresh: + def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch): + unit_path = tmp_path / "hermes-gateway.service" + unit_path.write_text("old unit\n", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) + monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + + calls = [] + + def fake_run(cmd, check=True, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.systemd_install() + + assert unit_path.read_text(encoding="utf-8") == "new unit\n" + assert calls[:2] == [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", gateway_cli.get_service_name()], + ] + def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch): unit_path = tmp_path / "hermes-gateway.service" unit_path.write_text("old unit\n", encoding="utf-8") @@ -96,6 +119,71 @@ class TestGatewayStopCleanup: assert kill_calls == [False] +class TestLaunchdServiceRecovery: + def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old content", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + + calls = [] + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_install() + + assert "--replace" in plist_path.read_text(encoding="utf-8") + assert calls[:2] == [ + ["launchctl", "unload", str(plist_path)], + ["launchctl", "load", str(plist_path)], + ] + + def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") + + calls = [] + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1: + raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + assert calls == [ + ["launchctl", "start", "ai.hermes.gateway"], + ["launchctl", "load", str(plist_path)], + ["launchctl", "start", "ai.hermes.gateway"], + ] + + def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old content", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=113, stdout="", stderr="Could not find service"), + ) + + gateway_cli.launchd_status() + + output = capsys.readouterr().out + assert str(plist_path) in output + assert "stale" in output.lower() + assert "not loaded" in output.lower() + + class TestGatewayServiceDetection: def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch): user_unit = SimpleNamespace(exists=lambda: True) @@ -158,6 +246,34 @@ class TestGatewaySystemServiceRouting: assert calls == [(False, False)] + def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("plist\n", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "is_linux", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: True) + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr( + gateway_cli, + "launchd_restart", + lambda: (_ for _ in ()).throw( + gateway_cli.subprocess.CalledProcessError(5, ["launchctl", "start", "ai.hermes.gateway"]) + ), + ) + + run_calls = [] + monkeypatch.setattr(gateway_cli, "run_gateway", lambda verbose=False, replace=False: run_calls.append((verbose, replace))) + monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda force=False: 0) + + try: + gateway_cli.gateway_command(SimpleNamespace(gateway_command="restart", system=False)) + except SystemExit as exc: + assert exc.code == 1 + else: + raise AssertionError("Expected gateway_command to exit when service restart fails") + + assert run_calls == [] + class TestEnsureUserSystemdEnv: """Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection."""