[BUG] Desktop: scheduled-task sessions never terminate — WarmLifecycle:session idle timeout disconnects, then re-arms indefinitely
Bug report: scheduled-task sessions never terminate — idle timeout disconnects, then re-arms forever
Environment
- Claude desktop app 1.24012.9
claude-code2.1.219- macOS Darwin 25.5.0, Apple Silicon, 16 GB RAM
- Scheduled task registered via the
scheduled-tasksMCP server, cron0 */4 * * *
Summary
A scheduled-task session runs its work to completion successfully, and is then never torn
down. At the idle timeout the app logs Idle timeout reached, disconnecting <sid> and, in
the same second, Starting idle timeout for <sid>: 900s — it re-arms rather than exiting. The
underlying claude process stays alive and repeats this every 15 minutes indefinitely.
Each leaked session holds 11 MCP servers (~6 OS processes, ~150 MB resident). At a 4-hour cron
cadence this accumulated 23 leaked session trees over ~3 days, exhausting RAM and driving
the machine deep into swap.
Evidence — complete log history of one session
Every line for local_<sessionA> in~/Library/Logs/Claude/main1.log, unedited apart from stripping the [info] tag:
16:06:33 Starting local session local_<sessionA> in /Users/<user>/projects/<project>
16:06:35 [WarmLifecycle:session] Starting idle timeout for local_<sessionA>: 900s
16:06:35 [WarmLifecycle:preview] Starting idle timeout for local_<sessionA>: 1800s
16:06:35 Enabling remote control for session local_<sessionA>
16:09:49 Mapping internal session local_<sessionA> to CLI session <cli-session-id>
16:09:59 [CCD start-timing] local_<sessionA> … mcp_count=11
16:13:26 [Stop hook] Query completed for session local_<sessionA>
16:13:26 [WarmLifecycle:session] Starting idle timeout for local_<sessionA>: 900s
16:13:26 [WarmLifecycle:preview] Starting idle timeout for local_<sessionA>: 1800s
16:13:26 [CCD CycleHealth] healthy cycle for local_<sessionA> (412s, hadFirstResponse=true)
16:28:26 [WarmLifecycle:session] Idle timeout reached, disconnecting local_<sessionA>
16:28:26 [WarmLifecycle:session] Starting idle timeout for local_<sessionA>: 900s
16:43:26 [WarmLifecycle:session] Idle timeout reached, disconnecting local_<sessionA>
16:43:26 [WarmLifecycle:session] Starting idle timeout for local_<sessionA>: 900s
16:58:26 [WarmLifecycle:session] Idle timeout reached, disconnecting local_<sessionA>
16:58:26 [WarmLifecycle:session] Starting idle timeout for local_<sessionA>: 900s
…repeats every 900s indefinitely…
Two things to note:
- The work succeeded.
[Stop hook] Query completedand
[CCD CycleHealth] healthy cycle (412s, hadFirstResponse=true) at 16:13:26. The task's
output file was written at 16:13. This is not a wedge or a hung tool call — the session has
nothing left to do and still does not exit.
disconnectingis immediately followed by a re-arm in the same second. Whatever
disconnecting does, it does not terminate the process, and it schedules another 900s
timeout instead of ending the lifecycle.
The Destroyed path is never invoked for sessions
Aggregating every WarmLifecycle line in the log:
| count | line |
|------:|------|
| 4489 | WarmLifecycle:session] Starting idle timeout for local… |
| 3924 | WarmLifecycle:session] Idle timeout reached, disconnecting local… |
| 592 | WarmLifecycle:preview] Starting idle timeout for local… |
| 302 | WarmLifecycle:preview] Warming up session local… |
| 26 | WarmLifecycle:session] Warming up session local… |
| 15 | WarmLifecycle:preview] Idle timeout reached, disconnecting local… |
| 1 | WarmLifecycle:session] Initialized |
| 1 | WarmLifecycle:preview] Initialized |
| 1 | WarmLifecycle:cowork] Initialized |
| 1 | WarmLifecycle:cowork] Destroyed |
WarmLifecycle:cowork] Destroyed exists, so a teardown/destroy path is implemented for at
least one lifecycle. WarmLifecycle:session] Destroyed never appears — not once in 3924
disconnects. The session lifecycle appears to have no terminal state.
Note also the asymmetry between :session and :preview: the preview timer fires only 15
times against 592 arms (it gets cancelled/reset normally), while the session timer fires 3924
times. The runaway is specific to :session.
Corroboration
The worst-affected session logged 303 disconnect/re-arm cycles.
303 × 900s = 272,700s = 75.75 hours.
The oldest leaked OS process at cleanup time had an elapsed time of 3d 04:02 = 76.0 hours.
The cycle count matches the process age almost exactly, confirming the session looped
continuously from spawn until it was killed manually, never once exiting on its own.
Impact (single machine, ~3 days of accumulation)
| | before cleanup | after |
|---|---|---|
| Load average (10 cores) | 37.39 | 3.12 |
| Swap used | 13.4 GB of 14.3 GB | 5.75 GB |
| Free RAM | 69 MB of 16 GB | 1.4 GB |
| Claude session processes | 29 | 9 |
| MCP helper processes | 191 | 37 |
| Total processes | 846 | 591 |
| Local Next.js app response | 11.0 s | 0.09 s |
The leaked sessions were not idle — each sat in state R consuming 3–8% CPU, which is what
produced load average 37. The user-visible symptom was an unrelated local web app taking 11
seconds to serve a page, because the machine was thrashing on swap.
Expected behavior
After Idle timeout reached, disconnecting <sid>, a session with no connected client and no
in-flight work should terminate its process and release its MCP servers — or at minimum the
re-arm should be bounded rather than unconditional.
Secondary observation (probably a separate path)
Two sessions logged [CCD] Skipping pause for session <sid> - N active background task(s)
repeatedly, blocking pause because background tasks never drained. This affected only 2 of the
~29 leaked sessions, so it is not the main cause, but it may be a second route to the same
"session never reaches a terminal state" outcome.
Workaround in place
An hourly launchd agent reaps sessions matching all three of: the scheduled-task argv profile
(--model default + --permission-mode default + no --effort), a descendant tree containing
only MCP helpers, and age > 3h.
Related issues (not duplicates)
- #73388 — "Agent-mode session silently drops mid-turn after tool_result on unattended Mac (WarmLifecycle idle_timeout suspected)". Same subsystem, opposite symptom: that session ends when it shouldn't; here it never ends.
- #77387 — references the 15-min warm idle timeout in the remote-control path.
- #73063 — idle session never re-registers remote control.
The common thread across these may be that WarmLifecycle:session treats "disconnect" and "terminate" as the same transition when they are not.
3 Comments
The re-arm behavior you're seeing -- idle timeout fires, logs the disconnect, then immediately starts a fresh 15-minute countdown -- suggests the idle-timeout handler is resetting the timer rather than terminating the session. The process stays alive and the loop repeats indefinitely.
The resource leak compounds fast at a 4-hour cron cadence: each leaked session holding 11 MCP servers and ~150MB resident means 3-4 leaked sessions could exceed 600MB before anything restarts, and the MCP server processes accumulate as orphans since they're tied to the session process tree.
One thing worth checking: does the leaked process show any sign of activity in the daemon logs between the disconnect/re-arm cycles? If the idle timeout is firing because the process isn't fully idle (some background keepalive or heartbeat counts as "activity"), the fix is different than if the timer is simply misconfigured to re-arm on timeout instead of terminate.
For the immediate workaround -- forcibly killing the leaked claude processes between cron runs via a cleanup step at the start of the next scheduled task would stop the accumulation while waiting for a fix.
Windows reproduction, plus two API-level observations not yet in this thread.
Confirming this on Windows 11 — Claude desktop
1.28929.0,claude-code2.1.222. Same signature as the macOS report, so it is not platform-specific.Scale
From a single
main1.log(host uptime at measurement ~78 h, 8 scheduled lanes per night):[WarmLifecycle:session] Idle timeout reached, disconnecting <sid>Starting idle timeout for <sid>: 900sfor the same sessionclaude-codeprocesses measured at the same momentLocalSessions.startappears 108 times in the same log; no stop / kill / terminate / dispose counterpart is logged at all. This also corroborates your:sessionvs:previewasymmetry from the other end: 17,038 session arms vs 650 preview arms.Also worth noting: the arming policy logs as
[WarmLifecycle:session] Initialized (arm=when-hidden), so a visible/active session keeps re-arming on activity and never reaches the disconnect branch at all — the runaway only begins once a session is hidden, which is exactly the scheduled-task case.New —
archive_sessioncannot reap a leaked sessionThe
ccd_session_mgmtMCP exposesarchive_session, documented as "Archiving stops the session's process and (by default) cleans up its worktree." Against a leaked session it is a no-op: I archived one, waited 4 minutes, and both theclaude-codeprocess count (52) and committed memory were unchanged.That appears to be the same root cause seen from the API side — once
disconnectinghas run, the app no longer holds a handle to the process, so the one sanctioned "stop this session" entry point has nothing to stop.New — leaked sessions report
isRunning: falselist_sessionsreturned every session asisRunning: falsewhile 52 processes were live. SoisRunningdescribes the transport channel rather than the process, and the app's own session list reports a clean state while the leak is invisible to it. Anything built onlist_sessionsto find or clean up stale sessions will find nothing.Relatedly, the processes carry no session binding in argv — only config UUIDs, no
--resumeor--session-id— so once the app drops its side there is no way to map a surviving process back to its session externally either. Between that andisRunning: false, a leaked session is unreachable from both the API and the OS side.Cost on Windows
| | |
|---|---|
|
claude-codeprocesses | 52 × ~680 MB ≈ 32.9 GiB committed || CPU per leaked process | ~28 CPU-sec/hour, sustained (matches your "not idle" observation) |
| MCP helper processes | 60 python (1.8 GiB) + 49 node (2.4 GiB) = 4.2 GiB |
| After a reboot | 1 process, ~40 GiB reclaimed |
One difference in emphasis from your report: on Windows the session processes are the cost, not the MCP servers — 32.9 GiB vs 4.2 GiB. The MCP helpers multiply with the session backlog but are a symptom; reaping sessions reclaims almost everything.
Endorsing the expected behavior
Agreed on the fix direction. Adding one point: a bounded re-arm alone would help, but it would still leave the API gap —
archive_sessionandisRunningshould also reflect process liveness, not just channel state, or the documented cleanup path stays a no-op against exactly the sessions that need cleaning.Addendum to my comment above —
archive_session's _other_ documented side effect also silently no-ops, and this one is easier to observe than the process leak.The tool is described as: "Archiving stops the session's process and (by default) cleans up its worktree."
I archived a session whose
cwdwas a git worktree. Neither half ran:git worktree listafterwards, at 448 MBarchive_sessionreturned a success string (Archived session local_<sid> ("<title>")) in both cases, so there is no error surfaced to the caller — the session moves to Archived in the UI while none of its documented cleanup happens.Why this is a useful test case: a leaked process is invisible without going to the OS, but a leaked worktree is visible from
git worktree listand costs hundreds of MB each. If sessions are being archived programmatically at any volume, worktree-backed ones accumulate silently. Same underlying cause as far as I can tell — oncedisconnectinghas run, the app no longer has the handle it needs to perform any teardown, so every documented side effect of archiving becomes a no-op against exactly the sessions that most need it.Cleaning up manually (
git worktree remove --force) was safe in my case but only because I could verify the branch was merged and the working-tree change was byte-identical to what had already landed onmain. A user trusting the documented behavior would have no reason to check either.