Cowork scheduled tasks on Windows leave claude.exe processes alive after completion, causing memory accumulation

Status Open
Maintainer reply None cached
Activity 9 comments · opened May 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Note: this report is about Claude Cowork (research preview, Windows desktop), not the Claude Code CLI. Filing here because Cowork is built on Claude Code / Agent SDK infrastructure and there's no dedicated Cowork repo.

On Windows, Cowork scheduled tasks appear to leave their claude.exe worker processes alive after the task prompt finishes. Over time these accumulate and consume significant RAM and committed memory. Manually killing all claude.exe processes fully reclaims the memory, confirming none of them are still doing useful work.

After ~24 hours of operation with several active schedules I observed:

  • 16 claude.exe processes alive simultaneously
  • ~4.3 GB total working set across those processes
  • Process start times spanning the previous 24 hours, no gaps corresponding to task completions
  • Killing every claude.exe reclaimed ~7.7 GB committed and ~3 GB physical RAM in one shot
  • Same leak does not occur from interactive chat sessions — only scheduled-task invocations

Diagnostic detail: each claude.exe and its descendants (Node MCP host, child shells) live in a Windows Job Object with LimitFlags = 0x3C00 (DIE_ON_UNHANDLED_EXCEPTION | BREAKAWAY_OK | SILENT_BREAKAWAY_OK | KILL_ON_JOB_CLOSE). The KILL_ON_JOB_CLOSE flag means the OS would reap the whole job once the job handle is released — so the leak is upstream of the OS: something in Cowork's scheduled-task lifecycle is keeping the worker claude.exe (and therefore the job handle) alive after the task prompt has returned.

What Should Happen?

When a Cowork scheduled task finishes executing its prompt, the claude.exe worker process that was spawned for that task should exit and release its memory back to the OS. The steady-state claude.exe process count and total memory footprint should remain roughly constant regardless of how many scheduled tasks have fired over the previous day.

Error Messages/Logs

No error is surfaced by Cowork — the leak is silent. The only observable signal is external: the `claude.exe` process count grows over time and total working-set/committed memory climbs into the multiple-GB range.

Observed snapshot from `Get-Process claude`:
- 16 processes alive at once after ~24 h
- Earliest process start time matched the start of the day; newest matched the most recent scheduled-task invocation
- After killing all `claude.exe` processes and letting Cowork relaunch normally: 10 fresh processes, ~2.2 GB total — i.e. 16 - 10 = 6 truly orphaned workers carrying ~2 GB of dead weight.

Steps to Reproduce

  1. On a Windows machine running Cowork, configure several scheduled tasks at different cron intervals — for example one every 30 minutes, one hourly during business hours, one daily early-morning, and one weekly. Five to ten tasks is enough to show the pattern clearly.
  2. Let the machine run uninterrupted for 12 to 24 hours.
  3. Periodically run Get-Process claude in PowerShell and track the process count and total working-set memory:

``powershell
Get-Process claude | Measure-Object WorkingSet64 -Sum |
Select-Object Count, @{n='TotalMB';e={[math]::Round($_.Sum/1MB,1)}}
``

  1. Observe that the process count grows monotonically over time and total memory climbs. Expected: roughly flat. Observed: linear growth in both.
  2. Confirm the leak is on Cowork's side, not the OS, by killing every claude.exe process (Get-Process claude | Stop-Process -Force) — the memory is reclaimed immediately and the next normal Cowork launch returns to a small steady-state footprint.

Claude Model

None

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

Claude for Windows build 1.8555.2 (a476c3)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

PowerShell

Additional Information

Current workaround

A daily scheduled kill of all claude.exe processes restores normal memory usage. Not ideal as a long-term solution because it briefly interrupts any in-progress interactive session and forces a relaunch.

Suggested investigation

  • Verify the scheduled-task runner is awaiting the completion handler and exiting the worker claude.exe after the task prompt returns.
  • Check whether errors during MCP server teardown (e.g., a connector tool failing to disconnect cleanly, or an MCP child still doing I/O) prevent the worker from exiting and leave the process spinning idle. Several of my scheduled tasks talk to MCP servers (Microsoft 365, CRM, local desktop shell) and the leak rate seems proportional to how many tasks invoke MCP tools.
  • Consider an idle-timeout safety net: if a scheduled-task worker has been alive for more than N minutes past its prompt completion, terminate it.

Impact

