From d6d29b00106fc1de16767247751df6f640d6083f Mon Sep 17 00:00:00 2001 From: JF Lemieux Date: Sun, 30 Aug 2026 11:26:45 +0000 Subject: [PATCH] fix(cron): anchor croniter to the configured IANA timezone croniter 6.x ignores tzinfo on its start time and instead uses the start's UTC *offset* as its working offset. compute_next_run passed a tz-aware last_run_at straight into croniter(expr, base_time), which produced two bugs: - a last_run_at stored in UTC (+00:00) shifted the next fire to the cron hour in UTC rather than local time (09:00 UTC = 05:00 America/Toronto) - on DST transition days the wall-clock hour drifted one hour off (08:00 on spring-forward, 10:00 on fall-back) Render the base as the configured zone's naive wall clock for croniter, then re-attach the zone to the result, so the wall-clock hour stays correct every calendar day including DST boundaries. Fall back to the base's own zone only when no timezone is configured. Adds regression tests covering a UTC-stored last_run_at, spring-forward, fall-back, and a full-year walk across both DST transitions. --- cron/jobs.py | 17 +++- ...t_compute_next_run_dst_and_utc_last_run.py | 86 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/cron/test_compute_next_run_dst_and_utc_last_run.py diff --git a/cron/jobs.py b/cron/jobs.py index 6002eb3a47..d3b0ca6cba 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -34,6 +34,7 @@ from typing import Optional, Dict, List, Any, Callable, Set, Tuple, Union, Colle logger = logging.getLogger(__name__) from hermes_time import now as _hermes_now +from hermes_time import get_timezone from utils import atomic_replace, atomic_write_text # croniter is imported lazily (slow import, only needed for cron exprs). HAS_CRONITER stays a @@ -1120,7 +1121,21 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None "reinstall hermes-agent or run 'pip install croniter' in your runtime env.", expr) return None - return croniter(expr, base_time).get_next(datetime).isoformat() + # Anchor cron matching to the CONFIGURED IANA timezone's WALL CLOCK, + # not to the UTC offset carried by ``base_time``. croniter ignores + # the tzinfo on its start time and uses the start's UTC offset as its + # working offset, so a ``last_run_at`` stored in UTC (+00:00) would + # push the next fire to 09:00 UTC instead of 09:00 local, and DST + # transition days (spring-forward / fall-back) would land one hour off + # (08:00 or 10:00). Render the base as the configured zone's naive + # wall clock for croniter, then re-attach the zone to the result, so + # the wall-clock hour stays correct every calendar day, including DST + # boundaries (morning-routine 09:00 America/Toronto). + # Fall back to the base's own zone only when nothing is configured. + zone = get_timezone() or base_time.tzinfo + base_wall = base_time.astimezone(zone).replace(tzinfo=None) + return (croniter(expr, base_wall).get_next(datetime) + .replace(tzinfo=zone).isoformat()) return None diff --git a/tests/cron/test_compute_next_run_dst_and_utc_last_run.py b/tests/cron/test_compute_next_run_dst_and_utc_last_run.py new file mode 100644 index 0000000000..f8be3a9f95 --- /dev/null +++ b/tests/cron/test_compute_next_run_dst_and_utc_last_run.py @@ -0,0 +1,86 @@ +"""Regression test: compute_next_run must honor the configured IANA timezone. + +Background (task t_392f55e9 -- "morning routine at 9:00 America/Toronto"): + +croniter 6.0.0 ignores the tzinfo on its start time and uses the start's +UTC *offset* as its working offset. The original code passed a tz-aware +``last_run_at`` straight into ``croniter(expr, base_time)``, which caused two +distinct bugs: + + 1. When ``last_run_at`` is stored in UTC (e.g. ``+00:00`` -- what the gateway + wrote while it was running without ``timezone`` configured), the next + occurrence was computed 24h later in UTC terms, so a ``0 9 * * *`` job + fired at 09:00 UTC = 05:00 Toronto (four hours early). + + 2. On DST transition days the wall-clock hour drifted: 08:00 on the + spring-forward day and 10:00 on the fall-back day, instead of 09:00. + +The fix renders the base as the *configured* IANA zone's naive wall clock for +croniter, then re-attaches the zone, so the wall-clock hour is correct on +every calendar day including DST boundaries. +""" + +import pytest +from datetime import datetime +from zoneinfo import ZoneInfo + +pytest.importorskip("croniter") + +from cron.jobs import compute_next_run + +# The configured IANA zone the fix must anchor to. +TORONTO = ZoneInfo("America/Toronto") + + +class TestCronComputeNextRunHonorsConfiguredTz: + """``compute_next_run`` must keep the wall-clock hour at the cron hour in + the configured IANA timezone, regardless of the offset carried by + ``last_run_at`` and across DST transitions.""" + + def test_toronto_utc_stored_last_run_stays_at_9am_local(self, monkeypatch): + """A last_run_at stored in UTC must NOT push the next fire to 09:00 UTC.""" + monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO) + # Pretend the last fire was recorded at 09:00 UTC (the buggy history). + last_run = "2026-08-03T09:00:39+00:00" + result = compute_next_run( + {"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run + ) + nxt = datetime.fromisoformat(result) + wall = nxt.astimezone(TORONTO) + assert (wall.hour, wall.minute) == (9, 0), f"expected 09:00 Toronto, got {wall}" + assert nxt.utcoffset() is not None, "result must carry a concrete zone offset" + + def test_spring_forward_keeps_9am(self, monkeypatch): + monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO) + # Base just before the spring-forward (Mar 8 2026, 02:00 EST -> 03:00 EDT). + last_run = "2026-03-07T09:00:00-05:00" + result = compute_next_run( + {"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run + ) + wall = datetime.fromisoformat(result).astimezone(TORONTO) + assert (wall.hour, wall.minute) == (9, 0), f"got {wall}" + + def test_fall_back_keeps_9am(self, monkeypatch): + monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO) + # Base just before the fall-back (Nov 1 2026, 02:00 EDT -> 01:00 EST). + last_run = "2026-10-31T09:00:00-04:00" + result = compute_next_run( + {"kind": "cron", "expr": "0 9 * * *"}, last_run_at=last_run + ) + wall = datetime.fromisoformat(result).astimezone(TORONTO) + assert (wall.hour, wall.minute) == (9, 0), f"got {wall}" + + def test_full_year_stable_at_9am(self, monkeypatch): + """Walking a full year (both DST transitions) must never drift off 09:00.""" + monkeypatch.setattr("cron.jobs.get_timezone", lambda: TORONTO) + last = datetime(2026, 1, 1, 9, 0, 0, tzinfo=TORONTO) + for _ in range(365): + nxt = datetime.fromisoformat( + compute_next_run( + {"kind": "cron", "expr": "0 9 * * *"}, + last_run_at=last.isoformat(), + ) + ) + wall = nxt.astimezone(TORONTO) + assert (wall.hour, wall.minute) == (9, 0), f"drift at {last}: {wall}" + last = nxt