Scheduled tasks fire multiple times per slot: deferred slots released on instance exit, and the lastScheduledFor dedupe key is dropped

Status Open
Reported on v2.1.206
Maintainer reply None cached
Activity 1 comment · opened Jul 30, 2026

Summary

Scheduled tasks fire more than once per slot. Across 8 recurring tasks on one machine I measured 88 excess invocations, with one daily task running exactly 2.00× every day for 30 consecutive days. Each duplicate is a full second run of the task's prompt — real token spend, and for tasks that write files or open PRs, real duplicated side effects.

Both invocations are indistinguishable from inside the session (identical <scheduled-task> wrapper), so a task cannot detect that it is the duplicate without inspecting its own prior output.

Environment

  • Claude Code 2.1.206, macOS 26.6, Node v22.23.1
  • Tasks created via the built-in scheduler (mcp__scheduled-tasks__*)
  • State file: ~/Library/Application Support/Claude/claude-code-sessions/<id>/<id>/scheduled-tasks.json
  • Laptop, frequently asleep at scheduled slots

Measured excess

Fires ÷ distinct calendar days on which the task fired, from session transcript start timestamps:

| task | cron | fires | days | ratio |
|---|---|---|---|---|
| task-1 | 0 13 * * * | 60 | 30 | 2.00× |
| task-2 | 0 9 * * * | 50 | 30 | 1.67× |
| task-3 | 0 19 * * * | 37 | 28 | 1.32× |
| task-4 | 0 20 * * * | 37 | 28 | 1.32× |
| task-5 | 0 15 * * 1,4 | 16 | 9 | 1.78× |
| task-6 | 0 12 * * 1,4 | 15 | 9 | 1.67× |
| task-7 | 30 14 * * 3 | 11 | 7 | 1.57× |
| task-8 | 0 14 * * 3 | 10 | 7 | 1.43× |

Weekly tasks scheduled overnight (03:00–05:00) showed 0 duplicates, but with only 4–6 fires each that is a small sample rather than evidence of immunity.

Defect 1 — a deferred slot is released the moment the running instance exits

When a slot arrives while an instance of the same task is still running, the scheduler does not drop the missed slot. It polls roughly every 60 s writing {"reason":"per_task_limit"} into recordedSkips, then dispatches as soon as the running instance exits.

Trace for task-7 (cron 30 14 * * 3 local = 13:30Z) on a single day:

session 1   13:13:10Z -> 13:40:29Z     (started off-slot; see Defect 2 / catch-up below)
slot        13:30:00Z                  (recorded as lastScheduledFor)
skips       13:30:14Z .. 13:40:14Z     11 x per_task_limit, ~60s apart
session 2   13:41:16Z                  <-- fires 47s after session 1 exited

The skip series terminates exactly when session 1 ends, and the duplicate dispatches on the next poll. A missed slot that could not be served while busy should be dropped, not queued for release — otherwise every long-running task is guaranteed a back-to-back second run.

Defect 2 — lastScheduledFor is dropped, and dedupe silently stops

lastScheduledFor appears to be the per-slot dedupe key. It is not stable. Historical copies of the state file (.bak-* siblings, created by unrelated manual cleanups) show the fleet-wide count collapsing and recovering:

| snapshot | tasks with lastScheduledFor | task-1 |
|---|---|---|
| 2026-05-06 | 13 / 15 cron tasks | present |
| 2026-05-29 (after a cleanup) | 3 / 18 | absent |
| 2026-06-10 (after a cleanup) | 12 / 23 | present |
| current | 19 / 22 | absent |

task-1 — the 2.00× worst offender — currently has no lastScheduledFor and zero recordedSkips entries, i.e. the suppression path in Defect 1 never engages for it at all. It has held the field before, so this is a value being lost, not a task that never had one.

Hypothesis (unconfirmed): the field is written on an on-slot dispatch but not on a catch-up/replay dispatch, so a task whose fires are mostly replays never re-acquires its dedupe key, which produces more replays. That would explain why exactly one task sits at a clean 2.00×.

Related: catch-up replay appears to fire a batch on wake

Tasks with unrelated crons start in the same second after the machine wakes:

07:17:14Z  task-1   (cron 0 13 * * *)
07:17:14Z  task-3   (cron 0 19 * * *)
07:17:14Z  task-7   (cron 30 14 * * 3)

Three more tasks followed within 6 minutes. Same signature on three other dates. This also produces off-day runs — a * * 3 (Wednesday) task fired on a Tuesday and on a Thursday. These replays are what start the off-slot run that Defect 1 then compounds.

Expected vs actual

Expected: at most one dispatch per task per cron slot; a slot that cannot be served while the task is busy is skipped, not deferred-then-released.

Actual: up to 4 dispatches in one day for a daily task (observed: 07:58, 12:03, 12:42, 12:43), including overlapping instances.

What I ruled out

  • Not misconfiguration — one enabled entry per task, correct cron expressions, no duplicate launchd agents on the machine.
  • Not fixable via the public APIupdate_scheduled_task with an identical cronExpression is a complete no-op (state file mtime unchanged, entry byte-identical). No config-level call reseeds a dispatch-time field.
  • Not model / permissionMode — task-1 is the only task carrying those keys and 0/19 tasks with the dedupe field carry them, but task-1 held both keys in the two snapshots where the field was present. The correlation is spurious; another affected task carries no unusual keys.

Impact

Duplicated side effects, not just wasted tokens. In my case a duplicated run re-executed a full measurement sweep, and one task advances a round-robin cursor — a same-day double run advances it twice, silently skipping an item for a full cycle.

Workaround in use: an idempotence check at the top of each task's prompt that looks for the current period's own output and exits early if present. That works, but it requires every task author to know about this and to have a durable per-run artifact to check.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