For users running several Cowork scheduled tasks on a workstation, this silently consumes several GB of RAM per day. On lower-spec laptops this is enough to noticeably degrade overall responsiveness within a day or two of normal use.

View original on GitHub ↗

7 Comments

jshaofa-ui · 3 months ago

Issue Summary

Title: Cowork scheduled tasks on Windows leave claude.exe processes alive after completion, causing memory accumulation
Labels: bug, has repro, platform:windows, perf:memory, area:cowork
Competition: 0 comments (zero competition)
Priority: 🔴 High — memory leak affecting long-running Cowork deployments

Root Cause Analysis

The diagnostic information in the issue is critical:

  • Each claude.exe and its descendants live in a Windows Job Object with LimitFlags = 0x3C00
  • KILL_ON_JOB_CLOSE flag is set, meaning the OS would reap the whole job once the job handle is released
  • The leak is upstream of the OS: something in Cowork's scheduled-task lifecycle keeps the worker claude.exe (and therefore the job handle) alive after the task prompt has returned

This means the process lifecycle management code is not calling the cleanup/teardown path after a scheduled task completes. The job handle reference is being held, preventing the OS from cleaning up the process tree.

Likely Culprits

  1. Missing CloseHandle() on the Job Object handle — the scheduled task manager holds a reference to the job handle after task completion
  2. Event loop not terminating — the claude.exe worker's event loop continues running after the task prompt returns (no exit signal sent)
  3. MCP child processes keeping parent alive — Node MCP hosts or child shells spawned during the task hold references that prevent parent exit
  4. Missing task completion callback — the scheduled task framework doesn't invoke the cleanup handler on normal completion (only on error paths)

Proposed Fix

Fix 1: Explicit Process Termination on Task Completion (Primary)

In the scheduled task completion path, ensure the worker process is explicitly terminated:

// In scheduled-task-manager.ts (or equivalent)
async function completeScheduledTask(taskId: string) {
  const task = this.tasks.get(taskId);
  if (!task) return;
  
  try {
    // ... existing completion logic ...
    await task.completionPromise;
  } finally {
    // CRITICAL: Always terminate the worker process on completion
    await this.terminateWorkerProcess(task.workerPid);
    this.cleanupJobHandle(task.jobHandle);
    this.tasks.delete(taskId);
  }
}

async function terminateWorkerProcess(pid: number): Promise<void> {
  try {
    // Use taskkill to ensure full process tree termination
    await execAsync(`taskkill /PID ${pid} /T /F`);
  } catch (err: any) {
    // Process may already be dead — that's fine
    if (err.code !== 128 /* process not found */ && err.code !== 1) {
      this.logger.warn(`Failed to terminate worker ${pid}: ${err.message}`);
    }
  }
}

function cleanupJobHandle(jobHandle: Handle): void {
  try {
    // Explicitly close the Windows Job Object handle
    // This triggers KILL_ON_JOB_CLOSE for any remaining processes
    CloseHandle(jobHandle);
  } catch (err: any) {
    this.logger.warn(`Failed to close job handle: ${err.message}`);
  }
}

Fix 2: Periodic Orphan Process Sweep (Defense in Depth)

Add a background sweep that detects and cleans up orphaned claude.exe processes:

// Periodic sweep every 5 minutes
setInterval(async () => {
  const activePids = new Set(
    [...this.tasks.values()].map(t => t.workerPid)
  );
  
  const allClaudePids = await getProcessPidsByName('claude.exe');
  for (const pid of allClaudePids) {
    if (!activePids.has(pid)) {
      // Orphaned process — check if it's idle
      const cpuUsage = await getProcessCpuUsage(pid);
      if (cpuUsage < 0.5) { // Less than 0.5% CPU = likely idle
        this.logger.info(`Sweeping orphaned claude.exe process ${pid}`);
        await execAsync(`taskkill /PID ${pid} /T /F`);
      }
    }
  }
}, 5 * 60 * 1000);

Fix 3: Job Object Handle Lifecycle Management

Ensure the Job Object handle is scoped to the task lifecycle:

// Use a scoped handle that auto-closes
class ScheduledTaskWorker {
  private jobHandle: HANDLE | null = null;
  
  async start(): Promise<void> {
    this.jobHandle = CreateJobObject(null, `ClaudeCowork-${this.taskId}`);
    SetInformationJobObject(
      this.jobHandle,
      JobObjectExtendedLimitInformation,
      { LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE }
    );
    
    // Assign the worker process to the job
    AssignProcessToJobObject(this.jobHandle, workerProcessHandle);
  }
  
  async stop(): Promise<void> {
    // Explicitly close the job handle — this triggers KILL_ON_JOB_CLOSE
    if (this.jobHandle) {
      CloseHandle(this.jobHandle);
      this.jobHandle = null;
    }
  }
}

Testing Methodology

  1. Reproduce the leak:
  • Configure 10+ scheduled tasks on Windows
  • Let them run over 24 hours
  • Verify claude.exe process count grows beyond active task count
  1. Verify fix:
  • Apply the fix
  • Run the same workload
  • Verify process count matches active task count at all times
  • Verify memory usage stays bounded
  1. Edge cases:
  • Task that crashes mid-execution (should still clean up)
  • Task that spawns long-running MCP servers (should terminate children)
  • Rapid task scheduling (no race conditions in cleanup)

Impact Assessment

  • User Impact: High — memory accumulation degrades system performance over time, eventually requiring manual intervention
  • Severity: Memory leak with no automatic recovery
  • Risk of Fix: Low — adding explicit cleanup is a safe change; the KILL_ON_JOB_CLOSE flag already ensures cleanup on handle close
  • Estimated Effort: 2-4 hours (primarily testing on Windows)

Competitive Advantage

  • Zero competition (0 comments)
  • has repro label means the bug is well-documented
  • Windows-specific — many contributors focus on macOS/Linux
  • Memory leak with clear diagnostic data (Job Object flags)
abhinas90 · 3 months ago

This is a classic process lifecycle management gap — not a one-off bug. When scheduled task workers don't have a parent process monitor with a hard kill timeout, zombie processes are inevitable across any LLM runtime on Windows.

Diagnostic matrix for anyone hitting this:

  1. Parent monitor check: Does the Cowork launcher register as a job object? Windows Job Objects auto-terminate all child processes when the parent exits. If Cowork isn't using CreateJobObject + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, every scheduled run is a leak risk.
  1. Process tree audit: Run wmic process where "name='claude.exe'" get ProcessId,ParentProcessId,CreationDate after a scheduled task completes. If orphans remain with dead parent PIDs, you're seeing the root cause.
  1. Timeout wrapper: The quickest mitigation is wrapping the Cowork invocation in a PowerShell script that starts a watchdog timer. If runtime > expected, Stop-Process -Name claude -Force as cleanup.
  1. Build-time hardening: For teams deploying agent fleets on Windows, this class of issue (zombie processes, handle leaks, unclosed named pipes) becomes the #1 reliability blocker at scale.

I've been hardening multi-agent deployments on Windows and this exact pattern comes up repeatedly. Happy to share the process-lifecycle checklist we use if it'd help.

(Not affiliated with Anthropic — just deep in the agent ops trenches.)

Keesan12 · 3 months ago

This is a place where the process tree needs a hard cleanup contract. On Windows, tying the scheduled task to a job object and explicitly closing the child tree on completion or cancel would keep the parent from accumulating zombie work. A visible exit receipt would make leaks obvious during test runs.

cellison-hashbrowns · 2 months ago

Cross-reference: I filed #66647 earlier today (now closed as a dup of this). Claude (the assistant doing the search) missed this issue when checking for prior reports — it searched on "stream-json", "does not exit", "cron", and "scheduled task" keywords and surfaced #1920, #24478, #24481, #25629, but not #62107. Apologies for the noise; the dup bot caught it correctly.

Posting the additional diagnostic from #66647 here since it complements what's already on this thread.

Process is stuck on stdin EOF, not on any internal work

Captured on a freshly-leaked process about 2 minutes after the cron tick (Windows 11, claude.exe 2.1.165, parent = Claude desktop main):

  • Cumulative CPU: 5 s total, 0 active.
  • All 28 threads in Wait state. Wait-reason histogram is Unknown × 20 (Node libuv parked on GetQueuedCompletionStatus) + EventPairLow × 5 + UserRequest × 2 + Executive × 1.
  • Structurally identical to an interactive session sitting at the prompt — same thread count, same wait shape. The only difference is that no one is going to send the cron-spawned one more stdin.
  • No real children — only conhost.exe. No hung wsl, kubectl, bash, python, etc.

