147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
"""CLI rendering of missed-run dispatch visibility (#99879).
|
|
|
|
`hermes cron list` shows a Dispatch line (scheduled vs actual, lateness,
|
|
disposition) and `hermes cron status` calls out jobs whose last dispatch
|
|
was a late/missed-fire catch-up.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from cron.jobs import create_job, load_jobs, save_jobs
|
|
from hermes_cli.cron import (
|
|
_dispatch_display,
|
|
_format_lateness,
|
|
_print_active_jobs_summary,
|
|
cron_list,
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
|
|
def _stamp_last_dispatch(job_id, stamp):
|
|
jobs = load_jobs()
|
|
for job in jobs:
|
|
if job["id"] == job_id:
|
|
job["last_dispatch"] = stamp
|
|
save_jobs(jobs)
|
|
|
|
|
|
def _catch_up_stamp(late_seconds=1860.0, kind="catch_up"):
|
|
scheduled = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
|
|
return {
|
|
"scheduled_at": scheduled.isoformat(),
|
|
"dispatched_at": (scheduled + timedelta(seconds=late_seconds)).isoformat(),
|
|
"lateness_seconds": late_seconds,
|
|
"kind": kind,
|
|
}
|
|
|
|
|
|
class TestCronListDispatchLine:
|
|
def test_catch_up_dispatch_rendered(self, tmp_cron_dir, capsys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
|
|
)
|
|
job = create_job(prompt="daily report", schedule="0 9 * * *")
|
|
_stamp_last_dispatch(job["id"], _catch_up_stamp())
|
|
|
|
cron_list()
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Dispatch:" in out
|
|
assert "catch-up after missed fire" in out
|
|
assert "2026-09-01T09:00:00+00:00" in out # scheduled instant
|
|
assert "31m late" in out
|
|
|
|
def test_on_time_dispatch_rendered_quietly(self, tmp_cron_dir, capsys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
|
|
)
|
|
job = create_job(prompt="daily report", schedule="0 9 * * *")
|
|
_stamp_last_dispatch(job["id"], _catch_up_stamp(30.0, kind="on_time"))
|
|
|
|
cron_list()
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Dispatch:" in out
|
|
assert "on time" in out
|
|
assert "catch-up" not in out
|
|
|
|
def test_no_stamp_no_dispatch_line(self, tmp_cron_dir, capsys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
|
|
)
|
|
create_job(prompt="daily report", schedule="0 9 * * *")
|
|
|
|
cron_list()
|
|
|
|
assert "Dispatch:" not in capsys.readouterr().out
|
|
|
|
|
|
class TestStatusLateJobsCallout:
|
|
def test_late_jobs_called_out(self, capsys):
|
|
jobs = [
|
|
{
|
|
"id": "abc123",
|
|
"name": "daily 9am",
|
|
"next_run_at": "2026-09-02T09:00:00+00:00",
|
|
"last_dispatch": _catch_up_stamp(),
|
|
},
|
|
{
|
|
"id": "def456",
|
|
"name": "hourly ping",
|
|
"next_run_at": "2026-09-01T10:00:00+00:00",
|
|
"last_dispatch": _catch_up_stamp(10.0, kind="on_time"),
|
|
},
|
|
]
|
|
|
|
_print_active_jobs_summary(jobs)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "1 job(s) last fired late (missed-fire catch-up)" in out
|
|
assert "abc123" in out
|
|
assert "31m late" in out
|
|
# On-time job is not in the callout.
|
|
assert "def456" not in out
|
|
|
|
def test_no_late_jobs_no_callout(self, capsys):
|
|
jobs = [
|
|
{
|
|
"id": "def456",
|
|
"name": "hourly ping",
|
|
"next_run_at": "2026-09-01T10:00:00+00:00",
|
|
"last_dispatch": _catch_up_stamp(10.0, kind="on_time"),
|
|
}
|
|
]
|
|
|
|
_print_active_jobs_summary(jobs)
|
|
|
|
assert "fired late" not in capsys.readouterr().out
|
|
|
|
|
|
class TestDisplayHelpers:
|
|
def test_format_lateness(self):
|
|
assert _format_lateness(45) == "45s"
|
|
assert _format_lateness(1860) == "31m"
|
|
assert _format_lateness(9000) == "2h 30m"
|
|
assert _format_lateness(90000) == "1d 1h"
|
|
assert _format_lateness("bogus") == "?"
|
|
|
|
def test_dispatch_display_malformed_returns_none(self):
|
|
assert _dispatch_display(None) is None
|
|
assert _dispatch_display("late") is None
|
|
assert _dispatch_display({}) is None
|
|
assert _dispatch_display({"scheduled_at": "x"}) is None
|
|
|
|
def test_dispatch_display_late_kind(self):
|
|
line = _dispatch_display(_catch_up_stamp(600.0, kind="late"))
|
|
assert "late" in line
|
|
assert "10m" in line
|