fix: scope gateway stop/restart to current profile, --all for global kill

gateway stop and restart previously called kill_gateway_processes() which
scans ps aux and kills ALL gateway processes across all profiles. Starting
a profile gateway would nuke the main one (and vice versa).

Now:
- hermes gateway stop → only kills the current profile's gateway (PID file)
- hermes -p work gateway stop → only kills the 'work' profile's gateway
- hermes gateway stop --all → kills every gateway process (old behavior)
- hermes gateway restart → profile-scoped for manual fallback path
- hermes update → discovers and restarts ALL profile gateways (systemctl
  list-units hermes-gateway*) since the code update is shared

Added stop_profile_gateway() which uses the HERMES_HOME-scoped PID file
instead of global process scanning.
This commit is contained in:
Teknium
2026-04-03 14:21:25 -07:00
parent 52ddd6bc64
commit 84a875ca02
4 changed files with 229 additions and 172 deletions

View File

@@ -103,7 +103,9 @@ class TestGeneratedSystemdUnits:
class TestGatewayStopCleanup:
def test_stop_sweeps_manual_gateway_processes_after_service_stop(self, tmp_path, monkeypatch):
def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch):
"""Without --all, stop uses systemd (if available) and does NOT call
the global kill_gateway_processes()."""
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("unit\n", encoding="utf-8")
@@ -123,6 +125,31 @@ class TestGatewayStopCleanup:
gateway_cli.gateway_command(SimpleNamespace(gateway_command="stop"))
assert service_calls == ["stop"]
# Global kill should NOT be called without --all
assert kill_calls == []
def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch):
"""With --all, stop uses systemd AND calls the global kill_gateway_processes()."""
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("unit\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
service_calls = []
kill_calls = []
monkeypatch.setattr(gateway_cli, "systemd_stop", lambda system=False: service_calls.append("stop"))
monkeypatch.setattr(
gateway_cli,
"kill_gateway_processes",
lambda force=False: kill_calls.append(force) or 2,
)
gateway_cli.gateway_command(SimpleNamespace(gateway_command="stop", **{"all": True}))
assert service_calls == ["stop"]
assert kill_calls == [False]

View File

@@ -47,6 +47,22 @@ def _make_run_side_effect(
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
# systemctl list-units hermes-gateway* — discover all gateway services
if "systemctl" in joined and "list-units" in joined:
if "--user" in joined and systemd_active:
return subprocess.CompletedProcess(
cmd, 0,
stdout="hermes-gateway.service loaded active running Hermes Gateway\n",
stderr="",
)
elif "--user" not in joined and system_service_active:
return subprocess.CompletedProcess(
cmd, 0,
stdout="hermes-gateway.service loaded active running Hermes Gateway\n",
stderr="",
)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
# systemctl is-active — distinguish --user from system scope
if "systemctl" in joined and "is-active" in joined:
if "--user" in joined:
@@ -305,15 +321,14 @@ class TestCmdUpdateLaunchdRestart:
launchctl_loaded=True,
)
# Mock get_running_pid to return a PID
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"), \
patch.object(gateway_cli, "launchd_restart") as mock_launchd_restart:
# Mock launchd_restart + find_gateway_pids (new code discovers all gateways)
with patch.object(gateway_cli, "launchd_restart") as mock_launchd_restart, \
patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(mock_args)
captured = capsys.readouterr().out
assert "Restarting gateway service" in captured
assert "Restart it with: hermes gateway run" not in captured
assert "Restarted" in captured
assert "Restart manually: hermes gateway run" not in captured
mock_launchd_restart.assert_called_once_with()
@patch("shutil.which", return_value=None)
@@ -321,7 +336,7 @@ class TestCmdUpdateLaunchdRestart:
def test_update_without_launchd_shows_manual_restart(
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
):
"""When no service manager is running, update should show the manual restart hint."""
"""When no service manager is running but manual gateway is found, show manual restart hint."""
monkeypatch.setattr(
gateway_cli, "is_macos", lambda: True,
)
@@ -336,14 +351,13 @@ class TestCmdUpdateLaunchdRestart:
launchctl_loaded=False,
)
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"), \
# Simulate a manual gateway process found by find_gateway_pids
with patch.object(gateway_cli, "find_gateway_pids", return_value=[12345]), \
patch("os.kill"):
cmd_update(mock_args)
captured = capsys.readouterr().out
assert "Restart it with: hermes gateway run" in captured
assert "Gateway restarted via launchd" not in captured
assert "Restart manually: hermes gateway run" in captured
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
@@ -360,13 +374,11 @@ class TestCmdUpdateLaunchdRestart:
systemd_active=True,
)
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"), \
patch("os.kill"):
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(mock_args)
captured = capsys.readouterr().out
assert "Gateway restarted" in captured
assert "Restarted hermes-gateway" in captured
# Verify systemctl restart was called
restart_calls = [
c for c in mock_run.call_args_list
@@ -422,13 +434,11 @@ class TestCmdUpdateSystemService:
system_service_active=True,
)
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"):
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(mock_args)
captured = capsys.readouterr().out
assert "system gateway service" in captured.lower()
assert "Gateway restarted (system service)" in captured
assert "Restarted hermes-gateway" in captured
# Verify systemctl restart (no --user) was called
restart_calls = [
c for c in mock_run.call_args_list
@@ -440,10 +450,10 @@ class TestCmdUpdateSystemService:
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_system_service_restart_failure_shows_sudo_hint(
def test_update_system_service_restart_failure_shows_error(
self, mock_run, _mock_which, mock_args, capsys, monkeypatch,
):
"""When system service restart fails (e.g. no root), show sudo hint."""
"""When system service restart fails, show the failure message."""
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
@@ -454,19 +464,18 @@ class TestCmdUpdateSystemService:
system_restart_rc=1,
)
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"):
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(mock_args)
captured = capsys.readouterr().out
assert "sudo systemctl restart" in captured
assert "Failed to restart" in captured
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_user_service_takes_priority_over_system(
self, mock_run, _mock_which, mock_args, capsys, monkeypatch,
):
"""When both user and system services are active, user wins."""
"""When both user and system services are active, both are restarted."""
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
@@ -476,12 +485,9 @@ class TestCmdUpdateSystemService:
system_service_active=True,
)
with patch("gateway.status.get_running_pid", return_value=12345), \
patch("gateway.status.remove_pid_file"), \
patch("os.kill"):
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
cmd_update(mock_args)
captured = capsys.readouterr().out
# Should restart via user service, not system
assert "Gateway restarted." in captured
assert "(system service)" not in captured
# Both scopes are discovered and restarted
assert "Restarted hermes-gateway" in captured