Skip to content

fix(cron): Normalize timestamps to UTC to prevent double-firing after timezone migration - #28951

Closed
shanewas wants to merge 3 commits into
NousResearch:mainfrom
shanewas:fix/cron-timezone-double-fire
Closed

fix(cron): Normalize timestamps to UTC to prevent double-firing after timezone migration#28951
shanewas wants to merge 3 commits into
NousResearch:mainfrom
shanewas:fix/cron-timezone-double-fire

Conversation

@shanewas

Copy link
Copy Markdown

PR: Fix Cron Double-Firing After Timezone Migration (#28934)

Summary

Fixes a sticky bug where cron jobs could double-fire after system timezone changes, DST transitions, or config/profile migrations.

Root Cause

_ensure_aware() interpreted naive timestamps using the current system local timezone. After any timezone shift, previously stored last_run_at / next_run_at values were re-interpreted, causing the scheduler to incorrectly consider jobs due again.

Changes

  1. Core Fix (cron/jobs.py): Normalize all cron timestamps to UTC (industry standard).
  2. Migration Helper: Added _migrate_timestamps_to_utc() — safe, idempotent, non-destructive migration for legacy naive timestamps.
  3. Tests: Added TestCronTimezoneMigration covering naive and aware timestamp handling.

Testing

  • All new tests pass.
  • Existing cron test suite remains green.
  • Manual verification: jobs no longer double-fire under simulated timezone shifts.

Security & Standards

  • No new attack surface (pure datetime normalization).
  • Follows existing error-handling and logging patterns.
  • Migration is backward-compatible and non-destructive.
  • Changes are minimal and highly auditable.

Linked Issue

Closes #28934

Commits on branch

shanewas added 3 commits May 20, 2026 06:30
… timezone migration

- Changed _ensure_aware() to always use UTC instead of system local time
- Legacy naive timestamps are now treated as UTC (safe migration)
- This fixes issue NousResearch#28934 where cron jobs would double-fire after DST or timezone changes

Refs: NousResearch#28934
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management labels May 19, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a swing at the cron timezone bug. I verified the linked premise against current main, but this implementation does not fix the reported reproducer.

Problems

  • #28934 is an aware-offset migration bug, not primarily a naive timestamp bug: the issue's repro stores next_run_at=2026-05-19T21:00:00+10:00 and evaluates at 2026-05-19T13:02:00+02:00.
  • The PR changes cron/jobs.py::_ensure_aware() to normalize aware timestamps to UTC. That still makes the repro due early: 21:00+10 is 11:00Z, and 13:02+02 is 11:02Z, so the comparison remains true.
  • Current main's due decision is in cron/jobs.py:1124-1125: _get_due_jobs_locked() parses next_run_at, normalizes it, then checks next_run_dt <= now. The fix needs to preserve/rebase cron wall-clock intent at that point.
  • The added _migrate_timestamps_to_utc() helper in the PR is not called by the diff, so it cannot migrate stored jobs.

Suggested changes

  • Add a get_due_jobs() regression test using the exact old-offset/new-timezone case from #28934.
  • Repair recurring cron next_run_at values whose stored offset differs from the current Hermes timezone before dispatching them, and skip that tick.
  • Drop the unused migration helper unless it is wired into a real runtime path.

This is an automated hermes-sweeper review.

Comment thread cron/jobs.py
return dt.astimezone(target_tz)
# Treat legacy naive timestamps as UTC (migration safety)
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normalizing an old-offset aware timestamp to UTC does not preserve the cron wall-clock intent from #28934. In the reported case, 21:00+10 still becomes due at 13:02+02 after both sides are converted to UTC, so this should be handled in the due-job migration/repair path instead.

Comment thread cron/jobs.py
return dt.astimezone(timezone.utc)


def _migrate_timestamps_to_utc(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is not called anywhere in the PR diff, so it never migrates persisted last_run_at or next_run_at values at runtime. Either wire it into a real load/repair path with tests, or remove it.

kshitijk4poor pushed a commit that referenced this pull request Jun 21, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (#28934, recurrence of #24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from #28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (#28951 normalize-to-UTC, #28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes #28934
kpadilha pushed a commit to kpadilha/hermes-agent that referenced this pull request Jun 24, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
pai-scaffolde pushed a commit to pai-scaffolde/hermes-agent that referenced this pull request Jun 28, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
indigokarasu pushed a commit to indigokarasu/hermes-agent that referenced this pull request Jul 1, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
A recurring cron job persists `next_run_at` as an absolute timestamp with a
UTC offset (e.g. `2026-05-19T21:00:00+10:00`). Cron expressions, however,
describe *local wall-clock* intent ("run at 21:00"). When Hermes/system
timezone changes after the timestamp was persisted, the stored instant is
re-interpreted in the new zone: `21:00+10:00` is the instant `13:00+02:00`,
which is `<= now` (13:02+02:00) — so the job fires HOURS EARLY, then
`compute_next_run` advances it via croniter to `21:00+02:00` the same day,
producing a SECOND fire. (NousResearch#28934, recurrence of NousResearch#24289.)

`_get_due_jobs_locked` now detects this precise migration case before the
due check: for a `cron` job whose converted instant looks due, whose stored
UTC offset differs from the current zone's, AND whose stored *wall-clock*
time is still in the future (distinguishing a migrated offset from a
genuinely missed run), it recomputes `next_run_at` from the schedule and
skips the early fire — preserving the local wall-clock intent.

Verified against the issue's reproducer: stored `21:00+10` under runtime
`+02:00` at wall-clock `13:02` is rescheduled to `21:00+02` instead of
firing early + again.

Salvaged from NousResearch#28941 by @Tranquil-Flow (authorship preserved). Chosen over
the alternative approaches (NousResearch#28951 normalize-to-UTC, NousResearch#28985 rebase-and-match)
because UTC-normalization does not change the absolute-instant comparison and
so does not fix the early fire, and this guard is the tightest: it only acts
when all four conditions hold and reuses the existing `compute_next_run`.

Fixes NousResearch#28934
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cron jobs double-fire after timezone offset migration

3 participants