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
This commit is contained in:
Teknium 2026-06-23 23:29:57 -07:00 committed by GitHub
parent 78e122ae1a
commit d93d0aee83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 1 deletions

View file

@ -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(),

View file

@ -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