Skip to content

[BUG] Scheduler catch-up storm on restart: re-fires daily tasks and runs enabled:false tasks (ghost fires, lastRunAt not updated) #74055

Description

@tonydzi

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). lastRunAt in list_scheduled_tasks is NOT updated by these ghost runs, so the registry claims the task never ran while a full claude -p session (with its full context cold-load) was actually spawned and billed against the subscription bucket.

Environment

  • Claude Code 2.1.185 (observed; just updated to 2.1.201, unknown if fixed)
  • Windows 11 Pro 10.0.26200, desktop app + scheduled-tasks MCP

Observed on 2026-07-03 (all times local, verified from ~/.claude/projects/**/*.jsonl session logs)

  1. Restart storm ~23:33: six daily tasks each fired a SECOND time within minutes of each other, far from their cron slots (crons: 00:20, 00:50, 04:00, 05:10, 05:30, 05:40). The duplicate runs burned ~30M tokens (one nightly maintenance task alone: 24.8M across its 2 runs).
  2. Disabled task fired: a task with enabled: false since 3 days prior (cron 20 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. Its lastRunAt still shows the pre-disable date.
  3. Wrong-day fire: a weekly task (cron 45 5 * * 1, Monday) fired on Thursday.

Expected

  • enabled: false is authoritative: a disabled task never spawns a session.
  • A restart never re-fires a task that already ran in the current cron period (missed-run catch-up should be opt-in, or at least skip already-ran-today tasks).
  • Every real run updates lastRunAt, so ghost runs are at least visible.

Impact

Each ghost fire is a full claude -p cold 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: false alone does not. Related older request: #59981 (delete_scheduled_task API) — a real delete would also solve this.

Activity

  1. added
    bugSomething isn't working
    area:routinesClaude Code routines on web, used for scheduled tasks, webhook-triggered tasks, etc.
    platform:windowsIssue specifically occurs on Windows
    on Jul 4, 2026
  2. yurukusa commented on Jul 4, 2026

    @yurukusa

    Your diagnosis is solid, and the worst part is the one you called out: the ghost fires are invisible because lastRunAt isn'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 -p session, and that session's transcript starts with the This is an automated run of a scheduled task preamble. So even though the registry's lastRunAt lies, the transcripts don't: each ghost fire leaves a .jsonl with 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:false being 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.md out 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:false as authoritative (never spawn), skip tasks that already ran in the current cron period on restart (or make missed-run catch-up opt-in), and update lastRunAt on 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.

  3. geokao commented on Jul 31, 2026

    @geokao

    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 lastRunAt was 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 lastRunAt in list_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!

  4. github-actions commented on Sep 6, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:routinesClaude Code routines on web, used for scheduled tasks, webhook-triggered tasks, etc.bugSomething isn't workingplatform:windowsIssue specifically occurs on WindowsstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions