Scheduled tasks fire multiple times per slot: deferred slots released on instance exit, and the lastScheduledFor dedupe key is dropped
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
launchdagents on the machine. - Not fixable via the public API —
update_scheduled_taskwith an identicalcronExpressionis 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.
4 Comments
Follow-up: a one-shot
fireAttask reproduced this on itself, and it widens the diagnosisWhile instrumenting the original report I created a one-shot task (
fireAt, no cron) to read some scheduler state at a later time. It reproduced the defect on itself within 17 seconds of creation.| | |
|---|---|
|
createdAt|2026-07-30T09:27:10Z|| its own run |
09:27:27Z → 09:30:09Z— 17 s after creation, ran to completion and wrote its output file ||
fireAt|2026-07-30T12:10:00Z— 2 h 43 m later || registry entry afterwards |
enabled: true, and nolastRunAtfield at all |So the task dispatched ~2 h 43 m early, completed, and the dispatch was never recorded. A one-shot task is documented to auto-disable after firing; this one cannot, because nothing recorded that it fired. It is still
enabledwith itsfireAtintact, i.e. armed to fire a second time at the originally scheduled moment.The run definitely happened — three independent traces:
approvedPermissions: [{"toolName":"Bash","ruleContent":"python3 -c ' *"}]— which by design only happens during a run.Why this matters for the diagnosis
The original report framed Defect 2 around
lastScheduledForon recurring tasks and hypothesised that catch-up/replay dispatches skip the write. This case is a one-shot with no cron at all, and the unwritten field islastRunAt. Same failure class:That is broader than "cron replay skips the dedupe write" — it looks like a property of the early/unscheduled dispatch path generally, independent of cron. If one fix is wanted, persisting dispatch bookkeeping unconditionally (on every dispatch, whatever triggered it) would address both the recurring and one-shot manifestations.
Two smaller observations
fireAtwas ~2 h 43 m in the future and clearly in-range; nothing about this task was overdue.enabled/ absence oflastRunAtis not a reliable "has not run yet" signal for anything built on top of the scheduler. I initially read the registry as "the task never ran" and only caught it by looking for side effects. Worth noting if any UI surfaces run state from these fields.Environment unchanged from the original report: Claude Code 2.1.206, macOS 26.6, Node v22.23.1.
We're seeing this too, on the Windows desktop app (Cowork mode), confirmed 30-09-2026.
Symptoms: several of our scheduled tasks show up with visibly inflated run counts in the sidebar — one task at roughly 2x its expected count over 30 days, others at similar multiples. Our own daily 06:00 task logged direct evidence in its own output: "Deze kennisoogst-ronde draaide vandaag met minstens drie sessies tegelijk op dezelfde vijf bronbestanden" (this round ran with at least three simultaneous sessions against the same five source files) on 29-09-2026.
We first suspected a second app installation (differing cwd paths across sessions: some showing AppData\Roaming\Claude\..., others the real MSIX-virtualized AppData\Local\Packages\Claude_...\LocalCache\Roaming\Claude\...), but confirmed via Windows Settings > Apps that only one Claude installation exists (Anthropic PBC, installed 29-09-2026). So the differing paths appear to be the display bug in #58421, unrelated to the duplicate-run issue itself.
Platform: Windows 11, Claude desktop app (Cowork mode), scheduled tasks with daily recurrence.
Impact for us: tasks that write files or send emails (Gmail draft creation, vault log writes) risk duplicate side effects when three sessions race on the same slot. We've had to add a manual once-per-day file-lock guard to our task prompts as a workaround while this is open.
Happy to share more detail (session logs, timestamps) if useful.
Possibly the same root cause through a different entry point: the session-only
CronCreatetool in the CLI (not the desktopmcp__scheduled-tasks__*scheduler).A recurring job with cron
7,22,37,52 * * * *(every 15 minutes) in a long-running Linux CLI session fired back-to-back instead, roughly 90 times in a row, each an identical run of the same prompt. There were no overlapping instances.CronCreatejobs only fire while the REPL is idle, so this looks like your Defect 1: slots that came due while the session couldn't run them were queued and then released one after another, instead of being dropped. I don't have per-fire timestamps to prove it was a drained backlog. It had happened in earlier sessions as well, and a separate/feedbackreport was sent._This comment was written by Claude (Claude Code) on behalf of @viceice._
Different, more specific pattern on the desktop Cowork app (Windows), not the CLI: two fully separate, independently-configured task objects for the same recurring task, not just duplicate/queued session runs.
Setup: a single hourly recurring task ("Bol repricer, elk uur", cron 0 8-21 *, created well before this was noticed). On 2026-09-30 the sidebar started showing two cards with the identical title, but:
Each card has been firing independently on its own hourly schedule (offset ~8 minutes apart) for at least two days, confirmed by timestamps in both cards' run histories.
Confirmed NOT a UI caching issue: the duplication persisted through (1) a full restart of the Claude desktop app, and (2) a full restart of the Windows machine itself. Both restarts were done, in that order, specifically to rule this out.
Confirmed these are genuinely separate backend objects, not one task rendered twice: I used the
mcp__scheduled-tasks__list_scheduled_tasks/update_scheduled_tasktools (available to the agent inside a Cowork session) to edit this task's prompt. The edit landed on, and is only visible in, Card A. Card B's instructions are unchanged. Repeated calls tolist_scheduled_tasksonly ever return a single task ID for this task — Card B does not appear under any task ID in that tool's output, so it is completely unreachable through that API surface.Working theory, not confirmed: the "Move to cloud" affordance on Card A suggests an active migration from device-bound/local scheduled-task execution to cloud-hosted execution. We suspect Card B may be a leftover from (or duplicate created during) that migration for a task that existed before it rolled out, with the old and new representations never merged or deduplicated. This would also fit the already-reported
device_absent/suspension behavior for device-bound tasks (#94415, #93626) — a cloud execution mode is a plausible fix for that, which would explain why such a migration is happening at all.Impact: this is a real operational risk beyond extra token cost. The duplicated task here changes live prices on a marketplace (price undercutting logic against competitors). Two independent, unsynchronized copies increase the chance of conflicting or redundant real-world price changes within the same hour.
Happy to provide more detail (exact timestamps, more screenshots) if useful.