diff --git a/docs/active-work.md b/docs/active-work.md index fc91e78..e0816fd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,6 +265,208 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and durable facts are in the retired registry row and the handoff §6 census. +## `scripts/gate --protocol` build step — IMPLEMENTED at `49bc141`, RE-OPENED by review, witness CLOSED at `677fd25`. No PR yet + +**PR #229 OPEN** — https://github.com/levineuwirth/pmacs/pull/229, +opened at `93d557a`. **Held, not merged.** Its first CI run went red on +`Test (macos-latest / lua54)`; the rerun turned that selector green and +went red on a **different** one. Both are recorded as **U4** and **U5** +in `docs/ci-red-signatures.md`, as separate incidents per the matching +rule rather than one signature twice. + +**Registry numbering conflict, expected at merge.** +`worker-identity-stage1` independently defines its own **U4** and **U5** +on its branch. This lane merges first, so on `main` the rows above are +U4/U5 and **worker identity must renumber its pair on rebase**. Flagged +here because a rebase that resolves the textual conflict without +renumbering produces two different incidents sharing an id, which is +exactly the failure the registry's matching rule exists to prevent. + +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `gate-protocol-build`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/gate-protocol-build` is the +authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout gate-protocol-build`. + +- **Framing `docs/gate-protocol-build-framing.md`, revision 5.** The + fix itself is implemented and green at `49bc141`; **its regression + witness landed separately at `677fd25`**, after review found the + original witness did not reach the step it named. Narrow by design: + one missing step in one script, plus the boundary question that let + it go missing. No `src/`, no protocol, no feature work. +- **WAS THE OPEN BLOCKER — the witnesses did not reach the step they + name. CLOSED at `677fd25`.** `--print-plan` **strips names** before + printing, so the ordering assertion saw only commands; `--self-test` + **hardcodes** `build-crdt` inside its own synthetic plan. Review + demonstrated the consequence: **renaming the real build step to + `sweep-crdt` left both tests passing.** So the lane had shipped + without the regression guard it was created to provide. §7 requires + **both** real emitter pairs — `build-crdt` and `sweep-crdt`, name + *and* exact command — because the hole is symmetric and revision 4 + closed only half of it. The synthetic `--self-test` stays: it + witnesses the *runner* (failure naming, `FAILED:` list, log paths, + non-zero exit, and continuation via the sentinel), which is a + different thing from attributing the real step, and it may no longer + stand in for it. **What closed it is the "THE WITNESS DID NOT REACH + THE STEP" bullet further down** — `--print-plan-named`, with all four + renames and drifts mutated red. +- **The defect, as found.** `--protocol` adds the CRDT workspace sweep, + whose documented precondition is `cargo build --workspace + --no-default-features --features luajit,crdt` — documented in handoff + **§5** at the time, **§3** now. The plan emitter had **no build step + at all** — read from the source, not inferred from the failure. +- **Why it was latent, and why that makes it urgent rather than tidy.** + Before #225 every worktree shared one `CARGO_TARGET_DIR`, which + almost always already held a `pmacs-gpu` binary, so the precondition + was satisfied **by accident**. Per-worktree target dirs start empty. + The hazard is not the red gate that stops you — it is a **green** + `--protocol` run whose crdt sweep was decided by the state of the + build directory rather than by the diff. That is a gate reporting + coverage it does not have, which is what #225 exists to prevent. +- **Observed on PR #228's first gate run:** twelve + `gpu_invocation_acceptance::crdt::*` failures, all *"build pmacs-gpu + before this acceptance suite"*, with `debug/pmacs-gpu` absent. +- **The durable half is a boundary question.** `scripts/gate`'s header + names handoff **§3** as the owner of its reasoning, and this + precondition lives in **§5** — a coherent cause for the omission, not + mere oversight. + + **Resolved in framing revision 2: §3 becomes the SOLE normative home, + §5 keeps the incident and its signature as history, and the script's + header keeps citing §3 ALONE.** Revision 1 proposed citing both, + which splits one executable contract across two homes and weakens the + script's only clean boundary at the same time as Q#GR-4 declines to + build any automated check for prose drift. This entry recorded that + superseded decision until now; a recovering machine reading the stale + version would have rebuilt revision 1's wrong boundary. +- **Q#GR-1 — SETTLED BY OBSERVATION, 2026-08-09, before any fix was + written.** On a **disposable** target directory (never a live lane's), + with `debug/pmacs-gpu` asserted **absent before each run** as a + recorded precondition, each sweep run **alone** from that same cold + state so neither could have built the binary for the other: + + | sweep | exit | result | `pmacs-gpu` after | + |---|---|---|---| + | default | **0** | green, 114 test targets | **still absent** | + | crdt | **101** | exactly **12** failures, all `gpu_invocation_acceptance::crdt::*`, all *"build pmacs-gpu before this acceptance suite"* | still absent | + + So framing §3's inference **holds** and §4's *"only under + `--protocol`"* is correct — the default sweep never builds the binary + and never needs it. **Mechanism, now established rather than + guessed:** `pmacs-gpu` has no `tests/` directory, so cargo never + uplifts its bin to `debug/pmacs-gpu`; only an explicit `cargo build` + produces it. + + **Found while doing it, and worse than the twelve:** + `bottom_panel_stage2b_gpu` a54 reported **`ok`** in that cold crdt + sweep. Its only path that does not spawn `pmacs-gpu` is its skip + branch, so a test whose whole purpose is real wgpu rendering passed + having rendered nothing. The missing build does not merely fail + twelve tests — it voids coverage in tests that report green. + (`vterm_stage3` a37 has the same shape by source read; cargo captures + passing tests' output, so the skip is invisible in the log.) +- **What landed.** A named `build-crdt` step emitted immediately before + `sweep-crdt` under `--protocol`, carrying the exact §5 invocation — + **not** folded into the sweep command, because `cargo build … && + cargo test …` reports a *build* failure under the name `sweep-crdt`. + Plus **`--self-test`** (Q#GR-5): a hardcoded three-line synthetic plan + — pass, fail-named-`build-crdt`, **pass sentinel** — driven through + the *real* runner loop, which is what makes the **runner's** failure + naming *and* continuation observable at all. (It does **not** witness + the real step's name — see the round-two entry below, which is where + that gap was found and closed.) `PLAN_FILE` is deliberately **not** + injectable: that would turn the runner's `eval` into a general command + executor, the same defect this script's review caught in + `--acceptance`. +- **THE WITNESS DID NOT REACH THE STEP — found in review of the + implementation, closed at `677fd25`.** The lane shipped without the + regression guard it was created to provide, because **neither witness + could see a name**: `--print-plan` renders `emit_plan | cut -f2-`, so + the ordering test compared *commands* with the names cut off, and + `--self-test` hardcodes the string `build-crdt` in its **own + synthetic** plan, so it proves things about the runner and nothing + about the real emitter. Review demonstrated it directly: **renaming + the real build step to `sweep-crdt` left both tests passing** — a plan + that would report a build failure under the sweep's name, sitting + green, which is the exact misattribution the separate step exists to + prevent. + + **The fix is `--print-plan-named`**: a second *rendering* of the same + `emit_plan`, printing the `namecommand` text the runner reads + back from `PLAN_FILE`, asserted by **whole-line equality** so name and + command are pinned together, and `sweep-crdt`'s pair asserted too + (asserting only the build's name leaves the identical hole open in the + other direction). **`PLAN_FILE` remains uninjectable** — a test that + supplied the runner's plan would turn its `eval` into a general + command executor, the defect the `--acceptance` refusal exists to + prevent — and **`--self-test` stays**, witnessing the *runner* (failure + naming, `FAILED:`, log paths, non-zero exit, continuation via the + sentinel), which it may no longer *stand in for* attribution of the + real step. A companion test pins `--print-plan` as that rendering + minus its names, so the two cannot drift into asserting a name the + runner never uses. Both new tests are on the **no-gates** paths. + + **Mutated individually, each now red** (the first is the one the + previous round passed): build renamed `build-crdt` → `sweep-crdt`; + sweep renamed `sweep-crdt` → `crdt-sweep`; build features + `luajit,crdt` → `luajit`; build emitted **after** the sweep. Suite is + 20 tests. +- **AUDITED FOR THE SAME DEFECT ELSEWHERE, and one instance is left + open deliberately.** Renaming **every other** plan step — `fmt`, + `clippy`, `lib`, `m4`, `gpu`, `sweep`, `diff-check`, + `acceptance-` — leaves all 20 tests green: no test asserts any + step name but `build-crdt` and `sweep-crdt`. For most that is only a + log filename and a `FAILED:` entry. **`sweep` is not**: the runner's + end-of-run listing globs `"$LOGDIR"/*-sweep.log` and + `*-sweep-crdt.log`, so renaming that step silently empties the *"read + these, do not re-run and grep"* listing that is the U2/U3 remedy, with + the suite still green. **Not closed here**: the listing only exists on + the *run* path, and every test in this file is deliberately no-gates, + so there is no cheap witness for it — recorded rather than papered + over. +- **Blocks PR #228 (discovery Stage 2).** That lane's `--protocol` + result needs re-establishing on a fresh target dir under the repaired + script. Deliberately **not** folded into that feature branch, and it + happens **after** this lands, not inside it. +- **Acceptance criterion, witnessed 2026-08-09.** + `scripts/gate --acceptance gate_script_acceptance --protocol` on a + target root that **did not exist** (precondition recorded, not + assumed): all eleven steps green, `09 build-crdt ok` producing + `debug/pmacs-gpu`, and `gpu_invocation_acceptance` at **15 passed / + 0 failed** where the same suite was 3/12 without the build step. + Zero occurrences of the *"build pmacs-gpu"* signature in the sweep + log, and a54/a37 ran for real rather than taking their skip branches. + **No manual build anywhere** — which is the thing that was false. +- **UNEXPLAINED RED, recorded rather than swept up.** An earlier + attempt at the same cold run failed step 10 with **36 + 4 + 6 + 4 + failures across `m5_5`/`m5_6`/`m5_7`/`m5_8`**, all real-daemon + suites, all with signature *"daemon exited with exit status: 101 + before socket appeared; socket=/tmp/.tmpXXXX/pmacs.sock — ``"*. **Not** the `pmacs-gpu` signature, and no row in + `docs/ci-red-signatures.md` matches it. Re-running the same test + binary from the same target directory gave 36/36 green, which by + that registry's own rule establishes **intermittence only, never + environmental cause** — so this stays open rather than being + attributed to the load (~25–30 across four concurrent lanes' gates). + **De-implicated from `build-crdt` by construction, not by the green + rerun:** the root crate's `default = ["luajit"]`, so + `--no-default-features --features luajit,crdt` enables *exactly* the + same feature set as the sweep's `--features crdt`. The build step + cannot hand the sweep a differently-featured binary, so it has no + mechanism by which to break a daemon suite. Local, not CI, so not a + registry row; noted here for whoever sees it next. +- **Gates:** `scripts/gate --acceptance gate_script_acceptance`. Note + the recursion — this lane edits the script that runs its own gates, + so `--print-plan`, `--print-plan-named`, `--help` and `--self-test` + were also checked by hand after each edit: a change that breaks the + script cannot be reported honestly by the script. The assertions were + **mutation tested**: wrong features, wrong position, unconditional + emission, an aborting runner, the build folded into `sweep-crdt`, and + — added in the second round — a **rename of either** the build or the + sweep step each fail the suite. + ## QoL arc retirement — PR #224 OPEN (docs only) **PR #224** — https://github.com/levineuwirth/pmacs/pull/224. Written diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 19155ca..65e7374 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -529,10 +529,21 @@ someone forgot. which is why name-keyed lists are not trustworthy. - **`basedpyright` hangs forever** — always `cargo test --test m4_acceptance -- --skip basedpyright`. -- **The crdt sweep needs `cargo build --workspace` first**, or twelve - `gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu` - binary. `cargo build --workspace --no-default-features --features - luajit,crdt` is the invocation that produces both binaries. +- **INCIDENT — the crdt sweep's missing build step. §3 now owns this + requirement; what stays here is the history and the signature.** + Signature: twelve `gpu_invocation_acceptance::crdt::*` failures, each + *"build pmacs-gpu before this acceptance suite"*, on a target + directory with no `debug/pmacs-gpu` in it. Seen on **PR #228's first + gate run** (2026-08-09) — the first `--protocol` run in a *fresh* + per-worktree target directory after #225. Latent for the whole life + of the shared `CARGO_TARGET_DIR`, which nearly always already held + the binary, so the precondition was met by accident. Reproduced + deliberately the same day on a disposable cold target: default sweep + exit 0, crdt sweep exit 101 with exactly those twelve. + **`scripts/gate --protocol` now runs the build as a named + `build-crdt` step**, so this signature appearing again means the + script was bypassed, not that the requirement moved. The invocation, + the measurement and the reasoning are in §3. - **A shared `CARGO_TARGET_DIR` makes concurrent sweeps unattributable.** Every worktree on this machine resolves to the same target directory, so `target/debug/pmacs` is a **shared mutable file**: a @@ -2298,15 +2309,50 @@ eight broken version assertions on CI. When the two disagree, **this list wins**. **Touching `PROTOCOL_VERSION` STRENGTHENS the sweep line. It does not -replace it:** +replace it — and the crdt sweep has a BUILD PRECONDITION:** ``` cargo test --workspace --no-fail-fast -- --skip basedpyright +cargo build --workspace --no-default-features --features luajit,crdt cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright ``` Every part is load-bearing: +- **The build before the crdt sweep is a PRECONDITION, not a courtesy, + and it is gate policy rather than an ops tip.** The crdt sweep spawns + `pmacs-gpu` as a *process*, and no `cargo test` run produces that + binary: `pmacs-gpu` has no `tests/` directory, so cargo never uplifts + its bin to `debug/pmacs-gpu`. Omit the build on a cold target + directory and twelve `gpu_invocation_acceptance::crdt::*` tests fail + on *"build pmacs-gpu before this acceptance suite"* — and, worse, + crdt tests that drive the real binary (`vterm_stage3` a37, + `bottom_panel_stage2b_gpu` a54) take their skip branch and report + **`ok`**, so the missing build also voids coverage *silently*. + `scripts/gate --protocol` emits it as its own named `build-crdt` + step, never folded into the sweep command, so a build failure is + attributed to the build rather than to the sweep. + + **Only the crdt sweep needs it, and that is MEASURED rather than + reasoned.** On 2026-08-09, on a disposable target directory with + `debug/pmacs-gpu` asserted **absent before each run** and each sweep + run alone from that cold state: the **default** sweep exited **0** + (114 test targets green) and left `debug/pmacs-gpu` **still absent** + — it never builds the binary and never needs it — while the **crdt** + sweep exited **101** with exactly those twelve failures. An + unconditional build would be a real cost paid for nothing on every + ordinary lane. + + **Why this was latent for years.** Before per-worktree target + directories (#225) every worktree on this machine shared one, which + nearly always already held a `pmacs-gpu` from some earlier build, so + the precondition was satisfied **by accident** on essentially every + run. The hazard is not the red gate — a red gate stops you. It is a + **green** `--protocol` run whose crdt sweep was decided by what + happened to be in the build directory rather than by the diff, which + is a gate reporting coverage it does not have. §5 keeps the incident + and its signature as history; **this section owns the requirement.** + - **`--workspace`, never `--tests`.** `--tests` selects 108 targets where `--workspace` selects 110, and the two it drops are **`pmacs_protocol` and `pmacs_gpu`**. On a protocol bump, dropping diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index ad050e9..0e4e6a9 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -566,3 +566,38 @@ The #214 occurrence is the strongest available evidence that these are not caused by the PRs they appeared on — that PR is **docs-only and its tree is byte-identical to a green `main`**. It is not evidence that any of them is harmless. + +### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS `lua54`, one occurrence + +Surfaced on PR #229's CI. + +| field | value | +|---|---| +| **selector** | `--test full_grid_resync_acceptance a_pty_resize_blanks_the_host_before_repainting` | +| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, `macos-26-arm64` | +| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the` · `bytes emitted after the first painted frame` | +| **NOT fragments** | the byte count and the `:LINE` suffix are **occurrence-specific** and must not be matched on — the count is the collected suffix length, which varies per run, and the line moves with the file | +| **status** | **one occurrence; INTERMITTENT — passed on rerun** | +| **why the diff is excluded** | #229 changes only `scripts/gate`, `tests/gate_script_acceptance.rs` and documentation — **no `src/`, and the workflow never invokes `scripts/gate`**. Decisively, `full_grid_resync_acceptance` runs **before** the changed gate suite, so even a cross-suite leaked-state path is not available. The `luajit` leg passing on the same commit is **corroboration only** — a deterministic defect *can* be Lua-flavour-specific, so that observation must not be used as a structural exclusion | +| **what IS established** | **no blank was OBSERVED after the mark** within the test's fixed 20-second deadline. The collected suffix was the **entire** post-mark output (`suffix.len()`, 25 362 bytes on this occurrence — not a capped window; only the *displayed* head is truncated to 400 bytes), and that head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was painting | +| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted after the deadline, or lost in transport is **open** — and "it never emitted the blank" is a claim this evidence does not support. **The failing run's ~20 s duration is the fixed `Duration::from_secs(20)` timeout**, so the spread against a fast passing run is mechanically determined and is **not** independent timing evidence | +| **discriminating control — ASYMMETRIC, and only one direction concludes** | the suffix is already complete, so "capture more bytes" is not the gap — arrival time is. Extending the deadline and recording whether `CLEAR_ALL` arrives, and at what offset: **if it arrives, "emitted late" is established.** **If it does not, that establishes only "not observed by the longer deadline"** — *not* "never emitted", because transport loss produces the same absence. Separating non-emission from transport loss needs **producer-side emission evidence** (did pmacs write the clear?) cross-checked against the collected stream; no deadline, however long, can do it alone | + +### U5 — `ctrl_c_during_reconnect_sleep_yields_clean_exit`, macOS `lua54`, one occurrence + +Surfaced on the **rerun** of PR #229's failed job — a *different* +selector from U4, so by this file's matching rule it is a **new +incident, not U4 occurring twice**. + +| field | value | +|---|---| +| **selector** | `--test m5_8_acceptance ctrl_c_during_reconnect_sleep_yields_clean_exit` | +| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, rerun attempt 2 | +| **required fragments** | `Ctrl-C during reconnect sleep should produce a clean exit` · `ExitStatus { code: 1, signal: Some("Interrupt: 2") }` | +| **NOT a fragment** | the `:LINE` suffix — occurrence-specific, moves with the file | +| **status** | **one occurrence, unresolved** | +| **what IS established** | Ctrl-C reached the process **as `SIGINT`** rather than as the raw-mode key event the test drives. That is all the exit status shows | +| **what is NOT** | whether injection preceded raw mode, raw mode was lost, or something else. Three mechanisms remain open and this fragment separates none of them | +| **exclusion strength — WEAKER than U4's, deliberately** | the changed `gate_script_acceptance` ran **earlier in the same job**, and it creates worktrees and directories. No leaked child or persistent signal-state mutation was observed, but "the diff touches no `src/`" is **not** the argument here that it is for U4, because cross-suite leaked state is a path reachability reasoning does not close | +| **control 1 — CROSS-SUITE ATTRIBUTION, and asymmetric** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. **A matching isolated RED proves the gate suite is not necessary** for the failure. **An isolated GREEN proves nothing beyond that run** — the failure is intermittent, so absence under one run is not evidence of dependence. It also does **not** discriminate among the three mechanisms in either direction | +| **control 2 — mechanism** | observe **readiness and raw-mode state at the moment of injection**. Another isolated pass, however many times repeated, cannot separate "injected before raw mode" from "raw mode lost" from a third cause | diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md new file mode 100644 index 0000000..2845aa7 --- /dev/null +++ b/docs/gate-protocol-build-framing.md @@ -0,0 +1,336 @@ +# `scripts/gate --protocol` — the build its sweep depends on + +**Status: revision 5. APPROVED and IMPLEMENTED at `49bc141`. The +regression-witness gap found in review of that implementation is +CLOSED at `677fd25` — Q#GR-5's criterion is now witnessed against the +REAL emitter rather than around it.** + +*(Revisions 1-3 read "Pre-implementation. Awaiting approval" while the +ledger recorded this lane as approved and implemented — the exact +contradiction class this project keeps correcting elsewhere.)* + +**Revision 3 fixes a witness that could not fail.** Revision 2's +`--self-test` plan put the failing step **last**, so an aborting runner +and a continuing one produce identical output — the witness for +Q#GR-2's "the suite keeps going" policy would have passed on a runner +doing the opposite. A passing **sentinel after** the failure, asserted +to have written its log, is what separates them. §7 also now pins the +**exact** build command rather than only the step's name and position, +since a `build-crdt` running plain `cargo build` would leave the gate +just as unsound while looking repaired. + +**Revision 2 takes three review findings.** The normative requirement +goes **entirely** into handoff §3 rather than being split across §3 and +§5 (§5, Q#GR-3). Q#GR-1's observation procedure is respecified on a +**disposable** target with the binary's absence asserted before each +run, rather than by deleting a file from a live worktree. And the +build-attribution criterion, which revision 1 stated with **no way to +observe it**, gets a witness — via a hardcoded synthetic plan, not the +plan-file injection that would reintroduce this script's own +`--acceptance` defect (Q#GR-5). + +**A narrow lane, deliberately.** One missing step in one script, plus +the boundary question that let it go missing. No feature work, no +`src/`, no protocol. + +--- + +## 1. The defect + +`scripts/gate --protocol` adds the CRDT workspace sweep. That sweep has +a documented precondition, and **the script does not run it**. + +`docs/agent-handoff.md` §5 (`:532-535`): + +> **The crdt sweep needs `cargo build --workspace` first**, or twelve +> `gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu` +> binary. `cargo build --workspace --no-default-features --features +> luajit,crdt` is the invocation that produces both binaries. + +`scripts/gate`'s plan emitter (`:187-204`) goes +`… → gpu → sweep → sweep-crdt → diff-check`, with **no build step +anywhere**. Read from the source, not inferred from the failure. + +**Observed, not theorised.** PR #228's first gate run failed step 09 +with twelve `gpu_invocation_acceptance::crdt::*` failures, all +*"build pmacs-gpu before this acceptance suite"*, and `debug/pmacs-gpu` +was absent from that worktree's target directory. Running the +documented invocation and re-running the gate turned it green. + +## 2. Why it was latent until now, which is the interesting part + +**#225 caused this to become reachable, and #225 is also what makes it +matter.** + +Before #225 every worktree on this machine resolved to **one shared** +`CARGO_TARGET_DIR`. That directory almost always already contained a +`pmacs-gpu` binary from some earlier build, so the precondition was +**satisfied by accident** on essentially every run. #225 gave each +worktree its own target directory; a fresh one starts empty, and the +omission becomes load-bearing on the very first `--protocol` run in a +new worktree. + +So this is not "a bug #225 introduced". It is a **pre-existing gap in +the documented procedure that #225 stopped hiding** — and the reason it +is urgent rather than tidy is that the failure mode is not a red gate. +A red gate is fine; it stops you. The hazard is the *reverse*: a green +`--protocol` run whose crdt sweep was decided by **what happened to be +in the build directory** rather than by the diff. That is a gate that +reports coverage it does not have, which is precisely what #225 exists +to prevent. + +## 3. The likely mechanism, marked as inference + +The failing tests are namespaced `gpu_invocation_acceptance::crdt::*`, +which suggests they are **feature-gated to `crdt`** and therefore +compile and run only under the crdt sweep. That would explain why the +default sweep passes on a tree with no `pmacs-gpu` binary at all — it +never runs the tests that spawn it. + +**This is inference from the test names and one observation, and it is +not yet verified.** Q#GR-1 makes establishing it part of the work +rather than an assumption the fix rests on. + +## 4. The change + +*My vote: **a named `build-crdt` gate step, emitted immediately before +`sweep-crdt` and only under `--protocol`***, running the invocation +handoff §5 names. + +- **A named step, not a silent prelude.** It gets its own numbered log + alongside the others and fails the suite under its own name. + (It does **not** "appear in `--print-plan`" — that mode prints + commands only. §7 says what witnesses the name.) +- **Not folded into the `sweep-crdt` command.** `cargo build … && cargo + test …` would make a *build* failure appear under the name `sweep-crdt` + in the failure list — a wrong attribution in the one place the script + exists to be trustworthy about. **Q#GR-5 is how that is witnessed**, + which revision 1 asserted without supplying. +- **Only under `--protocol`.** If §3's inference holds, the default + sweep does not need it, and adding an unconditional workspace build + to every gate run is a real cost paid for nothing. + +## 5. The boundary question, which is the durable half + +The script's own header says: + +> `docs/agent-handoff.md` section 3 owns the REASONING for each of +> these … `--print-plan` renders this without running anything, which +> is what makes **drift from section 3** testable. + +**The drift here is from §5, not §3** — and that is a coherent reason +for the omission rather than mere oversight. `scripts/gate` was written +against §3's gate policy; this precondition lives in §5's hazard +register, which the script never claimed to encode. + +So the durable fix is not only the missing line. It is deciding where a +requirement like this belongs. + +**The normative requirement moves ENTIRELY into §3.** It is gate policy +— it decides whether a gate's result means anything — and §3 is already +where such policy lives. §5 keeps the **incident and its signature**, +which is history, not contract. + +**The script header keeps citing §3 and only §3.** Revision 1 also +proposed citing §5, which was wrong twice over: it splits one +executable contract across two sections, and it weakens the single +clean boundary the script has (*"§3 owns the reasoning"*) at the same +time as Q#GR-4 declines to build any automated check for prose drift. +A boundary that is neither enforced nor singular is not a boundary. +One normative home, one citation. Q#GR-3. + +## 6. Open questions + +### Q#GR-1 — what exactly must be built, and does the default sweep need it too? + +§5 names `cargo build --workspace --no-default-features --features +luajit,crdt` and says it "produces both binaries". §3's inference says +the default sweep is unaffected. **Neither is verified by this +document.** + +*Required before implementation, by observation rather than reading.* +Revision 1 said "delete `pmacs-gpu` from a target directory", which is +both unsafe and insufficient: it **mutates a durable worktree's build +directory**, and removing one binary does not establish that the other +artifacts and feature permutations are cold — a stale dependency graph +can satisfy the run for reasons the experiment never sees. + +**The procedure:** + +1. A **disposable** target directory (a scratch `CARGO_TARGET_DIR`, or + a throwaway worktree), never a live lane's. Nothing under + `$HOME/build/pmacs-gate-targets/` belonging to a real branch is + touched. +2. **Assert `debug/pmacs-gpu` is ABSENT before each run**, as a + recorded precondition rather than an assumption. A run whose + starting state was not checked proves nothing about a cold tree. +3. Run the **default** sweep alone. Record pass/fail and, if it fails, + the failing test names. +4. Reset to the same cold state, assert absence again, run the **crdt** + sweep alone. Record the same. + +Each sweep separately, so a result cannot be explained by the other +having built the binary first — which is the exact accident (§2) that +hid this defect for the entire life of the shared target directory. + +If the default sweep also needs the binary, the step is unconditional +and §4's "only under `--protocol`" is wrong. + +**This is the one thing in this lane I would not accept on reasoning.** +The whole defect is a precondition nobody checked; establishing its +replacement by reading would repeat the error at one remove. + +### Q#GR-2 — does a build failure fail the suite, or abort it? + +*My vote: **fail like any other gate***, and let the remaining steps +run. `--no-fail-fast` is the established posture of this suite, and a +sweep that then fails for the missing binary produces a second, +consistent signal rather than a mysterious absence. + +The counter-argument is real: twelve downstream failures with a known +cause is noise. But the script already prints per-gate logs and a +`FAILED:` list, so the cause is named at the top, and suppressing +downstream output is how a tool starts deciding what its user is +allowed to see. + +### Q#GR-3 — where does this requirement live? + +See §5. **§3 gains it normatively; §5 keeps the incident; the script's +header keeps citing §3 alone.** Revision 1 proposed citing both, which +would have split one executable contract across two sections while +Q#GR-4 declines to build any check for prose drift. + +### Q#GR-5 — how is the attribution criterion witnessed at all? **(new in rev 2)** + +Revision 1 asserted that a build failure must be attributed to +`build-crdt` rather than `sweep-crdt`, and gave no way to observe it. +That criterion was unwitnessable as written: `tests/gate_script_acceptance.rs` +deliberately exercises only **no-gates** paths, so plan assertions can +prove a step's name and its order and **nothing about runtime +behaviour**. + +**The obvious seam is a trap.** Making `PLAN_FILE` injectable — let a +test hand the runner its own plan — would work, and it would turn the +script into a general command executor via the `eval` at its runner +loop. That is the **same class of defect this script's own review +already caught in `--acceptance`**, which was fixed with a refusal at +parse time. Reintroducing it one lane later, in the tool whose purpose +is to be trustworthy, is not a trade worth making. + +*My vote: **a `--self-test` mode running a HARDCODED synthetic plan*** +— **three** lines: a passing step, a failing one named `build-crdt`, +and **a passing SENTINEL after it**. + +**The third line is not padding, and revision 2's two-line plan was +broken without it.** With the failure last, a runner that **aborts** on +failure and one that **continues** produce identical output, so the +witness passes either way — and Q#GR-2's whole answer is that the suite +keeps going. A sentinel *after* the failing step, asserted to have run +and written its log, is the only thing that distinguishes them. + +So it asserts: the runner names the failing gate, lists it under +`FAILED:`, writes its log where it says it does, exits non-zero, **and +the sentinel after the failure has its own log** — which is Q#GR-2's +policy made observable rather than declared. + +- **No injection.** The synthetic plan is a literal inside the script; + nothing external supplies a command. +- **Runs no real gate**, so it stays on the cheap no-gates side of the + existing suite. `true`/`false` are the whole workload. +- **It tests the runner, which is the thing under test.** Whether + `cargo build` really fails is `cargo`'s business; whether *this + script names the right gate when a command fails* is the criterion, + and it is orthogonal to which command failed. + +The alternative is a **documented manual witness** — break the build by +hand, run the gate, record the output in the lane. Honest, and it rots: +nothing re-runs it, so it decays into a claim about a past machine. +Named as the fallback if review rejects a new mode. + +### Q#GR-4 — should `--print-plan` be asserted against the handoff? + +Tempting and out of scope. A test that parses prose out of +`agent-handoff.md` and compares it to the plan would be brittle in the +direction that produces false confidence. **Not in this lane**, and +named so it is not mistaken for an oversight. + +## 7. Verification + +- **`--print-plan --protocol` witnesses COMMAND and ORDER only** — it + strips names before printing, so it can show that the build command + appears immediately before the crdt sweep command and nothing about + which step is called what. Earlier revisions of this section said + named steps "appear in `--print-plan`"; they do not, and that wording + is what let the attribution witness drift away from the step it + names. +- **BOTH real emitter pairs are asserted, explicitly and separately:** + - `("build-crdt", "cargo build --workspace --no-default-features --features luajit,crdt")` + - `("sweep-crdt", "cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright")` + + Both, because the rename hole is symmetric: revision 4 closed it for + the build and left the sweep able to be renamed just as invisibly. + Asserted from the emitter, where the name still exists. + + The **command** half matters as much as the name: a `build-crdt` + running plain `cargo build` would leave the gate exactly as unsound + while looking repaired, because the crdt sweep needs *those* + features. +- **`--print-plan` WITHOUT `--protocol` does not emit it** (subject to + Q#GR-1 — if the default sweep turns out to need the binary too, this + assertion inverts and §4 changes with it). +- **A real fresh-target `--protocol` run goes green without a manual + build**, which is the acceptance criterion and the thing that was + false. Witnessed on a target directory with no `pmacs-gpu` in it. +- **A failing gate is attributed to its own name**, witnessed through + `--self-test`'s synthetic plan (Q#GR-5): the run exits non-zero, + prints `build-crdt` as the failing step, lists it under `FAILED:`, + and writes the log path it claims. This is the criterion revision 1 + stated with no way to observe it. +- **THE REAL PLAN'S `(name, command)` PAIR IS ASSERTED** — added in + revision 4, because the two witnesses above **do not connect to the + step they describe**. `--print-plan` strips names before printing, so + the order assertion sees only commands; `--self-test` hardcodes the + string `build-crdt` in its own synthetic plan. Review demonstrated + the gap: **renaming the real build step to `sweep-crdt` left both + tests passing.** The plan's emitted name and its command must be + asserted together, from the real emitter, so a rename cannot pass. + The synthetic failure/continuation test stays — it tests the runner, + which is a different thing — but it can no longer stand in for + attribution of the actual step. + + **Landed at `677fd25` as `--print-plan-named`**: a second *rendering* + of the same `emit_plan`, printing the `namecommand` text the + runner reads back from `PLAN_FILE`, asserted by **whole-line + equality** so the name and the command are pinned together. + **`sweep-crdt`'s own pair is asserted too** — asserting only the + build's name leaves the identical hole open in the other direction. + `PLAN_FILE` stays uninjectable, and a companion test pins that + `--print-plan` *is* that rendering minus its names, so the two cannot + drift and the assertion cannot come to pin a name the runner never + uses. **Mutation tested, each individually red:** the build renamed + to `sweep-crdt` (**the one the previous round passed**), the sweep + renamed, the build's features changed, the build emitted after the + sweep. +- **The suite CONTINUES past a failed gate** (Q#GR-2) — the sentinel + step after `build-crdt` in the synthetic plan has its own log. + **Revision 2's two-line plan could not assert this**: with the + failure last, an aborting runner and a continuing one are + indistinguishable, so the witness would have passed on a runner that + does the opposite of the stated policy. +- **The existing 15 `tests/gate_script_acceptance.rs` tests still + pass**, and the new assertions join them on the **no-gates paths** + (`--print-plan` runs nothing), keeping the suite cheap. + +**What this will NOT prove:** that the plan matches the handoff in +general (Q#GR-4), or that any other §5 hazard is encoded in the script +— this lane fixes one and asks where such requirements belong, it does +not audit §5. + +## 8. Not in scope + +Any feature work. Any `src/` change. Auditing the rest of handoff §5 +for further unencoded preconditions (worth doing; not here). A +plan-versus-handoff consistency test (Q#GR-4). Changing which gates the +suite runs, or the acceptance-suite selection policy — §3 remains +authoritative for both. **Rerunning PR #228's gate**, which is that +lane's unblocking step and happens after this lands, not inside it. diff --git a/scripts/gate b/scripts/gate index c305f8e..b946f69 100755 --- a/scripts/gate +++ b/scripts/gate @@ -3,11 +3,15 @@ # directory, with isolated ambient roots and durable logs. # # scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] +# scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named # scripts/gate --print-target-dir # scripts/gate --init # scripts/gate --prune [--force] +# scripts/gate --self-test # -# Framing: docs/gate-script-framing.md (revision 4, approved). +# Framing: docs/gate-script-framing.md (revision 4, approved), and +# docs/gate-protocol-build-framing.md (revision 5, approved) for the +# crdt build step, --self-test and --print-plan-named. # # WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one # CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK @@ -37,9 +41,11 @@ set -eu usage() { cat >&2 <<'EOF' usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] + scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named scripts/gate --print-target-dir scripts/gate --init scripts/gate --prune [--force] + scripts/gate --self-test --acceptance SUITE a touched acceptance suite to run (repeatable). docs/agent-handoff.md section 3 stays authoritative @@ -48,8 +54,15 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] working tree, and one that guessed would report coverage it does not have. --protocol the change touches PROTOCOL_VERSION; adds the CRDT - workspace sweep on top of the default one. - --print-plan print the exact gate commands and exit. + workspace sweep on top of the default one, plus + the build that sweep needs (see build-crdt below). + --print-plan print the exact gate commands and exit. Names are + stripped, so every line is runnable as printed. + --print-plan-named print the plan as `namecommand` lines and exit + --- the same text the runner reads. Exists because + a step's NAME is half its contract (a build failure + must be attributed to build-crdt, not sweep-crdt) + and --print-plan cannot show it. --print-target-dir print this worktree's build directory and exit. Creates nothing. --init create the build directory and ownership marker, @@ -57,6 +70,13 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] --prune list managed directories whose worktree is gone. Deletes NOTHING without --force. --force with --prune, actually delete. + --self-test drive the real runner with a HARDCODED synthetic + plan --- true, false, true --- to witness that a + failing gate is named as ITSELF and that the suite + CONTINUES past it. Runs no real gates. EXITS + NON-ZERO BY DESIGN: the middle step fails on + purpose, so a non-zero status is this mode + working, not this mode broken. EOF exit 2 } @@ -198,11 +218,80 @@ emit_plan() { if [ "$PROTOCOL" = 1 ]; then # Section 3: touching PROTOCOL_VERSION STRENGTHENS the sweep # line, it does not replace it. Both sweeps run. + # + # THE BUILD IS A PRECONDITION OF THE SWEEP, not a courtesy. The + # crdt sweep spawns `pmacs-gpu` as a PROCESS, and no `cargo + # test` run produces that binary: pmacs-gpu has no tests/ + # directory, so cargo never uplifts its bin to debug/pmacs-gpu. + # On a cold target directory the sweep therefore fails twelve + # gpu_invocation_acceptance::crdt::* tests on "build pmacs-gpu + # before this acceptance suite" --- and, worse, other crdt tests + # that render through the real binary SKIP THEMSELVES and report + # ok, so the missing build also voids coverage silently. + # + # WHY ONLY UNDER --protocol, measured rather than reasoned. On + # 2026-08-09, on a disposable target directory with + # debug/pmacs-gpu asserted ABSENT before each run and each sweep + # run alone from that cold state: the DEFAULT sweep exited 0 and + # left debug/pmacs-gpu still absent --- it never builds the + # binary and never needs it --- while the crdt sweep exited 101 + # with exactly those twelve failures. So the default gate does + # not pay for this build. + # + # A SEPARATE NAMED STEP, never folded into the sweep command. + # `cargo build ... && cargo test ...` would report a BUILD + # failure under the name `sweep-crdt`, which is a wrong + # attribution in the one place this script exists to be + # trustworthy about. --self-test is what witnesses that the + # runner names the failing gate as itself. + printf 'build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt\n' printf 'sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright\n' fi printf 'diff-check\tgit diff --check\n' } +# --------------------------------------------------------------------- +# The synthetic plan behind --self-test: the runner held to its own +# contract. +# +# WHY A MODE EXISTS AT ALL. tests/gate_script_acceptance.rs drives only +# NO-GATES paths --- a test that ran the real suite would run the gate +# suite inside the gate suite --- so plan assertions can prove a step's +# name and its order and NOTHING about what the runner does when a step +# fails. That left two stated properties with no way to observe them: +# a failing gate is attributed to ITSELF (which is the whole reason +# build-crdt is a separate step rather than `cargo build && cargo +# test`), and the suite CONTINUES past it rather than aborting. Same +# shape of argument as --init: verification needs a path it can drive +# safely, and this one is not a second implementation --- it hands the +# REAL runner loop a different plan file. +# +# THE PLAN IS A LITERAL, and that is the design, not a shortcut. The +# obvious seam --- letting a caller supply PLAN_FILE --- would work, +# and it would turn the runner's `eval` into a general command +# executor. That is the same class of defect this script's own review +# caught in --acceptance and fixed with a refusal at parse time; +# reintroducing it in the tool whose purpose is to be trustworthy is +# not a trade worth making. Nothing external supplies a command here. +# +# THREE LINES, AND THE THIRD IS LOAD-BEARING. With the failure LAST, a +# runner that aborts and a runner that continues produce IDENTICAL +# output, so the witness would pass on a runner doing the opposite of +# the stated policy. The sentinel after the failure, asserted to have +# written its own log, is the only thing that separates them. +# +# `true` and `false` are the entire workload, so this stays on the +# cheap side of the suite. Whether `cargo build` really fails is +# cargo's business; whether THIS SCRIPT names the right gate when a +# command fails is the criterion, and that is orthogonal to which +# command failed. +# --------------------------------------------------------------------- +emit_self_test_plan() { + printf 'self-pass\ttrue\n' + printf 'build-crdt\tfalse\n' + printf 'self-sentinel\ttrue\n' +} + # --------------------------------------------------------------------- # Pruning. # @@ -326,19 +415,65 @@ while [ $# -gt 0 ]; do shift 2 ;; --protocol) PROTOCOL=1; shift ;; --print-plan) MODE=plan; shift ;; + --print-plan-named) MODE=plannamed; shift ;; --print-target-dir) MODE=printdir; shift ;; --init) MODE=init; shift ;; --prune) MODE=prune; shift ;; + --self-test) MODE=selftest; shift ;; --force) FORCE=1; shift ;; -h|--help) usage ;; *) echo "gate: unknown argument: $1" >&2; usage ;; esac done +# --------------------------------------------------------------------- +# Mode dispatch. +# +# TWO RENDERINGS OF ONE PLAN, and the second exists because the first +# hid a defect. `--print-plan` pipes through `cut -f2-` so every printed +# line is a command a reader can copy and run --- and that same cut is +# why the emitted NAMES never reached a test. Not cosmetic: the entire +# reason build-crdt is a separate step is that a build failure must be +# attributed to `build-crdt` rather than to `sweep-crdt`, and with the +# names stripped, RENAMING THE REAL BUILD STEP TO `sweep-crdt` left the +# ordering assertion green. The witness could not reach the step it +# named. --self-test could not either --- it hardcodes the string +# `build-crdt` in its own synthetic plan, which proves things about the +# RUNNER and nothing about this emitter. +# +# --print-plan-named prints emit_plan VERBATIM: the same +# `namecommand` text the runner reads back from PLAN_FILE, so a +# test can assert both halves of a real step together and a rename +# cannot pass. +# +# WHY THIS RATHER THAN THE TWO ALTERNATIVES. +# +# Injecting PLAN_FILE would let a test hand the runner a plan and read +# the names back, and it would turn the runner's `eval` into a general +# command executor --- the same class of defect this script's own +# review caught in --acceptance and fixed with a parse-time refusal. +# Declined there; declined here for the same reason. +# +# Re-deriving the plan test-side (sourcing this file, or parsing +# emit_plan out of it) would be a SECOND implementation of the thing +# under test, which is the exact failure being repaired one level up. +# +# A DISTINCT MODE, not a modifier on --print-plan: there is then no +# `--with-names` without `--print-plan` whose behaviour has to be +# defined, and --print-plan's contract --- runnable lines --- is left +# exactly as it was. Both modes call emit_plan, and so does the runner, +# so neither rendering can drift from what actually executes; +# tests/gate_script_acceptance.rs pins that the stripped rendering is +# the named one minus its names, so this stays true by test and not +# only by reading. +# --------------------------------------------------------------------- case $MODE in plan) emit_plan | cut -f2- exit 0 ;; + plannamed) + emit_plan + exit 0 ;; printdir) target_dir_for "$(canon "$(worktree_root)")" echo @@ -397,11 +532,23 @@ echo "gate: logs $LOGDIR" # trap removes it, so it should be gone once the run finishes. echo "gate: ambient $AMBIENT" [ -n "$ACCEPTANCE" ] && echo "gate: acceptance $ACCEPTANCE" -[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT workspace sweep added)" +[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT build + workspace sweep added)" +if [ "$MODE" = selftest ]; then + echo "gate: SELF-TEST hardcoded synthetic plan --- NO real gate runs." + echo "gate: the middle step fails ON PURPOSE, so a non-zero" + echo "gate: exit is this mode working, not this mode broken." +fi echo +# The self-test hands the REAL runner loop below a different plan file. +# Everything after this point is shared, which is the point: a witness +# that exercised its own copy of the runner would witness nothing. PLAN_FILE="$LOGDIR/plan.txt" -emit_plan > "$PLAN_FILE" +if [ "$MODE" = selftest ]; then + emit_self_test_plan > "$PLAN_FILE" +else + emit_plan > "$PLAN_FILE" +fi N=0 FAILED='' diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index ac68b3e..37de5f2 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -127,6 +127,307 @@ fn the_crdt_workspace_sweep_is_added_by_protocol_and_absent_without_it() { ); } +/// **The precondition the plan did not encode**, and the reason a green +/// `--protocol` run could mean nothing. +/// +/// The crdt workspace sweep spawns `pmacs-gpu` as a *process*, and no +/// `cargo test` run produces that binary — `pmacs-gpu` has no `tests/` +/// directory, so cargo never uplifts its bin to `debug/pmacs-gpu`. On a +/// cold target directory the sweep fails twelve +/// `gpu_invocation_acceptance::crdt::*` tests on *"build pmacs-gpu +/// before this acceptance suite"*. Before per-worktree target +/// directories (#225) every worktree shared one that nearly always +/// already held the binary, so the precondition was satisfied **by +/// accident** — and the hazard was never the red gate, it was a green +/// one decided by the build directory rather than by the diff. +/// +/// **The exact command is asserted, not just the step's name and +/// position.** A `build-crdt` running plain `cargo build` would sit in +/// the right place under the right name and leave the gate exactly as +/// unsound: the crdt sweep needs *those* features, and the wrong ones +/// produce a binary the sweep cannot use. +/// +/// **What this test cannot see: the names.** `--print-plan` strips them +/// (`emit_plan | cut -f2-`), so everything below is an assertion about +/// *commands in an order* — renaming the real build step to `sweep-crdt` +/// leaves it green. The step's **name** is asserted by +/// `the_crdt_build_step_carries_its_own_name_and_its_exact_command` +/// below, which reads the plan in the form the runner reads it. +#[test] +fn the_crdt_sweep_is_immediately_preceded_by_the_build_that_produces_its_binary() { + let root = tempfile::tempdir().expect("tempdir"); + let build = "cargo build --workspace --no-default-features --features luajit,crdt"; + let crdt_sweep = "cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright"; + + let (plan, err, ok) = run(root.path(), &["--protocol", "--print-plan"]); + assert!(ok, "--protocol --print-plan must succeed; stderr:\n{err}"); + + let b = plan + .find(build) + .unwrap_or_else(|| panic!("the crdt sweep's build is missing; plan was:\n{plan}")); + let s = plan + .find(crdt_sweep) + .unwrap_or_else(|| panic!("the crdt sweep is missing; plan was:\n{plan}")); + + // Ordering is asserted BEFORE the slice below, which would + // otherwise panic with a byte-offset message ("begin > end (427 > + // 282)") that names neither step. Mutation-tested: emitting the + // build *after* the sweep produced exactly that, and a gate test + // whose failure has to be decoded is a gate test nobody trusts. + assert!( + b < s, + "the build must run BEFORE the crdt sweep, not after it — a sweep \ + that builds its own precondition afterwards has already failed; \ + plan was:\n{plan}" + ); + // IMMEDIATELY before: one newline between them and nothing else. A + // build that merely appears *somewhere* earlier could be separated + // from the sweep by a step that rewrites the same target directory. + assert_eq!( + &plan[b + build.len()..s], + "\n", + "the build must run IMMEDIATELY before the crdt sweep; plan was:\n{plan}" + ); +} + +/// **The witness that reaches the step it names**, and the reason this +/// lane needed a second round. +/// +/// This lane exists to guarantee two things: that the crdt sweep is +/// preceded by the build producing its binary, and that a build failure +/// is attributed to **`build-crdt`** rather than to `sweep-crdt`. The +/// first round shipped with neither guaranteed, because **neither +/// witness could see a name**: +/// +/// - `--print-plan` renders `emit_plan | cut -f2-`, so the ordering test +/// above compares commands and never sees the names beside them. +/// - `--self-test` hardcodes the string `build-crdt` inside its **own +/// synthetic** plan, so it proves things about the *runner* and +/// nothing about the real emitter. +/// +/// Review demonstrated the consequence directly: **renaming the real +/// build step to `sweep-crdt` left both tests passing** — a plan that +/// reports a build failure under the sweep's name, which is exactly the +/// misattribution the separate step exists to prevent, sitting green. +/// +/// So the pair is asserted **together, as one emitted line**, against +/// `--print-plan-named` — the plan in the form the runner reads it back +/// from `PLAN_FILE`. Name and command in the same `assert`, from the +/// real emitter, is what makes a rename unable to pass; either half +/// alone lets the other drift. +/// +/// The mode is a *rendering*, not a seam: `PLAN_FILE` stays +/// uninjectable, because a test that supplied the runner's plan would +/// turn its `eval` into a general command executor — the defect the +/// `--acceptance` refusal below exists to prevent. +#[test] +fn the_crdt_build_step_carries_its_own_name_and_its_exact_command() { + let root = tempfile::tempdir().expect("tempdir"); + let build = "build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt"; + let sweep = + "sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright"; + + let (plan, err, ok) = run(root.path(), &["--protocol", "--print-plan-named"]); + assert!( + ok, + "--protocol --print-plan-named must succeed; stderr:\n{err}" + ); + let lines: Vec<&str> = plan.lines().collect(); + + // Whole-line equality, not `contains`: the name, the tab, and the + // command with nothing appended. A step is its (name, command) pair + // and the plan is where both are decided. + let b = lines.iter().position(|l| *l == build).unwrap_or_else(|| { + panic!( + "no plan line is exactly:\n {build}\nA build step under a \ + different NAME misattributes its own failure; a build step \ + with different FEATURES hands the sweep a binary it cannot \ + use. Plan was:\n{plan}" + ) + }); + // The sweep's own pair, for the same reason in the other direction: + // asserting only the build's name lets a rename of the SWEEP slip + // through the identical hole. + let s = lines + .iter() + .position(|l| *l == sweep) + .unwrap_or_else(|| panic!("no plan line is exactly:\n {sweep}\nPlan was:\n{plan}")); + + assert_eq!( + s, + b + 1, + "the build must be the step IMMEDIATELY before the crdt sweep — a \ + build merely somewhere earlier could be separated from it by a \ + step that rewrites the same target directory. Plan was:\n{plan}" + ); + + // Conditionality, on this rendering too: an ordinary lane must not + // carry the step at all, not merely not carry its command. + let (default_plan, _, ok) = run(root.path(), &["--print-plan-named"]); + assert!(ok, "--print-plan-named must succeed"); + assert!( + !default_plan.contains("build-crdt"), + "the default sweep never builds pmacs-gpu and never needs it, so no \ + ordinary lane may pay for a workspace build; plan was:\n{default_plan}" + ); +} + +/// **The new rendering must be the same plan, or the assertion above +/// pins a string only the test ever reads.** +/// +/// `--print-plan-named` and `--print-plan` both call one emitter, and +/// the runner writes that same emitter to `PLAN_FILE` — so today they +/// cannot disagree. This pins that from outside, where a later edit +/// giving either mode its own plan text would be caught rather than +/// producing a witness that asserts a name the runner never uses. +/// +/// It also pins the **shape** the runner depends on: the loop reads each +/// line with `IFS= read -r name cmd`, so a plan line without its +/// tab would silently run under an empty command. +#[test] +fn the_named_plan_is_the_printed_plan_with_its_names_removed() { + let root = tempfile::tempdir().expect("tempdir"); + + for flags in [ + vec![], + vec!["--protocol"], + vec!["--acceptance", "m4_acceptance"], + ] { + let mut named_args = flags.clone(); + named_args.push("--print-plan-named"); + let mut plain_args = flags.clone(); + plain_args.push("--print-plan"); + + let (named, err, ok_named) = run(root.path(), &named_args); + assert!(ok_named, "{named_args:?} must succeed; stderr:\n{err}"); + let (plain, err, ok_plain) = run(root.path(), &plain_args); + assert!(ok_plain, "{plain_args:?} must succeed; stderr:\n{err}"); + + let mut stripped = String::new(); + for l in named.lines() { + let (_name, cmd) = l.split_once('\t').unwrap_or_else(|| { + panic!( + "every plan line must be `namecommand` — the runner \ + splits on that tab, so a line without one runs an empty \ + command under the whole line's name. Line was:\n {l:?}" + ) + }); + stripped.push_str(cmd); + stripped.push('\n'); + } + + assert_eq!( + stripped, plain, + "the two renderings must be one plan; with {flags:?} they diverged" + ); + } +} + +/// **Conditionality, settled by measurement rather than by reading** — +/// which is the whole methodological point of this lane, since the +/// defect it repairs was a precondition nobody checked. +/// +/// Measured 2026-08-09 on a disposable target directory, with +/// `debug/pmacs-gpu` asserted **absent** before each run and each sweep +/// run alone from that same cold state: the default sweep exited **0** +/// and left `debug/pmacs-gpu` **still absent** — it never builds the +/// binary and never needs it — while the crdt sweep exited **101** with +/// exactly twelve `gpu_invocation_acceptance::crdt::*` failures. +/// +/// So an unconditional build would be a real cost paid for nothing on +/// every ordinary lane. +#[test] +fn the_crdt_build_is_absent_without_protocol() { + let root = tempfile::tempdir().expect("tempdir"); + let (plan, _, ok) = run(root.path(), &["--print-plan"]); + assert!(ok, "--print-plan must succeed"); + assert!( + !plan.contains("cargo build"), + "the default sweep passes on a tree with no pmacs-gpu at all, so a \ + normal lane must not pay for a workspace build; plan was:\n{plan}" + ); +} + +/// **The attribution and continuation criteria, made observable.** +/// +/// Everything else in this file drives a no-gates path, so it can prove +/// a step's name and its order and **nothing** about what the runner +/// does when a step fails. `--self-test` closes that gap by handing the +/// *real* runner loop a hardcoded three-line plan — a passing step, a +/// failing one named `build-crdt`, and a passing sentinel after it. +/// +/// **Why `build-crdt` must be its own step** is exactly what this +/// witnesses: folded into the sweep as `cargo build … && cargo test …`, +/// a *build* failure would be reported under the name `sweep-crdt` — a +/// wrong attribution in the one place this script exists to be +/// trustworthy about. +/// +/// **The sentinel assertion is the load-bearing one.** With the failure +/// last, a runner that aborts and one that continues produce identical +/// output, so a two-line witness would pass on a runner doing the +/// opposite of the stated `--no-fail-fast` policy. The sentinel's own +/// log existing is the only thing that separates them — delete that +/// assertion and this test stops testing continuation at all. +/// +/// The plan is a literal inside the script on purpose. Making +/// `PLAN_FILE` injectable would let this test supply its own commands, +/// and would turn the runner's `eval` into a general command executor — +/// the same defect the `--acceptance` refusal above exists to prevent. +#[test] +fn self_test_names_the_failing_gate_and_the_suite_continues_past_it() { + let root = tempfile::tempdir().expect("tempdir"); + let (out, err, ok) = run(root.path(), &["--self-test"]); + + assert!( + !ok, + "a plan containing a failing step must exit non-zero; stdout:\n{out}stderr:\n{err}" + ); + assert!( + out.contains("build-crdt"), + "the failing gate must be named as it runs; stdout:\n{out}" + ); + assert!( + err.contains("FAILED: build-crdt"), + "the failing gate must be listed under FAILED: by its OWN name; stderr:\n{err}" + ); + + // The runner claims a log path for the failure. Assert the file is + // actually there: a tool that prints a path it did not write is + // worse than one that prints nothing, because the absence is only + // discovered while chasing a real failure. + let claimed = err + .lines() + .find_map(|l| l.split_once("log: ").map(|(_, path)| path.trim())) + .unwrap_or_else(|| panic!("the failing gate's log path must be printed; stderr:\n{err}")); + assert!( + claimed.ends_with("02-build-crdt.log"), + "the log must be numbered and named for the gate that failed; was {claimed}" + ); + assert!( + Path::new(claimed).is_file(), + "the runner must WRITE the log it claims at {claimed}" + ); + + let logdir = Path::new(claimed) + .parent() + .expect("the log lives in a log directory"); + assert!( + logdir.join("01-self-pass.log").is_file(), + "the step before the failure must have its own log; dir was {}", + logdir.display() + ); + // THE ASSERTION THE WHOLE MODE EXISTS FOR. + assert!( + logdir.join("03-self-sentinel.log").is_file(), + "the suite must CONTINUE past a failed gate — the sentinel after \ + build-crdt wrote no log, so this runner ABORTED. Stdout:\n{out}" + ); + assert!( + out.contains("self-sentinel"), + "the sentinel must be reported like any other gate; stdout:\n{out}" + ); +} + /// The seam handoff §3 keeps authority over: a script cannot infer /// which acceptance suites a change touched, so it runs what it is /// handed — each one, in order.