[BUG] `Monitor` `until`-loop self-matches when model uses `pgrep -f "<cmdline>"` as its check

Status Closed — not planned
Reported on v2.1.117
Maintainer reply None cached
Activity 6 comments · opened Apr 23, 2026 · closed Jun 19, 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?

Monitor until-loop self-matches when model uses pgrep -f "<cmdline>" as its check

Environment

  • Claude Code (Opus, but pattern is model-agnostic — generated bash is the same)
  • Linux, bash
  • Monitor tool with the documented polling pattern (until <check>; do sleep N; done)

What happens

When the model invokes Monitor with a pgrep-style check like:

until ! pgrep -f "dotnet test <args>" > /dev/null; do sleep 20; done

…the Monitor tool spawns the loop inside a bash -c '<the whole until expression>'. That bash's own argv contains the literal string the check is searching for. pgrep -f matches against the full command line, so it finds the Monitor's own bash process. pgrep returns 0, ! negates to false, and the loop never exits — even after the real background command has completed long ago.

Evidence from a hung session

$ ps --ppid <claude_pid> -o pid,cmd
 PID  CMD
N    /usr/bin/bash -c ... eval 'until ! pgrep -f "dotnet test <args>" > /dev/null; do sleep 20; done; ...'

$ cat /proc/N/wchan
do_wait            # blocked on the child sleep

$ pgrep -af "dotnet test <args>"
N /usr/bin/bash -c ... 'until ! pgrep -f "dotnet test <args>" ...'
#   ^ the Monitor bash matches itself

# The actual background command finished hours ago; no other process matches.

Claude Code's parent process cannot exit because it's still awaiting the Monitor tool result, which will never arrive. In headless mode (claude -p), this hangs the entire invocation indefinitely.

Why it matters

This is a plausible default check for models instructed to "wait for a background command to finish." The self-match trap is subtle — it's not obvious from reading the generated bash, and the surface symptom ("claude hangs forever") is easy to misattribute to MCP teardown or network issues. I wasted time chasing those red herrings before tracing it.

Suggested directions

Any of these would fix it; they're not mutually exclusive:

  1. Monitor tool implementation: invoke the check through a wrapper that doesn't leak the pattern into the shell's argv. e.g. write the check command to a temp file and bash <file>, or pass the pattern via an environment variable and have the check read it from $CHECK_PATTERN. Either prevents pgrep -f self-matching.
  2. Tool description: explicitly warn against pgrep -f "<cmdline>" as a check and recommend alternatives: kill -0 <pid>, tail -f <output> | grep -m1 <marker>, or a sentinel file. Many models will default to pgrep -f otherwise because it's the most natural phrasing.
  3. Monitor tool safety: apply a reasonable max wall-clock timeout on the outer until loop so a self-matching check eventually fails rather than hanging forever.

Minimal repro

Ask an agent (any model) to run a short background command and then use Monitor to wait for it using pgrep -f "<the exact command>". The Monitor never exits even after the command completes.

What Should Happen?

Monitoring a process should not hang claude.

Error Messages/Logs

Steps to Reproduce

Minimal repro

Ask an agent (any model) to run a short background command and then use Monitor to wait for it using pgrep -f "<the exact command>". The Monitor never exits even after the command completes.

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.117 (Claude Code)

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

5 Comments

the-exodus · 5 months ago

After adding prompt-level guidance to avoid pgrep -f and prefer tail -f "$output_file" | grep -E <marker> instead, the next iteration hit a different hang with the same net effect.

The model generated:

tail -f <background-task-output-file> | grep -E --line-buffered "Failed!|Passed!|error|Error|FAIL|fail"

— no -m1. The model was using the pipeline as a streaming notification source, which fits the Monitor tool's docstring ("each stdout line is a notification"). But when the background dotnet test finished, two things happened at once:

  1. The writer to the output file stopped.
  2. tail -f on a regular file has no natural termination condition — it keeps polling the file for new bytes that will never come.
  3. Without -m1, grep keeps reading from the now-idle pipe forever.

Net result: Monitor never exits → Claude Code's parent blocks waiting for the tool result → session hangs. Symptom identical to the pgrep -f trap; root cause is a different footgun in the same family.

Evidence:

$ tail -5 <output-file>
... Passed!  - Failed: 0, Passed: 2189, ... Duration: 2m 45s

# size stable over several seconds — writer gone
$ stat -c %s <output-file>; sleep 3; stat -c %s <output-file>
657
657

# no dotnet test process
$ pgrep -af 'dotnet test' ; pgrep -af testhost
(nothing matching)

# but the Monitor pipeline is still running
$ pstree -p <claude_pid>
claude(...)─┬─bash(...)─┬─grep(...)
             │           └─tail(...)
             └─...

This reinforces the underlying issue: Monitor has no fundamental guarantee that its check terminates, and models are prone to writing checks that don't. Both pgrep -f and tail -f | grep (without -m1) look reasonable on first read.

