Background bash tasks (run_in_background polling loops) get stuck post-completion and across sessions; TaskStop can't reach prior-session IDs
Environment
- Claude Code CLI (Opus 4.7 1M context backend)
- Linux (WSL2) host: bash invocations launched via the
Bashtool withrun_in_background: true. - Reproduced repeatedly across a multi-hour session involving Windows dist builds (PyInstaller → Inno Setup) where the Linux-side agent polls
/mnt/d/temp/<name>-build.logfor"Exit code:"to know when the cmd.exe build finishes.
Summary
Bash background tasks dispatched as until grep -q "Exit code:" <log>; do sleep N; done; tail -3 <log> polling loops:
- Frequently fail to terminate after their exit condition becomes true. The log file does contain
"Exit code: 0"but the loop's lastgrepreturned non-zero before the file was written, and after the nextsleep Ncycle the kernel never delivered SIGCHLD or the harness never resumed the foreground reader, so the loop stays parked on itssleepforever.
- Survive across context-window compaction / agent rolls. A shell I dispatched on May 2 (PID 225159) was still running on May 3 at 18:04 in a different context-window session. Its command was
until [ ! -e /proc/\$(pgrep -f boyypu0xm 2>/dev/null) ] 2>/dev/null; do sleep 2; done— waiting for a process whose task ID was from a prior session and had long since terminated. Thepgrepreturned empty,[ ! -e /proc/ ]was true (test on empty arg evaluates the path/proc/, which exists, so the until condition is false → infinite loop). Even ignoring the empty-arg edge case, the wait was for a process that had been gone for >24 hours.
TaskStopcannot reach prior-session IDs. When I tried to clean up the four stuck tasks the user surfaced from their UI:
TaskStop bkwkmgk2z → success
TaskStop bggjhb076 → success
TaskStop btyayj726 → success
TaskStop b7811r12n → \"No task found with ID\"
TaskStop boyypu0xm → \"No task found with ID\"
TaskStop bv2w06y5n → \"No task found with ID\"
TaskStop bt2w37i5u → \"No task found with ID\"
The task IDs that failed are real on-disk entries (/tmp/claude-1001/.../tasks/<id>.output exists for each), they just aren't in the current session's task table. The orphan from May 2 had to be killed with a raw kill 225159 — there was no in-tool way to terminate it.
Repro pattern
# Run a Windows dist build in the background; poll for completion.
Bash(command='cmd.exe /c \"D:\\\\temp\\\\build.bat\" > /tmp/run.out 2>&1 &',
run_in_background=True)
Bash(command='until grep -q \"Exit code:\" /mnt/d/temp/build.log 2>/dev/null; do '
'sleep 10; done; tail -3 /mnt/d/temp/build.log',
run_in_background=True)
Build finishes ~4 minutes later. The first task always reports completion via the system-notification stream. The second task — the polling loop — sometimes reports completion, sometimes doesn't. The user sees it stuck under \"Background tasks\" indefinitely; the agent has no signal that it's stuck.
Across a session boundary the situation gets worse: the loop's task ID no longer matches the new session's table, so the agent can't TaskStop it. It just sits.
Impact
- Resource leak: a sleeping bash + a sleep child per stuck task, persistent across days. Trivial CPU but the kernel reaps them only on host reboot.
- UX noise: the user's \"Background tasks\" pane shows phantom \"running\" entries. Uncertainty about whether the agent's actual work is done — \"are these still doing something? did the agent crash mid-build?\"
- Trust gap: the agent confidently said \"all four shipped, dist 1.7.39\" while four wait-loops the agent itself dispatched were still consuming task slots. The user (rightly) flagged it. Working as documented vs working in practice.
Suggested fixes
- Reap-on-PID-exit, not poll-on-stdout. The harness already tracks the bash PID for each background task. When that PID exits the kernel reports it via SIGCHLD; the harness should mark the task completed at that moment regardless of whether the bash printed anything terminal-marker-shaped.
- Cross-session reconciliation. On agent boot, walk
/tmp/claude-<uid>/.../tasks/. For each<id>.output, check whether the originally-recorded PID still exists. If not, mark the task completed and surface the result. Currently they linger as \"running\" forever.
TaskStopshould accept any on-disk task ID. If/tmp/claude-*/tasks/<id>.outputexists, the harness knows about that task; it should be able to look up the PID via the file's metadata or a sidecar<id>.pidand kill it. Refusing with \"No task found with ID\" leaves the user no in-tool recovery path.
- Discourage
until ...; sleep Npolling loops in tool docs. TheBashtool description already nudges agents towardMonitorfor streaming andrun_in_background: truewith a \"command that exits when the condition is true\" for one-shot waits. The first option works. The second option only works reliably when the loop body's grep/test is bulletproof, and bash'suntilsemantics with edge-cases likepgrepreturning empty ([ ! -e /proc/ ]evaluates to false because/proc/always exists) silently break the contract. Detect the pattern in tool-call validation; warn the agent.
- Foreground sync wait should be a first-class option. The agent writes polling loops because a synchronous foreground wait that survives the 2-minute Bash default timeout isn't obvious. A
Bash(command=..., wait_for_file_pattern=..., timeout=600000)parameter would let agents express \"block until this file contains this string\" without dispatching a polling shell. Cleaner contract.
Repro stickiness
Reliable. In a 6-hour session today I left at least 5 polling loops stuck; the user surfaced 4 from their UI, I found a 5th from a previous session's bash via ps -ef --forest. None recovered on their own. None could be cleaned up via TaskStop once the task ID rolled out of the current session.
Logs / IDs
The four stuck IDs from this session (the user's screenshot showed these as \"running\" in the Background tasks pane):
bkwkmgk2z—until ! grep -q \"Exit code:\" /mnt/d/temp/757a-build.log; sleep 10; done; tail -3 ...bggjhb076—until grep -q \"Exit code:\" /mnt/d/temp/pr7-build.log; sleep 10; done; tail -3 ...b7811r12n— same as above (duplicate dispatch from a different turn)- one watching
boyypu0xmfrom a prior session
Build logs in question reached \"Exit code: 0\" 20+ minutes before the user surfaced the issue. The polling loops' last grep cycle landed before the log was written; the next was scheduled \"+10s later\" and apparently never fired.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