Files
hermes-agent/tests/hermes_cli/test_serve_runtime_inventory.py
T
Teknium ba030bc0db fix(test-seams): monkeypatch.setattr facade aliases — also patch the defining module (57 files)
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a
sibling module and the production path reads the sibling's binding. Where production
reads through BOTH bindings the setattr is duplicated onto the defining module (import
added next to the existing alias import); where only the sibling reads it the target is
repointed. Seams whose production readers go through the facade are left alone.
2026-09-03 19:33:28 -07:00

248 lines
9.7 KiB
Python

"""Serve-kind runtime inventory + stop/relaunch rung (#63206, campaign #91277).
A network-bound `hermes serve --host <ip>` powering a remote Desktop used to
be invisible to the update pipeline: not in the inventory, a dead-end at the
venv-holder guard, and never relaunched after `hermes update` killed it. The
fix threads the spawn ledger's structured launch identity (host/port/profile,
registered at serve startup) through inventory → guard rung → relaunch.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from unittest.mock import patch # noqa: F401 - kept for parity with siblings
import hermes_cli.update_cmd as update_cmd
import hermes_cli.update_inventory as update_inventory
from hermes_cli import main as cli_main
import hermes_cli.main_install_repair as main_install_repair
import hermes_cli.main_dashboard as main_dashboard
def _ledger_entry(**over):
entry = {
"pid": 4321,
"create_time": 111.0,
"purpose": "serve",
"install": "inst",
"spawner_pid": None,
"spawner_create": None,
"registered_at": 222.0,
"argv": "hermes serve --host 100.94.65.93 --port 9119",
"host": "100.94.65.93",
"port": 9119,
"profile": "",
}
entry.update(over)
return entry
# ---------------------------------------------------------------------------
# process_identity: structured detail round-trip
# ---------------------------------------------------------------------------
def test_register_self_records_structured_detail(tmp_path, monkeypatch):
from hermes_cli import process_identity as pi
monkeypatch.setattr(pi, "_ledger_path", lambda: tmp_path / "ledger.json")
monkeypatch.setattr(pi, "install_id", lambda *a, **k: "inst")
assert pi.register_self(
"serve", detail={"host": "100.94.65.93", "port": 9119, "profile": "work"}
)
entries = [
e
for e in pi._read_ledger(tmp_path / "ledger.json")
if e["purpose"] == "serve"
]
assert entries, "serve entry must be written"
e = entries[-1]
assert e["host"] == "100.94.65.93"
assert e["port"] == 9119
assert e["profile"] == "work"
def test_register_self_without_detail_stays_backward_compatible(
tmp_path, monkeypatch
):
from hermes_cli import process_identity as pi
monkeypatch.setattr(pi, "_ledger_path", lambda: tmp_path / "ledger.json")
monkeypatch.setattr(pi, "install_id", lambda *a, **k: "inst")
assert pi.register_self("gateway")
e = pi._read_ledger(tmp_path / "ledger.json")[-1]
assert e["host"] == "" and e["port"] is None and e["profile"] == ""
# ---------------------------------------------------------------------------
# update_inventory: serve collector
# ---------------------------------------------------------------------------
def test_inventory_includes_manual_serve_from_ledger(monkeypatch):
entry = _ledger_entry()
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: None, # no spawner recorded → manual
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves, "manual serve must appear in the inventory"
row = serves[0]
assert row.pid == 4321
assert row.supervisor == "manual-serve"
assert row.restart_via == "respawn-argv"
assert row.detail["host"] == "100.94.65.93"
assert row.detail["port"] == 9119
def test_inventory_classifies_desktop_owned_serve(monkeypatch):
entry = _ledger_entry(spawner_pid=999, spawner_create=1.0)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: False, # Electron parent alive
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves and serves[0].supervisor == "desktop"
assert serves[0].restart_via == "desktop"
def test_describe_restart_mechanism_respawn_argv():
text = update_inventory.describe_restart_mechanism("respawn-argv", "default")
assert "relaunch" in text
# ---------------------------------------------------------------------------
# update_cmd: guard rung helpers
# ---------------------------------------------------------------------------
def test_ledger_manual_serve_holders_filters_correctly(monkeypatch):
manual = _ledger_entry(pid=100)
desktop_owned = _ledger_entry(pid=200, spawner_pid=999, spawner_create=1.0)
gateway = _ledger_entry(pid=300, purpose="gateway")
not_a_holder = _ledger_entry(pid=400)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [manual, desktop_owned, gateway, not_a_holder],
spawner_is_dead=lambda e: False if e["pid"] == 200 else None,
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
holders = [(100, "python.exe", "..."), (200, "python.exe", "..."), (300, "python.exe", "...")]
result = update_cmd._ledger_manual_serve_holders(holders)
pids = [e["pid"] for e in result]
assert pids == [100], (
"only the manual serve holder qualifies: desktop-owned keeps the "
"refusal, gateways belong to the pause machinery, non-holders skipped"
)
def test_serve_relaunch_commands_built_from_structured_identity(monkeypatch):
monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: None)
monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None)
entries = [
_ledger_entry(), # default profile
_ledger_entry(pid=5000, profile="work", port=9200, host=""),
_ledger_entry(pid=6000, port=None), # no port → skipped
_ledger_entry(pid=7000, purpose="dashboard", host="0.0.0.0", port=9300),
]
cmds = update_cmd._serve_relaunch_commands(entries)
assert ["hermes", "serve", "--host", "100.94.65.93", "--port", "9119"] in cmds
assert ["hermes", "--profile", "work", "serve", "--port", "9200"] in cmds
assert ["hermes", "dashboard", "--host", "0.0.0.0", "--port", "9300"] in cmds
assert len(cmds) == 3 # the port-less entry is skipped
def test_relaunch_stopped_serves_is_idempotent(monkeypatch):
calls = []
monkeypatch.setattr(
cli_main, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(
main_dashboard, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: None)
monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None)
token = {"pending": True, "entries": [_ledger_entry()]}
update_cmd._relaunch_stopped_serves(token)
update_cmd._relaunch_stopped_serves(token) # atexit double-fire
assert len(calls) == 1, "relaunch must fire exactly once"
assert token["pending"] is False
def test_relaunch_stopped_serves_untriggered_token_noop(monkeypatch):
calls = []
monkeypatch.setattr(
cli_main, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
monkeypatch.setattr(
main_dashboard, "_respawn_dashboard_processes", lambda cmds: calls.append(cmds) or []
)
update_cmd._relaunch_stopped_serves({"pending": False, "entries": [_ledger_entry()]})
assert calls == []
# ---------------------------------------------------------------------------
# dashboard_procs: ledger augmentation of the scan (#81564 half)
# ---------------------------------------------------------------------------
def test_scan_dashboard_processes_includes_ledger_only_serves(monkeypatch):
"""A profiled serve (`hermes --profile p serve ...`) matches no scan
pattern; the ledger row must still surface it."""
import hermes_cli.dashboard_procs as dp
profiled = _ledger_entry(
pid=8123,
argv="hermes --profile work serve --host 100.94.65.93 --port 9119",
profile="work",
)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [profiled])
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
# Force the ps/wmic scan itself to find nothing.
fake_run = SimpleNamespace(returncode=0, stdout="")
monkeypatch.setattr(
dp.subprocess, "run", lambda *a, **k: fake_run
)
result = dp._scan_dashboard_processes()
assert (8123, profiled["argv"]) in result
def test_scan_dashboard_processes_ledger_respects_exclusions(monkeypatch):
import hermes_cli.dashboard_procs as dp
entry = _ledger_entry(pid=8124)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [entry])
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
fake_run = SimpleNamespace(returncode=0, stdout="")
monkeypatch.setattr(dp.subprocess, "run", lambda *a, **k: fake_run)
assert dp._scan_dashboard_processes(exclude_pids={8124}) == []
def test_inventory_records_the_serve_process_incarnation(monkeypatch):
"""The plan carries ``(pid, create_time)``, not just the PID (#92145 review).
The post-abort survivor probe compares a planned serve against the live
spawn ledger. With only the number to compare, a NEW serve that reused the
old PID reads as the pre-update process that never restarted, and recovery
stays incomplete forever.
"""
entry = _ledger_entry(create_time=1712345678.5)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: None,
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves and serves[0].detail["create_time"] == 1712345678.5