# NousResearch/hermes-agent · Cron naive timestamp timezone handling fix

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #949. Verify it addresses your case.
- **Recommended:** read PR #949 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-949`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #806: \[Bug\]: get_due_jobs may skip legacy naive timestamps under non-local Hermes timezone

## Prior & competing PRs

- **#949: feat: ACP server + cron timezone fix**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cron/jobs.py`, `tests/test_timezone.py`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#807: fix(cron): handle naive legacy timestamps in due-job checks**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#806`
- **#929: fix(cron): handle naive legacy timestamps in due-job checks**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#949`, `#1319`

## Fix status

- Merged to main in PR #949 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-11`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `acp`
