`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>
55 lines
2.3 KiB
Python
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
|