So it's not a hung tool call, not a stuck MCP server inside the worker, not a deadlocked promise — it's just the libuv event loop waiting on stdin that the parent never closes.

The smoking-gun command line

From Win32_Process.CommandLine on a leaked cron-spawned process:

claude.exe --output-format stream-json --verbose --input-format stream-json
           --model default --permission-prompt-tool stdio
           --allowedTools mcp__computer-use,mcp__ccd_session__*
           --disallowedTools AskUserQuestion
           --setting-sources=user,project,local --permission-mode default
           --include-partial-messages --plugin-dir <...>
           --replay-user-messages --settings {}

--input-format stream-json with no termination signal flag. The scheduler writes the prompt as stream-json frames to stdin, consumes the response, then keeps stdin open. No EOF → libuv has nothing to drain → process parks forever.

Likely-related side effect: scheduled-tasks.json unbounded growth

The scheduler's recordedSkips array grows by one entry per minute per task whenever it skips a fire due to per_task_limit — which it does because it (correctly!) sees the prior run as "still alive." Observed 1,599 stale entries / 128 KB in %APPDATA%\Claude\claude-code-sessions\<id>\<sub>\scheduled-tasks.json after a few days. Plausibly the same root cause: fix the exit and the skip-spam goes away.

Suggested fixes (ranked)

  1. Scheduler closes stdin on the child once the response stream has been consumed. Cleanest. No CLI changes; also stops recordedSkips accumulating.
  2. Auto-exit after terminal result frame in --input-format stream-json when stdin is non-TTY. Behind a flag (--exit-on-result) to preserve current behavior for long-lived sessions.
  3. Idle timeout on stdin in stream-json mode (e.g. 60 s without a new frame → exit).

(1) is highest-leverage IMO.

Workaround used in the meantime

End-of-turn detached self-kill from the SKILL.md prompt — delay long enough for the assistant's final stream-json frames and any notifySessionId callback to flush:

