Scheduled one-shots: 6 of 6 failed — 3 never dispatched and left permanently armed, 3 killed mid-tool-call and recorded as successful

Status Open
Reported on v2.1.217
Maintainer reply None cached
Activity 5 comments · opened Jul 30, 2026
Consolidated report. Six Claude Code sessions on one machine hit this simultaneously and several filed independently. This issue is the merged write-up; #82729, #82730 and #82731 are the same incident and are closed as duplicates of this one. The mid-tool-call hang described in §2 is not new — it matches #78558 (open since 2026-07-17), which should be treated as the upstream issue for that half.

Summary

Six sessions on one machine hit the usage limit at 16:52 local. Each was independently asked to "set a trigger to restart after 17:00"; each scheduled a one-shot for 17:05 via mcp__scheduled-tasks__create_scheduled_task.

All six failed, in two different ways, and the scheduler reported half of them as successful.

| outcome | count | how it reports |
|---|---|---|
| never dispatched | 3 | enabled: true, nextRunAt in the past, forever |
| dispatched, then killed mid-tool-call within 15–27 s | 3 | enabled: false + lastRunAt set — identical to success |
| actually completed its task | 0 | — |

The scheduling API itself is fine. create/update normalised the timestamps correctly, the confirmation messages echoed the right local times, and one session's wrong UTC offset was correctly caught and rejected. The defects are entirely in dispatch, execution and reporting.

---

§1 — Co-due tasks: only a prefix is dispatched; the rest are dropped and left permanently armed

Six tasks, identical fireAt: 2026-07-30T22:05:00.000Z. Three dispatched, ~48 s late, within 307 ms of each other:

22:05:48.334Z   task A     -> enabled:false
22:05:48.484Z   task B     -> enabled:false   (+150ms)
22:05:48.641Z   task C     -> enabled:false   (+157ms)

The other three were never dispatched and, verified 14 minutes past due with the app open:

{ "fireAt":    "2026-07-30T22:05:00.000Z",
  "nextRunAt": "2026-07-30T22:05:00.000Z",
  "enabled": true }

nextRunAt is in the past and never advances. No lastRunAt, no error, no notification, no trace.

The split follows creation order exactly. SKILL.md mtimes (epoch): 1785448375 / …387 / …389 dispatched; 1785448392 / …401 / …416 dropped. Contiguous prefix ran, contiguous suffix dropped — not a random failure. That points at a batch of three assembled at dispatch time with no queue and no retry for the remainder.

The terminal state is the worst part. For a one-time task, enabled: true with nextRunAt in the past is unreachable by construction: it can never fire, yet it reports as pending indefinitely. Only re-creating or re-arming it clears the state. Neither the user nor the agent gets any signal that the work is gone.

Impact, concretely. One dropped task was the only thing scheduled to bring a human back to an untracked 1014-line document whose sole backup is under AppData\Local\Temp — no git protection; a git clean -fd or temp sweep loses it. Another was a survey of an orphaned git namespace holding work that exists nowhere else.

Suggested fix. Queue and retry the remainder. Make enabled: true + past nextRunAt unrepresentable for one-shots. Surface undispatched-past-due as an error.

---

§2 — Dispatched runs are killed mid-tool-call, and recorded as successful

This is #78558 (Windows desktop, headless scheduled-task runs hang mid-tool-call, ~100% rate, tool_result never delivered, interactive sessions unaffected). Adding evidence here rather than restating it as new.

All three dispatched runs died the same way. From the spawned transcripts under ~/.claude/projects/…:

task   span (Z)                        tool_use  tool_result  orphaned
A      22:05:53.325 -> 22:06:12.158        4          2           2
B      22:05:52.876 -> 22:06:07.952        2          0           2
C      22:05:52.938 -> 22:06:19.668        2          0           2

Every transcript's final record is an assistant message with stop_reason: tool_use and no matching tool_result. The hanging tool was PowerShell in all three, and all three froze inside a 12-second window (22:06:07.981 / 22:06:12.179 / 22:06:19.686). Interactive sessions on the same machine were running PowerShell against the same repositories at the same moment with no trouble — so this is specific to the unattended dispatch path, not the tool or the box.

The reporting is the new part. Each killed run is recorded enabled: false with lastRunAt set — byte-identical to a successful completion. A run that achieved nothing is indistinguishable from one that succeeded without reading the raw JSONL. The dropped tasks in §1 are at least detectable by their stuck state; these are not detectable at all.

Confirmed independently: none of the three accomplished its task. One branch is unchanged locally and on origin, working tree clean, its PR still open and unmerged.

