{"repo":"NousResearch/hermes-agent","public_id":"806","display_label":"Cron naive timestamp timezone handling fix","summary":"These members address a bug where get_due_jobs may skip legacy naive timestamps when the system timezone differs from local time. Includes a fix, regression test, and related ACP server changes.","subsystem":"acp","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":949,"merged_at":"2026-03-11T15:47:10+00:00"},"resolution":{"state":"resolved","by":"pr-949"},"freshness":{"state":"stale","last_verified_at":"2026-03-11T15:47:10+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":806,"pr_number":949,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-11T15:47:10+00:00"}],"members":[{"kind":"issue","number":806,"title":"[Bug]: get_due_jobs may skip legacy naive timestamps under non-local Hermes timezone","url":null,"state":"closed"},{"kind":"pr","number":807,"title":"fix(cron): handle naive legacy timestamps in due-job checks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#806","number":806}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":929,"title":"fix(cron): handle naive legacy timestamps in due-job checks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#949","number":949},{"kind":"pr","value":"#1319","number":1319}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":949,"title":"feat: ACP server + cron timezone fix","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","tests/test_timezone.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -168,16 +168,22 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:\n \n \n def _ensure_aware(dt: datetime) -> datetime:\n-    \"\"\"Make a naive datetime tz-aware using the configured timezone.\n+    \"\"\"Return a timezone-aware datetime in Hermes configured timezone.\n \n-    Handles backward compatibility: timestamps stored before timezone support\n-    are naive (server-local).  We assume they were in the same timezone as\n-    the current configuration so comparisons work without crashing.\n+    Backward compatibility:\n+    - Older stored timestamps may be naive.\n+    - Naive values are interpreted as *system-local wall time* (the timezone\n+      `datetime.now()` used when they were created), then converted to the\n+      configured Hermes timezone.\n+\n+    This preserves relative ordering for legacy naive timestamps across\n+    timezone changes and avoids false not-due results.\n     \"\"\"\n+    target_tz = _hermes_now().tzinfo\n     if dt.tzinfo is None:\n-        tz = _hermes_now().tzinfo\n-        return dt.replace(tzinfo=tz)\n-    return dt\n+        local_tz = datetime.now().astimezone().tzinfo\n+        return dt.replace(tzinfo=local_tz).astimezone(target_tz)\n+    return dt.astimezone(target_tz)\n \n \n def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]:"]},{"file":"tests/test_timezone.py","hunks":["@@ -249,6 +249,85 @@ def test_get_due_jobs_handles_naive_timestamps(self, tmp_path, monkeypatch):\n         due = get_due_jobs()\n         assert len(due) == 1\n \n+    def test_ensure_aware_naive_preserves_absolute_time(self):\n+        \"\"\"_ensure_aware must preserve the absolute instant for naive datetimes.\n+\n+        Regression: the old code used replace(tzinfo=hermes_tz) which shifted\n+        absolute time when system-local tz != Hermes tz.  The fix interprets\n+        naive values as system-local wall time, then converts.\n+        \"\"\"\n+        from cron.jobs import _ensure_aware\n+\n+        os.environ[\"HERMES_TIMEZONE\"] = \"Asia/Kolkata\"\n+        hermes_time.reset_cache()\n+\n+        # Create a naive datetime — will be interpreted as system-local time\n+        naive_dt = datetime(2026, 3, 11, 12, 0, 0)\n+\n+        result = _ensure_aware(naive_dt)\n+\n+        # The result should be in Kolkata tz\n+        assert result.tzinfo is not None\n+\n+        # The UTC equivalent must match what we'd get by correctly interpreting\n+        # the naive dt as system-local time first, then converting\n+        system_tz = datetime.now().astimezone().tzinfo\n+        expected_utc = naive_dt.replace(tzinfo=system_tz).astimezone(timezone.utc)\n+        actual_utc = result.astimezone(timezone.utc)\n+        assert actual_utc == expected_utc, (\n+            f\"Absolute time shifted: expected {expected_utc}, got {actual_utc}\"\n+        )\n+\n+    def test_ensure_aware_normalizes_aware_to_hermes_tz(self):\n+        \"\"\"Already-aware datetimes should be normalized to Hermes tz.\"\"\"\n+        from cron.jobs import _ensure_aware\n+\n+        os.environ[\"HERMES_TIMEZONE\"] = \"Asia/Kolkata\"\n+        hermes_time.reset_cache()\n+\n+        # Create an aware datetime in UTC\n+        utc_dt = datetime(2026, 3, 11, 15, 0, 0, tzinfo=timezone.utc)\n+        result = _ensure_aware(utc_dt)\n+\n+        # Must be in Hermes tz (Kolkata) but same absolute instant\n+        kolkata = ZoneInfo(\"Asia/Kolkata\")\n+        assert result.utctimetuple()[:5] == (2026, 3, 11, 15, 0)\n+        expected_local = utc_dt.astimezone(kolkata)\n+        assert result == expected_local\n+\n+    def test_ensure_aware_due_job_not_skipped_when_system_ahead(self, tmp_path, monkeypatch):\n+        \"\"\"Reproduce the actual bug: system tz ahead of Hermes tz caused\n+        overdue jobs to appear as not-yet-due.\n+\n+        Scenario: system is Asia/Kolkata (UTC+5:30), Hermes is UTC.\n+        A naive timestamp from 5 minutes ago (local time) should still\n+        be recognized as due after conversion.\n+        \"\"\"\n+        import cron.jobs as jobs_module\n+        monkeypatch.setattr(jobs_module, \"CRON_DIR\", tmp_path / \"cron\")\n+        monkeypatch.setattr(jobs_module, \"JOBS_FILE\", tmp_path / \"cron\" / \"jobs.json\")\n+        monkeypatch.setattr(jobs_module, \"OUTPUT_DIR\", tmp_path / \"cron\" / \"output\")\n+\n+        os.environ[\"HERMES_TIMEZONE\"] = \"UTC\"\n+        hermes_time.reset_cache()\n+\n+        from cron.jobs import create_job, load_jobs, save_jobs, get_due_jobs\n+\n+        job = create_job(prompt=\"Bug repro\", schedule=\"every 1h\")\n+        jobs = load_jobs()\n+\n+        # Simulate a naive timestamp that was written by datetime.now() on a\n+        # system running in UTC+5:30 — 5 minutes in the past (local time)\n+        naive_past = (datetime.now() - timedelta(minutes=5)).isoformat()\n+        jobs[0][\"next_run_at\"] = naive_past\n+        save_jobs(jobs)\n+\n+        # Must be recognized as due regardless of tz mismatch\n+        due = get_due_jobs()\n+        assert len(due) == 1, (\n+            \"Overdue job was skipped — _ensure_aware likely shifted absolute time\"\n+        )\n+\n     def test_create_job_stores_tz_aware_timestamps(self, tmp_path, monkeypatch):\n         \"\"\"New jobs store timezone-aware created_at and next_run_at.\"\"\"\n         import cron.jobs as jobs_module\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}