Files
hermes-agent/tests/hermes_cli/test_cron_status_next_run.py
teknium1 eddf7283e4 fix(cron): label overdue next runs in cron list, dashboard and Desktop; one parser for the instant
`hermes cron list`, the web dashboard Cron page and the Desktop cron panel/sidebar all
rendered a `next_run_at` parked hours in the past as an ordinary upcoming "Next run" —
the only user-visible trace of a scheduler that stopped ticking (#114309). Every
surface now labels a slot past `cron doctor`'s 15-minute grace as overdue (CLI
`Overdue:` row with the lateness, web `Overdue since`, Desktop `Overdue since` label
on the detail panel and sidebar meta) while paused/disabled/completed jobs keep the
plain label because they are not expected to fire.

The CLI's overdue check now parses through `cron.jobs._parse_aware` and `hermes_time.now`
so status/list/doctor and the ticker agree on the instant (mixed offsets, DST folds,
legacy naive stamps read as system-local like the scheduler does), and both the
ordering and the subtraction normalise to UTC: Python compares same-tzinfo datetimes by
wall clock, which is wrong across a DST fold. Status still orders the soonest run by
instant (#113874) and prints the stored stamp.

Tests: the salvaged status tests are trimmed to two invariants (overdue + stale
heartbeat is loud on status AND list; within-grace stays plain on both), the DST-fold
ordering tests freeze the CLI clock as well so their 2026-11 fixtures never start
reading as overdue, and one vitest each pins the web and Desktop helpers.

Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
2026-09-18 12:46:54 -07:00

55 lines
2.3 KiB
Python

"""Cron status orders persisted next runs by instant, including a DST fold."""
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from cron import jobs as job_store
from hermes_cli.cron import _print_active_jobs_summary
@pytest.fixture(autouse=True)
def _frozen_clock(monkeypatch):
# Freeze both the scheduler's clock (normalisation) and the CLI's (overdue check): the
# fixtures below are 2026-11 instants and must never start reading as overdue.
now = datetime(2026, 10, 31, tzinfo=ZoneInfo("America/New_York"))
monkeypatch.setattr(job_store, "_hermes_now", lambda: now)
monkeypatch.setattr("hermes_time.now", lambda: now)
@pytest.mark.parametrize("later,earlier", [
("2026-11-01T01:15:00-05:00", "2026-11-01T01:45:00-04:00"),
("2026-11-02T09:00:00-05:00", "2026-11-02T08:00:00-05:00"),
("2026-11-02T08:00:00-05:00", "2026-11-02T12:30:00+00:00"),
])
def test_status_earliest_persisted_instant(later, earlier, capsys):
# Only the clock is frozen: creation, persistence, normalization and listing are real.
late_job = job_store.create_job(prompt="later", schedule=later)
early_job = job_store.create_job(prompt="earlier", schedule=earlier)
jobs = job_store.list_jobs()
assert {job["id"] for job in jobs} == {late_job["id"], early_job["id"]}
expected = next(job["next_run_at"] for job in jobs if job["id"] == early_job["id"])
_print_active_jobs_summary(jobs)
out = capsys.readouterr().out
assert "2 active job(s)" in out
assert f"Next run: {expected}\n" in out
assert job_store.list_jobs() == jobs # Rendering never reschedules a job.
@pytest.mark.parametrize("jobs,expected", [
([], " No active jobs\n"),
([{}, {"next_run_at": None}, {"next_run_at": ""}], " 3 active job(s)\n"),
([{"next_run_at": "bad"}, {"next_run_at": 123}], " 2 active job(s)\n"),
([{"next_run_at": "bad"}, {"next_run_at": "2026-11-01T06:15:00Z"}],
" 2 active job(s)\n Next run: 2026-11-01T06:15:00Z\n"),
([{"next_run_at": "2026-11-01T06:15:00Z"},
{"next_run_at": "2026-11-01T01:15:00-05:00"}],
" 2 active job(s)\n Next run: 2026-11-01T06:15:00Z\n"),
])
def test_status_missing_invalid_and_equivalent_instants(jobs, expected, capsys):
_print_active_jobs_summary(jobs)
assert capsys.readouterr().out == expected