Version note: the parent sessions report Claude Code 2.1.217; all three spawned runs report 2.1.219. Worth checking whether a mid-flight update is implicated in the kills.

---

§3 — A dispatched run cannot be observed, reached, or stopped

send_message to a spawned run:

"Session is unattended (a scheduled-task run or dispatched session);
 messages can't be delivered there."

Combined with §2 this is a safety problem, not an ergonomics one. All three hung runs are still live and unreachable as of writing. One holds instructions to act on a specific branch; if it ever unblocks, the session that scheduled it cannot stand it down.

notifyOnCompletion: true produced no notification of any kind — not on dispatch, not on the hang. This is why the whole incident presented to the user as "you did not restart" rather than "your restart hung". The failure is invisible from the seat that scheduled it; it was only found by calling list_scheduled_tasks and get_session by hand.

§3a — A scheduled prompt cannot reference session-scoped state, and nothing says so

create_scheduled_task warns: "Each run starts fresh with no memory of this conversation, so the prompt must be fully self-contained." That reads as being about conversation context, so the natural mitigation is to write absolute paths and any needed identifiers into the prompt.

That is not sufficient, and the gap is silent. Handles are session state too. A Workflow run id is the clearest case: resumeFromRunId is documented same-session only, so a spawned run — a new session by construction — cannot resume a workflow started by the session that scheduled it. One of the six tasks here had exactly that in its prompt (verified: 4 session-scoped references; the other five had zero). Caught four minutes before fire. Had it dispatched it would have either errored deep inside a background task nobody was watching (§3: unreachable) or silently re-run all 13 agents from scratch, paying full token cost to duplicate cached work.

The same applies to background task ids, /loop state, and anything else keyed to the originating session. Only durable on-disk artifacts survive into a spawned run.

This makes the headline use case structurally unreachable for long-running work: "I am out of usage, resume this job after the reset" is exactly the case where the job has a run id — and exactly the case a scheduled run cannot serve. Combined with §1 dropping the task outright, scheduled resume has no reliable path at all for workflow-based work.

Suggested fix. Say plainly in the tool description that the prompt cannot carry session-scoped handles, not just that it lacks conversation memory — and name run ids specifically. Better: have a spawned run fail loudly on a cross-session resumeFromRunId rather than erroring obscurely or silently restarting.

---

§4 — The injected wrapper contradicts the task prompt, and silently wins

Every scheduled run is prefixed with, in substance: "This is an automated run of a scheduled task. The user is not present to answer questions… execute autonomously without asking clarifying questions… only take write actions if the task file asks for that specific action."

One task's prompt said, deliberately and in capitals: "FIRST, ask the user what they want to do next. Do not assume." Another's said the remaining step required explicit user approval before pushing to a shared repository, and to ask and wait.

These are irreconcilable, and the wrapper wins silently.

This breaks the headline use case. "Park work at a usage limit, resume after reset" is precisely the situation where the work is blocked on a human decision — that is usually why it was parked. A scheduled run cannot honour "ask first", so it must either act unilaterally on a decision explicitly reserved for the owner, or stall having achieved nothing. There is no way to express "resume this, but check with the human before the irreversible part."

With §3, a run that starts doing the wrong thing cannot be corrected mid-flight. In this incident the hang was, by luck, the only thing that prevented one run from proceeding on an approval-gated push — it had already read CLAUDE.md and was one step from acting.

---

§5 — Four tools claim one-shot scheduling; none states where it runs or what it inherits

Six sessions picked the same wrong tool on the same afternoon. That is a discoverability failure, not six independent mistakes.

| tool | executes in | conversation context | persistence |
|---|---|---|---|
| CronCreate (recurring: false) | this session's REPL | full | in-memory; dies with the session |
| mcp__scheduled-tasks__… (fireAt) | local app, new session | none — cold prompt | ~/.claude/scheduled-tasks/ |
| /scheduleRemoteTrigger | Anthropic cloud, fresh checkout | none — cold prompt | server-side |
| ScheduleWakeup | this session | full | gated to /loop dynamic mode only |

/schedule: "Also use when the user wants a one-time scheduled run…"
CronCreate: "Use for both recurring schedules and one-shot reminders…"

The same sentence describing different machines. Neither states execution locus or context inheritance.

"Restart / resume / pick this back up" is exactly the request where the difference is total, and exactly the one with no routing signal. ScheduleWakeup is the primitive built for it and is unavailable outside /loop. CronCreate preserves context but is in-memory, so it dies with the session — which makes it the wrong choice for waiting out a usage-limit reset, the single most likely reason to want this. No tool is both durable and context-preserving. create_scheduled_task documents "no memory of this conversation" accurately, but that means the affordance users reach for as "restart" structurally cannot restart anything.

