[BUG] Scheduled tasks evaluate their cron in the timezone they were armed in, while catch-up uses the current one

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 0 comments · opened Aug 21, 2026

Preflight Checklist

  • [x] I have searched existing issues. The two closest are both closed as not-planned and are different bugs: #24213 (a fixed +8h shift in nextRunAtMs on cron.update, different product and mechanism) and #51110 (the injected currentDate string using UTC — a context-injection bug, not a dispatch bug). This one is about which timezone the cron is evaluated in at dispatch.
  • [x] This is a single bug report. It presents as two symptoms; the body shows they share one cause. Happy to split if you'd rather.
  • [ ] Not on the very latest. Observed on 2.1.237; 2.1.239 is current. I checked the 2.1.238 and 2.1.239 changelogs for a scheduler/timezone fix and found none, so I don't believe this is stale — but I haven't re-observed it on .239, and reproducing means changing timezone again.

What's Wrong?

A scheduled task's cron is evaluated against whatever timezone was in effect when that task was last armed — arming happens on create and on enable, not on every evaluation. The missed-run catch-up path, by contrast, uses the host's current timezone.

After the host changes timezone, those two disagree permanently, which produces two symptoms that look like separate bugs:

  1. Every task armed before the change fires 59–60 minutes off its own reported nextRunAt.
  2. Every restart dispatches a catch-up batch of the entire fleet, because each pre-change task computes as exactly one slot behind.

Symptom 2 is the expensive one. These are unattended agent runs with production write access.

Symptom 1 — dispatch is an hour before the scheduler's own nextRunAt

For an unedited cron, lastRunAt lands 59–60 min earlier in the day than the nextRunAt the scheduler itself reports. The rendered schedule string agrees with nextRunAt, so the display is right and the dispatch is early. The 59-vs-60 spread is jitterSeconds, already reflected in nextRunAt.

| task | cron h:m | lastRunAt (CDT) | nextRunAt (CDT) | delta |
|---|---|---|---|---|
| branch-janitor | 2:30 | 08-21 01:35:24 | 08-22 02:35:03 | −60 |
| tracing-approval-inbox | 0:30 | 08-20 23:30:28 | 08-22 00:30:07 | −60 |
| crm-hygiene-sweep | 21:55 | 08-20 21:03:47 | 08-21 22:03:27 | −60 |
| weekly-traffic-read | 15:00 | 08-20 14:06:38 | 08-27 15:06:20 | −60 |
| crm-behavioral-sync | 11:00 | 08-20 10:03:04 | 08-24 11:02:46 | −59 |
| task-cleanup | 15:00 | 08-18 14:05:51 | 08-25 15:04:53 | −59 |
| stewardship-rotation | 7:25 | 08-20 06:33:17 | 08-22 07:32:59 | −59 |
| capability-freshness-watch | 8:15 | 08-16 07:16:46 | 08-23 08:16:42 | −60 |
| credential-liveness-watch | 7:45 | 08-16 06:53:16 | 08-23 07:53:12 | −60 |
| env-contract-watch | 8:35 | 08-16 07:41:59 | 08-23 08:41:53 | −60 |
| correction-miner | 6:00 | 08-15 05:03:49 | 09-15 06:03:00 | −60 |
| memory-reconciliation | 7:00 | 08-15 06:07:46 | 09-01 07:06:57 | −59 |
| crm-people-compaction | 12:00 | 08-04 11:07:10 | 09-04 12:07:05 | −60 |
| monthly-newsletter-reconciliation | 10:00 | 08-01 09:10:34 | 09-01 10:09:42 | −59 |

Each affected task fired within ~20s of cron-time-in-the-OLD-zone + jitterSeconds. That's the tell: the dispatcher evaluates the cron in the old zone while nextRunAt renders in the current one. Tasks created or re-enabled after the timezone change do not show this.

Symptom 2 — a fleet-wide catch-up batch on restart

On restart the scheduler recomputes each task's most recent due slot in the current zone. For a task still bound to the old zone that slot sits one hour later in absolute time than the slot recorded in lastScheduledFor, so it reads as a slot that passed unrun and gets a catch-up dispatch. Every pre-change task is one slot behind by construction, so the whole fleet queues at once.

Claude Code 2.1.237 was unpacked and .verified in ~/Library/Application Support/Claude/claude-code/<ver>/ at 04:06:08. The first catch-up dispatch landed at 04:06:29 — 21 seconds later. The trigger is the restart; the update merely forces one.

| fired (CDT) | task | cron | next genuinely due |
|---|---|---|---|
| 04:06:32 | schedule-drift-check | 0 8 * * 0 | Sunday |
| 04:06:33 | funding-match-engine | 30 5 * * 5 | same day, 05:39 |
| 04:06:34 | crm-org-compaction | 0 13 18 * * | the 18th — 28 days out |
| 04:15:31 | lightbulb-checkin-sweep | 0 13 * * 1 | Monday |
| 04:16:31 | org-enrichment-sweep | 0 8 * * 3 | Wednesday |
| 04:20:31 | people-enrichment-sweep | 0 8 * * 6 | Saturday |
| 04:23:31 | engagement-log-scanner | 0 4 * * 5 | had already run at 03:07 that day |

