Scheduled-task sessions complete successfully but their OS process never exits
Scheduled-task sessions complete successfully but their OS process never exits
Summary
On the Claude Code desktop app, a scheduled task's session process stays resident
forever after the run finishes. The session is marked done and idle, the result is
returned, and the process still never exits. Because scheduled tasks re-fire on a
cron, every run adds a new permanently resident process.
Over ~10 hours at a 15-minute cadence this accumulated 41 leaked session pairs
(82 processes, 3.08 GB RSS, 261% CPU combined) and drove system load average
from ~6 to 82 on a 16 GB Mac.
Environment
- Claude.app 1.34493.1
- bundled claude-code 2.1.237 (the CLI on PATH was already 2.1.239)
- macOS Darwin 25.4.0, Apple Silicon, 16 GB RAM
Reproduction
- Create any scheduled task with a recurring cron (observed at
*/15 * * * *). - Let it fire repeatedly. The task body does not matter; see "Not the task" below.
- After each run, the session shows as finished but
psstill lists itsclaude
process. Count grows by one pair per fire, indefinitely.
Evidence that the run actually completed
Transcript of one leaked session, read via list_events, in full:
[user] <scheduled-task name="disk-space-guardian" ...>
[assistant] (called Bash)
[assistant] Disk OK: 62Gi free. No cleanup needed.
[result] done (success), 2 turns
list_sessions reports that same session as isRunning: false, and the UI shows
it idle. So the agent loop finished and the result was delivered. Only the OS
process failed to terminate.
Characteristics of each leaked process
- state
S, burning 3 to 14% CPU continuously while doing no work - no child processes (its transient Bash child exited normally)
- holds ~26 ESTABLISHED TCP connections, all to a single Anthropic API host
(160.79.104.10:443), on sequential source ports (e.g. 60191 through 60222)
- process tree:
Claude.app->disclaimer->claude->claudechild - RSS ~45 to 175 MB per process
The socket pool is the most likely thing holding the runtime alive: an open
keep-alive handle that is never closed or unref'd would keep the event loop from
draining after the turn completes.
Ruled out
Not a permission prompt hang. The run returned done (success). A prompt-blocked
run would never have produced its report.
Not the task's own prompt. The affected task's disk threshold was never crossed,
so every run took a two-turn trivial path and still leaked. More conclusively, a
second scheduled task with a completely unrelated prompt leaked identically on its
first run: the leaked process's start time (10:01:24) matched that task'slastRunAt to the second.
Not user sessions. Interactive sessions in the same app were not observed to
leak this way; the accumulation tracked the cron cadence exactly. The even 15-minute
spacing of process start times is the clearest signature.
Impact
Silent and compounding. Nothing surfaces in the UI, because each session correctly
reports success. The only visible symptom is the machine progressively degrading:
load average climbing into the 80s, swap growing (observed macOS expanding the swap
file 2 GB -> 5 GB -> 6 GB), and memory pressure rising. A user is very likely to
misattribute this to whatever heavy app they happen to have open.
Higher cron frequency makes it worse linearly. A */15 task leaks 96 processes/day.
Workaround in place
Each task now reaps its own predecessors at the start of every run, using a pid
ledger at ~/.claude-guardian-state/<taskId>.pids. The ledger is required rather
than matching on working directory, because a task whose cwd is a real project
directory cannot otherwise distinguish its own leaked processes from the user's
live sessions.
Two details that matter for anyone copying this workaround:
- Identify the session pid with
ps -o comm=(executable path only), never
ps -o command=. The wrapper shell's argv contains the matching script text
itself, so an argv match returns the wrapper shell's pid instead of the session's.
- Re-verify the pid is still a
claudebinary immediately before killing, to guard
against pid reuse.
Ask
Terminate the session process once its run completes, or close/unref the API socket
pool during teardown so the runtime can exit on its own.
Version investigation
Checked the public changelog directly rather than relying on a summary.
Latest published is 2.1.241; the desktop app bundles 2.1.237, so it is four
versions behind. However, no entry between 2.1.238 and 2.1.241 describes a fix for
this. 2.1.240 and 2.1.241 are both listed only as "Bug fixes and reliability
improvements". 2.1.239 is a large release whose nearest-adjacent item is MCP server
reconnection handling, which is not this.
The memory-related fixes that do appear by name in the changelog are all older than
the installed build and therefore already present:
- 2.1.235, unbounded memory growth in long interactive sessions
- 2.1.216, truncated MCP tool outputs retaining the full result
- 2.1.214, unbounded memory growth when
--settingspoints at a device file
So updating is worth doing on general grounds, but it should not be assumed to fix
this. As far as the public changelog shows, this specific defect is unreported.