[scheduler] scheduled-task discarded on wake from macOS maintenance sleep — no catch-up / replay
Summary
On macOS (Apple Silicon, laptop), a scheduled task whose dispatch window falls
inside a maintenance-sleep cycle is silently discarded on wake rather than
replayed. The combination of (a) Claude Code's PreventUserIdleSystemSleep
assertion not being strong enough to block maintenance sleep, and (b) the
scheduler's hard-expiry semantics, means routine automation can silently fail
on any laptop with default power settings.
Today's miss cost ~40 minutes (I manually fired "run now" after noticing
"skipped" in the routines page). On a different day with no human-in-the-loop,
the morning briefing would simply not exist.
Environment
- Claude Code: 2.1.143
- macOS: 26.4.1 (build 25E253), arm64 (Apple Silicon)
- Power settings (default-ish):
standby 1,powernap 1,tcpkeepalive on,
on AC the whole time
- Task: a custom
morning-briefingscheduled task (~/.claude/scheduled-tasks/morning-briefing/SKILL.md),
scheduled daily at 07:00
Timeline (2026-05-18)
| Time (BST) | Event |
|--------------|-------------------------------------------------------------------------------------------|
| 07:00:14 | Scheduler logs the task as due, applies 463s jitter → planned dispatch ~07:07:43 |
| 07:01:31 | macOS enters maintenance sleep: Entering Sleep state due to 'Maintenance Sleep':TCPKeepAlive=active Using AC (Charge:79%) |
| 07:07:43 | Planned dispatch time — machine is asleep |
| 07:17:07 | macOS dark-wakes. Claude scheduler logs Cleared stale pending dispatch for: morning-briefing |
| 07:43 | I notice "skipped" on routines page, manually click "run now" → succeeds |
Evidence: ~/Library/Logs/Claude/main.log (scheduler trace), pmset -g log
(sleep/wake transitions), pmset -g assertions (assertion type held).
Root cause (two-part)
1. Sleep assertion is too weak.pmset -g shows sleep prevented by powerd, Claude. This is aPreventUserIdleSystemSleep assertion — it blocks user-idle sleep, but does
NOT prevent powerd from entering maintenance sleep. On Apple Silicon,
maintenance sleep can happen even on AC with the lid open.
The right assertion for a process that needs to fire jobs reliably while the
machine is unattended is PreventSystemSleep (held by caffeinate -s and
equivalents).
2. Scheduler discards delayed dispatch instead of replaying it on wake.
Even with the weaker assertion, a robust laptop-aware scheduler would notice
on wake that a dispatch window was missed and execute the job immediately
(misfire-grace-period / catch-up semantics — see cron's @reboot, systemd
timers' Persistent=true, anacron, etc.). Claude Code's scheduler does the
opposite: explicitly clears the stale dispatch on wake.
Proposed fix (scheduler-side, preferred)
Add wake-replay / catch-up semantics. On wake, for any task whose dispatch
window expired during sleep:
- Within a configurable grace period (default e.g. 30 minutes), execute the
job immediately and log it as a delayed dispatch.
- Beyond the grace period, log a skip with the reason (
sleepvserror
vs overlap) so the routines UI can distinguish them.
This makes Claude Code's scheduled tasks reliable on laptops without forcing
users to wrap the launcher in caffeinate or globally disable maintenance
sleep.
Workaround for current users
Wrap whatever launches Claude Code with caffeinate -dimsu so it holdsPreventSystemSleep. Verify with pmset -g assertions — should listPreventSystemSleep, not just PreventUserIdleSystemSleep. Caveats: affects
battery on portable use; doesn't fix the scheduler-side fragility, only the
specific maintenance-sleep trigger.
Why this matters
Modern Macs default to aggressive maintenance-sleep behaviour. Any user
running scheduled automation on a laptop is exposed — the failure is silent
(skip logged, no notification) and structural (will recur until mitigated).
Daily automation that the user relies on to surface inbox items, briefings,
or status updates can fail open without warning.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