Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import asyncio
import logging
import os
import shlex
import shutil
import signal
import subprocess
Expand Down Expand Up @@ -2394,6 +2395,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
# _stable_service_working_dir() for the full rationale.
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
venv_dir = _remap_path_for_user(venv_dir, home_dir)
python_path = _remap_path_for_user(python_path, home_dir)
planned_stop_exec = (
f"{shlex.quote(python_path)} -m hermes_cli.main "
"gateway planned-stop-helper --pid $MAINPID"
)
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
path_entries.extend(_build_wsl_interop_paths(path_entries))
Expand Down Expand Up @@ -2422,6 +2428,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
ExecStop={planned_stop_exec}
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutStopSec={restart_timeout}
StandardOutput=journal
Expand All @@ -2433,6 +2440,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)

hermes_home = str(get_hermes_home().resolve())
profile_arg = _profile_arg(hermes_home)
planned_stop_exec = (
f"{shlex.quote(python_path)} -m hermes_cli.main "
"gateway planned-stop-helper --pid $MAINPID"
)
path_entries.extend(_build_user_local_paths(Path.home(), path_entries))
path_entries.extend(_build_wsl_interop_paths(path_entries))
path_entries.extend(common_bin_paths)
Expand All @@ -2455,6 +2466,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
ExecStop={planned_stop_exec}
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutStopSec={restart_timeout}
StandardOutput=journal
Expand Down Expand Up @@ -2836,6 +2848,16 @@ def systemd_stop(system: bool = False):
print(f"✓ {_service_scope_label(system).capitalize()} service stopped")


def gateway_planned_stop_helper(pid: int) -> bool:
"""Write the planned-stop marker for ``pid`` and return success."""
try:
from gateway.status import write_planned_stop_marker

return bool(write_planned_stop_marker(pid))
except Exception:
return False


def systemd_restart(system: bool = False):
system = _select_systemd_scope(system)
if system:
Expand Down Expand Up @@ -6507,6 +6529,17 @@ def _gateway_command_inner(args):
else:
print(f"✓ Stopped {get_service_name()} service")

elif subcmd == "planned-stop-helper":
pid = int(getattr(args, "pid", 0) or 0)
if pid <= 0:
print("✗ --pid must be a positive integer")
sys.exit(1)
ok = gateway_planned_stop_helper(pid)
if not ok:
print("✗ Failed to write planned-stop marker")
sys.exit(1)
return

elif subcmd == "restart":
# Defense: refuse self-targeting gateway restart from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/subcommands/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
help="Stop ALL gateway processes across all profiles",
)

gateway_stop_helper = gateway_subparsers.add_parser(
"planned-stop-helper",
help=argparse.SUPPRESS,
)
gateway_stop_helper.add_argument(
"--pid",
type=int,
required=True,
help=argparse.SUPPRESS,
)

# gateway restart
gateway_restart = gateway_subparsers.add_parser(
"restart", help="Restart gateway service"
Expand Down
32 changes: 30 additions & 2 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self
unit = gateway_cli.generate_systemd_unit(system=False)

assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecStop=" in unit
assert "gateway planned-stop-helper --pid $MAINPID" in unit
assert "gateway stop" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
Expand Down Expand Up @@ -386,7 +388,9 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se
unit = gateway_cli.generate_systemd_unit(system=True)

assert "ExecStart=" in unit
assert "ExecStop=" not in unit
assert "ExecStop=" in unit
assert "gateway planned-stop-helper --pid $MAINPID" in unit
assert "gateway stop" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
Expand All @@ -395,6 +399,30 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se
assert self._expected_timeout_stop_sec() in unit
assert "WantedBy=multi-user.target" in unit

def test_planned_stop_helper_writes_marker_for_target_pid(self, monkeypatch):
calls = []

monkeypatch.setattr(
gateway_cli,
"gateway_planned_stop_helper",
lambda pid: calls.append(pid) or True,
)

gateway_cli.gateway_command(SimpleNamespace(gateway_command="planned-stop-helper", pid=321))

assert calls == [321]

def test_planned_stop_helper_exits_nonzero_on_marker_failure(self, monkeypatch, capsys):
monkeypatch.setattr(gateway_cli, "gateway_planned_stop_helper", lambda pid: False)

with pytest.raises(SystemExit) as exc_info:
gateway_cli.gateway_command(
SimpleNamespace(gateway_command="planned-stop-helper", pid=321)
)

assert exc_info.value.code == 1
assert "Failed to write planned-stop marker" in capsys.readouterr().out


class TestGatewayStopCleanup:
def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch):
Expand Down