Background bash tasks (run_in_background polling loops) get stuck post-completion and across sessions; TaskStop can't reach prior-session IDs

Resolved 💬 4 comments Opened May 3, 2026 by SlyWombat Closed Jun 14, 2026

Environment

  • Claude Code CLI (Opus 4.7 1M context backend)
  • Linux (WSL2) host: bash invocations launched via the Bash tool with run_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.log for "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:

  1. Frequently fail to terminate after their exit condition becomes true. The log file does contain "Exit code: 0" but the loop's last grep returned non-zero before the file was written, and after the next sleep N cycle the kernel never delivered SIGCHLD or the harness never resumed the foreground reader, so the loop stays parked on its sleep forever.
  1. 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. The pgrep returned 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.
  1. TaskStop cannot 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

  1. 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.
  1. 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.
  1. TaskStop should accept any on-disk task ID. If /tmp/claude-*/tasks/<id>.output exists, the harness knows about that task; it should be able to look up the PID via the file's metadata or a sidecar <id>.pid and kill it. Refusing with \"No task found with ID\" leaves the user no in-tool recovery path.
  1. Discourage until ...; sleep N polling loops in tool docs. The Bash tool description already nudges agents toward Monitor for streaming and run_in_background: true with 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's until semantics with edge-cases like pgrep returning empty ([ ! -e /proc/ ] evaluates to false because /proc/ always exists) silently break the contract. Detect the pattern in tool-call validation; warn the agent.
  1. 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):

  • bkwkmgk2zuntil ! grep -q \"Exit code:\" /mnt/d/temp/757a-build.log; sleep 10; done; tail -3 ...
  • bggjhb076until 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 boyypu0xm from 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.

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