NousResearch/hermes-agent

Cron job scheduling bugs: timezone anchoring and error isolation

Cluster 51021. Data as of

Nothing open on GitHub. Fix merged in #51695 on 24 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #51695. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #51695. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51695 fix(cron): anchor naive schedule timestamps to configured timezone

    merged to main

    files: cron/jobs.py, tests/cron/test_jobs.py

    Show the diff
    • 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(),
    • tests/cron/test_jobs.py
      @@ -110,6 +110,57 @@ def test_invalid_cron_raises(self):
               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

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #51267 fix(cron): isolate per-job schedule errors so one bad job can't stall… · superseded by another PR → #50377, #51021
Closed issues · 1
  • Closed#51021[Bug]: Cron Jobs Never Fire Despite Ticker Being Alive
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#51695
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.