Repository navigation
[BUG] Scheduler catch-up storm on restart: re-fires daily tasks and runs enabled:false tasks (ghost fires, lastRunAt not updated) #74055
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea:routinesClaude Code routines on web, used for scheduled tasks, webhook-triggered tasks, etc.Claude Code routines on web, used for scheduled tasks, webhook-triggered tasks, etc.platform:windowsIssue specifically occurs on WindowsIssue specifically occurs on Windows
on Jul 4, 2026 Your diagnosis is solid, and the worst part is the one you called out: the ghost fires are invisible because
lastRunAtisn't updated. Here's a way to make them visible and quantified while this is unfixed — it turns "something is eating my tokens" into an exact list.Why a detector works
Every scheduled run — ghost or legitimate — spawns a real
claude -psession, and that session's transcript starts with theThis is an automated run of a scheduled taskpreamble. So even though the registry'slastRunAtlies, the transcripts don't: each ghost fire leaves a.jsonlwith that preamble, a timestamp, and its own token usage. That's enough to reconstruct the truth independently of the buggy registry.I confirmed on my own machine that the signature is present and greppable across transcripts, and that per-run timestamp and token usage are extractable from each file.
Ghost-fire auditor
#!/usr/bin/env bash # List every scheduled-task run found in transcripts, with time + token cost. # Cross-reference the output against your cron slots / enabled:false tasks: # - two runs of the same task on the same day -> catch-up storm duplicate # - a run far from the task's cron slot -> ghost fire # - a run of a task you disabled -> enabled:false ignored python3 - <<'PY' import glob, os, json rows = [] for f in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")): if os.path.getsize(f) == 0: continue ts = cwd = None; toks = 0; is_sched = False try: with open(f, encoding="utf-8", errors="ignore") as fh: for line in fh: if "automated run of a scheduled task" in line: is_sched = True try: o = json.loads(line) except: continue if ts is None and o.get("timestamp"): ts = o["timestamp"] if not cwd and o.get("cwd"): cwd = o["cwd"] m = o.get("message") if isinstance(m, dict) and isinstance(m.get("usage"), dict): u = m["usage"] toks += (u.get("input_tokens",0) + u.get("output_tokens",0) + u.get("cache_read_input_tokens",0) + u.get("cache_creation_input_tokens",0)) except: continue if is_sched: rows.append((ts or "?", toks, cwd or "?", os.path.basename(f))) rows.sort() print(f"{'started':25} {'tokens':>14} project / session") for ts, toks, cwd, name in rows: print(f"{ts:25} {toks:14,} {cwd} {name}") print(f"\n{len(rows)} scheduled runs, {sum(r[1] for r in rows):,} tokens total") PY
Sort tells the story: two lines for the same task minutes apart = a catch-up duplicate; a line nowhere near that task's cron slot = a ghost fire; a line for a task you disabled =
enabled:falsebeing ignored. And you get the token cost of each, so you can put a real number on the burn (your ~24.8M nightly-task figure is exactly the kind of thing this surfaces).On workarounds
- Your folder-archive trick is the only hard guarantee for a task that must not run — moving the
SKILL.mdout leaves the runner nothing to execute. Worth keeping for anything genuinely disabled. - For tasks you want to keep enabled, the auditor above at least makes the storm visible/quantified instead of silent, and — since the storm is restart-triggered — avoiding app restarts near your cron slots reduces duplicate fires until it's fixed.
Real fix (CC side): treat
enabled:falseas authoritative (never spawn), skip tasks that already ran in the current cron period on restart (or make missed-run catch-up opt-in), and updatelastRunAton every real spawn so a ghost run can never be invisible again.Caveat (honest): I don't run the scheduled-tasks MCP, so I didn't reproduce the catch-up storm itself. What I verified is that the preamble signature is present and greppable in transcripts and that timestamp + token usage are reliably extractable — which is all the auditor needs to make the burn visible regardless of the registry bug.
- Your folder-archive trick is the only hard guarantee for a task that must not run — moving the
Confirming on macOS, desktop app 1.24012.9 (Darwin 25.5.0), with a 41-task fleet — plus one detail that differs from the OP: here
lastRunAtwas overwritten by the ghost runs, which destroys the audit trail.Observed 2026-07-30: ~90 seconds after relaunching the desktop app (~5pm local), a rolling catch-up storm fired 20 of 41 enabled tasks at ~one per minute over ~45 minutes. Selection looks arbitrary rather than "missed while the app was closed":
- Weekly tasks whose cron slot passed 3–5 days ago (Sat/Sun/Mon slots) re-fired — each had already run on time in its own slot (session transcripts exist for both runs).
- Daily and every-3-day tasks that had verifiably completed the same morning (2–3am slots) re-fired ~14h later.
- Other equally "stale" weeklies (Tue/Wed/Fri slots, also already run) did not fire.
Difference from OP: these ghost runs DID update
lastRunAtinlist_scheduled_tasks, overwriting the legitimate timestamps — the registry now claims a Sunday-3am weekly "last ran" Thursday 5pm. So the one cheap forensic signal an auditing routine can use (lastRunAt vs lastScheduledFor drift) is destroyed by the storm itself.Second occurrence: lastRunAt forensics show a smaller storm after a prior restart on 2026-07-26 (a Friday-slot weekly recorded as last run Sunday 20:14 local).
Mitigation that worked: zero duplicated public side effects, only because every outward-facing task prompt carries an explicit idempotency guard ("check current state before acting — the draft/post may already exist"). Four outward-facing tasks re-ran; all four no-op'd on their guards. Until this is fixed, scheduled tasks have to be designed at-least-once, not exactly-once — but the ~20 cold-start ghost sessions of token spend per restart can't be designed away.
+1 to the OP's expected behavior: catch-up opt-in, and never re-fire a task that already ran in its current cron period. The scheduler is otherwise the backbone of this whole setup — which is exactly why the restart storm is so visible. Thanks!
- added a commit that references this issue
on Sep 4, 2026 Closing for now — inactive for too long. Please open a new issue if this is still relevant.
Summary
The scheduled-tasks runner performs a "catch-up storm" after an app restart: it re-fires daily tasks that already ran that day, and fires tasks that are explicitly
enabled: false(some disabled days earlier).lastRunAtinlist_scheduled_tasksis NOT updated by these ghost runs, so the registry claims the task never ran while a fullclaude -psession (with its full context cold-load) was actually spawned and billed against the subscription bucket.Environment
Observed on 2026-07-03 (all times local, verified from
~/.claude/projects/**/*.jsonlsession logs)enabled: falsesince 3 days prior (cron20 5 * * *) fired at 20:29 — wrong time AND disabled. Session log shows the standard "This is an automated run of a scheduled task" preamble, so it came from the scheduler, not a manual run. ItslastRunAtstill shows the pre-disable date.45 5 * * 1, Monday) fired on Thursday.Expected
enabled: falseis authoritative: a disabled task never spawns a session.lastRunAt, so ghost runs are at least visible.Impact
Each ghost fire is a full
claude -pcold start (~0.7-2M tokens with a large user CLAUDE.md + MCP schemas), so a single restart storm can silently burn tens of millions of tokens per day. Users tracking down "which background service eats my tokens" will find the scheduler itself.Workaround found
Moving the task folder out of
~/.claude/scheduled-tasks/(archive) prevents ghost fires (the runner then has no SKILL.md to execute);enabled: falsealone does not. Related older request: #59981 (delete_scheduled_task API) — a real delete would also solve this.