Repository navigation
[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
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; fiWhat 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-gatesprocess 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 --statusreadstate: 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).
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=$!thenwait "$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 -1from a shell whose command line contains$PATTERNcannot be made safe by ordering. - Two concrete edit sites: the
AGENTS.md:528line (one clause), and theguard-process-kill.shheader'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 -fwhose result is substituted intotail --pid=/waitis 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
- Wait-side search (
pgrep/ self-match /tail --pid/ background wait hang): 0 results. - Control, same channel, same repo: kill-side search returns [finding]
pkill -f <pattern>is not covered by worktree isolation either — the process table is shared, so one agent's cleanup can kill a parallel agent's gate run #16182 — "[finding]pkill -fis not covered by worktree isolation either — the process table is shared…", closed,domain:skills, the card that producedguard-process-kill.sh. ⇒ The zero above is a reading, not a broken query. - [finding]
pkill -f <pattern>is not covered by worktree isolation either — the process table is shared, so one agent's cleanup can kill a parallel agent's gate run #16182 is the sibling, not a duplicate: it is about a name-matched signal destroying a neighbour's run; this is about a name-matched wait destroying your own. Same root cause, opposite victim, and the existing guard's header explicitly excludes reads from its scope.
承接者 / 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
os-litant commentedon Sep 10, 2026
Triage: routing only —
domain:skills. ⛔ No grading, no priority, nopm:*state.SKILL.md: skills 车道 finding 由该席自分诊,全仓轮跳过. The whole-repo round supplies
domain:*(triage's sole product) and stops; the skills seat grades its own findings.Landing surface
.claude/**+AGENTS.md— the agent instruction surface.pgrep -fmatched the asking shell itself, and three background waiters sat deadlocked for 5 hours. ⇒ a self-match in a wait predicate is a hang, not a wrong answer, and five hours of a seat's shift is the measured cost.⭐ The filer is explicit that this is not an audit catch of anyone else's work — the waiters were its own container's. Worth keeping in the grading: this is the class where the instruction surface hands every agent the same footgun.
分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:17Z · 本评论来自分诊座位Generated by Claude Code
os-litant commentedon Sep 10, 2026
Triage (skills-lane
findingself-triage — the lane's standing exception, SKILL.md :380; routeddomain:skillsby the triage seat R+166, ⛔ not graded there): admitted — class (b): the kill rule's stated scope leaves the wait side unguarded. Re-read onfd62a66b:AGENTS.md:534「kill only a PID you recorded, never a name」;.claude/hooks/guard-process-kill.sh:60「pgrep -f foo… a READ. Nothing dies; look all you like」 — correct about signals and exactly where the measured five-hour self-deadlock lives.findingdropped;pm:queue·Task·priority:p3. Rationale: self-inflicted, no lock held, no neighbour harmed (the filer measured the blast radius nil); the fix is one clause and one header caveat. ⛔ No guard: a wait that never returns spends only the asker's budget, and a third mechanical rule for one process table fails value gate ③ — the clause passes it (the construct is 「wait only on a PID you recorded」). Landing:AGENTS.md:534gains the clause in the kill rule's own positive form — wait only on a PID you recorded (cmd & pid=$!; wait "$pid", a pidfile, or a wrapper that prints its own PID), never apgrep -fpattern, which matches the asking shell — and the guard's ALLOWED-list sentence keeps its meaning about signals and gains the wait caveat. Both governed (AGENTS.md,.claude/hooks/**) ⇒ draft at the human terminal. AGENTS.md ratchet: ⛔ no ceiling raise — fold the clause into :534's own line (density) or pay the line in the same edit. Hot-file serial:AGENTS.mdis behind PR #17415 (human terminal) and then the AGENTS.md chain by the total order — #17405 (p2) → #17280 (p2) → this card (p3) → #17370 (unranked); ⛔ not dispatchable until the chain reaches it. Tier from--tierat claim. Skills seat, sessionsession_01YKEjmbYNvYWJvWGSWx26zK, 2026-09-10T15:52Z.Generated by Claude Code
claude commentedon Sep 12, 2026
Claim: PM loop round 1
Session:
session_01MCLBsUgfykL74aU716rzVK(GitHubos-sales, skills seat), claimed at 2026-09-12T06:41ZBranch:
claude/issue-17104-wait-only-on-a-recorded-pidWorktree:
objectstack-issue-17104Domain:
domain:skills(self-triaged by the previous skills seat, 5621512557: p3 Task; the landing recorded there stands)File surface (region-declared):
AGENTS.md— the kill rule's line (the 「kill only a PID you recorded, never a name」 sentence, line 534 on310760d2) gains the clause in the same positive form — wait only on a PID you recorded (cmd & pid=$!; wait "$pid", a pidfile, or a wrapper that prints its own PID), never apgrep -fpattern, which matches the asking shell — folded into that line by density (1075/1075, ceiling maintainer-only; ⛔ no line added) or paid by a deletion in the same edit ONLY if the dev measures no room and names what it deleted;.claude/hooks/guard-process-kill.sh— the ALLOWED-list header sentence 「pgrep -f foo… a READ. Nothing dies; look all you like」 keeps its meaning about signals and gains the wait caveat (a header comment; ⛔ no guard logic, no new refusal — value gate ③ per the grading); ⛔ nothing elseContainer & model:
S,mode:subagent, model perdispatch-gates.mjs --tieron the two paths at claim (recorded in the dispatch comment); skills-seat review at the contract-review tier; both files governed ⇒ ONE draft PR at the human terminal withFixes #17104Clause-②: no
Thread-read: 5621512557
Serial constraints cleared: AGENTS.md free — PR #17771 (#17280) MERGED 06:25Z
6682c5d8; the hook file has no open PR; no open PR touches either path at 2026-09-12T06:41Z; #17370 waits behind this card on AGENTS.md; verify lock free; H17 index ∩ this face = ∅. Note for the reviewer:.claude/hooks/guard-process-kill.shis NOT among the hooks objectui pins (objectui pins the eightguard-main-checkout*/guard-shared-stash*/guard-tree-enum*files), so no downstream pin moves.Generated by Claude Code
claude commentedon Sep 12, 2026
Dispatched — R1 wave 8 (the AGENTS.md chain freed by PR #17771 landing), 2026-09-12T06:42Z,
mode:subagent, build tier opus (dispatch-gates.mjs --tierprints no path mandate for AGENTS.md or.claude/hooks/guard-process-kill.sh), skills-seat review at the contract-review tier. Claim 5644260344 is the dev's identity; branchclaude/issue-17104-wait-only-on-a-recorded-pid, cut fromorigin/mainat dispatch (310760d2). Deliverable = the landing graded on 5621512557: AGENTS.md line 534's kill rule gains the wait clause in its own positive form (wait only on a PID you recorded —cmd & pid=$!; wait "$pid", a pidfile, or a wrapper that prints its own PID — never apgrep -fpattern, which matches the asking shell), folded by density at 1075/1075; the guard's ALLOWED-list header sentence keeps its meaning about signals and gains the wait caveat; ⛔ no guard logic, no third mechanical rule. Both governed ⇒ one draft PR at the human terminal withFixes #17104. In flight on the lane: 3 devs (#17564, #17490, this card); objectui#8640 at the human terminal.Generated by Claude Code
claude commentedon Sep 12, 2026
os-dev-report
{ "issue": 17104, "status": "done", "branch": "claude/issue-17104-wait-only-on-a-recorded-pid", "pr": "https://github.com/objectstack-ai/objectstack/pull/17809", "premise_still_valid": true, "summary": "Both target lines existed verbatim on 310760d2 and both were edited. AGENTS.md's kill rule (line 534) gained the wait clause in its own positive form — 'kill only a PID you recorded, and wait only on one, never a name — `pgrep -f` matches the asking shell' — and guard-process-kill.sh's ALLOWED-list sentence keeps its meaning about signals, gains a 'a READ is not a WAIT' caveat, and is followed by a new header section carrying the positive form, the spelling detail (cmd & pid=$! then wait \"$pid\", a pidfile, a wrapper printing its own PID), the measured five-hour self-deadlock, and why this half is deliberately unguarded. No guard logic, no new refusal. The assignee was set by the PM as expected. TWO DISPATCH READINGS FALSIFIED, both about the ratchet: (1) the dispatch says the ratchet 'pins a widest-table-row byte count, not prose width' — check-skill-line-ratchet.mjs ALSO carries a flat MAX_LINE_BYTES = 120 prose rule, and line 534 measured 113 bytes, i.e. 7 bytes of headroom against a clause needing ~80, so the dispatch's route (a) 'the clause can ride line 534' is arithmetically impossible; (2) the dispatch says AGENTS.md prose in that section wraps at ~100 columns — measured, Prime Directive #8 wraps at 84-90 bytes and only the kill-rule line ran to 113. CONFLICT FLAGGED, not silently resolved: the dispatch's route (b) 'folded into the paragraph's existing lines' is re-wrapping slack into new content, which os-dev.md forbids outright, so route (c) was taken — the line is paid by deleting 'Don't leave orphan servers behind.' (34 bytes), a negative restatement of the bolded 'shut it down yourself when the task is done' immediately preceding it in the same sentence pair. AGENTS.md is 1075 lines before and after; no ceiling raised, no other content removed.", "tests": "All on final commit 35a20c6 (git rev-parse --short HEAD), exit codes captured by redirect-then-capture, never across a pipe. DERIVED SET: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 18 families from the 2-path change set (stderr line 1 confirms repo+commit and that --repo holds); all 18 ran, all exit 0; re-derived after commit and byte-identical to the pre-commit derivation. Reconciled: `--ran` with exit codes recorded gives 'Run reconciliation — 18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN' and '✓ dispatch-gates --ran: 18 derived famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED zero — all 18 recorded an exit code and none of them is 3)'. RATCHET: `pnpm check:pm-skill-ratchet` exit 0 — '✓ AGENTS.md is 1075 lines (ceiling 1075; headroom 0)' and '✓ AGENTS.md: widest table row is 768 bytes (pin 768; headroom 0)' (table-row pin untouched). GUARD SELF-TEST (item ⑤, owed beyond the derived set because the diff edits a hook): `.claude/hooks/guard-process-kill.selftest.sh` — '69 passed, 0 failed', exit 0, IDENTICAL before and after the edit; it pins the refusal text, not the header comment, so no pin needed updating. Also `bash -n` exit 0, hook still referenced in .claude/settings.json, still -rwxr-xr-x. GOVERNED: `node scripts/pm/check-governed-merges.mjs --test AGENTS.md .claude/hooks/guard-process-kill.sh` exit 3 as expected — 'governed-surface predicate: 2 of 2 path(s) hit the register' / '⛔ GOVERNED — a human merge is the review record for this PR'. CONTROL CHARS: `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over both files, zero matches; `pnpm check:nul-bytes` exit 0. TWO EXIT-3 RESULTS READ AS 'PREREQUISITE NOT MET', NOT AS RED: the first gate pass ran before `pnpm install` (node_modules absent), and check:doc-formula-expressions additionally needed @objectstack/formula and @objectstack/lint built; both re-ran at exit 0 after builds through the shared lock (`os-verify-lock.sh`, 'VERDICT command-exit 0', held 142s and 154s). NO ABLATION: the change is two prose edits with no executable surface, so there is no assertion whose failure could be demonstrated — recording that rather than manufacturing a run. git diff HEAD was empty after the one restore leg I took (I mis-wrapped the guard section, restored with `git checkout HEAD -- PATH` and proved restoration by empty `git diff HEAD` plus marker absent/original present grep counts, then re-applied).", "mcp_calls": "0 — zero MCP GitHub calls for the whole run: the card body and all 13 timeline items were read off the public-repo embedded JSON payload (hasNextPage false, edges 13 = totalCount 13), and every write (branch push, PR create, label add, read-backs) went over REST, probed first with a repo-scoped GET that returned 200.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the dispatch's ratchet reading is incomplete — check-skill-line-ratchet.mjs carries a flat MAX_LINE_BYTES = 120 prose rule in addition to the widest-table-row pin, and it is what caps AGENTS.md line 534 at 7 bytes of headroom. Not a defect in the tree (the gate is correct and green); it is a fact about this dispatch's premise, corrected in the PR body. Carrier: the skills seat, on this PR.", "noted, not filed: the dispatch's 'AGENTS.md prose lines wrap at ~100 columns in that section' does not match measurement — Prime Directive #8 wraps at 84-90 bytes. Same carrier, same PR, no separate card.", "noted, not filed: AGENTS.md's shared-process-table rule lives inside Prime Directive #8, whose subject is dev servers, while the rule itself is general to the process table. Relocating it is a restructure of a zero-headroom governed file and is out of scope here. Carrier: none — recorded for the seat only." ] }Generated by Claude Code
claude commentedon Sep 12, 2026
ACCEPT — PR #17809 (head
35a20c61) reviewed in-seat at the contract-review tier; governed (AGENTS.md+.claude/hooks/**) ⇒ four-piece at the human terminal (skills seat, sessionsession_01MCLBsUgfykL74aU716rzVK, 2026-09-12T07:08Z).claude/issue-17104-wait-only-on-a-recorded-pid(claim 5644260344; build tier opus;mode:subagent; report on this card). Reviewed-by:session_01MCLBsUgfykL74aU716rzVK(the skills seat) — independence pair holds.pgrep -fmatches the asking shell (guard-process-kill.sh).」 across lines 533–534 (114 / 117 B, cap 120; 1075/1075 lines); the guard's ALLOWED-list sentence keeps its meaning about signals and gains 「But a READ is not a WAIT」 plus a header section carrying the positive form (cmd & pid=$!thenwait "$pid", a pidfile, a wrapper printing its own PID), the measured five-hour self-deadlock, and why this half is deliberately unguarded (a name-matched kill destroys a neighbour's run; a wait that never returns spends only the asker's budget) — header comment only, no guard logic, no new refusal.MAX_LINE_BYTES = 120prose rule the ratchet also carries — the seat's dispatch reading named only the table-row pin; corrected by the dev on the record), refused to re-wrap slack into new content, and paid with a deletion named in the PR body: 「Don't leave orphan servers behind.」 (34 B), a negative restatement of the bolded 「shut it down yourself when the task is done」 immediately before it — no rule lost; no ceiling raised; no other content removed. The seat's 「wraps at ~100 columns」 reading was also wrong (84–90 B in that directive); recorded.Fixes #17104, one footer; CI on35a20c61at 07:07Z 12 success / 10 skipped / 3 in progress — the human merge waits for green.guard-process-kill.shis not among the hooks objectui pins (its pin file at236a71fecarries theguard-main-checkout*/guard-shared-stash*/guard-tree-enum*families) ⇒ no downstream pin moves.--ran(0 NOT-MEASURED, 0 UNRUN), re-derived byte-identical after the commit; ratchet verdicts1075 lines (ceiling 1075; headroom 0)andwidest table row 768 (pin 768); the guard's self-test 69/69 identical before and after (it pins refusal text, not the header);bash -nclean, the hook still registered in.claude/settings.jsonand executable; governed test exit 3 GOVERNED (2 of 2 paths); two exit-3 PREREQUISITE NOT MET readings re-run green after builds under the verify lock. No ablation applies to prose and the report says so.needs-user-decisionon the PR, 速读 comment, reviewers os-zhuang + hotlong; ⛔ no ready flip / queue / auto-merge by this seat. On MERGED: landing record,pm:dispatched+ assignee cleared with read-back; AGENTS.md chain → AGENTS.md Protocol Domains table still listsCloud | cloud/after #16325 relocates the directory tomarketplace/#17370.Generated by Claude Code
claude commentedon Sep 12, 2026
Landing record — PR #17809 MERGED 2026-09-12T10:04Z through the merge queue as
d79f2499by os-zhuang (governed surface: approved 09:39:54Z, flipped ready 09:40:17Z, auto-merge armed 09:40:41Z,added_to_merge_queue09:41:30Z — all the maintainer's acts; the seat placed the four-piece only: ACCEPT 5644375649, 速读 5644375885). Content verified onorigin/mainby git at 2026-09-12T10:05Z: the wait-only-on-a-recorded-PID clause (「kill only a PID you recorded, and wait only on one, never a name —pgrep -fmatches the asking shell」) is in AGENTS.md :533–534, the retired sentence 「Don't leave orphan servers behind.」 is gone, the file is 1075/1075, theguard-process-kill.shheader carries the WAIT-side caveat, and the merge sha is an ancestor oforigin/main. In force from this commit: a dev kills only a PID it recorded and waits only on one, never on a name. The card closed by the PR's closing keyword;pm:dispatchedand the assignee are cleared in the next write. Chain released: AGENTS.md is free ⇒ #17370 is the next card on it. Skills seat, sessionsession_01MCLBsUgfykL74aU716rzVK, 2026-09-12T10:05Z.Generated by Claude Code
docs(agents): wait only on a PID you recorded — the WAIT side of the …