{"repo":"NousResearch/hermes-agent","public_id":"51021","display_label":"Cron job scheduling bugs: timezone anchoring and error isolation","summary":"Three items concern cron scheduling: one PR about anchoring naive schedule timestamps to the configured timezone, one PR about isolating per-job schedule errors so a single bad job doesn't stall others, and one bug report of cron jobs not firing while the ticker is alive. The titles suggest related scheduler behavior, but they do not confirm a shared root cause or how the items relate.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51695,"merged_at":"2026-06-24T06:29:58+00:00"},"resolution":{"state":"resolved","by":"pr-51695"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:29:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":51021,"pr_number":51695,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-24T06:29:58+00:00"}],"members":[{"kind":"issue","number":51021,"title":"[Bug]: Cron Jobs Never Fire Despite Ticker Being Alive","url":null,"state":"closed"},{"kind":"pr","number":51267,"title":"fix(cron): isolate per-job schedule errors so one bad job can't stall…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50377","number":50377},{"kind":"pr","value":"#51021","number":51021}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":51695,"title":"fix(cron): anchor naive schedule timestamps to configured timezone","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","tests/cron/test_jobs.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -359,8 +359,19 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:\n             dt = datetime.fromisoformat(schedule.replace('Z', '+00:00'))\n             # Make naive timestamps timezone-aware at parse time so the stored\n             # value doesn't depend on the system timezone matching at check time.\n+            #\n+            # Anchor to the CONFIGURED Hermes timezone, not the server's local\n+            # timezone. The due-check (`get_due_jobs`) compares `next_run_at`\n+            # against `hermes_time.now()`, which uses the configured zone. If a\n+            # naive \"20:07\" were interpreted as server-local (e.g. UTC) while\n+            # now() runs in Asia/Kolkata, the stored instant would land hours\n+            # off from the user's wall-clock intent — far enough that one-shots\n+            # never become due and recurring jobs fire at the wrong time. Using\n+            # the configured zone makes \"20:07\" mean 20:07 on the same clock the\n+            # scheduler checks against (#51021).\n             if dt.tzinfo is None:\n-                dt = dt.astimezone()  # Interpret as local timezone\n+                hermes_tz = _hermes_now().tzinfo\n+                dt = dt.replace(tzinfo=hermes_tz)\n             return {\n                 \"kind\": \"once\",\n                 \"run_at\": dt.isoformat(),"]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -110,6 +110,57 @@ def test_invalid_cron_raises(self):\n         with pytest.raises(ValueError):\n             parse_schedule(\"99 99 99 99 99\")\n \n+    def test_naive_iso_anchors_to_configured_tz_not_server_local(self, monkeypatch):\n+        \"\"\"A naive ISO timestamp must be interpreted in the CONFIGURED Hermes\n+        timezone, NOT the server's local timezone (#51021).\n+\n+        Regression: when the configured zone differs from the server's local\n+        zone (common on cloud hosts running UTC), parse_schedule used\n+        ``dt.astimezone()`` (server-local), baking in the wrong offset. The\n+        due-check compares against ``_hermes_now()`` (configured zone), so the\n+        stored instant landed hours off the user's wall-clock intent — far\n+        enough that one-shots never became due. This asserts the parsed offset\n+        matches the configured-now offset, the invariant that keeps the stored\n+        instant on the same clock the scheduler checks against.\n+        \"\"\"\n+        configured_now = datetime(2026, 6, 22, 20, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: configured_now)\n+\n+        result = parse_schedule(\"2026-06-22T20:07:00\")  # naive, user wall-clock\n+\n+        assert result[\"kind\"] == \"once\"\n+        parsed = datetime.fromisoformat(result[\"run_at\"])\n+        assert parsed.utcoffset() == configured_now.utcoffset()\n+        # Same wall-clock the user typed, on the configured clock.\n+        assert parsed.replace(tzinfo=None) == datetime(2026, 6, 22, 20, 7, 0)\n+\n+\n+# =========================================================================\n+# Timezone-divergence regression (#51021)\n+# =========================================================================\n+\n+class TestNaiveScheduleTimezoneDivergence:\n+    \"\"\"End-to-end: a one-shot created with a naive recent-past timestamp must\n+    become due even when the configured Hermes timezone differs from the\n+    server's local timezone. Before #51021 the naive value was anchored to\n+    server-local, so the job never fired.\"\"\"\n+\n+    def test_recent_past_oneshot_is_due_under_diverging_tz(self, tmp_cron_dir, monkeypatch):\n+        # Configured zone: a fixed +05:30 offset. The server's actual local\n+        # zone is irrelevant to the parse now — that is the whole point.\n+        configured = timezone(timedelta(hours=5, minutes=30))\n+        now = datetime(2026, 6, 22, 20, 7, 30, tzinfo=configured)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+\n+        # 30s ago in the configured wall clock, supplied as a NAIVE string.\n+        naive_str = (now - timedelta(seconds=30)).replace(tzinfo=None).isoformat()\n+        job = create_job(prompt=\"test message\", schedule=naive_str, deliver=\"local\")\n+\n+        due = get_due_jobs()\n+        assert any(d[\"id\"] == job[\"id\"] for d in due), (\n+            f\"one-shot should be due; next_run_at={job['next_run_at']}\"\n+        )\n+\n \n # =========================================================================\n # compute_next_run\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}