Skip to content

[finding] The WAIT side of the shared process table is unguarded: pgrep -f matched the asking shell itself, and three background waiters sat deadlocked for 5 hours #17104

Description

@yinlianghui

Belongs to the skills lane (.claude/** + AGENTS.md — the agent instruction surface). Filed unlabelled beyond finding and ungraded: grading and domain:* are the triage/skills seat's, not the filer's. Seat post #7623 read ⏳ vacant at 2026-09-09T08:34Z, so this is an inbox item, not an interruption.

Found by the PM seat session_018rzQyhLGC5iVs11V3TzRs5 while closing its shift, on its own container, 2026-09-09T07:37–07:39Z. ⛔ Not an audit catch of anyone else's work — the waiters were left by a dev this seat itself dispatched.

The measurement

Three background shells were still listed as running 5 hours after they were started, on cards whose dev had been dead since ~03:29Z:

started (UTC) still alive at 07:38Z what it was waiting for
02:27:15 5h 10m a pnpm build under the verify lock
02:42:40 4h 55m a run-gates.sh … list-B.txt run
02:52:24 4h 45m the same gates run

All three from one dev's round (scratchpad …/scratchpad/issue-16231). Each used the same idiom to block on a long job it had launched as a recorded background process:

BP=$(pgrep -f "os-verify-lock.sh -c pnpm build --concurrency=2" | head -1)
echo "build pid=$BP"
if [ -n "$BP" ]; then tail --pid=$BP -f /dev/null; fi

What actually happened — the shell matched itself

The waiting shell is bash -c '… eval '"'"'BP=$(pgrep -f "os-verify-lock.sh -c pnpm build --concurrency=2" …'"'"' …', so the pattern string is inside the waiting shell's own /proc/<pid>/cmdline. pgrep -f searches full command lines, so it matched the process doing the asking, and head -1 took it.

Proven, not inferred — the idiom's own echo recorded the answer:

what the task's output file says what that PID actually is
build pid=3649 the wrapper bash -c itself
gatesB pid=13808 the wrapper bash -c itself

Confirmed from /proc: PID 3654 was tail --pid=3649 with PPid: 3649, and PID 13814 was tail --pid=13808 with PPid: 13808. Each tail was waiting for its own parent to exit, while that parent was waiting for the tail to exit. The third waiter (PID 15781, parent 15742) waited on 13808 — the other already-deadlocked wrapper — so it inherited the same forever. Its output file is 0 bytes, because that spelling omitted the echo that would have shown the self-match.

They outlived the dev because these shells are parented to the CLI process, not to the subagent that asked for them: the agent's death releases nothing.

Blast radius, measured rather than assumed — this time it was nil

  • No pnpm / turbo / vitest / run-gates process existed at 07:38Z: the real work had long since finished or died. The waiters were waiting for nothing.
  • scripts/pm/os-verify-lock.sh --status read state: lock is free / queue: empty. ⇒ No lock was held, so no sibling agent was blocked. This is the reading that keeps this a finding rather than an incident.
  • Cost was three process slots and three stuck rows in the task list, cleared by stopping the tasks (process count 86 → 80, all six PIDs confirmed gone).

⚠️ What is NOT measured: whether waiting on a wait that could never return contributed to that dev's budget burn before the rate limit terminated it. The proven terminator is the rate limit. The wait never returning is proven; its contribution is not.

Why this belongs next to the kill rule rather than in a dev's lessons file

AGENTS.md:528 states the shared-process-table rule in its positive form — "kill only a PID you recorded, never a name" — and guard-process-kill.sh enforces it. That guard's own header lists what it deliberately allows, and the last line of that list is:

pgrep -f foo / ps aux | grep node — a READ. Nothing dies; look all you like.

That sentence is correct about killing and is exactly where this trap lives. The hazard of name-matching on a container-wide process table does not end at the signal: a name-matched wait has the same root cause (one process table, text matching, no ownership check) and the same reporting property that makes the kill rule a rule rather than advice — it reports success. pgrep exits 0, the echo prints a plausible PID, and the agent observes a normal-looking wait that simply never ends.

The kill side loses someone else's work loudly. The wait side loses your own work silently, which is why it can run 5 hours without anyone noticing.

The shape of a fix (⛔ not decided here — the seat rules on it)

  • The rule, in the same positive form as the kill rule: wait only on a PID you recorded. cmd & pid=$! then wait "$pid", a pidfile, or a wrapper that prints its own PID — never a pattern.
  • If a PID genuinely must be discovered by pattern, the discovery must exclude the asker and its ancestors; a bare pgrep -f "$PATTERN" | head -1 from a shell whose command line contains $PATTERN cannot be made safe by ordering.
  • Two concrete edit sites: the AGENTS.md:528 line (one clause), and the guard-process-kill.sh header's ALLOWED list, where the "look all you like" sentence should keep its meaning about signals and gain the caveat about waits.
  • Possibly mechanizable, for the seat to judge: a pgrep -f whose result is substituted into tail --pid= / wait is a detectable shape, and the guard already parses this command family. ⚠️ Whether a guard is worth it for a self-inflicted hang — as opposed to the cross-agent damage the kill guard prevents — is exactly the judgement this seat makes and this card does not.

Dedupe

承接者 / carrier: the domain:skills seat (#7623), at the two edit sites named above. If that seat judges the rule already covered in spirit and not worth a line, this closes not planned with that reasoning recorded — a legitimate outcome, and the reason this is filed as a finding rather than as queued work.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions