mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
`hermes dashboard` is a long-lived foreground server that users often start and forget about, sometimes in a shell they've since closed. We didn't have a way to stop it — users had to find the PID manually. Adds two lifecycle flags that reuse the same detection + termination path the post-`hermes update` cleanup (PR #17832) uses: hermes dashboard --status List running hermes dashboard processes with PID + cmdline. Exit 0, informational. hermes dashboard --stop Terminate all running dashboards (3s grace then force-kill survivors). Exit 0 if none remain, 1 if any couldn't be stopped. Windows uses `taskkill /F` as before. Both flags short-circuit before any fastapi/uvicorn import so they work even on installations where the dashboard extras aren't installed — useful when you're cleaning up after uninstalling. The kill helper gained an optional `reason=...` param so the output reads "(requested via --stop)" instead of the post-update-specific "running backend no longer matches the updated frontend" wording. E2E: `hermes dashboard --status` with nothing running prints the empty message; with a fake `hermes dashboard ...` cmdline spawned via `exec -a`, `--status` lists it, `--stop` terminates it (exit -15), and a follow-up `--status` returns empty.
181 lines
7.7 KiB
Python
181 lines
7.7 KiB
Python
"""Tests for ``hermes dashboard --stop`` / ``--status`` flags.
|
|
|
|
These flags share the detection + kill path with the post-``hermes update``
|
|
cleanup, so the heavy coverage of SIGTERM / SIGKILL / Windows taskkill lives
|
|
in ``test_update_stale_dashboard.py``. This file just verifies the flag
|
|
dispatch: argparse wiring, no-op when nothing is running, and correct
|
|
exit codes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.main import cmd_dashboard, _report_dashboard_status
|
|
|
|
|
|
def _ns(**kw):
|
|
"""Build an argparse.Namespace with dashboard defaults plus overrides."""
|
|
defaults = dict(
|
|
port=9119, host="127.0.0.1", no_open=False, insecure=False,
|
|
tui=False, stop=False, status=False,
|
|
)
|
|
defaults.update(kw)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
class TestDashboardStatus:
|
|
def test_status_no_processes(self, capsys):
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(status=True))
|
|
assert exc.value.code == 0
|
|
out = capsys.readouterr().out
|
|
assert "No hermes dashboard processes running" in out
|
|
|
|
def test_status_with_processes(self, capsys):
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[12345, 12346]), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(status=True))
|
|
# Status is informational — always exits 0.
|
|
assert exc.value.code == 0
|
|
out = capsys.readouterr().out
|
|
assert "2 hermes dashboard process(es) running" in out
|
|
assert "PID 12345" in out
|
|
assert "PID 12346" in out
|
|
|
|
def test_status_does_not_try_to_import_fastapi(self):
|
|
"""`--status` must not require dashboard runtime deps — it's a
|
|
process-table scan only. We prove this by making fastapi import
|
|
fail and confirming --status still succeeds."""
|
|
orig_import = __import__
|
|
def fake_import(name, *a, **kw):
|
|
if name == "fastapi":
|
|
raise ImportError("fastapi missing")
|
|
return orig_import(name, *a, **kw)
|
|
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
patch("builtins.__import__", side_effect=fake_import), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(status=True))
|
|
assert exc.value.code == 0
|
|
|
|
|
|
class TestDashboardStop:
|
|
def test_stop_when_nothing_running(self, capsys):
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(stop=True))
|
|
assert exc.value.code == 0
|
|
out = capsys.readouterr().out
|
|
assert "No hermes dashboard processes running" in out
|
|
|
|
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
|
|
"""After the kill, if the second scan returns empty we exit 0."""
|
|
# First scan: finds two processes. Second (verification) scan: empty.
|
|
scans = iter([[12345, 12346], []])
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
side_effect=lambda: next(scans)), \
|
|
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(stop=True))
|
|
mock_kill.assert_called_once()
|
|
# --stop should pass a reason so the output doesn't say "running
|
|
# backend no longer matches the updated frontend" (that wording is
|
|
# for the post-`hermes update` path).
|
|
kwargs = mock_kill.call_args.kwargs
|
|
assert "reason" in kwargs
|
|
assert "stop" in kwargs["reason"].lower()
|
|
assert exc.value.code == 0
|
|
|
|
def test_stop_exits_nonzero_if_kill_leaves_survivors(self):
|
|
"""If the second scan still finds PIDs, we exit 1 so scripts can
|
|
detect that the stop didn't succeed (e.g. permission denied)."""
|
|
scans = iter([[12345], [12345]]) # both scans find the same PID
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
side_effect=lambda: next(scans)), \
|
|
patch("hermes_cli.main._kill_stale_dashboard_processes"), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(stop=True))
|
|
assert exc.value.code == 1
|
|
|
|
def test_stop_does_not_try_to_import_fastapi(self):
|
|
"""Like --status, --stop must work without dashboard runtime deps."""
|
|
orig_import = __import__
|
|
def fake_import(name, *a, **kw):
|
|
if name == "fastapi":
|
|
raise ImportError("fastapi missing")
|
|
return orig_import(name, *a, **kw)
|
|
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
patch("builtins.__import__", side_effect=fake_import), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cmd_dashboard(_ns(stop=True))
|
|
assert exc.value.code == 0
|
|
|
|
|
|
class TestLifecycleFlagsTakePrecedence:
|
|
"""If both --stop and --status are set, --status wins (it's listed
|
|
first in cmd_dashboard). Neither is allowed to fall through to the
|
|
server-start path, which is the critical safety property — a user
|
|
who typed ``hermes dashboard --stop`` must not end up ALSO starting
|
|
a new server."""
|
|
|
|
def test_status_wins_over_stop(self, capsys):
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
|
|
pytest.raises(SystemExit):
|
|
cmd_dashboard(_ns(status=True, stop=True))
|
|
# Kill path must NOT run when --status is also set.
|
|
mock_kill.assert_not_called()
|
|
|
|
def test_stop_does_not_fall_through_to_server_start(self):
|
|
"""Covers the worst-case regression: if --stop ever stopped exiting
|
|
early, the user would start the dashboard they just asked to stop."""
|
|
called = {"start": False}
|
|
def fake_start_server(**kw):
|
|
called["start"] = True
|
|
|
|
# Provide a fake web_server module so the import doesn't matter.
|
|
fake_ws = MagicMock()
|
|
fake_ws.start_server = fake_start_server
|
|
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
patch.dict(sys.modules, {"hermes_cli.web_server": fake_ws}), \
|
|
pytest.raises(SystemExit):
|
|
cmd_dashboard(_ns(stop=True))
|
|
assert called["start"] is False
|
|
|
|
|
|
class TestArgparseWiring:
|
|
"""Confirm the flags are exposed via the real argparse tree so
|
|
``hermes dashboard --stop`` / ``--status`` actually parse."""
|
|
|
|
def test_flags_are_registered(self):
|
|
from hermes_cli.main import main as _cli_main # noqa: F401
|
|
# Rebuild the argparse tree by re-running the section of main()
|
|
# that builds it. Cheapest way: introspect via --help on the
|
|
# already-built parser would require refactoring; instead we
|
|
# parse the flags directly via a minimal replay.
|
|
import importlib
|
|
mod = importlib.import_module("hermes_cli.main")
|
|
# Find the dashboard_parser instance by running build logic would
|
|
# be too invasive. Instead parse args as if via the CLI by
|
|
# intercepting parse_args. This is overkill for a smoke test —
|
|
# we just want to know the flags don't KeyError.
|
|
with patch("hermes_cli.main._find_stale_dashboard_pids",
|
|
return_value=[]), \
|
|
pytest.raises(SystemExit) as exc:
|
|
mod.cmd_dashboard(_ns(status=True))
|
|
assert exc.value.code == 0
|