From 605ba4adea51af2580f1ab94fd6372e873c108e7 Mon Sep 17 00:00:00 2001 From: 0xNyk <0xNyk@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:38:24 -0700 Subject: [PATCH 1/2] fix(cron): interpret naive timestamps as local time in due-job checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy cron job rows may store next_run_at without timezone info. _ensure_aware() previously stamped the Hermes-configured tz directly via replace(tzinfo=...), which shifts absolute time when system-local tz differs from Hermes tz — causing overdue jobs to appear not due. Now: naive datetimes are interpreted as system-local wall time first, then converted to Hermes tz. Aware datetimes are normalized to Hermes tz for consistency. Cherry-picked from PR #807, rebased onto current main. Fixes #806 Co-authored-by: 0xNyk <0xNyk@users.noreply.github.com> --- cron/jobs.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 0c062cfea39..6cbb168f0c5 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -168,16 +168,22 @@ def parse_schedule(schedule: str) -> Dict[str, Any]: def _ensure_aware(dt: datetime) -> datetime: - """Make a naive datetime tz-aware using the configured timezone. + """Return a timezone-aware datetime in Hermes configured timezone. - Handles backward compatibility: timestamps stored before timezone support - are naive (server-local). We assume they were in the same timezone as - the current configuration so comparisons work without crashing. + Backward compatibility: + - Older stored timestamps may be naive. + - Naive values are interpreted as *system-local wall time* (the timezone + `datetime.now()` used when they were created), then converted to the + configured Hermes timezone. + + This preserves relative ordering for legacy naive timestamps across + timezone changes and avoids false not-due results. """ + target_tz = _hermes_now().tzinfo if dt.tzinfo is None: - tz = _hermes_now().tzinfo - return dt.replace(tzinfo=tz) - return dt + local_tz = datetime.now().astimezone().tzinfo + return dt.replace(tzinfo=local_tz).astimezone(target_tz) + return dt.astimezone(target_tz) def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]: From a5ffa1278c987dda5e551fb8772d5e75c67d3869 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 08:42:04 -0700 Subject: [PATCH 2/2] test(cron): add regression tests for _ensure_aware timezone conversion Three new tests for the naive timestamp fix (PR #807): - test_ensure_aware_naive_preserves_absolute_time: verifies UTC equivalent is preserved when interpreting naive datetimes as system-local time - test_ensure_aware_normalizes_aware_to_hermes_tz: verifies already-aware datetimes are normalized to Hermes tz without shifting the instant - test_ensure_aware_due_job_not_skipped_when_system_ahead: end-to-end regression test for the original bug scenario --- tests/test_timezone.py | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 3d657989e0e..9902817d87b 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -249,6 +249,85 @@ def test_get_due_jobs_handles_naive_timestamps(self, tmp_path, monkeypatch): due = get_due_jobs() assert len(due) == 1 + def test_ensure_aware_naive_preserves_absolute_time(self): + """_ensure_aware must preserve the absolute instant for naive datetimes. + + Regression: the old code used replace(tzinfo=hermes_tz) which shifted + absolute time when system-local tz != Hermes tz. The fix interprets + naive values as system-local wall time, then converts. + """ + from cron.jobs import _ensure_aware + + os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata" + hermes_time.reset_cache() + + # Create a naive datetime — will be interpreted as system-local time + naive_dt = datetime(2026, 3, 11, 12, 0, 0) + + result = _ensure_aware(naive_dt) + + # The result should be in Kolkata tz + assert result.tzinfo is not None + + # The UTC equivalent must match what we'd get by correctly interpreting + # the naive dt as system-local time first, then converting + system_tz = datetime.now().astimezone().tzinfo + expected_utc = naive_dt.replace(tzinfo=system_tz).astimezone(timezone.utc) + actual_utc = result.astimezone(timezone.utc) + assert actual_utc == expected_utc, ( + f"Absolute time shifted: expected {expected_utc}, got {actual_utc}" + ) + + def test_ensure_aware_normalizes_aware_to_hermes_tz(self): + """Already-aware datetimes should be normalized to Hermes tz.""" + from cron.jobs import _ensure_aware + + os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata" + hermes_time.reset_cache() + + # Create an aware datetime in UTC + utc_dt = datetime(2026, 3, 11, 15, 0, 0, tzinfo=timezone.utc) + result = _ensure_aware(utc_dt) + + # Must be in Hermes tz (Kolkata) but same absolute instant + kolkata = ZoneInfo("Asia/Kolkata") + assert result.utctimetuple()[:5] == (2026, 3, 11, 15, 0) + expected_local = utc_dt.astimezone(kolkata) + assert result == expected_local + + def test_ensure_aware_due_job_not_skipped_when_system_ahead(self, tmp_path, monkeypatch): + """Reproduce the actual bug: system tz ahead of Hermes tz caused + overdue jobs to appear as not-yet-due. + + Scenario: system is Asia/Kolkata (UTC+5:30), Hermes is UTC. + A naive timestamp from 5 minutes ago (local time) should still + be recognized as due after conversion. + """ + import cron.jobs as jobs_module + monkeypatch.setattr(jobs_module, "CRON_DIR", tmp_path / "cron") + monkeypatch.setattr(jobs_module, "JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr(jobs_module, "OUTPUT_DIR", tmp_path / "cron" / "output") + + os.environ["HERMES_TIMEZONE"] = "UTC" + hermes_time.reset_cache() + + from cron.jobs import create_job, load_jobs, save_jobs, get_due_jobs + + job = create_job(prompt="Bug repro", schedule="every 1h") + jobs = load_jobs() + + # Simulate a naive timestamp that was written by datetime.now() on a + # system running in UTC+5:30 — 5 minutes in the past (local time) + naive_past = (datetime.now() - timedelta(minutes=5)).isoformat() + jobs[0]["next_run_at"] = naive_past + save_jobs(jobs) + + # Must be recognized as due regardless of tz mismatch + due = get_due_jobs() + assert len(due) == 1, ( + "Overdue job was skipped — _ensure_aware likely shifted absolute time" + ) + def test_create_job_stores_tz_aware_timestamps(self, tmp_path, monkeypatch): """New jobs store timezone-aware created_at and next_run_at.""" import cron.jobs as jobs_module