"""Regression guard for #98790 — profile isolation in cron status. Issue #98790: `hermes cron status` in profile B reports profile A's gateway PID as proof that B's jobs will fire. Root causes: 1. systemd branch of ``_get_service_pids()`` returns every hermes-gateway* unit's MainPID even when ``all_profiles=False``, contradicting its docstring («Default-scope callers keep seeing only the current profile's service») and ``find_gateway_pids()``'s contract. 2. Missing ticker heartbeat is reported healthy: when a profile's ticker has never run (``ticker_heartbeat`` absent -> ``hb_age is None``), the stalled branch requires ``hb_age is not None``, so the profile falls through to «Gateway is running — cron jobs will fire automatically». The fix: filter systemd units by the current profile's service name when ``all_profiles=False``, and treat a missing heartbeat as «ticker has never ticked» unless the gateway process itself started less than STALE_AFTER ago (fresh restart grace period). """ from __future__ import annotations import time from unittest.mock import MagicMock, Mock, patch from types import SimpleNamespace import pytest @pytest.fixture() def tmp_cron_dir(tmp_path, monkeypatch): monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron") monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json") monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output") return tmp_path class TestCronStatusHeartbeatGuard: """Ensure `hermes cron status` correctly warns when the heartbeat file is absent. Issue #98790 (root cause 2): profiles with no `cron/ticker_heartbeat` fall through to the "✓ Gateway is running" branch even though ticks won't fire. """ def test_no_heartbeat_triggers_yellow_warning(self, monkeypatch, capsys): from hermes_cli import cron as cron_mod with ( patch("hermes_cli.gateway.find_gateway_pids", return_value={789}), patch("cron.jobs.get_ticker_heartbeat_age", return_value=None), patch("cron.jobs.get_ticker_success_age", return_value=None), patch("cron.jobs.get_ticker_last_error", return_value=None), patch("cron.jobs.TICKER_INTERVAL_SECONDS", 60), ): cron_mod.cron_status() stdout = capsys.readouterr().out assert "⚠ Gateway is running but the cron ticker has not reported a heartbeat" in stdout assert "Cron jobs will NOT fire" in stdout # Must NOT show the green ✓ assert "✓ Gateway is running" not in stdout def test_fresh_heartbeat_shows_green_checkmark(self, monkeypatch, capsys): from hermes_cli import cron as cron_mod with ( patch("hermes_cli.gateway.find_gateway_pids", return_value={999}), patch("cron.jobs.get_ticker_heartbeat_age", return_value=10), patch("cron.jobs.get_ticker_success_age", return_value=8), patch("cron.jobs.TICKER_INTERVAL_SECONDS", 60), ): cron_mod.cron_status() stdout = capsys.readouterr().out assert "✓ Gateway is running — cron jobs will fire automatically" in stdout assert "⚠" not in stdout class TestGetServicePidsProfileScope: """systemd branch must honor ``all_profiles`` and filter by profile.""" def test_default_scope_filters_current_profile_systemd_unit(self, monkeypatch): from hermes_cli import gateway as gateway_mod def _run_side_effect(args, **kwargs): cmd_str = " ".join(str(a) for a in args) if "list-units" in cmd_str: # systemctl now filters: return only the unit that matches the pattern if "hermes-gateway-jarvis" in cmd_str: stdout = "hermes-gateway-jarvis.service loaded active running\n" elif "hermes-gateway-coder" in cmd_str: stdout = "hermes-gateway-coder.service loaded active running\n" elif "hermes-gateway*" in cmd_str: stdout = ( "hermes-gateway-jarvis.service loaded active running\n" "hermes-gateway-coder.service loaded active running\n" ) else: stdout = "" return MagicMock(returncode=0, stdout=stdout, stderr="") if "show" in cmd_str and "MainPID" in cmd_str: if "hermes-gateway-jarvis" in cmd_str: return MagicMock(returncode=0, stdout="123\n", stderr="") elif "hermes-gateway-coder" in cmd_str: return MagicMock(returncode=0, stdout="456\n", stderr="") return MagicMock(returncode=0, stdout="", stderr="") with ( patch("hermes_cli.gateway.is_macos", return_value=False), patch("hermes_cli.gateway.supports_systemd_services", return_value=True), patch("hermes_cli.gateway.get_service_name", return_value="hermes-gateway-jarvis"), patch("subprocess.run", side_effect=_run_side_effect), ): pids = gateway_mod._get_service_pids() assert pids == {123}, "default scope must filter to current profile's unit" def test_all_profiles_true_enumerates_fleet(self, monkeypatch): from hermes_cli import gateway as gateway_mod def _run_side_effect(args, **kwargs): cmd_str = " ".join(str(a) for a in (args[:4] if args else [])) if "list-units" in cmd_str: return MagicMock( returncode=0, stdout=( "hermes-gateway.service loaded active running\n" "hermes-gateway-profile-b.service loaded active running\n" ), stderr="", ) if "show" in cmd_str and "MainPID" in cmd_str: if "hermes-gateway.service" in " ".join(args): return MagicMock(returncode=0, stdout="111\n", stderr="") elif "hermes-gateway-profile-b.service" in " ".join(args): return MagicMock(returncode=0, stdout="222\n", stderr="") return MagicMock(returncode=0, stdout="", stderr="") with ( patch("hermes_cli.gateway.is_macos", return_value=False), patch("hermes_cli.gateway.supports_systemd_services", return_value=True), patch("hermes_cli.gateway.get_service_name", return_value="hermes-gateway"), patch("subprocess.run", side_effect=_run_side_effect), ): pids = gateway_mod._get_service_pids(all_profiles=True) assert pids == {111, 222}, "all_profiles=True must enumerate the whole fleet" class TestCronStatusMissingHeartbeat: """Missing ticker heartbeat must be reported honestly, not as healthy.""" def test_missing_heartbeat_warns_when_gateway_old(self, tmp_cron_dir, capsys, monkeypatch): import io from contextlib import redirect_stdout import hermes_cli.cron as cron_cli from cron.jobs import create_job create_job(prompt="Test", schedule="every 1h") out = io.StringIO() with ( patch("hermes_cli.cron._active_cron_provider_name", return_value="builtin"), patch("hermes_cli.gateway.find_gateway_pids", return_value=[4242]), patch("gateway.status.is_gateway_runtime_lock_active", return_value=True), patch("gateway.status.get_running_pid", return_value=4242), patch("cron.jobs.get_ticker_heartbeat_age", return_value=None), patch("cron.jobs.get_ticker_success_age", return_value=None), patch("cron.jobs.TICKER_INTERVAL_SECONDS", 60), # Gateway started long ago, ticker should have heartbeat by now patch("gateway.status._read_pid_record", return_value={"pid": 4242, "start_time": 1}), patch("gateway.status._get_process_start_time", return_value=1), redirect_stdout(out), ): cron_cli.cron_status() text = out.getvalue() assert "has not reported a heartbeat" in text or "no heartbeat" in text.lower() assert "will fire" not in text.lower() or "will NOT fire" in text def test_missing_heartbeat_green_when_gateway_just_started(self, tmp_cron_dir, capsys, monkeypatch): import io from contextlib import redirect_stdout import hermes_cli.cron as cron_cli from cron.jobs import create_job create_job(prompt="Test", schedule="every 1h") now = time.time() out = io.StringIO() with ( patch("hermes_cli.cron._active_cron_provider_name", return_value="builtin"), patch("hermes_cli.gateway.find_gateway_pids", return_value=[4242]), patch("gateway.status.is_gateway_runtime_lock_active", return_value=True), patch("gateway.status.get_running_pid", return_value=4242), patch("cron.jobs.get_ticker_heartbeat_age", return_value=None), patch("cron.jobs.get_ticker_success_age", return_value=None), patch("cron.jobs.TICKER_INTERVAL_SECONDS", 60), # Gateway started a few seconds ago, ticker hasn't had its first tick yet patch("gateway.status._read_pid_record", return_value={"pid": 4242, "start_time": int(now)}), patch("gateway.status._get_process_start_time", return_value=int(now)), patch("time.time", return_value=now + 5), redirect_stdout(out), ): cron_cli.cron_status() text = out.getvalue() assert "will fire" in text or "running" in text assert "never ticked" not in text.lower()