$p = $PID
while ($p) {
  $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
  if (-not $proc) { break }
  if ($proc.Name -eq 'claude.exe') { break }
  $p = $proc.ParentProcessId
}
if ($p) {
  Start-Process -WindowStyle Hidden powershell `
    -ArgumentList '-NoProfile','-Command',"Start-Sleep -Seconds 45; Stop-Process -Id $p -Force"
}

Or a Windows-side janitor scheduled task that kills claude-code\*\claude.exe idle > N minutes. Both are band-aids.

itguysocal · 2 months ago

June 18th release notes claim this bug is now resolved in build 1.14271.0
<img width="626" height="584" alt="Image" src="https://github.com/user-attachments/assets/e13120a9-f4fd-4984-8073-0e9b98585294" />

danstern1807 · 25 days ago

Corroborating this on Windows 10 (build 19045 / 22H2) and on much newer versions — it still reproduces on Claude Code 2.1.219 and 2.1.221, so this should probably lose the stale label.

I posted a detailed writeup on #71424 (the macOS report of what appears to be the same bug): https://github.com/anthropics/claude-code/issues/71424#issuecomment-5194707293

What matches this issue exactly:

  • Desktop app scheduled tasks, one on cron 5-55/10 * * * * (every 10 minutes)
  • Every run that does real work leaves its claude.exe alive after the run's transcript is complete and its final report is written
  • Accumulated to 49 processes holding 21 GB of commit charge out of a 40 GB commit limit, leaving 156 MB free — at which point the Electron app crashed, claude --version died with a Bun stack overflow, and PowerShell's ConvertFrom-Json threw OutOfMemoryException on an 8 KB file
  • ~470–550 MB commit per leaked process, ~6 runs/hour ≈ 4 GB/hour
  • Restarting the Desktop app is what reclaims it, exactly as described here

Diagnostic detail that may narrow the cause. A leaked process whose run has demonstrably finished still holds established connections to the API:

PID 29904 (v2.1.221)   commit 469 MB   threads 31 (all in Wait)   CPU 0.5%
TCP: 9 x Established -> 160.79.104.10:443   (api.anthropic.com)

And the control group that makes it sharp: on one day, 58 of 91 scheduled runs died early on API Error: Unable to connect to API (ENOTFOUND) during two network outages, and every one of those exited cleanly. All 33 runs that did real work leaked. Work => leak, no work => clean exit. The early-failing runs never resolved DNS, so they never established the API connection pool — which points at the parent's own undrained keep-alive connections rather than at MCP children holding resources.

Cross-referencing three reports that look like one bug:

| Issue | Platform | Scale |
|---|---|---|
| #62107 (this one) | Windows, Cowork scheduled tasks | 16 processes, 4.3 GB / 24 h |
| #71424 | macOS, local-agent sessions + scheduled tasks | 50+ processes, GBs |
| #77459 | Windows 11, desktop app sessions | 100 processes, 14.66 GB, 23 h+ survival |

Worth highlighting that #77459 is on Windows 11 while this report and mine are Windows 10, and #71424 is macOS — so this is not tied to a Windows version or to one OS at all.

One more point from #77459 that I can independently confirm: updating the CLI does not help, because the Desktop app ships its own bundled CLI on a separate update channel. On my machine ~/.local/bin/claude was 2.1.204 while the app was running 2.1.219 — claude update moved only the former.

Full details, plus a reaping workaround that reliably distinguishes leaked scheduled runs from live interactive sessions (via ~/.claude/sessions/<PID>.json plus the session transcript), are in my #71424 comment linked above.

wellidev · 16 days ago

Still reproducing on current builds, ~3 months after this was filed. Adding a fresh data point plus one detail that may help scope the fix.

Environment

  • Claude Desktop 1.30096.1.0 (MSIX, Windows 11 Pro 26200) — original report was build 1.8555.2
  • Bundled CLI claude-code/2.1.229 — #66647 (closed as dup of this) reported 2.1.165
  • 5 scheduled tasks via mcp__scheduled-tasks, cron */15 across staggered daily windows

Snapshot after one morning (~2h45 of firings)

| | |
|---|---|
| Total claude.exe | 28 processes / 5,379 MB |
| Older than 20 min | 23 processes / 3,247 MB |
| Confirmed-idle runner trees reaped | 11 trees / 2,622 MB reclaimed |

Oldest leaked runner had been alive 2h44m past its firing, at 0 active CPU. Process start times map 1:1 onto cron fires (07:49, 08:04, 08:19, 08:34, 08:49, 09:02, 09:08, 09:25, 09:38, 09:49, 10:08) with none missing — every fire leaks, none self-reap.

Confirming the original report's key asymmetry: interactive sessions in the same app do not leak. My own %LOCALAPPDATA%-installed CLI sessions exited normally throughout, and the Electron helper processes (--type=renderer, --type=gpu-process, --type=utility) are all correctly parented and reaped. Only the scheduled-task runners accumulate.

Current command line still has no exit-on-result path

Captured today from a live leaked runner (trimmed):

claude-code\2.1.229\claude.exe --output-format stream-json --verbose --input-format stream-json
  --model default --permission-prompt-tool stdio
  --allowedTools mcp__computer-use,mcp__ccd_session__* --disallowedTools AskUserQuestion
  --setting-sources=user,project,local --permission-mode auto
  --include-partial-messages --plugin-dir <...> --replay-user-messages --settings {}

Unchanged from what @cellison-hashbrowns captured on 2.1.165 in #66647.

The parent appears to have enough information to know the session is one-shot

--disallowedTools AskUserQuestion is present on every leaked scheduled-task runner and absent from every interactive session, on this machine and (per #80885) on macOS too. That flag is the runner's own declaration that no human will ever answer this session — which is exactly the condition under which stdin can be closed after the final result frame without breaking anything.

Worth flagging because the flag set here is otherwise correct: --input-format stream-json is required for the live --permission-prompt-tool stdio channel and for --include-partial-messages, so this isn't a case of a wrong flag being passed. The gap is purely that nothing signals EOF once the unattended turn is done. That seems narrower than the job-handle path described in the original report, and possibly independent of it.

Workaround for anyone else hitting this

Filtering on the CLI path plus the stream-json signature avoids killing the Electron helpers or your own interactive sessions, and /T takes the MCP children (which otherwise survive as orphans, cf. #71424):

Get-CimInstance Win32_Process -Filter "Name='claude.exe'" |
  Where-Object { $_.CommandLine -like '*claude-code*stream-json*' -and
                 $_.CreationDate -lt (Get-Date).AddMinutes(-20) } |
  ForEach-Object { taskkill /PID $_.ProcessId /T /F }

The 20-minute floor spares an in-flight run. Adjust it above your longest task duration.

Showing cached comments. Read the full discussion on GitHub ↗