Monitor/TaskStop removes the task from the panel but leaves the spawned process running (orphaned tail -F accumulates)
Summary
When a Monitor task ends — either by hitting its timeout_ms or via TaskStop — the entry disappears from the tasks panel, but the process it spawned keeps running. Re-arming the same monitor every 30 minutes therefore accumulates one orphaned process per cycle, indefinitely.
Same behaviour for background Bash tasks: TaskStop removes the panel entry without terminating the child process.
Environment
- Claude Code 2.1.220, desktop app (Code tab)
- Windows 10.0.19045, Git Bash (
Bashtool) - Long-running session using
Monitorto watch a log file
Reproduce
- Start a background process that writes to a log (e.g.
Bashwithrun_in_background: true). - Arm a watcher:
Monitor → command: tail -n0 -F /path/to.log | grep --line-buffered -E '^\*\*\*', timeout_ms: 1800000
- Wait for the 30-minute timeout (or call
TaskStop). - The task disappears from the panel. Now check the process table:
ps -ef | grep 'tail -n0 -F'
- The
tailis still running. Re-arm the monitor and repeat — each cycle leaves another one.
Actual vs expected
- Actual: panel entry removed, child process alive and holding the file open. Nothing in the UI indicates it still exists.
- Expected: on timeout or
TaskStop, the spawned process (ideally the whole process group) is terminated.
Measured impact
In a session that re-arms a log watcher every 30 minutes:
- 10 live
tail -n0 -F <same log>processes while exactly one Monitor was active (observed 2026-09-17 08:47). - Later the same day, 8 more had accumulated after the session that created them had ended; all 8 confirmed dead only after an explicit
kill+ verification. - Killed processes were verified with
kill -0afterwards, because a plain "no output from grep" is not proof of death.
Every leaked tail -F keeps a handle on a log file that the session no longer reads, and on Windows they re-parent so they are easy to miss. Because the panel is empty, there is no signal that cleanup is needed — the only way to find them is to inspect the process table manually.
Workaround
A helper that lists watcher processes (ps -ef, not ps aux — under Git Bash ps aux has no COMMAND column, so ps aux | grep … silently returns nothing and looks clean) and prints ready kill lines, plus a rule that TaskStop is treated as part of the kill rather than the cleanup itself:
TaskStop <task_id> AND kill <PID> # both, in the same step
Note
This is easy to mistake for correct behaviour, because the missing panel entry reads as "the task is gone". It only becomes visible when something else counts the processes.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Same symptom on Windows, with a reproducible mechanism that none of the related reports names. Tested on Claude Code 2.1.274 (the field case below happened on 2.1.272), Windows 11 26200.9457, Git for Windows bash.
Mechanism
Git Bash is MSYS2. When bash runs an MSYS program (a shell script such as
npx, ortail/grep/cut), the exec emulation creates a new Windows process and the old one exits. The new process's recorded parent PID is dead from the moment it starts, so the Windows parent/child chain is broken at that hop.TaskStop (and background-task cleanup generally) appears to terminate along the Windows parent chain starting from the shell it spawned. Everything below the break survives, and the task is still reported as
Successfully stopped.Below the break the chain is intact: a single
taskkill /T /Fon the surviving root kills the whole subtree. That also rules out a job object being used — job membership is inherited at creation and would have caught these processes regardless of the broken parent link.Minimal repro
Each command was started with
run_in_background: true, then stopped with TaskStop:| Command | Chain before stop | After stop |
|---|---|---|
|
node -e "setInterval(()=>{},1000)" 2>&1 \| tail -40| intact | everything killed ||
./fake-npx wrangler dev 2>&1 \| tail -40wherefake-npxis a bash script that runsnode→cmd /d /s /c node …→node→ 2 childnode| broken at the bash script | 9 processes survived || same tree, entry point changed to
fake-npx.cmd(a batch file) | intact | everything killed |Note that
npxon Windows resolves to a bash script under Git Bash, sonpx <anything long-running>always lands on the broken path, whilenpx.cmddoes not.Field case
A subagent started
npx wrangler dev --port 8799 --local 2>&1 | tail -40four times withrun_in_background: trueand called TaskStop after each one. All four calls returnedSuccessfully stopped. Three and a half days later all four trees were still running: 45 processes, ~3.4 GB private memory, ~6 CPU-hours, and the fouresbuildwatchers had issued roughly 1e9 file-metadata operations between them (~3,300/s combined, measured viaOtherOperationCount). Separately, threetail -F | grep | cutmonitor pipelines from another session survived the same way —tail,grepandcutare MSYS programs, so they break the chain by themselves.Nothing else reaps these: Windows has no cascading process-group kill,
wrangler devnever exits on its own, andtailwaits forever for an EOF that the surviving writer never sends. The stale servers even released their listening port, so the next launch on the same port succeeded and the caller had no signal that anything had leaked.Suggested fix
Assign each Bash-tool shell to a Windows job object created with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEand without silent breakaway, then close the job on timeout/TaskStop/session exit. Job membership is inherited by every descendant no matter what happens to parent PIDs, so it is immune to the MSYS exec break. Verify the descendants are gone before reportingSuccessfully stopped.Measured follow-up to @LilPoppy's analysis, which matches what I see — with one addition that narrows the viable fix.
**In an MSYS-only tree the chain is broken at every hop, not just the first.**
My leaked processes are
tail,grepandsleep— all MSYS programs. I built the treebash script.sh→tail -n0 -F … | grep --line-buffered …+ twosleep, then enumerated descendants the Windows way (Win32_Process.ParentProcessId) starting from the root's Windows PID:tailprocess's WindowsParentProcessIdpointed at a PID that no longer existedSo for these trees there is nothing below the break to walk: every hop re-parents.
taskkill /T /Fon the surviving root is blind to them for the same reason. That still holds in the node/npx case from the comment above, where the native part of the tree is intact — but it means a fix must not rely on walking the tree at stop time.Consequence for the suggested fix: the job object has to be created and assigned when the shell is spawned, not derived at TaskStop/timeout. Job membership is inherited across the MSYS exec break, so it is the only mechanism here that survives it; anything that enumerates descendants first will miss exactly the processes that leak.
Workaround that does work today, for anyone cleaning up manually: enumerate the tree with MSYS
ps -ef(itsPPIDcolumn is intact and knows the real parentage), map each PID to its Windows PID via theWINPIDcolumn of plainps, assign all of them to one job object withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, then close the handle and verify. Tested: root +tail+grep+ 2×sleep→ 5/5 gone from a single root PID.Minor detail for the
tail -F | grepcase specifically: killing thetailalone is enough, becausegrepthen gets EOF on the pipe and exits — measured. So a re-armed log watcher leaks one process per cycle, not two. Processes that EOF does not reach (sleep, an inline loop,node) are the ones that accumulate.Environment: Claude Code 2.1.220, Windows 10.0.19045, Git for Windows bash.
Still present on 2.1.278 (newest version named in this thread so far), Windows 11 26200.9457, Git for Windows bash. Adding handle-level forensics that corroborate @Pioszym's "chain broken at every hop" measurement from a different angle, plus one escape hatch that turns out to be closed too.
Case: 10 orphans (5 ×
tail -f <file> | grep -E --line-buffered <pat>) alive for ~34 h at 0 % CPU delta over a 3 s sample, ~51 MB combined working set. All 10 had deadParentProcessIds.1. Re-parenting signature. The two halves of a single pipeline, spawned in the same second by the same subshell, have different dead PPIDs — e.g.
tail18012 → PPID 10788,grep29540 → PPID 38720. Sibling-level divergence, consistent with the MSYS exec break re-parenting each hop independently.2. Why the pair cannot self-terminate. A system-wide handle search shows both halves on the same MSYS pipe:
grepblocks onread()and will only get EOF when the last write handle closes — held bytail, which never exits by design. This is the same fact as "killingtailalone is enough", measured from the handle side.3. The EPIPE escape is closed as well. Their stdout/stderr are libuv pipes owned by the still-running session process:
So no broken-pipe teardown either. And even if that process exited, neither orphan would notice: both are blocked on read/sleep and never attempt another write —
tail's target file stopped growing 34 h ago. Worth noting the spawning session never ended and still considers these tasks finished, so the leak happens at task end, not only at session end (cf. #92583).4. On
taskkill /T /F. In a sandbox repro where the PPID chain stayed intact (bash→bash→tail/grep),taskkill /PID <root> /T /Fdid clean all four. On the real orphans it would find nothing, for exactly the reason measured above. Supports the conclusion that the job object has to be assigned at spawn time rather than derived at stop time.Command-level workaround that needs no harness change, for anyone hitting this with log watchers: GNU
tailin Git Bash supports--pid, somakes the pipeline self-terminating —
taildies with the shell,grepfollows on EOF. Verified on Git for Windows.