From d93d0aee83939f425c3aa4e49e0829479d4de15c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:29:57 -0700 Subject: [PATCH] fix(cron): anchor naive schedule timestamps to configured timezone (#51695) A naive ISO timestamp (e.g. 2026-06-22T20:07:00) was anchored to the server's local timezone via dt.astimezone(), but the due-check (get_due_jobs -> _hermes_now()) runs in the CONFIGURED Hermes timezone. When the two diverge (cloud host on UTC with a different timezone: set, or vice-versa) the stored instant lands hours off the user's wall-clock intent, so one-shots never become due and recurring jobs fire at the wrong time. The ticker stays healthy (heartbeat + success markers fresh) because every tick finds nothing due, matching the silent no-fire in #51021. Anchor naive timestamps to _hermes_now().tzinfo so '20:07' means 20:07 on the same clock the scheduler checks against. The legacy _ensure_aware path still treats already-stored naive values as server-local for back-compat. Fixes #51021 --- cron/jobs.py | 13 ++++++++++- tests/cron/test_jobs.py | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cron/jobs.py b/cron/jobs.py index ed0ac61fb21..434a4f378f2 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -359,8 +359,19 @@ def parse_schedule(schedule: str) -> Dict[str, Any]: dt = datetime.fromisoformat(schedule.replace('Z', '+00:00')) # Make naive timestamps timezone-aware at parse time so the stored # value doesn't depend on the system timezone matching at check time. + # + # Anchor to the CONFIGURED Hermes timezone, not the server's local + # timezone. The due-check (`get_due_jobs`) compares `next_run_at` + # against `hermes_time.now()`, which uses the configured zone. If a + # naive "20:07" were interpreted as server-local (e.g. UTC) while + # now() runs in Asia/Kolkata, the stored instant would land hours + # off from the user's wall-clock intent — far enough that one-shots + # never become due and recurring jobs fire at the wrong time. Using + # the configured zone makes "20:07" mean 20:07 on the same clock the + # scheduler checks against (#51021). if dt.tzinfo is None: - dt = dt.astimezone() # Interpret as local timezone + hermes_tz = _hermes_now().tzinfo + dt = dt.replace(tzinfo=hermes_tz) return { "kind": "once", "run_at": dt.isoformat(), diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index b554d19983b..d7c4016cac9 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -110,6 +110,57 @@ class TestParseSchedule: with pytest.raises(ValueError): parse_schedule("99 99 99 99 99") + def test_naive_iso_anchors_to_configured_tz_not_server_local(self, monkeypatch): + """A naive ISO timestamp must be interpreted in the CONFIGURED Hermes + timezone, NOT the server's local timezone (#51021). + + Regression: when the configured zone differs from the server's local + zone (common on cloud hosts running UTC), parse_schedule used + ``dt.astimezone()`` (server-local), baking in the wrong offset. The + due-check compares against ``_hermes_now()`` (configured zone), so the + stored instant landed hours off the user's wall-clock intent — far + enough that one-shots never became due. This asserts the parsed offset + matches the configured-now offset, the invariant that keeps the stored + instant on the same clock the scheduler checks against. + """ + configured_now = datetime(2026, 6, 22, 20, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) + monkeypatch.setattr("cron.jobs._hermes_now", lambda: configured_now) + + result = parse_schedule("2026-06-22T20:07:00") # naive, user wall-clock + + assert result["kind"] == "once" + parsed = datetime.fromisoformat(result["run_at"]) + assert parsed.utcoffset() == configured_now.utcoffset() + # Same wall-clock the user typed, on the configured clock. + assert parsed.replace(tzinfo=None) == datetime(2026, 6, 22, 20, 7, 0) + + +# ========================================================================= +# Timezone-divergence regression (#51021) +# ========================================================================= + +class TestNaiveScheduleTimezoneDivergence: + """End-to-end: a one-shot created with a naive recent-past timestamp must + become due even when the configured Hermes timezone differs from the + server's local timezone. Before #51021 the naive value was anchored to + server-local, so the job never fired.""" + + def test_recent_past_oneshot_is_due_under_diverging_tz(self, tmp_cron_dir, monkeypatch): + # Configured zone: a fixed +05:30 offset. The server's actual local + # zone is irrelevant to the parse now — that is the whole point. + configured = timezone(timedelta(hours=5, minutes=30)) + now = datetime(2026, 6, 22, 20, 7, 30, tzinfo=configured) + monkeypatch.setattr("cron.jobs._hermes_now", lambda: now) + + # 30s ago in the configured wall clock, supplied as a NAIVE string. + naive_str = (now - timedelta(seconds=30)).replace(tzinfo=None).isoformat() + job = create_job(prompt="test message", schedule=naive_str, deliver="local") + + due = get_due_jobs() + assert any(d["id"] == job["id"] for d in due), ( + f"one-shot should be due; next_run_at={job['next_run_at']}" + ) + # ========================================================================= # compute_next_run