Files
hermes-agent/tests/hermes_cli/test_update_fleet_home_scope.py
teknium1 c500dc6d99 fix: hermes update restarts only the gateways of the home it updates (#93349)
hermes-gateway*/hermes-serve* units, ai.hermes.gateway* LaunchAgents and
`gateway run` processes are account-wide namespaces shared by every Hermes
install on the box. The restart phase enumerated them by name, so a scratch
home's `hermes update` drained and restarted the account's real
hermes-gateway.service and SIGTERMed sibling installs' gateways (live: #93349
comment, 2026-09-23), then warned "Fleet version check returned no rows"
because none of those runtimes belonged to the updating home.

Ownership is now judged from what a runtime actually runs on, never from the
label: the live process environment (`_hermes_home_for_pid`), the unit's
declared Environment=HERMES_HOME, or the plist's pinned HERMES_HOME, compared
against the homes the update's plan inventories (the updating root and its
profiles/<name>). Foreign or unreadable ownership is named in the output and
left alone; it is not a failed restart. Applies to the systemd fleet loop, the
catch-up best-effort restart, the launchd derived-label loop, the manual
gateway sweep, and the pre/post-restart PID snapshots that drive the
fail-closed verdict.
2026-09-23 05:36:30 -07:00

96 lines
4.4 KiB
Python

"""#93349 — ``hermes update`` restarts only the gateways of the home it is updating.
``hermes-gateway*`` units and ``gateway run`` processes are host-wide namespaces shared by every
Hermes install under the account. A scratch home's update used to drain and restart the account's
real ``hermes-gateway.service`` and SIGTERM sibling installs' gateways because they were listed,
not because they ran the updated code.
"""
from __future__ import annotations
import os
import signal
import subprocess
from pathlib import Path
import pytest
from hermes_cli import update_cmd_fleet as fleet
from hermes_cli import dashboard_procs
FOREIGN_HOME = "/srv/other-account-home/.hermes"
@pytest.fixture
def own_home(monkeypatch, tmp_path):
home = tmp_path / "homeA" / ".hermes"
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
# The plan inventory is this install only; the fixture home has no profiles dir.
monkeypatch.setattr("hermes_cli.update_receipt._profile_homes", lambda: [("default", home)])
return home
def _pid_homes(monkeypatch, mapping: dict):
monkeypatch.setattr(dashboard_procs, "_hermes_home_for_pid", lambda pid: mapping.get(pid))
def test_systemd_unit_of_another_home_is_left_alone(monkeypatch, own_home):
"""user/hermes-gateway runs on another HERMES_HOME → no drain, no restart, not a failure;
the same unit running on the updating home is still restarted (control)."""
homes = {4242: FOREIGN_HOME, 4343: str(own_home)}
_pid_homes(monkeypatch, homes)
main_pid = {"value": 4242}
write_verbs: list[list[str]] = []
def fake_systemctl(cmd, *, timeout):
if "is-active" in cmd:
return subprocess.CompletedProcess(cmd, 0, stdout="active\n", stderr="")
if "show" in cmd and "--property=MainPID" in cmd:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{main_pid['value']}\n", stderr="")
if "show" in cmd:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
write_verbs.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(fleet, "_systemctl", fake_systemctl)
monkeypatch.setattr(fleet, "_repair_unit_without_fatal_exit_park", lambda *a, **k: None)
drained: list[int] = []
monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda pid, *a, **k: drained.append(pid) or True)
monkeypatch.setattr(fleet, "_wait_for_service_active", lambda *a, **k: True)
restarted: list[str] = []
failed: list[str] = []
fleet._restart_one_systemd_gateway_unit(
"hermes-gateway", scope="user", scope_cmd=["systemctl", "--user"], drain_budget=5.0,
_manage_cmd_cache={}, restarted_services=restarted, failed_or_stale_units=failed,
)
assert drained == [] and write_verbs == [] and restarted == [] and failed == []
main_pid["value"] = 4343 # control: same unit name, this update's home
fleet._restart_one_systemd_gateway_unit(
"hermes-gateway", scope="user", scope_cmd=["systemctl", "--user"], drain_budget=5.0,
_manage_cmd_cache={}, restarted_services=restarted, failed_or_stale_units=failed,
)
assert drained == [4343] and restarted == ["hermes-gateway"] and failed == []
def test_manual_gateway_of_another_home_is_not_stopped(monkeypatch, own_home):
"""Of two ``gateway run`` processes on the host, only the one on the updating home is SIGTERMed;
a process whose home cannot be read is spared as well."""
_pid_homes(monkeypatch, {111: str(own_home), 222: FOREIGN_HOME, 333: None})
monkeypatch.setattr("hermes_cli.gateway._get_service_pids", lambda **k: set())
monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda **k: [111, 222, 333])
monkeypatch.setattr("hermes_cli.gateway.find_profile_gateway_processes", lambda **k: [])
monkeypatch.setattr("hermes_cli.gateway._wait_for_gateway_exit", lambda **k: None)
killed: list[tuple[int, int]] = []
monkeypatch.setattr(os, "kill", lambda pid, sig: killed.append((pid, sig)))
out = fleet._GatewayRestartOutcome(
incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], restarted_services=[],
failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(),
)
fleet._restart_manual_gateways(out, 5.0)
assert killed == [(111, signal.SIGTERM)]
assert out.killed_pids == {111}