A prior occurrence on 2026-08-17 dispatched three tasks in the same 04:06 minute, including a Sunday-cron task firing on a Monday.

The visible runs understate the scope badly. Tasks blocked by the concurrency cap retry roughly every 60s and are logged in recordedSkips. On 2026-08-21, 13 routines ran but 19 more were still retrying when they were disabled by hand — branch-janitor retried 32 times over 31 minutes and was held off by nothing but a lost concurrency race. Anyone reading only lastRunAt sees about a third of the real blast radius.

Why one cause and not two
  • Both symptoms appeared together, immediately after a host timezone change, on a fleet that had run clean for months.
  • Both affect exactly the tasks armed before the change, and exactly not those armed after.
  • Symptom 1's offset equals the zone offset; Symptom 2's "one slot behind" is that same offset expressed as a scheduling decision.
  • Re-enabling a task re-arms it in the current zone and fixes both. After re-enabling all 30 enabled routines, a deliberate restart produced zero dispatches and zero recorded skips — against 21 seconds to first dispatch on the unfixed fleet.

That last point is the strongest evidence and also the workaround, but it isn't a fix: the fleet stays permanently fragile to the host crossing a timezone in either direction, and nothing tells a user they need to re-arm anything.

What Should Happen?

A task's cron should be evaluated against the host's current timezone at dispatch time, so that lastRunAt matches the nextRunAt the scheduler itself reported. A restart should re-arm tasks against their cron; a task whose next occurrence is days or weeks away should not run on startup. Crossing a timezone should not require the user to re-arm anything.

Error Messages/Logs

No error is emitted — that is part of the problem. Both symptoms are visible only in
the scheduler's own bookkeeping.

From list_scheduled_tasks (cronExpression, jitterSeconds, lastRunAt, nextRunAt):

  branch-janitor   cron "30 2 * * *"   jitterSeconds 303
    lastRunAt   2026-08-21T06:35:24Z  = 01:35:24 CDT   <- dispatched
    nextRunAt   2026-08-22T07:35:03Z  = 02:35:03 CDT   <- reported, and correct
    schedule string: "At 02:35 AM, every day"

From ~/Library/Application Support/Claude/claude-code-sessions/<a>/<b>/scheduled-tasks.json:

  "lastScheduledFor" — the slot armed for. Render it in both zones and compare
  against the cron to see which zone a task is bound to.
  "recordedSkips"    — per-task retry log with global_limit / per_task_limit
  reasons. This is where the real blast radius of a replay is legible; the
  visible runs are a fraction of it.

Steps to Reproduce

  1. Configure several recurring tasks with crons that are not currently due (a mix of daily, weekly and monthly makes the effect unmistakable).
  2. Let them run at least once, so they arm.
  3. Change the host timezone — System Settings, or by travelling.
  4. Symptom 1, immediately: compare lastRunAt against nextRunAt from list_scheduled_tasks for any cron you have not edited. They will differ by the zone offset.
  5. Symptom 2: restart the app. Observe the catch-up batch in lastRunAt, and the retry storm in recordedSkips in the session state file.
  6. Re-enable any one task (toggle enabled false then true) and repeat step 5. That task no longer participates, which isolates arming as the binding moment.

Impact

These are unattended agent runs holding production write access. In the 2026-08-21 batch alone the spuriously-dispatched routines made live CRM record edits, and a routine that sends customer-facing email fired outside its window — its recipient set happened to be empty that morning, which was timing, not a guard. The runtime offers no per-task idempotency, so a routine without its own run-ledger does its full work on a spurious dispatch, writes and sends included.

Symptom 1 carries a quieter harm: the displayed schedule is what a user reasons about when sequencing dependent routines. An hour of undocumented skew silently reorders a pipeline — a task written to run after a 02:30 job began firing at 01:35, ahead of it, and would read stale input without erroring.

Suggested fix direction

Evaluate the cron against the host's current timezone at dispatch time rather than caching a zone at arm time — or, failing that, detect a host timezone change and re-arm the fleet without treating the resulting slot shift as missed runs. A user-visible signal that the fleet needs re-arming would also be a large improvement over silence.

Is this a regression?

Not sure. It only becomes observable when the host changes timezone, so a long-standing version of this could have gone unnoticed indefinitely. This fleet had run clean for months from a fixed location.

Claude Code Version

2.1.237 (embedded in the macOS desktop app; previous version 2.1.229). Current release is 2.1.239 — see the preflight note above.

Platform

macOS 15 (Darwin 24.6.0), Apple silicon. Host timezone changed America/New_York → America/Chicago by travel. No DST transition falls in the observation window. ~30 enabled recurring tasks.

View original on GitHub ↗