NousResearch/hermes-agent

Cron naive timestamp timezone handling fix

Cluster 806 · acp. Data as of

Nothing open on GitHub. Fix merged in #949 on 11 Mar 2026. Check it covers your case.

The short answer

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

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.

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.

  • #949 feat: ACP server + cron timezone fix

    merged to main

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

    Show the diff
    • 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]:
    • 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

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #807 fix(cron): handle naive legacy timestamps in due-job checks · other → #806
  • #929 fix(cron): handle naive legacy timestamps in due-job checks · merged via another change → #949, #1319
Closed issues · 1
  • Closed#806[Bug]: get_due_jobs may skip legacy naive timestamps under non-local Hermes timezone
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.50: 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

Subsystem
acp
Merged PRs
#949
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
11 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.