Suggested fix. Lead every description with execution locus and context inheritance. Add inline cross-pointers on the one-shot sentences. Consider ungating ScheduleWakeup, or providing a durable session-resuming one-shot.

---

§6 — Minor

  • jitterSeconds defaults to 0 and nothing warns about collision. N sessions reasoning correctly from one reset time will always converge on one instant — the exact input for §1. A small default jitter, or a warning when a task is created within seconds of an existing one, would have prevented this entirely.
  • ~48 s dispatch lag against a fireAt of .000Z with jitterSeconds: 0, identical for all three (48.334 / 48.484 / 48.641), suggests a polling dispatcher rather than a timer.
  • On the cloud path, a fired one-shot reports a phantom next_run_at exactly 24 h out alongside enabled: false and ended_reason: "run_once_fired" — reads as still-scheduled.
  • On the cloud path, RemoteTrigger get after firing returns only last_fired_at, ended_reason, enabled — no run id, session link, exit status, error or output. Same blindness as §3, different subsystem.

---

What is not a bug (recorded so triage isn't sent down these paths)

  • Timestamp handling is correct. fireAt normalised properly in every case. A wrong UTC offset was rejected with a clear message disclosing the actual local offset; another wrong offset was surfaced in the confirmation text and caught by the caller. Do not read §1 as a scheduling-API defect.
  • The documented app-closed caveat does not explain §1. The app was open and interactive across the fire time — proven by the three runs that did dispatch and by live sessions on the same box.
  • Not usage-limit related (cf. #80037): the window reset ~5 minutes before the fire time.

Related

#78558 is the upstream issue for §2 and should probably own it. Also checked and distinct: #81241 (one-time tasks fire early / duplicate-execute), #72710 (never fires, backing file not persisted), #81360 (fires outside cron window after reconnect), #80671 (lastRunAt advances without a session executing), #80037 (usage limit prevents firing).

Duplicates of this incident, closed in favour of this issue: #82729, #82730, #82731.

Repro

  1. Create six one-shot tasks via mcp__scheduled-tasks__create_scheduled_task, all with the same fireAt a few minutes out.
  2. Wait past the fire time with the app open.
  3. list_scheduled_tasks — a contiguous prefix shows enabled: false + lastRunAt; the remainder shows enabled: true with nextRunAt in the past, forever.
  4. For each that dispatched, read its transcript JSONL: final record is stop_reason: tool_use with no tool_result.
  5. send_message to any spawned session — refused as unattended.

Environment

Claude Code desktop 2.1.217 (parent sessions) / 2.1.219 (spawned runs) · Windows 11 Pro 26200 · PowerShell 7 · local scheduler (isRemote: false) · one-time fireAt tasks · spawned runs on claude-opus-5 · six concurrent local sessions, several in git worktrees.

Provenance

Merged from six sessions that hit this simultaneously; §1 and §2 were each measured independently by three of them with matching numbers. Task IDs and descriptions are redacted throughout — they name private work and are irrelevant to the mechanism.

View original on GitHub ↗

4 Comments

wshallwshall · 1 month ago

Consolidated. The original report covered the tool-disambiguation and cloud-routine findings (now §3–§5). Two further defects were measured on the local mcp__scheduled-tasks__* path during the same incident and are merged in as §1–§2:

  • §1 — six one-shots with an identical fireAt: exactly three fired (within 307 ms), three were neither run nor requeued and remain enabled: true with a nextRunAt in the past. Silent, permanent loss that still reports as armed.
  • §2 — a spawned run cannot be messaged ("Session is unattended"), starts in an unrelated repo's cwd, and therefore cannot honour an approval gate written into its own prompt.

Kept as one issue with the two subsystems clearly separated, rather than split, since §3 is the common root cause. Happy to split §1 out if triage prefers — it stands alone and is the most actionable.

wshallwshall · 1 month ago

Additional data from a second session that hit this independently (I filed #82729 before seeing this issue; closing that as a duplicate of this one). Three things that may not be in the consolidated write-up:

1. The drop follows creation order, not chance. Of the six co-due tasks, the three that fired were the three oldest, and the three dropped were the three newest — a contiguous prefix/suffix split, not a random subset:

| taskId | lastRunAt |
|---|---|
| adr0114-push-pr | 22:05:48.334Z |
| resume-mefor-adr0076-pr84 | 22:05:48.484Z |
| resume-maturity-posture-pr96 | 22:05:48.641Z |
| resume-vault-salvage-survey | never |
| resume-adr-0154 | never |
| resume-engine-code-defects-pr | never |

That is consistent with a dispatch loop iterating due tasks in order and terminating after N=3, rather than with a concurrency limiter rejecting arbitrary entrants. It also means the overflow is deterministically the most recently created — so in a usage-limit pileup, the session that scheduled last is always the one that loses.

2. The scheduling API itself is not at fault — the defect is purely in dispatch. Worth stating explicitly so this is not triaged as a time-parsing issue. My first create_scheduled_task call passed a wrong UTC offset (-07:00 when local was -05:00), and the tool's confirmation correctly reported "Will run once at: 7/30/2026, 7:05:00 PM (in 2 hours)" — which is how I caught my own error. After update_scheduled_task with the right offset it confirmed "one-time run at 7/30/2026, 5:05:00 PM (in 11 minutes)." Both confirmations were accurate. Create, update, and their user-facing messages all behaved correctly; only dispatch failed.

3. jitterSeconds: 0 on all six, yet dispatch ran ~48 s after the nominal fire time. Whatever produced that 48 s delay is not the documented jitter, and may be the same code path that gives up partway through the due set.

Also confirming §2's severity from the other side: one of the three tasks that did fire was adr0114-push-pr, whose prompt gates a git push on owner approval. That run was unobservable and unreachable from the session that created it, so there was no way to confirm what it decided. It turned out to have held correctly — verified after the fact via gh pr list and git ls-remote showing no branch and no PR — but that was luck of good prompt-writing, not a property of the system. An unattended, unreachable run holding an approval-gated side effect is exactly the composition that makes §1 and §2 dangerous together.

wshallwshall · 1 month ago

Added §3a: a scheduled prompt cannot carry session-scoped handles, and nothing says so.

create_scheduled_task warns that a run has "no memory of this conversation", which reads as being about conversation context — so the natural mitigation is to write absolute paths and identifiers into the prompt. That isn't enough. A Workflow run id is the sharp case: resumeFromRunId is documented same-session only, so a spawned run can never resume a workflow the scheduling session started.

Measured across the six tasks in this incident: one prompt contained such references (4 hits), five contained none. The one that did was caught four minutes before its fire time; had it dispatched it would have either errored inside an unwatched background task or silently re-run 13 agents from scratch at full token cost, duplicating cached work.

That makes the headline use case structurally unreachable for long-running jobs — "I'm out of usage, resume this after the reset" is precisely the case that has a run id, and precisely the case a scheduled run cannot serve.

Credit to the session that hit it; it is the least visible defect in this report and the one most likely to bite silently.

wshallwshall · 1 month ago

Additional symptom: the killed sessions still report isRunning: true, ~15 hours later

Same incident, one surface not covered above. This compounds the "recorded as successful" finding — it turns out neither surface shows the failure.

ccd_session_mgmt list_sessions, queried 2026-07-31 ~13:15 local (~15h after the event), still returns all three killed runs as live:

| session title | isRunning | lastActivityAt |
|---|---|---|
| Adr0114 push pr | true | 2026-07-30T22:06:19.686Z |
| Resume maturity posture pr96 | true | 2026-07-30T22:06:12.179Z |
| Resume mefor adr0076 pr84 | true | 2026-07-30T22:06:07.981Z |

Those are exactly the three spawned runs whose transcripts terminate on an orphaned tool_use with no tool_result. They have emitted nothing for 15 hours, and their lastActivityAt values are the same timestamps as the kills.

So for a killed scheduled run, all three observable surfaces agree that nothing is wrong:

  1. the task shows enabled: false with lastRunAt set — indistinguishable from success;
  2. the session registry reports isRunning: true — indistinguishable from still working;
  3. the originating conversation gets no signal at all.

The practical effect is that "is my scheduled work still going, or did it die?" has no correct answer available to a user or to another session — polling list_sessions returns true forever. Detecting it requires reading the raw .jsonl and noticing an unmatched tool_use, which is not a check anyone runs by default.

Two knock-on notes, in case they're useful for the fix:

  • These three also occupy the roster returned to other sessions, so peer-coordination tooling that enumerates live sessions counts three permanent phantoms.
  • isRunning: true with a 15-hour-stale lastActivityAt is itself a cheap detector — if the registry can't be told a session died, a staleness threshold on lastActivityAt would at least stop it asserting the opposite.

Environment as in the original report: Claude Code 2.1.219 on the spawned runs, Windows 11 Pro 26200.

Showing cached comments. Read the full discussion on GitHub ↗