docs: row 211 R5 records for the busy check fixes (#1709, #426)

- BUILD.md: an R5 section covering N1 to N3, the fixes, the tests, the
  RED and GREEN counts, the 57-mutant table, the 13-shape live probe,
  the false BUSY and miss checks, and the known limits after R5. N3
  corrects the R4 bind-mount note: a same-filesystem bind mount keeps
  st_dev, so a detector needs mount metadata such as
  /proc/self/mountinfo. No code changed for N3. Earlier sections are
  untouched.
- docs/TOOLS.md: the busy check's flat form, span rule, exit 2 on text
  it can't classify, the literal listing, and 153 cases.
- BUILD-LOG.md and docs/SESSIONS.md: one appended entry each.
This commit is contained in:
jason.woltje committed 2026-11-02 17:53:36 -06:00
1 parent afc176de02
commit 794f4c4a24
4 files changed
+419 -6

No files matched your search

+22
View File
@@ -5856,3 +5856,25 @@ sourced() that a GREEN-time case now pins; GREEN 105 passed, 0 failed (20:02:40Z
of 30 killed, runner exit 0 (20:02:56Z to 20:06:58Z, base green). No 64-hex test pin names a changed path.
Not pushed. Gaps: lexical false BUSY and missed runtime-built sourced paths, and an undetected bind-mounted
hooks directory; details in agents/darkwing/work/queue-211/BUILD.md, section "R4".
## 2026-11-02T23:53:07Z: row 211 (#1709, #426) R5, busy check listing, quoting and spacing
Before: row 211 R4 (fd1aa8cc8 on `t3/row211-push-guard-r4-fix`, over 09a847eda). The GPT review of R4
(row211-r4-gpt.md) found that gone() parsed `ls -f`, whose names follow `QUOTING_STYLE`, so under
`shell-always` or `c` a failed read of a listed pid became CLEAR (N1). It also found that seven of 13 live
shell-snapshot wrappers that source a test, with no child, gave CLEAR: a quoted path with spaces, a quoted
`source`, `;` with no blank, a quoted snapshot, a tab and two spaces (N2). And the R4 bind-mount note named
st_dev, which a same-filesystem bind mount keeps (N3).
After: two R5 commits by the lead's implementation sub-agent, code and tests, then records. gone() lists with
`ls -f -1 --quoting-style=literal`, the script exports `LC_ALL=C`, and a failed listing is still never gone.
A command line holding quotes, backslashes, shell operators or uneven whitespace gets a flat form (awk). The
snapshot rule reads the flat form, the general rules read it and then the raw text, and a wrapper's text goes
through an awk word walk. In the walk, every word of each `source` or `.` span counts, and quoted text is read
again. Text the walk can't classify (over 32 levels, an awk failure) is exit 2. test-git-guard.sh: RED 119
passed, 34 failed (23:32:37Z to 23:33:12Z); GREEN 153 passed, 0 failed (23:33:23Z to 23:33:35Z). Mutants 57
of 57 killed, runner exit 0 (23:33:53Z to 23:46:34Z, base green). Live probes of the 13 shapes
(23:46:56Z to 23:47:18Z): R4 CLEAR on 7 to 13, R5 BUSY on all 13. No 64-hex test pin names a changed path.
Not pushed. Gaps: new lexical false BUSY (span arguments, redirects, `find .`, parentheses), still-missed
runtime-built and ANSI-C sourced paths, and an undetected bind-mounted hooks directory; details in
agents/darkwing/work/queue-211/BUILD.md, section "R5".
+381
View File
@@ -571,3 +571,384 @@ The runner cuts each first failing case at 100 characters, which is why three of
output lines. Chain on the exit code, never on the lines (unchanged advice).
- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and
can't make it BUSY. This host's `/proc` has no hidepid option.
## R5, the GPT review of R4 (2026-11-02)
The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from
the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r5-fix.md` and the review
`/mnt/storage/scratch/mosaic-lead/reviews/row211-r4-gpt.md` (N1 to N3, the 13 live shapes in Check 2, and
Check 6). Base 09a847eda, branch `t3/row211-push-guard-r5-fix` from fd1aa8cc8, then one R5 code commit and
one record commit. Only `slot-busy.sh`, the suite, the mutant runner and the record files change; the hook
and the installer are untouched. The suite goes from 105 cases to 153 and the mutants from 30 to 57. The
sections above stay as written; where R5 changes them, it says so here.
### N1: a listing that reads the same in any environment
Was: `gone()` ran `ls -f -- ROOT` and looked for the pid among the names. GNU ls reads `QUOTING_STYLE`. With
`shell-always` or `c` it prints a present pid as `'4194430'` or `"4194430"`, the name check misses it, and
the pid reads as gone. A failed cmdline read and a failed ancestor read then both ended in CLEAR.
Fix: `ls -f -1 --quoting-style=literal -- ROOT`. The flag overrides the variable, and `-1` asks for one name
per line. R2's rule is unchanged: a failed or denied listing returns 1, never gone. The script also exports
`LC_ALL=C` before it runs any tool, for the locale finding below.
I checked every other tool the script runs or parses, on this host (GNU ls 9.11, GNU Awk 5.4.1, bash
5.3.15):
- ls. `shell-always` and `shell-escape-always` print `'N'`, `c` prints `"N"`, and `locale` prints the
locale's quote marks around N. `escape`, `shell`, `shell-escape` and `literal` print a digits-only name
bare, so the brief's `escape` control passes on R4 too and stays as a control. `LS_COLORS`, `CLICOLOR`,
`CLICOLOR_FORCE`, `COLUMNS`, `TABSIZE` and `POSIXLY_CORRECT` don't change the output to a pipe. To a pipe,
`-1` changes nothing either, so no test can pin it; it states the format. BSD and BusyBox ls reject
`--quoting-style`, so on such a host every listing fails and every failed read is exit 2.
- awk. `AWKPATH`, `GAWK_READ_TIMEOUT=1`, `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` left the status read's output
as it was. `GAWK_PERSIST_FILE` naming a missing file makes gawk exit 2 with a fatal error. Every awk call
in the script checks its exit status, so that ends in exit 2, never CLEAR.
- tr. It maps bytes; `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` gave the same bytes, 0xff included.
- readlink only feeds the cwd field of a BUSY line, and it prints the link as it is. `sed` runs only for
`--help`.
- bash itself. bash doesn't take `IFS` from the environment. `GLOBIGNORE` had no effect on the pid glob.
`SHELLOPTS=noglob` (exported) turns the glob into its literal, which fails the numeric test, so nothing is
seen and the script exits 2, "no processes". Exported functions, `BASH_ENV` and `PATH` let a caller put
its own code in place of `ls`, `awk` or `tr`; the suite's shims rely on exactly that. The check can't
defend against its own caller, and that stays a stated limit.
- Locale. Under a UTF-8 locale, bash's `[^ /]*` in `RE_TEST` stops at a byte that isn't valid UTF-8, so
`bash scripts/test-<0xff>x.sh` didn't match. `export LC_ALL=C` makes every regex and every tool read bytes.
This host has `C.utf8`, so that test isn't vacuous here.
Tests, each RED on R4 except the `escape` pair: a loop over `QUOTING_STYLE` = `shell-always`, `c`, `escape`,
`shell-escape-always` and `locale`, each with "QUOTING_STYLE=X: a failing cmdline read with the pid still
listed: exit 2, no CLEAR" (the `failtr` shim) and "QUOTING_STYLE=X: a failing ancestor status read with the
pid still listed: exit 2, no CLEAR" (`awkn fail`, which now passes extra environment through `env`). "under
LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" pins the locale line.
Mutants: `ls-env-quoting` (the `--quoting-style=literal` flag removed) and `locale-kept` (the `LC_ALL=C` line
removed). `denied-listing-gone` keeps its intent at the new line. All killed.
### N2: quoting, spacing and operators no longer hide a sourced test
Was: three regular expressions read the text with a fixed idea of spacing. `RE_SNAPSHOT` needed single
spaces and a space after `.sh`, `RE_SOURCED` cut a quoted path at its first blank and didn't allow a quote
around `source`, and the general rules needed a blank after `.sh`. Seven of the review's 13 live wrappers
gave CLEAR.
Fix, in three parts.
1. The flat form. When a command line holds a quote, a backslash, one of `; & | ( ) { } < >`, a backtick,
any whitespace other than a single space, or two spaces in a row, `flat()` rewrites it in awk: quotes
and backslashes removed, and each run of whitespace and those operators made one space. A command line
with none of them is its own flat form, so most of the host's command lines skip the awk call (550 of
661 on this host). `flat()` prints an `F` before the text; a failed awk or output without the `F` is
exit 2.
2. The rules on both forms. `RE_SNAPSHOT` now runs on the flat form only, so a tab, two spaces, a quoted
snapshot path or `;` with no blank all reduce to the shape it expects. For any other process the general
rules run on the flat form first, then on the raw form. The flat form finds `bash scripts/test-x.sh;echo
done`, `docker compose` and `docker<TAB>compose`. The raw form keeps every R4 match, such as a script
named `gate;1.sh` passed as one argument, which the flat form splits.
3. The walk over a wrapper's text. `sourced()` is now an awk program that splits the wrapper's raw text
into words the way the shell does. A blank, a newline or one of `; & | ( ) < >` and backtick ends a word.
`'...'` and `"..."` are parts of a word, with the quotes removed. A backslash keeps the next character,
and `$'...'` and `$"..."` read as quotes. A span starts after a word that is `source` or `.`, quoted or
not, and runs to the next `;`, `&`, `|` or newline outside parentheses and backticks. Every word in the
span is checked, not only the first, against the gate rule, the `mutant` rule and the test-script rule,
each anchored at the word's end. So a quoted path with spaces is one word that still ends in
`/scripts/test-x.sh`, and `source 2>/dev/null T`, `source $(cd scripts && pwd)/test-x.sh` and the
backtick form match. Quoted text that holds a blank, an operator, a quote or a backslash is walked again
as shell text, which covers `eval '...'`, nested `bash -c` and the harness's `'\''` form. A span still
open at the end of quoted text stays open after the word. The first `shell-snapshots/snapshot-*.sh` word
in a span is the harness's own snapshot and is skipped whatever its name, and so is a later word equal
to it.
What the walk can't classify:
- An unbalanced quote isn't an error. The quote runs to the end of the text, and that text is walked again,
so a `source` span after it still counts and gives BUSY. An error there would turn every wrapper with a
stray apostrophe (`don't` in a heredoc, a commit message) into exit 2 and stall every gate on the host,
while reading on keeps the one property that matters: a named test, gate or mutant file in a source
argument is never CLEAR. The cost is a false BUSY when the quoted text only mentions a test.
- Quoting deeper than 32 walk levels is exit 2, "cannot classify the command line of PID". Each level of
escaped double quotes costs the walk about two levels, so 16 levels still read and 17 don't. Each level
walks its quoted text again, so the limit also bounds the time on a text that is megabytes long.
- A failed awk, or output that is neither a rule nor "none", is exit 2 with the same message. The scan goes
on, so other BUSY lines still print, and the final line says the host isn't known to be idle.
The first version of the flat form was bash, `${s//x/y}` per character class. A fake proc root holding one
2 MB command line full of quotes then took 36 s to 135 s to scan, since each replacement costs time per
match. A busy check that takes minutes stalls every gate that waits on it. With the awk version the same
root takes 238 ms to 478 ms as a plain process and 320 ms to 1387 ms as a wrapper, over 12 fill patterns
(quotes, operators, blanks, backslashes, an unclosed quote, repeated `source` and `.` words). I checked the
awk output against the bash version's on 27 inputs. On the real `/proc` (975 pids) R5 took 1317 ms to
1349 ms against R4's 1168 ms to 1172 ms, both CLEAR at the time.
Tests (all RED on R4 except the controls named here):
- Rule cases on the wrapper shape, "sourced test script", RED on R4: `source 2>/dev/null T`, `source $(cd
scripts && pwd)/test-x.sh`, the backtick form, `source "/seat/a;b (c)/scripts/test-x.sh"`, `eval 'source
"/seat/a;b/..."'`, an `eval "..."` with escaped quotes around a path with spaces, the harness's
`eval 'source '\''scripts/test-x.sh'\'''` with `pwd -P >|`, and a heredoc holding `don't` before
`source "it's dir/scripts/test-x.sh"`. Two more pass on R4 and stay as controls:
`eval $'source scripts/test-x.sh'`, and an unclosed `"` before `source /seat/scripts/test-x.sh`.
- General rule cases: "test script" for `bash -c "bash scripts/test-x.sh;echo done"`, "docker compose" for
two spaces and for a tab between `docker` and `compose`, and "gate" for `bash "/seat/gate;1.sh"` (a
control for the raw form; it passes on R4).
- CLEAR cases: a wrapper whose own snapshot is named `snapshot-bash-3-gate.sh`, bare and quoted (controls
that pass on R4), and the same snapshot sourced twice (RED on R4, which read the second as a gate).
- Depth: "a sourced test under 16 escaped quoting levels (131094 bytes): exit 1, sourced test script" and
"quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR".
- awk failure: a shim that fails or prints nothing for one program only, keyed on the walk's `function
walk` and the flat form's `gsub`: "the wrapper walk's awk exits 1", "the wrapper walk's awk prints
nothing", "the flat form's awk exits 1" and "the flat form's awk prints nothing", each "exit 2, cannot
classify, no CLEAR".
- Live wrappers, one per shape in the review's table: a real `bash -c` in `$SANDBOX/wrap` whose sourced
file writes a ready marker and blocks in `read -t 60 -u 3` on a fifo. Each case checks the marker, an
empty children file, exit 1, no CLEAR and a BUSY line naming the wrapper's pid, the rule and its cwd,
through a proc root that links only that wrapper. Shapes 1 to 6 pass on R4 and must still pass; shapes 7
to 13 fail on R4. Live shape 14 is a plain wrapper doing the same blocking read inline, which must stay
CLEAR. The suite kills and reaps each wrapper before the next. R4's external `node --test` child control
(F2, F5) is unchanged and still passes: exit 1 on the child's line, no line for its wrapper.
Mutants, all killed: `quotes-kept`, `operators-kept`, `snapshot-single-space`, `flat-skipped`,
`flat-double-blank-missed`, `special-tab-missed`, `flat-failure-passes`, `flat-sentinel-unchecked`,
`raw-rules-dropped`, `flat-rules-dropped`, `source-arg-first-word`, `no-requote`, `quoted-span-reset`,
`span-ends-in-parens`, `backtick-ignored`, `sq-kept`, `dq-kept`, `dollar-quote-glued`, `backslash-kept`,
`snapshot-path-checked`, `snapshot-repeat-checked`, `depth-unbounded`, `awk-failure-none`,
`lexer-garbage-none` and `classify-error-passes`. Each edits one line once; the table below gives the site
by name.
Sites R5 moved, same intent: `snapshot-matched` deletes the new snapshot line, `no-node-rule` and
`no-compose-rule` drop the rule from `rules()`, `snapshot-substring` turns the flat-form match into a
substring test, `denied-listing-gone` edits the new `ls` line, `sourced-test-hidden` makes the snapshot
branch return 1, `sourced-first-only` ends the walk at the end of the first span, and `dot-source-missed`
drops `.` from the span start.
New false BUSY. Each one only delays a gate, and each was a choice in favor of never giving CLEAR while a
named file runs. I checked each example on a fake proc root holding that one process, with R4 and R5
(the claims table under "R5 runs").
New in R5 (CLEAR on R4, BUSY on R5):
- Every word in a span counts, not only the sourced file. So the file's arguments count
(`source env.sh scripts/test-x.sh`), and so do redirection targets (`source x 2>notes-mutant.log`)
and the words inside `$(...)` and backticks.
- A `.` word starts a span, so `find . -name 'test-*.sh'` is BUSY.
- A span doesn't end at `;`, `&` or `|` inside parentheses, `$(...)` or backticks, so
`( source x; cat scripts/test-x.sh )` is BUSY. That keeps `source $(cd scripts && pwd)/test-x.sh` one
span. An unbalanced `(` keeps the span open to the end.
- A span open at the end of quoted text stays open: `echo 'x; source' scripts/test-x.sh` is BUSY.
- The general rules now see the flat form in every process. So a shell's `-c` text that names a test,
gate or mutant file next to a quote or an operator is BUSY, where R4 needed a blank after `.sh`:
`bash -c 'cat scripts/test-x.sh|wc -l'`.
Kept from R4 (BUSY on both): `grep -rn x . scripts/test-x.sh`, `git add . gate.sh`,
`grep -n source scripts/test-x.sh`, `echo source scripts/test-x.sh`, a commit message
`git commit -m "fix; source scripts/test-x.sh"` (also with the path in single quotes), a `#` comment line
naming one, the text after an unbalanced quote, and a second, different snapshot path whose name matches the
gate rule. R5 still reads quoted text again as shell text and doesn't know comments, so these stay BUSY.
Two more, by reasoning, not tested:
- Waiters can block each other. Take two wrappers that each run the check and whose text also names a
test in a source span. Each sees the other as BUSY until one gives up. R4 had the same risk with fewer
shapes.
- Text quoted more than 32 levels deep is exit 2 (that part is tested), which also holds a gate.
### N3: a correction to the R4 limit on bind mounts
R4's known limits say a mount-point test, "such as the hooks directory's st_dev against its parent's",
would catch a bind-mounted hooks directory. That is wrong for the case it names. A different st_dev finds
a mount of a different filesystem. A bind mount of a directory from the same filesystem keeps the device
number, so the comparison passes it. A detector would have to read mount metadata, such as
`/proc/self/mountinfo`, and look for a mount point at the hooks directory or between it and the
repository. None is built; the limit stands. The R4 text stays as written; this entry supersedes it. No
code changed for N3.
### What didn't change
`scripts/git-hooks/pre-push` and `scripts/install-git-hooks.sh` are untouched, and their cases pass
unchanged in GREEN. The ancestor walk, the read loop, the gone rule (a successful literal listing that
lacks the pid) and the exit codes are as R4 left them, apart from the new "cannot classify" error.
`docs/TOOLS.md` now gives 153 cases, the flat form, the span rule, the exit 2 on text it can't classify, and
the literal listing.
### R5 runs
`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was under `timeout -k 10 1800`,
one at a time, with output to files there. The suite runs were in the foreground. The mutant runner took
12 minutes 41 seconds, longer than the tool's 10-minute foreground limit. So it ran as a background job of
the tool, under the same `timeout`, and nothing else ran until it ended.
- RED, all new tests in place and R4's busy check, 23:32:37Z to 23:33:12Z: 119 passed, 34 failed. For this
one run the checkout's `scripts/slot-busy.sh` was fd1aa8cc8's copy (`git diff --quiet fd1aa8cc8` on it
passed), then R5's was put back (sha256 `8571e27d...` before and after). The hook and the installer are
the same blobs in R4 and R5. All 105 R4 tests pass there. Of the 48 new tests, 34 fail and 14 pass. The 14
are the controls named above: `bash "/seat/gate;1.sh"`, `eval $'source scripts/test-x.sh'`, the unclosed
`"` before a source span, the two `snapshot-bash-3-gate.sh` wrappers, the `QUOTING_STYLE=escape` pair,
live shapes 1 to 6 and live shape 14. Two earlier RED runs at 148 cases gave 117/31 and 119/29. In the
first, a fake pid that one of my new tests left in a shared fake root made two other tests fail. I fixed
that in the test before the second run. The last five cases added after that all fail on R4.
- GREEN, 23:33:23Z to 23:33:35Z: 153 passed, 0 failed. The three code files haven't changed since.
- R4's own suite, in a scratch tree of fd1aa8cc8's four scripts, 23:47:46Z to 23:47:53Z: 105 passed,
0 failed. Its 105 labels all appear in GREEN, and GREEN's other 48 are the new tests above.
- Mutants, 23:33:53Z to 23:46:34Z: base green (153/0), runner exit 0, 57 of 57 killed, none survived, no
NO MATCH, no COPY FAILED, no timeout. Every kill exited 1. A DRY run before it matched all 57 sites
once.
| Mutant | FAIL | First failing case |
|---|---|---|
| ancestor-dropped | 8 | push --force of a rewritten branch is refused |
| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first |
| delete-passes | 13 | the install into a clean clone exits 0 |
| install-over-hookspath | 2 | it says core.hooksPath is set |
| busy-exits-0 | 52 | a node --test process: exit 1 |
| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) |
| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes |
| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install |
| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused |
| snapshot-matched | 44 | no matching process: exit 0 and CLEAR |
| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line |
| no-node-rule | 10 | a node --test process: exit 1 |
| no-compose-rule | 4 | busy on: docker compose -p x up -d (docker compose) |
| unreadable-read-clear | 13 | a pid directory with no cmdline: exit 2 |
| vanished-is-error | 3 | a process that vanishes during its read isn't an error: exit 0 and CLEAR |
| read-errors-ignored | 18 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR |
| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) |
| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused |
| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) |
| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref |
| ancestor-read-unchecked | 7 | an ancestor's status read that fails after its own read passed: exit 2, no CLEAR |
| exited-ancestor-error | 1 | an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR |
| denied-lookup-vanished | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| denied-listing-gone | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| dir-check-skips | 1 | a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR |
| sourced-test-hidden | 36 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| sourced-first-only | 34 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| dot-source-missed | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && . /seat/scri |
| installer-fixed-tmp | 2 | the install writes nothing through a link planted at a pid-named temporary path |
| ls-env-quoting | 8 | QUOTING_STYLE=shell-always: a failing cmdline read with the pid still listed: exit 2, no CLEAR |
| locale-kept | 1 | under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1 |
| quotes-kept | 1 | live shape 11, source "S" && source "T", no child: exit 1, sourced test script |
| operators-kept | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| snapshot-single-space | 2 | busy on: /bin/sh -c docker compose -p x up (docker compose) |
| flat-skipped | 10 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| flat-double-blank-missed | 1 | busy on: /bin/sh -c docker compose -p x up (docker compose) |
| special-tab-missed | 1 | busy on: `/bin/sh -c docker<TAB>compose -p x up` (docker compose) |
| flat-failure-passes | 2 | the flat form's awk exits 1: exit 2, cannot classify, no CLEAR |
| flat-sentinel-unchecked | 1 | the flat form's awk prints nothing: exit 2, cannot classify, no CLEAR |
| raw-rules-dropped | 1 | busy on: bash /seat/gate;1.sh (gate) |
| flat-rules-dropped | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| source-arg-first-word | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source 2>/de |
| no-requote | 10 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n |
| quoted-span-reset | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "/sea |
| span-ends-in-parens | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source $(cd |
| backtick-ignored | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source `cd s |
| sq-kept | 6 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n |
| dq-kept | 13 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "scri |
| dollar-quote-glued | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval $'sourc |
| backslash-kept | 3 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval "source |
| snapshot-path-checked | 3 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo h |
| snapshot-repeat-checked | 1 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && . /hom |
| depth-unbounded | 1 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| awk-failure-none | 1 | the wrapper walk's awk exits 1: exit 2, cannot classify, no CLEAR |
| lexer-garbage-none | 2 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| classify-error-passes | 5 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
The runner cuts each line at 100 characters, which is why some first failing cases end mid-word.
Live probes, 23:46:56Z to 23:47:18Z, outside the suite (`probe.sh` in TMPDIR, exit 0). Each shape is one
real `bash -c` started in `$P/wrap`, whose sourced file writes a ready marker and blocks in
`read -t 60 -u 3` on a fifo. With the marker present and the children file read, R4's and R5's
`slot-busy.sh` each scanned a proc root that links only that wrapper. Then R5 scanned the real `/proc`,
and the table counts its BUSY lines for that pid. Each wrapper was killed and reaped before the next, and no
"still alive" warning printed. `S` is an empty snapshot under `$P/shell-snapshots`, `SF` one that runs
`false`, and `T` is `$P/scripts/test-x.sh`. Shape 2's path is relative to `$P/wrap`.
| Shape | Wrapper text | Pid | Marker, children | R4 | R5 | BUSY lines on the real /proc (R5) |
|---|---|---|---|---|---|---|
| 1 | `source S && source T` | 732550 | ready, none | exit 1, BUSY 732550 sourced test script | exit 1, BUSY 732550 sourced test script | 1 |
| 2 | `source S && .<TAB><TAB>'scripts/test-x.sh'` | 737522 | ready, none | exit 1, BUSY 737522 sourced test script | exit 1, BUSY 737522 sourced test script | 1 |
| 3 | `source S ; source T ; :` | 742569 | ready, none | exit 1, BUSY 742569 sourced test script | exit 1, BUSY 742569 sourced test script | 1 |
| 4 | `source SF \|\| source T` | 747729 | ready, none | exit 1, BUSY 747729 sourced test script | exit 1, BUSY 747729 sourced test script | 1 |
| 5 | `source S && . "./gate.sh"` | 755860 | ready, none | exit 1, BUSY 755860 sourced gate | exit 1, BUSY 755860 sourced gate | 1 |
| 6 | `source S && source ./mutants.sh` | 760031 | ready, none | exit 1, BUSY 760031 sourced mutant | exit 1, BUSY 760031 sourced mutant | 1 |
| 7 | `source S && source ".../dir with spaces/scripts/test-x.sh"` | 763715 | ready, none | exit 0, CLEAR | exit 1, BUSY 763715 sourced test script | 1 |
| 8 | `source S && "source" T` | 767367 | ready, none | exit 0, CLEAR | exit 1, BUSY 767367 sourced test script | 1 |
| 9 | `source S;source T;:` | 771113 | ready, none | exit 0, CLEAR | exit 1, BUSY 771113 sourced test script | 1 |
| 10 | `source S;. T;:` | 774899 | ready, none | exit 0, CLEAR | exit 1, BUSY 774899 sourced test script | 1 |
| 11 | `source "S" && source "T"` | 778649 | ready, none | exit 0, CLEAR | exit 1, BUSY 778649 sourced test script | 1 |
| 12 | `source<TAB>S && source "T"` | 782425 | ready, none | exit 0, CLEAR | exit 1, BUSY 782425 sourced test script | 1 |
| 13 | `source<SPACE><SPACE>S && source "T"` | 786085 | ready, none | exit 0, CLEAR | exit 1, BUSY 786085 sourced test script | 1 |
Claims, 23:47:24Z to 23:48:44Z (`claims.sh` and `claims3.sh` in TMPDIR). Each row is one process in a fake
proc root, scanned by R4 and R5. Unless the row says otherwise, the argv is
`/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && TEXT"`.
| TEXT | R4 | R5 |
|---|---|---|
| `source env.sh scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script |
| `source x 2>notes-mutant.log` | exit 0, CLEAR | exit 1, sourced mutant |
| `find . -name 'test-*.sh'` | exit 0, CLEAR | exit 1, sourced test script |
| `( source x; cat scripts/test-x.sh )` | exit 0, CLEAR | exit 1, sourced test script |
| `echo 'x; source' scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script |
| argv `/bin/bash -c 'cat scripts/test-x.sh\|wc -l'`, no snapshot | exit 0, CLEAR | exit 1, test script |
| `grep -rn x . scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `git add . gate.sh` | exit 1, sourced gate | exit 1, sourced gate |
| `grep -n source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `echo source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `git commit -m "fix; source scripts/test-x.sh"` | exit 1, sourced test script | exit 1, sourced test script |
| `git commit -m "fix; source 'scripts/test-x.sh'"` | exit 1, sourced test script | exit 1, sourced test script |
| `echo hi`, then a line `# source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `echo "it's && source /seat/scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `source /home/u/.claude/shell-snapshots/snapshot-bash-9-gate.sh && echo hi` | exit 1, sourced gate | exit 1, sourced gate |
| `source $'scripts/test\x2dx.sh'` | exit 0, CLEAR | exit 0, CLEAR |
| `f=scripts/test-x.sh; source "$f"` | exit 0, CLEAR | exit 0, CLEAR |
| argv `/bin/bash -c 'source "/home/a b/.claude/shell-snapshots/snapshot-bash-1.sh" && source scripts/test-x.sh'` | exit 1, test script | exit 1, test script |
| the same with `&& node --test x.test.mjs; :` in place of the second source | exit 1, node --test | exit 1, node --test |
| `echo hi && ls -la` | exit 0, CLEAR | exit 0, CLEAR |
The ANSI-C and variable rows are the misses under "Known limits after R5". The two rows with a blank in the
snapshot path show that such a wrapper isn't skipped and goes through the general rules. Run as
`env SHELLOPTS=noglob bash slot-busy.sh`, both versions exit 2 with "no processes", on a fake root holding
`sleep 1` and on the real `/proc` (23:47:40Z). A first try set `SHELLOPTS=noglob` as a prefix
assignment. bash refused it ("readonly variable") and ran the check without it, so that row is the `env`
run.
- Real host, 23:50:13Z to 23:50:17Z: `bash slot-busy.sh` on the real `/proc`, R4, R5, R4, R5. Each exited
0 with CLEAR. R4 took 1157 ms and 1183 ms, R5 1352 ms and 1275 ms, over about 965 pids. The earlier
timing runs above gave R5 145 ms to 181 ms more than R4.
- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the
positive control). None names a path this round changes, and none holds the old or new sha256 or blob id
of the three changed code files. `git grep` for any of those twelve ids over the whole tree finds nothing.
No test reads `docs/TOOLS.md`, `BUILD-LOG.md`, `docs/SESSIONS.md` or this file. `gate-lane-auth.test.mjs`
leaves `docs`, `agents` and `BUILD-LOG.md` out of its copy, and `stack-release.test.mjs` only names
TOOLS.md in an expected message.
### Known limits after R5
- `git push --no-verify` skips the hook. Gitea branch protection is the guard that holds (unchanged).
- The busy rules read command lines as text. The walk is a model of shell word splitting, not bash's
parser: it doesn't know comments, `case` patterns, arithmetic or here-strings. The false BUSY cases above
are the price of reading every quoted and spanned word.
- Still missed: a test sourced by a path built at run time (a variable, a glob, command substitution
output), by an ANSI-C escape (`$'scripts/test\x2dx.sh'`, which the walk reads as plain quotes), by a
nested file, a function or an alias, or by a shell reading its commands from stdin or a terminal. The
command line doesn't show any of those, and the check may not read another seat's files (the review's
Check 6, item 2).
- A snapshot path with a blank in it, from a home directory with a space, doesn't match `RE_SNAPSHOT`, so
that wrapper is never skipped. Its text goes through the general rules, which can only add BUSY lines.
- `SPECIAL` must list every character `flat()` changes, or a command line holding only that character keeps
its raw form. The tab and two spaces are pinned by tests and mutants; CR, VT and FF are listed but not
pinned.
- `--quoting-style` needs GNU ls. BSD or BusyBox ls fails every listing, which fails closed (exit 2 on any
failed read) but turns every exit race into an error.
- `-1` is a no-op for a pipe, so no test pins it.
- Exported functions, `BASH_ENV` and `PATH` are the caller's to set; with them a caller can replace the tools
the check runs.
- A scan costs 145 ms to 181 ms more than R4's on this host (975 pids), and a single 2 MB wrapper text took
up to 1.4 s.
- A bind-mounted hooks directory isn't detected (corrected by N3 above).
- The installer has a check-then-use window after its path checks (unchanged).
- A BUSY line holds the command line as it is, so a wrapper whose `-c` text has newlines prints over several
output lines. Chain on the exit code, never on the lines (unchanged advice).
- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and
can't make it BUSY. This host's `/proc` has no hidepid option (unchanged).
+1
View File
@@ -1882,3 +1882,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R3 from the GPT review, on t3/row211-push-guard-r3-fix over 09a847eda (picks 5734c1a80, c8f2cb70c) | F1-F5 and the object-name length check fixed with tests; suite RED 67/18, GREEN 85/0; 21 of 21 mutants killed; one local commit, not pushed
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R4 from the GPT review of R3, on t3/row211-push-guard-r4-fix over 09a847eda (picks bcd74ec33, 3a121885c, 7d03f212d) | R1-R3, runner exit status and installer mktemp fixed with tests; suite RED 90/14, GREEN 105/0; 30 of 30 mutants killed, runner exit 0; one local commit, not pushed
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R5 from the GPT review of R4, on t3/row211-push-guard-r5-fix over 09a847eda (from fd1aa8cc8) | N1-N3 fixed (literal ls listing under LC_ALL=C, flat form and awk word walk for sourced tests, bind-mount note corrected) with tests; suite RED 119/34, GREEN 153/0; 57 of 57 mutants killed, runner exit 0; 13 of 13 live shapes BUSY; two local commits, not pushed
+15 -6
View File
@@ -166,7 +166,7 @@ resolved through the CLI.
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 105 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 153 cases |
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath`, a symlinked hooks directory or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error, including a process or an ancestor it can't read that isn't confirmed gone; chain on the exit code |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
@@ -199,11 +199,20 @@ Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
and agent shell-snapshot wrappers (a shell whose `-c` text starts by sourcing
`.../shell-snapshots/snapshot-*.sh`). A wrapper whose text also sources
(`source` or `.`) a gate, test or mutant script is BUSY, because that script
runs in the wrapper's own shell. It reads only `/proc/<pid>/cmdline`, the cwd
link, its own and its ancestors' `status` and, after a failed read, the proc
root's listing. A process or an ancestor it can't read makes it exit 2, never
`.../shell-snapshots/snapshot-*.sh`). Each rule runs on the command line as it
is and on a flat form with the quotes and backslashes removed and every run of
blanks, newlines and the operators `; & | ( ) { } < >` and backtick turned into
one space, so quoting and spacing don't hide a match. A wrapper is BUSY when
any word after a `source` or `.`, up to the next `;`, `&`, `|` or newline
outside parentheses and backticks, names a gate, test or mutant script,
because that script runs in the wrapper's own shell. Quoted text, an `eval`
string for one, is read the same way. That rule can call a wrapper BUSY that
runs no test; row 211's BUILD.md (R5) lists the cases. A command line it can't
classify, quoting more than 32 levels deep for one, makes it exit 2. It reads
only `/proc/<pid>/cmdline`, the cwd link, its own and its ancestors' `status`
and, after a failed read, the proc root's listing, which it takes from GNU
`ls --quoting-style=literal` under `LC_ALL=C` so the caller's environment
can't change it. A process or an ancestor it can't read makes it exit 2, never
CLEAR, unless a listing of the proc root succeeds and no longer has that pid.
## Seat launch with registration (`scripts/mosaic`)