mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(cron): anchor naive schedule timestamps to configured timezone (#51695)
A naive ISO timestamp (e.g. 2026-06-22T20:07:00) was anchored to the server's local timezone via dt.astimezone(), but the due-check (get_due_jobs -> _hermes_now()) runs in the CONFIGURED Hermes timezone. When the two diverge (cloud host on UTC with a different timezone: set, or vice-versa) the stored instant lands hours off the user's wall-clock intent, so one-shots never become due and recurring jobs fire at the wrong time. The ticker stays healthy (heartbeat + success markers fresh) because every tick finds nothing due, matching the silent no-fire in #51021. Anchor naive timestamps to _hermes_now().tzinfo so '20:07' means 20:07 on the same clock the scheduler checks against. The legacy _ensure_aware path still treats already-stored naive values as server-local for back-compat. Fixes #51021
This commit is contained in:
parent
78e122ae1a
commit
d93d0aee83
2 changed files with 63 additions and 1 deletions
13
cron/jobs.py
13
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(),
|
||||
|
|
|
|||
|
|
@ -110,6 +110,57 @@ class TestParseSchedule:
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue