Orchestrata Ultimate › Plans › briefs › qwen › Q8-hook-process-matching
Q8 — hooks that judge a process by the words in its command line
Source: plans/briefs/qwen/Q8-hook-process-matching.md at 8a2cb42ad · last changed 2026-10-12 · first committed 2026-10-12 · 271 lines.
Prose pending.
Plan text
Q8 — hooks that judge a process by the words in its command line
DESIGN_WAIVER: none needed. Both changes make a hook do what its own header already says it does. Neither adds a capability.
Worktree: /home/mohamed/workspace/wt-HOOK-MATCH (branch
fix/hook-process-matching, from origin/master 762935ab6). Work ONLY there.
0. Rules (binding)
- A red test blocks completion, whoever owns it. Every failed, errored or skipped test needs a root cause with evidence (traceback, line) and either a fix or a STOP naming the cause. "Outside my files", "pre-existing", "flaky", "infra" and "unrelated" are not statuses. STOP means blocked, not done.
- No mocks, stubs, fakes, monkeypatch, skip, xfail, or
-n/xdist. Real processes, real/proc, real signals. - Do NOT commit, merge, push, checkout, restore, reset, stash or clean.
git add/show/diff/status/logare allowed. The orchestrator commits. - Read Python through
scripts/serialize_codebase_ast.py symbols|source|refswhere it serves. Reading a file you are about to edit is fine. - Absolute imports.
os.environ.get(...). Typed signatures and docstrings. - Never write the word "should". Every number in the report comes from a command run in this session, quoted next to it.
- A guard denial is reported, never worked around. Do not reshape a denied command (brackets, quoting, splitting) so that it passes.
- No test may signal a process it did not start. Every reaper
invocation in a test passes
ORPHAN_WAITER_ONLY_PIDS(R3 below) naming only that test's fixture pids. This host runs other agents whose waiters are exactly the shape the reaper hunts; an unscoped run kills them. - Do not remove a function or constant. If a change leaves one without a reference, report it by name; the orchestrator decides.
0.1 Running tests
Other agents run suites on this host, and two runs share worker databases and ports. Run tests only through the wrapper the orchestrator placed in your worktree root (do not edit or stage it):
mkdir -p .q8
bash .run_exclusive.sh --force --json <module> ... > .q8/run-<label>.json 2> .q8/run-<label>.err; echo "run rc=$?"
It takes a host-wide lock (flock), waits for any run started outside the
lock, then runs. --force means every selected test executes and no green
is reused. Exit 124 means the wait exceeded 90 minutes: STOP and report.
Do not write your own pgrep wait loop. .q8/ is scratch, never staged.
1. Evidence (orchestrator, 2026-10-11)
1.1 The reaper killed a live executor
.claude/hooks/reap-orphan-waiters.sh runs at SessionStart (compaction
included). At 20:55:12 it printed:
[reap-orphan-waiters] killed pid 930286 after 3148s: timeout 5400 env -i HOME=/home/mohamed USER=mohamed LOGNAME=mohamed LANG=C.UTF-8 TERM=dumb
[reap-orphan-waiters] killed pid 931060 after 3132s: /bin/bash -c timeout 3600 bash -c 'while pgrep -f "scripts/run_tests[.]py" >/dev/null; do
[reap-orphan-waiters] killed pid 931062 after 3132s: bash -c while pgrep -f "scripts/run_tests[.]py" >/dev/null; do sleep 15; done
- pid 930286 was
timeout 5400 env -i ... opencode run ... "<prompt>", a live agent executor. It is not a shell, and it runs no loop. One argv element, the prompt, quoted a wait loop as instructions. The reaper joins every argv element into one string and greps it, so the quoted text matched.pkill -9 -P 930286then killed its child, the opencode process, and the agent's work stopped mid-task (its dispatch exited 137). - pids 931060/931062 were that live agent's own waiter, about to be satisfied. Their parent was alive. The header says the reaper kills loops "orphaned by a finished agent", but the code never checks for an orphan; age is the only gate.
- The existing test
test_a_matching_waiter_past_the_age_gate_is_reapedasserts the defect. Its fixture loop's parent is the live test process, and it runs the real reaper over the whole host with the age gate at 0, so every run of that test kills every matching loop on the machine.
1.2 Who adopts an orphan on this host
Measured: bash -c 'sleep 30 & echo $!; exit 0', then
ps -o ppid= -p <that pid> → 1 (/proc/1/comm = systemd). The
orchestrator's own process chain is bash → claude → bash → konsole → systemd(1): no subreaper in it.
1.3 The status hook counts a dispatcher as a pytest run
claude/hooks/test_run_context.py _runs() decides "authoritative run"
by argv element (_is_authoritative, which skips any element containing
whitespace, so a quoted pattern or a -c payload never counts). It
decides "other pytest process" by _PYTEST_RE.search(cmd) over the joined
command line (_PYTEST_RE = re.compile(r"\bpytest\b|-m\s+pytest")). For
53 minutes on 2026-10-11 it reported pid 930286, an opencode dispatcher
whose prompt contains the word pytest, as
1 targeted pytest process(es) running: pid 930286 · wt-BARE-PYTHON.
2. Work
Part 1 — the reaper (.claude/hooks/reap-orphan-waiters.sh)
First, git ls-files | grep -i reap-orphan and
grep -rn reap-orphan-waiters .claude/settings*.json claude/ plugins/ 2>/dev/null.
Report every copy and registration. Change each tracked copy of the hook
the same way. A generated copy is regenerated by its generator, never
hand-edited.
Contract after the change:
-
R1. Candidate = a shell running a loop payload. Read
/proc/<pid>/cmdlineas NUL-separated argv, not joined. The process is a candidate only if:basename(argv[0]), a leading-stripped, is one ofbash sh dash zsh;- the payload is the argv element right after the first element matching
^-[A-Za-z]*c[A-Za-z]*$(-c,-lc,-ec, …); - the existing shape tests (
(while|until)\s…done…sleep Nor an emptydo :;body) apply to that payload only.
A process whose loop text sits in any other argv element is not a candidate. Neither is a non-shell, or a shell running a script file (no payload).
-
R2. Orphan = adopted. The candidate's parent pid (field 4 of
/proc/<pid>/stat, parsed after the last)) is in the adopter set. The adopter set is$ORPHAN_WAITER_ADOPTER_PIDS(space-separated), default1(§1.2). A candidate whose parent is anything else belongs to a live process and is left alone. -
R3. Test scope. When
$ORPHAN_WAITER_ONLY_PIDSis set and non-empty (space-separated), only those pids are considered. Unset: every pid, as today. The header comment documents it as a test seam. -
R4. The age gate (
ORPHAN_WAITER_MIN_AGE_MIN, default 30) and the "never this hook / its parent" exclusions stay. -
R5. The kill stays
pkill -9 -P <pid>thenkill -9 <pid>. The log line becomes[reap-orphan-waiters] killed pid <pid> (ppid <ppid>) after <N>s: <payload, first 90 chars>. -
R6. Rewrite the header comment to state R1–R3 and cite the 2026-10-11 incident (§1.1) in two or three lines. Keep "never gates the session: always exits 0".
Part 2 — the status hook (claude/hooks/test_run_context.py)
- S1. Add
_is_pytest(argv: list[str]) -> bool, with a docstring citing §1.3. It is True when, skipping argv elements that contain whitespace (the same rule as_is_authoritative):- an element's basename is
pytestorpy.test; or - an element equal to
-mis immediately followed by an element equal topytest; or - an element equals
-mpytest.
- an element's basename is
- S2.
_runs()uses_is_pytest(_argv(pid))in place of_PYTEST_RE.search(cmd). Then runscripts/serialize_codebase_ast.py refs _PYTEST_RE. If it has no remaining use, leave it in place and report it (rule 9). - S3. Nothing else in the hook changes.
Tests, red first
Write each test first, run it, and quote its red output before changing
the code. Each fixture process is started by the test and killed in
finally or a function-scoped fixture's teardown, whatever happened.
tests/scripts/test_reap_orphan_waiters.py:
- T-helper. A small adopter helper (a real subprocess, not this
process):
python -c "import ctypes,os,subprocess,sys,time; ctypes.CDLL(None).prctl(36,1,0,0,0); ..."prctl(PR_SET_CHILD_SUBREAPER=36, 1). It starts the fixture through an intermediatebash -c '<fixture> & echo $!; exit 0'. It prints the fixture's pid (now an orphan adopted by the helper), keeps itself alive while reaping zombies (os.waitpid(-1, os.WNOHANG)in a loop with a sleep), and exits when stdin closes. The test reads the pid, verifiesppid == helper.pidfrom/proc/<pid>/stat, and passesORPHAN_WAITER_ADOPTER_PIDS=<helper pid>. Making the pytest worker itself a subreaper is forbidden: it would adopt every other test's orphans for the rest of the worker's life. - T1 (incident, red today). An orphaned non-shell carrying loop text: a
python -c "import time; time.sleep(120)" "timeout 3600 bash -c 'while pgrep -f x >/dev/null; do sleep 15; done'"process, adopted by the helper. Reaper withMIN_AGE 0,ONLY_PIDS=<it>,ADOPTER=<helper>→ still alive after 5 s. - T2 (incident, red today). A shell waiter whose parent is alive: a
long-lived intermediate (
bash -c '<loop> & wait') whose child isbash -c 'until test -f /nonexistent-<uuid>; do sleep 5; done'. Reaper withMIN_AGE 0,ONLY_PIDS=<child>, noADOPTERoverride → the child is alive after 5 s. - T2 replaces
test_a_matching_waiter_past_the_age_gate_is_reaped. That test asserts the defect of §1.1 (a waiter with a live parent is killed), against the hook's own header. The orchestrator authorises this one contract change. In the report, quote the old test's assertion and this paragraph. - T3 (the purpose, green after the change). An orphaned shell loop
(
until test -f /nonexistent-<uuid>; do sleep 5; done, adopted by the helper). Reaper withMIN_AGE 0,ONLY_PIDS=<it>,ADOPTER=<helper>→ gone within 15 s. The hook's stdout carrieskilled pid <it> (ppid <helper>). - T4. The same orphan with no
ADOPTERoverride (default1) → alive. This proves the default does not adopt arbitrary parents. - T5. A qualifying orphan whose pid is not in
ONLY_PIDS(pass a different fixture pid there) → alive. This proves the scope seam. - T6.
sh -candbash -ecpayload variants of T3 → reaped. - T7. The existing tests (
test_the_reaper_never_gates_the_session,test_a_young_waiter_is_left_alone,test_a_loop_without_sleep_or_empty_body_is_not_matched,test_hook_is_registered_at_session_start) keep their assertions. Any of them that invokes the reaper now passesONLY_PIDS. Its fixture becomes a helper-adopted orphan, so it fails the gate it names (age, shape) and not the orphan check. Showgit diffproving the assertions are unchanged.
tests/claude/test_test_run_context.py:
- P1 (incident, red today). A real process
python -c "import time; time.sleep(60)" "run pytest -q tests/x.py and report"._runs()lists it in neither returned list. Use the module loader the file already uses (_load_hook). - P2. A real
python -m pytest <tmp_path>/test_sleep.py -q -p no:cacheprovider, where the test sleeps 30 s. It appears in_runs()(others, or authoritative per the existing rules). Reuselive_run_treeif it already proves this; then say so with its line. - P3. A real process whose argv element is a path ending
/pytest(the venv's.venv/bin/pytest, run on the same sleeping test) → appears.
Verification
-
Through §0.1, label
q8:tests/scripts/test_reap_orphan_waiters.py,tests/claude/test_test_run_context.py,tests/claude/test_test_run_context_coverage.py,tests/scripts/test_session_start_hooks.py, plus theenv -u PYTHONPATH .venv/bin/python scripts/affected_tests.py --json --files <every changed file>set. If the affected set escalates to the whole suite, do not run it; report it. Pass: 0 failed, 0 errors, 0 skipped,reused.modules0. Quotetest_totalsandreused. -
Host-safety proof. Before and after the q8 run, record every process on the host matching
(while|until)[[:space:]]:pgrep -fa '(while|until)[[:space:]]' > .q8/loops-before.txtand> .q8/loops-after.txt. Report that no pid in the before-file that your tests did not start disappeared during the run. The orchestrator's own waiters are in that list on purpose. -
The incident, end to end. Outside pytest, by hand:
- start a helper-free reproduction of §1.1: a python process whose argv element quotes the wait loop, and under a live intermediate a real shell waiter;
- run
ORPHAN_WAITER_MIN_AGE_MIN=0 ORPHAN_WAITER_ONLY_PIDS="<both pids>" bash .claude/hooks/reap-orphan-waiters.sh; - show both alive; kill them yourself afterwards.
Quote the commands and output.
-
bash -nandshellcheck(ifcommand -v shellcheckfinds it) on the hook;ruff check+black --check+pyrighton changed Python;scripts/gate.py run;scripts/serialize_codebase_ast.py check-deps --include-tests;git addyour changes (never.q8/,.run_exclusive.sh,reports/);scripts/gate.py review-staged. All exit 0.
3. Done-when
- [ ] Part 1 (R1–R6) and Part 2 (S1–S3) done; tests T1–T7, P1–P3 red first where marked, then green.
- [ ] Verification 1–4 done, every number with its command.
- [ ] Changes staged; scratch unstaged.
- [ ]
plans/briefs/qwen/Q8-report.mdwritten:- every copy/registration of the reaper found;
- each requirement with the line that implements it;
- red-then-green output per test;
- the replaced test's old assertion and the authorising paragraph;
- the host-safety proof;
- the incident reproduction;
- anything BLOCKED, with the reason.
STOP conditions (write the report, then stop):
- a copy of the hook that is generated and whose generator you cannot run;
- any red result whose cause you cannot fix here (name it exactly);
- a host-safety proof showing a pid you did not start disappeared;
- an affected set that escalates to the whole suite;
- a guard denial that blocks a required step (name the command and the need).