Additional suggested directions on top of the ones in the original report:

  1. Tool description: explicitly state that every Monitor check must terminate on its own, and call out the two known traps by name: pgrep -f "<cmdline>" self-matches; tail -f on a regular file never exits naturally, so downstream grep must use -m1 to terminate the pipeline. If a model wants both streaming notifications and a termination condition, it should be guided to issue two Monitor invocations (one streaming, one for the terminal marker with -m1) rather than a single pipeline that tries to do both.
  2. Outer timeout: re-stressing suggestion #3 from the original report. A max wall-clock timeout on the outer until loop (or the entire Monitor invocation) would cap the blast radius of every check pattern the model might pick — known traps and future ones.

Minimal repro (second trap)

Ask an agent to run_in_background a command that writes to a file and finishes in a few seconds. Then ask it to use Monitor with tail -f <file> | grep -E "<pattern>" — no -m1. Monitor will never exit after the command finishes, even though the matching line has already been emitted.

atrawog · 5 months ago

What I observed

claude -p hangs forever after it has finished its work.

claude -p does its tool calls, emits a final assistant message stating the iteration is complete, and stops issuing tool calls. It does not exit. The process stays alive, holding the connection open indefinitely. There is no further output, no further tool activity — just a process that never returns control.

The original report describes the in-loop hang while a Monitor poll-loop is active. What I observed is a hang after claude -p has stopped producing output entirely — claude -p itself never terminates.

Why

A bash -c '… until ! pgrep -f "<the pattern claude -p chose>" …' subprocess is still alive after claude -p's final message. /proc/<pid>/wchan is do_wait on its child sleep. pgrep -f is matching this bash itself — the search pattern is part of its argv via the eval wrapper — so the loop's exit condition is permanently false. Same self-match the original report describes.

What's different from the original report's tool path:

In the original report, Claude Code reaches this loop via Monitor. In what I observed, claude -p reaches the same loop via Bash{run_in_background: true} followed by TaskOutput{block: true, timeout: 600000}. TaskOutput does return — at the 600 s mark, by timeout. But the underlying bash subprocess keeps running unsupervised. claude -p moves on, does real work in subsequent tool calls, and emits its final message. The orphaned bash never exits. And claude -p waits for it.

Two effects layered on top of the pgrep self-match:

  1. The supported "wait for background task" path (Bash{run_in_background: true} + TaskOutput{block: true}) inherits the same self-match characteristic as Monitor, because the wrapper bash's argv has the same bash -c '… eval "<command>" …' shape.
  2. TaskOutput's timeout returning does not stop the underlying task. The orphan keeps running, and claude -p does not exit while it's alive.

Sequence inside one repro

  • claude -p kicks off a long-running shell command via Bash{run_in_background: true}
  • claude -p kicks off an until ! pgrep … waiter loop via Bash{run_in_background: true} and blocks on it via TaskOutput{block: true, timeout: 600000}
  • the long-running command completes; the only pgrep -f hit from this point is the waiter bash itself
  • TaskOutput times out at 600 s, returns
  • claude -p ignores the empty TaskOutput result, reads the long-running command's output by other means, continues with subsequent tool calls, emits its final assistant message
  • claude -p is silent and alive; the orphaned waiter bash is still spinning; no exit
gjkim42 · 4 months ago

+1 — hit the exact same trap in a Kubernetes Job running claude -p non-interactively (Claude Code 2.1.143, opus).

The wrapper is:

bash -c "... eval 'until [ -z \"\$(pgrep -f '\\''make verify'\\'')\" ]; do sleep 5; done; tail -60 OUT'"

Confirmed self-match with pgrep -af:

$ pgrep -af 'make verify'
12586  /bin/bash -c "... until [ -z \"\$(pgrep -f 'make verify')\" ] ..."   ← the wrapper itself
    9  claude --prompt "... run \`make verify\` and \`make test\` ..."        ← parent's argv also contains it

Process tree at hang time:

PID 1   entrypoint.sh                           wchan: do_wait
PID 9   claude --prompt "..."                   wchan: do_epoll_wait
        ├─ FD 25 = pidfd → PID 12586  (make verify waiter)
        └─ FD 27 = pidfd → PID 7251   (go test waiter)
PID 12586  bash -c "until [ -z \$(pgrep -f 'make verify') ]; do sleep 5; done; ..."
   └─ sleep 5  (respawning forever)

The real make verify had finished — the captured output file ends with Verification passed. Only the polling wrapper hangs, claude blocks on its pidfd in do_epoll_wait, the Kubernetes Job never reaches phase: Succeeded, and the pod has to be killed manually. We've had to clean up several of these by hand.

Note the wrapper self-match is sufficient on its own — the parent claude argv containing the pattern is just extra reinforcement. So the bug fires even when the user prompt doesn't mention the command being polled.

Workaround applied on our side: an entry in CLAUDE.md telling the model not to use pgrep -f 'CMD_NAME' for completion polls (kelos-dev/kelos#1165). Effective for our agents, but obviously not a general fix.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

weathon · 1 month ago

I had the same issue is it solved

Showing cached comments. Read the full discussion on GitHub ↗