From 01316cbc747968309d5948926c7edb8713fbdc71 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:22:04 +0200 Subject: [PATCH 01/66] docs: frame the scripts/gate --protocol build step (revision 1) --protocol promises the CRDT workspace sweep. That sweep documented precondition is cargo build --workspace --no-default-features --features luajit,crdt (handoff section 5:532-535), and the plan emitter at scripts/gate:187-204 has no build step at all --- read from the source, not inferred from the failure. The interesting part is why it stayed invisible. Before #225 every worktree on this machine resolved to one shared CARGO_TARGET_DIR, which almost always already contained a pmacs-gpu binary, so the precondition was satisfied by accident on essentially every run. Per-worktree target dirs start empty. So this is not a bug #225 introduced; it is a pre-existing gap in the documented procedure that #225 stopped hiding. That also decides the urgency. 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. A gate reporting coverage it does not have is exactly what #225 exists to prevent, so the tool shipping with this gap teaches the opposite of what it is for. Observed on PR #228 first gate run: twelve gpu_invocation_acceptance::crdt::* failures, all "build pmacs-gpu before this acceptance suite", with debug/pmacs-gpu absent from the fresh target dir. The durable half is a boundary question rather than a missing line. The script header names handoff section 3 as the owner of its reasoning, and this precondition lives in section 5 --- a coherent cause for the omission, not oversight. Q#GR-3 proposes section 3 gains it, section 5 keeps the incident and its signature, and the script stops naming section 3 as its only source. Q#GR-1 is marked as the one thing this lane will not accept on reasoning: whether the default sweep also needs the binary must be established by deleting it and running both sweeps. The whole defect is a precondition nobody checked, and establishing its replacement by reading would repeat the error at one remove. The mechanism section states its own inference (the failing tests are namespaced ::crdt:: and so are probably feature-gated) and marks it unverified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 44 +++++++ docs/gate-protocol-build-framing.md | 180 ++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 docs/gate-protocol-build-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index fc91e78..5e5d403 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,6 +265,50 @@ 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 — BRANCHED, framing in review + +**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 1**, in + review. 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. +- **The defect.** `--protocol` adds the CRDT workspace sweep, whose + documented precondition is `cargo build --workspace + --no-default-features --features luajit,crdt` (handoff §5:532-535). + The plan emitter (`scripts/gate:187-204`) has **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. Q#GR-3 proposes §3 gains it, §5 keeps the incident, + and the script cites both. +- **Q#GR-1 must be settled by OBSERVATION before implementation** — + whether the default sweep needs the binary too. The entire defect is + a precondition nobody checked; establishing its replacement by + reading would repeat the error at one remove. +- **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. +- **Gates:** `scripts/gate --acceptance gate_script_acceptance`. + ## QoL arc retirement — PR #224 OPEN (docs only) **PR #224** — https://github.com/levineuwirth/pmacs/pull/224. Written diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md new file mode 100644 index 0000000..07344a0 --- /dev/null +++ b/docs/gate-protocol-build-framing.md @@ -0,0 +1,180 @@ +# `scripts/gate --protocol` — the build its sweep depends on + +**Status: framing pass, revision 1. Pre-implementation. Awaiting +approval.** + +**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 appears in + `--print-plan`, gets its own numbered log alongside the others, and + fails the suite with its own name if the build fails. +- **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. +- **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, and making the script's stated contract +match what it actually has to guarantee. *My vote: **§3 gains the +precondition** (it is gate policy — it decides whether a gate's result +means anything), §5 keeps the incident and its signature, and the +script's header stops naming §3 as its only source.* 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:* +delete `pmacs-gpu` from a target directory, run the **default** sweep, +and record whether it passes; then repeat for the crdt sweep. If the +default sweep also needs a 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. *My vote: §3 gains it, §5 keeps the incident, the script's +header cites both.* The alternative — leave §5 as the only home and +have the script silently encode it — reproduces exactly the condition +that made this gap invisible. + +### 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` emits `build-crdt` immediately before + `sweep-crdt`.** Order asserted, not just presence: a build after the + sweep it feeds is the same defect with an extra line. +- **`--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 build is attributed to `build-crdt`**, not to + `sweep-crdt` (Q#GR-2) — the wrong-name case §4 rejects. +- **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. From 4e02cb09b4f2681f6716592f7945ee70b372324b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:57:47 +0200 Subject: [PATCH 02/66] docs: gate framing revision 2 --- one normative home, a real witness Three review findings. The normative build requirement goes entirely into handoff section 3. Revision 1 proposed section 3 gaining it while the script header cited both sections, which splits one executable contract across two homes and weakens the single clean boundary the script has --- 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. Section 5 keeps the incident and its signature, which is history rather than contract. Q#GR-1 observation procedure was unsafe and insufficient. "Delete pmacs-gpu from a target directory" mutates a live worktree 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. Now: a disposable target, the binary asserted ABSENT before each run as a recorded precondition, and the two sweeps run separately so neither can be explained by the other having built the binary first. That last point is the same accident that hid this defect for the whole life of the shared target dir. The attribution criterion had no feasible witness. gate_script_acceptance deliberately runs no gates, so plan assertions prove name and order and nothing about runtime behaviour. The obvious seam is a trap: making PLAN_FILE injectable would turn the script into a general command executor through its runner eval --- the same class of defect this script own review already caught in --acceptance and fixed with a parse-time refusal. Reintroducing it one lane later, in the tool whose purpose is to be trustworthy, is not a trade worth making. Q#GR-5 proposes --self-test over a HARDCODED two-line synthetic plan, true and false, with the failing one named build-crdt. No injection, no real gate, and it tests the thing actually under test: whether the runner names the right gate when a command fails. Whether cargo build really fails is cargo business. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/gate-protocol-build-framing.md | 118 +++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5e5d403..7cfd6e3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. 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 1**, in +- **Framing `docs/gate-protocol-build-framing.md`, revision 2**, in review. 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. diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md index 07344a0..d1750e4 100644 --- a/docs/gate-protocol-build-framing.md +++ b/docs/gate-protocol-build-framing.md @@ -1,8 +1,18 @@ # `scripts/gate --protocol` — the build its sweep depends on -**Status: framing pass, revision 1. Pre-implementation. Awaiting +**Status: framing pass, revision 2. Pre-implementation. Awaiting approval.** +**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. @@ -77,7 +87,8 @@ handoff §5 names. - **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. + 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. @@ -96,11 +107,20 @@ 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, and making the script's stated contract -match what it actually has to guarantee. *My vote: **§3 gains the -precondition** (it is gate policy — it decides whether a gate's result -means anything), §5 keeps the incident and its signature, and the -script's header stops naming §3 as its only source.* Q#GR-3. +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 @@ -111,11 +131,33 @@ 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:* -delete `pmacs-gpu` from a target directory, run the **default** sweep, -and record whether it passes; then repeat for the crdt sweep. If the -default sweep also needs a binary, the step is unconditional and §4's -"only under `--protocol`" is wrong. +*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 @@ -136,10 +178,47 @@ allowed to see. ### Q#GR-3 — where does this requirement live? -See §5. *My vote: §3 gains it, §5 keeps the incident, the script's -header cites both.* The alternative — leave §5 as the only home and -have the script silently encode it — reproduces exactly the condition -that made this gap invisible. +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*** +— two lines, `true` and `false`, with the failing one named +`build-crdt`. It asserts what actually needs asserting: the runner +prints the failing gate's name, lists it under `FAILED:`, writes its +log where it says it does, and exits non-zero. + +- **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? @@ -159,8 +238,11 @@ named so it is not mistaken for an oversight. - **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 build is attributed to `build-crdt`**, not to - `sweep-crdt` (Q#GR-2) — the wrong-name case §4 rejects. +- **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 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. From 12affd78e19e50e74c1addd5c636bfbf3e88f73a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:10:09 +0200 Subject: [PATCH 03/66] docs: gate framing revision 3 --- a witness that can fail, and the ledger Two findings, both about a claim that could not be falsified. The --self-test plan put the failing step last. With the failure last, a runner that ABORTS on failure and one that CONTINUES produce identical output, so the witness for Q#GR-2 policy --- the suite keeps going --- would have passed on a runner doing the exact opposite. The plan is now three lines with a passing SENTINEL after build-crdt, asserted to have written its own log. That is the only thing that distinguishes the two behaviours, and it turns Q#GR-2 from a declared policy into an observed one. The plan test also now pins the EXACT command, not only the step name and its position. A build-crdt running plain cargo build would leave the gate exactly as unsound while looking repaired --- the crdt sweep needs those specific features, which is the whole defect. The ledger still recorded the superseded boundary decision: "section 3 gains it, section 5 keeps the incident, and the script cites both". Revision 2 replaced that with section 3 as the sole normative home and the script citing section 3 alone. active-work.md is the volatile cross-machine record, so a recovering machine reading the stale entry would have rebuilt revision 1 wrong boundary. Now updated, and it says which decision it supersedes rather than silently replacing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 14 +++++++-- docs/gate-protocol-build-framing.md | 46 ++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7cfd6e3..305bb99 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. 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 2**, in +- **Framing `docs/gate-protocol-build-framing.md`, revision 3**, in review. 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. @@ -298,8 +298,16 @@ authoritative tip** — the ref, not a SHA. Recover with - **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. Q#GR-3 proposes §3 gains it, §5 keeps the incident, - and the script cites both. + 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 must be settled by OBSERVATION before implementation** — whether the default sweep needs the binary too. The entire defect is a precondition nobody checked; establishing its replacement by diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md index d1750e4..5ee8dd2 100644 --- a/docs/gate-protocol-build-framing.md +++ b/docs/gate-protocol-build-framing.md @@ -1,8 +1,18 @@ # `scripts/gate --protocol` — the build its sweep depends on -**Status: framing pass, revision 2. Pre-implementation. Awaiting +**Status: framing pass, revision 3. Pre-implementation. Awaiting approval.** +**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 @@ -201,10 +211,20 @@ 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*** -— two lines, `true` and `false`, with the failing one named -`build-crdt`. It asserts what actually needs asserting: the runner -prints the failing gate's name, lists it under `FAILED:`, writes its -log where it says it does, and exits non-zero. +— **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. @@ -230,8 +250,14 @@ named so it is not mistaken for an oversight. ## 7. Verification - **`--print-plan --protocol` emits `build-crdt` immediately before - `sweep-crdt`.** Order asserted, not just presence: a build after the - sweep it feeds is the same defect with an extra line. + `sweep-crdt`, carrying the EXACT command.** All three asserted — + presence, position, and the literal + `cargo build --workspace --no-default-features --features luajit,crdt`. + Name and position alone would pass on a step that builds the wrong + feature set, which is the failure this lane is fixing: the crdt sweep + needs *those* features, and a `build-crdt` that ran plain + `cargo build` would leave the gate exactly as unsound while looking + repaired. - **`--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). @@ -243,6 +269,12 @@ named so it is not mistaken for an oversight. 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 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. From f55ce54627092e2ffb702bc85497ae7064318aaa Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:39:14 +0200 Subject: [PATCH 04/66] fix(gate): build pmacs-gpu before the crdt sweep, and witness the runner `scripts/gate --protocol` emitted `sweep-crdt` with no build step. The crdt sweep spawns `pmacs-gpu` as a process, and nothing in a `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". The hazard was never the red gate. Before per-worktree target directories (#225) every worktree shared one, which nearly always already held the binary, so the precondition was satisfied BY ACCIDENT for the whole life of that arrangement --- a GREEN `--protocol` run whose crdt sweep was decided by the state of the build directory rather than by the diff. Q#GR-1 SETTLED BY OBSERVATION, not by reading. On a disposable target directory with `debug/pmacs-gpu` asserted ABSENT before each run (recorded, not assumed), each sweep run alone from the same cold state: default cargo test --workspace --no-fail-fast -- --skip basedpyright exit 0, 114 test targets green, and `debug/pmacs-gpu` was STILL ABSENT afterwards --- the default sweep never builds it and never needs it. crdt cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright exit 101, exactly twelve failures, all `gpu_invocation_acceptance::crdt::*`, matching the signature handoff section 5 recorded. So the step is conditional on `--protocol`, which the framing voted for on an inference this run confirms rather than assumes. Also observed, and worse than the twelve: `a54_real_daemon_real_pty_and_ headless_gpu_render_one_panel_hosted_terminal` reported `ok` in that same 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 only fail twelve tests --- it silently voids coverage in tests that report green. A NAMED STEP, NOT A FOLDED COMMAND. `cargo build ... && cargo test ...` would report a BUILD failure under the name `sweep-crdt`, a wrong attribution in the one place this script exists to be trustworthy about. `--self-test` is how that attribution is witnessed at all. The existing suite drives only no-gates paths, so plan assertions can prove a step's name and order and NOTHING about what the runner does when a step fails. The mode runs a HARDCODED three-line synthetic plan through the real runner: a passing step, a failing one named `build-crdt`, and a passing SENTINEL after it. The sentinel is load-bearing --- with the failure last, an aborting runner and a continuing one produce identical output, so the witness would pass on a runner doing the opposite of the stated policy. The plan is a literal inside the script. Making `PLAN_FILE` injectable would work and would turn the runner's `eval` into a general command executor --- the same defect this script's own review caught in `--acceptance` and fixed with a refusal at parse time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- scripts/gate | 102 +++++++++++++++++++++- tests/gate_script_acceptance.rs | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 4 deletions(-) diff --git a/scripts/gate b/scripts/gate index c305f8e..9eb3d3a 100755 --- a/scripts/gate +++ b/scripts/gate @@ -6,8 +6,11 @@ # 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 3, approved) for the +# crdt build step and --self-test. # # WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one # CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK @@ -40,6 +43,7 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] 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,7 +52,8 @@ 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. + 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. --print-target-dir print this worktree's build directory and exit. Creates nothing. @@ -57,6 +62,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 +210,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. # @@ -329,6 +410,7 @@ while [ $# -gt 0 ]; do --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 ;; @@ -397,11 +479,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..d417758 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -127,6 +127,156 @@ 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. +#[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}")); + + // 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}" + ); +} + +/// **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. From 898a98120cceff87d301cb15cede14148fbee518 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:39:35 +0200 Subject: [PATCH 05/66] docs: one normative home for the crdt sweep's build precondition The durable half of this lane is a boundary question, not the missing line. `scripts/gate`'s header names handoff section 3 as the owner of its reasoning, and this precondition lived in section 5's hazard register --- a coherent cause for the omission rather than mere oversight. A requirement the script was never told to encode is one it will keep not encoding. So section 3 gains it NORMATIVELY: the build joins the protocol-bump block as a third line, with its own load-bearing bullet covering the mechanism (pmacs-gpu has no tests/ directory, so cargo never uplifts its bin), the measurement that makes it conditional, and why it was latent until per-worktree target directories stopped hiding it. Section 5 keeps the INCIDENT and its signature, which is history rather than contract, and now says so: twelve `gpu_invocation_acceptance::crdt::*` failures on a target directory with no `debug/pmacs-gpu`, first seen on PR #228's first gate run. Recast so that seeing the signature again reads as "the script was bypassed", not "the requirement moved". The script's header keeps citing section 3 and ONLY section 3. Citing both would split one executable contract across two homes and weaken the script's only clean boundary 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. The ledger records Q#GR-1's observed answer rather than the question: both sweeps run alone from the same cold disposable target with `debug/pmacs-gpu` asserted absent beforehand --- default exit 0 with the binary still absent afterwards, crdt exit 101 with exactly twelve failures --- plus the silent-skip finding, which is the part nobody was looking for: a54 reported `ok` in that cold crdt sweep because its only non-spawning path is its skip branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 70 +++++++++++++++++++++++++++++++++---------- docs/agent-handoff.md | 56 ++++++++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 21 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 305bb99..05f1287 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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 — BRANCHED, framing in review +## `scripts/gate --protocol` build step — IMPLEMENTED, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,15 +275,15 @@ from #171 and #215. 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 3**, in - review. 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. -- **The defect.** `--protocol` adds the CRDT workspace sweep, whose - documented precondition is `cargo build --workspace - --no-default-features --features luajit,crdt` (handoff §5:532-535). - The plan emitter (`scripts/gate:187-204`) has **no build step at - all** — read from the source, not inferred from the failure. +- **Framing `docs/gate-protocol-build-framing.md`, revision 3, + APPROVED and implemented.** 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. +- **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 @@ -308,14 +308,52 @@ authoritative tip** — the ref, not a SHA. Recover with 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 must be settled by OBSERVATION before implementation** — - whether the default sweep needs the binary too. The entire defect is - a precondition nobody checked; establishing its replacement by - reading would repeat the error at one remove. +- **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 attribution *and* + continuation observable at all. `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`. - **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. -- **Gates:** `scripts/gate --acceptance gate_script_acceptance`. + script. Deliberately **not** folded into that feature branch, and it + happens **after** this lands, not inside it. +- **Gates:** `scripts/gate --acceptance gate_script_acceptance`. Note + the recursion — this lane edits the script that runs its own gates, + so `--print-plan`, `--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. ## QoL arc retirement — PR #224 OPEN (docs only) 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 From 053866c7f62212e0a23d259a338507c42892b723 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:42:09 +0200 Subject: [PATCH 06/66] test(gate): name the ordering failure instead of panicking on a range Found by mutation-testing the assertion rather than by reading it. Emitting `build-crdt` AFTER `sweep-crdt` does fail the test --- so the position criterion was never vacuous --- but it failed by panicking inside the slice with begin > end (427 > 282) when slicing `cargo fmt --check ...` which names neither step and reads as a bug in the test. A gate test whose failure has to be decoded is a gate test nobody trusts, and this suite exists precisely to be trustworthy about the gate. An explicit ordering assertion ahead of the slice says what is wrong: the build must run before the sweep, because a sweep that builds its own precondition afterwards has already failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- tests/gate_script_acceptance.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index d417758..a3da6ee 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -162,6 +162,17 @@ fn the_crdt_sweep_is_immediately_preceded_by_the_build_that_produces_its_binary( .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. From 49bc141500f3d57e2ee6f1e6e81b0c2f0e8859d9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:15:56 +0200 Subject: [PATCH 07/66] docs: record the acceptance run, and the red that came with it The acceptance criterion is witnessed: `scripts/gate --acceptance gate_script_acceptance --protocol` on a target root that did not exist beforehand goes green in all eleven steps, with `09 build-crdt ok` producing `debug/pmacs-gpu` and `gpu_invocation_acceptance` at 15 passed / 0 failed where the same suite is 3 / 12 without the build step. No manual build anywhere, which is the thing that was false. An EARLIER attempt at that same cold run went red, and it is recorded rather than dropped once a later run was green. Fifty failures across m5_5/m5_6/m5_7/m5_8 --- all real-daemon suites --- with the signature "daemon exited with exit status: 101 before socket appeared; socket=/tmp/.tmpXXXX/pmacs.sock" and an EMPTY daemon stderr. 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. Per that registry's own rule a green rerun establishes INTERMITTENCE ONLY, never environmental cause, so this is left open rather than blamed on the load it happened under. What DOES rule out this lane's change is a construction argument, not the rerun: the root crate declares `default = ["luajit"]`, so `--no-default-features --features luajit,crdt` enables exactly the same feature set as the sweep's `--features crdt`. `build-crdt` cannot hand the sweep a differently-featured binary, so it has no mechanism by which to break a daemon suite. Also records that the new assertions were mutation tested --- wrong features, wrong position, unconditional emission, an aborting runner, and the build folded into `sweep-crdt` each fail the suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 05f1287..dcf7b4e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -349,11 +349,41 @@ authoritative tip** — the ref, not a SHA. Recover with 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`, `--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. + reported honestly by the script. The assertions were **mutation + tested**: wrong features, wrong position, unconditional emission, + an aborting runner, and the build folded into `sweep-crdt` each fail + the suite. ## QoL arc retirement — PR #224 OPEN (docs only) From 48120f2a80636f40324fc8b6470e936eb60b616b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:55:13 +0200 Subject: [PATCH 08/66] docs: gate framing revision 4 --- the witness does not reach the step Review mutated the REAL build step name to sweep-crdt and both existing witnesses still passed. That is the gap: --print-plan strips names before printing, so the order assertion sees only commands, and --self-test hardcodes build-crdt inside its own synthetic plan. Neither witness is connected to the step it claims to describe, so this lane shipped without the regression guard it exists to provide. Section 7 now requires asserting the real emitter (name, command) pair together, so a rename cannot pass. The synthetic failure and 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. Also fixed the header, which read "Pre-implementation. Awaiting approval" through three revisions while the ledger recorded this lane as approved and implemented. That is the same contradiction class this project keeps correcting elsewhere, left standing in the document that keeps correcting it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gate-protocol-build-framing.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md index 5ee8dd2..390e24a 100644 --- a/docs/gate-protocol-build-framing.md +++ b/docs/gate-protocol-build-framing.md @@ -1,7 +1,12 @@ # `scripts/gate --protocol` — the build its sweep depends on -**Status: framing pass, revision 3. Pre-implementation. Awaiting -approval.** +**Status: revision 4. APPROVED and IMPLEMENTED at `49bc141`; one +regression-witness gap found in review of that implementation remains +open (Q#GR-5).** + +*(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 @@ -269,6 +274,17 @@ named so it is not mistaken for an oversight. 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. - **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 From 677fd25bcd7a70b501080ff825b7d52b2449838f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:02:36 +0200 Subject: [PATCH 09/66] test(gate): assert the real plan's (name, command) pair, not one half The 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`. It shipped with neither guaranteed, because NEITHER WITNESS COULD SEE A NAME. --print-plan renders `emit_plan | cut -f2-`, so the ordering test compared commands and never saw 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: 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 exactly the misattribution the separate step exists to prevent. --print-plan-named prints emit_plan VERBATIM: the same `namecommand` text the runner reads back from PLAN_FILE. The new assertion compares WHOLE LINES against it, so name and command are pinned together and a rename of either step fails. The sweep's own pair is asserted too --- asserting only the build's name leaves the identical hole open in the other direction. WHY A RENDERING AND NOT A SEAM. PLAN_FILE stays uninjectable: a test that supplied the runner's plan would turn its `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. Re-deriving the plan test-side would be a second implementation of the thing under test, which is the failure being repaired one level up. A distinct mode rather than a --with-names modifier leaves --print-plan's contract --- runnable lines --- exactly as it was, and defines no flag combination that has no meaning. --self-test STAYS. It witnesses the runner: failure naming, the FAILED: list, log paths, non-zero exit, and continuation past a failure via the sentinel. That is a different thing from attributing the real step, and what it may no longer do is stand in for it. A second test pins that the two renderings are one plan --- the stripped one is the named one minus its names --- so a later edit giving either mode its own text is caught rather than leaving an assertion on a name the runner never uses. It also pins the `namecommand` shape the runner's `IFS= read` depends on. Both new tests stay on the no-gates paths, so the suite stays cheap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- scripts/gate | 59 +++++++++++++- tests/gate_script_acceptance.rs | 140 ++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/scripts/gate b/scripts/gate index 9eb3d3a..a351371 100755 --- a/scripts/gate +++ b/scripts/gate @@ -3,14 +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), and -# docs/gate-protocol-build-framing.md (revision 3, approved) for the -# crdt build step and --self-test. +# docs/gate-protocol-build-framing.md (revision 4, 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 @@ -40,6 +41,7 @@ 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] @@ -54,7 +56,13 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] --protocol the change touches PROTOCOL_VERSION; adds the CRDT 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. + --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, @@ -407,6 +415,7 @@ 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 ;; @@ -417,10 +426,54 @@ while [ $# -gt 0 ]; do 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 diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index a3da6ee..37de5f2 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -146,6 +146,13 @@ fn the_crdt_workspace_sweep_is_added_by_protocol_and_absent_without_it() { /// 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"); @@ -183,6 +190,139 @@ fn the_crdt_sweep_is_immediately_preceded_by_the_build_that_produces_its_binary( ); } +/// **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. From 74d735ff8de539fee942ec70859b3897f96aa97a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:11:14 +0200 Subject: [PATCH 10/66] docs: gate framing revision 5 --- pin both emitter pairs, correct print-plan Revision 4 said it would close the rename hole for the sweep as well as the build, and then did not: its verification bullet required only a singular real-plan pair in the context of build-crdt. The hole is symmetric --- renaming sweep-crdt slips through exactly the same gap --- so section 7 now pins BOTH emitter pairs explicitly, name and exact command, asserted from the emitter where the name still exists. Two older bullets also still claimed named steps appear in --print-plan. They do not; that mode prints commands only, which is the wording that let the attribution witness drift away from the step it names in the first place. --print-plan is now described as the command and order witness, and nothing more. The ledger recorded revision 4 as approved and implemented, and presented the synthetic self-test as the attribution witness. Both were read as done. A recovering machine or a PR preparation from that entry would have shipped this lane without the regression guard it exists to provide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 89 ++++++++++++++++++++++++----- docs/gate-protocol-build-framing.md | 56 +++++++++++++----- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index dcf7b4e..afaffbd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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, no PR yet +## `scripts/gate --protocol` build step — IMPLEMENTED at `49bc141`, then RE-OPENED by review **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,10 +275,24 @@ from #171 and #215. 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 3, - APPROVED and implemented.** 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. +- **Framing `docs/gate-protocol-build-framing.md`, revision 5.** The + fix itself is implemented and green at `49bc141`; **the regression + witness is NOT, and that is an open blocker.** 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. +- **OPEN BLOCKER — the witnesses do not reach the step they name.** + `--print-plan` **strips names** before printing, so the ordering + assertion sees 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 this lane currently ships without the regression + guard it was created to provide. §7 now 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. - **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 @@ -340,11 +354,59 @@ authoritative tip** — the ref, not a SHA. Recover with 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 attribution *and* - continuation observable at all. `PLAN_FILE` is deliberately **not** + 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 @@ -378,12 +440,13 @@ authoritative tip** — the ref, not a SHA. Recover with 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`, `--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, and the build folded into `sweep-crdt` each fail - the suite. + 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) diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md index 390e24a..2845aa7 100644 --- a/docs/gate-protocol-build-framing.md +++ b/docs/gate-protocol-build-framing.md @@ -1,8 +1,9 @@ # `scripts/gate --protocol` — the build its sweep depends on -**Status: revision 4. APPROVED and IMPLEMENTED at `49bc141`; one -regression-witness gap found in review of that implementation remains -open (Q#GR-5).** +**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 @@ -96,9 +97,10 @@ rather than an assumption the fix rests on. `sweep-crdt` and only under `--protocol`***, running the invocation handoff §5 names. -- **A named step, not a silent prelude.** It appears in - `--print-plan`, gets its own numbered log alongside the others, and - fails the suite with its own name if the build fails. +- **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 @@ -254,15 +256,25 @@ named so it is not mistaken for an oversight. ## 7. Verification -- **`--print-plan --protocol` emits `build-crdt` immediately before - `sweep-crdt`, carrying the EXACT command.** All three asserted — - presence, position, and the literal - `cargo build --workspace --no-default-features --features luajit,crdt`. - Name and position alone would pass on a step that builds the wrong - feature set, which is the failure this lane is fixing: the crdt sweep - needs *those* features, and a `build-crdt` that ran plain - `cargo build` would leave the gate exactly as unsound while looking - repaired. +- **`--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). @@ -285,6 +297,20 @@ named so it is not mistaken for an oversight. 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 From 93d557a527b4829325710b09461b8f96a33866b0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:18:34 +0200 Subject: [PATCH 11/66] docs: record the witness as closed, and what the audit found next door Revision 5's ledger edits were written while the witness was still the open blocker, and landed alongside the commit that closed it --- so the entry asserted both at once: an OPEN BLOCKER bullet saying this lane "currently ships without the regression guard it was created to provide", and, further down, that same gap closed at 677fd25. A recovering machine reads the top of an entry first, so the stale half is the half that gets acted on. Reconciled in place: the heading, the framing bullet and the blocker bullet now say re-opened by review and CLOSED at 677fd25, and point at the bullet that closed it. The script header cited framing revision 4; it is revision 5. Also recorded, from auditing whether any OTHER assertion in that suite is detached from the thing it names: renaming every other plan step --- fmt, clippy, lib, m4, gpu, sweep, diff-check, acceptance- --- leaves all 20 tests green. For most that is only a log filename and a FAILED: entry. `sweep` is not: the runner's end-of-run listing globs *-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. Left open deliberately and said so --- that listing exists only on the RUN path, and every test in this file is no-gates by design, so there is no cheap witness for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 40 ++++++++++++++++++++++------------------ scripts/gate | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index afaffbd..93fb78a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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`, then RE-OPENED by review +## `scripts/gate --protocol` build step — IMPLEMENTED at `49bc141`, RE-OPENED by review, witness CLOSED at `677fd25`. No PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -276,23 +276,27 @@ 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`; **the regression - witness is NOT, and that is an open blocker.** 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. -- **OPEN BLOCKER — the witnesses do not reach the step they name.** - `--print-plan` **strips names** before printing, so the ordering - assertion sees 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 this lane currently ships without the regression - guard it was created to provide. §7 now 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. + 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 diff --git a/scripts/gate b/scripts/gate index a351371..b946f69 100755 --- a/scripts/gate +++ b/scripts/gate @@ -10,7 +10,7 @@ # scripts/gate --self-test # # Framing: docs/gate-script-framing.md (revision 4, approved), and -# docs/gate-protocol-build-framing.md (revision 4, approved) for the +# 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 From ae6a815435310921dd22ab568f6d8ffef25159e6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 23:30:53 +0200 Subject: [PATCH 12/66] docs: two unresolved macOS signatures from #229 CI, as separate rows PR #229 first CI run went red on Test (macos-latest / lua54) with a_pty_resize_blanks_the_host_before_repainting. The rerun turned that selector green in 0.50s against 20.26s failing, and went red on a DIFFERENT selector, ctrl_c_during_reconnect_sleep_yields_clean_exit. Per this file matching rule that is a new incident, not the resize signature occurring twice, so they are two rows. U4 records the resize failure with its exact fragments. The diff is excluded on two structural grounds that do not depend on a rerun: #229 touches no src/ at all and no test but gate_script_acceptance, and the sibling luajit leg passed on the same commit. A deterministic platform defect fails both flavours --- that is how #227 non-UTF-8 fixture presented. U5 records the Ctrl-C failure at deliberately WEAKER exclusion strength, and says so in its own field. The changed gate suite ran earlier in the same job and creates worktrees and directories. No leaked child or persistent signal-state mutation was observed, but "the diff touches no src/" is not the same argument here as for U4, because cross-suite leaked state is a path reachability reasoning does not close. Its control is to run m5_8_acceptance alone, without the gate suite ahead of it, before attributing anything either way. Neither row claims a mechanism. The exit status shows only that Ctrl-C arrived as SIGINT rather than as the raw-mode key event the test drives; whether injection preceded raw mode, raw mode was lost, or something else happened is open, and the fragment does not separate them. Also flagged: worker-identity-stage1 independently defines its own U4 and U5. This lane merges first, so that branch must renumber on rebase --- a conflict resolved textually without renumbering would leave two different incidents sharing an id, which is the failure the matching rule exists to prevent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 15 +++++++++++++++ docs/ci-red-signatures.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 93fb78a..e0816fd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -267,6 +267,21 @@ 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. diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index ad050e9..f94d9ea 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -566,3 +566,39 @@ 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. **The diff is excluded on two independent +structural grounds, neither of which is a rerun**: #229 touches no +`src/` at all and no test but `tests/gate_script_acceptance.rs`, and the +sibling `macos-latest / luajit` leg **passed on the same commit**. A +deterministic platform defect fails both flavours — that is how #227's +non-UTF-8 fixture presented. + +| 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`. **NOT** seen on the `luajit` leg of the same commit | +| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the 25362 bytes emitted after the first painted frame` · `panicked at tests/full_grid_resync_acceptance.rs:126` | +| **status** | **one occurrence; INTERMITTENT — passed on rerun in 0.50s against 20.26s failing** | +| **what IS established** | the assertion saw ~25 KB of post-frame output with no `CSI 2 J` in it, and the suffix head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was *painting* — it just never emitted the blank. The rerun's 0.50s versus 20.26s is a 40× spread, which is itself the signal | +| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted before the capture window, or lost in transport is open. The green rerun establishes **intermittence only**, per this file's rerun rule | +| **discriminating control for the next occurrence** | capture the **full** post-frame byte stream rather than the 25 KB window, with timestamps, so "never emitted" separates from "emitted outside the window". The 40× duration spread suggests a readiness/timing interaction worth timing directly | + +### 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") }` · `panicked at tests/m5_8_acceptance.rs:546` | +| **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 the fragment does not separate them | +| **exclusion strength — WEAKER than U4's, deliberately** | #229's 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 same argument here as it is for U4, because cross-suite leaked state is a path that reasoning about reachability does not close. Recorded at this strength on purpose | +| **discriminating control for the next occurrence** | run `m5_8_acceptance` **alone** on macOS `lua54`, without the gate suite ahead of it, before attributing anything to either | From 5029915ae3716475799b7f4cf220bb3b64a18ef0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 00:02:53 +0200 Subject: [PATCH 13/66] docs: normalize U4/U5 fragments, and drop three claims the evidence does not carry Four review findings, all mine. Normalization. The byte count and the :LINE suffix are occurrence-specific --- the count is the collected suffix length, which varies per run, and the line moves with the file --- so neither can be a required fragment. Both rows now carry stable fragments and an explicit NOT-fragments field naming what must not be matched on. The LuaJIT-pass argument was the rejected overreach again. I used a passing sibling leg as a STRUCTURAL exclusion; a deterministic defect can be Lua-flavour-specific, so it is corroboration only. The row now says so in those words. The real grounds are stronger anyway and were sitting there: the workflow never invokes scripts/gate, and full_grid_resync_acceptance runs BEFORE the changed gate suite, which closes even the leaked-state path. Three contradictions inside U4, each removed. "It never emitted the blank" asserts a mechanism the next field simultaneously calls open. Now: no blank was OBSERVED after the mark within the deadline. The 25,362 bytes were not a capped window. suffix.len() is the ENTIRE post-mark output; only the displayed head is truncated, to 400 bytes. Verified in the test source. So my control --- capture the full stream rather than the window --- was solving a gap that does not exist. The gap is arrival TIME, and the control now instruments that instead. The ~20s failure duration IS the fixed Duration::from_secs(20) timeout, so the ratio against a fast pass is mechanically determined and is not independent timing evidence. Also verified in the source. U5 control relabelled. Running m5_8_acceptance alone decides whether the gate suite is implicated --- cross-suite attribution --- and nothing more. It cannot separate injected-before-raw-mode from raw-mode-lost from a third cause, and another isolated pass cannot either however often it is repeated. Mechanism discrimination needs readiness and raw-mode state observed AT INJECTION, which is now a second, separately labelled control. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index f94d9ea..f938216 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -569,22 +569,19 @@ of them is harmless. ### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS `lua54`, one occurrence -Surfaced on PR #229's CI. **The diff is excluded on two independent -structural grounds, neither of which is a rerun**: #229 touches no -`src/` at all and no test but `tests/gate_script_acceptance.rs`, and the -sibling `macos-latest / luajit` leg **passed on the same commit**. A -deterministic platform defect fails both flavours — that is how #227's -non-UTF-8 fixture presented. +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`. **NOT** seen on the `luajit` leg of the same commit | -| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the 25362 bytes emitted after the first painted frame` · `panicked at tests/full_grid_resync_acceptance.rs:126` | -| **status** | **one occurrence; INTERMITTENT — passed on rerun in 0.50s against 20.26s failing** | -| **what IS established** | the assertion saw ~25 KB of post-frame output with no `CSI 2 J` in it, and the suffix head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was *painting* — it just never emitted the blank. The rerun's 0.50s versus 20.26s is a 40× spread, which is itself the signal | -| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted before the capture window, or lost in transport is open. The green rerun establishes **intermittence only**, per this file's rerun rule | -| **discriminating control for the next occurrence** | capture the **full** post-frame byte stream rather than the 25 KB window, with timestamps, so "never emitted" separates from "emitted outside the window". The 40× duration spread suggests a readiness/timing interaction worth timing directly | +| **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 three docs — **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 for the next occurrence** | the suffix is already complete, so "capture more bytes" is not the gap — **arrival time is**. Instrument the wait to record whether `CLEAR_ALL` arrives at all when the deadline is extended, and at what offset, which separates "never emitted" from "emitted late" | ### U5 — `ctrl_c_during_reconnect_sleep_yields_clean_exit`, macOS `lua54`, one occurrence @@ -596,9 +593,11 @@ incident, not U4 occurring twice**. |---|---| | **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") }` · `panicked at tests/m5_8_acceptance.rs:546` | +| **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 the fragment does not separate them | -| **exclusion strength — WEAKER than U4's, deliberately** | #229's 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 same argument here as it is for U4, because cross-suite leaked state is a path that reasoning about reachability does not close. Recorded at this strength on purpose | -| **discriminating control for the next occurrence** | run `m5_8_acceptance` **alone** on macOS `lua54`, without the gate suite ahead of it, before attributing anything to either | +| **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 ONLY** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. This decides whether the gate suite is implicated. **It does NOT discriminate among the three mechanisms** and must not be read as doing so | +| **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 | From 3b10f9d916de9b6811ce600853acb28ff90010f6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 10:26:07 +0200 Subject: [PATCH 14/66] docs: both U4 and U5 controls are asymmetric, and said so only one way Three corrections. The first two are the same error in two rows. I wrote each control as if it DECIDES, when each is informative in only one direction. U4: extending the deadline establishes "emitted late" IF the clear arrives. If it does not arrive, that establishes only "not observed by the longer deadline" --- not "never emitted" --- because transport loss produces the same absence. No deadline, however long, separates non-emission from transport loss. That needs producer-side emission evidence, did pmacs write the clear, cross-checked against the collected stream. The row now states both branches and names what the negative branch cannot conclude. U5: one isolated run cannot decide whether the gate suite is implicated. A matching isolated RED proves the gate suite is not necessary for the failure. An isolated GREEN proves nothing beyond that run, because the failure is intermittent and absence under one run is not evidence of dependence. I had written it as though either outcome settled the question. This is worth naming as a class rather than two typos: a control whose positive branch is conclusive and whose negative branch is not, written up as though both were, is how an inconclusive result gets recorded as an exclusion. Two rows in this file had it. Third, minor: "three docs" was accurate at the occurrence tip and is not now --- #229 has since added this registry file. Replaced with "documentation" so the claim does not rot again with the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index f938216..0e4e6a9 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -578,10 +578,10 @@ Surfaced on PR #229's CI. | **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 three docs — **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 | +| **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 for the next occurrence** | the suffix is already complete, so "capture more bytes" is not the gap — **arrival time is**. Instrument the wait to record whether `CLEAR_ALL` arrives at all when the deadline is extended, and at what offset, which separates "never emitted" from "emitted late" | +| **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 @@ -599,5 +599,5 @@ incident, not U4 occurring twice**. | **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 ONLY** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. This decides whether the gate suite is implicated. **It does NOT discriminate among the three mechanisms** and must not be read as doing so | +| **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 | From 7992054153628ee292531e949712c0527c58b42b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:28:53 +0200 Subject: [PATCH 15/66] docs: LSP LaTeX coverage framing revision 2, on a branch at last Revision 1 was untracked, on main, in a single checkout. By the handoff own rule --- work is portable only after it is committed and pushed --- it did not travel. Committing it here is the first fix. Three factual corrections, all from checking rather than reading. haskell-language-server IS installed on this machine, along with its wrapper. Revision 1 said it was not, and that claim was the entire basis of its Slice 1 / Slice 2 split. Only OCaml still lacks a server. The argument for leaving Haskell out survives, but on use evidence --- the .hs files are a rarely-edited Hakyll generator --- not on dependency cost. Slice 1 is smaller than framed. Revision 1 proposed filetype mappings for .tex/.latex/.sty/.cls "so highlighting and LSP agree on what a LaTeX file is". They already agree, by construction: the grammar carries exactly those extensions at src/syntax.rs:1111, grammar-extension detection sits AHEAD of the LSP filetype map in the precedence chain per the merged grammar framing at :166-171, and lsp.lua:267-270 calls the filetype map "mainly the LSP-only fallback". The real missing piece is one config entry. Q#LX3 deferral argument read a stale line. COHERENCE.md:124 and :867 both record multi-root LSP affinity as merged in #161; only :1669 still says "first slice in flight", contradicting the same document twice. The one item revision 1 said could justify deferring therefore dissolves. The COHERENCE.md inconsistency is real and is left for whoever next touches section 20 rather than smuggled in here. Q#LX2, which revision 1 called the question most likely to make the entry wrong in practice, is now answered rather than shrugged at. An upward marker walk through config.latex.root, which already accepts a resolver function. .git is deliberately excluded: a repository root is the wrong answer for LaTeX, since texlab wants the document root, and this is the one place where copying the other fourteen entries instinct would be actively wrong. The .texlabroot marker is marked UNVERIFIED and blocking. texlab 5.25.1 is installed and its version and CLI were checked directly, but the CLI exposes only run and inverse-search, so its LSP-level behaviour was not established. Confirm against a live session before implementing. Same discipline the gate-protocol-build lane applies to its own precondition: the thing the design rests on gets observed, not reasoned about. Also renumbered Q#HS1 to Q#LX4, because revision 1 Q#LX2 collided with a live question ID in the merged grammar framing for the same language. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 58 ++++++- docs/lsp-language-coverage-framing.md | 238 ++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 docs/lsp-language-coverage-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index e0816fd..62f6afb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,10 +265,61 @@ 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 +## LSP LaTeX coverage — BRANCHED, framing in review -**PR #229 OPEN** — https://github.com/levineuwirth/pmacs/pull/229, -opened at `93d557a`. **Held, not merged.** Its first CI run went red on +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `lsp-latex-coverage`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/lsp-latex-coverage` is the +authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout lsp-latex-coverage`. + +- **Framing `docs/lsp-language-coverage-framing.md`, revision 2**, in + review. **Revision 1 was UNTRACKED on `main` in one checkout** and + therefore did not travel; committing it here is the fix. +- **Scope: one `pmacs.lsp.config.latex` entry plus its root resolver.** + `texlab` 5.25.1 is installed and unused; a `.tex` buffer highlights + correctly and offers no completion, diagnostics, or go-to-definition. +- **Revision 2 found Slice 1 is SMALLER than revision 1 framed.** The + proposed `.tex`/`.latex`/`.sty`/`.cls` filetype mappings are + redundant: the grammar already carries exactly those extensions + (`src/syntax.rs:1111`), grammar-extension detection sits **ahead** of + the LSP filetype map in the precedence chain + (`docs/latex-grammar-math-substrate-framing.md:166-171`), and + `lsp.lua:267-270` calls that map "mainly the LSP-only fallback". The + two systems cannot disagree, because the grammar's extension list is + what drives detection. +- **Two other corrections.** `haskell-language-server` **is** installed + on this machine — revision 1 said it was not, which was the whole + basis of its Slice 1 / Slice 2 split. And Q#LX3's deferral argument + read `COHERENCE.md:1669` ("first slice in flight") when `:124` and + `:867` both record multi-root affinity as **merged (#161)**; that + line contradicts the same document twice and wants a separate fix. +- **Q#LX2 (the LaTeX root) is answered, with a caveat that must be + discharged first.** An upward marker walk — `.texlabroot`, + `latexmkrc`/`.latexmkrc`, `Tectonic.toml`, then the file's own + directory — through `config.latex.root`, which already accepts a + resolver function (`lsp.lua:543`). **`.git` is deliberately excluded**: + a repo root is the wrong answer for LaTeX, and it is the one place + copying the other fourteen entries' instinct is actively wrong. + **Whether texlab honours `.texlabroot` is UNVERIFIED** — only its + version and CLI were checked, and the CLI exposes no such surface. + Confirm against a live session before implementing; if it is not a + real marker, the walk starts at `latexmkrc`. +- **Gates:** `scripts/gate --acceptance `. No + `--protocol` — a config entry, no wire. + +## `scripts/gate --protocol` build step — **MERGED as #229** (`7cf4653`) + +**MERGED as PR #229** — https://github.com/levineuwirth/pmacs/pull/229, +at `3b10f9d`, 14/14 CI green including both macOS legs. `main` is now +`7cf4653`. *(This lane still awaits Rule 4 retirement — its durable +facts belong in the handoff before the entry is removed. Corrected here +only because the previous text said "Held, not merged", which the merge +falsified; the retirement itself is not this lane's work.)* + +**History, retained:** opened at `93d557a`. 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 @@ -466,6 +517,7 @@ authoritative tip** — the ref, not a SHA. Recover with 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. +||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last) ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md new file mode 100644 index 0000000..67bf343 --- /dev/null +++ b/docs/lsp-language-coverage-framing.md @@ -0,0 +1,238 @@ +# LSP language coverage: LaTeX (and the Haskell/OCaml question) + +**Status: framing pass, revision 2. Pre-implementation. Awaiting +approval.** + +**Revision 2 corrects three facts revision 1 got wrong or stale, and +answers the question revision 1 named as most likely to make the entry +wrong in practice.** Haskell's server *is* installed; Slice 1 is +**smaller** than framed because the extension wiring already exists; +Q#LX3's deferral argument rests on a `COHERENCE.md` line the same +document contradicts twice; and Q#LX2 (the LaTeX root) now has a +proposal rather than a shrug. + +**Revision 1 was untracked, on `main`, in one checkout.** Per the +handoff's own rule — work is portable only after it is committed and +pushed — it did not travel. That is fixed by this branch. + +--- + +## 0. What prompted this + +An audit of the host machine against `builtin/runtime/lsp.lua`. pmacs +configures LSP for fourteen languages — verified exactly, by extracting +the `pmacs.lsp.config.*` keys: + + bash c cmake cpp cuda dockerfile go json lua + python rust toml yaml zig + +**Lean is NOT among the gaps, and revision 1's first draft wrongly said +it was.** Lean 4 has `builtin/runtime/lean.lua`, `lean_abbrev.lua` and +`lean_input.lua` (all three present), an `arborium-lean` grammar, and +comment/typed-edit integration — Arc 8 Stages 1–4b, merged. The error +came from grepping `lsp.lua` alone, which is the wrong place to look +for a language that earned its own module. + +## 1. The gap, re-measured + +| Language | tree-sitter | LSP config | Server on this machine | +|---|---|---|---| +| LaTeX | ✅ grammar + `builtin/queries/latex/highlights.scm` | ❌ | **`texlab` 5.25.1 — installed** | +| Haskell | ❌ | ❌ | **`haskell-language-server` — INSTALLED** | +| OCaml | ❌ | ❌ | `ocaml`/`opam`/`dune` yes, `ocaml-lsp-server` **absent** | + +**Correction: revision 1 said Haskell's server was missing.** Both +`haskell-language-server` and `haskell-language-server-wrapper` are on +this machine. That collapses revision 1's Slice 1 / Slice 2 split, +which rested on "Slice 2 needs servers installed first" — only OCaml +does now. + +LaTeX remains the sharp case: the grammar work landed, so a `.tex` +buffer highlights correctly **and** offers no completion, no +diagnostics, no go-to-definition, while `texlab` sits on disk unused. + +## 2. Ground truth — Slice 1 is smaller than revision 1 claimed + +Revision 1 proposed "one `pmacs.lsp.config.latex` entry, **plus** +filetype mappings for `.tex`/`.latex`/`.sty`/`.cls`, matching the +grammar's existing extension set so highlighting and LSP agree on what +a LaTeX file is." + +**The filetype mappings are redundant, and the rationale describes a +problem that cannot occur.** Three facts, read rather than assumed: + +- **The grammar already carries exactly those extensions.** + `src/syntax.rs:1110-1112` — `name: "latex"`, + `extensions: &["tex", "latex", "sty", "cls"]`. +- **Grammar-extension detection sits AHEAD of the LSP filetype map.** + The merged `docs/latex-grammar-math-substrate-framing.md:166-171` + states the chain — *modeline → grammar extension → LSP filetype map → + filename map → shebang* — and concludes that adding those extensions + "**wires the whole chain with no Lua edit**". +- **The filetype map is explicitly a fallback.** `lsp.lua:267-270`: + "Every language with an LSP config now also ships a grammar, so this + is mainly the **LSP-only fallback** that keeps a language id stable if + a grammar is ever dropped, plus the seam for user-added mappings." + +So a `.tex` buffer **already** resolves to language `latex`. They +cannot disagree, because the grammar's extension list *is* what drives +detection. + +**Slice 1 is therefore one thing: the `pmacs.lsp.config.latex` entry** +(plus its root resolver, §3). Filetype-map entries may still be added +as the documented drop-a-grammar fallback, but that is belt-and-braces +and should be labelled as such rather than sold as making two systems +agree. + +## 3. Q#LX2 — the LaTeX project root **(answered in rev 2)** + +Revision 1 called this "the question most likely to make the entry +wrong in practice" and left it open. It is the difference between +texlab serving a multi-file thesis and serving isolated files, so it is +the whole value of the lane for the stated use case. + +**The mechanism exists.** `pmacs.lsp.config..root` accepts a +string **or a resolver function**, resolved through `resolve_root_fn` +(`lsp.lua:543`) with per-resolver memoization, and on the *reuse* path +as well as the spawn path (`:535`). A configured root "MUST be a +canonical absolute path" (`:525`). So this is a config entry, not new +machinery. + +*My vote: **an upward marker walk with an explicit precedence, falling +back to the file's own directory.*** In order: + +1. **`.texlabroot`** — if texlab honours it (see the verification + caveat below), an explicit user-placed marker should win over + everything inferred. +2. **`latexmkrc` / `.latexmkrc`** — a build config is a strong, + deliberate signal of a document root. +3. **`Tectonic.toml`** — the same for tectonic projects. +4. **The file's own directory**, as the fallback. + +**Deliberately NOT in the walk: `.git`.** A repository root is the +wrong answer for LaTeX — texlab wants the *document* root, and a thesis +inside a monorepo would otherwise get the monorepo. This is the one +place where copying the other fourteen entries' instinct would be +actively wrong. + +**Deliberately NOT proposed: scanning for `\documentclass`.** That is +the semantically correct notion of a root document, and it is a +directory scan on every resolve, with its own caching and invalidation +questions. If the marker walk proves insufficient in use, that is the +next increment — with evidence. + +**VERIFICATION CAVEAT, stated rather than buried.** `texlab 5.25.1` is +installed and its version and CLI surface were checked directly. Its +**LSP-level** behaviour — whether it honours `.texlabroot`, and how it +resolves multi-file projects from a root URI — was **not** verified +here; the CLI exposes only `run` / `inverse-search`, so this needs a +live session. **Marker 1 is provisional and must be confirmed against a +running texlab before implementation**, exactly as the sibling +`gate-protocol-build` lane requires its precondition to be observed +rather than reasoned about. If `.texlabroot` is not a real marker, it +drops and the walk starts at `latexmkrc`. + +## 4. Open questions + +### Q#LX1 — does `texlab` need `settings` or `init_options`? + +It pulls configuration via `workspace/configuration` under a `texlab` +section, which pmacs answers (#13). An empty section takes defaults, as +the Go entry does for gopls. + +*My vote: **ship nothing.*** Build-on-save and forward-search are the +two candidates and both are opinionated; forward-search additionally +needs a configured viewer, so a default would be wrong for most +machines. Users override through the same `init.lua` seam as the other +fourteen. + +### Q#LX4 — do Haskell and OCaml belong in this lane at all? *(renumbered — see below)* + +With HLS installed, Haskell is now the same shape as LaTeX: one entry, +no new dependency. **But the argument against it never rested on the +dependency.** The `.hs` files here are `levineuwirth.org`'s Hakyll +generator, edited rarely; HLS is version-coupled to GHC and is a large +resident process for a language touched a few times a year. + +*My vote: **LaTeX only in this lane.*** Add Haskell when there is use +evidence, which is a one-line change at that point. OCaml needs +`ocaml-lsp-server` via opam (not packaged for Arch) and is not close. + +**Renumbered from Q#HS1 deliberately.** The merged +`docs/latex-grammar-math-substrate-framing.md` already uses **Q#LX2** +for a different question — its grammar vendoring source (`:83`) — so +revision 1's Q#LX2 collided with a live ID in the same language area. +This document's LaTeX questions are Q#LX1 and the root question in §3; +the language-scope question takes Q#LX4 to avoid a second collision. + +### Q#LX3 — does this touch multi-root LSP affinity? — **RESOLVED, and revision 1 read a stale line** + +Revision 1 called this "the one item that could argue for deferring +Slice 1", on the basis that multi-root affinity was in flight. + +**It merged as PR #161.** `COHERENCE.md:124` lists it among landed +coherence work, and `:867` says "First slice landed (PR #161)". Only +`:1669` still says "first slice in flight" — and that line contradicts +the other two **within the same document**. + +So the deferral argument dissolves: a LaTeX entry keyed like the +existing servers rides the convention that already landed. **The +`COHERENCE.md:1669` inconsistency is real and should be fixed**, but by +whoever next touches §20 — not smuggled into this lane. + +## 5. Coherence impact (§20) + +- **Journey steps touched: none.** This adds a row to an existing + registry; no new surface, keybinding, or panel. +- **Interaction islands: none added.** +- **Config registry adoption: yes, and only that.** One entry in the + existing `pmacs.lsp.config` table, overridable from `init.lua` by the + same mechanism as the fourteen already there. +- **Background-work attribution (§9): unchanged, and NOT improved.** + texlab spawns under the existing LSP supervision path with no new + lifecycle — but it is another process that appears in `*lsp*` and + whose requests appear in `*workers*` with nothing joining them. The + worker-identity lane owns that; this lane neither helps nor worsens + it. +- **§20 classification: WIRING, not model.** It surfaces machinery that + already exists rather than adding a runtime entity — and §2 shows it + is *more* purely wiring than revision 1 thought. + +## 6. Verification + +- **A `.tex` buffer attaches texlab**, witnessed end to end rather than + by asserting the config table's contents. +- **Detection is unchanged**: `.tex`/`.latex`/`.sty`/`.cls` still + resolve to `latex` via the grammar path (§2), asserted so that a + later "helpful" filetype-map addition cannot be mistaken for the + thing that made it work. +- **The root resolver returns the marker directory**, witnessed on a + fixture with a `latexmkrc` above a `chapters/` subdirectory — the + thesis shape, which is the case a file-directory root gets wrong. +- **It falls back to the file's own directory** with no marker present. +- **`.git` does NOT become the root** (§3) — a fixture with a + repository above a document directory, asserting the document + directory wins. This is the case where copying the other entries' + instinct is wrong, so it is pinned. +- **A missing `texlab` surfaces guidance**, through the existing + spawn-failure path (#204) — asserted, not assumed, since that path is + what makes the failure honest. +- **Fixtures bound project detection** with + `pmacs.project.set_search_boundary`. R8 was a fixture letting + detection escape into the developer's environment; a LaTeX root + fixture is exactly that hazard's shape. + +**What this will NOT prove:** that texlab resolves multi-file `\input` +graphs correctly (that is texlab's job, not pmacs's), or that Haskell +and OCaml work (Q#LX4). + +## 7. Not in scope + +New tree-sitter grammars — Haskell and OCaml would have LSP without +highlighting, a real asymmetry that must be stated in the PR rather +than discovered by a user. Any change to Lean, which needs none. +Math/typesetting work (`#172` owns it). Any change to the LSP +spawn-failure surface (#204). Scanning for `\documentclass` to find a +root document (§3). Fixing `COHERENCE.md:1669`'s stale multi-root line +(Q#LX3) — real, but another lane's edit. Haskell and OCaml entries +(Q#LX4). From 75f5f97582850e952ec670be33208a5d726b1eee Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:28:52 +0200 Subject: [PATCH 16/66] docs: authorize LSP LaTeX implementation, precisely Recorded as "implementation authorized" rather than "approved", because that is what happened: the user authorized dispatch after a summary of revision 2 four corrections, not after returning findings on the document the way they did for the other four lanes. The distinction matters for one reason. Section 3 .texlabroot caveat is unverified and blocking --- texlab 5.25.1 is installed and its version and CLI were checked, but the CLI exposes only run and inverse-search, so its LSP-level behaviour was never established. A framing marked plainly "approved" invites a reader to treat that caveat as settled prose. It is step zero of the work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 8 ++++++-- docs/lsp-language-coverage-framing.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 62f6afb..7be4ce1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,8 +275,12 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout lsp-latex-coverage`. -- **Framing `docs/lsp-language-coverage-framing.md`, revision 2**, in - review. **Revision 1 was UNTRACKED on `main` in one checkout** and +- **Framing `docs/lsp-language-coverage-framing.md`, revision 2 — + IMPLEMENTATION AUTHORIZED 2026-08-09**, after a summary of its four + corrections rather than a findings round on the document itself. + Recorded that way deliberately: **the §3 `.texlabroot` verification + caveat is still live and binding**, and is step zero of the work, not + a footnote it can be read past. **Revision 1 was UNTRACKED on `main` in one checkout** and therefore did not travel; committing it here is the fix. - **Scope: one `pmacs.lsp.config.latex` entry plus its root resolver.** `texlab` 5.25.1 is installed and unused; a `.tex` buffer highlights diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md index 67bf343..e7e1f5c 100644 --- a/docs/lsp-language-coverage-framing.md +++ b/docs/lsp-language-coverage-framing.md @@ -1,7 +1,11 @@ # LSP language coverage: LaTeX (and the Haskell/OCaml question) -**Status: framing pass, revision 2. Pre-implementation. Awaiting -approval.** +**Status: revision 2, IMPLEMENTATION AUTHORIZED 2026-08-09.** + +*Recorded precisely: the user authorized dispatch after a summary of +revision 2's four corrections, rather than returning findings on the +document as they did for the other lanes. The §3 verification caveat is +therefore still live and binding — it is step zero, not a footnote.* **Revision 2 corrects three facts revision 1 got wrong or stale, and answers the question revision 1 named as most likely to make the entry From e4c6421612778323e9d5a14324932347e80505f2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:00:29 +0200 Subject: [PATCH 17/66] feat(lsp): LaTeX via texlab, with a document-root resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framing: `docs/lsp-language-coverage-framing.md` (revision 2, §3 and §6). One `pmacs.lsp.config.latex` entry — command `texlab`, args none, a function-valued `root`, and deliberately nothing else. STEP ZERO: the §3 caveat, discharged by observation. Revision 2 marked marker 1 (`.texlabroot`) UNVERIFIED and blocking: only texlab's version and CLI had been checked, and the CLI exposes just `run` / `inverse-search`. Driving a minimal LSP client against `texlab run` by hand, plus reading texlab's own source at the exact installed tag, settles it — and corrects the premise underneath it. `.texlabroot` IS a real marker. `crates/distro/src/language.rs` at v5.25.1 maps `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` -> Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc, and `ProjectRoot::walk_and_find` (`crates/base-db/src/deps/root.rs`) walks ancestors testing all three, innermost wins. So the shipped marker set is texlab's own rather than a plausible-looking guess, and marker 1 stays. But texlab CANNOT apply that walk to rescue a root pmacs gets wrong. Each arm of `walk_and_find` searches `workspace.iter()` — documents already loaded — and the workspace is built from the folders the CLIENT supplies. Live sessions confirm it: with `rootUri` at a `chapters/` subdirectory, no marker above it (`.texlabroot` included) widened texlab's view, and its dependency graph never reached the parent document; with `rootUri` at the marker directory the parent resolved, marker present or not. texlab honours the root it is handed and never corrects a too-narrow one. That inverts the significance of the resolver rather than weakening it: whatever `config.latex.root` returns *is* the project scope. It is the whole value of the lane, not a nicety. Also observed, because the entry depends on it: bare `texlab` serves LSP over stdio — `initialize` returns `TexLab 5.25.1` with no subcommand — so `args = {}` is right and `run` is not needed. WHY `.git` IS EXCLUDED, AND WHY THAT IS NOT AN OMISSION. texlab wants the document root; a thesis inside a monorepo must not get the monorepo. The subtlety is that leaving `.git` out of the marker list does not achieve this on its own. `project_root_for` falls through to `pmacs.project.detect` when a resolver returns nil, and that walk does include `.git` — so a resolver that declined on a markerless file would hand texlab the repository root by the back door. The resolver therefore never declines for a file that has a directory: no marker means the file's own directory, which is also framing marker 4. The acceptance pins this end to end through attach, not just on the resolver's return, and asserts in the same fixture that the shared detector really would have answered the repository root. NO FILETYPE MAPPINGS, per revision 2 §2 — verified, not inherited. `src/syntax.rs` already declares `name: "latex"` with `extensions: ["tex", "latex", "sty", "cls"]`, and grammar-extension detection sits ahead of the LSP filetype map in `detect_buffer_language` (`syntax.lua`). A `.tex` buffer already resolves to `latex`. The suite asserts both halves — the extensions resolve, and `pmacs.lsp.filetypes` is empty for them — so a later "helpful" addition cannot be mistaken for the thing that made attach work. Q#LX1: no `settings`, no `init_options`. Build-on-save and forward-search are both opinionated and forward-search needs a configured viewer. Fixtures bound detection with `pmacs.project.set_search_boundary` and assert the boundary took — R8's hazard is exactly this fixture's shape, and one test pins the walk stopping at the boundary directly. Attach fixtures use `pmacs_fake_lsp`, and the missing-server fixture an asserted-absent path: texlab is installed on this machine, so relying on either its presence or its absence would behave differently here and in CI. Verification: fourteen tests, one per §6 bullet plus the boundary and decline cases. Seven mutations each fail the suite — resolver declining on no marker (6 tests), no marker walk (4), a redundant `filetypes.tex` (1), boundary ignored (1), `io.open` truthiness so a directory counts as a marker (1), marker set narrowed (4), command renamed with opinionated settings added (1). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/lsp.lua | 143 ++++++++ tests/lsp_latex_acceptance.rs | 610 ++++++++++++++++++++++++++++++++++ 2 files changed, 753 insertions(+) create mode 100644 tests/lsp_latex_acceptance.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eb4bb25..e0a7e81 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -260,6 +260,149 @@ pmacs.lsp.config.yaml = pmacs.lsp.config.yaml or { }, } +-- LaTeX via `texlab`. Framing: +-- `docs/lsp-language-coverage-framing.md` §3 (the root) and Q#LX1 (no +-- settings). +-- +-- **No `pmacs.lsp.filetypes` entries ship for `.tex`/`.latex`/`.sty`/ +-- `.cls`, deliberately.** The bundled grammar already declares exactly +-- those extensions (`src/syntax.rs`, `name: "latex"`), and grammar +-- extension detection sits AHEAD of this map in +-- `detect_buffer_language` (`syntax.lua`) — modeline → grammar +-- extension → LSP filetype map → filename → shebang. So a `.tex` buffer +-- already resolves to `latex` and a map entry would be dead weight that +-- a later reader could mistake for the thing that made attach work. +-- +-- **No `settings` / `init_options` (Q#LX1).** texlab pulls its config +-- through `workspace/configuration` under a `texlab` section, which +-- pmacs answers; an absent section takes texlab's defaults. The two +-- candidates — build-on-save and forward-search — are both opinionated, +-- and forward-search additionally needs a configured viewer, so any +-- default would be wrong for most machines. Users override through the +-- same `init.lua` seam as every other entry here. +-- +-- Q#LX2 — the root, and why it cannot be `pmacs.project.detect`. +-- +-- **`.git` is deliberately NOT a marker.** texlab wants the *document* +-- root, not the repository root: a thesis inside a monorepo would +-- otherwise hand texlab the monorepo. This is the one entry where +-- copying the other fourteen's instinct is actively wrong — which is +-- also why this resolver must never return nil for a markerless file. +-- `project_root_for` falls through to `pmacs.project.detect` on a nil, +-- and that walk *does* include `.git`; returning the file's own +-- directory is what keeps the repository root out. +-- +-- The marker set is texlab's own, established by observation against +-- texlab 5.25.1 rather than assumed — `crates/distro/src/language.rs` +-- at that tag maps `.texlabroot`/`texlabroot` → `Root`, +-- `Tectonic.toml` → `Tectonic`, `.latexmkrc`/`latexmkrc` → `Latexmkrc`, +-- and `ProjectRoot::walk_and_find` (`crates/base-db/src/deps/root.rs`) +-- tests all three per ancestor directory, innermost wins. Matching that +-- set means pmacs hands texlab the directory texlab would itself pick. +-- +-- **texlab cannot pick it alone, which is what makes this resolver +-- load-bearing.** `walk_and_find` only sees markers belonging to +-- documents already in the workspace, and the workspace is built from +-- the folders the CLIENT supplies. Live LSP sessions confirmed it: with +-- `rootUri` at a `chapters/` subdirectory, no marker above it — +-- `.texlabroot` included — widened texlab's view, and its dependency +-- graph never reached the parent document; with `rootUri` at the marker +-- directory the parent resolved, marker or not. texlab honours the root +-- it is given and never corrects a too-narrow one, so whatever this +-- function returns *is* the project scope. +-- +-- Intra-directory precedence is unobservable here on purpose: the walk +-- returns a DIRECTORY, so two markers side by side yield the same +-- answer in either order. Only the innermost-ancestor rule matters. +-- +-- Scanning for `\documentclass` — the semantically correct notion of a +-- root document — is deliberately not done: it is a directory scan per +-- resolve with its own caching and invalidation questions. If the +-- marker walk proves insufficient in use, that is the next increment, +-- with evidence. +local LATEX_ROOT_MARKERS = { + ".texlabroot", "texlabroot", + "Tectonic.toml", + ".latexmkrc", "latexmkrc", +} + +-- Synchronous existence test. `pmacs.fs.stat` is unusable here: it +-- returns an awaitable handle, and this runs inside `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine to await on. `io.open` is the only synchronous check, and +-- it is wrong in both directions on its own — it SUCCEEDS on a +-- directory, and requiring a non-nil read would reject an empty +-- `.texlabroot`, which is the normal way that marker is written. The +-- discriminator is `read`'s second return, exactly as `lean.lua` +-- establishes it: content -> no error; empty file -> nil, no error; +-- directory -> nil, "Is a directory"; missing -> `io.open` nil. +local function latex_marker_in(dir) + for _, name in ipairs(LATEX_ROOT_MARKERS) do + local f = io.open(dir .. "/" .. name, "r") + if f then + local _, err = f:read(1) + f:close() + if err == nil then return true end + end + end + return false +end + +local function latex_parent_of(dir) + local up = dir:match("^(.*)/[^/]+$") + if up == nil or up == dir or up == "" then return nil end + return up +end + +-- The walk stops at `pmacs.project.search_boundary()`. Not politeness: +-- `detect_project_within` (`src/project.rs`) exists so a stray marker +-- above a temp fixture cannot leak into detection, and a Lua walk that +-- ignored the boundary would break that contract — and make this +-- resolver's own acceptance fixtures non-hermetic against any +-- `latexmkrc` sitting above the test's tempdir (R8's shape exactly). +local function latex_within_boundary(dir, boundary) + if not boundary then return true end + return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" +end + +-- Returns the INNERMOST ancestor holding a texlab root marker, or the +-- file's own directory when there is none. +-- +-- **The result is canonical, and must be.** A configured root reaches +-- `file_uri_for` verbatim and that URI is the server-affinity key +-- (#161); one document tree opened through a symlink and through its +-- real path would otherwise spawn two texlab processes. Canonicalizing +-- once up front suffices — every ancestor of a canonical path is itself +-- canonical, because the walk only strips trailing components. +-- +-- Declines (nil) only when there is no directory to vouch for: a +-- pathless buffer, or a canonicalize failure on a deleted file or +-- broken symlink. +local function latex_root_for(path) + if type(path) ~= "string" then return nil end + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + dir = pmacs.fs.canonicalize(dir) + if not dir then return nil end + local boundary + local ok, b = pcall(pmacs.project.search_boundary) + if ok then boundary = b end + -- The boundary is canonicalized at set time (`set_search_boundary`), + -- so comparing it against a canonical `dir` is apples to apples. + local cur = dir + while cur and latex_within_boundary(cur, boundary) do + if latex_marker_in(cur) then return cur end + cur = latex_parent_of(cur) + end + return dir +end + +pmacs.lsp.config.latex = pmacs.lsp.config.latex or { + command = "texlab", + args = {}, + root = latex_root_for, +} + -- LSP-side extension → language map, deliberately independent of the -- tree-sitter detection in `pmacs.parse`. Consulted only when -- `pmacs.parse.language_for_path` finds nothing (an extension with a diff --git a/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs new file mode 100644 index 0000000..e62a03e --- /dev/null +++ b/tests/lsp_latex_acceptance.rs @@ -0,0 +1,610 @@ +// tests/lsp_latex_acceptance.rs --- LSP language coverage: LaTeX. + +//! `docs/lsp-language-coverage-framing.md` §6, one test per bullet. +//! +//! The lane ships exactly one thing: `pmacs.lsp.config.latex`, command +//! `texlab`, with a function-valued `root` that walks up for texlab's +//! own project markers and stops at the document directory. Two pins +//! are load-bearing and the rest guard the boundary around them: +//! +//! * the resolver returns the MARKER directory for a thesis whose +//! chapters live in a subdirectory — the case a file-directory root +//! gets wrong; and +//! * `.git` NEVER becomes the root. This is the one entry where +//! copying the other fourteen's instinct is actively wrong, and the +//! failure mode is subtle: the resolver does not exclude `.git` by +//! omitting it from its marker list, it excludes it by never +//! declining, because `project_root_for` falls through to +//! `pmacs.project.detect` on a nil and *that* walk includes `.git`. +//! So the pin is end to end through attach, not just on the +//! resolver's return. +//! +//! **Every fixture calls `pmacs.project.set_search_boundary` at its own +//! tempdir root.** R8 was a fixture letting detection escape into the +//! developer's environment, and a LaTeX root fixture is precisely that +//! hazard's shape: a stray `latexmkrc` or `.git` anywhere above the +//! temp directory would otherwise turn the markerless cases into marked +//! ones, and the assertions would still pass while testing nothing. +//! +//! **Attach fixtures point the command at `pmacs_fake_lsp`, and the +//! missing-server fixture at a path asserted not to exist.** The shipped +//! default is `texlab`, which is genuinely installed on the development +//! machine — a suite that relied on either its presence or its absence +//! would behave differently here and in CI. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +/// A fresh editor with the SHIPPED configs intact — this suite is about +/// the shipped `latex` entry, so it cannot clear the table the way the +/// multi-root suite does. +fn editor() -> EditorState { + EditorState::new_with_roots(&crate::iso::roots()) +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua`. Reimplemented +/// rather than imported so the test states the expected encoding +/// independently of the code under test. +fn file_uri(path: &Path) -> String { + let mut out = String::from("file://"); + for ch in path.display().to_string().chars() { + match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch), + _ => { + use std::fmt::Write as _; + let mut buf = [0u8; 4]; + for byte in ch.encode_utf8(&mut buf).as_bytes() { + let _ = write!(out, "%{byte:02X}"); + } + } + } + } + out +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + /// Canonicalized, because the resolver canonicalizes before walking + /// (`/var` is a symlink to `/private/var` on macOS) and the expected + /// roots below have to compare equal to what it returns. + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + Self { _dir: dir, root } + } + + fn write(&self, rel: &str, contents: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + path + } + + fn mkdir(&self, rel: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + fn bind(&self, state: &EditorState) { + exec( + state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&self.root) + ), + ); + // The boundary is the whole hermeticity story for this suite, so + // assert it took rather than trusting the call. + let seen: String = eval(state, "return pmacs.project.search_boundary() or \"\""); + assert_eq!( + seen, + self.root.display().to_string(), + "fixture precondition: the search boundary must be this tempdir" + ); + } +} + +/// Call the SHIPPED resolver directly. +fn resolve_root(state: &EditorState, file: &Path) -> Option { + let got: Option = eval( + state, + &format!("return pmacs.lsp.config.latex.root(\"{}\")", lua_str(file)), + ); + got +} + +/// Repoint only the command, preserving the shipped `root` resolver — +/// which is the thing under test. +fn point_command_at(state: &EditorState, command: &str) { + exec( + state, + &format!("pmacs.lsp.config.latex.command = {command:?}"), + ); +} + +fn open(state: &EditorState, path: &Path) { + exec( + state, + &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), + ); +} + +fn settle(state: &mut EditorState) { + for _ in 0..8 { + state.tick_processes(); + state.tick_lsp(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// One `language_id|root_uri|cwd|state` row per live server. +fn rows(state: &EditorState) -> Vec { + let joined: String = eval( + state, + r#" + local out = {} + for _, s in ipairs(pmacs.lsp.list()) do + out[#out + 1] = table.concat({ + s.language_id or "", + s.root_uri or "", + s.cwd or "", + (s.state and s.state.kind) or "", + }, "|") + end + table.sort(out) + return table.concat(out, "\n") + "#, + ); + if joined.is_empty() { + Vec::new() + } else { + joined.lines().map(str::to_owned).collect() + } +} + +fn status(state: &EditorState) -> String { + state.core.borrow().status.clone() +} + +const DOC: &str = "\\documentclass{article}\n\\begin{document}\nhi\n\\end{document}\n"; + +// --------------------------------------------------------------------------- +// §6 — the shipped entry. Command `texlab`, and NOTHING opinionated +// (Q#LX1: no `settings`, no `init_options`). +// --------------------------------------------------------------------------- + +#[test] +fn latex_entry_ships_texlab_with_a_resolver_and_no_opinionated_config() { + let state = editor(); + let command: String = eval(&state, "return pmacs.lsp.config.latex.command"); + assert_eq!( + command, "texlab", + "the shipped LaTeX server is texlab, invoked bare — the binary \ + serves LSP over stdio with no subcommand" + ); + + // Q#LX1. Build-on-save and forward-search are both opinionated and + // forward-search needs a configured viewer, so an empty section + // takes texlab's defaults through the `workspace/configuration` + // answer pmacs already gives. + let has_settings: bool = eval(&state, "return pmacs.lsp.config.latex.settings ~= nil"); + assert!(!has_settings, "Q#LX1: no `settings` may ship"); + let has_init: bool = eval(&state, "return pmacs.lsp.config.latex.init_options ~= nil"); + assert!(!has_init, "Q#LX1: no `init_options` may ship"); + + let root_kind: String = eval(&state, "return type(pmacs.lsp.config.latex.root)"); + assert_eq!( + root_kind, "function", + "the root must be a resolver — the shared marker walk cannot \ + express a LaTeX root, because it would include .git" + ); +} + +// --------------------------------------------------------------------------- +// §6 — detection is unchanged: `.tex`/`.latex`/`.sty`/`.cls` resolve to +// `latex` through the GRAMMAR path, ahead of the LSP filetype map. +// +// Pinned so that a later "helpful" filetype-map addition cannot be +// mistaken for the thing that made attach work. Revision 2 of the +// framing exists because revision 1 proposed exactly that addition. +// --------------------------------------------------------------------------- + +#[test] +fn latex_extensions_resolve_through_the_grammar_not_the_lsp_filetype_map() { + let state = editor(); + for ext in ["tex", "latex", "sty", "cls"] { + let language: Option = eval( + &state, + &format!("return pmacs.parse.language_for_path(\"/tmp/doc.{ext}\")"), + ); + assert_eq!( + language.as_deref(), + Some("latex"), + ".{ext} must resolve to `latex` via the bundled grammar" + ); + + // And the map is empty for it, so the assertion above cannot be + // being satisfied by a filetype entry. + let mapped: Option = + eval(&state, &format!("return pmacs.lsp.filetypes[\"{ext}\"]")); + assert_eq!( + mapped, None, + "no `pmacs.lsp.filetypes.{ext}` ships: the grammar already \ + carries the extension and sits ahead of this map in \ + detect_buffer_language" + ); + } +} + +// --------------------------------------------------------------------------- +// §6 — LOAD-BEARING: the resolver returns the MARKER directory, on the +// thesis shape (marker above a `chapters/` subdirectory). This is +// exactly the case a file-directory root gets wrong. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_is_the_marker_directory_for_a_thesis_with_chapters() { + // texlab's own marker set, from `crates/distro/src/language.rs` at + // v5.25.1: `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` -> + // Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc. + for marker in [ + ".texlabroot", + "texlabroot", + "Tectonic.toml", + ".latexmkrc", + "latexmkrc", + ] { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + // Empty, because `.texlabroot` is normally written empty and + // existence — not content — is the marker semantics. + fx.write(&format!("thesis/{marker}"), ""); + fx.write("thesis/thesis.tex", DOC); + let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &chapter).as_deref(), + Some(fx.dir("thesis").display().to_string().as_str()), + "{marker}: the root must be the marker directory, not the \ + chapter's own directory" + ); + } +} + +#[test] +fn latex_root_takes_the_innermost_marker_when_markers_nest() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + fx.write("outer/latexmkrc", ""); + fx.write("outer/inner/Tectonic.toml", ""); + let doc = fx.write("outer/inner/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("outer/inner").display().to_string().as_str()), + "innermost ancestor wins, matching texlab's own \ + ProjectRoot::walk_and_find" + ); +} + +#[test] +fn latex_root_ignores_a_marker_that_is_a_directory() { + // `io.open` succeeds on a directory, so a bare truthiness test would + // accept `latexmkrc/` as a marker. The read-error discriminator is + // what rejects it; without this pin that subtlety is unguarded. + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + fx.mkdir("proj/latexmkrc"); + let doc = fx.write("proj/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("proj/chapters").display().to_string().as_str()), + "a DIRECTORY named latexmkrc is not a marker" + ); +} + +// --------------------------------------------------------------------------- +// §6 — it falls back to the file's own directory with no marker present. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_falls_back_to_the_files_own_directory() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + let doc = fx.write("loose/note.tex", DOC); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("loose").display().to_string().as_str()), + "a markerless document roots at its own directory" + ); +} + +// --------------------------------------------------------------------------- +// §6 — LOAD-BEARING: `.git` does NOT become the root. +// +// Both halves matter. The resolver must not return the repository root, +// AND it must not DECLINE — a nil falls through to +// `pmacs.project.detect`, whose marker walk does include `.git`, so a +// declining resolver would hand texlab the monorepo by the back door. +// The second assertion is therefore end to end through attach. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_is_never_a_git_repository_root() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + // A repository ABOVE a document directory — the thesis-inside-a- + // monorepo shape. + fx.mkdir("repo/.git"); + fx.write("repo/README.md", "monorepo\n"); + let doc = fx.write("repo/paper/paper.tex", DOC); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("repo/paper").display().to_string().as_str()), + "the document directory wins: texlab wants the DOCUMENT root, \ + and a thesis in a monorepo must not get the monorepo" + ); + + // The same fixture proves `pmacs.project.detect` really would have + // answered the repository root, so the assertion above is not + // vacuous. + let detected: Option = eval( + &state, + &format!( + "local ok, d = pcall(pmacs.project.detect, \"{}\")\n\ + if ok and d then return d.root end\n\ + return nil", + lua_str(&doc) + ), + ); + assert_eq!( + detected.as_deref(), + Some(fx.dir("repo").display().to_string().as_str()), + "fixture precondition: the shared detector DOES answer the \ + repository root here — that is what the resolver must avoid" + ); +} + +#[test] +fn a_tex_buffer_in_a_git_repo_attaches_at_the_document_directory() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.mkdir("repo/.git"); + let doc = fx.write("repo/paper/paper.tex", DOC); + open(&state, &doc); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "one latex server: {rows:?}"); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[0], "latex"); + assert_eq!( + fields[1], + file_uri(&fx.dir("repo/paper")), + "root_uri must be the document directory, NOT the repository root" + ); + assert_eq!( + fields[2], + fx.dir("repo/paper").display().to_string(), + "cwd must be the document directory" + ); +} + +// --------------------------------------------------------------------------- +// §6 — a `.tex` buffer attaches the LaTeX server, witnessed end to end +// rather than by asserting the config table's contents. +// --------------------------------------------------------------------------- + +#[test] +fn a_tex_buffer_attaches_the_latex_server_at_the_marker_root() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + open(&state, &chapter); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "expected one latex server: {rows:?}"); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!( + fields[0], "latex", + "the buffer must resolve to language `latex` and attach" + ); + assert_eq!( + fields[1], + file_uri(&fx.dir("thesis")), + "the attached server's root is the marker directory" + ); +} + +#[test] +fn two_chapters_of_one_thesis_share_a_single_server() { + // The marker walk's whole point: without it each chapter directory + // would be its own root and texlab would serve isolated files. + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "both chapters share the thesis root, so one server: {rows:?}" + ); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("thesis")) + ); +} + +#[test] +fn two_markerless_documents_in_different_directories_do_not_share_a_server() { + // The complement of the pin above: the fallback is the file's own + // directory, so unrelated loose documents keep separate scopes + // rather than collapsing into one rootless server. + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + let one = fx.write("a/one.tex", DOC); + let two = fx.write("b/two.tex", DOC); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 2, "one server per document directory: {rows:?}"); + let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect(); + assert!( + roots.contains(&file_uri(&fx.dir("a")).as_str()), + "{roots:?}" + ); + assert!( + roots.contains(&file_uri(&fx.dir("b")).as_str()), + "{roots:?}" + ); +} + +// --------------------------------------------------------------------------- +// §6 — a missing `texlab` surfaces guidance through the existing +// spawn-failure path (#204). Asserted, not assumed. +// --------------------------------------------------------------------------- + +#[test] +fn a_missing_texlab_surfaces_installation_guidance() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + // A path that cannot exist, asserted — texlab IS installed on the + // development machine, so relying on its absence would make this + // vacuous here and meaningful only in CI. + let absent = fx.dir("no-such-bin/texlab"); + assert!( + !absent.exists(), + "fixture precondition: {} must not exist", + absent.display() + ); + point_command_at(&state, &absent.display().to_string()); + let doc = fx.write("paper/paper.tex", DOC); + open(&state, &doc); + settle(&mut state); + + assert!(rows(&state).is_empty(), "nothing may have started"); + let status = status(&state); + assert!( + status.contains("did not start") && status.contains("latex"), + "the spawn-failure path must name the language: {status:?}" + ); + assert!( + status.contains("pmacs.lsp.config.latex.command"), + "the guidance must name the override seam: {status:?}" + ); + + // And the failure is recorded, not just flashed. + let recorded: bool = eval( + &state, + "for _, f in ipairs(pmacs.lsp.spawn_failures()) do\n\ + if f.language == \"latex\" then return true end\n\ + end\n\ + return false", + ); + assert!(recorded, "M-x lsp.status must carry the latex failure"); +} + +// --------------------------------------------------------------------------- +// The resolver declines only when there is no directory to vouch for. +// A decline is the one path that reaches `pmacs.project.detect`, so its +// preconditions are worth pinning. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_declines_for_a_non_string_or_pathless_argument() { + let state = editor(); + let nil_arg: Option = eval(&state, "return pmacs.lsp.config.latex.root(nil)"); + assert_eq!(nil_arg, None, "a pathless buffer declines"); + let bare: Option = eval( + &state, + "return pmacs.lsp.config.latex.root(\"noslash.tex\")", + ); + assert_eq!(bare, None, "a name with no directory component declines"); +} + +#[test] +fn latex_root_walk_stops_at_the_search_boundary() { + // R8's shape, pinned directly: a marker ABOVE the boundary must be + // invisible, or every markerless assertion in this file is hostage + // to the developer's filesystem. + let fx = Fixture::new(); + let state = editor(); + // Marker at the tempdir root, boundary set BELOW it. + fx.write("latexmkrc", ""); + let inner = fx.mkdir("inner"); + fx.write("inner/chapters/one.tex", "\\section{One}\n"); + exec( + &state, + &format!("pmacs.project.set_search_boundary(\"{}\")", lua_str(&inner)), + ); + + let doc = fx.dir("inner/chapters/one.tex"); + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("inner/chapters").display().to_string().as_str()), + "the walk must not climb past the search boundary to reach the \ + marker above it" + ); +} + +#[path = "common/iso.rs"] +mod iso; From 22d72fa7f703f6fcd2453e1cef8deee63a276d02 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:02:02 +0200 Subject: [PATCH 18/66] docs: record the LaTeX lane as implemented, step zero discharged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-local edit to the lane's own block in `docs/active-work.md`. The heading moves from "BRANCHED, framing in review" to "IMPLEMENTED, gates green, no PR yet", and the §3 `.texlabroot` caveat is written up as discharged rather than merely resolved: what texlab actually does, how it was observed, and the one place the framing now reads stale. The substantive finding recorded here is not "the marker works". It is that texlab's own root walk only sees markers belonging to documents already in its workspace, and the workspace comes from the folders the client supplies — so texlab honours the root pmacs hands it and never corrects a too-narrow one. That makes `config.latex.root` the project scope rather than a hint, which is worth carrying forward whether or not anyone rereads the framing. Also recorded: the shared-`CARGO_TARGET_DIR` trap, because a bare `cargo test` in this worktree fails with compile errors from a sibling lane's code and reads exactly like a broken branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 89 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7be4ce1..d279c33 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## LSP LaTeX coverage — BRANCHED, framing in review +## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -278,9 +278,10 @@ authoritative tip** — the ref, not a SHA. Recover with - **Framing `docs/lsp-language-coverage-framing.md`, revision 2 — IMPLEMENTATION AUTHORIZED 2026-08-09**, after a summary of its four corrections rather than a findings round on the document itself. - Recorded that way deliberately: **the §3 `.texlabroot` verification - caveat is still live and binding**, and is step zero of the work, not - a footnote it can be read past. **Revision 1 was UNTRACKED on `main` in one checkout** and + Recorded that way deliberately: the §3 `.texlabroot` verification + caveat was live and binding, and was step zero of the work rather + than a footnote it could be read past. **It is now discharged — see + below.** **Revision 1 was UNTRACKED on `main` in one checkout** and therefore did not travel; committing it here is the fix. - **Scope: one `pmacs.lsp.config.latex` entry plus its root resolver.** `texlab` 5.25.1 is installed and unused; a `.tex` buffer highlights @@ -300,19 +301,73 @@ authoritative tip** — the ref, not a SHA. Recover with read `COHERENCE.md:1669` ("first slice in flight") when `:124` and `:867` both record multi-root affinity as **merged (#161)**; that line contradicts the same document twice and wants a separate fix. -- **Q#LX2 (the LaTeX root) is answered, with a caveat that must be - discharged first.** An upward marker walk — `.texlabroot`, - `latexmkrc`/`.latexmkrc`, `Tectonic.toml`, then the file's own - directory — through `config.latex.root`, which already accepts a - resolver function (`lsp.lua:543`). **`.git` is deliberately excluded**: - a repo root is the wrong answer for LaTeX, and it is the one place - copying the other fourteen entries' instinct is actively wrong. - **Whether texlab honours `.texlabroot` is UNVERIFIED** — only its - version and CLI were checked, and the CLI exposes no such surface. - Confirm against a live session before implementing; if it is not a - real marker, the walk starts at `latexmkrc`. -- **Gates:** `scripts/gate --acceptance `. No - `--protocol` — a config entry, no wire. +- **Q#LX2 (the LaTeX root) is answered.** An upward marker walk through + `config.latex.root`, which already accepts a resolver function + (`lsp.lua:543`), falling back to the file's own directory. + **`.git` is deliberately excluded**: a repo root is the wrong answer + for LaTeX, and it is the one place copying the other fourteen + entries' instinct is actively wrong. +- **STEP ZERO IS DISCHARGED — §3's `.texlabroot` caveat, by + observation.** Marker 1 **ships**, and the framing's premise for it + was corrected in the process. + - **`.texlabroot` is a real texlab marker.** texlab v5.25.1's + `crates/distro/src/language.rs` maps `.texlabroot`/`texlabroot` → + Root, `Tectonic.toml` → Tectonic, `.latexmkrc`/`latexmkrc` → + Latexmkrc; `ProjectRoot::walk_and_find` + (`crates/base-db/src/deps/root.rs`) walks ancestors testing all + three, innermost wins. The shipped marker set is **texlab's own**, + including the bare `texlabroot`/`latexmkrc` spellings the framing + did not list. + - **But texlab cannot apply that walk to fix a root pmacs gets + wrong**, which is the correction that matters. Each arm searches + `workspace.iter()` — documents ALREADY LOADED — and the workspace + comes from the folders the CLIENT supplies. Hand-driven LSP + sessions confirmed it: with `rootUri` at a `chapters/` + subdirectory, no marker above it (`.texlabroot` included) widened + texlab's view and its dependency graph never reached the parent + document; with `rootUri` at the marker directory the parent + resolved, marker present or not. **texlab honours the root it is + handed and never corrects a too-narrow one**, so what + `config.latex.root` returns *is* the project scope. That makes the + resolver the whole value of the lane rather than a nicety. + - **`args = {}` is also observed**, not assumed: bare `texlab` + answers `initialize` with `TexLab 5.25.1` over stdio, so the `run` + subcommand is not needed. + - **§3 therefore reads slightly stale** — it frames marker 1 as + conditional on texlab honouring the file, when the operative fact + is that texlab honours the *client-supplied root*. Worth a revision + 3 by whoever next touches the document; not smuggled into this + lane's commit. +- **`.git` exclusion needed more than omitting it from the list.** + `project_root_for` falls through to `pmacs.project.detect` when a + resolver returns nil, and **that** walk includes `.git` — so a + resolver declining on a markerless file would hand texlab the + repository root by the back door. The resolver therefore never + declines for a file that has a directory. Pinned end to end through + attach, with the same fixture asserting the shared detector really + would have answered the repo root. +- **Commit `a9ef37f`** — `builtin/runtime/lsp.lua` plus + `tests/lsp_latex_acceptance.rs` (14 tests, one per §6 bullet plus the + boundary and decline cases). No `settings`/`init_options` (Q#LX1); no + filetype mappings (§2, asserted both ways). +- **Gates: ALL GREEN** via + `./scripts/gate --acceptance lsp_latex_acceptance` — fmt, clippy, + lib, lib-crdt, the new suite, m4, gpu, the workspace sweep (115 + suites, zero failures), diff-check. No `--protocol` — a config entry, + no wire. +- **Seven mutations each fail the suite**: resolver declining on no + marker (6 tests), no marker walk (4), a redundant `filetypes.tex` + (1), boundary ignored (1), `io.open` truthiness so a directory counts + as a marker (1), marker set narrowed (4), command renamed with + opinionated settings added (1). +- **Trap for the next agent in this worktree:** this machine exports a + shared `CARGO_TARGET_DIR`, so a bare `cargo test` compiles against a + sibling worktree's artifacts and fails with errors from code that is + not in this tree. Use `scripts/gate`, or + `CARGO_TARGET_DIR="$(./scripts/gate --print-target-dir)"` for ad-hoc + runs. `scripts/gate`'s own header documents this; the failure looks + like a broken branch, which is why it is recorded here. +- **No PR opened**, by instruction. ## `scripts/gate --protocol` build step — **MERGED as #229** (`7cf4653`) From 12b7ee58d83a580a588455281c675f05d7789e3d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:04:21 +0200 Subject: [PATCH 19/66] docs: LaTeX framing revision 3 --- step zero corrected the premise The .texlabroot caveat is discharged, and discharging it falsified the reasoning behind it. .texlabroot is real, and texlab marker set is wider than this document listed --- the bare texlabroot and latexmkrc spellings count too, and the walk takes the innermost. The implementation ships texlab own set. But the caveat framed marker 1 as conditional on texlab honouring the FILE, which was the wrong question. Every arm of texlab ancestor walk searches documents already loaded, and that workspace is built from the folders the CLIENT supplies. Observed: with rootUri at chapters/, no ancestor marker widened texlab view and its dependency graph never reached the parent document; with rootUri at the marker directory the parent resolved either way. So texlab honours the root it is handed and never corrects a too-narrow one. config.latex.root IS the project scope, which makes the resolver the whole value of the lane for a multi-file thesis rather than a nicety --- the opposite of how section 2 "Slice 1 is one config entry" reads. Second correction, and this one was a real trap. Revision 2 said .git is deliberately excluded from the walk and stopped there. Omitting it is not sufficient: project_root_for falls through to pmacs.project.detect when a resolver returns nil, and that walk lists .git among its markers at src/project.rs:184. A resolver that politely declined on a markerless file would hand texlab the monorepo by the back door, with the exclusion looking correct at every line you would think to read. The resolver never declines for a file with a directory, and the fixture asserts the shared detector really would have answered the repo root so the pin cannot pass vacuously. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/lsp-language-coverage-framing.md | 53 +++++++++++++++++++++------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index d279c33..3327aad 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout lsp-latex-coverage`. -- **Framing `docs/lsp-language-coverage-framing.md`, revision 2 — +- **Framing `docs/lsp-language-coverage-framing.md`, revision 3 — IMPLEMENTATION AUTHORIZED 2026-08-09**, after a summary of its four corrections rather than a findings round on the document itself. Recorded that way deliberately: the §3 `.texlabroot` verification diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md index e7e1f5c..7a6b55e 100644 --- a/docs/lsp-language-coverage-framing.md +++ b/docs/lsp-language-coverage-framing.md @@ -1,6 +1,6 @@ # LSP language coverage: LaTeX (and the Haskell/OCaml question) -**Status: revision 2, IMPLEMENTATION AUTHORIZED 2026-08-09.** +**Status: revision 3. Implemented at `d79afdc`; step zero discharged by observation, and its result corrected two things this document had wrong.** *Recorded precisely: the user authorized dispatch after a summary of revision 2's four corrections, rather than returning findings on the @@ -119,22 +119,53 @@ inside a monorepo would otherwise get the monorepo. This is the one place where copying the other fourteen entries' instinct would be actively wrong. +**And omitting it is NOT sufficient — revision 2 stopped one step +short.** `project_root_for` falls through to `pmacs.project.detect` +when a resolver returns `nil`, and **that** walk lists `.git` among its +markers (`src/project.rs:184`). So a resolver that politely declined on +a markerless file would hand texlab the monorepo **by the back door**, +with the exclusion looking correct at every line you would think to +read. The resolver therefore **never declines** for a file with a +directory, and the pin is end-to-end through attach — with the same +fixture asserting the shared detector really would have answered the +repository root, so the test cannot pass vacuously. + **Deliberately NOT proposed: scanning for `\documentclass`.** That is the semantically correct notion of a root document, and it is a directory scan on every resolve, with its own caching and invalidation questions. If the marker walk proves insufficient in use, that is the next increment — with evidence. -**VERIFICATION CAVEAT, stated rather than buried.** `texlab 5.25.1` is -installed and its version and CLI surface were checked directly. Its -**LSP-level** behaviour — whether it honours `.texlabroot`, and how it -resolves multi-file projects from a root URI — was **not** verified -here; the CLI exposes only `run` / `inverse-search`, so this needs a -live session. **Marker 1 is provisional and must be confirmed against a -running texlab before implementation**, exactly as the sibling -`gate-protocol-build` lane requires its precondition to be observed -rather than reasoned about. If `.texlabroot` is not a real marker, it -drops and the walk starts at `latexmkrc`. +**CAVEAT DISCHARGED (revision 3), and the premise behind it was wrong +in a way that raises the lane's stakes.** + +Established by driving a hand-written LSP client against `texlab run` +and reading texlab's source at the exact installed tag `v5.25.1`: + +- **`.texlabroot` is real**, and so is a wider marker set than this + document listed. `crates/distro/src/language.rs` maps + `.texlabroot`/**`texlabroot`** → Root, `Tectonic.toml` → Tectonic, + `.latexmkrc`/**`latexmkrc`** → Latexmkrc, and + `ProjectRoot::walk_and_find` walks ancestors testing all three, + **innermost winning**. The implementation ships texlab's own set, + including the bare spellings §3 omitted. +- **But texlab cannot rescue a root we get wrong.** Every arm of that + walk searches `workspace.iter()` — documents *already loaded* — and + the workspace is built from the folders **the client supplies**. + Observed directly: with `rootUri` at `chapters/`, no ancestor marker + (`.texlabroot` included) widened texlab's view, and its dependency + graph never reached the parent document; with `rootUri` at the marker + directory, the parent resolved whether or not a marker was present. + +**So `config.latex.root` IS the project scope.** Revision 2 framed the +resolver as choosing between plausible roots that texlab might refine. +It does not refine. The resolver is the whole value of the lane for a +multi-file thesis, not a nicety — which is the opposite of how §2's +"Slice 1 is one config entry" reads, and worth stating plainly. + +*(Also observed rather than assumed: bare `texlab` answers `initialize` +over stdio, so `args = {}` is correct and the `run` subcommand is +unnecessary.)* ## 4. Open questions From 9a1e11c0c8ac99ef05fadc089b6e83455c201707 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 19:31:13 +0200 Subject: [PATCH 20/66] fix(lsp): a `/` search boundary no longer disables the LaTeX marker walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `latex_within_boundary` answered a question about path COMPONENTS with string arithmetic: dir:sub(1, #boundary + 1) == boundary .. "/" With a `/` boundary the needle is `"//"`, which no canonical path begins with. Every ancestor was therefore judged out of bounds, the marker walk never examined a single directory, and each chapter of a thesis got its own root — two texlab processes for one document tree. The lane's headline behaviour, silently off, with all fourteen shipped tests still green because every one of them clamps the boundary to its own tempdir. Fixed by comparing segments rather than characters, so the root is a boundary with zero segments — containing everything by construction instead of by a special case, and tolerating a trailing separator for free. The same root-is-special trap sat at two other points on the same path and is closed with it: * `latex_parent_of` returned nil for a top-level directory, making `/` the one directory the walk could never examine — the identical bug from the far end. It now yields `/`, matching `walk_for_marker`'s `Path::ancestors` on the Rust side, and still terminates because `/` has no component to strip. * `latex_root_for` sliced `/paper.tex` to an EMPTY directory, which canonicalizes to nothing and made the resolver DECLINE — and a decline is the one path that falls through to `pmacs.project.detect`, whose walk includes `.git`. A document at the filesystem root now roots at `/`. * `latex_marker_in`'s join is guarded for `dir == "/"`, which this change makes reachable for the first time; the naive form produces `//name`, the one spelling POSIX leaves implementation-defined. Two new pins plus a strengthened one, 16 tests: * `two_chapters_share_one_server_under_a_root_search_boundary` — the defect end to end through ATTACH, not on the predicate, because the symptom is two servers rather than a wrong string. Restoring the old comparison fails exactly this test, with the two-server output. * `latex_root_walk_stops_at_the_search_boundary` now asserts BOTH directions. "Stops at the boundary" is also satisfied by a walk that never runs — which is precisely what a `/` boundary produced — so the hermeticity property (R8's shape: a stray `latexmkrc` above the tempdir must stay invisible) is now paired with the walk still climbing to, and examining, the boundary directory itself. * `latex_root_for_a_document_at_the_filesystem_root_is_the_root`. Also corrects `docs/active-work.md`: §3 no longer awaits a revision 3 — `b5eaf27` IS revision 3 — and the lane entry now records that boundary handling has been this resolver's interesting part twice, so a reader weighing whether to trust it knows where to look first. Gates: ALL GREEN via `./scripts/gate --acceptance lsp_latex_acceptance`. `/tmp` on this machine is a near-full tmpfs and three grep tests fail there with `QuotaExceeded`; the green run used `TMPDIR=/home/jeans/build/pmacs-gate-tmp/lsp-latex`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/lsp.lua | 54 +++++++++++++++++++-- docs/active-work.md | 36 ++++++++++++-- tests/lsp_latex_acceptance.rs | 89 +++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index e0a7e81..9ace580 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -337,8 +337,14 @@ local LATEX_ROOT_MARKERS = { -- establishes it: content -> no error; empty file -> nil, no error; -- directory -> nil, "Is a directory"; missing -> `io.open` nil. local function latex_marker_in(dir) + -- Joining, not testing: `/` is the one directory that already ends in + -- a separator, and `dir .. "/" .. name` would give `//name` — the + -- exactly-two-leading-slashes spelling POSIX leaves implementation- + -- defined. `/` became reachable here once the walk stopped treating + -- the filesystem root as off-limits, so the join has to say so. + local base = (dir == "/") and "" or dir for _, name in ipairs(LATEX_ROOT_MARKERS) do - local f = io.open(dir .. "/" .. name, "r") + local f = io.open(base .. "/" .. name, "r") if f then local _, err = f:read(1) f:close() @@ -348,9 +354,18 @@ local function latex_marker_in(dir) return false end +-- `/` is a directory like any other. The pattern below yields the EMPTY +-- string for a top-level directory (`/tmp` -> ``), and treating that as +-- "no parent" would make the filesystem root the one directory this walk +-- can never examine — the same root-is-special bug the boundary test +-- below had, from the other end. `/` itself matches nothing (no +-- non-separator component to strip), so the walk still terminates there. +-- This matches `walk_for_marker`'s use of `Path::ancestors` +-- (`src/project.rs`), which likewise ends at `/` inclusive. local function latex_parent_of(dir) local up = dir:match("^(.*)/[^/]+$") - if up == nil or up == dir or up == "" then return nil end + if up == nil or up == dir then return nil end + if up == "" then return "/" end return up end @@ -360,9 +375,37 @@ end -- ignored the boundary would break that contract — and make this -- resolver's own acceptance fixtures non-hermetic against any -- `latexmkrc` sitting above the test's tempdir (R8's shape exactly). +-- +-- Containment is a question about PATH COMPONENTS, so it is answered by +-- comparing components. The previous string-prefix form +-- (`dir:sub(1, #boundary + 1) == boundary .. "/"`) silently disabled the +-- entire walk for a `/` boundary: the needle became `"//"`, which no +-- canonical path begins with, so every ancestor was judged out of +-- bounds, no marker was ever examined, and each chapter of a thesis got +-- its own server. Segment comparison makes the root boundary a boundary +-- with zero segments — containing everything, by construction rather +-- than by a special case — and absorbs a trailing separator for free. +-- +-- Both arguments are canonical absolute paths (`latex_root_for` +-- canonicalizes `dir`; `set_search_boundary` canonicalizes the boundary +-- at set time), so a leading-separator mismatch cannot arise. +local function latex_path_segments(path) + local segs = {} + for seg in path:gmatch("[^/]+") do + segs[#segs + 1] = seg + end + return segs +end + local function latex_within_boundary(dir, boundary) if not boundary then return true end - return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" + local want = latex_path_segments(boundary) + local have = latex_path_segments(dir) + if #have < #want then return false end + for i = 1, #want do + if have[i] ~= want[i] then return false end + end + return true end -- Returns the INNERMOST ancestor holding a texlab root marker, or the @@ -382,6 +425,11 @@ local function latex_root_for(path) if type(path) ~= "string" then return nil end local dir = path:match("^(.*)/[^/]*$") if not dir then return nil end + -- Same root-is-special trap as `latex_parent_of`: `/paper.tex` slices + -- to an EMPTY directory, which canonicalizes to nothing and would make + -- the resolver DECLINE — and a decline is the one path that reaches + -- `pmacs.project.detect`, whose walk includes `.git`. + if dir == "" then dir = "/" end dir = pmacs.fs.canonicalize(dir) if not dir then return nil end local boundary diff --git a/docs/active-work.md b/docs/active-work.md index 3327aad..59783a1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -333,11 +333,12 @@ authoritative tip** — the ref, not a SHA. Recover with - **`args = {}` is also observed**, not assumed: bare `texlab` answers `initialize` with `TexLab 5.25.1` over stdio, so the `run` subcommand is not needed. - - **§3 therefore reads slightly stale** — it frames marker 1 as - conditional on texlab honouring the file, when the operative fact - is that texlab honours the *client-supplied root*. Worth a revision - 3 by whoever next touches the document; not smuggled into this - lane's commit. + - **§3 said the wrong thing and has been corrected — `b5eaf27` IS + revision 3.** It framed marker 1 as conditional on texlab honouring + the `.texlabroot` *file*, when the operative fact is that texlab + honours the *client-supplied root* and never widens it. The caveat + was discharged by observation, and revision 3 records what that + established. Nothing about §3 is outstanding. - **`.git` exclusion needed more than omitting it from the list.** `project_root_for` falls through to `pmacs.project.detect` when a resolver returns nil, and **that** walk includes `.git` — so a @@ -360,6 +361,31 @@ authoritative tip** — the ref, not a SHA. Recover with (1), boundary ignored (1), `io.open` truthiness so a directory counts as a marker (1), marker set narrowed (4), command renamed with opinionated settings added (1). +- **The boundary has now been the interesting part twice, and the + second time it was a real defect (fixed in review).** First it was + hermeticity — every fixture sets `set_search_boundary` at its own + tempdir because R8's shape (a stray `latexmkrc` above the tempdir) + would make the markerless assertions pass while testing nothing. + Then review found `latex_within_boundary` answering a PATH question + with string arithmetic: `dir:sub(1, #boundary + 1) == boundary .. "/"` + compares against `"//"` when the boundary is `/`, which no canonical + path matches, so a root boundary judged **every** ancestor out of + bounds, ran no marker walk at all, and gave each chapter of a thesis + its own server — the lane's headline behaviour silently off, with + every shipped test still green because each one clamps to a tempdir. + The same trap sat at the other end (`/` was never a walk candidate, + and `/paper.tex` sliced to an empty directory and declined into the + `.git`-aware detector). Now segment comparison throughout: the root + is a boundary with zero segments, contained by construction rather + than by a special case. Pinned by an ATTACH-level test under a `/` + boundary — two chapters, one server, marker root — and the + hermeticity property asserts **both** directions, since "stops at the + boundary" is also satisfied by a walk that never runs. Suite is 16 + tests. **A reader + deciding whether to trust this resolver should read it as: the marker + set and the `.git` exclusion were settled by observation and are + solid; the boundary arithmetic around them was not, and is the place + to look first if roots come back wrong.** - **Trap for the next agent in this worktree:** this machine exports a shared `CARGO_TARGET_DIR`, so a bare `cargo test` compiles against a sibling worktree's artifacts and fails with errors from code that is diff --git a/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs index e62a03e..4ccd138 100644 --- a/tests/lsp_latex_acceptance.rs +++ b/tests/lsp_latex_acceptance.rs @@ -488,6 +488,54 @@ fn two_chapters_of_one_thesis_share_a_single_server() { ); } +#[test] +fn two_chapters_share_one_server_under_a_root_search_boundary() { + // A `/` boundary is "clamp nothing", spelled as a path — and it used + // to disable the marker walk OUTRIGHT. The containment test was + // string arithmetic (`dir:sub(1, #boundary + 1) == boundary .. "/"`), + // so a `/` boundary asked whether each ancestor began with `"//"`, + // which no canonical path does. Every ancestor was judged out of + // bounds, no marker was ever examined, and each chapter got its own + // root — the lane's headline behaviour, silently off, with the + // predicate's unit-level answers all still looking plausible. + // + // Pinned through ATTACH because that is where the symptom lives: two + // texlab processes for one thesis, not a wrong string. + // + // Still hermetic despite the unclamped boundary: innermost marker + // wins, and `thesis/` has one, so no `latexmkrc` above the tempdir + // can change the answer. + let fx = Fixture::new(); + let mut state = editor(); + exec(&state, "pmacs.project.set_search_boundary(\"/\")"); + let seen: String = eval(&state, "return pmacs.project.search_boundary() or \"\""); + assert_eq!( + seen, "/", + "fixture precondition: the boundary must be the filesystem root" + ); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "a root boundary must behave like any other boundary: both \ + chapters resolve to the thesis root, so ONE server: {rows:?}" + ); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("thesis")), + "and that one server is rooted at the marker directory" + ); +} + #[test] fn two_markerless_documents_in_different_directories_do_not_share_a_server() { // The complement of the pin above: the fallback is the file's own @@ -604,6 +652,47 @@ fn latex_root_walk_stops_at_the_search_boundary() { "the walk must not climb past the search boundary to reach the \ marker above it" ); + + // The other direction, and it is not decoration: "stops at the + // boundary" is also satisfied by a walk that never runs at all — + // which is precisely what a `/` boundary used to produce. So assert + // that within the boundary the walk still CLIMBS, and that the + // boundary directory itself is a candidate (inclusive, matching + // `set_search_boundary`'s documented contract). + fx.write("inner/.texlabroot", ""); + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(inner.display().to_string().as_str()), + "a marker AT the boundary directory is found, and the walk \ + climbs out of `chapters/` to reach it" + ); +} + +#[test] +fn latex_root_for_a_document_at_the_filesystem_root_is_the_root() { + // The same root-is-special trap one level up: `/paper.tex` slices to + // an EMPTY directory string, which canonicalizes to nothing, so the + // resolver DECLINED — and a decline is the one path that falls + // through to `pmacs.project.detect`, whose walk includes `.git`. + // Hermetic: the boundary is this fixture's tempdir, so `/` is out of + // bounds, no marker is examined, and the answer is the directory + // itself regardless of what sits at the filesystem root. + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + let doc = Path::new("/pmacs-lsp-latex-no-such-document.tex"); + assert!( + !doc.exists(), + "fixture precondition: {} must not exist", + doc.display() + ); + + assert_eq!( + resolve_root(&state, doc).as_deref(), + Some("/"), + "a document at the filesystem root roots at `/`; it must not \ + decline into the shared `.git`-aware detector" + ); } #[path = "common/iso.rs"] From e9bfe9a14cd4c17c4d9e1489415376e471bee82c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 13:46:18 +0200 Subject: [PATCH 21/66] =?UTF-8?q?docs:=20frame=20Discovery=20Stage=202=20(?= =?UTF-8?q?revision=202)=20=E2=80=94=20M-x=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COHERENCE.md section 5 grades discoverability Partial after Stage 1 and names "M-x rows are still bare names". The descriptions ALREADY EXIST --- `Command.description` is required, and `help.list-commands` renders them --- so this is substrate without surface in its purest form: the information is present, surfaced elsewhere, and absent from the one moment it would change a decision. TWO REVISION-1 CLAIMS WERE WRONG, both checkable in the tree: - "Change `candidates` in place and gate at >= 23." Postcard is NOT self-describing: fields encode positionally, so a v22 peer decoding `Vec` where it expects `Vec` mis-reads the bytes rather than skipping them. And gating would not have rescued it --- with only one variant to gate, a v12-v22 peer would have received NO MINIBUFFER AT ALL. That variant goes to every peer negotiated >= 12 (src/daemon.rs:1472). Revision 2 is additive: `MinibufferPromptRows` APPENDED to the enum (indices are positional; inserting renumbers everything), `MinibufferPrompt` frozen for v12-v22, per-peer selection, per- variant cache keys, and close matching the open's family --- a mismatched close is how a popup stays on screen forever. - "Both frontends render label + detail." The grid TUI never reads `MinibufferPrompt`; src/editor.rs contains ZERO references to it. It paints from `core.minibuffer` and renders the selected candidate as `format!(" [{cand}]")` (src/editor.rs:5484). The rich wire reaches pmacs-gpu only. So the TUI half is a LOCAL formatting change --- it is in-process with the core and reads `Command.description` from the registry directly, with no wire involvement. The contract is pinned including clipping: THE NAME SURVIVES AND THE DESCRIPTION IS DROPPED at narrow widths, because a clipped name is strictly worse than today's bare one. A multi-row TUI chooser is explicitly not this lane. This lane HOLDS THE BUMP SLOT. Git Stage 1 is no-wire and runs beside it; git Stage 2 needs a bump and must wait. Q#D2-5 records a trap that arrives with the feature: richer rows make M-x LOOK like a closed set, inviting someone to make acceptance reject unmatched input. Completion is assistance, not validation --- `resolve_accepted_value` returns literal typed text by design --- so that would be a behaviour change, not a rendering one, and it is out of scope. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 33 ++++ docs/discovery-stage2-framing.md | 295 +++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 docs/discovery-stage2-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 59783a1..352eb53 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,6 +265,38 @@ 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. +## Discovery Stage 2 — BRANCHED, framing in review + +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `discovery-stage2`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/discovery-stage2` is the authoritative +tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout discovery-stage2`. + +- **Framing `docs/discovery-stage2-framing.md`, revision 2**, in + review. Scope: `COHERENCE.md` §5's "M-x rows are still bare names". + Descriptions already exist on `Command` and are already rendered by + `help.list-commands`; they are missing at the one moment they would + change a decision. +- **PROTOCOL BUMP v22 → v23, and this lane HOLDS THE BUMP SLOT.** + Additive: a new `MinibufferPromptRows` variant **appended** to the + enum, with `MinibufferPrompt` **frozen** for v12–v22. An in-place + field change is a wire break — postcard encodes positionally, and + that variant is sent to every peer `>= 12` (`src/daemon.rs:1472`). +- **Git Stage 2 (gutter markers) also needs a bump and must wait for + this to land.** Git Stage 1 is no-wire and runs beside it. +- **Two halves, only one of which is wire work.** `pmacs-gpu` renders + the new variant. **The grid TUI never reads `MinibufferPrompt` at + all** — it paints from `core.minibuffer` and renders + `format!(" [{cand}]")` (`src/editor.rs:5484`), so its half is a + local formatting change reading the registry directly. A multi-row + TUI chooser is explicitly NOT this lane. +- **Gates:** `scripts/gate --protocol --acceptance ` — + the strengthened two-configuration sweep, which is what `--protocol` + exists for. + ## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet **Written with the lane's first commit**, per the standing correction @@ -603,6 +635,7 @@ authoritative tip** — the ref, not a SHA. Recover with — added in the second round — a **rename of either** the build or the sweep step each fail the suite. ||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last) +||||||| parent of 312ec7a (docs: frame Discovery Stage 2 (revision 2) — M-x rows) ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/discovery-stage2-framing.md b/docs/discovery-stage2-framing.md new file mode 100644 index 0000000..b2a12af --- /dev/null +++ b/docs/discovery-stage2-framing.md @@ -0,0 +1,295 @@ +# Discovery Stage 2 — M-x rows stop being bare names + +**Status: framing pass, revision 2. Pre-implementation. Awaiting +approval.** + +**Revision 2 fixes two claims revision 1 made about compatibility and +about the TUI, both wrong, both checkable.** An in-place field change +cannot preserve v22 — postcard is not self-describing — and "both +frontends render it" was false, because the grid TUI never reads that +message at all. Verified in the tree, not reasoned about. + +--- + +## 1. The gap, stated exactly + +`COHERENCE.md` §5 grades unified discoverability **Partial** after +Stage 1 (#207), and names three things left. This lane takes one: + +> `Command` still has no title/category/flags, **M-x rows are still +> bare names**, and the Rust help layer is still orphaned. + +**The descriptions already exist.** `Command.description` is a required +field (`src/command.rs:69`), and `help.list-commands` already renders +"every registered command **with its description**" +(`builtin/runtime/help.lua:339`). A user who runs `M-x help` can read +what everything does. + +**What they cannot do is see it at the moment of choosing.** `M-x` +shows names alone — so the information exists, is already surfaced +elsewhere, and is missing from the one place it would change a +decision. That is §1.1's *substrate without surface* in its purest +form, and it is felt every time the editor is used. + +## 2. Ground truth + +Scouted: + +- **The wire asymmetry is a single field.** + `InstanceMessage::MinibufferPrompt` carries + `candidates: Vec` (`pmacs-protocol/src/message.rs:1113`). +- **The rich pattern is already proven in a sibling variant.** + `CompletionPopup` carries `rows: Vec` — `label`, + `kind: u8`, `detail: Option` (`:1387`) — and both frontends + already render it. + + *(Revision note: an earlier read of mine reported two bare-string + sites. There is one. The second grep hit was `CompletionPopup`'s + doc comment, which says "candidates" while the field is `rows`.)* +- **`Command` needs no change for this lane.** `description` is + already there and already required. Title/category/aliases — the + lane's other Stage-2 candidate — would enrich these rows further and + are **deliberately not** in scope: they are a ~175-site change and + this lane can deliver the felt improvement without them. +- **`ADVERTISED_PROTOCOL_VERSION` is pinned at 20** + (`pmacs-protocol/src/message.rs:1767`) and **must not be edited**, + per handoff §3/§5. +- **The transport is postcard** (`pmacs-protocol/src/transport.rs:1`), + which is **not self-describing**: enum variants encode by index and + fields by position. **Changing a field's type in place is a wire + break**, not a compatible evolution — a v22 peer would mis-decode the + bytes rather than ignore them. +- **`MinibufferPrompt` is sent to every peer negotiated `>= 12`** + (`src/daemon.rs:1472`, "Q#MB1 — MinibufferPrompt gated at v12"). So + the population that would break is every frontend from v12 to v22. +- **The grid TUI never reads `MinibufferPrompt`.** `src/editor.rs` + contains **zero** references to it; `paint_minibuffer` reads + `core.minibuffer` directly and renders the selected candidate as an + inline suffix, `format!(" [{cand}]")` (`src/editor.rs:5484`), with + its own `ui.minibuffer.candidate` face. **The rich wire reaches + `pmacs-gpu` only.** + +## 3. The change + +**This is a protocol change: v22 → v23**, and it is **additive**, not +an edit. + +### 3.1 A new variant, because an in-place change cannot be compatible + +Revision 1 proposed changing `candidates` in place. **That breaks every +frontend from v12 to v22**: postcard encodes fields positionally, so a +v22 peer decoding a `Vec` where it expects +`Vec` mis-reads the bytes — it does not skip them. + +And gating the changed variant at `>= 23` does not rescue it: the peer +would then receive **no minibuffer message at all**, because there is +only one variant to send. Compatibility means *sending the old shape*, +which requires the old shape to still exist. + +So: + +- **`MinibufferPrompt` is retained, unchanged, for v12–v22.** Its + encoding is frozen. +- **`MinibufferPromptRows` is a NEW variant appended to the enum**, + carrying `rows: Vec` and otherwise mirroring + `MinibufferPrompt`'s fields. +- **Appended, not inserted.** Variant indices are positional in + postcard; inserting anywhere but the end renumbers every later + variant and breaks everything at once. + +### 3.2 Per-session selection, and the ordering that matters + +- **Selection is per peer, decided from its negotiated version**: + `>= 23` receives `MinibufferPromptRows`; `12..=22` receives + `MinibufferPrompt`. This mirrors the existing gates in + `src/daemon.rs:1472`, which already suppress `MenuPrompt`, + `MinibufferPrompt` and `LineNumbers` per peer. +- **Exactly one of the two is sent to any given peer, ever.** Sending + both to a v23 peer would double-render; sending neither is the bug + gating alone would have caused. +- **Close and cache ordering.** The prompt is cached-compare + suppressed, so the cache key must be **per variant**, or a v23 peer + that reconnects at v22 (or vice versa across a restart) can have its + first message suppressed as a duplicate of one it never received. + **The close message must use the same variant family as the open** — + a `MinibufferPromptRows` session closed by a legacy clear is exactly + the kind of mismatch that leaves a popup on screen forever. + +### 3.3 What each frontend does + +- **`pmacs-gpu`** renders label + detail from the new variant. +- **The grid TUI does not consume this message at all** and is + addressed separately in §3.4. + +### 3.4 The TUI presentation contract + +Revision 1 said "both frontends render label + detail". **The grid TUI +does not read `MinibufferPrompt`** — it paints from `core.minibuffer` +and renders the selected candidate as `format!(" [{cand}]")` +(`src/editor.rs:5484`). The wire change reaches it not at all. + +*My vote: **an inline selected form, matching what is already there***: + +``` +M-x buffer.sa [buffer.save — Write the buffer to its file] +``` + +- **Source: local.** The TUI is in-process with the core, so it reads + `Command.description` from the registry directly. **No wire + involvement**, which is why this half of the lane is independent of + the bump. +- **Only the selected candidate**, as today. This is a formatting + change to an existing suffix, not a new surface. +- **Clipping is explicit**: the suffix is already written against + `max = term_size.cols` with a running `written` count. The **name + must survive clipping and the description is what gets truncated** — + a row that clips to `[buffer.sa…]` would be strictly worse than + today. If the terminal is too narrow for `name — ` plus one + character of description, **the description is dropped entirely** + rather than shown as an ellipsis stub. +- **The `ui.minibuffer.candidate` face already exists** and continues + to cover the suffix. + +**A multi-row TUI chooser is explicitly NOT this lane.** It would be a +new interaction surface, a §6 island risk, and materially larger than +the wire work — it is named here so that "make the TUI match the GPU" +does not quietly become that. + +### 3.5 Scheduling consequence, which is not incidental + +`PROTOCOL_VERSION` is a strict serialization point — two lanes bumping +it collide, and this session recorded eight broken version assertions +from a single bump. So: + +- **This lane holds the bump slot.** Git Stage 1 is deliberately + no-wire and runs beside it without contention. +- **Git Stage 2 (gutter markers) also needs a bump and must therefore + wait for this to land.** That ordering should be explicit in the + ledger rather than discovered when the two collide. + +## 4. Coherence impact (§20) + +- **§5 unified discoverability — the direct target**, and the specific + clause "M-x rows are still bare names". +- **Journey step 4** ("understand the interface"): `COHERENCE.md` P4 + says most of it "rides on" discovery. This improves the step without + adding one. +- **§16 semantic frontend:** a clean instance of the architecture — + the instance states *what a candidate is*, each frontend decides how + to draw it. Degradation is the established practice (Q#D2-4). +- **Interaction islands (§6): none added.** No new key interception; + this changes what an existing prompt carries. +- **Config registry:** no new setting. Whether detail rendering is + optional is Q#D2-3, and my vote is no setting at all. +- **Background-work attribution (§9): untouched.** No new background + work. + +## 5. Open questions + +### Q#D2-1 — reuse `CompletionPopupRow`, or a new type? + +Reuse is tempting and I think wrong. `CompletionPopupRow.kind` is an +**LSP `CompletionItemKind` code (1..=25)** with a documented contract; +an M-x command is not an LSP completion item and has no honest value +for that field. Reusing it would mean either inventing a fake kind or +declaring 0/unknown everywhere — a type whose invariant is +"meaningless in half its uses". + +*My vote: **a new `MinibufferRow { label, detail: Option }`*** +— no `kind`. If a category field is wanted later it arrives with +`Command.category` (the other Stage-2 candidate), typed as what it +actually is rather than borrowed from LSP. + +### Q#D2-2 — which prompts get rows? + +`pmacs.minibuffer.read` serves many sources, not just M-x: file paths, +buffer names, apropos substrings, settings. Only some have a natural +`detail`. + +*My vote: **the field is `Option` per row and the daemon fills +it where it has one.*** Commands get their description; a file-path +prompt leaves it `None` and renders exactly as today. No source is +obliged to invent a detail, and none is prevented from gaining one +later. + +### Q#D2-3 — is detail rendering configurable? + +*My vote: **no setting.*** §11 grades the registry "partial +(foundation only)"; adding a speculative toggle for a feature nobody +has yet asked to disable is how a registry becomes noise. If somebody +wants it off, that is use evidence and a later one-line addition. + +### Q#D2-4 — older frontends — **RESOLVED in rev 2, in §3.1–3.2** + +No longer open, and the revision-1 answer was wrong. "Gate the richer +form at `>= 23`" would have **removed the minibuffer entirely** from +every v12–v22 peer, because there would have been only one variant to +gate. Compatibility requires the legacy shape to still exist and still +be sent — hence the additive `MinibufferPromptRows` variant, per-peer +selection, per-variant cache keys, and matched open/close families. + +The `CompletionPopup` gate I proposed copying (`daemon-gated >= 15`) +**is** the right precedent for *how to select per peer*; it is not a +precedent for changing a live variant's shape, because that variant was +new when it was gated. + +### Q#D2-5 — does this tempt closed-set acceptance? **(a trap)** + +The discovery lane's own handoff note warns: **completion is +assistance, not validation** — `resolve_accepted_value` returns the +literal typed text when no candidate is selected, so closed-set +acceptance is unbuilt Rust work. + +Richer rows make M-x *look* like a closed set, which invites someone to +make acceptance reject unmatched input. **That is out of scope and +would be a behaviour change**, not a rendering one. Stated here because +the temptation arrives with the feature. + +## 6. Verification + +- **A command's description reaches the GPU row**, asserted through + the real prompt path rather than by constructing a message. +- **A v22 peer still receives `MinibufferPrompt`, with its old + encoding** — the case revision 1 would have broken. Asserted by + negotiating v22 and observing the legacy variant arrive, **not** by + observing "no error". +- **A v23 peer receives `MinibufferPromptRows` and NOT the legacy + variant** — the double-render guard. +- **A round-trip encode/decode of the frozen `MinibufferPrompt`** + pins its shape, so a later field addition to it fails a test rather + than silently breaking v12–v22. +- **The cache key is per variant**: a session that opens for a v23 peer + and a later one for a v22 peer are not suppressed as duplicates of + each other (§3.2). +- **Close matches open**: a `MinibufferPromptRows` session is closed by + its own family, witnessed by the popup actually clearing. +- **The TUI renders `name — description` for the selected candidate** + (§3.4), from the local registry, with **no wire involvement**. +- **TUI clipping preserves the NAME and drops the description** at + narrow widths — witnessed at a width where both cannot fit, because + a clipped name is worse than today's bare name. +- **A source with no detail renders exactly as before** — the + file-path prompt is the witness (Q#D2-2). +- **Typed-but-unmatched input is still accepted** (Q#D2-5) — the + guard against this lane quietly becoming a validation change. +- **The version-bump discipline**: `ADVERTISED_PROTOCOL_VERSION` + unchanged at 20, and the tripwire assertions updated **knowingly**. + Handoff §3 requires the strengthened two-configuration sweep for a + `PROTOCOL_VERSION` change — `scripts/gate --protocol`, which exists + precisely for this. + +**What this will not prove:** that `Command` carries title or category +(not in scope), or that predicates are evaluated (Stage 3+). + +## 7. Not in scope + +`Command` gaining title/category/aliases/flags/arg-schema — the +~175-site change, and the lane's next candidate. **A multi-row TUI +chooser** (§3.4) — a new interaction surface and materially larger than +this lane. **Changing `MinibufferPrompt`'s existing shape** — it is +frozen for v12–v22. Predicate evaluation, +which makes commands stop being invocable and needs its own decision at +each call site. Help-layer unification (`src/help.rs` is still +orphaned). The help prefix key — `C-h` is **not** free, since non-kitty +terminals cannot disambiguate Ctrl+Backspace from Ctrl+H (both are +byte 0x08). Closed-set acceptance (Q#D2-5). From b3a6da2416c25187fa9c5890fc3bdc5bd5f7c121 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 13:51:32 +0200 Subject: [PATCH 22/66] docs: Discovery Stage 2 framing, revision 3 Three corrections, each to something revision 2 asserted without checking the mechanism it was reasoning about. All three verified in the tree. A ROUND-TRIP FREEZES NOTHING. Revision 2 proposed pinning the legacy variant with an encode/decode round-trip. That test uses the SAME types on both sides, so adding a field to `MinibufferPrompt` leaves it passing while every v12-v22 peer in the field breaks. The existing `minibuffer_prompt_round_trips_through_postcard` (src/protocol.rs:2363) is precisely that shape, and there are NO literal byte fixtures anywhere in the protocol tests today --- checked, not assumed. Only `assert_eq!(encoded, LEGACY_BYTES)` against bytes captured now can fail when the encoding changes. Two fixtures, open and clear, matching the shapes the semantic test already covers so the corpus is not a fresh judgement call. THE CACHE HAZARD I DESCRIBED CANNOT HAPPEN. Revision 2 argued for a per-variant cache key on the grounds that a peer reconnecting at a different version could have its first message suppressed as a duplicate. `SemanticRenderState::for_peer(frontend_id, negotiated_protocol_version)` is created PER PEER with its version baked in on attach (src/daemon.rs:2080) and removed on detach (:1591), so a cache never spans two versions and reconnecting creates a fresh state. One per-peer cache stands; the selection is a producer gate, `peer_knows_minibuffer_rows`, alongside the four such gates that already exist (:1410-1435). The test for the impossible condition is REMOVED rather than written --- a test that cannot fail passes forever and teaches the next reader that the hazard is real. The matched open/close family requirement is independent of caching and stands unchanged. "THE NAME MUST SURVIVE" IS NOT ACHIEVABLE. Prompt plus typed input consume the width budget first, so the remaining suffix space can be too small for even the bare name --- and revision 2's rule would then have forced a partial name, which reads as a DIFFERENT command. The rule is now ordered: if the whole name does not fit, omit the suffix entirely; only once it fits is a description attempted; if the description does not fit whole, drop it. The guarantee becomes "never a partial name", which is achievable, rather than "the name always survives", which is not. Witnessed at three widths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/discovery-stage2-framing.md | 87 ++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/docs/discovery-stage2-framing.md b/docs/discovery-stage2-framing.md index b2a12af..a7b461a 100644 --- a/docs/discovery-stage2-framing.md +++ b/docs/discovery-stage2-framing.md @@ -1,8 +1,14 @@ # Discovery Stage 2 — M-x rows stop being bare names -**Status: framing pass, revision 2. Pre-implementation. Awaiting +**Status: framing pass, revision 3. Pre-implementation. Awaiting approval.** +**Revision 3 fixes three things revision 2 asserted without checking +the mechanism it was reasoning about**: a "frozen-shape" test that +freezes nothing, a cache hazard that this architecture makes +impossible, and a clipping rule that is unachievable at narrow enough +widths. All three verified in the tree. + **Revision 2 fixes two claims revision 1 made about compatibility and about the TUI, both wrong, both checkable.** An in-place field change cannot preserve v22 — postcard is not self-describing — and "both @@ -107,13 +113,27 @@ So: - **Exactly one of the two is sent to any given peer, ever.** Sending both to a v23 peer would double-render; sending neither is the bug gating alone would have caused. -- **Close and cache ordering.** The prompt is cached-compare - suppressed, so the cache key must be **per variant**, or a v23 peer - that reconnects at v22 (or vice versa across a restart) can have its - first message suppressed as a duplicate of one it never received. - **The close message must use the same variant family as the open** — - a `MinibufferPromptRows` session closed by a legacy clear is exactly - the kind of mismatch that leaves a popup on screen forever. +- **The selection is a producer gate**, named + `peer_knows_minibuffer_rows`, alongside the existing + `peer_knows_minibuffer_prompt` / `peer_knows_menu_prompt` / + `peer_knows_completion_popup` (`src/daemon.rs:1410-1435`). One new + gate in an established pattern, not a new mechanism. +- **ONE per-peer minibuffer cache, not a per-variant key.** + + **Revision 2's rationale for a per-variant key was false**, and the + architecture is why: `SemanticRenderState::for_peer(frontend_id, + negotiated_protocol_version)` is created **per peer, with its version + baked in, on attach** (`src/daemon.rs:2080`) and **removed on + detach** (`:1591`). A cache therefore never spans two negotiated + versions — the v23→v22 reconnect suppression I described **cannot + occur**, because reconnecting creates a fresh state. The + corresponding test is removed rather than written; a test for an + impossible condition passes forever and teaches the next reader that + the hazard is real. +- **The close message must still use the same variant family as the + open** — a `MinibufferPromptRows` session closed by a legacy clear is + the mismatch that leaves a popup on screen forever. That one is + independent of caching and stands. ### 3.3 What each frontend does @@ -140,13 +160,22 @@ M-x buffer.sa [buffer.save — Write the buffer to its file] the bump. - **Only the selected candidate**, as today. This is a formatting change to an existing suffix, not a new surface. -- **Clipping is explicit**: the suffix is already written against - `max = term_size.cols` with a running `written` count. The **name - must survive clipping and the description is what gets truncated** — - a row that clips to `[buffer.sa…]` would be strictly worse than - today. If the terminal is too narrow for `name — ` plus one - character of description, **the description is dropped entirely** - rather than shown as an ellipsis stub. +- **Clipping, in three ordered steps.** The suffix is already written + against `max = term_size.cols` with a running `written` count, and + the prompt plus typed input consume that budget first — so the + remaining width can be **too small even for the bare name**. + Revision 2 said "the name must survive", which is not achievable at + arbitrary widths and would have forced a partial name. The rule: + + 1. **If the remaining suffix width cannot fit the WHOLE name, omit + the suffix entirely.** Never emit a partial name — `[buffer.sa…]` + is worse than nothing, because it reads as a different command. + 2. **Only once the whole name fits** is a description attempted. + 3. **If the description does not fit whole, drop the description**, + leaving today's `[name]`. No ellipsis stub. + + So the guarantee is *"never a partial name"*, which is achievable, + rather than *"the name always survives"*, which is not. - **The `ui.minibuffer.candidate` face already exists** and continues to cover the suffix. @@ -255,9 +284,22 @@ the temptation arrives with the feature. observing "no error". - **A v23 peer receives `MinibufferPromptRows` and NOT the legacy variant** — the double-render guard. -- **A round-trip encode/decode of the frozen `MinibufferPrompt`** - pins its shape, so a later field addition to it fails a test rather - than silently breaking v12–v22. +- **LITERAL POSTCARD BYTE FIXTURES for the legacy variant**, open and + clear: `assert_eq!(encoded, LEGACY_BYTES)` against a constant. + + **Revision 2 proposed a round-trip and that freezes nothing.** A + round-trip encodes and decodes with the *same* types, so adding a + field to `MinibufferPrompt` leaves it passing — both sides simply + learn the new shape, while every v12–v22 peer in the field breaks. + The existing `minibuffer_prompt_round_trips_through_postcard` + (`src/protocol.rs:2363`) is exactly that kind of test, and **there + are no literal byte fixtures anywhere in the protocol tests today** — + checked, not assumed. + + Only comparing against bytes captured *now* can fail when the + encoding changes. Two fixtures: an open prompt with candidates and a + selection, and a cleared band — the two shapes the existing semantic + test already covers, so the corpus is not a new judgement call. - **The cache key is per variant**: a session that opens for a v23 peer and a later one for a v22 peer are not suppressed as duplicates of each other (§3.2). @@ -265,9 +307,12 @@ the temptation arrives with the feature. its own family, witnessed by the popup actually clearing. - **The TUI renders `name — description` for the selected candidate** (§3.4), from the local registry, with **no wire involvement**. -- **TUI clipping preserves the NAME and drops the description** at - narrow widths — witnessed at a width where both cannot fit, because - a clipped name is worse than today's bare name. +- **TUI clipping is witnessed at THREE widths** (§3.4): wide enough + for name + description; wide enough for the name only (description + dropped, `[name]` as today); and **too narrow for even the whole + name — the suffix vanishes entirely**. The last is the case revision + 2's rule could not express, and the assertion is that no *prefix* of + a name is ever emitted. - **A source with no detail renders exactly as before** — the file-path prompt is the witness (Q#D2-2). - **Typed-but-unmatched input is still accepted** (Q#D2-5) — the From 0e260e03773920fadb473c5ba124c60d905b218f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 13:55:17 +0200 Subject: [PATCH 23/66] docs: clear three stale references revision 3 left behind Revision 3 corrected the cache model in section 3.2 and the clipping rule in 3.4, then failed to propagate either. Internal contradictions in a framing document are worse than the original errors: a later reader resolves them by picking whichever statement they read first. - Q#D2-4 still summarised the design as "per-variant cache keys", contradicting 3.2's corrected one-per-peer model. Now names the `peer_knows_minibuffer_rows` producer gate and the single per-peer cache, with a parenthetical recording that the sentence was stale rather than silently rewriting history. - Section 6 still required the cross-version cache test that revision 3 removed as unfalsifiable. Replaced with what IS assertable --- a v22 and a v23 peer attached simultaneously each receive their own variant and only their own --- and the bullet says why the other test is absent, so it does not get "restored" later by someone who notices the gap. - The active-work lane still called the framing revision 2. Swept for the same class of residue from the other two corrections ("the name must survive", "both frontends render"); the remaining hits are the notes ABOUT those corrections, which are deliberate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/discovery-stage2-framing.md | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 352eb53..23c7d20 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout discovery-stage2`. -- **Framing `docs/discovery-stage2-framing.md`, revision 2**, in +- **Framing `docs/discovery-stage2-framing.md`, revision 3**, in review. Scope: `COHERENCE.md` §5's "M-x rows are still bare names". Descriptions already exist on `Command` and are already rendered by `help.list-commands`; they are missing at the one moment they would diff --git a/docs/discovery-stage2-framing.md b/docs/discovery-stage2-framing.md index a7b461a..993ab75 100644 --- a/docs/discovery-stage2-framing.md +++ b/docs/discovery-stage2-framing.md @@ -248,14 +248,19 @@ later. has yet asked to disable is how a registry becomes noise. If somebody wants it off, that is use evidence and a later one-line addition. -### Q#D2-4 — older frontends — **RESOLVED in rev 2, in §3.1–3.2** +### Q#D2-4 — older frontends — **RESOLVED, in §3.1–3.2** No longer open, and the revision-1 answer was wrong. "Gate the richer form at `>= 23`" would have **removed the minibuffer entirely** from every v12–v22 peer, because there would have been only one variant to gate. Compatibility requires the legacy shape to still exist and still -be sent — hence the additive `MinibufferPromptRows` variant, per-peer -selection, per-variant cache keys, and matched open/close families. +be sent — hence the additive `MinibufferPromptRows` variant, a +per-peer `peer_knows_minibuffer_rows` producer gate, **one per-peer +minibuffer cache**, and matched open/close families. + +*(Revision 2 said "per-variant cache keys" here. §3.2 corrected that in +revision 3 — the render state is per peer with its version baked in, so +a cache cannot span two versions — and this sentence was left stale.)* The `CompletionPopup` gate I proposed copying (`daemon-gated >= 15`) **is** the right precedent for *how to select per peer*; it is not a @@ -300,9 +305,12 @@ the temptation arrives with the feature. encoding changes. Two fixtures: an open prompt with candidates and a selection, and a cleared band — the two shapes the existing semantic test already covers, so the corpus is not a new judgement call. -- **The cache key is per variant**: a session that opens for a v23 peer - and a later one for a v22 peer are not suppressed as duplicates of - each other (§3.2). +- **No cross-version cache test.** Revision 2 required one; it asserts + a condition this architecture makes impossible (§3.2), and a test + that cannot fail passes forever while teaching the next reader that + the hazard is real. What *is* asserted is the producer gate: a v22 + peer and a v23 peer attached simultaneously each receive their own + variant and only their own. - **Close matches open**: a `MinibufferPromptRows` session is closed by its own family, witnessed by the popup actually clearing. - **The TUI renders `name — description` for the selected candidate** From 6eecbfcca112374e71adb38e5efd7836b4900b7e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 14:00:53 +0200 Subject: [PATCH 24/66] docs: record discovery Stage 2 framing approval Revision 3 was approved 2026-08-09 at 6a9a25d, after the three stale internal references were cleared. The lane heading and the framing status line both still said "in review", which is the kind of contradiction that makes a reader trust whichever line they hit first. The lane entry now also records WHAT the three review rounds found, rather than only how many there were. A count says the doc was reviewed; the list says what class of error it was reviewed for, which is the part a later reader can act on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 +++++++++--- docs/discovery-stage2-framing.md | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 23c7d20..b13f606 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## Discovery Stage 2 — BRANCHED, framing in review +## Discovery Stage 2 — BRANCHED, pre-implementation **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,8 +275,14 @@ from #171 and #215. tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout discovery-stage2`. -- **Framing `docs/discovery-stage2-framing.md`, revision 3**, in - review. Scope: `COHERENCE.md` §5's "M-x rows are still bare names". +- **Framing `docs/discovery-stage2-framing.md`, revision 3, APPROVED + 2026-08-09** after three review rounds. Each round found the previous + one reasoning about a mechanism instead of reading it — an in-place + field change that postcard cannot make compatible, a TUI that never + reads the message at all, a round-trip test that freezes nothing, a + cache hazard the per-peer render state makes impossible, and a + clipping rule unachievable at narrow widths. + Scope: `COHERENCE.md` §5's "M-x rows are still bare names". Descriptions already exist on `Command` and are already rendered by `help.list-commands`; they are missing at the one moment they would change a decision. diff --git a/docs/discovery-stage2-framing.md b/docs/discovery-stage2-framing.md index 993ab75..56da2bf 100644 --- a/docs/discovery-stage2-framing.md +++ b/docs/discovery-stage2-framing.md @@ -1,7 +1,7 @@ # Discovery Stage 2 — M-x rows stop being bare names -**Status: framing pass, revision 3. Pre-implementation. Awaiting -approval.** +**Status: revision 3, APPROVED 2026-08-09. Implementation may +proceed.** **Revision 3 fixes three things revision 2 asserted without checking the mechanism it was reasoning about**: a "frozen-shape" test that From 70e578142004734de97f21ca57afb192048d6a05 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 14:51:00 +0200 Subject: [PATCH 25/66] =?UTF-8?q?feat(discovery):=20M-x=20rows=20carry=20d?= =?UTF-8?q?escriptions=20=E2=80=94=20protocol=20v22=20->=20v23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Command.description` has always been required and has always been rendered by `help.list-commands`. It was missing at the one moment it would change a decision: the M-x row. This carries it there. COHERENCE.md §5's clause "M-x rows are still bare names", per docs/discovery-stage2-framing.md revision 3. ## The wire half is additive, and the old variant is FROZEN postcard is not self-describing: enum variants encode by index and fields by position. Widening `MinibufferPrompt.candidates` in place would make every v12–v22 peer MIS-DECODE the bytes rather than ignore them — and gating the widened form at `>= 23` would not rescue them either, because with only one variant to gate they would receive no minibuffer message at all. Compatibility requires the old shape to still exist AND still be sent. So `MinibufferPrompt` is retained unchanged for `12..=22`, and `MinibufferPromptRows { prompt, input, cursor, rows, selected, total }` is APPENDED as the final variant, carrying `MinibufferRow { label, detail: Option }`. A new row type, not `CompletionPopupRow`, whose `kind` is an LSP `CompletionItemKind` code with no honest value for a command (Q#D2-1). Exactly one of the two reaches any peer, ever. The producer selects on the session's negotiated version, so the CLOSE necessarily uses the same family as the OPEN — a rows session closed by a legacy clear leaves the dropdown on screen forever. The daemon's write loop gates both directions again, with the legacy gate written as a RANGE (`12..MINIBUFFER_ROWS_MIN_VERSION`) rather than a floor, so a v23 peer cannot receive both and double-render. `ADVERTISED_PROTOCOL_VERSION` stays 20, untouched. ## The TUI half involves no wire at all `src/editor.rs` contains zero references to `MinibufferPrompt`: `paint_minibuffer` reads `core.minibuffer` directly. So it reads `Command.description` from the registry in-process, which is why this half is independent of the bump. Clipping is three ORDERED steps (§3.4), and the guarantee is "never a PARTIAL name", not "the name always survives" — the prompt and typed input consume the budget first, so the remainder can be too small even for the bare name. If the whole name does not fit, the suffix is omitted entirely; only once it fits is a description attempted; a description that does not fit whole is dropped, leaving today's `[name]`. No ellipsis stub, and no prefix of a name is ever emitted. ## Verification `src/protocol.rs` gains this repo's FIRST literal postcard byte fixtures: `minibuffer_prompt_v12_wire_bytes_are_frozen`, open and cleared. A round-trip freezes nothing — it encodes and decodes with the same types, so a field addition leaves it passing while every shipped peer breaks. Bite-verified: reordering two fields of `MinibufferPrompt` leaves `minibuffer_prompt_round_trips_through_postcard` green and fails the fixture. `line_wrap_facts_encoding_is_unchanged_by_the_v23_build` pins the PREVIOUS final variant, per the handoff §4 rule that an appended variant's own round-trip cannot detect a discriminant shift. `tests/discovery_stage2_acceptance.rs` runs ONE daemon serving a v22 and a v23 session simultaneously, through the real M-x key path, and asserts each receives its own variant AND ONLY its own — open and close alike — by collecting every minibuffer message rather than filtering for the expected one. No cross-version cache test, deliberately (§3.2): `SemanticRenderState::for_peer` bakes the negotiated version in at attach and is dropped at detach, so a cache cannot span two versions. A test for an impossible condition passes forever while teaching the next reader that the hazard is real. Five version assertions updated, each read before editing: `src/protocol.rs` (the `PROTOCOL_VERSION` tripwire, renamed; and the v6-floor ladder's accepted/rejected ranges), `tests/statusline_segments_acceptance.rs`, `tests/bottom_panel_stage2b_gpu_acceptance.rs`, `tests/vterm_stage3_acceptance.rs`. No `ADVERTISED_PROTOCOL_VERSION` assertion fired. Gates: `scripts/gate --protocol --acceptance discovery_stage2_acceptance` — all ten green, including the strengthened two-configuration sweep. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- COHERENCE.md | 31 +- docs/active-work.md | 33 +- docs/agent-handoff.md | 21 +- pmacs-gpu/src/main.rs | 209 +++++++- pmacs-protocol/src/lib.rs | 11 +- pmacs-protocol/src/message.rs | 122 ++++- src/daemon.rs | 34 +- src/editor.rs | 64 ++- src/frontend.rs | 4 + src/protocol.rs | 145 ++++- src/semantic_render.rs | 269 +++++++++- tests/bottom_panel_stage2b_gpu_acceptance.rs | 13 +- tests/discovery_stage2_acceptance.rs | 537 +++++++++++++++++++ tests/statusline_segments_acceptance.rs | 9 +- tests/vterm_stage3_acceptance.rs | 5 +- 15 files changed, 1406 insertions(+), 101 deletions(-) create mode 100644 tests/discovery_stage2_acceptance.rs diff --git a/COHERENCE.md b/COHERENCE.md index e9cb970..c12acfc 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -570,9 +570,11 @@ descriptions, indexed by `M-x help`. It needed **no Rust** — the data was all reachable from Lua, and even the settings completion source is a Lua function through `CompletionSource::Custom`. -**What is still missing** is itemized below and unchanged by that stage: -`Command` has no title/category/aliases/flags/arg-schema; the predicate -is still never evaluated; M-x rows are still bare name strings; the Rust +**What is still missing** is itemized below. Discovery Stage 2 took one +item — **M-x rows now carry each command's description** — and the rest +is unchanged by both stages: `Command` has no +title/category/aliases/flags/arg-schema; the predicate +is still never evaluated; the Rust help layer is still orphaned (Stage 1 funnels every command through one Lua seam so the eventual migration is enumerated per subject rather than per call site); **packages** have no discovery surface, and workers, @@ -621,12 +623,21 @@ the sharpest instance of §1.1.** M-x filtering, or the menu. The doc comment's claim that "the command palette (T M2.7) uses it to gray out unavailable entries" describes something that never shipped. -- **M-x shows bare name strings.** `CompletionSource::Commands` returns - `Vec` of names; the wire type `MinibufferPrompt.candidates` - is `Vec` (`pmacs-protocol/src/message.rs:994-1006`). No - description, no keybinding, no category alongside candidates — while - `CompletionPopupRow` (`:1231`) already carries `kind` and `detail`, - proving richer rows are a solved wire problem in this codebase. +- **M-x rows carry a description — Discovery Stage 2 (protocol v23).** + `CompletionSource::Commands` still returns `Vec` of names, but + the row the user reads is no longer one. The GPU receives + `InstanceMessage::MinibufferPromptRows` — `MinibufferRow { label, + detail }`, a new type rather than a borrowed `CompletionPopupRow`, + whose `kind` is an LSP code with no honest value for a command — and + the grid TUI renders `[name — description]` inline from the registry + **in-process**, since `src/editor.rs` never consumed the wire variant + at all. The bump is additive: `MinibufferPrompt` is FROZEN and still + sent to every `12..=22` peer (postcard is positional, so widening it + would mis-decode there rather than be ignored), and exactly one of the + two variants reaches any peer. + **What is still missing here:** no keybinding and no category + alongside the candidate — those wait on `Command` gaining the fields + at all. - **The entire Rust help layer is orphaned** (§1.1). Consequence: two parallel `*help*` implementations exist — `help.rs`'s cross-referenced renderer and the Lua `show_help_text` in @@ -684,6 +695,8 @@ the sharpest instance of §1.1.** provenance in the config registry, (c) a dozen interactive commands and richer M-x candidate rows over introspection that **already exists**. This is the highest payoff-per-effort concern in the document. +*(c) is done: Stage 1 shipped the command family, Stage 2 the richer +rows. (a) and (b) remain.* --- diff --git a/docs/active-work.md b/docs/active-work.md index b13f606..3323bca 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## Discovery Stage 2 — BRANCHED, pre-implementation +## Discovery Stage 2 — IMPLEMENTED, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -299,9 +299,34 @@ tip** — the ref, not a SHA. Recover with `format!(" [{cand}]")` (`src/editor.rs:5484`), so its half is a local formatting change reading the registry directly. A multi-row TUI chooser is explicitly NOT this lane. -- **Gates:** `scripts/gate --protocol --acceptance ` — - the strengthened two-configuration sweep, which is what `--protocol` - exists for. +- **Gates:** `scripts/gate --protocol --acceptance + discovery_stage2_acceptance` — the strengthened two-configuration + sweep, which is what `--protocol` exists for. +- **IMPLEMENTED.** `PROTOCOL_VERSION` is 23, + `ADVERTISED_PROTOCOL_VERSION` is untouched at 20. New suite + `tests/discovery_stage2_acceptance.rs`; the daemon half is + `crdt`-gated (a semantic session is necessarily a text replica) and + runs one daemon serving a v22 and a v23 session simultaneously. +- **The freeze is enforced by LITERAL byte fixtures**, not a round-trip + — `minibuffer_prompt_v12_wire_bytes_are_frozen` in `src/protocol.rs`, + the first such fixture in this repo. Bite-verified: reordering two + fields of `MinibufferPrompt` leaves + `minibuffer_prompt_round_trips_through_postcard` **passing** and fails + the fixture, which is exactly the hazard a round-trip cannot see. +- **Version assertions updated (five, each read before editing):** + `src/protocol.rs` — the `PROTOCOL_VERSION == 22` tripwire (renamed + `protocol_version_is_twenty_three_for_minibuffer_prompt_rows`) and + `supported_protocol_versions_resume_ladder_on_v6_floor`'s + accepted/rejected ranges; `tests/statusline_segments_acceptance.rs` + (version + supported range + the `!supported` ceiling); + `tests/bottom_panel_stage2b_gpu_acceptance.rs`; + `tests/vterm_stage3_acceptance.rs`. **No `ADVERTISED_PROTOCOL_VERSION` + assertion fired**, which is the pin doing its job. +- **No cross-version cache test, deliberately** (framing §3.2/§6): + `SemanticRenderState::for_peer` bakes the negotiated version in at + attach and is dropped at detach, so a cache cannot span two versions. + A test for an impossible condition passes forever while teaching the + next reader that the hazard is real. ## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 65e7374..ca53c70 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -2598,11 +2598,30 @@ cannot advertise 21 without stranding existing v20 clients before `AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm terminal family; v20 = semantic `SessionBootstrapRequest` plus appended -`InitialTargetResult`; v21 reserves the panel frame/event family. New wire +`InitialTargetResult`; v21 reserves the panel frame/event family; +v22 = `LineWrapFacts`; v23 = `MinibufferPromptRows`. New wire surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS final variant — its own round-trip cannot detect a discriminant shift. +**A SUPERSEDED variant can be frozen rather than widened, and v23 is the +first case.** Discovery Stage 2 needed richer minibuffer rows. +Widening `MinibufferPrompt` in place was not an option — postcard +encodes fields positionally, so every v12–v22 peer would **mis-decode** +the bytes rather than ignore them — and gating the widened form at +`>= 23` would have left those peers with **no minibuffer message at +all**, because there would have been only one variant to gate. +Compatibility requires the old shape to still exist *and still be sent*. +So `MinibufferPrompt` is retained unchanged for `12..=22`, +`MinibufferPromptRows` is appended for `>= 23`, and the daemon gate is a +**range on both sides** so exactly one variant reaches any peer. +Two consequences worth carrying forward: a frozen variant needs a +**literal byte fixture** (`assert_eq!(encoded, LEGACY_BYTES)`), because a +round-trip encodes and decodes with the same types and so freezes +nothing; and the CLOSE message must use the same variant family as the +OPEN, or a session closed by the other family's clear leaves its surface +on screen forever. + **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), `sighelp`. Use these for capability-matrix tests, not real servers. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 52a2a29..cbb58e3 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -44,9 +44,10 @@ use pmacs_protocol::{ CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, - MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, MouseButton as ProtocolMouseButton, - MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment, - StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle, + MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers, + MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind, + SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, + UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, @@ -2259,16 +2260,25 @@ struct SearchPromptLocal { invalid: bool, } -/// The live minibuffer (Q#MB1, protocol v12), mirrored from a -/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw -/// in the bottom band with a caret; `candidates` (a windowed slice) feed -/// the dropdown. +/// The live minibuffer (Q#MB1, protocol v12), mirrored from whichever +/// minibuffer variant this session's negotiated version carries, when +/// its `prompt` was `Some`. The prompt+input draw in the bottom band +/// with a caret; `rows` (a windowed slice) feed the dropdown. +/// +/// **One local shape for two wire variants.** A `>= 23` daemon sends +/// `MinibufferPromptRows` with per-row details; a `12..=22` daemon sends +/// the frozen `MinibufferPrompt` with bare strings, which land here as +/// rows whose `detail` is `None`. Both are live: this binary offers its +/// own `PROTOCOL_VERSION` only when the daemon advertises the current +/// baseline, and echoes an older baseline verbatim — so an older daemon +/// still negotiates an older session, and the legacy arm is reachable +/// rather than dead code. #[derive(Clone, Debug, PartialEq)] struct MinibufferLocal { prompt: String, input: String, cursor: u32, - candidates: Vec, + rows: Vec, selected: Option, total: u32, } @@ -5085,7 +5095,10 @@ impl State { None } // Q#MB1 — the minibuffer prompt/input/candidates. `prompt: - // None` closes it. + // None` closes it. This is the FROZEN legacy variant, which + // only a `12..=22` daemon sends; its candidates carry no + // detail, so they become rows with `detail: None` and render + // exactly as they did before v23. InstanceMessage::MinibufferPrompt { prompt, input, @@ -5098,7 +5111,38 @@ impl State { prompt, input, cursor, - candidates, + rows: candidates + .into_iter() + .map(|label| MinibufferRow { + label, + detail: None, + }) + .collect(), + selected, + total, + }); + self.request_redraw(); + None + } + // Discovery Stage 2 — the v23 rows form of the same surface, + // carrying an optional per-row detail (a command's + // description). `prompt: None` closes it, and the close + // arrives in THIS family because the daemon picks the family + // per peer: a rows session closed by a legacy clear would + // leave the dropdown on screen forever. + InstanceMessage::MinibufferPromptRows { + prompt, + input, + cursor, + rows, + selected, + total, + } => { + self.minibuffer = prompt.map(|prompt| MinibufferLocal { + prompt, + input, + cursor, + rows, selected, total, }); @@ -7619,11 +7663,22 @@ impl State { /// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per /// candidate, best match first. Empty when there are no candidates. + /// + /// Discovery Stage 2: a row with a `detail` renders `label detail`, + /// the same two-space form the completion dropdown already uses. A + /// row without one renders the bare label, so a file-path or + /// buffer-name prompt looks exactly as it did before v23. fn refresh_mb_buffer(&mut self) { - let text = self - .minibuffer - .as_ref() - .map_or_else(String::new, |mb| mb.candidates.join("\n")); + let text = self.minibuffer.as_ref().map_or_else(String::new, |mb| { + mb.rows + .iter() + .map(|row| match row.detail.as_deref() { + Some(detail) => format!("{} {detail}", row.label), + None => row.label.clone(), + }) + .collect::>() + .join("\n") + }); let family = self.resolved_family.clone(); self.mb_buffer.set_text( &mut self.font_system, @@ -7644,7 +7699,7 @@ impl State { let mb = self.minibuffer.as_ref()?; let band_top = status_band_top(self.config.height, self.fm); mb_dropdown_window( - mb.candidates.len(), + mb.rows.len(), mb.selected.map_or(0, |s| s as usize), band_top, self.fm, @@ -10868,6 +10923,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::SearchPrompt { .. } => "SearchPrompt", InstanceMessage::MenuPrompt { .. } => "MenuPrompt", InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt", + InstanceMessage::MinibufferPromptRows { .. } => "MinibufferPromptRows", InstanceMessage::BlockAdornments { .. } => "BlockAdornments", InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::ResourceOffer { .. } => "ResourceOffer", @@ -13766,6 +13822,22 @@ mod tests { // They skip (not fail) when no wgpu adapter is available — a dev box // without working Vulkan, or CI without lavapipe. + /// Detail-free minibuffer rows from bare labels — what a `12..=22` + /// daemon's frozen `MinibufferPrompt` lands as. + fn detailless_rows(labels: I) -> Vec + where + I: IntoIterator, + S: Into, + { + labels + .into_iter() + .map(|label| MinibufferRow { + label: label.into(), + detail: None, + }) + .collect() + } + /// Build a headless `State`, or return `None` and log when there's no /// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set /// (CI, where lavapipe is installed) a missing adapter is a hard @@ -14888,7 +14960,7 @@ mod tests { prompt: "M-x ".to_owned(), input: "find".to_owned(), cursor: 4, - candidates: Vec::new(), + rows: Vec::new(), selected: None, total: 0, }); @@ -15291,7 +15363,7 @@ mod tests { prompt: "M-x ".into(), input: "theme".into(), cursor: 5, - candidates: Vec::new(), + rows: Vec::new(), selected: None, total: 0, }); @@ -15335,7 +15407,7 @@ mod tests { prompt: "M-x ".into(), input: "the".into(), cursor: 3, - candidates: vec!["theme-set".into(), "theme-clear".into()], + rows: detailless_rows(["theme-set", "theme-clear"]), selected: Some(0), total: 2, }); @@ -15357,6 +15429,101 @@ mod tests { ); } + /// Discovery Stage 2: a row's `detail` reaches the shaped dropdown + /// line, and BOTH wire families land in the same local shape. + /// + /// Driven through `apply_attach_message` rather than by assigning + /// `state.minibuffer` — the mapping from wire variant to local row + /// is exactly what this asserts, so constructing the local value + /// would skip the thing under test. The shaped `layout_runs()` text + /// is what glyphon rasterizes, so a description present there is a + /// description on screen. + #[test] + fn a_minibuffer_row_detail_reaches_the_shaped_dropdown_line() { + let Some(mut state) = headless_or_skip(600, 400, "hello") else { + return; + }; + + // The v23 rows form: a row with a detail, and a row without. + let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows { + prompt: Some("M-x ".into()), + input: "buf".into(), + cursor: 3, + rows: vec![ + MinibufferRow { + label: "buffer.save".into(), + detail: Some("Write the buffer to its file".into()), + }, + MinibufferRow { + label: "buffer.kill".into(), + detail: None, + }, + ], + selected: Some(0), + total: 2, + }); + state.refresh_mb_buffer(); + let lines: Vec = state + .mb_buffer + .layout_runs() + .map(|run| run.text.to_owned()) + .collect(); + assert!( + lines + .iter() + .any(|l| l.contains("buffer.save") && l.contains("Write the buffer to its file")), + "the detail must be shaped into the row: {lines:?}" + ); + assert_eq!( + lines + .iter() + .find(|l| l.contains("buffer.kill")) + .map(String::as_str), + Some("buffer.kill"), + "a row with no detail renders the bare label, exactly as before v23: {lines:?}" + ); + + // The frozen `12..=22` form, which an older daemon still sends: + // bare strings become detail-free rows. + let _ = state.apply_attach_message(InstanceMessage::MinibufferPrompt { + prompt: Some("M-x ".into()), + input: "buf".into(), + cursor: 3, + candidates: vec!["buffer.save".into()], + selected: Some(0), + total: 1, + }); + assert_eq!( + state.minibuffer.as_ref().map(|mb| mb.rows.clone()), + Some(vec![MinibufferRow { + label: "buffer.save".into(), + detail: None, + }]), + "the legacy variant lands as a detail-free row" + ); + state.refresh_mb_buffer(); + let legacy: Vec = state + .mb_buffer + .layout_runs() + .map(|run| run.text.to_owned()) + .collect(); + assert_eq!(legacy, vec!["buffer.save".to_owned()]); + + // Either family closes the surface with `prompt: None`. + let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows { + prompt: None, + input: String::new(), + cursor: 0, + rows: Vec::new(), + selected: None, + total: 0, + }); + assert!( + state.minibuffer.is_none(), + "a rows clear closes the surface" + ); + } + #[test] fn headless_diag_face_recolors_band_counter_despite_unchanged_text() { // Acceptance 22 — the round-1 finding-3 bite. The E: counter @@ -15954,7 +16121,7 @@ mod tests { prompt: "P: ".into(), input: String::new(), cursor: 0, - candidates: vec![long.clone(), long.clone()], + rows: detailless_rows([long.clone(), long.clone()]), selected: Some(1), total: 2, }); @@ -16172,7 +16339,7 @@ mod tests { prompt: "M-x ".into(), input: String::new(), cursor: 0, - candidates: (0..30).map(|i| format!("candidate-{i}")).collect(), + rows: detailless_rows((0..30).map(|i| format!("candidate-{i}"))), selected: Some(1), total: 30, }); @@ -17095,7 +17262,7 @@ mod tests { prompt: ":".into(), input: String::new(), cursor: 0, - candidates: Vec::new(), + rows: Vec::new(), selected: None, total: 0, }); diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 9a0a2dc..6976a8c 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -65,11 +65,12 @@ pub use message::{ InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, - MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, - PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, - SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, - is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, - negotiate_capabilities, negotiated_session_version, requested_protocol_version, + MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, + NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, + SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, + StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, + is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, + negotiated_session_version, requested_protocol_version, }; pub use panel::{ MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload, diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index b4c8e7e..8516d4d 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1098,7 +1098,24 @@ pub enum InstanceMessage { /// v12). The minibuffer is a single *global* core instance, so this /// is bufferless; the producer still emits it from the active-buffer /// viewport. `prompt: None` clears the GUI. Cached-compare - /// suppressed like `SearchPrompt`; daemon-gated `>= 12`. + /// suppressed like `SearchPrompt`; daemon-gated `12..=22`. + /// + /// # FROZEN — this variant's encoding must not move + /// + /// Discovery Stage 2 (v23) needed richer rows, and postcard is not + /// self-describing: enum variants encode by index and fields by + /// position, so widening `candidates` in place would make every + /// v12–v22 peer **mis-decode** these bytes rather than ignore them. + /// Gating the widened shape at `>= 23` would not rescue them either + /// — with only one variant to send, they would receive no minibuffer + /// message at all. So the rich form went into a new appended + /// variant, [`Self::MinibufferPromptRows`], and this one is retained + /// unchanged as what a `12..=22` peer receives. + /// + /// Its bytes are pinned literally by + /// `minibuffer_prompt_v12_wire_bytes_are_frozen` in + /// `src/protocol.rs` — a round-trip cannot detect a field addition, + /// because both sides simply learn the new shape. MinibufferPrompt { /// The prompt string (e.g. `"M-x "`), or `None` when no /// minibuffer is open. @@ -1299,6 +1316,59 @@ pub enum InstanceMessage { /// Whether that buffer's long lines wrap. wrap: bool, }, + /// Discovery Stage 2 (protocol v23): the minibuffer prompt with + /// **structured rows** — a label and an optional one-line detail — + /// instead of bare candidate strings. + /// + /// # Why a second variant rather than a wider `MinibufferPrompt` + /// + /// `Command.description` already exists and is already rendered by + /// `help.list-commands`; it is missing at the one moment it would + /// change a decision, which is the `M-x` row. Carrying it means + /// widening the minibuffer's candidate shape — and postcard encodes + /// fields **positionally**, so changing `candidates: Vec` in + /// place is a wire break, not an evolution: a v22 peer mis-decodes + /// the bytes rather than skipping them. Gating the changed variant + /// at `>= 23` does not rescue it either, because a `12..=22` peer + /// would then receive no minibuffer message at all. Compatibility + /// requires the old shape to still exist *and still be sent*, so + /// [`Self::MinibufferPrompt`] is frozen and this is appended beside + /// it. + /// + /// # Exactly one of the two reaches any peer + /// + /// The producer selects on the session's negotiated version and the + /// daemon's write loop gates both directions: `>= 23` receives this + /// and never the legacy variant; `12..=22` receives the legacy + /// variant and never this. Sending both would double-render; sending + /// neither is the bug gating alone would have caused. The close + /// message must use the same family as the open — a rows session + /// closed by a legacy clear leaves a popup on screen forever. + /// + /// Otherwise this mirrors [`Self::MinibufferPrompt`] exactly: + /// bufferless (one global core minibuffer), `prompt: None` clears + /// the GUI, cached-compare suppressed, emitted from the + /// active-buffer viewport. + /// + /// Appended after [`Self::LineWrapFacts`], the final v22 variant, so + /// no existing postcard discriminant moves. + MinibufferPromptRows { + /// The prompt string (e.g. `"M-x "`), or `None` when no + /// minibuffer is open. + prompt: Option, + /// The text typed so far. + input: String, + /// Codepoints before the cursor within `input` (the caret + /// position). + cursor: u32, + /// A windowed slice of the completion candidates (best-first, + /// already filtered/sorted by the core), `<= MB_VISIBLE`. + rows: Vec, + /// Highlighted row *within* `rows`, or `None`. + selected: Option, + /// Total candidate count (the window is a slice of this). + total: u32, + }, } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1394,6 +1464,35 @@ pub struct CompletionPopupRow { pub detail: Option, } +/// One row of the minibuffer's candidate list on the wire +/// ([`InstanceMessage::MinibufferPromptRows`], Discovery Stage 2, +/// protocol v23). +/// +/// # Why this is not `CompletionPopupRow` +/// +/// Reuse was tempting and is wrong. [`CompletionPopupRow::kind`] is an +/// LSP `CompletionItemKind` code with a documented contract, and an +/// `M-x` command is not an LSP completion item — it has no honest value +/// for that field. Reusing it would mean inventing a fake kind or +/// declaring unknown everywhere: a type whose invariant is "meaningless +/// in half its uses". If a category is wanted later it arrives with +/// `Command.category`, typed as what it actually is rather than +/// borrowed from LSP. +/// +/// `detail` is optional **per row** because `pmacs.minibuffer.read` +/// serves many sources — file paths, buffer names, settings — and only +/// some have a natural detail. A source with none leaves it `None` and +/// renders exactly as it did before v23. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct MinibufferRow { + /// Display label — the candidate itself, and the value acceptance + /// resolves to. + pub label: String, + /// Optional one-line detail rendered after the label (a command's + /// description, for `M-x`). + pub detail: Option, +} + /// Flat selection state for the wire. /// /// Mirrors [`crate::window::Selection`] but as a self-contained pair @@ -1731,7 +1830,17 @@ pub enum ResourceBody { /// directions: a v20 peer neither receives `PanelFrame` nor is placed in /// a side window, because denying only the events would leave its /// window invisible. -pub const PROTOCOL_VERSION: u32 = 22; +/// +/// Discovery Stage 2: bumped 22 → 23 for +/// [`InstanceMessage::MinibufferPromptRows`] — the minibuffer's +/// candidate rows gaining an optional per-row detail. Appended after +/// `LineWrapFacts`, the final v22 variant, so no existing discriminant +/// moves; [`InstanceMessage::MinibufferPrompt`] is retained **frozen** +/// and still sent to `12..=22` peers, because postcard's positional +/// encoding makes an in-place widening a wire break rather than an +/// evolution, and gating the widened form would have left those peers +/// with no minibuffer message at all. +pub const PROTOCOL_VERSION: u32 = 23; /// Protocol version placed in the daemon's server-first [`Hello`]. /// @@ -1905,8 +2014,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 { /// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend /// negotiates v21, never receives the variant, and keeps its own /// behavior. +/// +/// Discovery Stage 2: extended to `[6, ..., 23]` for +/// [`InstanceMessage::MinibufferPromptRows`]. Additive and daemon-gated, +/// and unusually the gate is a **range on both sides**: a `12..=22` peer +/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a +/// `>= 23` peer receives only the rows form, and no peer ever receives +/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[ - 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, ]; /// T M10.5: predicate for the handshake check. Returns `true` if diff --git a/src/daemon.rs b/src/daemon.rs index fff5e21..e5c5a94 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1420,9 +1420,23 @@ fn dispatcher_loop( let peer_knows_menu_prompt = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 11); - let peer_knows_minibuffer_prompt = session_registry - .session_state(*fid) - .is_some_and(|s| s.negotiated_protocol_version >= 12); + // Q#MB1 / Discovery Stage 2 — the minibuffer is the one + // surface with TWO live variants, and the gate is a + // RANGE on both sides rather than a floor. The legacy + // `MinibufferPrompt` is frozen and belongs to `12..=22`; + // `MinibufferPromptRows` belongs to `>= 23`. Writing the + // legacy gate as a bare `>= 12` would let a v23 peer + // receive both and double-render its dropdown. + let peer_knows_minibuffer_prompt = + session_registry.session_state(*fid).is_some_and(|s| { + (12..crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION) + .contains(&s.negotiated_protocol_version) + }); + let peer_knows_minibuffer_rows = + session_registry.session_state(*fid).is_some_and(|s| { + s.negotiated_protocol_version + >= crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION + }); // UX gutter — `LineNumbers` carries a `LineNumberMode` since // v14 (was `enabled: bool` in v13); a peer below 14 keeps // its gutter off rather than mis-decoding the wider shape. @@ -1470,12 +1484,24 @@ fn dispatcher_loop( continue; } // Q#MB1 — MinibufferPrompt gated at v12; a v11 peer - // simply can't render the GUI minibuffer. + // simply can't render the GUI minibuffer. Discovery + // Stage 2 closed the range at the top: a v23 peer + // gets the rows form instead, never both. if !peer_knows_minibuffer_prompt && matches!(msg, InstanceMessage::MinibufferPrompt { .. }) { continue; } + // Discovery Stage 2 — MinibufferPromptRows gated at + // v23. A `12..=22` peer keeps the frozen legacy + // variant above, which is why gating alone was never + // enough: with one variant it would have lost the + // minibuffer entirely. + if !peer_knows_minibuffer_rows + && matches!(msg, InstanceMessage::MinibufferPromptRows { .. }) + { + continue; + } if !peer_knows_line_numbers && matches!(msg, InstanceMessage::LineNumbers { .. }) { diff --git a/src/editor.rs b/src/editor.rs index 8d80a6c..7050023 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4732,7 +4732,10 @@ pub fn paint_frame( paint_search_prompt(grid, core, term_size, &theme); None } else if core.minibuffer.is_active() { - Some(paint_minibuffer(grid, core, term_size, &theme)) + // The command registry is a separate `RefCell` from the core, so + // this borrow does not contend with the one held above. + let commands = state.lua_host.commands().borrow(); + Some(paint_minibuffer(grid, core, &commands, term_size, &theme)) } else { None }; @@ -5464,9 +5467,42 @@ fn minibuffer_style(theme: &crate::highlight::Theme) -> crate::cell::Style { }) } +/// The inline candidate suffix for the minibuffer's bottom row, given +/// the columns still free after the prompt and the typed input. +/// +/// Discovery Stage 2 §3.4 — three ORDERED steps, and the guarantee is +/// **"never a partial name"**, not "the name always survives". The +/// latter is unachievable: the prompt and the typed input consume the +/// budget first, so the remainder can be too small even for the bare +/// name. +/// +/// 1. If the whole name does not fit, emit **nothing**. A truncated +/// `[buffer.sa…]` is worse than no suffix, because it reads as a +/// different command. +/// 2. Only once the whole name fits is a description attempted. +/// 3. If the description does not fit whole, drop it — leaving exactly +/// today's `[name]`. No ellipsis stub. +/// +/// Measured in `char`s, matching the painter below: it writes one cell +/// per `char`. +fn minibuffer_candidate_suffix(name: &str, detail: Option<&str>, remaining: u32) -> String { + let bare = format!(" [{name}]"); + if bare.chars().count() as u32 > remaining { + return String::new(); + } + if let Some(detail) = detail.map(str::trim).filter(|d| !d.is_empty()) { + let full = format!(" [{name} — {detail}]"); + if full.chars().count() as u32 <= remaining { + return full; + } + } + bare +} + fn paint_minibuffer( grid: &mut crate::cell::CellGrid<'_>, core: &EditorCore, + commands: &crate::command::CommandRegistry, term_size: crate::cell::CellSize, theme: &crate::highlight::Theme, ) -> u32 { @@ -5477,12 +5513,6 @@ fn paint_minibuffer( .expect("called only when active"); let prompt = &session.prompt; let contents = core.minibuffer.contents(); - let mut suffix = String::new(); - if let Some(idx) = session.selected - && let Some(cand) = session.candidates.get(idx) - { - suffix = format!(" [{cand}]"); - } let row = term_size.rows - 1; let mut col: u32 = 0; let mut written: u32 = 0; @@ -5537,6 +5567,26 @@ fn paint_minibuffer( cursor_col = prompt_end; } + // Discovery Stage 2 (§3.4): the selected candidate's suffix now + // carries the command's DESCRIPTION, read from the registry + // in-process. The grid TUI never consumes `MinibufferPrompt` — it + // paints from `core.minibuffer` — so this half of the lane involves + // no wire at all and is independent of the v23 bump. + // + // Q#D2-2: only the command source has a detail. A file-path or + // buffer-name prompt renders exactly as it did before. + let suffix = match session.selected.and_then(|idx| session.candidates.get(idx)) { + Some(cand) => { + let detail = matches!( + session.source, + crate::minibuffer::CompletionSource::Commands + ) + .then(|| commands.get(cand).map(|c| c.description.as_str())) + .flatten(); + minibuffer_candidate_suffix(cand, detail, max.saturating_sub(col)) + } + None => String::new(), + }; for ch in suffix.chars() { if col >= max { break; diff --git a/src/frontend.rs b/src/frontend.rs index 1d04e9f..60d7449 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -406,6 +406,10 @@ impl Frontend { // surface; the TUI paints the minibuffer via its own bottom // row, so it drops this silently too. | InstanceMessage::MinibufferPrompt { .. } + // Discovery Stage 2 — the v23 rows form of the same surface. + // The TUI reads `Command.description` from the registry + // in-process instead, so this reaches it not at all. + | InstanceMessage::MinibufferPromptRows { .. } // UX gutter — LineNumbers is the semantic-frontend gutter // toggle; the cell-grid TUI reads its window's mode directly, // so it drops this silently like the other semantic families. diff --git a/src/protocol.rs b/src/protocol.rs index 1a1c723..33beee0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_two_for_line_wrap_facts() { + fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1732,7 +1732,15 @@ mod tests { // daemon-gated, appended after the final v21 variant). The // GPU lays out locally and would otherwise never hear the wrap // setting; the advertised baseline is deliberately unmoved. - assert_eq!(PROTOCOL_VERSION, 22); + // Discovery Stage 2 bumps 22→23 (`InstanceMessage:: + // MinibufferPromptRows`, daemon-gated, appended after the final + // v22 variant). The first bump to leave the SUPERSEDED variant + // live rather than widening it: postcard is positional, so + // widening `MinibufferPrompt` would break every v12–v22 peer, + // and gating the wider form would have left them with no + // minibuffer at all. `MinibufferPrompt` is therefore frozen and + // pinned by literal bytes below. + assert_eq!(PROTOCOL_VERSION, 23); } #[test] @@ -1809,17 +1817,18 @@ mod tests { // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v20 (semantic initial-target bootstrap), v21 (the bottom - // panel band), and v22 (`LineWrapFacts`) all interoperate. - for accepted in 6..=22 { + // panel band), v22 (`LineWrapFacts`), and v23 + // (`MinibufferPromptRows`) all interoperate. + for accepted in 6..=23 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v22 binary" + "v{rejected} must be rejected by a v23 binary" ); } } @@ -2406,6 +2415,130 @@ mod tests { } } + #[test] + fn minibuffer_prompt_v12_wire_bytes_are_frozen() { + // Discovery Stage 2 (v23) froze `MinibufferPrompt` and put the + // richer shape in an appended `MinibufferPromptRows`. THIS is + // what makes the freeze real, and the round-trip above is not: + // a round-trip encodes and decodes with the SAME types, so + // adding a field to `MinibufferPrompt` leaves it passing while + // every v12–v22 peer in the field mis-decodes the bytes. Only a + // comparison against bytes captured now can fail when the + // encoding changes. + // + // Two fixtures, the two shapes the producer emits: an open + // prompt with a windowed candidate list and a selection, and a + // cleared band. Discriminant 20, then the fields positionally + // (postcard is not self-describing). + let open = InstanceMessage::MinibufferPrompt { + prompt: Some("M-x ".to_owned()), + input: "ed".to_owned(), + cursor: 2, + candidates: vec!["edit.copy".to_owned(), "edit.cut".to_owned()], + selected: Some(1), + total: 7, + }; + assert_eq!( + postcard::to_allocvec(&open).expect("encode open"), + [ + 20, // InstanceMessage::MinibufferPrompt + 1, 4, b'M', b'-', b'x', b' ', // prompt: Some("M-x ") + 2, b'e', b'd', // input: "ed" + 2, // cursor + 2, 9, b'e', b'd', b'i', b't', b'.', b'c', b'o', b'p', b'y', 8, b'e', b'd', b'i', + b't', b'.', b'c', b'u', b't', // candidates + 1, 1, // selected: Some(1) + 7, // total + ], + "MinibufferPrompt's v12 wire bytes changed. It is FROZEN for \ + v12..=22 — a widening here mis-decodes on every already-shipped \ + frontend rather than being ignored. Richer minibuffer rows \ + belong in MinibufferPromptRows." + ); + + let clear = InstanceMessage::MinibufferPrompt { + prompt: None, + input: String::new(), + cursor: 0, + candidates: Vec::new(), + selected: None, + total: 0, + }; + assert_eq!( + postcard::to_allocvec(&clear).expect("encode clear"), + [20, 0, 0, 0, 0, 0, 0], + "MinibufferPrompt's cleared-band v12 wire bytes changed — see the \ + open-prompt fixture above" + ); + } + + #[test] + fn line_wrap_facts_encoding_is_unchanged_by_the_v23_build() { + // Discovery Stage 2 placement pin: `MinibufferPromptRows` must + // be APPENDED after `LineWrapFacts` — the final v22 variant, + // whose ordinal moves if anything is inserted before any v22 + // variant. The new variant's own round-trip cannot detect a + // shift, which is why the pin sits on the PREVIOUS final variant + // (handoff §4). + let msg = InstanceMessage::LineWrapFacts { + buffer_id: pmacs_protocol::BufferId::from_raw(4), + wrap: true, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes, + [29, 4, 1], + "LineWrapFacts' v22 wire bytes changed — a variant was \ + inserted before it; append new InstanceMessage variants \ + at the end" + ); + } + + #[test] + fn minibuffer_prompt_rows_round_trips_and_appends_after_line_wrap_facts() { + // The v23 variant itself: both shapes, a detail present and a + // detail absent (Q#D2-2 — a source with no detail leaves it + // `None` and renders as it always did), plus the cleared band. + let cases = [ + ( + Some("M-x ".to_owned()), + "ed".to_owned(), + 2u32, + vec![ + MinibufferRow { + label: "edit.copy".to_owned(), + detail: Some("Copy the region".to_owned()), + }, + MinibufferRow { + label: "notes.txt".to_owned(), + detail: None, + }, + ], + Some(1u32), + 7u32, + ), + (None, String::new(), 0, Vec::new(), None, 0), + ]; + for (prompt, input, cursor, rows, selected, total) in cases { + let msg = InstanceMessage::MinibufferPromptRows { + prompt: prompt.clone(), + input: input.clone(), + cursor, + rows: rows.clone(), + selected, + total, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes.first(), + Some(&30), + "MinibufferPromptRows must be appended after v22 LineWrapFacts" + ); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(decoded, msg); + } + } + #[test] fn key_event_to_crossterm_round_trips() { // Build a protocol KeyEvent, translate to crossterm, translate diff --git a/src/semantic_render.rs b/src/semantic_render.rs index de9f417..2e62952 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -38,7 +38,7 @@ use crate::cell::{CellSize, Style}; use crate::editor::EditorState; use crate::protocol::{ AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, - FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION, + FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, MinibufferRow, PANEL_MIN_VERSION, StatuslineSegment, StyleSegment, StyleSpan, }; use crate::statusline::{ @@ -95,10 +95,26 @@ type SearchPromptFacts = (Option, Option, u32, bool, bool); /// menu. type MenuPromptFacts = (Vec, Option); -/// Cached `MinibufferPrompt` payload for cached-compare suppression -/// (Q#MB1): `(prompt, input, cursor, candidates-window, selected, total)`. -/// A `None` prompt means the minibuffer is closed. -type MinibufferFacts = (Option, String, u32, Vec, Option, u32); +/// Cached minibuffer payload for cached-compare suppression (Q#MB1): +/// `(prompt, input, cursor, rows-window, selected, total)`. A `None` +/// prompt means the minibuffer is closed. +/// +/// **ONE cache per peer, not one per variant.** [`SemanticRenderState`] +/// is constructed by [`SemanticRenderState::for_peer`] with the +/// session's negotiated version baked in on attach and dropped on +/// detach, so a cache can never span two negotiated versions and a +/// per-variant key would guard nothing. The rows are the cached form +/// either way: for a `12..=22` peer every `detail` is `None` (the +/// producer does not resolve details it cannot ship), so the cache +/// describes exactly what that peer received. +type MinibufferFacts = ( + Option, + String, + u32, + Vec, + Option, + u32, +); /// Cached `CompletionPopup` payload for cached-compare suppression /// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`. @@ -115,10 +131,20 @@ type CompletionPopupFacts = ( /// scrolled window around the selection, not the full (≤1024) list. const MB_VISIBLE: usize = 10; +/// The first protocol version that carries +/// [`InstanceMessage::MinibufferPromptRows`] (Discovery Stage 2). +/// +/// Named rather than written as a literal `23` at each site, and NOT +/// derived from `PROTOCOL_VERSION`: the contract is "the version this +/// variant was introduced at", which is an absolute fact, while +/// `PROTOCOL_VERSION` moves with every later bump. Handoff §5 records +/// five defects of exactly that shape from one previous bump. +pub const MINIBUFFER_ROWS_MIN_VERSION: u32 = 23; + /// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus /// the selection's index *within* that window. Keeps the selected row /// visible as the user cycles a long list. -fn minibuffer_window(candidates: &[String], selected: Option) -> (Vec, Option) { +fn minibuffer_window(candidates: &[T], selected: Option) -> (Vec, Option) { if candidates.is_empty() { return (Vec::new(), None); } @@ -216,10 +242,18 @@ pub struct SemanticRenderState { /// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for /// cached-compare suppression (see [`MenuPromptFacts`]). last_menu_prompt: HashMap, - /// Last emitted `MinibufferPrompt` payload (Q#MB1) — a single value, - /// not per-buffer, because the minibuffer is one global core - /// instance. + /// Last emitted minibuffer payload (Q#MB1) — a single value, not + /// per-buffer, because the minibuffer is one global core instance, + /// and a single value across both wire variants, because this state + /// belongs to one peer at one negotiated version (see + /// [`MinibufferFacts`]). last_minibuffer: Option, + /// Whether the peer negotiated protocol >= 23 (Discovery Stage 2). + /// `true` ⇒ it receives `MinibufferPromptRows` and never the legacy + /// variant; `false` ⇒ the frozen `MinibufferPrompt` and never the + /// rows form. Also gates the per-row detail lookup: a peer that + /// cannot carry a detail does not pay to resolve one. + peer_knows_minibuffer_rows: bool, /// Last emitted `CompletionPopup` payload per buffer (Arc 1a /// Q#C5), for cached-compare suppression (see /// [`CompletionPopupFacts`]). @@ -478,6 +512,7 @@ impl SemanticRenderState { s.peer_knows_theme_facts = negotiated_protocol_version >= 16; s.peer_knows_font_facts = negotiated_protocol_version >= 17; s.peer_knows_line_wrap = negotiated_protocol_version >= 22; + s.peer_knows_minibuffer_rows = negotiated_protocol_version >= MINIBUFFER_ROWS_MIN_VERSION; s.peer_knows_statusline_segments = negotiated_protocol_version >= 18; s.peer_knows_terminal_frames = negotiated_protocol_version >= 19; s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION; @@ -500,6 +535,7 @@ impl SemanticRenderState { last_search_prompt: HashMap::new(), last_menu_prompt: HashMap::new(), last_minibuffer: None, + peer_knows_minibuffer_rows: true, last_completion_popup: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(), @@ -1637,11 +1673,20 @@ impl SemanticRenderState { Some(msg) } - /// The `MinibufferPrompt` message for this frame, or `None` when the + /// The minibuffer message for this frame, or `None` when the /// (global) minibuffer state is unchanged (Q#MB1). Emitted only from /// the active buffer's viewport so the bufferless message ships once /// per frame. Closed = `prompt: None`; first sight while closed stays - /// silent. The daemon keeps the variant off wires negotiated `< 12`. + /// silent. + /// + /// **Exactly one variant, chosen by the peer's negotiated version** + /// (Discovery Stage 2). `>= 23` gets `MinibufferPromptRows` with + /// per-row details; `12..=22` gets the frozen `MinibufferPrompt` + /// carrying bare labels. Because the choice is made here, the CLOSE + /// necessarily uses the same family as the OPEN — a rows session + /// closed by a legacy clear would leave a popup on screen forever. + /// The daemon's write loop gates both directions again as + /// belt-and-braces. fn minibuffer_prompt_msg( &mut self, state: &EditorState, @@ -1662,13 +1707,44 @@ impl SemanticRenderState { .take_while(|(i, _)| *i < cursor_byte) .count() as u32; let total = session.candidates.len() as u32; - let (candidates, selected) = + let (labels, selected) = minibuffer_window(&session.candidates, session.selected); + // Q#D2-2: the detail is per row and optional. Only + // the command source has one today; a file-path or + // buffer-name prompt leaves it `None` and renders + // exactly as it did before v23. Resolved only for a + // peer that can carry it, so the cached facts + // describe what that peer actually received. + let detail_source = self.peer_knows_minibuffer_rows + && matches!( + session.source, + crate::minibuffer::CompletionSource::Commands + ); + let rows = if detail_source { + let commands = state.lua_host.commands().borrow(); + labels + .into_iter() + .map(|label| { + let detail = commands + .get(&label) + .map(|command| command.description.clone()); + MinibufferRow { label, detail } + }) + .collect() + } else { + labels + .into_iter() + .map(|label| MinibufferRow { + label, + detail: None, + }) + .collect() + }; ( Some(session.prompt.clone()), input, cursor, - candidates, + rows, selected, total, ) @@ -1684,13 +1760,24 @@ impl SemanticRenderState { self.last_minibuffer = Some(facts); return None; } - let msg = InstanceMessage::MinibufferPrompt { - prompt: facts.0.clone(), - input: facts.1.clone(), - cursor: facts.2, - candidates: facts.3.clone(), - selected: facts.4, - total: facts.5, + let msg = if self.peer_knows_minibuffer_rows { + InstanceMessage::MinibufferPromptRows { + prompt: facts.0.clone(), + input: facts.1.clone(), + cursor: facts.2, + rows: facts.3.clone(), + selected: facts.4, + total: facts.5, + } + } else { + InstanceMessage::MinibufferPrompt { + prompt: facts.0.clone(), + input: facts.1.clone(), + cursor: facts.2, + candidates: facts.3.iter().map(|row| row.label.clone()).collect(), + selected: facts.4, + total: facts.5, + } }; self.last_minibuffer = Some(facts); Some(msg) @@ -5773,9 +5860,28 @@ mod tests { let short: Vec = vec!["a".into(), "b".into(), "c".into()]; assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2))); // Empty. - assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None)); + assert_eq!( + minibuffer_window::(&[], Some(0)), + (Vec::new(), None) + ); } + /// The v23 rows form: `(prompt, input, rows)`. + fn minibuffer_rows_of( + msgs: &[InstanceMessage], + ) -> Option<(Option, String, Vec)> { + msgs.iter().find_map(|m| match m { + InstanceMessage::MinibufferPromptRows { + prompt, + input, + rows, + .. + } => Some((prompt.clone(), input.clone(), rows.clone())), + _ => None, + }) + } + + /// The frozen `12..=22` form: `(prompt, input, candidates)`. fn minibuffer_prompt_of( msgs: &[InstanceMessage], ) -> Option<(Option, String, Vec)> { @@ -5798,7 +5904,7 @@ mod tests { s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); // No minibuffer: the producer stays silent on first sight. - assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none()); + assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none()); // Open an `M-x` prompt (command completion) via the Lua API. state @@ -5807,28 +5913,133 @@ mod tests { .load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }") .exec() .expect("open minibuffer"); - let (prompt, input, cands) = - minibuffer_prompt_of(&s.render_frame(&state)).expect("minibuffer prompt emitted"); + let (prompt, input, rows) = + minibuffer_rows_of(&s.render_frame(&state)).expect("minibuffer prompt emitted"); assert_eq!(prompt.as_deref(), Some("M-x ")); assert_eq!(input, ""); // Empty input matches every command; the wire carries a window. - assert!(!cands.is_empty(), "M-x seeds command candidates"); - assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed"); + assert!(!rows.is_empty(), "M-x seeds command candidates"); + assert!(rows.len() <= MB_VISIBLE, "candidates ship windowed"); // Unchanged → suppressed (cached-compare). - assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none()); + assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none()); - // Cancel: the prompt clears (None). + // Cancel: the prompt clears (None), in the SAME family as the + // open — a rows session closed by a legacy clear would leave the + // dropdown on screen forever. state .lua_host .lua() .load("pmacs.minibuffer.cancel()") .exec() .expect("cancel"); - let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted"); + let frame = s.render_frame(&state); + assert!( + minibuffer_prompt_of(&frame).is_none(), + "a v23 peer must never see the legacy variant, not even to close" + ); + let (prompt, _, _) = minibuffer_rows_of(&frame).expect("clear emitted"); assert!(prompt.is_none(), "cancel clears the minibuffer band"); } + #[test] + fn a_v22_peer_gets_the_frozen_variant_and_a_v23_peer_gets_rows_with_details() { + // The producer half of the exclusivity guarantee, at the two + // versions that straddle the boundary. The real-daemon half — + // two sessions negotiating simultaneously — is in + // `tests/discovery_stage2_acceptance.rs`. + let state = empty_state(); + let bid = active_buffer(&state); + state + .lua_host + .lua() + .load( + "pmacs.command.define{ name = 'mb.probe', description = 'Probe the row detail.', \ + fn = function() end }", + ) + .exec() + .expect("define probe command"); + + let mut v22 = SemanticRenderState::for_peer(FrontendId::LOCAL, 22); + let mut v23 = SemanticRenderState::for_peer(FrontendId::LOCAL, 23); + for s in [&mut v22, &mut v23] { + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); + } + + state + .lua_host + .lua() + .load( + "pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', \ + on_accept = function() end }", + ) + .exec() + .expect("open minibuffer"); + state + .lua_host + .lua() + .load("pmacs.minibuffer.set_contents('mb.probe')") + .exec() + .expect("narrow to the probe command"); + + let v22_frame = v22.render_frame(&state); + assert!( + minibuffer_rows_of(&v22_frame).is_none(), + "a v22 peer must never receive the v23 rows variant" + ); + let (_, _, candidates) = + minibuffer_prompt_of(&v22_frame).expect("v22 gets the frozen variant"); + assert!( + candidates.iter().any(|c| c == "mb.probe"), + "the frozen variant still carries the candidate names: {candidates:?}" + ); + + let v23_frame = v23.render_frame(&state); + assert!( + minibuffer_prompt_of(&v23_frame).is_none(), + "a v23 peer must never receive the frozen variant" + ); + let (_, _, rows) = minibuffer_rows_of(&v23_frame).expect("v23 gets the rows variant"); + let probe = rows + .iter() + .find(|r| r.label == "mb.probe") + .expect("the probe command is a candidate"); + assert_eq!( + probe.detail.as_deref(), + Some("Probe the row detail."), + "the row carries the command's registered description" + ); + } + + #[test] + fn a_source_with_no_detail_ships_rows_with_none() { + // Q#D2-2: only the command source has a detail today. A + // buffer-name prompt leaves it `None`, and the GPU then renders + // exactly what it rendered before v23. + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); + + state + .lua_host + .lua() + .load( + "pmacs.minibuffer.read{ prompt = 'Buffer: ', source = 'buffers', \ + on_accept = function() end }", + ) + .exec() + .expect("open buffer prompt"); + let (_, _, rows) = minibuffer_rows_of(&s.render_frame(&state)).expect("prompt emitted"); + assert!(!rows.is_empty(), "the buffer registry seeds candidates"); + assert!( + rows.iter().all(|r| r.detail.is_none()), + "a source with no detail leaves every row's detail None: {rows:?}" + ); + } + #[test] fn status_facts_emit_on_change_and_freeze_counts_while_stale() { let state = empty_state(); diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index 257680a..32c197d 100644 --- a/tests/bottom_panel_stage2b_gpu_acceptance.rs +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -337,8 +337,9 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { #[test] fn the_baseline_stays_and_the_counter_offer_activates() { // A deliberate tripwire: bumping the wire must be a conscious edit - // here, not a silent one. v22 is `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 22); + // here, not a silent one. v23 is `MinibufferPromptRows` (Discovery + // Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 23); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, "moving this is the incompatible act the mechanism exists to avoid" @@ -352,10 +353,10 @@ fn the_baseline_stays_and_the_counter_offer_activates() { // This replaces `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)`, // which asserted a **coincidence**: panel frames were the newest // feature when it was written, so their minimum happened to equal - // the current wire. Any later feature falsifies that — v22 is the - // first, and the equality would have had to be edited on every - // subsequent bump while telling a reader something that was never - // the contract. + // the current wire. Any later feature falsifies that — v22 was the + // first and v23 the second, and the equality would have had to be + // edited on every subsequent bump while telling a reader something + // that was never the contract. // `const` blocks, matching the line above: these are compile-time // constants, so a runtime `assert!` is both a clippy error and a // weaker check than the language already offers. diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs new file mode 100644 index 0000000..f290368 --- /dev/null +++ b/tests/discovery_stage2_acceptance.rs @@ -0,0 +1,537 @@ +// discovery_stage2_acceptance.rs --- Discovery Stage 2 +// (docs/discovery-stage2-framing.md §6). + +//! `M-x` rows stop being bare names. +//! +//! `Command.description` already existed and was already rendered by +//! `help.list-commands`; it was missing at the one moment it would +//! change a decision. Carrying it to the row is two independent halves, +//! and this suite keeps them separate because they fail separately: +//! +//! - **The wire half** is a protocol bump, v22 → v23, and it is +//! *additive*. `MinibufferPrompt` is FROZEN and still sent to every +//! `12..=22` peer, because postcard encodes fields positionally — a +//! widened `candidates` would make those peers mis-decode rather than +//! ignore, and gating the widened form would have left them with no +//! minibuffer message at all. The rich shape lives in an appended +//! `MinibufferPromptRows`, and **exactly one of the two reaches any +//! peer, ever**. +//! - **The TUI half involves no wire at all.** `src/editor.rs` contains +//! zero references to `MinibufferPrompt`: `paint_minibuffer` reads +//! `core.minibuffer` directly and renders the selected candidate as an +//! inline suffix. So it reads `Command.description` from the registry +//! in-process, which is why this half is independent of the bump. +//! +//! The daemon fixtures are `crdt`-gated because a semantic session is +//! necessarily a text replica: a non-CRDT build advertises no +//! `semantic_render` and cannot host one. They run in the +//! `--features crdt` sweep that `scripts/gate --protocol` adds. + +mod common; + +use std::path::Path; + +use pmacs::bootstrap::BootstrapRoots; +use pmacs::editor::EditorState; +use pmacs_protocol::{ + ADVERTISED_PROTOCOL_VERSION, PROTOCOL_VERSION, is_supported_protocol_version, +}; + +#[cfg(feature = "crdt")] +use std::os::unix::net::UnixStream; +#[cfg(feature = "crdt")] +use std::time::{Duration, Instant}; + +#[cfg(feature = "crdt")] +use pmacs_protocol::cell::CellSize; +#[cfg(feature = "crdt")] +use pmacs_protocol::message::{ + AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent, + Modifiers, SessionBootstrapRequest, +}; +#[cfg(feature = "crdt")] +use pmacs_protocol::transport::{read_message, write_message}; +#[cfg(feature = "crdt")] +use pmacs_protocol::{ByteRange, MinibufferRow}; + +#[cfg(feature = "crdt")] +use common::daemon::{TestDaemon, build_default_caps}; + +// --------------------------------------------------------------------------- +// Version-bump discipline (§6, last bullet) +// --------------------------------------------------------------------------- + +/// The bump is deliberate, and the advertised baseline does NOT move. +/// +/// `ADVERTISED_PROTOCOL_VERSION` is pinned at 20 and is the one constant +/// that must never be edited (handoff §3/§5): the handshake is +/// server-first, so moving it locks out every already-shipped frontend +/// before it can counter-offer. An additive family never needs it. +#[test] +fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() { + assert_eq!( + PROTOCOL_VERSION, 23, + "v23 is MinibufferPromptRows (Discovery Stage 2)" + ); + assert_eq!( + ADVERTISED_PROTOCOL_VERSION, 20, + "moving this is the incompatible act the counter-offer mechanism exists to avoid" + ); + // The whole v12..=22 population this lane is compatible with is + // still supported, and the set ends at the new wire — a widened set + // is a failure rather than a silent pass. + for version in 6..=23 { + assert!( + is_supported_protocol_version(version), + "v{version} must still be supported" + ); + } + assert!(!is_supported_protocol_version(24)); +} + +// --------------------------------------------------------------------------- +// The TUI half: no wire involvement (§3.4, §6) +// --------------------------------------------------------------------------- + +fn session(name: &str) -> EditorState { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("discovery-stage2") + .join(name); + let _ = std::fs::remove_dir_all(&base); + let roots = BootstrapRoots::isolated_under(&base); + for (_, dir) in roots.child_env() { + std::fs::create_dir_all(&dir).expect("create controlled root"); + } + let state = EditorState::new_with_roots(&roots); + state.install_state_dirs(); + state +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Render one frame at `cols` columns and return the bottom row's text. +/// +/// Through `RenderState` and the wire rather than by calling the painter +/// directly: the spans are what the TUI actually consumes, so this +/// asserts on the cells that reach a screen. +fn bottom_row(s: &EditorState, rows: u32, cols: u32) -> String { + use std::collections::HashMap; + + let size = pmacs::cell::CellSize::new(rows, cols); + let mut rs = pmacs::instance_render::RenderState::new(size); + let msgs = rs.render_frame(s, pmacs::protocol::FrontendId::LOCAL, &HashMap::new(), &[]); + + let mut row = vec![' '; cols as usize]; + for msg in &msgs { + if let pmacs_protocol::InstanceMessage::CellDelta { spans, .. } = msg { + for span in spans { + if span.start.row != rows - 1 { + continue; + } + for (i, cell) in span.cells.iter().enumerate() { + let c = span.start.col as usize + i; + if c < cols as usize + && let pmacs::cell::Glyph::Char(ch) = cell.glyph + { + row[c] = ch; + } + } + } + } + } + row.into_iter().collect::().trim_end().to_owned() +} + +/// Open `M-x`, narrowed to exactly one command with a known +/// description, and report the bottom row at `cols` columns. +fn mx_bottom_row(s: &EditorState, cols: u32) -> String { + exec( + s, + "pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }", + ); + exec(s, "pmacs.minibuffer.set_contents('zzprobe')"); + bottom_row(s, 24, cols) +} + +const PROBE_DESCRIPTION: &str = "Probe the description row."; + +fn define_probe(s: &EditorState) { + exec( + s, + &format!( + "pmacs.command.define{{ name = 'zzprobe', description = '{PROBE_DESCRIPTION}', \ + fn = function() end }}" + ), + ); +} + +#[test] +fn the_tui_renders_the_description_beside_the_selected_name() { + let s = session("tui-wide"); + define_probe(&s); + let row = mx_bottom_row(&s, 120); + assert!( + row.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")), + "the selected candidate carries its description: {row:?}" + ); +} + +#[test] +fn the_tui_drops_the_description_then_the_whole_suffix_as_width_shrinks() { + // §3.4's three ORDERED steps, at the three widths that separate + // them. The guarantee is "never a PARTIAL name", which is + // achievable; "the name always survives" is not, because the prompt + // and the typed input consume the budget first. + let s = session("tui-clip"); + define_probe(&s); + + // 1. Wide: name + description. + let wide = mx_bottom_row(&s, 120); + assert!( + wide.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")), + "wide: {wide:?}" + ); + + // 2. Room for the whole name but not the whole description: the + // description is dropped, leaving exactly today's `[name]`. No + // ellipsis stub, and no prefix of the description either. + let medium = mx_bottom_row(&s, 30); + assert!(medium.contains("[zzprobe]"), "medium: {medium:?}"); + assert!( + !medium.contains('—'), + "a description that does not fit whole is dropped entirely: {medium:?}" + ); + + // 3. Too narrow for even the whole name: the suffix vanishes. The + // assertion is that no PREFIX of the name is emitted — `[zzpr` + // would read as a different command, which is worse than nothing. + let narrow = mx_bottom_row(&s, 18); + assert!( + !narrow.contains('['), + "a suffix that cannot hold the whole name is omitted entirely: {narrow:?}" + ); + assert!( + narrow.starts_with("M-x zzprobe"), + "the prompt and the typed input still own the row: {narrow:?}" + ); + for cut in 1.."zzprobe".len() { + assert!( + !narrow.contains(&format!("[{}", &"zzprobe"[..cut])), + "no prefix of the name may be emitted: {narrow:?}" + ); + } +} + +#[test] +fn a_source_with_no_detail_renders_exactly_as_before_in_the_tui() { + // Q#D2-2: the file-path prompt is the witness. It has no detail, so + // its suffix is the pre-v23 `[name]` and nothing else. + let s = session("tui-files"); + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("discovery-stage2-files"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create file-prompt dir"); + std::fs::write(dir.join("zznotes.txt"), b"x").expect("seed a file"); + exec( + &s, + &format!( + "pmacs.minibuffer.read{{ prompt = 'File: ', source = 'files', \ + source_root = '{}', on_accept = function() end }}", + dir.display() + ), + ); + exec(&s, "pmacs.minibuffer.set_contents('zznotes.txt')"); + let row = bottom_row(&s, 24, 120); + assert!(row.contains("[zznotes.txt]"), "file prompt row: {row:?}"); + assert!( + !row.contains('—'), + "a source with no detail gains no separator: {row:?}" + ); +} + +#[test] +fn typed_but_unmatched_input_is_still_accepted() { + // Q#D2-5, the trap this lane arrives with: richer rows make `M-x` + // LOOK like a closed set, which invites making acceptance reject + // unmatched input. That would be a behaviour change, and it is out + // of scope. `resolve_accepted_value` still returns the literal typed + // text when nothing is selected. + let s = session("open-set"); + exec( + &s, + "_G.ACCEPTED = nil + pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', + on_accept = function(v) _G.ACCEPTED = v end }", + ); + exec( + &s, + "pmacs.minibuffer.set_contents('no-such-command-at-all')", + ); + assert_eq!( + eval::(&s, "return #pmacs.minibuffer.candidates()"), + 0, + "the probe input must match nothing, or this asserts the wrong thing" + ); + exec(&s, "pmacs.minibuffer.accept()"); + assert_eq!( + eval::(&s, "return _G.ACCEPTED"), + "no-such-command-at-all", + "completion is assistance, not validation" + ); +} + +// --------------------------------------------------------------------------- +// The wire half: one real daemon, two negotiated versions (§6) +// --------------------------------------------------------------------------- + +/// An `init.lua` that registers the probe command whose description the +/// wire must carry. +#[cfg(feature = "crdt")] +const PROBE_INIT: &str = r#" +pmacs.command.define { + name = "zzprobe", + description = "Probe the description row.", + fn = function() end, +} +"#; + +/// A minibuffer message, in whichever family it arrived. +#[cfg(feature = "crdt")] +#[derive(Debug)] +enum Mb { + Legacy { + prompt: Option, + candidates: Vec, + }, + Rows { + prompt: Option, + rows: Vec, + }, +} + +#[cfg(feature = "crdt")] +fn semantic_caps() -> FrontendCapabilities { + FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..build_default_caps() + } +} + +/// Attach a semantic session offering exactly `offer`, declare a +/// viewport so the projection producer is live, and hand back the +/// stream plus this session's frontend id. +#[cfg(feature = "crdt")] +fn attach_semantic(daemon: &TestDaemon, offer: u32) -> (UnixStream, pmacs_protocol::FrontendId) { + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set read timeout"); + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + assert_eq!( + hello.protocol_version, ADVERTISED_PROTOCOL_VERSION, + "the server-first Hello must stay at the compatibility baseline" + ); + let fid = hello.assigned_frontend_id; + write_message( + &mut stream, + &AttachRequest { + protocol_version: offer, + frontend_capabilities: semantic_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write AttachRequest"); + // A v20-or-later semantic session sends the bootstrap envelope; the + // daemon reads it unconditionally for those, so skipping it would + // desynchronize the stream. + if offer >= 20 { + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: None, + }, + ) + .expect("write bootstrap"); + } + let document = pump(&mut stream, "first BufferSnapshot", |msg| match msg { + InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id), + _ => None, + }); + write_message( + &mut stream, + &FrontendEvent::Viewport { + frontend_id: fid, + buffer_id: document, + visible: ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ) + .expect("declare a viewport"); + (stream, fid) +} + +#[cfg(feature = "crdt")] +fn pump( + stream: &mut UnixStream, + what: &str, + mut want: impl FnMut(&InstanceMessage) -> Option, +) -> T { + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + match read_message::(stream) { + Ok(msg) => { + if let Some(found) = want(&msg) { + return found; + } + } + Err(error) => panic!("{what}: read stopped: {error}"), + } + } + panic!("timed out waiting for {what}"); +} + +/// Collect every minibuffer message this session receives, up to and +/// including the first one `done` accepts. +/// +/// Collecting rather than filtering is the point: "a v23 peer receives +/// the rows form" is only half the guarantee, and the other half — that +/// it never receives the legacy form — can only be checked against +/// everything that arrived. +#[cfg(feature = "crdt")] +fn collect_minibuffer( + stream: &mut UnixStream, + what: &str, + mut done: impl FnMut(&Mb) -> bool, +) -> Vec { + let mut seen = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + match read_message::(stream) { + Ok(InstanceMessage::MinibufferPrompt { + prompt, candidates, .. + }) => { + seen.push(Mb::Legacy { prompt, candidates }); + } + Ok(InstanceMessage::MinibufferPromptRows { prompt, rows, .. }) => { + seen.push(Mb::Rows { prompt, rows }); + } + Ok(_) => continue, + Err(error) => panic!("{what}: read stopped: {error}"), + } + if done(seen.last().expect("just pushed")) { + return seen; + } + } + panic!("timed out waiting for {what}; saw {seen:?}"); +} + +#[cfg(feature = "crdt")] +fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key, mods: Modifiers) { + write_message( + stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: fid, + key, + mods, + timestamp_ns: 0, + }), + ) + .expect("write key"); +} + +/// The whole exclusivity guarantee, on one live daemon: a v22 peer and a +/// v23 peer attached **simultaneously** each receive their own variant +/// and only their own — open and close alike. +/// +/// One daemon rather than two, and both directions in one fixture. Two +/// daemons could each pass their own half while the same build was +/// incapable of serving both, which is the only property that matters; +/// and a test that only proved "v23 gets rows" would pass with the +/// compatibility half broken. +#[cfg(feature = "crdt")] +#[test] +fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() { + let daemon = TestDaemon::spawn_with_config(PROBE_INIT); + + // The compatibility half attaches FIRST, deliberately: it is the + // half an over-eager bump destroys, so a regression fails here + // rather than after the interesting half has already passed. + let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); + let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); + assert_eq!(PROTOCOL_VERSION, 23); + + // Open the real `M-x` through the real key path, then narrow to the + // probe command by typing it — the candidate window is ten rows out + // of well over a hundred commands, so an unnarrowed prompt would + // assert nothing about the probe. + send_key(&mut current, current_fid, Key::Char('x'), Modifiers::ALT); + for ch in "zzprobe".chars() { + send_key(&mut current, current_fid, Key::Char(ch), Modifiers::NONE); + } + + let on_current = collect_minibuffer(&mut current, "v23 open", |mb| match mb { + Mb::Rows { prompt, rows } => { + prompt.is_some() && rows.iter().any(|row| row.label == "zzprobe") + } + Mb::Legacy { .. } => false, + }); + assert!( + on_current.iter().all(|mb| matches!(mb, Mb::Rows { .. })), + "a v23 peer must never receive the frozen legacy variant: {on_current:?}" + ); + let Some(Mb::Rows { rows, .. }) = on_current.last() else { + unreachable!("collect_minibuffer returns on a Rows match") + }; + let probe = rows + .iter() + .find(|row| row.label == "zzprobe") + .expect("the probe command is a candidate"); + assert_eq!( + probe.detail.as_deref(), + Some(PROBE_DESCRIPTION), + "the description reaches the row through the real prompt path" + ); + + // The same session state, seen by the v22 peer, in the frozen shape. + let on_legacy = collect_minibuffer(&mut legacy, "v22 open", |mb| match mb { + Mb::Legacy { prompt, candidates } => { + prompt.is_some() && candidates.iter().any(|c| c == "zzprobe") + } + Mb::Rows { .. } => false, + }); + assert!( + on_legacy.iter().all(|mb| matches!(mb, Mb::Legacy { .. })), + "a v22 peer must never receive the v23 rows variant: {on_legacy:?}" + ); + + // The close must arrive in the SAME family as the open. A rows + // session closed by a legacy clear leaves the dropdown on screen + // forever, and the witness for "it actually cleared" is a `prompt: + // None` in the family the frontend is mirroring. + send_key(&mut current, current_fid, Key::Escape, Modifiers::NONE); + let closed_current = collect_minibuffer(&mut current, "v23 close", |mb| { + matches!(mb, Mb::Rows { prompt: None, .. }) + }); + assert!( + closed_current + .iter() + .all(|mb| matches!(mb, Mb::Rows { .. })), + "the v23 close must not arrive as a legacy clear: {closed_current:?}" + ); + let closed_legacy = collect_minibuffer(&mut legacy, "v22 close", |mb| { + matches!(mb, Mb::Legacy { prompt: None, .. }) + }); + assert!( + closed_legacy + .iter() + .all(|mb| matches!(mb, Mb::Legacy { .. })), + "the v22 close must stay in the frozen family: {closed_legacy:?}" + ); +} diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b583c83..a4dc5f1 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -789,7 +789,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // Vterm Stage 3 appended the terminal family as v19; GPU initial targets // appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1 // appended the panel family as v21; long-lines Stage 3 appended - // `LineWrapFacts` as v22. This acceptance owns the STATUSLINE + // `LineWrapFacts` as v22; Discovery Stage 2 appended + // `MinibufferPromptRows` as v23. This acceptance owns the STATUSLINE // variant's placement and gate, so it tracks the current wire version // rather than pinning 18: the v18 floor it actually cares about is asserted // below and in `peer_accepts_statusline_message`. @@ -798,11 +799,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // three lines on purpose. The ceiling assertion is the load-bearing // one — it says the supported set ENDS here, which is what makes an // accidentally-widened set a failure rather than a silent pass. - assert_eq!(PROTOCOL_VERSION, 22); - for version in 6..=22 { + assert_eq!(PROTOCOL_VERSION, 23); + for version in 6..=23 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(23)); + assert!(!is_supported_protocol_version(24)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index b5a71c5..92c287b 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -888,9 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - // Tripwire: a wire bump must be a conscious edit here. v22 is + // Tripwire: a wire bump must be a conscious edit here. v23 is + // `MinibufferPromptRows` (Discovery Stage 2); v22 was // `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 22); + assert_eq!(PROTOCOL_VERSION, 23); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), From bf561ff29644afd7de7daadf184ceda609012e5b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:19:58 +0200 Subject: [PATCH 26/66] docs: record PR #228 and its merge block The lane heading said "no PR yet". It also needs to carry WHY the PR is merge-blocked, because a reader who finds only "blocked" will treat it as backlog hygiene and unblock it by rerunning the gate. The problem is gate integrity. --protocol promises the CRDT workspace sweep, that sweep has a documented precondition (handoff section 5), and the script does not run it --- confirmed by reading the plan emitter, not inferred from the failure. So a --protocol result can be decided by whether the build directory happened to contain pmacs-gpu rather than by the diff under test. It was latent until #225 gave each worktree its own target directory. A shared target dir usually already had pmacs-gpu built, which satisfied the precondition by accident and hid the omission. Unblocking needs both halves recorded: the scripts/gate repair as its own framing and PR, and then a fresh-target rerun of this branch protocol gate under the repaired script. A rerun alone would reproduce the same accident. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3323bca..57788bc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,29 @@ 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. -## Discovery Stage 2 — IMPLEMENTED, no PR yet +## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED** + +**PR #228** — https://github.com/levineuwirth/pmacs/pull/228. Opened +2026-08-09 at `2d298dd`. **Open for review, not for merge.** + +**The block is a gate-integrity problem, not backlog hygiene.** This +lane's gate is `scripts/gate --protocol`, which promises the CRDT +workspace sweep. That sweep's documented precondition is +`cargo build --workspace --no-default-features --features luajit,crdt` +(handoff §5), and **the script does not run it** — confirmed by reading +its plan emitter. On a fresh per-worktree target directory the sweep +fails on twelve `gpu_invocation_acceptance` tests missing the +`pmacs-gpu` binary, so a `--protocol` result can be decided by the +state of the build directory rather than by the diff. + +Latent until #225 gave each worktree its own target dir — a shared one +usually already had `pmacs-gpu` built, satisfying the precondition by +accident. It surfaced on this branch's first gate run. + +**Unblocking requires both:** the `scripts/gate` repair, in its own +narrow framing and its own PR (explicitly **not** folded into this +feature branch), and then a **fresh-target rerun of this branch's +protocol gate** under the repaired script. **Written with the lane's first commit**, per the standing correction from #171 and #215. From 5cf1d61b92e0515ea561a888e6dcbc26691037da Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:31:22 +0200 Subject: [PATCH 27/66] fix(discovery): clip command descriptions at the single-row surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #228 review found a correctness gap this lane made reachable. The GPU dropdown derives its height, its visible window and its selection-highlight offset from `rows.len()` — ONE logical row per candidate — while a detail carrying a line break shapes into more physical lines than that. One such row misaligns every row below it and the highlight with it. The grid TUI has the same exposure from the other side: it writes the description into a single-row suffix on the minibuffer band. ## Why not reject CR/LF at registration That was the obvious fix. It was implemented, measured, and abandoned on evidence. MCP tool registration renders a whole schema block into `Command.description` — tool text, blank line, `Arguments:`, then one line per argument (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a `table.concat(lines, "\n")`, used at `:496`). And `tests/m9_6_acceptance.rs:583-598` ASSERTS four of those lines. A one-line guard in `CommandRegistry::define` fails 36 tests across `m9_6` (19/25), `m9_7` (16/19) and `m9_8` (1/17), in both feature configurations, and could only be made green by deleting a shipped acceptance criterion. So the one-line constraint goes where the constraint actually is: the surfaces that have one row. `Command.description` stays free-form, which it legitimately is. ## The change `Command::description_first_line` clips to the first CR **or** LF — a lone CR ends a line too, and an LF-only clip would pass a bare `\r` straight through to the same surface. Both single-row consumers call it: the semantic producer filling `MinibufferRow.detail` (`src/semantic_render.rs`) and the TUI suffix (`src/editor.rs`). A first line that is empty ships as `None` rather than `Some("")`, which would draw trailing padding. No ellipsis or truncation marker, matching the in-tree precedent and the minibuffer's own width rule. `describe-command` and `help.list-commands` are untouched and still report every line. That is what makes this a rendering decision rather than data loss, and it is asserted, not assumed. ## Precedent, already in this tree The same MCP fixture clips a tool RESULT to its first line because "a multi-line set_status would corrupt the row layout" (`init.lua:277-285`), leaving width clipping to the frontend. Same hazard class, same resolution. ## Verification `src/command.rs`: a schema block registers AND clips, in all three break forms; a single-line description is byte-identical after the clip; an empty first line clips to empty. `tests/discovery_stage2_acceptance.rs`: an MCP-shaped description reaches the TUI band and the GPU row as one line, through the real prompt path — with the full text still reachable via `describe-command` asserted alongside, so a clip that deleted the schema block everywhere would fail rather than pass. `pmacs-gpu`: one physical shaped line per logical candidate row — the geometry invariant the dropdown depends on. Mutation-checked: neutering `first_line` to the identity fails all four new break-handling tests (`a_multi_line_description_registers_and_clips_to_its_first_line`, `a_description_whose_first_line_is_empty_clips_to_empty`, `a_multi_line_description_reaches_the_tui_band_as_one_line`, `a_multi_line_description_reaches_the_gpu_row_as_one_physical_line`) and leaves the two "did not tighten past purpose" tests green. `Command.description`'s doc comment claimed "one-line", which the MCP path openly violates. It now states the real contract and records why a registration guard must not be re-proposed. `m9_6`/`m9_7`/`m9_8` pass COMPLETELY UNTOUCHED, and are now named gate suites so that stays on the record. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 41 ++++++- pmacs-gpu/src/main.rs | 11 ++ src/command.rs | 147 ++++++++++++++++++++++++- src/editor.rs | 11 +- src/semantic_render.rs | 19 +++- tests/discovery_stage2_acceptance.rs | 157 ++++++++++++++++++++++++++- 6 files changed, 376 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 57788bc..ad3851a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -322,13 +322,50 @@ tip** — the ref, not a SHA. Recover with local formatting change reading the registry directly. A multi-row TUI chooser is explicitly NOT this lane. - **Gates:** `scripts/gate --protocol --acceptance - discovery_stage2_acceptance` — the strengthened two-configuration - sweep, which is what `--protocol` exists for. + discovery_stage2_acceptance --acceptance m9_6_acceptance --acceptance + m9_7_acceptance --acceptance m9_8_acceptance` — the strengthened + two-configuration sweep, which is what `--protocol` exists for. The + three m9 suites are named because the PR #228 review round measured + them as this change's blast radius (see the description-clip bullet); + their continued passing is on the record rather than assumed. + **`--protocol` does NOT run its own documented precondition** + (`cargo build --workspace --no-default-features --features + luajit,crdt`, handoff §5) — run it by hand first or twelve + `gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu` + binary. That omission is the `gate-protocol-build` lane's, not this + one's. - **IMPLEMENTED.** `PROTOCOL_VERSION` is 23, `ADVERTISED_PROTOCOL_VERSION` is untouched at 20. New suite `tests/discovery_stage2_acceptance.rs`; the daemon half is `crdt`-gated (a semantic session is necessarily a text replica) and runs one daemon serving a v22 and a v23 session simultaneously. +- **Multi-line descriptions are clipped AT THE SURFACE, and + registration-level rejection was investigated and REJECTED ON + EVIDENCE — do not re-propose it.** PR #228 review found the real + hazard: the GPU dropdown derives its height, visible window and + highlight offset from `rows.len()` (one logical row per candidate), + so a detail carrying a line break misaligns every row below it; the + TUI writes into a single-row band. The obvious fix — reject CR/LF in + `CommandRegistry::define` — was implemented and measured, and it + **fails 36 tests across `m9_6`/`m9_7`/`m9_8`**, because MCP tool + registration renders a whole schema block into `description` + (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, + `table.concat(lines, "\n")`, used at `:496`) and + **`tests/m9_6_acceptance.rs:583-598` asserts four separate lines of + it** — tool text, `Arguments:`, and two per-argument lines. No + single-line rendering satisfies those assertions, so a registry guard + could only go green by deleting a shipped acceptance criterion. + The one-line constraint belongs to the surfaces that have it: + `Command::description_first_line` clips, both single-row consumers + call it, and the full text still reaches `describe-command` / + `help.list-commands` untouched. Precedent already in-tree — the same + MCP fixture clips a tool RESULT to its first line because *"a + multi-line set_status would corrupt the row layout"* (`:277-285`). + **A startup census is not a corpus census**: booting an + `EditorState` and scanning all 180 registered descriptions found zero + offenders, because MCP registers at RUNTIME and builds the string by + concatenation — invisible to both that census and a grep for literals. + The workspace sweep is what caught it. - **The freeze is enforced by LITERAL byte fixtures**, not a round-trip — `minibuffer_prompt_v12_wire_bytes_are_frozen` in `src/protocol.rs`, the first such fixture in this repo. Bite-verified: reordering two diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index cbb58e3..78eb64a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -15482,6 +15482,17 @@ mod tests { Some("buffer.kill"), "a row with no detail renders the bare label, exactly as before v23: {lines:?}" ); + // The geometry invariant the dropdown depends on: it derives + // its height, its visible window and its selection-highlight + // offset from `rows.len()`, so ONE physical line per logical + // row is what keeps those aligned. The daemon clips a detail to + // its first line (`Command::description_first_line`) precisely + // so this holds for an MCP schema block. + assert_eq!( + lines.len(), + state.minibuffer.as_ref().map_or(0, |mb| mb.rows.len()), + "one physical line per candidate row: {lines:?}" + ); // The frozen `12..=22` form, which an older daemon still sends: // bare strings become detail-free rows. diff --git a/src/command.rs b/src/command.rs index 2b21d7a..ec93899 100644 --- a/src/command.rs +++ b/src/command.rs @@ -66,7 +66,28 @@ impl SourceLocation { pub struct Command { /// Unique name (e.g. `buffer.save`). pub name: String, - /// One-line human-readable description (R42, required). + /// Human-readable description. Required and non-empty after trim + /// (R42), but otherwise **free-form, and legitimately multi-line**. + /// + /// # Do not add a registration-time one-line guard + /// + /// This doc used to read "one-line human-readable description", + /// which was an aspiration rather than the contract: MCP tool + /// registration renders a whole schema block in here — the tool's + /// text, a blank line, `Arguments:`, then one line per argument + /// (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a + /// `table.concat(lines, "\n")`) — and `m9_6_acceptance.rs:583-598` + /// asserts all four of those lines. Rejecting CR/LF in + /// [`CommandRegistry::define`] was tried, measured, and abandoned: + /// it fails 36 tests across `m9_6`/`m9_7`/`m9_8` and could only be + /// made green by deleting a shipped acceptance criterion. + /// + /// The one-line constraint belongs to the **surfaces that have + /// it**, so a consumer rendering into a single row clips with + /// [`Self::description_first_line`] — the minibuffer band and the + /// completion dropdown both do. The full text stays intact for + /// `describe-command` and `help.list-commands`, which is what keeps + /// this a rendering decision rather than data loss. pub description: String, /// Where the command was defined. pub source: SourceLocation, @@ -79,6 +100,53 @@ pub struct Command { pub predicate: Option, } +impl Command { + /// [`Self::description`] clipped to its first line, for a consumer + /// rendering into a surface that has exactly one row. + /// + /// The description is free-form and may carry a whole schema block + /// (see that field). Two surfaces cannot show one: the grid TUI + /// writes the selected candidate into a single-row suffix on the + /// minibuffer band, and the GPU dropdown derives its height, its + /// visible window and its selection-highlight offset from + /// `rows.len()` — **one logical row per candidate** — so a detail + /// that shapes into more physical lines than that misaligns every + /// row below it and the highlight with it. + /// + /// Clipping here rather than refusing at registration follows the + /// precedent already in this tree: the MCP fixture's result + /// delivery keeps only the first line of a tool result because + /// *"a multi-line `set_status` would corrupt the row layout"* + /// (`tests/fixtures/pmacs-mcp-tools/init.lua:277-285`), leaving + /// width clipping to the frontend. Same hazard class, same + /// resolution. + /// + /// **No ellipsis or truncation marker**, matching that precedent + /// and the minibuffer's own width rule, which rejects stub markers + /// for the same reason: the full text is one `describe-command` + /// away, and a marker in a candidate row reads as part of the + /// candidate. + #[must_use] + pub fn description_first_line(&self) -> &str { + first_line(&self.description) + } +} + +/// The prefix of `text` before its first line break. +/// +/// Breaks on CR **or** LF, not LF alone: a lone CR ends a line on +/// classic-Mac-era input and is the leading half of a CRLF, so an +/// LF-only clip would pass a bare `\r` straight through to a +/// single-row surface — and a CR-only clip would do the same for `\n`. +/// Splitting on the first of either handles all three forms with one +/// scan, since CRLF's `\r` comes first. +fn first_line(text: &str) -> &str { + match text.find(['\n', '\r']) { + Some(break_at) => &text[..break_at], + None => text, + } +} + /// Errors raised by the command registry. #[derive(Debug, Error)] pub enum CommandError { @@ -271,6 +339,83 @@ mod tests { )); } + #[test] + fn a_multi_line_description_registers_and_clips_to_its_first_line() { + // Registration accepts it — MCP tool registration renders a + // whole schema block into `description` and + // `m9_6_acceptance.rs:583-598` asserts four of its lines, so a + // one-line guard here would delete a shipped contract. The + // one-line constraint lives at the single-row surfaces, which + // read `description_first_line`. + // + // All three break forms: a clip that split on `\n` alone would + // pass a bare `\r` through, and one that split on `\r` alone + // would pass `\n` through. + let lua = Lua::new(); + for (label, description) in [ + ( + "LF", + "Greet someone.\n\nArguments:\n name (string, required)", + ), + ( + "CR", + "Greet someone.\r\rArguments:\r name (string, required)", + ), + ( + "CRLF", + "Greet someone.\r\n\r\nArguments:\r\n name (string, required)", + ), + ] { + let mut r = CommandRegistry::new(); + r.define(make_command(&lua, "mcp.greet", description)) + .unwrap_or_else(|e| panic!("{label}: a schema block must still register: {e}")); + let cmd = r.get("mcp.greet").expect("registered"); + assert_eq!( + cmd.description, description, + "{label}: the registry stores the description verbatim — the clip is a \ + rendering decision, so `describe-command` must still see every line" + ); + assert_eq!( + cmd.description_first_line(), + "Greet someone.", + "{label}: a single-row surface gets the first line only" + ); + assert!( + !cmd.description_first_line().contains(['\n', '\r']), + "{label}: the clipped form must carry no break at all" + ); + } + } + + #[test] + fn a_single_line_description_is_byte_identical_after_the_clip() { + // The other half: the clip must not tighten past its purpose. + // Interior whitespace, punctuation and non-ASCII all survive, + // and there is no ellipsis or truncation marker. + let lua = Lua::new(); + let mut r = CommandRegistry::new(); + let description = "Write the buffer to its file — with a dash, and \ttabs."; + r.define(make_command(&lua, "buffer.save", description)) + .expect("registers"); + assert_eq!( + r.get("buffer.save").unwrap().description_first_line(), + description, + "a description with no break is returned unchanged" + ); + } + + #[test] + fn a_description_whose_first_line_is_empty_clips_to_empty() { + // The case the producer turns into `None` rather than + // `Some("")`: a leading break leaves nothing to render, and a + // `Some("")` detail would draw trailing padding after the label. + let lua = Lua::new(); + let mut r = CommandRegistry::new(); + r.define(make_command(&lua, "x", "\nArguments:\n a (string)")) + .expect("registers"); + assert_eq!(r.get("x").unwrap().description_first_line(), ""); + } + #[test] fn empty_name_is_rejected() { let lua = Lua::new(); diff --git a/src/editor.rs b/src/editor.rs index 7050023..0e78fe8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -5575,13 +5575,22 @@ fn paint_minibuffer( // // Q#D2-2: only the command source has a detail. A file-path or // buffer-name prompt renders exactly as it did before. + // + // FIRST LINE ONLY: this band is a single row, and + // `Command.description` is free-form — MCP registration renders a + // whole schema block into it. The full text stays reachable through + // `describe-command`. let suffix = match session.selected.and_then(|idx| session.candidates.get(idx)) { Some(cand) => { let detail = matches!( session.source, crate::minibuffer::CompletionSource::Commands ) - .then(|| commands.get(cand).map(|c| c.description.as_str())) + .then(|| { + commands + .get(cand) + .map(crate::command::Command::description_first_line) + }) .flatten(); minibuffer_candidate_suffix(cand, detail, max.saturating_sub(col)) } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 2e62952..3359504 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1725,9 +1725,26 @@ impl SemanticRenderState { labels .into_iter() .map(|label| { + // FIRST LINE ONLY. `Command.description` + // is free-form and MCP registration puts + // a whole schema block in it, while the + // dropdown sizes itself from + // `rows.len()` — one logical row per + // candidate. Shipping the block would + // shape into more physical lines than + // the geometry accounts for and + // misalign every row below it. The full + // text stays reachable through + // `describe-command`. let detail = commands .get(&label) - .map(|command| command.description.clone()); + .map(|command| command.description_first_line().to_owned()) + // A description whose first line is + // empty (`"\nArguments:…"`) carries + // nothing to render, so it ships as + // absent rather than as `Some("")`, + // which would draw trailing padding. + .filter(|detail| !detail.is_empty()); MinibufferRow { label, detail } }) .collect() diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs index f290368..571c605 100644 --- a/tests/discovery_stage2_acceptance.rs +++ b/tests/discovery_stage2_acceptance.rs @@ -34,7 +34,8 @@ use std::path::Path; use pmacs::bootstrap::BootstrapRoots; use pmacs::editor::EditorState; use pmacs_protocol::{ - ADVERTISED_PROTOCOL_VERSION, PROTOCOL_VERSION, is_supported_protocol_version, + ADVERTISED_PROTOCOL_VERSION, ByteRange, InstanceMessage, MinibufferRow, PROTOCOL_VERSION, + is_supported_protocol_version, }; #[cfg(feature = "crdt")] @@ -46,13 +47,11 @@ use std::time::{Duration, Instant}; use pmacs_protocol::cell::CellSize; #[cfg(feature = "crdt")] use pmacs_protocol::message::{ - AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent, - Modifiers, SessionBootstrapRequest, + AttachRequest, FrontendCapabilities, FrontendEvent, Hello, Key, KeyEvent, Modifiers, + SessionBootstrapRequest, }; #[cfg(feature = "crdt")] use pmacs_protocol::transport::{read_message, write_message}; -#[cfg(feature = "crdt")] -use pmacs_protocol::{ByteRange, MinibufferRow}; #[cfg(feature = "crdt")] use common::daemon::{TestDaemon, build_default_caps}; @@ -148,6 +147,36 @@ fn bottom_row(s: &EditorState, rows: u32, cols: u32) -> String { row.into_iter().collect::().trim_end().to_owned() } +/// Open `M-x` narrowed to `zzprobe` and return the candidate rows the +/// semantic producer ships to a current-wire peer. +/// +/// Through `SemanticRenderState` and the real minibuffer session rather +/// than by constructing a message: the clip lives in the producer, so a +/// hand-built row would skip the thing under test. +fn mx_rows(s: &EditorState) -> Vec { + let bid = s.core.borrow().active_buffer_id(); + let mut render = pmacs::semantic_render::SemanticRenderState::for_peer( + pmacs::protocol::FrontendId::LOCAL, + PROTOCOL_VERSION, + ); + render.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + let _ = render.render_frame(s); + + exec( + s, + "pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }", + ); + exec(s, "pmacs.minibuffer.set_contents('zzprobe')"); + render + .render_frame(s) + .into_iter() + .find_map(|msg| match msg { + InstanceMessage::MinibufferPromptRows { rows, .. } => Some(rows), + _ => None, + }) + .expect("the producer ships a rows prompt") +} + /// Open `M-x`, narrowed to exactly one command with a known /// description, and report the bottom row at `cols` columns. fn mx_bottom_row(s: &EditorState, cols: u32) -> String { @@ -254,6 +283,124 @@ fn a_source_with_no_detail_renders_exactly_as_before_in_the_tui() { ); } +// --------------------------------------------------------------------------- +// Multi-line descriptions reach single-row surfaces as ONE line +// --------------------------------------------------------------------------- + +/// An MCP-shaped description: tool text, blank line, `Arguments:`, then +/// one line per argument. +/// +/// This is the real shape, not an invented one — +/// `tests/fixtures/pmacs-mcp-tools/init.lua:272` builds it with +/// `table.concat(lines, "\n")` and `m9_6_acceptance.rs:583-598` asserts +/// four of its lines, which is why registration accepts it and the +/// SURFACES clip instead. +const MCP_SHAPED: &str = "Greet someone.\\n\\nArguments:\\n name (string, required)"; + +fn define_multiline_probe(s: &EditorState, name: &str, description: &str) { + exec( + s, + &format!( + "pmacs.command.define{{ name = '{name}', description = \"{description}\", \ + fn = function() end }}" + ), + ); +} + +#[test] +fn a_multi_line_description_reaches_the_tui_band_as_one_line() { + let s = session("tui-multiline"); + define_multiline_probe(&s, "zzprobe", MCP_SHAPED); + let row = mx_bottom_row(&s, 200); + assert!( + row.contains("[zzprobe — Greet someone.]"), + "the band shows the first line only: {row:?}" + ); + assert!( + !row.contains("Arguments:"), + "the schema block must not reach a single-row band: {row:?}" + ); + // `bottom_row` reads one grid row, so anything below would be lost + // rather than visibly wrong — assert on the registry-side clip too, + // which is what the painter consumed. + let clipped: String = eval(&s, "return pmacs.describe.command('zzprobe').description"); + assert!( + clipped.contains("Arguments:"), + "describe-command must still see the WHOLE description, or the clip \ + silently deleted the schema block everywhere: {clipped:?}" + ); +} + +#[test] +fn a_multi_line_description_reaches_the_gpu_row_as_one_physical_line() { + // The geometry hazard, through the real prompt path: the dropdown + // sizes itself from `rows.len()` — one logical row per candidate — + // so a detail carrying a break would shape into more physical lines + // than the geometry accounts for. + // + // All three break forms, since a clip handling only LF would pass a + // bare CR through to the same surface. + for (label, description, tail) in [ + ("LF", MCP_SHAPED, "Arguments:"), + ( + "CR", + "Greet someone.\\r\\rArguments:\\r name (string, required)", + "Arguments:", + ), + ( + "CRLF", + "Greet someone.\\r\\n\\r\\nArguments:\\r\\n name (string, required)", + "Arguments:", + ), + ] { + let s = session(&format!("gpu-multiline-{label}")); + define_multiline_probe(&s, "zzprobe", description); + let rows = mx_rows(&s); + let probe = rows + .iter() + .find(|row| row.label == "zzprobe") + .unwrap_or_else(|| panic!("{label}: the probe command is a candidate")); + let detail = probe + .detail + .as_deref() + .unwrap_or_else(|| panic!("{label}: the row carries a detail")); + assert_eq!( + detail, "Greet someone.", + "{label}: the wire row carries the first line only" + ); + assert!( + !detail.contains(['\n', '\r']), + "{label}: a row detail must carry no line break: {detail:?}" + ); + assert!( + !detail.contains(tail), + "{label}: the schema block must not reach the dropdown" + ); + + // And the full text is still there for the discoverability + // path, which is what makes this a rendering decision. + let full: String = eval(&s, "return pmacs.describe.command('zzprobe').description"); + assert!( + full.contains("name (string, required)"), + "{label}: describe-command must still report every line: {full:?}" + ); + } +} + +#[test] +fn a_single_line_description_is_unchanged_on_the_wire() { + // The clip did not tighten past its purpose: a description with no + // break reaches the row byte-identical, with no truncation marker. + let s = session("wire-single-line"); + define_probe(&s); + let rows = mx_rows(&s); + let probe = rows + .iter() + .find(|row| row.label == "zzprobe") + .expect("the probe command is a candidate"); + assert_eq!(probe.detail.as_deref(), Some(PROBE_DESCRIPTION)); +} + #[test] fn typed_but_unmatched_input_is_still_accepted() { // Q#D2-5, the trap this lane arrives with: richer rows make `M-x` From 71ef95153589ddbfa93501986b1b4277aa1ad904 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:46:30 +0200 Subject: [PATCH 28/66] docs: frame a general destination capture (revision 1) PR #227 review found that git async completions surface in whichever frame is active when git exits, and named the right mechanism: commit_to exists for exactly this continuation boundary, built by Journey Stage 1a Q#JR14 because the work settles a tick or more later, by which time the ambient frontend, window and buffer may all name something else. The fix is not available to git, which is why this is a lane rather than a line in #227. commit_to takes a DirectoryDestinationLua that is nonconstructible from Lua by deliberate design, and the only site that mints one is inside the path.open-directory listener dispatch, from a pub(crate) capture. Any async Lua continuation that is not a directory open has no way to say where its result belongs. The captured data is already generic --- frontend, window, buffer, with nothing directory-specific in it. Only the name and the capture site are, and the rename is 8 references across 4 files, counted rather than estimated. The substantive question is Q#DC-2, and scouting is what surfaced it. Git two continuations are different in kind. *git-status* goes to the bottom panel, because listview.open resolves display with a "panel" default. *git-diff* replaces a document window, deliberately, so the status panel it was invoked from stays visible beside it. The stale-intent check that commit_to preflight runs --- the window still shows the captured buffer --- is right for the second and wrong for the first: the panel never touches that window buffer, so refusing because the user switched files there is a refusal with no relationship to what the continuation does. One shape either over-refuses the panel case or under-checks the document case, and the framing votes for a parameterized preflight while holding that vote loosely. No adopter in this lane. Git adoption is #227 work after this lands; a prerequisite that also converts its first consumer makes the two impossible to review separately. Verification carries a stop signal rather than a target: if any existing dired test needs editing, the generalization changed Journey Stage 1a semantics and that is cause to stop, not to adjust a test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 46 +++++++ docs/destination-capture-framing.md | 198 ++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 docs/destination-capture-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 59783a1..2ebc4b8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,6 +265,51 @@ 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. +## Destination capture (Q#JR14 generalization) — BRANCHED, framing in review + +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `destination-capture`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/destination-capture` is the +authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout destination-capture`. + +- **Framing `docs/destination-capture-framing.md`, revision 1**, in + review. +- **A PREREQUISITE LANE. PR #227 (git Stage 1) blocks on it.** #227's + P1a review finding is why it exists: git's async completions mutate + and display UI without capturing the initiating frontend + (`builtin/runtime/git.lua:609`, `:854`), so a result surfaces in + whichever frontend is active when git exits. +- **The mechanism exists but is not Lua-reachable.** + `pmacs.window.commit_to` takes a `DirectoryDestinationLua`, which is + **nonconstructible from Lua** by design + (`src/lua_bindings/mod.rs:4256`) and minted only inside the + `path.open-directory` listener dispatch (`src/editor.rs:1311`) from a + `pub(crate)` capture (`:1241`). So no async Lua continuation outside + a directory open can say where its result belongs. +- **Scope:** a Lua-reachable capture, a generic rename + (`DirectoryDestination` → `ViewDestination`, 8 references across 4 + files — counted, not estimated), and the preflight question below. + **No adopter**: git's adoption is #227's work after this lands, since + a prerequisite that converts its own first consumer cannot be + reviewed separately from it. +- **The substantive question (Q#DC-2)** is that git's two continuations + differ in kind. `*git-status*` goes to the **bottom panel** + (`listview.open` defaults `display` to `"panel"`, + `builtin/runtime/listview.lua:550`); `*git-diff*` replaces a + **document** window. `commit_to`'s stale-intent check (Q#JR14c) is + right for the second and wrong for the first — the panel never + touches the captured window's buffer, so refusing on its change is a + refusal unrelated to what the continuation does. One shape + over-refuses the panel or under-checks the document. +- **Stop signal recorded in the framing:** if any existing dired test + needs editing, the generalization changed Journey Stage 1a's + semantics, and that is cause to stop rather than to adjust the test. +- **Gates:** `scripts/gate --acceptance ` plus dired's. + No `--protocol` — core and Lua bindings only. + ## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet **Written with the lane's first commit**, per the standing correction @@ -603,6 +648,7 @@ authoritative tip** — the ref, not a SHA. Recover with — added in the second round — a **rename of either** the build or the sweep step each fail the suite. ||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last) +||||||| parent of ac1d6cc (docs: frame a general destination capture (revision 1)) ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md new file mode 100644 index 0000000..10e32fa --- /dev/null +++ b/docs/destination-capture-framing.md @@ -0,0 +1,198 @@ +# A destination capture any async continuation can use + +**Status: framing pass, revision 1. Pre-implementation. Awaiting +approval.** + +**A prerequisite lane. PR #227 (git Stage 1) blocks on it**, and its +P1a review finding is the reason this exists. + +--- + +## 1. Why, and why as its own lane + +PR #227's review found that git's async completions mutate and display +UI without capturing the initiating frontend +(`builtin/runtime/git.lua:609`, `:854`), so a result can surface in +whichever frontend happens to be active when git exits. Run +`git.status` in frontend A, let frontend B become active, and A's panel +opens in B. + +**The finding named the right mechanism.** `pmacs.window.commit_to` +exists for exactly this continuation boundary: Journey Stage 1a's +Q#JR14 built it because "the listing settles a tick or more later, and +by then the ambient frontend, selected window, and active buffer may +all name something else" (`src/editor.rs:1238-1240`). + +**But it is not reachable from Lua outside one path**, which is why +this is a lane and not a line in #227: + +- `commit_to` takes a `DirectoryDestinationLua`, **nonconstructible + from Lua** by deliberate design (`src/lua_bindings/mod.rs:4256`) — + userdata with no constructor and no setters, so a caller cannot + fabricate a plausible triple. +- The only site that mints one is inside the `path.open-directory` + listener dispatch (`src/editor.rs:1311`), from + `capture_directory_destination`, which is `pub(crate)` + (`src/editor.rs:1241`). + +So any async Lua continuation that is **not** a directory open has no +way to say where its result belongs. Git is the first to need it; it +will not be the last. + +Landing this inside #227 would put new Lua API surface, over another +lane's merged mechanism, inside a feature branch — the same folding +that was declined for the `scripts/gate` repair, for the same reason. + +## 2. Ground truth + +- **The captured data is already generic.** + `DirectoryDestination { frontend, window, buffer }` + (`src/editor_core.rs:159-166`) contains nothing directory-specific. + Only its **name** and its **capture site** are. +- **The blast radius of a rename is small**: 8 references across 4 + files (`editor_core.rs`, `editor.rs`, `lua_bindings/mod.rs`, + `lua_bindings/window_panel.rs`). Checked, not estimated. +- **`commit_to`'s preflight is four checks** + (`src/lua_bindings/window_panel.rs:488-525`), in order: the + requesting frontend still has a layout; the destination window is + still live in it; **the window still shows the captured buffer** + (Q#JR14c stale intent); and the window is not dedicated (Q#JR14f). +- **`Handle:await` refuses inside a commit scope** + (`builtin/runtime/async.lua:87-90`) — yielding would restore the + scope while the coroutine is still parked. Any adopter awaits + *before* committing, as dired does. +- **Git's two continuations do not have the same shape**, and this is + the finding that shapes the design: + - `*git-status*` goes through `listview.open`, which resolves + `display` with a **`"panel"`** default + (`builtin/runtime/listview.lua:550`). It lands in the bottom + panel, **not** in a document window. + - `*git-diff*` calls `pmacs.window.display(buf, { select = true })` + — the **document** target, deliberately, "so the status panel it + was invoked from stays visible beside it" + (`builtin/runtime/git.lua:852-854`). + +## 3. The tension this lane has to resolve + +`DirectoryDestination.buffer` exists for one purpose, stated at its +definition: *"what that window held at capture time, so **stale intent +loses to the user**"* — a user who replaced the buffer while work was +in flight is newer information than the request. + +**That predicate is right for a document replacement and wrong for a +panel.** The git status panel does not replace the captured window's +buffer; it opens in the bottom panel beside it. Refusing to show it +because the user switched files in the document window would be a +refusal with no relationship to what the continuation actually does — +the panel case would inherit a check about a window it never touches. + +Meanwhile the diff case *is* a document replacement, and wants exactly +the dired semantics. + +So a single one-size destination either **over-refuses** the panel case +or **under-checks** the document case. Q#DC-2 is where that gets +decided, and it is the substance of this lane. + +## 4. The change, in outline + +- **A Lua-reachable capture**, returning the same nonconstructible + userdata for the *current* frontend and its document window. +- **Generic naming.** `DirectoryDestination` becomes something that + does not lie about a git panel; `capture_directory_destination` and + the userdata type follow. 8 references (§2). +- **The directory path keeps behaving exactly as it does today** — this + lane generalizes the capture, it does not change Journey Stage 1a's + semantics. +- **No adopter in this lane.** Git's adoption is #227's, after this + lands. A prerequisite that also converts its first consumer makes the + two impossible to review separately. + +## 5. Open questions + +### Q#DC-1 — what does the capture take as arguments? + +*My vote: **no arguments** — capture the acting frontend and its +document window from the ambient state at call time.* That is what the +existing `capture_directory_destination(frontend, window)` is handed by +its one caller, and a Lua-supplied frontend id would reintroduce the +fabrication hole the userdata design closes. + +### Q#DC-2 — one destination shape, or a panel/document distinction? **(the substantive one)** + +§3 is the problem. Three candidates: + +1. **One shape, all four checks.** Simplest; over-refuses the panel + case, and the refusal reason would be about a window the panel does + not touch. +2. **One shape, preflight parameterized by the continuation** — the + caller declares whether it is replacing the captured window's + buffer, and the stale-intent check applies only then. +3. **Two capture kinds**, document and panel, with different preflights. + +*My vote: **(2)***. The four checks are not equally applicable, and +which apply is a property of *what the continuation does*, which only +the caller knows. (3) duplicates the liveness checks that both need; +(1) ships a refusal that will read as a bug the first time a user hits +it. + +**I hold this one loosely.** It is the design decision of the lane, and +(1) has a real argument — a uniform rule is easier to reason about than +a parameterized one, and over-refusal is at least *safe*. + +### Q#DC-3 — what is the type called? + +*My vote: **`ViewDestination`***, with `pmacs.window.capture_destination()` +as the Lua entry point. It names what it is — a place in a view where a +continuation's result belongs — without claiming a directory or a +buffer kind. + +The Q#JR14 doc comments should keep their references intact; a rename +that orphans the rationale is worse than a slightly stale name. + +### Q#DC-4 — is the capture refused when there is no document window? + +`capture_directory_destination` already returns `None` when the +frontend has no document window (`src/editor.rs:1236`). *My vote: +**return `nil`, and require every adopter to handle it***, rather than +inventing a fallback destination. A continuation with nowhere to land +should say so, and #227's adopter should degrade to today's ambient +behaviour with a status message rather than silently guessing. + +## 6. Verification + +- **A captured destination survives a frontend switch**: capture in A, + make B active, commit, and assert the result lands in **A**. This is + P1a's actual failure and the reason the lane exists — asserting only + that the API returns userdata would pass on a capture that does + nothing. +- **A fabricated destination is still refused** — the existing Q#JR14d + guarantee, re-asserted after the rename so the generalization cannot + quietly open the hole it was built to close. +- **Each preflight refusal is witnessed by its own case**: frontend + gone, window gone, stale buffer, dedicated window — and, under + Q#DC-2's answer, that the stale-buffer refusal does **not** fire for + a continuation that declared it is not replacing that buffer. +- **`nil` when the frontend has no document window** (Q#DC-4). +- **The directory path is unchanged** — dired's existing acceptance + coverage passes untouched. **If any dired test needs editing, the + generalization changed Journey Stage 1a's semantics** and that is a + stop signal, not a fixup. +- **`Handle:await` still refuses inside the scope**, including through + `pmacs.async.yield_to_next_tick` if the worker-identity lane's Q#W-7 + has landed by then; if it has not, this lane does **not** add that + guard — it belongs to that lane and duplicating it would produce a + conflict for no benefit. + +**What this will NOT prove:** that git surfaces in the right frontend — +that is #227's adoption, after this lands. This lane ships the +mechanism and one set of tests for the mechanism. + +## 7. Not in scope + +**Adopting the capture anywhere**, including git (#227 does that) and +including migrating other async continuations that have the same latent +bug — worth an audit, not this lane's work. Changing Journey Stage 1a's +directory semantics. The `commit_to` scope guard for +`yield_to_next_tick` (worker identity Q#W-7). Any protocol change — +this is entirely core + Lua bindings. Panel geometry or placement +policy, which is the bottom-panel arc's. From 91e4e514a180884170b32d66cd08e6d70321f017 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:59:56 +0200 Subject: [PATCH 29/66] docs: destination-capture revision 2 --- full matrix, preservation suite, coherence Three review findings. Q#DC-2 parameterization was incomplete. Revision 1 said only "skip the stale-buffer check for a non-replacing continuation", but a panel result does not depend on the captured document window at all: it does not replace that window buffer (check 3), does not occupy it (check 4), and does not need that specific window to exist (check 2). Retaining any of the three can reject git.status for an unrelated document-window change; dropping them without an explicit profile risks weakening document replacement. The question now carries a four-row matrix with two profiles, and check 1 --- the requesting frontend still has a layout --- is the entire panel profile. That has a consequence the framing now states rather than leaving to be discovered: if the panel profile needs only the frontend, a frontend with no document window can still host a panel, so Q#DC-4 return-nil rule is right for the document profile and possibly wrong for the panel one. Settled as part of answering Q#DC-2, not after it. tests/journey_acceptance.rs joins dired as a named preservation suite and stop signal. It carries 27 commit_to references across nine named pins --- forged destination, scope-and-restore on normal return and on raise, await refusal, delivery to the requesting frontend, the declining-listener redirect guard, and two already named preservation_* --- and Journey Stage 1a own framing treats it as a required gate. A lane that generalizes its substrate does not get to relax that. The stop signal now covers both suites: a suite edited to accommodate the change under test has stopped being evidence. The coherence-impact section was missing entirely. CLAUDE.md and COHERENCE.md section 25 both require one for coherence-affecting work, and this lane qualifies twice over --- new Lua API surface, and a generalization of a Journey substrate. Section 16 is the section it serves. Journey steps: none added, one protected. Islands, config registry: none. Section 9: neutral, and stated precisely, because knowing which frontend a result belongs to is NOT knowing who asked for it --- that is the worker-identity arc and the two should not be conflated just because both concern async continuations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 11 +- docs/destination-capture-framing.md | 149 ++++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 2ebc4b8..1d6f6a9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,8 +275,15 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 1**, in - review. +- **Framing `docs/destination-capture-framing.md`, revision 2**, in + review. Revision 2 took three findings: Q#DC-2's parameterization was + incomplete (a panel depends on **none** of checks 2–4, not just check + 3, so the question now carries a full preflight matrix with every + omission testable); `tests/journey_acceptance.rs` joins dired as a + **preservation suite and stop signal**, since it holds the + `commit_to` scope, forged-userdata, preflight and restoration pins + this lane generalizes; and the **coherence-impact section was missing + entirely**, which `CLAUDE.md` and `COHERENCE.md` §25 both require. - **A PREREQUISITE LANE. PR #227 (git Stage 1) blocks on it.** #227's P1a review finding is why it exists: git's async completions mutate and display UI without capturing the initiating frontend diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 10e32fa..87b3c30 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,8 +1,21 @@ # A destination capture any async continuation can use -**Status: framing pass, revision 1. Pre-implementation. Awaiting +**Status: framing pass, revision 2. Pre-implementation. Awaiting approval.** +**Revision 2 takes three review findings.** Q#DC-2's parameterization +was **incomplete** — a panel result does not depend on the captured +document window being live or non-dedicated either, not just on its +buffer, so the question now carries a full **preflight matrix** with +every omission testable. `tests/journey_acceptance.rs` joins dired as a +named **preservation suite and stop signal**; it carries the +`commit_to` scope, forged-userdata, preflight and restoration pins this +lane generalizes, and Journey Stage 1a's framing treats it as a +required gate. And **§5 (coherence impact) was missing entirely**, +which `CLAUDE.md` and `COHERENCE.md` §25 both require of +coherence-affecting work — this lane adds Lua API surface and +generalizes a Journey substrate, so it qualifies twice over. + **A prerequisite lane. PR #227 (git Stage 1) blocks on it**, and its P1a review finding is the reason this exists. @@ -107,7 +120,48 @@ decided, and it is the substance of this lane. lands. A prerequisite that also converts its first consumer makes the two impossible to review separately. -## 5. Open questions +## 5. Coherence impact (§20) + +**Revision 1 omitted this section entirely, and it is required.** +`CLAUDE.md` and `COHERENCE.md` §25 both say a framing for +coherence-affecting work must cite the section it serves and state its +impact — and this lane adds **new Lua API surface** and generalizes a +Journey-substrate mechanism, which is coherence-affecting on both +counts. Recording the impacts as neutral where they are neutral is part +of the requirement, not a way around it. + +- **§16 semantic frontend — the section this serves.** The defect it + removes is a continuation resolving its target from *ambient* state a + tick after the request, which is precisely the multi-frontend + correctness §16 exists to protect. A capture makes "which frontend + asked" a value rather than a guess. +- **§14 workbench primitives — indirect, and the honest framing is + *enabling*.** This does not add a primitive. It removes the reason an + async adopter would hand-roll frontend tracking, which is the + mechanism by which primitives acquire per-consumer idiosyncrasies. +- **Journey steps touched: none directly, one PROTECTED.** The golden + journey does not gain a step. But Journey Stage 1a's Q#JR14 substrate + is what this generalizes, and §7 makes `tests/journey_acceptance.rs` + a preservation suite precisely so a generalization cannot erode the + step it came from. +- **Interaction islands (§6): none added.** No key interception, no + dispatch precedence rung. `dispatch_key` is untouched. +- **Config registry: no setting.** Where a continuation lands is a + correctness property, not a preference, and a toggle would offer to + turn correctness off. +- **Background-work attribution (§9): NEUTRAL, and worth stating + precisely rather than skipping.** This lane adds no background work + and no new unattributable surface. It also does **not** improve §9 — + knowing which frontend a result belongs to is not knowing who asked + for it or why. That is the worker-identity lane's arc, and the two + should not be confused because both concern async continuations. +- **§10 extension trust — a small positive.** The capture keeps the + Q#JR14d property that a destination is **nonconstructible from Lua**, + so generalizing the mechanism does not widen what extension code can + fabricate. §7 re-asserts the forged-destination refusal after the + rename for exactly this reason. + +## 6. Open questions ### Q#DC-1 — what does the capture take as arguments? @@ -129,15 +183,49 @@ fabrication hole the userdata design closes. buffer, and the stale-intent check applies only then. 3. **Two capture kinds**, document and panel, with different preflights. -*My vote: **(2)***. The four checks are not equally applicable, and -which apply is a property of *what the continuation does*, which only -the caller knows. (3) duplicates the liveness checks that both need; -(1) ships a refusal that will read as a bug the first time a user hits -it. +*My vote: **(2)***, with the profiles spelled out below rather than +left to implementation. -**I hold this one loosely.** It is the design decision of the lane, and -(1) has a real argument — a uniform rule is easier to reason about than -a parameterized one, and over-refusal is at least *safe*. +**Revision 1 said only "skip the stale-buffer check for a non-replacing +continuation", and that was incomplete.** Review is right: a panel +result does not depend on the captured **document window** at all. It +does not replace that window's buffer, so check 3 is irrelevant; it +does not occupy that window, so check 4 (dedicated) is irrelevant; and +it does not need that specific window to exist, so check 2 is +irrelevant. Retaining any of the three can reject `git.status` for a +document-window change that has nothing to do with where the panel +goes. But dropping them **without an explicit profile** is how document +replacement quietly loses its guarantees. + +**The matrix, stated so every omission is deliberate and testable:** + +| # | Precondition (`window_panel.rs:488-525`) | Document replacement | Frontend/panel scope | +|---|---|---|---| +| 1 | Requesting frontend still has a layout | **required** | **required** | +| 2 | Destination window still live in it | **required** | not applicable | +| 3 | Window still shows the captured buffer (Q#JR14c stale intent) | **required** | not applicable | +| 4 | Window is not dedicated (Q#JR14f) | **required** | not applicable | + +**Check 1 is the entire panel profile**, and that is the honest reading +of what a panel continuation actually depends on: the frontend it was +launched from still exists. Everything else in the capture is document +state the panel never touches. + +**Consequence for the capture, which follows and should not be +discovered later:** if the panel profile needs only the frontend, then +a frontend with **no document window** can still host a panel — so +Q#DC-4's "return `nil`" is right for the document profile and possibly +wrong for the panel one. That interaction is settled as part of +answering this, not after it. + +**I hold the *choice* loosely, not the matrix.** (1) has a real +argument — a uniform rule is easier to reason about, and over-refusal +is safe — but it would refuse the git panel for reasons unrelated to +it, and "safe" refusals that users cannot explain are how a mechanism +gets worked around. If review prefers (1) or (3), the matrix above is +what changes, and **every cell marked "not applicable" must still be +tested as deliberately omitted** (§7) so a future reader cannot mistake +an omission for an oversight. ### Q#DC-3 — what is the type called? @@ -158,7 +246,7 @@ inventing a fallback destination. A continuation with nowhere to land should say so, and #227's adopter should degrade to today's ambient behaviour with a status message rather than silently guessing. -## 6. Verification +## 7. Verification - **A captured destination survives a frontend switch**: capture in A, make B active, commit, and assert the result lands in **A**. This is @@ -168,15 +256,36 @@ behaviour with a status message rather than silently guessing. - **A fabricated destination is still refused** — the existing Q#JR14d guarantee, re-asserted after the rename so the generalization cannot quietly open the hole it was built to close. -- **Each preflight refusal is witnessed by its own case**: frontend - gone, window gone, stale buffer, dedicated window — and, under - Q#DC-2's answer, that the stale-buffer refusal does **not** fire for - a continuation that declared it is not replacing that buffer. -- **`nil` when the frontend has no document window** (Q#DC-4). +- **Every preflight refusal is witnessed by its own case, in BOTH + profiles** (Q#DC-2's matrix): frontend gone, window gone, stale + buffer, dedicated window — each asserted to **refuse** under the + document profile, and each of the three marked "not applicable" + asserted to **NOT refuse** under the panel profile. A deliberately + omitted check that has no test is indistinguishable from a check + someone forgot, and the next reader will restore it. +- **`nil` when the frontend has no document window** (Q#DC-4) — for + the **document** profile. Whether the panel profile can capture + without one follows from Q#DC-2 and is asserted whichever way it is + answered. - **The directory path is unchanged** — dired's existing acceptance - coverage passes untouched. **If any dired test needs editing, the - generalization changed Journey Stage 1a's semantics** and that is a - stop signal, not a fixup. + coverage passes untouched. +- **`tests/journey_acceptance.rs` passes UNCHANGED**, as a named + preservation suite. It carries the established contract this lane + generalizes — 27 `commit_to` references across nine named pins + including `commit_to_refuses_a_forged_destination`, + `commit_to_scopes_and_restores_on_a_normal_return`, + `commit_to_restores_when_the_callback_raises`, + `commit_to_refuses_an_await_and_restores`, + `commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one`, + `a_declining_listener_cannot_redirect_the_destination`, and two + rows already named `preservation_*`. Journey Stage 1a's own framing + treats this suite as a required gate; a lane that generalizes its + substrate does not get to relax that. +- **STOP SIGNAL, for both suites.** If any existing `dired` or + `journey_acceptance` test needs editing, the generalization changed + Journey Stage 1a's semantics. That is cause to stop and report, not + to adjust the test — a suite edited to accommodate the change under + test has stopped being evidence. - **`Handle:await` still refuses inside the scope**, including through `pmacs.async.yield_to_next_tick` if the worker-identity lane's Q#W-7 has landed by then; if it has not, this lane does **not** add that @@ -187,7 +296,7 @@ behaviour with a status message rather than silently guessing. that is #227's adoption, after this lands. This lane ships the mechanism and one set of tests for the mechanism. -## 7. Not in scope +## 8. Not in scope **Adopting the capture anywhere**, including git (#227 does that) and including migrating other async continuations that have the same latent From 6b8e07c73084445826d4b54440541dd062b563d8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:08:43 +0200 Subject: [PATCH 30/66] docs: destination-capture revision 3 --- decide Q#DC-4, pin the gate line Q#DC-4 contradicted Q#DC-2, and on the primary panel API. Q#DC-2 concluded a panel profile depends only on a live frontend, so it can commit with no document window at all; Q#DC-4 still voted to return nil in exactly that case and told git to fall back to ambient behaviour. Those cannot both hold, and the fallback advice was independently wrong: falling back to ambient IS the P1a bug this lane exists to remove. Decided rather than voted on, since it is the primary API. The destination document pair is optional; capture_destination() is profile-blind and argument-free, because making capture profile-aware would force a caller to know at capture time what it will do at commit time, which is the opposite of why capture exists. The profile is declared at commit_to, where Q#DC-2 parameterization already lives, and a document-profile commit with no document pair is refused alongside the other four preflight refusals. Capture never returns nil while a frame exists. Section 4 outline and Q#DC-1 were updated to match rather than left to disagree --- Q#DC-1 no-arguments answer is now load-bearing rather than incidental, because no arguments is what keeps capture profile-blind. The ledger gate line said "new suite plus dired". --acceptance is repeatable, so it now carries the executable command including journey_acceptance and dired_acceptance, both named as preservation suites and a stop signal. A volatile ledger that understates required coverage is how a recovering machine runs a weaker gate than the lane agreed to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 20 ++++++-- docs/destination-capture-framing.md | 73 ++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1d6f6a9..cf615fe 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 2**, in +- **Framing `docs/destination-capture-framing.md`, revision 3**, in review. Revision 2 took three findings: Q#DC-2's parameterization was incomplete (a panel depends on **none** of checks 2–4, not just check 3, so the question now carries a full preflight matrix with every @@ -314,8 +314,22 @@ authoritative tip** — the ref, not a SHA. Recover with - **Stop signal recorded in the framing:** if any existing dired test needs editing, the generalization changed Journey Stage 1a's semantics, and that is cause to stop rather than to adjust the test. -- **Gates:** `scripts/gate --acceptance ` plus dired's. - No `--protocol` — core and Lua bindings only. +- **Gates, as the executable line rather than a description:** + + ``` + scripts/gate --acceptance \ + --acceptance journey_acceptance \ + --acceptance dired_acceptance + ``` + + `--acceptance` is repeatable, so there is no reason for this ledger + to say "plus dired's" and leave the reader to reconstruct it. + **`journey_acceptance` and `dired_acceptance` are preservation suites + and a STOP SIGNAL**: they carry the `commit_to` scope, + forged-userdata, preflight and restoration pins this lane + generalizes, and if either needs editing, the change altered Journey + Stage 1a's semantics rather than closing a gap in them. No + `--protocol` — core and Lua bindings only. ## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 87b3c30..30e52df 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,8 +1,18 @@ # A destination capture any async continuation can use -**Status: framing pass, revision 2. Pre-implementation. Awaiting +**Status: framing pass, revision 3. Pre-implementation. Awaiting approval.** +**Revision 3 decides Q#DC-4, which revision 2 left contradicting +Q#DC-2 — on the primary panel API.** Q#DC-2 concluded a panel needs +only a live frontend; Q#DC-4 still returned `nil` without a document +window and told git to fall back to ambient behaviour, which is the +very bug this lane removes. Resolved: the destination's document pair +is **optional**, `capture_destination()` is **profile-blind and +argument-free**, the profile is declared at `commit_to`, and a +document-profile commit without a document pair is refused. §4 and +Q#DC-1 were updated to match rather than left to disagree. + **Revision 2 takes three review findings.** Q#DC-2's parameterization was **incomplete** — a panel result does not depend on the captured document window being live or non-dedicated either, not just on its @@ -109,7 +119,10 @@ decided, and it is the substance of this lane. ## 4. The change, in outline - **A Lua-reachable capture**, returning the same nonconstructible - userdata for the *current* frontend and its document window. + userdata for the *current* frontend, **with** its document window and + buffer when it has one and without them when it does not (Q#DC-4). + The capture takes no arguments and is profile-blind; the profile is + declared at `commit_to`. - **Generic naming.** `DirectoryDestination` becomes something that does not lie about a git panel; `capture_directory_destination` and the userdata type follow. 8 references (§2). @@ -237,14 +250,45 @@ buffer kind. The Q#JR14 doc comments should keep their references intact; a rename that orphans the rationale is worse than a slightly stale name. -### Q#DC-4 — is the capture refused when there is no document window? +### Q#DC-4 — what happens when there is no document window? **(DECIDED in rev 3)** -`capture_directory_destination` already returns `None` when the -frontend has no document window (`src/editor.rs:1236`). *My vote: -**return `nil`, and require every adopter to handle it***, rather than -inventing a fallback destination. A continuation with nowhere to land -should say so, and #227's adopter should degrade to today's ambient -behaviour with a status message rather than silently guessing. +**Revision 2 left this contradicting Q#DC-2 and it is the primary panel +API, so it is decided here rather than voted on.** Q#DC-2 concluded a +panel profile depends only on a live frontend — so it can commit with +no document window at all — while this question still said the capture +returns `nil` in exactly that case, and told git to fall back to +ambient behaviour. Those cannot both hold, and the fallback advice was +independently wrong: falling back to ambient **is** the P1a bug this +lane exists to remove. + +**The decision:** + +- **`ViewDestination { frontend, window: Option, buffer: + Option }`.** The frontend is always present; the document + pair is optional and absent exactly when the frontend has no document + window. +- **`capture_destination()` is NOT profile-aware and takes no + arguments.** It records what is there. Making capture profile-aware + would force the caller to know at *capture* time what it will do at + *commit* time, which is the opposite of why capture exists — the + whole point is to freeze the truth early and decide later. +- **The profile is declared at `commit_to`**, which is where Q#DC-2's + parameterization already lives. One place makes the decision, and it + is the place that knows. +- **A document-profile commit on a destination with no document pair is + REFUSED**, with a reason naming that, joining the four preflight + refusals rather than being a separate failure mode. +- **Capture therefore never returns `nil`** while a frontend exists, + and the "adopter degrades to ambient" advice is **withdrawn**. An + adopter with nowhere to land gets a refusal it can report; it does + not get permission to guess. + +**What this changes elsewhere, so the decision does not sit alone:** +§4's outline says the capture returns userdata "for the *current* +frontend and its document window" — it returns one for the current +frontend, **with** its document window when there is one. Q#DC-1's "no +arguments" answer is unchanged and now load-bearing rather than +incidental: no arguments is what keeps capture profile-blind. ## 7. Verification @@ -263,10 +307,13 @@ behaviour with a status message rather than silently guessing. asserted to **NOT refuse** under the panel profile. A deliberately omitted check that has no test is indistinguishable from a check someone forgot, and the next reader will restore it. -- **`nil` when the frontend has no document window** (Q#DC-4) — for - the **document** profile. Whether the panel profile can capture - without one follows from Q#DC-2 and is asserted whichever way it is - answered. +- **Capture SUCCEEDS with no document window** (Q#DC-4), returning a + destination whose document pair is absent — asserted as a successful + capture, not as `nil`. +- **A panel-profile commit on that destination SUCCEEDS**, and a + **document-profile commit on it is REFUSED** with a reason naming the + missing document window. Both halves, because asserting only the + refusal would pass on a capture that refuses everything. - **The directory path is unchanged** — dired's existing acceptance coverage passes untouched. - **`tests/journey_acceptance.rs` passes UNCHANGED**, as a named From 1b4046b624bff9abc2c54601929b5d405ee5777e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:18:00 +0200 Subject: [PATCH 31/66] docs: destination-capture revision 4 --- pin the commit_to call shape Revisions 2 and 3 said "the profile is declared at commit_to" and never said how. That is not a detail. The binding accepts exactly (dest, body) today, so without a specified form #227 has no stable API to adopt against, and the promise that existing callers keep their semantics was a hope rather than a contract. Q#DC-5: commit_to(dest, body [, profile]). An optional TRAILING string, typed Option, so there is no arity sniffing and no table-or-function dispatch on argument 2 --- the existing binding chose Value over AnyUserData specifically to keep its error message reachable and naming the rule, and a polymorphic second argument would undo that. Trailing reads badly after a long inline closure, but that is not the call shape in use: dired defines a named local commit at dired.lua:670 and calls commit_to(opts.dest, commit) at :717. Verified, not assumed. Against a named body the trailing profile reads fine. The value set is CLOSED --- document and panel, exactly Q#DC-2 two profiles. A third is a decision, not a spelling. Omitted means document, and that is the load-bearing part: every existing two-argument call keeps all four preflight checks by definition of the signature, so journey_acceptance passing untouched follows from the API shape rather than from care. An unrecognized profile is an ERROR naming the accepted values, not a silent fallback to document. A fallback would hand a caller stricter or looser checks than it asked for, which is the failure the whole parameterization exists to prevent. Its witness asserts the legacy two-argument form through a check the panel profile OMITS --- a stale-buffer refusal --- because asserting merely that it does not error would pass on a call silently downgraded to panel, which is the regression that would quietly void Journey Stage 1a guarantees. Git mapping settled here rather than rediscovered during adoption: *git-status* takes panel, *git-diff* takes document. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 17 ++++++- docs/destination-capture-framing.md | 71 ++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cf615fe..24280f5 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,8 +275,21 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 3**, in - review. Revision 2 took three findings: Q#DC-2's parameterization was +- **Framing `docs/destination-capture-framing.md`, revision 4**, in + review. +- **The public API #227 adopts against (Q#DC-5), pinned so it is a + contract rather than an intention:** + `pmacs.window.commit_to(dest, body [, profile])`. Profile is an + optional trailing string from a **closed** set — `"document"` and + `"panel"`. **Omitted means `"document"`**, so every existing + two-argument caller keeps all four preflight checks *by definition of + the signature*, which is what makes `journey_acceptance` passing + untouched a consequence rather than a hope. An unrecognized or + non-string profile **errors**, naming the accepted values — a silent + fallback would hand a caller different checks than it asked for, + which is the exact failure the parameterization exists to prevent. + Git's mapping is settled here too: `*git-status*` → panel, + `*git-diff*` → document. Revision 2 took three findings: Q#DC-2's parameterization was incomplete (a panel depends on **none** of checks 2–4, not just check 3, so the question now carries a full preflight matrix with every omission testable); `tests/journey_acceptance.rs` joins dired as a diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 30e52df..563f2ba 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,8 +1,19 @@ # A destination capture any async continuation can use -**Status: framing pass, revision 3. Pre-implementation. Awaiting +**Status: framing pass, revision 4. Pre-implementation. Awaiting approval.** +**Revision 4 specifies the call shape the last two revisions kept +referring to without defining.** "The profile is declared at +`commit_to`" named no signature, no value set, no invalid-profile +behaviour, and nothing about the existing two-argument callers — so +#227 had no stable API to adopt and the Journey preservation promise +rested on care rather than contract. Q#DC-5 fixes that: +`commit_to(dest, body [, profile])`, a **closed** two-value set, +**omitted means `"document"`** so every existing call keeps all four +preflight checks by definition, and an unrecognized profile **errors** +rather than falling back. + **Revision 3 decides Q#DC-4, which revision 2 left contradicting Q#DC-2 — on the primary panel API.** Q#DC-2 concluded a panel needs only a live frontend; Q#DC-4 still returned `nil` without a document @@ -250,6 +261,53 @@ buffer kind. The Q#JR14 doc comments should keep their references intact; a rename that orphans the rationale is worse than a slightly stale name. +### Q#DC-5 — the exact Lua call shape for the profile **(new in rev 4)** + +Revisions 2 and 3 said "the profile is declared at `commit_to`" and +never said **how**. That is not a detail: today's binding accepts +exactly `(dest, body)` (`window_panel.rs:453-456`), so without a +specified form #227 has no stable API to adopt against, and the +promise that existing callers keep their semantics is a hope rather +than a contract. + +**The signature:** + +```lua +pmacs.window.commit_to(dest, body) -- document profile +pmacs.window.commit_to(dest, body, "panel") -- panel profile +``` + +- **`profile` is an OPTIONAL THIRD argument**, a string, typed + `Option` at the binding. No arity sniffing, no + table-or-function dispatch on argument 2 — the existing binding + chose `Value` over `AnyUserData` specifically so its error message + would stay *reachable* and name the rule, and a polymorphic second + argument would undo that. +- **Trailing, and readable in practice.** A profile after a long inline + closure would read badly, but that is not the call shape in use: + dired defines `local function commit() … end` and calls + `commit_to(opts.dest, commit)` (`builtin/runtime/dired.lua:670,717`). + Against a named body, `commit_to(dest, commit, "panel")` reads fine. +- **The value set is CLOSED: `"document"` and `"panel"`.** Exactly the + two profiles in Q#DC-2's matrix. Not an open string namespace — a + third profile is a decision, not a spelling. +- **Omitted means `"document"`.** This is the load-bearing part: every + existing `commit_to(dest, fn)` call keeps **all four** preflight + checks, unchanged, by definition of the signature. `journey_acceptance` + passing untouched (§7) then follows from the API shape rather than + from care. +- **An unrecognized profile is an ERROR**, naming the accepted values — + **not** a silent fall back to `"document"`. A fallback would hand a + caller stricter or looser checks than it asked for, which is the + failure mode the whole parameterization exists to prevent. A + non-string profile errors the same way. + +**Which profile each of git's continuations takes**, so #227's adoption +is decided here rather than rediscovered: `*git-status*` → **panel** +(it lands in the bottom panel, `listview.lua:550`); `*git-diff*` → +**document** (it replaces a document window deliberately, +`git.lua:852-854`). + ### Q#DC-4 — what happens when there is no document window? **(DECIDED in rev 3)** **Revision 2 left this contradicting Q#DC-2 and it is the primary panel @@ -274,7 +332,8 @@ lane exists to remove. whole point is to freeze the truth early and decide later. - **The profile is declared at `commit_to`**, which is where Q#DC-2's parameterization already lives. One place makes the decision, and it - is the place that knows. + is the place that knows. **Its exact call shape is Q#DC-5**, which + revisions 2 and 3 left unspecified. - **A document-profile commit on a destination with no document pair is REFUSED**, with a reason naming that, joining the four preflight refusals rather than being a separate failure mode. @@ -307,6 +366,14 @@ incidental: no arguments is what keeps capture profile-blind. asserted to **NOT refuse** under the panel profile. A deliberately omitted check that has no test is indistinguishable from a check someone forgot, and the next reader will restore it. +- **A legacy two-argument `commit_to(dest, body)` gets the DOCUMENT + profile** (Q#DC-5), witnessed by a check the panel profile omits — + a stale-buffer refusal. Asserting merely that it does not error would + pass on a call silently downgraded to the panel profile, which is the + regression that would quietly void Journey Stage 1a's guarantees. +- **An unrecognized profile string is REFUSED**, with a message naming + the accepted values — not silently treated as `"document"`. +- **A non-string profile is refused** the same way. - **Capture SUCCEEDS with no document window** (Q#DC-4), returning a destination whose document pair is absent — asserted as a successful capture, not as `nil`. From a177d61bf306df1afee63800d76b6559718107b7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:26:19 +0200 Subject: [PATCH 32/66] docs: destination-capture revision 5 --- make the profile error reachable Revision 4 API spec contradicted itself at the binding boundary. It required profile: Option AND a pointed error naming "document" and "panel" when a non-string arrives. mlua rejects a number or table during argument conversion, before the closure body runs, so that message was unreachable: a caller passing 42 would have got mlua generic conversion error instead. This is the identical trap the existing binding already documents for dest --- typed Value rather than AnyUserData specifically so the message stays REACHABLE and names the rule --- and revision 4 quoted that comment as its reasoning while repeating the mistake one argument to the right. The profile is now mlua::Value, validated in the body. Nil and absence BOTH mean document, spelled out because a Lua caller threading an optional variable produces nil rather than absence and a third behaviour there would stay invisible until someone hit it. A non-string is refused by the same message that names the accepted values. The verification bullet is now the guard on the type choice rather than on the behaviour: the non-string refusal is asserted ON ITS CONTENT, so retyping the argument to Option later stops the assertion matching rather than silently degrading the error a user sees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 9 ++++- docs/destination-capture-framing.md | 60 +++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 24280f5..ad3f2da 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,12 +275,17 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 4**, in +- **Framing `docs/destination-capture-framing.md`, revision 5**, in review. - **The public API #227 adopts against (Q#DC-5), pinned so it is a contract rather than an intention:** `pmacs.window.commit_to(dest, body [, profile])`. Profile is an - optional trailing string from a **closed** set — `"document"` and + optional trailing argument typed **`mlua::Value`, not + `Option`** — with `Option` mlua rejects a number or + table during argument *conversion*, before the closure runs, making + the promised "accepted values are…" message unreachable. That is the + same trap the existing binding documents for `dest`. Validated in the + body against a **closed** set — `"document"` and `"panel"`. **Omitted means `"document"`**, so every existing two-argument caller keeps all four preflight checks *by definition of the signature*, which is what makes `journey_acceptance` passing diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 563f2ba..798ddfc 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,8 +1,18 @@ # A destination capture any async continuation can use -**Status: framing pass, revision 4. Pre-implementation. Awaiting +**Status: framing pass, revision 5. Pre-implementation. Awaiting approval.** +**Revision 5 fixes a binding-level contradiction in revision 4's own +API spec.** It required `profile: Option` *and* a pointed error +naming the accepted values for a non-string — but mlua rejects a +number or table during argument conversion, before the closure runs, so +that message was unreachable. This is the exact trap the existing +binding documents for `dest`, in a comment revision 4 quoted while +repeating the mistake one argument to the right. The profile is now +`mlua::Value`, validated in the body, with `nil` and absence both +meaning `"document"`. + **Revision 4 specifies the call shape the last two revisions kept referring to without defining.** "The profile is declared at `commit_to`" named no signature, no value set, no invalid-profile @@ -277,12 +287,36 @@ pmacs.window.commit_to(dest, body) -- document profile pmacs.window.commit_to(dest, body, "panel") -- panel profile ``` -- **`profile` is an OPTIONAL THIRD argument**, a string, typed - `Option` at the binding. No arity sniffing, no - table-or-function dispatch on argument 2 — the existing binding - chose `Value` over `AnyUserData` specifically so its error message - would stay *reachable* and name the rule, and a polymorphic second - argument would undo that. +- **`profile` is an OPTIONAL THIRD argument, typed `mlua::Value` at + the binding — NOT `Option`.** + + **Revision 4 said `Option` and that contradicted its own + error requirement.** mlua rejects a number or table *during argument + conversion*, before the closure body runs, so the promised message + naming `"document"` and `"panel"` would be **unreachable** — a caller + passing `42` would get mlua's generic conversion error instead. This + is the identical trap the existing binding already documented for + `dest`, in a comment revision 4 cited while making the same mistake + one argument to the right: + + > Typed as `Value` rather than `AnyUserData` so this message is + > REACHABLE: with the narrower type mlua rejects a table during + > argument conversion, and a caller who fabricated one got "error + > converting Lua table to userdata" — true, but it names neither the + > rule nor how to get a real destination. + + So: accept `Value`, and validate in the body. + - **`Nil` or absent → `"document"`.** Both spellings, since + `commit_to(dest, body, nil)` is what a Lua caller threading an + optional variable produces, and it must not be a third behaviour. + - **`String` → must be `"document"` or `"panel"`**, else refused, + naming both accepted values. + - **Anything else → refused by the SAME message**, which now names + the accepted values *and* says a string was expected. That message + only exists if the type is `Value`. +- No arity sniffing and no table-or-function dispatch on argument 2 — + a polymorphic second argument would put the *destination*'s error + message back at risk, which is what that comment was protecting. - **Trailing, and readable in practice.** A profile after a long inline closure would read badly, but that is not the call shape in use: dired defines `local function commit() … end` and calls @@ -373,7 +407,17 @@ incidental: no arguments is what keeps capture profile-blind. regression that would quietly void Journey Stage 1a's guarantees. - **An unrecognized profile string is REFUSED**, with a message naming the accepted values — not silently treated as `"document"`. -- **A non-string profile is refused** the same way. +- **A non-string profile (a number, a table) is refused by that SAME + message**, asserted **on its content**, not merely that an error + occurred. This is the bullet that fails if the argument is ever + retyped to `Option`: mlua would reject the value during + conversion and the assertion on the message would stop matching. The + test is therefore the guard on the type choice, not just on the + behaviour. +- **An explicit `nil` profile takes the document profile**, identical + to omitting it — witnessed separately, because a Lua caller threading + an optional variable produces `nil` rather than absence, and a third + behaviour there would be invisible until someone hit it. - **Capture SUCCEEDS with no document window** (Q#DC-4), returning a destination whose document pair is absent — asserted as a successful capture, not as `nil`. From 9fee5618eefeca2478e14962717a8f1b8d40efe1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:59:34 +0200 Subject: [PATCH 33/66] feat(window): a destination any async continuation can capture Journey Stage 1a built `pmacs.window.commit_to` for the continuation boundary --- "the listing settles a tick or more later, and by then the ambient frontend, selected window, and active buffer may all name something else" --- but nothing outside the `path.open-directory` dispatch could mint a destination to hand it. Every other async Lua continuation therefore resolved its target from ambient state a tick after the request, which is PR #227's P1a finding: run `git.status` in frontend A, let B become active, and A's panel opens in B. This is the prerequisite lane #227 blocks on (`docs/destination-capture-framing.md`, revision 5). No adopter here: git's adoption is #227's work, since a prerequisite that converts its own first consumer cannot be reviewed separately from it. Three parts. **`pmacs.window.capture_destination()`** returns the same nonconstructible userdata for the current frontend. No arguments, and that is load-bearing rather than minimal (Q#DC-1): a Lua-supplied frontend id would reintroduce exactly the fabrication hole the userdata design closes. Profile-blind for the same kind of reason (Q#DC-4) --- capture freezes what is true now, and what a commit depends on is declared later, at the commit. **`DirectoryDestination` -> `ViewDestination`**, with the Lua userdata and the capture renamed to match. The captured triple was already generic; only its name and its capture site were not. The document pair is now `Option`, set and cleared together, so a frontend with no live document window still captures rather than returning nothing and sending the caller back to the ambient state this exists to replace. **`commit_to(dest, body [, profile])`** (Q#DC-2/Q#DC-5), a closed set of two. The document profile keeps all four preflight checks. The panel profile keeps only the first --- the requesting frontend still has a layout --- because a panel result does not occupy the captured document window, does not replace its buffer, and does not need it to exist, so each of the other three would refuse for a reason unrelated to what the continuation does. Omitting the profile means `"document"`, which is what makes the preservation promise contractual rather than careful: every existing two-argument caller keeps all four checks by definition of the signature. The profile argument is typed `mlua::Value`, NOT `Option`, so its error is REACHABLE: with the narrower type mlua rejects a number or a table during argument conversion, before the closure body runs, and the message naming the accepted values never appears. That is the same trap the `dest` argument documents one position to its left. `nil` and absence are the same answer; anything else is refused by one message that names both accepted values. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 2 +- src/editor.rs | 28 +++-- src/editor_core.rs | 81 +++++++++---- src/lua_bindings/mod.rs | 33 ++++-- src/lua_bindings/window_panel.rs | 189 +++++++++++++++++++++++++------ 5 files changed, 259 insertions(+), 74 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index fff5e21..00c648b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1801,7 +1801,7 @@ fn open_initial_target( let (buffer_id, fire) = match resolved { crate::editor_core::ResolvedTarget::Directory { path } => { let dest = editor - .capture_directory_destination(frontend_id, origin_window) + .capture_view_destination(frontend_id, origin_window) .ok_or_else(|| format!("cannot open {}: no document window", path.display()))?; editor.dispatch_directory_open(&path, dest); editor.reconcile_panel_layout(frontend_id); diff --git a/src/editor.rs b/src/editor.rs index 8d80a6c..eb58138 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1219,22 +1219,32 @@ impl EditorState { } /// Capture the destination a directory open must commit to - /// (Q#JR14), or `None` when `frontend` has no document window. + /// (Q#JR14), or `None` when `window` is gone. /// /// Synchronous by necessity: the listing settles a tick or more /// later, and by then the ambient frontend, selected window, and /// active buffer may all name something else. - pub(crate) fn capture_directory_destination( + /// + /// Takes the window **explicitly**, unlike + /// [`crate::editor_core::EditorCore::capture_view_destination`], + /// which reads the ambient one. Both directory callers already hold + /// the exact window the open was resolved against — the daemon's is + /// read before `resolve_target_buffer` runs (Q#BP11b) — and + /// recapturing it from ambient state here would discard that. + /// A directory open therefore always yields a full document pair, + /// which is why this keeps returning `Option` rather than the total + /// capture's `ViewDestination`. + pub(crate) fn capture_view_destination( &self, frontend: crate::protocol::FrontendId, window: crate::window::WindowId, - ) -> Option { + ) -> Option { let core = self.core.borrow(); let buffer = core.windows.get(&window)?.buffer_id; - Some(crate::editor_core::DirectoryDestination { + Some(crate::editor_core::ViewDestination { frontend, - window, - buffer, + window: Some(window), + buffer: Some(buffer), }) } @@ -1258,7 +1268,7 @@ impl EditorState { .borrow() .primary_document_window(crate::protocol::FrontendId::LOCAL); let dest = window.and_then(|window| { - self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window) + self.capture_view_destination(crate::protocol::FrontendId::LOCAL, window) }); let Some(dest) = dest else { self.core.borrow_mut().status = @@ -1288,13 +1298,13 @@ impl EditorState { pub(crate) fn dispatch_directory_open( &mut self, path: &std::path::Path, - dest: crate::editor_core::DirectoryDestination, + dest: crate::editor_core::ViewDestination, ) { let display = path.display().to_string(); let args = { let lua = self.lua_host.lua(); let destination = - match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) { + match lua.create_userdata(crate::lua_bindings::ViewDestinationLua(dest)) { Ok(userdata) => mlua::Value::UserData(userdata), Err(error) => { self.core.borrow_mut().status = format!("cannot open {display}: {error}"); diff --git a/src/editor_core.rs b/src/editor_core.rs index 0976756..0243968 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -130,39 +130,55 @@ pub enum ResolvedTarget { }, } -/// Where a directory open was requested, captured **synchronously** at -/// resolve time (Journey Stage 1a, Q#JR14). +/// Where an asynchronous continuation's result belongs, captured +/// **synchronously** at request time (Journey Stage 1a, Q#JR14; +/// generalized by `docs/destination-capture-framing.md`). /// -/// The listing that satisfies a directory open is asynchronous -/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the -/// code that finally builds and displays the listing runs a tick or more -/// later — outside interactive dispatch, where `pmacs.window.*` acts on -/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`). -/// Without a captured destination, a second frontend dispatching in the -/// meantime silently redirects the listing. +/// The work that satisfies such a request is asynchronous (a directory +/// listing is worker-dispatched and must be awaited; so is a `git` +/// invocation), so the code that finally builds and displays the result +/// runs a tick or more later — outside interactive dispatch, where +/// `pmacs.window.*` acts on the *ambient* frontend by documented design +/// (`builtin/runtime/dired.lua`). Without a captured destination, a +/// second frontend dispatching in the meantime silently redirects the +/// result. /// -/// All three fields are load-bearing: +/// The fields are load-bearing, and the document pair is **optional** +/// (Q#DC-4) because a frontend showing only a side window can still host +/// a panel result: /// -/// * `frontend` — the scope the commit must run in. +/// * `frontend` — the scope the commit must run in. Always present. /// * `window` — the exact destination; the ambient selected window is -/// not it. +/// not it. Absent when the frontend had no document window at capture +/// time. /// * `buffer` — what that window held at capture time, so **stale /// intent loses to the user** (Q#JR14c). A user who replaced the -/// buffer while the listing was in flight is newer information than -/// the launch argument, and must not be overwritten. +/// buffer while the work was in flight is newer information than the +/// launch argument, and must not be overwritten. Present exactly when +/// `window` is. +/// +/// The pair is set or cleared together — see +/// [`EditorCore::capture_view_destination`], which is the only place +/// that reads them off ambient state. +/// +/// Which of those a commit actually requires is the **profile**, chosen +/// at `pmacs.window.commit_to` rather than at capture (Q#DC-2/Q#DC-5): +/// the document profile requires all of them, the panel profile requires +/// only a live `frontend`. Capture stays profile-blind so a caller does +/// not have to know at capture time what it will do at commit time. /// /// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a /// table, the *same* value is handed to every resolver listener in turn, /// so one could mutate it and then decline — redirecting later listeners /// — and any Lua could fabricate a plausible triple. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct DirectoryDestination { - /// Frontend that requested the directory. +pub struct ViewDestination { + /// Frontend that requested the work. pub frontend: FrontendId, - /// Window the listing must land in. - pub window: WindowId, + /// Window the result must land in, when there is one. + pub window: Option, /// Buffer that window held at capture time (stale-intent check). - pub buffer: BufferId, + pub buffer: Option, } /// A `display_buffer` request (Q#BP3). @@ -3042,6 +3058,33 @@ impl EditorCore { self.non_side_target(fid).ok() } + /// Capture where `fid`'s next asynchronous result belongs (Q#JR14, + /// generalized by Q#DC-1/Q#DC-4). + /// + /// **Profile-blind and total**: it records what is there rather than + /// what a caller intends to do later, and it never fails while a + /// frontend id exists. A frontend with no document window yields a + /// destination carrying only `frontend` — enough for a panel commit, + /// and refused by a document commit with a reason naming the missing + /// window. Returning `None` here instead would push the caller back + /// onto ambient state, which is the misrouting the capture exists to + /// remove. + /// + /// The document pair is set or cleared **together**: a window whose + /// entry has gone yields neither half, so no consumer has to handle + /// a window without its captured buffer. + #[must_use] + pub fn capture_view_destination(&self, fid: FrontendId) -> ViewDestination { + let pair = self + .primary_document_window(fid) + .and_then(|window| Some((window, self.windows.get(&window)?.buffer_id))); + ViewDestination { + frontend: fid, + window: pair.map(|(window, _)| window), + buffer: pair.map(|(_, buffer)| buffer), + } + } + /// [`Self::primary_document_window`]'s buffer, falling back to the /// focused window's when the layout is degenerate. #[must_use] diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b2de320..d4386d5 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -4239,25 +4239,34 @@ fn install_path_module(lua: &Lua) -> mlua::Result { Ok(path) } -/// Lua handle for a captured directory destination (Q#JR14d). +/// Lua handle for a captured view destination (Q#JR14d). /// -/// Deliberately **nonconstructible from Lua** and read-only. The same -/// value is passed to every `path.open-directory` listener in turn: as a -/// table, an earlier listener could mutate it and then decline, -/// redirecting later listeners or the fallback to a window the user -/// never asked for — and any Lua could fabricate a plausible -/// frontend/window/buffer triple and hand it to `commit_to`. Userdata -/// with no constructor and no setters makes both unrepresentable rather -/// than merely discouraged. +/// Deliberately **nonconstructible from Lua** and read-only, which the +/// generalization to `pmacs.window.capture_destination()` preserves: +/// capture mints one from editor state, and there is still no +/// constructor and no setter. The same value is passed to every +/// `path.open-directory` listener in turn: as a table, an earlier +/// listener could mutate it and then decline, redirecting later +/// listeners or the fallback to a window the user never asked for — and +/// any Lua could fabricate a plausible frontend/window/buffer triple and +/// hand it to `commit_to`. Userdata with no constructor and no setters +/// makes both unrepresentable rather than merely discouraged. /// /// The single accessor exists because dired needs the exact window for /// its `display{window = …}` target; nothing needs the frontend or the /// captured buffer, which stay private to the preflight. -pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination); +/// +/// `window()` returns **nil** when the capturing frontend had no +/// document window (Q#DC-4) — such a destination is still commitable +/// under the panel profile, so the accessor reports the absence rather +/// than inventing an id. +pub(crate) struct ViewDestinationLua(pub(crate) crate::editor_core::ViewDestination); -impl mlua::UserData for DirectoryDestinationLua { +impl mlua::UserData for ViewDestinationLua { fn add_methods>(methods: &mut M) { - methods.add_method("window", |_, this, ()| Ok(this.0.window.raw())); + methods.add_method("window", |_, this, ()| { + Ok(this.0.window.map(crate::window::WindowId::raw)) + }); } } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index fe8b758..11e92b7 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -63,6 +63,65 @@ pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { .unwrap_or_else(|| core.borrow().active_frontend_key()) } +/// Which of `commit_to`'s preconditions a body actually depends on +/// (Q#DC-2). +/// +/// A **closed** set of two, not an open string namespace: a third +/// profile is a decision about what a continuation may depend on, not a +/// spelling. Chosen at `commit_to` rather than at capture, because the +/// caller knows what it is about to do only then. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CommitProfile { + /// The body replaces the captured window's buffer: **all four** + /// preflight checks apply. This is what an omitted profile means, + /// so every caller written before the profile existed keeps exactly + /// the guarantees it was written against. + Document, + /// The body puts its result somewhere that is not the captured + /// document window — a bottom panel, typically. Only the "requesting + /// frontend still has a layout" check applies; see the preflight for + /// why each of the other three is *deliberately* omitted. + Panel, +} + +/// One message for every bad profile — an unrecognized string and a +/// non-string alike (Q#DC-5). +/// +/// Stated once so the parser and the message cannot drift, and phrased +/// to name the accepted values *and* the default, because a caller who +/// gets this wrong is guessing at the vocabulary. +const BAD_COMMIT_PROFILE: &str = "pmacs.window.commit_to: profile must be the string \"document\" \ + or \"panel\" (omitting it, or passing nil, means \"document\")"; + +/// Resolve the optional third argument of `commit_to`. +/// +/// Takes a [`Value`] rather than an `Option` **so this refusal +/// is reachable**: with the narrower type mlua rejects a number or a +/// table during argument conversion, before the closure body runs, and +/// the caller gets a generic conversion error that names neither the +/// accepted values nor the default. That is the same trap the `dest` +/// argument documents at its own borrow site. +/// +/// `Nil` and absence are the **same** answer, not two: a Lua caller +/// threading an optional variable produces `commit_to(dest, body, nil)`, +/// and a third behaviour there would stay invisible until someone hit +/// it. +fn commit_profile(value: &Value) -> mlua::Result { + match value { + Value::Nil => Ok(CommitProfile::Document), + Value::String(name) => match &*name.to_str()? { + "document" => Ok(CommitProfile::Document), + "panel" => Ok(CommitProfile::Panel), + // An unrecognized profile ERRORS rather than falling back to + // the document one: a fallback would silently hand a caller + // stricter or looser checks than it asked for, which is the + // failure the parameterization exists to prevent. + _ => Err(mlua::Error::runtime(BAD_COMMIT_PROFILE)), + }, + _ => Err(mlua::Error::runtime(BAD_COMMIT_PROFILE)), + } +} + /// Run the panel-reconciliation transaction from a Lua-owning context /// (Q#BP2b). /// @@ -452,7 +511,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result "commit_to", lua.create_function( move |lua, - (dest, body): (mlua::Value, mlua::Function)| + (dest, body, profile): (mlua::Value, mlua::Function, mlua::Value)| -> mlua::Result { // Journey Stage 1a (Q#JR14). Preflight FIRST, then // scope, then run. The ordering is the whole point: @@ -472,7 +531,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result // rule nor how to get a real destination. let dest = match &dest { mlua::Value::UserData(userdata) => { - userdata.borrow::().ok() + userdata.borrow::().ok() } _ => None, }; @@ -484,43 +543,74 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result ) })? .0; + // Q#DC-5. Resolved AFTER the destination so a caller + // who got both wrong hears about the destination + // first --- it is the argument that cannot be fixed + // by reading this signature. + let profile = commit_profile(&profile)?; - // 1. The requesting frontend still has a layout. let refusal = { let core = cc.borrow(); + // 1. The requesting frontend still has a layout. + // Required by BOTH profiles: it is the whole + // of the panel profile (Q#DC-2), because a + // frontend that is gone can host nothing. if !core.views.contains_key(&dest.frontend) { Some("requesting frontend is gone".to_string()) - } else if !core - .views - .get(&dest.frontend) - .is_some_and(|view| view.layout.iter_ids().contains(&dest.window)) - { - // 2. The destination window is still live in it. - Some(format!("window {} is gone", dest.window.raw())) - } else if core - .windows - .get(&dest.window) - .is_some_and(|w| w.buffer_id != dest.buffer) - { - // 3. Stale intent (Q#JR14c): the user - // replaced the buffer while the work was - // in flight. Their action is newer - // information than the request, so the - // request loses. - Some(format!( - "window {} now shows another buffer", - dest.window.raw() - )) - } else if !core.window_accepts_buffer(dest.window, None) { - // 4. Replaceability (Q#JR14f). `None` - // because the replacement does not exist - // yet — passing the captured buffer would - // approve a window dedicated to *it*, and - // the handler's different buffer would be - // refused later, after mutating. - Some(format!("window {} is dedicated", dest.window.raw())) - } else { + } else if profile == CommitProfile::Panel { + // 2, 3 and 4 are DELIBERATELY OMITTED here, + // not overlooked (Q#DC-2). A panel result + // does not occupy the captured document + // window, does not replace its buffer, and + // does not need it to exist --- so each of + // those checks would refuse for a reason + // unrelated to what the continuation does, + // and a refusal a user cannot explain is how + // a mechanism gets worked around. Every one + // of the three is pinned as NOT refusing + // under this profile. None + } else if let Some(window) = dest.window { + if !core + .views + .get(&dest.frontend) + .is_some_and(|view| view.layout.iter_ids().contains(&window)) + { + // 2. The destination window is still live in it. + Some(format!("window {} is gone", window.raw())) + } else if core + .windows + .get(&window) + .is_some_and(|w| Some(w.buffer_id) != dest.buffer) + { + // 3. Stale intent (Q#JR14c): the user + // replaced the buffer while the work was + // in flight. Their action is newer + // information than the request, so the + // request loses. + Some(format!("window {} now shows another buffer", window.raw())) + } else if !core.window_accepts_buffer(window, None) { + // 4. Replaceability (Q#JR14f). `None` + // because the replacement does not exist + // yet — passing the captured buffer would + // approve a window dedicated to *it*, and + // the handler's different buffer would be + // refused later, after mutating. + Some(format!("window {} is dedicated", window.raw())) + } else { + None + } + } else { + // The capture found no document window + // (Q#DC-4). A refusal rather than a raise, so + // it joins the four above as one more thing + // the destination can fail to satisfy and an + // adopter handles it the same way. + Some( + "destination has no document window (capture it from a frontend \ + that has one, or commit with the \"panel\" profile)" + .to_string(), + ) } }; if let Some(reason) = refusal { @@ -563,6 +653,39 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result )?; } + { + // Q#DC-1 — the capture half, reachable from Lua at last. + // + // Journey Stage 1a built `commit_to` for the continuation + // boundary, but the only thing that could mint a destination was + // the `path.open-directory` dispatch, so every other async + // continuation had to resolve its target from ambient state a + // tick after the request --- which is a misrouting waiting for a + // second frontend to become active. + // + // NO ARGUMENTS, and that is load-bearing rather than + // minimalism. A Lua-supplied frontend id would reintroduce + // exactly the fabrication hole the nonconstructible userdata + // closes (Q#JR14d): the point of userdata is that Lua names a + // destination it was *given*, never one it composed. + // + // PROFILE-BLIND, likewise (Q#DC-4). Capture records what is + // there; what a commit depends on is declared at `commit_to`, + // because a caller knows what it is about to do only then. + // Making capture profile-aware would force it to know at capture + // time what it will do at commit time, which is the opposite of + // why capture exists --- freeze the truth early, decide later. + let cc = core.clone(); + win.set( + "capture_destination", + lua.create_function(move |lua, ()| { + let fid = acting_frontend(lua, &cc); + let dest = cc.borrow().capture_view_destination(fid); + lua.create_userdata(super::ViewDestinationLua(dest)) + })?, + )?; + } + { // Q#S3-1 — the shared adopter-display rule, reachable from Lua. // From 96c7e466f117a51217a103ca924be4cbf7f96fb4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:59:55 +0200 Subject: [PATCH 34/66] test(destination): pin the capture, the profile, and both matrix columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/destination-capture-framing.md` §7, eight tests. The one that decides the lane is `the_preflight_matrix_holds_in_both _profiles`. Every cell Q#DC-2 marks "not applicable" for the panel profile is asserted as NOT refusing, not merely left untested: a check deliberately omitted and a check someone forgot look identical from the outside, and the next reader restores the second one. The document column re-asserts all four refusals in the same table, so a mutation that collapses the two profiles fails one column or the other. `a_bad_profile_is_refused_by_one_message_that_names_the_accepted_values` is the guard on the argument's TYPE, not only on its behaviour. It asserts the number, table and boolean cases produce the same message as an unrecognized string --- which stops being true the moment the argument is retyped to `Option`, because mlua then rejects the value during argument conversion and the pointed message is never reached. `a_captured_destination_survives_a_frontend_switch` runs under both profiles. The panel profile drops three of the four preflight checks, and a plausible way to implement that is to drop the frontend scope with them --- which would leave a panel continuation resolving its target from ambient state, the exact defect the lane removes. `a_two_argument_commit_takes_the_document_profile` witnesses the default through a check the panel profile omits (a stale buffer), because asserting merely that a legacy call does not error would pass on one silently downgraded to the panel profile. ONE FINDING, RECORDED IN THE TEST RATHER THAN WORKED AROUND. Q#DC-4's "a frontend with no document window" reads as a frontend showing only a bottom panel, and that state is asserted impossible: Q#BP6 says a layout always retains at least one non-side window, and `non_side_target` carries a `debug_assert!` that fires under `cargo test` if one ever does. So with Q#BP6 held, a registered frontend in a healthy editor always has a live document window, and the absent document pair is a DEFENSIVE branch rather than a routine one. The decision still stands --- capture stays total, and an adopter with nowhere to land gets a refusal naming that rather than permission to guess --- and the two Q#DC-4 pins drive the reachable spelling of the same condition: a layout whose document window has gone while the view remains. The helper says so at its definition. `tests/journey_acceptance.rs` (47) and `tests/dired_acceptance.rs` (31) pass UNCHANGED, which is §7's stop signal and the reason the profile default is the document one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- tests/destination_capture_acceptance.rs | 635 ++++++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 tests/destination_capture_acceptance.rs diff --git a/tests/destination_capture_acceptance.rs b/tests/destination_capture_acceptance.rs new file mode 100644 index 0000000..534d90c --- /dev/null +++ b/tests/destination_capture_acceptance.rs @@ -0,0 +1,635 @@ +// tests/destination_capture_acceptance.rs --- the Lua-reachable capture. + +//! Acceptance for `docs/destination-capture-framing.md` §7: a +//! destination any asynchronous continuation can capture, and the +//! profile that says which of `commit_to`'s preconditions it depends on +//! (Q#DC-1 … Q#DC-5). +//! +//! **What this suite does NOT prove**, deliberately: that git — or any +//! other adopter — surfaces in the right frontend. This lane ships the +//! mechanism and the tests for the mechanism; adoption is #227's, after +//! it lands (§8). Every test here therefore drives the Lua surface +//! directly rather than through a consumer. +//! +//! Two disciplines it keeps: +//! +//! * **Every "not applicable" cell in Q#DC-2's preflight matrix is +//! asserted as NOT refusing**, not merely left untested. A check +//! deliberately omitted and a check someone forgot look identical from +//! the outside, and the next reader restores the second one. +//! * **A refusal is asserted on its reason**, never on the mere fact +//! that something failed. `commit_to` has five distinct refusals and a +//! raise; "it errored" would pass on any of the wrong ones. +//! +//! `tests/journey_acceptance.rs` and `tests/dired_acceptance.rs` are the +//! preservation half of the same §7 and are run alongside this suite: +//! they hold the Stage 1a contract this lane generalizes, and if either +//! needed editing the generalization changed Journey semantics rather +//! than extending them. + +use pmacs::buffer::BufferId; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// A fresh editor with LSP disabled: no test here asserts anything about +/// a language server, so the wipe cannot make an assertion vacuous. +fn editor() -> EditorState { + let s = EditorState::new_with_roots(&crate::iso::roots()); + exec(&s, "pmacs.lsp.config = {}"); + s +} + +/// A directory with a file worth displaying. +fn project() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("alpha.txt"), b"alpha\n").expect("write alpha"); + td +} + +fn active_name(s: &EditorState) -> String { + eval(s, "return pmacs.window.buffer():name()") +} + +fn buffer_in(s: &EditorState, window: WindowId) -> Option { + s.core.borrow().windows.get(&window).map(|w| w.buffer_id) +} + +fn local_window(s: &EditorState) -> WindowId { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .active +} + +/// The frontend that competes for ambient authority. +const COMPETITOR: FrontendId = FrontendId(7); + +/// A frontend that has a layout but no live document window (Q#DC-4). +const DOCUMENTLESS: FrontendId = FrontendId(9); + +/// Register a second frontend with its own single-window layout, +/// mirroring `build_fresh_frontend_view` — the same helper shape +/// `journey_acceptance` and `bottom_panel_stage1_acceptance` use. +fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId { + let win = WindowId::next(); + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view(fid, view_over(win)); + win +} + +/// Register a frontend whose layout names a window that is **not live**, +/// so `primary_document_window` finds nothing to hand back. +/// +/// **Why this shape and not a side-window-only layout.** The obvious +/// reading of "a frontend with no document window" is a frontend showing +/// only a bottom panel — but that state is asserted impossible: Q#BP6 +/// says a layout always retains at least one non-side window, and +/// `EditorCore::non_side_target` carries a `debug_assert!` that fires +/// under `cargo test` if one ever does. So the reachable spelling of the +/// same condition is a layout whose document window has gone while the +/// view remains, which is what this builds. +/// +/// **Recorded honestly, because the framing implies more than the tree +/// does** (`docs/destination-capture-framing.md` Q#DC-4): with Q#BP6 +/// held, a *registered* frontend in a healthy editor always has a live +/// document window, so the absent document pair is a **defensive** +/// branch rather than a routine one. It is still the right decision — +/// capture stays total, and an adopter with nowhere to land gets a +/// refusal naming that rather than permission to fall back to ambient +/// state — and it is still worth pinning, because the alternative to +/// pinning it is a branch nothing ever executes. +fn attach_documentless_frontend(s: &EditorState, fid: FrontendId) { + let mut core = s.core.borrow_mut(); + core.register_frontend_view(fid, view_over(WindowId::next())); +} + +fn view_over(win: WindowId) -> FrontendView { + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + } +} + +/// Capture through the **production** Lua entry point and leave the +/// userdata in the global `dest`. +/// +/// Nothing in this suite can construct one by any other route — that is +/// what `a_forged_destination_is_still_refused` is about — so every test +/// below runs against a destination the editor minted. +fn capture(s: &EditorState) { + exec(s, "dest = pmacs.window.capture_destination()"); + assert!( + eval::(s, "return dest ~= nil"), + "the capture must always yield a destination while a frontend exists" + ); +} + +/// Run `body` under `profile` and report `(ok, reason)`. +/// +/// `profile` is spliced as a Lua expression, so a caller can pass +/// `"nil"`, `"'panel'"`, `"42"` — the argument-shape distinctions +/// Q#DC-5 turns on are exactly what this suite has to vary. +fn commit(s: &EditorState, profile: Option<&str>) { + let call = match profile { + Some(profile) => format!("pmacs.window.commit_to(dest, body, {profile})"), + None => "pmacs.window.commit_to(dest, body)".to_string(), + }; + exec( + s, + &format!( + "ran = false + local body = function() ran = true end + raised = nil + local caught, a, b = pcall(function() return {call} end) + if caught then ok, reason = a, b + else ok, reason, raised = false, nil, tostring(a) end" + ), + ); +} + +fn ok(s: &EditorState) -> bool { + eval(s, "return ok == true") +} + +fn ran(s: &EditorState) -> bool { + eval(s, "return ran") +} + +fn reason(s: &EditorState) -> String { + eval(s, "return tostring(reason)") +} + +/// The message a raise (as opposed to a `(false, reason)` refusal) +/// carried, or `None` if nothing was raised. +fn raised(s: &EditorState) -> Option { + eval::>(s, "return raised") +} + +// --------------------------------------------------------------------------- +// §7 — a captured destination survives a frontend switch +// --------------------------------------------------------------------------- + +/// **N** — the failure the lane exists for: the result lands in the +/// frontend that *asked*, not in whichever one is ambient when the work +/// settles. +/// +/// Asserted for **both** profiles. The panel profile drops three of the +/// four preflight checks, and a plausible way to implement that is to +/// drop the scope with them — which would leave a panel continuation +/// resolving its target from ambient state, the exact P1a defect. So the +/// scope is pinned per profile rather than once. +/// +/// Falsified by making the commit display ambiently: the file then +/// appears in the competitor's window. Asserting merely that +/// `capture_destination()` returns userdata would pass on a capture that +/// does nothing. +#[test] +fn a_captured_destination_survives_a_frontend_switch() { + for profile in [None, Some("'panel'")] { + let td = project(); + let s = editor(); + capture(&s); + + let local_win = local_window(&s); + let other_win = attach_frontend(&s, COMPETITOR); + let other_before = buffer_in(&s, other_win); + + // The competitor becomes the dispatching frontend while the work + // is "in flight" — the state a worker completion returns to. + s.core.borrow_mut().active_frontend = COMPETITOR; + + let alpha = td.path().join("alpha.txt").display().to_string(); + exec( + &s, + &format!( + "committed = pmacs.window.commit_to(dest, function() + pmacs.window.display_file({alpha:?}) + end{})", + profile.map_or(String::new(), |p| format!(", {p}")) + ), + ); + + assert!( + eval::(&s, "return committed"), + "{profile:?}: the commit must be accepted" + ); + assert_eq!( + buffer_in(&s, other_win), + other_before, + "{profile:?}: the competing frontend's window must be untouched" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert_eq!( + active_name(&s), + alpha, + "{profile:?}: the commit must land in the capturing frontend's window" + ); + assert_eq!( + local_window(&s), + local_win, + "{profile:?}: and in that window, not a new one" + ); + } +} + +// --------------------------------------------------------------------------- +// §7 — the forged destination stays refused +// --------------------------------------------------------------------------- + +/// **P (Q#JR14d)** — generalizing the capture does not widen what +/// extension code can fabricate. +/// +/// A plausible `{frontend, window, buffer}` table is what any Lua could +/// build, and the capture now hands out the *same* userdata type through +/// a public entry point — so the type check is re-asserted after the +/// rename rather than assumed to have survived it. +/// +/// *Mutation:* accept `mlua::Value::Table` in the borrow arm. This +/// fails; nothing in `journey_acceptance` covers the new entry point. +#[test] +fn a_forged_destination_is_still_refused() { + let s = editor(); + capture(&s); + let win = eval::(&s, "return dest:window()"); + + exec( + &s, + &format!( + "ran = false + local caught, err = pcall(pmacs.window.commit_to, + {{ frontend = 0, window = {win}, buffer = 0 }}, + function() ran = true end) + rejected = (not caught) and tostring(err) or ''" + ), + ); + + let rejected: String = eval(&s, "return rejected"); + assert!( + rejected.contains("cannot be constructed from Lua"), + "a forged table must be rejected by type, not merely fail later; got {rejected:?}" + ); + assert!( + !ran(&s), + "a rejected destination must not reach the callback" + ); +} + +// --------------------------------------------------------------------------- +// §7 — the preflight matrix, in BOTH profiles (Q#DC-2) +// --------------------------------------------------------------------------- + +/// **N** — each of the four preconditions refuses under the document +/// profile, and each of the three the panel profile omits does **not** +/// refuse under it. +/// +/// This is the substance of Q#DC-2. The matrix: +/// +/// | # | precondition | document | panel | +/// |---|--------------|----------|-------| +/// | 1 | frontend has a layout | required | **required** | +/// | 2 | window still live | required | not applicable | +/// | 3 | window still shows the captured buffer | required | not applicable | +/// | 4 | window is not dedicated | required | not applicable | +/// +/// The panel column is the half that could not be written before this +/// lane, and the half most at risk of being "fixed" later by someone who +/// reads an omission as an oversight — a panel result does not occupy +/// the captured document window, does not replace its buffer, and does +/// not need it to exist, so each of checks 2–4 would refuse `git.status` +/// for a document-window change unrelated to where the panel goes. +/// +/// Table-driven so the failure message names *which* cell regressed, +/// which eight near-identical tests would give up in exchange for +/// nothing. +/// +/// *Mutation:* apply all four checks in both profiles — the three panel +/// rows fail. *Second mutation:* apply only check 1 in both profiles — +/// the three document rows fail. +#[test] +fn the_preflight_matrix_holds_in_both_profiles() { + // (label, Lua that breaks the precondition, reason fragment, + // whether the PANEL profile refuses too) + let cases: [(&str, &str, &str, bool); 4] = [ + ( + "frontend gone", + // Handled in Rust below: unregistering a view has no Lua surface. + "", + "requesting frontend is gone", + true, + ), + ( + "window gone", + "local doomed = dest:window() + pmacs.window.split_horizontal() + while pmacs.window.current() == doomed do pmacs.window.focus_next() end + pmacs.window.close_others()", + "is gone", + false, + ), + ( + "stale buffer", + "pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))", + "now shows another buffer", + false, + ), + ( + "dedicated", + "pmacs.window.set_params(dest:window(), { dedicated = true })", + "is dedicated", + false, + ), + ]; + + for (label, break_it, expected, panel_refuses) in cases { + for profile in [None, Some("'panel'")] { + let s = editor(); + capture(&s); + + if label == "frontend gone" { + s.core + .borrow_mut() + .unregister_frontend_view(FrontendId::LOCAL); + } else { + exec(&s, break_it); + } + + commit(&s, profile); + assert_eq!( + raised(&s), + None, + "{label}/{profile:?}: a precondition is a refusal, not a raise" + ); + + let refuses = profile.is_none() || panel_refuses; + if refuses { + assert!(!ok(&s), "{label}/{profile:?}: commit_to must refuse"); + assert!( + reason(&s).contains(expected), + "{label}/{profile:?}: reason must say why; wanted {expected:?}, got {:?}", + reason(&s) + ); + assert!( + !ran(&s), + "{label}/{profile:?}: the callback must not run at all -- validating \ + after it is four mutations too late" + ); + } else { + assert!( + ok(&s), + "{label}/panel: this check is DELIBERATELY omitted for a panel \ + result, which touches no document window; got refusal {:?}", + reason(&s) + ); + assert!(ran(&s), "{label}/panel: the callback must run"); + } + } + } +} + +// --------------------------------------------------------------------------- +// §7 — the profile argument (Q#DC-5) +// --------------------------------------------------------------------------- + +/// **P** — a two-argument `commit_to(dest, body)` takes the **document** +/// profile, so every caller written before the profile existed keeps all +/// four checks. +/// +/// Witnessed by a check the panel profile omits — a stale buffer. +/// Asserting merely that the call does not error would pass on a legacy +/// call silently downgraded to the panel profile, which is the +/// regression that would quietly void Journey Stage 1a's guarantees for +/// dired and every future two-argument caller. +/// +/// *Mutation:* default the profile to `Panel`. This fails; +/// `journey_acceptance` also fails, which is the point — the default is +/// what makes that suite's untouched pass a consequence of the signature +/// rather than of care. +#[test] +fn a_two_argument_commit_takes_the_document_profile() { + let s = editor(); + capture(&s); + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))", + ); + + commit(&s, None); + + assert!( + !ok(&s), + "a two-argument commit must keep the stale-intent check" + ); + assert!( + reason(&s).contains("now shows another buffer"), + "and refuse for that reason; got {:?}", + reason(&s) + ); + assert!(!ran(&s), "the callback must not run"); +} + +/// **N** — an explicit `nil` profile is the document profile, exactly as +/// omitting it is. +/// +/// Witnessed separately from the two-argument case rather than assumed +/// equivalent: a Lua caller threading an optional variable produces +/// `commit_to(dest, body, nil)`, and a third behaviour there would stay +/// invisible until someone hit it in production. +/// +/// *Mutation:* treat `Value::Nil` as an unrecognized profile. This +/// fails; the two-argument test above does not, because mlua supplies +/// `Nil` for a missing argument either way only if the binding asks for +/// a `Value` — which is the type this suite also pins below. +#[test] +fn an_explicit_nil_profile_is_the_document_profile() { + let s = editor(); + capture(&s); + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))", + ); + + commit(&s, Some("nil")); + + assert_eq!( + raised(&s), + None, + "an explicit nil must not be treated as a bad profile" + ); + assert!(!ok(&s), "an explicit nil must keep the stale-intent check"); + assert!( + reason(&s).contains("now shows another buffer"), + "and refuse for that reason; got {:?}", + reason(&s) + ); +} + +/// **N** — an unrecognized profile is an ERROR naming the accepted +/// values, and a non-string profile is refused by the **same** message. +/// +/// Two claims, one test, because their whole content is that they agree: +/// +/// * a fallback to `"document"` would hand a caller different checks +/// than it asked for — the failure the parameterization exists to +/// prevent — so an unknown string raises; +/// * **this is the guard on the argument's type.** With +/// `profile: Option` mlua rejects `42` and `{}` during +/// argument *conversion*, before the closure body runs, and the +/// message below becomes unreachable — the caller gets a generic +/// conversion error naming neither the rule nor the vocabulary. So the +/// number and table cases are asserted on the message's *content* and +/// against the string case's message, not merely on "an error +/// occurred". +/// +/// *Mutation:* retype the argument to `Option`. The number and +/// table rows fail. +#[test] +fn a_bad_profile_is_refused_by_one_message_that_names_the_accepted_values() { + let mut messages = Vec::new(); + for (label, profile) in [ + ("unknown string", "'documents'"), + ("number", "42"), + ("table", "{}"), + ("boolean", "true"), + ] { + let s = editor(); + capture(&s); + commit(&s, Some(profile)); + + let raised = raised(&s).unwrap_or_else(|| panic!("{label}: a bad profile must raise")); + assert!( + raised.contains("\"document\"") && raised.contains("\"panel\""), + "{label}: the message must name both accepted values; got {raised:?}" + ); + assert!( + raised.contains("must be the string"), + "{label}: and say a string was expected; got {raised:?}" + ); + assert!( + !ran(&s), + "{label}: a bad profile must not reach the callback" + ); + messages.push((label, raised)); + } + + let (_, first) = &messages[0]; + for (label, message) in &messages[1..] { + assert_eq!( + message, first, + "{label}: a non-string profile must be refused by the SAME message as an \ + unrecognized one -- a different message means mlua rejected the value \ + during argument conversion, which is what `Option` would do" + ); + } +} + +// --------------------------------------------------------------------------- +// §7 — no document window (Q#DC-4) +// --------------------------------------------------------------------------- + +/// **N** — a frontend with no live document window still captures, and +/// the destination reports the absence. +/// +/// Asserted as a *successful* capture rather than as `nil`: returning +/// `nil` here would push the adopter back onto ambient behaviour, which +/// is the P1a bug this lane removes. An adopter with nowhere to land +/// gets a refusal it can report; it does not get permission to guess. +/// +/// The `window()` accessor reporting **nil** is the other half: the pair +/// is set or cleared together, so no consumer ever sees a window id +/// without the buffer that was captured with it. +/// +/// *Mutation:* return `None` from `capture_view_destination` when +/// `primary_document_window` finds nothing. This fails on the capture +/// assertion inside the helper. *Second mutation:* keep `window` while +/// clearing `buffer`. This fails here. +#[test] +fn capture_succeeds_with_no_document_window() { + let s = editor(); + attach_documentless_frontend(&s, DOCUMENTLESS); + s.core.borrow_mut().active_frontend = DOCUMENTLESS; + + capture(&s); + + assert!( + eval::(&s, "return dest:window() == nil"), + "the document pair must be reported as ABSENT, not invented" + ); +} + +/// **N** — on that destination a panel commit **succeeds** and a +/// document commit is **refused**, naming the missing window. +/// +/// Both halves, because asserting only the refusal would pass on a +/// capture that refuses everything, and asserting only the success would +/// pass on one that checks nothing. Together they are Q#DC-4's decision: +/// the document pair is optional, and the profile is what decides +/// whether its absence matters. +/// +/// The refusal is a `(false, reason)` like the other four rather than a +/// raise, so an adopter handles all five the same way. +/// +/// *Mutation:* drop the `dest.window == None` arm. The document half +/// then commits against no window at all. +#[test] +fn a_panel_commit_succeeds_where_a_document_commit_is_refused() { + let s = editor(); + attach_documentless_frontend(&s, DOCUMENTLESS); + s.core.borrow_mut().active_frontend = DOCUMENTLESS; + capture(&s); + + commit(&s, Some("'panel'")); + assert!( + ok(&s), + "a panel result needs only a live frontend; got refusal {:?}", + reason(&s) + ); + assert!(ran(&s), "and its callback must run"); + + commit(&s, None); + assert_eq!( + raised(&s), + None, + "the missing document window joins the preflight refusals rather than raising" + ); + assert!(!ok(&s), "a document commit has nowhere to land"); + assert!( + reason(&s).contains("no document window"), + "and must say so; got {:?}", + reason(&s) + ); + assert!(!ran(&s), "and must not reach the callback"); +} + +// Isolated bootstrap storage roots (see the module docs): an +// integration test is compiled without `cfg(test)`, so a raw +// `EditorState::new()` would read the developer's real `init.lua` and +// write into their real data root. +#[path = "common/iso.rs"] +mod iso; From 3e64898c64dda9beadce028bde65fc3b314d76a3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:11:42 +0200 Subject: [PATCH 35/66] docs: record the destination-capture lane as implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-local edit to the lane's own block; several lanes edit this file concurrently. Records the two commits, the green gate line (both preservation suites unchanged), the mutation checks that show a matrix of deliberate omissions is not passing vacuously, and the two places the framing did not match the tree: - the rename was 11 references across 5 files, not 8 across 4 — `src/daemon.rs` also calls the capture; - Q#DC-4's "frontend with no document window" is a DEFENSIVE branch. Q#BP6 asserts a layout always keeps a non-side window, with a `debug_assert!` in `non_side_target` that fires under `cargo test`, so a registered frontend in a healthy editor always has a live document window. The decision stands, but #227 should not expect to meet that refusal. Neither changed a decision, and both are recorded rather than quietly absorbed: the framing says "counted, not estimated", and the next reader will check. `ViewDestination`'s own doc comment is corrected in the same commit, because it repeated the framing's over-claim ("a frontend showing only a side window") in the one place a reader would trust it, and `capture_view_destination` now says how reachable its empty pair actually is. Code, not only ledger, since the ledger is not what someone reads when they wonder whether that branch can fire. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 59 ++++++++++++++++++++++++++++++++++++++------- src/editor_core.rs | 14 +++++++++-- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ad3f2da..0632278 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## Destination capture (Q#JR14 generalization) — BRANCHED, framing in review +## Destination capture (Q#JR14 generalization) — IMPLEMENTED, gate green, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,8 +275,46 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 5**, in - review. +- **Framing `docs/destination-capture-framing.md`, revision 5**, + APPROVED after four review rounds. +- **Implemented in two commits.** `779bb02` is the mechanism + (`pmacs.window.capture_destination()`, the `ViewDestination` rename, + the profile argument); `d5a6170` is + `tests/destination_capture_acceptance.rs`, eight pins covering §7. + The full gate line below is green, and both preservation suites pass + **unchanged** (journey 47, dired 31) — no edit to either, which is + §7's stop signal not firing rather than being suppressed. +- **TWO FRAMING CLAIMS THE TREE DID NOT MATCH.** Neither changed a + decision; both are recorded because the framing says "counted, not + estimated" and a reader will check. + 1. **The rename was 11 references across 5 files, not 8 across 4.** + `src/daemon.rs:1804` also calls the capture (the attaching + frontend's directory open), and `editor.rs` holds six references + rather than the counted total. Mechanical either way. + 2. **Q#DC-4's "a frontend with no document window" is a DEFENSIVE + branch, not a routine one.** The obvious spelling — a frontend + showing only a bottom panel — is asserted impossible: Q#BP6 says a + layout always retains at least one non-side window, and + `EditorCore::non_side_target` carries a `debug_assert!` that fires + under `cargo test` when one does. So with Q#BP6 held a *registered* + frontend always has a live document window. The decision still + stands (capture stays total; an adopter with nowhere to land gets a + refusal naming that rather than permission to fall back to ambient + state), and the two Q#DC-4 pins drive the reachable spelling of the + same condition — a layout whose document window has gone while the + view remains. **#227 should not expect to hit this refusal**; it is + insurance, not a path. +- **Mutation-tested, since a matrix of deliberate omissions is exactly + what passes vacuously.** Retyping the profile to `Option` + fails the table and boolean rows with mlua's conversion error (the + number row survives — Lua coerces it — which is why the closed set is + witnessed by more than one non-string). Applying all four checks in + both profiles fails the panel column; applying only check 1 in both + fails the document column. Defaulting an omitted profile to `"panel"` + fails **`journey_acceptance`'s two preservation pins**, which is the + contract claim being executable rather than asserted. Dropping the + frontend scope for the panel profile fails the survives-a-switch pin's + panel row; dropping the no-document-window arm fails the Q#DC-4 pair. - **The public API #227 adopts against (Q#DC-5), pinned so it is a contract rather than an intention:** `pmacs.window.commit_to(dest, body [, profile])`. Profile is an @@ -307,16 +345,19 @@ authoritative tip** — the ref, not a SHA. Recover with and display UI without capturing the initiating frontend (`builtin/runtime/git.lua:609`, `:854`), so a result surfaces in whichever frontend is active when git exits. -- **The mechanism exists but is not Lua-reachable.** - `pmacs.window.commit_to` takes a `DirectoryDestinationLua`, which is +- **The mechanism existed but was not Lua-reachable** until `779bb02`. + `pmacs.window.commit_to` took a `DirectoryDestinationLua`, which is **nonconstructible from Lua** by design (`src/lua_bindings/mod.rs:4256`) and minted only inside the `path.open-directory` listener dispatch (`src/editor.rs:1311`) from a `pub(crate)` capture (`:1241`). So no async Lua continuation outside - a directory open can say where its result belongs. + a directory open could say where its result belongs. Line numbers are + the pre-lane ones, kept because they are what the finding was written + against. - **Scope:** a Lua-reachable capture, a generic rename - (`DirectoryDestination` → `ViewDestination`, 8 references across 4 - files — counted, not estimated), and the preflight question below. + (`DirectoryDestination` → `ViewDestination`; the framing counted 8 + references across 4 files, the tree held **11 across 5** — see the + finding above), and the preflight question below. **No adopter**: git's adoption is #227's work after this lands, since a prerequisite that converts its own first consumer cannot be reviewed separately from it. @@ -335,7 +376,7 @@ authoritative tip** — the ref, not a SHA. Recover with - **Gates, as the executable line rather than a description:** ``` - scripts/gate --acceptance \ + scripts/gate --acceptance destination_capture_acceptance \ --acceptance journey_acceptance \ --acceptance dired_acceptance ``` diff --git a/src/editor_core.rs b/src/editor_core.rs index 0243968..bae3137 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -144,8 +144,8 @@ pub enum ResolvedTarget { /// result. /// /// The fields are load-bearing, and the document pair is **optional** -/// (Q#DC-4) because a frontend showing only a side window can still host -/// a panel result: +/// (Q#DC-4) because a panel result needs only a live frontend, so a +/// frontend whose document window has gone can still host one: /// /// * `frontend` — the scope the commit must run in. Always present. /// * `window` — the exact destination; the ambient selected window is @@ -3073,6 +3073,16 @@ impl EditorCore { /// The document pair is set or cleared **together**: a window whose /// entry has gone yields neither half, so no consumer has to handle /// a window without its captured buffer. + /// + /// **How reachable the empty pair is, stated because the framing + /// implies more than the tree does.** Q#BP6 says a frontend layout + /// always retains at least one non-side window, and + /// [`Self::non_side_target`] carries a `debug_assert!` that fires + /// when one does not — so with that invariant held, a *registered* + /// frontend always has a live document window and this branch is + /// **defensive** rather than routine. It stays because the + /// alternative is a capture that can fail, and a caller that can + /// fail is a caller that falls back to ambient state. #[must_use] pub fn capture_view_destination(&self, fid: FrontendId) -> ViewDestination { let pair = self From 6032ed1c2a9ae05075392d84089efd2066972499 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:55:38 +0200 Subject: [PATCH 36/66] docs: destination-capture revision 6 --- the panel matrix was unsound Q#DC-2 gave the panel profile only check 1, on the stated claim that a panel result never touches a document window. That claim is false, and the tree says so in its own comment: reaching Ordinary while a side was REQUESTED means the request fell back --- not panel-capable, or the one side slot is dedicated elsewhere --- and the result is then installed into an ordinary document window. So a "panel" commit on a non-panel-capable frontend could replace a NEWER document while skipping every stale-intent guard, reintroducing exactly the failure this API exists to prevent. Reproduced in review, not theorised. That makes it a correctness defect rather than a strictness preference, and it is my framing error: I wrote the matrix. The relaxation is now conditional on the placement really being a panel. Both fallback causes are readable from core state at preflight, and nothing can change between preflight and placement because commit_to runs its body synchronously in a scope that refuses await --- so the prediction cannot go stale under the commit it guards. What is deliberately NOT the fix: refusing a panel commit that would fall back. Falling back is existing, intentional behaviour for a frame without panel capability, and refusing would turn a graceful degradation into an error. The panel profile relaxes checks; it does not get to change where things land. Also closes an invalid-UTF-8 hole in the profile diagnostic. Lua strings are byte strings, so string.char(255) reaches to_str() and produces mlua generic conversion error before the documented message naming the accepted values is ever constructed. Same reachability class as revision 5 Option defect, one layer further down --- which is worth noticing, because I fixed that one and did not look for the next one. And the header said "Pre-implementation. Awaiting approval" through revisions 2 to 5 while the ledger recorded the lane approved and implemented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/destination-capture-framing.md | 79 ++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0632278..0b16cf5 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 5**, +- **Framing `docs/destination-capture-framing.md`, revision 6**, APPROVED after four review rounds. - **Implemented in two commits.** `779bb02` is the mechanism (`pmacs.window.capture_destination()`, the `ViewDestination` rename, diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 798ddfc..233d33e 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,7 +1,24 @@ # A destination capture any async continuation can use -**Status: framing pass, revision 5. Pre-implementation. Awaiting -approval.** +**Status: revision 6. APPROVED and IMPLEMENTED at `0efc8c0`; revision 6 +carries a correctness blocker found in review of that implementation +and is NOT yet implemented.** + +*(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the +ledger recorded the lane as approved and implemented. Same +contradiction class this document keeps correcting elsewhere, left +standing in its own header.)* + +**Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave +the panel profile only check 1, on the claim that a panel result never +touches a document window. **Panel placement falls back to an ordinary +document window** when the frontend is not panel-capable or its side +slot is dedicated — so a `"panel"` commit could replace a *newer* +document while skipping every stale-intent guard. Reproduced in review. +The relaxation is now conditional on the placement really being a +panel. Revision 6 also closes an invalid-UTF-8 hole in the profile +diagnostic — the same reachability class as revision 5's, one layer +down. **Revision 5 fixes a binding-level contradiction in revision 4's own API spec.** It required `profile: Option` *and* a pointed error @@ -240,10 +257,44 @@ replacement quietly loses its guarantees. | 3 | Window still shows the captured buffer (Q#JR14c stale intent) | **required** | not applicable | | 4 | Window is not dedicated (Q#JR14f) | **required** | not applicable | -**Check 1 is the entire panel profile**, and that is the honest reading -of what a panel continuation actually depends on: the frontend it was -launched from still exists. Everything else in the capture is document -state the panel never touches. +**Check 1 is the entire panel profile ONLY WHEN THE PLACEMENT REALLY IS +A PANEL — revision 5's matrix was unsound, and this is the correction.** + +The matrix rested on "the panel never touches the captured window's +buffer". **That is false when panel placement falls back.** +`editor_core.rs:4138-4148` says so in its own comment: *"Reaching +`Ordinary` while a side was REQUESTED means the request fell back (not +panel-capable, or the one slot is dedicated elsewhere)"* — and the +result is then installed into an ordinary **document** window. So a +`"panel"` commit on a non-panel-capable frontend replaces a document +view while skipping every check that exists to stop it replacing a +*newer* one. That reintroduces exactly the stale-intent failure the +API was built to prevent, which makes it a correctness defect and not +a strictness preference. + +**The rule, restated:** the panel profile's relaxation is conditional +on the placement actually being a panel. Whenever placement **can** +fall back to a document window, the panel profile runs the **full +document preflight**. + +**Both fallback causes are predictable at preflight**, which is what +makes this implementable rather than a race: + +1. `view.panel_capable` is false — a property of the frontend. +2. The frontend's single side slot is dedicated elsewhere — readable + from core state. + +And nothing can change between preflight and placement: `commit_to` +runs its body synchronously inside a scope that **refuses `await`** +(`async.lua:87-90`), so the prediction cannot go stale under the +commit it guards. + +**What is NOT the fix: refusing a panel commit that would fall back.** +Falling back to an ordinary window is existing, deliberate behaviour +for a frontend without panel capability; refusing would turn a +graceful degradation into an error and regress consumers that work +today. The panel profile relaxes checks; it does not get to change +where things land. **Consequence for the capture, which follows and should not be discovered later:** if the panel profile needs only the frontend, then @@ -405,8 +456,24 @@ incidental: no arguments is what keeps capture profile-blind. a stale-buffer refusal. Asserting merely that it does not error would pass on a call silently downgraded to the panel profile, which is the regression that would quietly void Journey Stage 1a's guarantees. +- **A `"panel"` commit that FALLS BACK to a document window runs the + document preflight**, witnessed for **both** causes separately — + a non-panel-capable frontend, and a dedicated side slot. Each asserts + the stale-intent refusal fires: capture A, make B newer, commit + `"panel"`, and observe the refusal rather than B being replaced. + This is the defect revision 5's matrix admitted. +- **A `"panel"` commit that really lands in the panel still skips + checks 2–4** — otherwise the fix has quietly collapsed the two + profiles into one and the parameterization buys nothing. - **An unrecognized profile string is REFUSED**, with a message naming the accepted values — not silently treated as `"document"`. +- **An invalid-UTF-8 profile is refused by that SAME message.** Lua + strings are byte strings, so a `string.char(255)` profile reaches + `to_str()` and produces mlua's generic conversion error *before* + the documented message is ever constructed — the same reachability + class as the `Option` defect, one layer deeper. Compare + bytes, or map the conversion failure onto the message; asserted on + content, in the bad-profile matrix beside the number and table rows. - **A non-string profile (a number, a table) is refused by that SAME message**, asserted **on its content**, not merely that an error occurred. This is the bullet that fails if the argument is ever From edb84a520dfaac400228f0361213281c9630a966 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:10:14 +0200 Subject: [PATCH 37/66] docs: destination-capture revision 7 --- enforce at placement, not preflight Review found revision 6 fix unsound for the same reason its target was. Revision 6 moved the panel/document decision to a preflight prediction, arguing nothing could change before placement because commit_to refuses await. That refusal stops CONCURRENT INTERLEAVING --- another coroutine mutating state while this one is parked. It does nothing about the body itself, which is arbitrary synchronous Lua and can obtain the panel, set dedicated = true, and then request panel display. Preflight sees a reusable panel and relaxes checks 2-4; the body causes the fallback; the result replaces a stale document. No preflight predicate closes this, however phrased --- the measurement is taken before the thing it measures is decided. So enforcement moves to the placement boundary, where resolving to Ordinary for a request that asked for a side IS the fallback rather than a forecast of one. The commit scope is already Rust-side app data, so the profile and the destination can ride there. The tempting non-fix is named so nobody reaches for it: widening the predicate from "will it fall back" to "could it ever" is always true, since the body can always dedicate the slot --- which collapses the two profiles and buys nothing. Section 7 gains the test that distinguishes the designs: the callback dedicates the side slot MID-COMMIT. Both fallback tests revision 6 asked for establish their state before commit_to is entered, so a preflight-snapshot design passes them. A design passing only those two has not been shown to work. The ledger claimed the lane implemented with eight pins covering section 7. Those pins were written against revision 5 matrix, which review disproved --- none exercises a fallback placement. A recovering machine reading that entry would have prepared a PR from a lane with an open correctness blocker. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 56 ++++++++++++++++---- docs/destination-capture-framing.md | 79 ++++++++++++++++++++++------- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0b16cf5..830ce56 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,33 @@ 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. -## Destination capture (Q#JR14 generalization) — IMPLEMENTED, gate green, no PR yet +## Destination capture (Q#JR14 generalization) — IMPLEMENTED at `0efc8c0`, then RE-OPENED by review + +**DO NOT PREPARE A PR FROM THIS LANE'S CURRENT STATE.** The mechanism +landed at `0efc8c0` with 8 pins green — and review of that +implementation found a **correctness blocker** that is still open. +Framing revisions 6 and 7 carry it; neither is implemented yet. + +**The blocker:** the panel profile skips checks 2–4 on the claim that a +panel result never touches a document window. **Panel placement falls +back to an ordinary document window** when the frontend is not +panel-capable or its side slot is dedicated +(`src/editor_core.rs:4138-4148`), so a `"panel"` commit could replace a +**newer** document with every stale-intent guard skipped. Reproduced in +review. + +**Revision 6's fix was itself unsound and revision 7 replaces it.** +Revision 6 predicted the fallback at preflight, arguing the body cannot +`await`. That stops concurrent interleaving, not the body: arbitrary +synchronous Lua can dedicate the side slot *inside the callback* and +cause the fallback the preflight just ruled out. **Enforcement belongs +at the placement boundary**, and §7 now requires an +inside-the-body test that no preflight-snapshot design can pass. + +**Also open:** an invalid-UTF-8 profile (`string.char(255)`) reaches +`to_str()` and surfaces mlua's generic conversion error instead of the +documented message naming the accepted values — the same reachability +class as revision 5's `Option` defect, one layer down. **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,15 +301,25 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 6**, - APPROVED after four review rounds. -- **Implemented in two commits.** `779bb02` is the mechanism - (`pmacs.window.capture_destination()`, the `ViewDestination` rename, - the profile argument); `d5a6170` is - `tests/destination_capture_acceptance.rs`, eight pins covering §7. - The full gate line below is green, and both preservation suites pass - **unchanged** (journey 47, dired 31) — no edit to either, which is - §7's stop signal not firing rather than being suppressed. +- **Framing `docs/destination-capture-framing.md`, revision 7.** + Revisions 1–5 were approved over four review rounds; **revisions 6 + and 7 are corrections carrying the open blocker above** and have not + been implemented. +- **Implemented in two commits, and superseded in part.** `779bb02` is + the mechanism (`pmacs.window.capture_destination()`, the + `ViewDestination` rename, the profile argument); `d5a6170` is + `tests/destination_capture_acceptance.rs`. The gate line below was + green at `0efc8c0` and both preservation suites passed **unchanged** + (journey 47, dired 31) — §7's stop signal not firing rather than + being suppressed. + + **But those eight pins do NOT cover §7 as it now reads.** They were + written against revision 5's matrix, which review disproved: none of + them exercises a fallback placement, and none could — the two + fallback tests revision 6 asked for did not exist yet, and revision + 7 adds a third (the inside-the-body transition) that no + preflight-snapshot design can pass. Reading "eight pins covering §7" + off this entry is exactly the mistake it now exists to prevent. - **TWO FRAMING CLAIMS THE TREE DID NOT MATCH.** Neither changed a decision; both are recorded because the framing says "counted, not estimated" and a reader will check. diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 233d33e..29c478e 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,14 +1,25 @@ # A destination capture any async continuation can use -**Status: revision 6. APPROVED and IMPLEMENTED at `0efc8c0`; revision 6 -carries a correctness blocker found in review of that implementation -and is NOT yet implemented.** +**Status: revision 7. The mechanism is implemented at `0efc8c0`; +revisions 6 and 7 carry an OPEN correctness blocker that is NOT yet +implemented.** *(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the ledger recorded the lane as approved and implemented. Same contradiction class this document keeps correcting elsewhere, left standing in its own header.)* +**Revision 7 replaces revision 6's fix, which was unsound for the same +reason revision 6's target was.** Revision 6 moved the panel/document +decision to a **preflight prediction**, arguing nothing could change +before placement because the body cannot `await`. The await refusal +stops *concurrent interleaving*; it does not stop the body — arbitrary +synchronous Lua — from dedicating the side slot itself and causing the +very fallback the preflight just ruled out. **Enforcement moves to the +placement boundary**, where the fallback is a fact rather than a +forecast, and §7 gains the inside-the-body test that the two +pre-established-state tests could never catch. + **Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave the panel profile only check 1, on the claim that a panel result never touches a document window. **Panel placement falls back to an ordinary @@ -277,17 +288,42 @@ on the placement actually being a panel. Whenever placement **can** fall back to a document window, the panel profile runs the **full document preflight**. -**Both fallback causes are predictable at preflight**, which is what -makes this implementable rather than a race: +**ENFORCEMENT IS AT THE PLACEMENT BOUNDARY, NOT AT PREFLIGHT — +revision 6 got this wrong too, and the reason is worth stating because +it is a whole class of mistake.** -1. `view.panel_capable` is false — a property of the frontend. -2. The frontend's single side slot is dedicated elsewhere — readable - from core state. +Revision 6 said the two fallback causes are "predictable at preflight", +because `commit_to` refuses `await` so "nothing can change between +preflight and placement". **The await refusal prevents *concurrent +interleaving* — another coroutine mutating state while this one is +parked. It says nothing about the body itself**, which is arbitrary +Lua running synchronously and perfectly able to change the state the +preflight just measured: -And nothing can change between preflight and placement: `commit_to` -runs its body synchronously inside a scope that **refuses `await`** -(`async.lua:87-90`), so the prediction cannot go stale under the -commit it guards. +> obtain the existing panel → set it `dedicated = true` → request panel +> display + +Preflight sees a reusable panel and relaxes checks 2–4; the body then +causes the fallback; the result replaces a stale document. **No +preflight predicate can close this**, however it is phrased — the +measurement is simply taken before the thing it measures is decided. + +**So the check moves to where the fact is known.** Placement resolving +to `PlacementKind::Ordinary` for a request that asked for a side *is* +the fallback (`editor_core.rs:4138-4148`). At that point, under an +active panel-profile commit, the document preconditions are evaluated +against the captured destination and refused if they fail. The commit +scope is already Rust-side app data (`CommitScopeActive`), so the +profile and the destination can ride there for the placement path to +consult. + +**And the tempting non-fix, named so nobody reaches for it:** widening +the preflight predicate from "will it fall back" to "*could* it ever". +Since the body can always dedicate the side slot, that predicate is +always true, the panel profile collapses into the document profile, and +the parameterization buys nothing. If collapsing them is genuinely +right, that is a design decision needing its own approval — not a way +to make a broken predicate safe. **What is NOT the fix: refusing a panel commit that would fall back.** Falling back to an ordinary window is existing, deliberate behaviour @@ -456,12 +492,19 @@ incidental: no arguments is what keeps capture profile-blind. a stale-buffer refusal. Asserting merely that it does not error would pass on a call silently downgraded to the panel profile, which is the regression that would quietly void Journey Stage 1a's guarantees. -- **A `"panel"` commit that FALLS BACK to a document window runs the - document preflight**, witnessed for **both** causes separately — - a non-panel-capable frontend, and a dedicated side slot. Each asserts - the stale-intent refusal fires: capture A, make B newer, commit - `"panel"`, and observe the refusal rather than B being replaced. - This is the defect revision 5's matrix admitted. +- **A `"panel"` commit that FALLS BACK to a document window is checked + against the document preconditions**, witnessed for **both** causes + separately — a non-panel-capable frontend, and a dedicated side slot. + Each asserts the stale-intent refusal fires: capture A, make B newer, + commit `"panel"`, observe the refusal rather than B being replaced. +- **THE FALLBACK STATE IS ALSO ESTABLISHED FROM INSIDE THE BODY**, in + its own test: the callback dedicates the side slot **mid-commit** and + then requests panel display. This is the case that distinguishes + placement-time enforcement from preflight prediction, and **the two + bullets above cannot catch it** — both set up their fallback state + *before* `commit_to` is entered, so a preflight-snapshot design + passes them. A design that passes only those two has not been shown + to work. - **A `"panel"` commit that really lands in the panel still skips checks 2–4** — otherwise the fix has quietly collapsed the two profiles into one and the parameterization buys nothing. From 86cd08959a1f9d622b5cd74186cf5287c45a88c1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:23:33 +0200 Subject: [PATCH 38/66] fix(window): enforce the panel profile at placement, not at preflight Revision 7 of `docs/destination-capture-framing.md`, closing the correctness blocker review found in `0efc8c0` and the smaller reachability hole beside it. THE BLOCKER. The `"panel"` commit profile skipped preflight checks 2-4 on the claim that a panel result never touches a document window. That claim is false: panel placement FALLS BACK to an ordinary document window when the frontend is not `panel_capable` or its one side slot is dedicated elsewhere -- `apply_placement` says so in its own comment -- and then installs the result there. So a `"panel"` commit could replace a NEWER document with every stale-intent guard skipped: capture A, the user opens B, the continuation lands, B is gone. That is the exact failure `commit_to` exists to prevent, reached through the profile meant to be the safe one. WHY NOT A PREFLIGHT PREDICTION. Revision 6 proposed predicting the fallback at preflight, arguing nothing could change in between because the body cannot `await`. Refusing `await` prevents another COROUTINE interleaving; it places no restriction on the body itself, which is arbitrary Lua running synchronously and can invalidate the snapshot in two statements -- take the panel, set it `dedicated`, then request a side display. No preflight predicate closes that, however phrased: the measurement is taken before the thing it measures is decided. WHAT THIS DOES INSTEAD. `EditorCore::display_buffer` refuses between `resolve_placement` and `apply_placement` when a side request resolved to `PlacementKind::Ordinary` under an active `"panel"` contract whose destination fails the document preconditions. That is the first moment the fallback is a fact rather than a guess, and refusing before `apply_placement` means a refused fallback mutates nothing. The contract rides on the core, installed and restored by the same `ScopedFrontendGuard` that scopes the frontend, so a profile can never outlive the body that declared it; the field is crate-private, so Lua cannot claim a profile for a placement it did not commit to. The preflight predicate SURVIVES as an early refusal and not as the guarantee. `panel_placement_can_fall_back` still gates the relaxation in `commit_destination_refusal`, so the statically knowable case -- a frontend that cannot render a panel at all, and will not acquire the capability mid-body -- refuses before the body allocates a buffer, registers a handle and paints. That is the same reason `commit_to` preflights at all. Both layers are pinned, and neither pin subsumes the other. The four document checks now live once, in `EditorCore::document_destination_refusal`: they are evaluated from two sites, and two hand-written copies is how a backstop ends up weaker than the thing it backs. THREE DELIBERATE LIMITS, each a different decision rather than a stricter version of this one. The document profile is untouched -- re-running its checks at placement would newly refuse dired's own documented panel path, which is a preservation-suite stop signal. Only a fallback is guarded, not every `Ordinary` placement -- a `"panel"` body calling `display_file` is pinned as succeeding. And the refusal is of the PLACEMENT, not of falling back: a `"panel"` commit with an intact destination still degrades gracefully into the document window, because turning graceful degradation into an error would regress every consumer that works today on a frontend without panel capability. THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings are BYTE strings, so a `string.char(255)` profile hit mlua's generic UTF-8 conversion error before `BAD_COMMIT_PROFILE` was ever constructed -- the same reachability class as the `Option` defect revision 5 fixed, one layer down. The comparison is on bytes now, and the invalid-UTF-8 row joins the number/table/boolean rows asserting on message content. FOUR DOC SITES repeated the false claim (`ViewDestination`'s own doc twice, `capture_view_destination`, `ViewDestinationLua`) and are corrected. Nothing else relied on it: dired, the only Lua `commit_to` consumer, takes the two-argument document profile and already had all four checks; `compile.lua`'s `already_in_panel` queries live state; and the terminal adopter's rollback keys off `created_side`, already false on a fallback. Tests: 12 pins, up from 8. Three carry the enforcement split and none subsumes another -- the pre-established fallback (both causes, the body must not run), the inside-the-body transition (the body runs, the result must not land), and the graceful fallback (a valid destination still lands). Mutation-checked four ways; the pattern of which rows survive each mutation is in `docs/active-work.md`. `journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 140 ++++++--- src/editor.rs | 30 +- src/editor_core.rs | 306 ++++++++++++++++++- src/lua_bindings/mod.rs | 5 +- src/lua_bindings/window_panel.rs | 141 +++------ tests/destination_capture_acceptance.rs | 387 +++++++++++++++++++++++- 6 files changed, 850 insertions(+), 159 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 830ce56..71b68e9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,33 +265,36 @@ 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. -## Destination capture (Q#JR14 generalization) — IMPLEMENTED at `0efc8c0`, then RE-OPENED by review +## Destination capture (Q#JR14 generalization) — revision 7 IMPLEMENTED, gate green, no PR yet -**DO NOT PREPARE A PR FROM THIS LANE'S CURRENT STATE.** The mechanism -landed at `0efc8c0` with 8 pins green — and review of that -implementation found a **correctness blocker** that is still open. -Framing revisions 6 and 7 carry it; neither is implemented yet. +**The blocker review re-opened this lane for is CLOSED.** The mechanism +landed at `0efc8c0` with 8 pins green; review of that implementation +found a correctness blocker, framing revisions 6 and 7 carried it, and +revision 7's design is implemented in the commit named below with 12 +pins green. No PR yet — the lane was told not to open one. -**The blocker:** the panel profile skips checks 2–4 on the claim that a -panel result never touches a document window. **Panel placement falls -back to an ordinary document window** when the frontend is not +**The blocker was:** the panel profile skipped checks 2–4 on the claim +that a panel result never touches a document window. **Panel placement +falls back to an ordinary document window** when the frontend is not panel-capable or its side slot is dedicated -(`src/editor_core.rs:4138-4148`), so a `"panel"` commit could replace a -**newer** document with every stale-intent guard skipped. Reproduced in -review. +(`src/editor_core.rs`, `apply_placement`), so a `"panel"` commit could +replace a **newer** document with every stale-intent guard skipped. +Reproduced in review. -**Revision 6's fix was itself unsound and revision 7 replaces it.** -Revision 6 predicted the fallback at preflight, arguing the body cannot -`await`. That stops concurrent interleaving, not the body: arbitrary -synchronous Lua can dedicate the side slot *inside the callback* and -cause the fallback the preflight just ruled out. **Enforcement belongs -at the placement boundary**, and §7 now requires an -inside-the-body test that no preflight-snapshot design can pass. +**Revision 6's fix was itself unsound and revision 7 replaced it, which +is the part most worth not re-learning.** Revision 6 predicted the +fallback at preflight, arguing the body cannot `await`. That stops +concurrent interleaving, not the body: arbitrary synchronous Lua can +dedicate the side slot *inside the callback* and cause the fallback the +preflight just ruled out. **No preflight snapshot can carry this +invariant.** Enforcement is therefore at the **placement boundary**, and +§7's inside-the-body test is what no preflight-snapshot design passes. -**Also open:** an invalid-UTF-8 profile (`string.char(255)`) reaches -`to_str()` and surfaces mlua's generic conversion error instead of the +**Also closed:** an invalid-UTF-8 profile (`string.char(255)`) reached +`to_str()` and surfaced mlua's generic conversion error instead of the documented message naming the accepted values — the same reachability -class as revision 5's `Option` defect, one layer down. +class as revision 5's `Option` defect, one layer down. The +comparison is on bytes now. **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -302,24 +305,62 @@ authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. - **Framing `docs/destination-capture-framing.md`, revision 7.** - Revisions 1–5 were approved over four review rounds; **revisions 6 - and 7 are corrections carrying the open blocker above** and have not - been implemented. -- **Implemented in two commits, and superseded in part.** `779bb02` is - the mechanism (`pmacs.window.capture_destination()`, the - `ViewDestination` rename, the profile argument); `d5a6170` is - `tests/destination_capture_acceptance.rs`. The gate line below was - green at `0efc8c0` and both preservation suites passed **unchanged** - (journey 47, dired 31) — §7's stop signal not firing rather than - being suppressed. - - **But those eight pins do NOT cover §7 as it now reads.** They were - written against revision 5's matrix, which review disproved: none of - them exercises a fallback placement, and none could — the two - fallback tests revision 6 asked for did not exist yet, and revision - 7 adds a third (the inside-the-body transition) that no - preflight-snapshot design can pass. Reading "eight pins covering §7" - off this entry is exactly the mistake it now exists to prevent. + Revisions 1–5 were approved over four review rounds; revisions 6 and 7 + are corrections carrying the blocker above, and **revision 7's design + is what the tree implements** — revision 6's preflight prediction is + NOT the shipped mechanism and must not be restored from that document. +- **Implemented in three commits.** `779bb02` is the mechanism + (`pmacs.window.capture_destination()`, the `ViewDestination` rename, + the profile argument); `d5a6170` is + `tests/destination_capture_acceptance.rs`; the revision-7 commit is + the panel-profile correction plus the invalid-UTF-8 hole. **12 pins**, + and both preservation suites pass **unchanged** (journey 47, dired 31) + — §7's stop signal not firing rather than being suppressed. +- **HOW THE PANEL PROFILE IS ENFORCED, so revision 6's version does not + get reinstated by someone reading only that document.** + - `EditorCore::display_buffer` refuses **between** `resolve_placement` + and `apply_placement` when a side request resolved to + `PlacementKind::Ordinary` under an active `"panel"` contract whose + destination fails the document preconditions + (`fallback_commit_refusal`). Refusing there means a refused fallback + mutates nothing. + - The contract (`CommitContract { destination, profile }`) rides on + the core, installed and restored by the **same** `ScopedFrontendGuard` + that scopes the frontend, so a `"panel"` profile can never outlive + the body that declared it. The field is private to the crate — Lua + cannot claim a profile for a placement it did not commit to. + - **The preflight predicate survives as an EARLY REFUSAL, not as the + guarantee.** `panel_placement_can_fall_back` still gates the + relaxation in `commit_destination_refusal`, so the statically + knowable case — a frontend that cannot render a panel at all, and + will not acquire the capability mid-body — refuses *before* the body + allocates a buffer, registers a handle and paints. That is the same + reason `commit_to` preflights at all. Both layers are pinned + separately and neither test subsumes the other. + - The four document checks live once, in + `EditorCore::document_destination_refusal`, because they are now + evaluated from two sites and two hand-written copies is how a + backstop ends up weaker than the thing it backs. + - **Three deliberate limits**, each a different decision rather than a + stricter version of this one: the **document profile is untouched** + (re-running its checks at placement would newly refuse dired's own + documented panel path — a preservation-suite stop signal); only a + **fallback** is guarded, not every `Ordinary` placement (a `"panel"` + body calling `display_file` is pinned as succeeding by + `a_captured_destination_survives_a_frontend_switch`); and the + refusal is of the **placement**, not of falling back — a `"panel"` + commit with an intact destination still degrades gracefully into the + document window. +- **Audit: nothing else relied on "a panel never touches a document".** + Four doc sites repeated the claim (`ViewDestination`'s own doc twice, + `capture_view_destination`, `ViewDestinationLua`) and were corrected; + no other code depended on it. Dired — the only Lua `commit_to` + consumer — takes the **two-argument document profile**, so all four + checks already applied to it, and it separately documents and accepts + the side-slot fallback (`builtin/runtime/dired.lua`). + `compile.lua`'s `already_in_panel` queries live state rather than + assuming, and the terminal adopter's rollback keys off + `DisplayOutcome::created_side`, already false on a fallback. - **TWO FRAMING CLAIMS THE TREE DID NOT MATCH.** Neither changed a decision; both are recorded because the framing says "counted, not estimated" and a reader will check. @@ -351,6 +392,27 @@ authoritative tip** — the ref, not a SHA. Recover with contract claim being executable rather than asserted. Dropping the frontend scope for the panel profile fails the survives-a-switch pin's panel row; dropping the no-document-window arm fails the Q#DC-4 pair. + + **Revision 7's four, each isolating a different way to get it wrong** — + and the pattern of *which* rows survive each is the evidence the layers + are independent rather than redundant: + 1. delete the `fallback_commit_refusal` call from `display_buffer` → + **only** the inside-the-body pin fails. Every other test passes, + which is exactly the hole revision 6 would have shipped. + 2. delete the `panel_placement_can_fall_back` arm from + `commit_destination_refusal` → **only** the two pre-established + fallback rows fail, and they fail on shape (a raise from the + backstop, with the body having run) rather than on outcome. + 3. make `panel_placement_can_fall_back` unconditionally `true` (the + "widen the predicate" non-fix) → the really-lands-in-the-panel pin, + the Q#DC-4 panel pin and the matrix's three panel rows all fail. + That is the profiles collapsing into one, made visible. + 4. make `fallback_commit_refusal` refuse *every* panel fallback → only + the graceful-degradation pin fails, which is the guard + over-reaching. + + And reverting the byte comparison to `to_str()?` fails the + `invalid utf-8` row with mlua's conversion error, on content. - **The public API #227 adopts against (Q#DC-5), pinned so it is a contract rather than an intention:** `pmacs.window.commit_to(dest, body [, profile])`. Profile is an diff --git a/src/editor.rs b/src/editor.rs index eb58138..c3da5bb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::{CellCoord, CellSize}; -use crate::editor_core::{EditorCore, GeometryUpdate}; +use crate::editor_core::{CommitContract, EditorCore, GeometryUpdate}; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::key::{Chord, display_sequence}; use crate::keymap_stack::{Action, KeyDispatcher}; @@ -119,20 +119,26 @@ impl ScopedFrontend { } /// Enter a background frontend scope, also swapping the core's - /// ambient `active_frontend`. Both are restored on drop, on every - /// exit path including a raising callback. + /// ambient `active_frontend` and publishing `contract`. All three are + /// restored on drop, on every exit path including a raising callback. + /// + /// The frontend comes from `contract.destination` rather than being + /// passed separately: a scope entered for one frontend while carrying + /// another's destination would let the placement guard check the + /// wrong window, and there is no caller that wants them to differ. pub(crate) fn enter( &self, core: &SharedCore, commit_scope: &CommitScopeActive, - frontend_id: FrontendId, + contract: CommitContract, ) -> ScopedFrontendGuard { + let frontend_id = contract.destination.frontend; let previous = self.0.replace(Some(frontend_id)); - let previous_active = { + let (previous_active, previous_contract) = { let mut core = core.borrow_mut(); let was = core.active_frontend; core.active_frontend = frontend_id; - was + (was, core.enter_commit_contract(Some(contract))) }; let previous_commit = commit_scope.0.replace(true); ScopedFrontendGuard { @@ -140,6 +146,7 @@ impl ScopedFrontend { core: core.clone(), previous, previous_active, + previous_contract, commit_scope: commit_scope.clone(), previous_commit, } @@ -151,6 +158,11 @@ pub(crate) struct ScopedFrontendGuard { core: SharedCore, previous: Option, previous_active: FrontendId, + /// The contract in force before this commit, restored with the rest + /// (Q#DC-2). Held here rather than on a separate guard so a + /// `"panel"` profile can never outlive the body that declared it and + /// govern an unrelated later display. + previous_contract: Option, /// Cleared together with the scope, so an awaiting callback cannot /// leave `await` refused after the commit ends (Q#JR14b). commit_scope: CommitScopeActive, @@ -160,7 +172,11 @@ pub(crate) struct ScopedFrontendGuard { impl Drop for ScopedFrontendGuard { fn drop(&mut self) { self.scope.0.set(self.previous); - self.core.borrow_mut().active_frontend = self.previous_active; + { + let mut core = self.core.borrow_mut(); + core.active_frontend = self.previous_active; + core.enter_commit_contract(self.previous_contract); + } self.commit_scope.0.set(self.previous_commit); } } diff --git a/src/editor_core.rs b/src/editor_core.rs index bae3137..43c6656 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -144,8 +144,9 @@ pub enum ResolvedTarget { /// result. /// /// The fields are load-bearing, and the document pair is **optional** -/// (Q#DC-4) because a panel result needs only a live frontend, so a -/// frontend whose document window has gone can still host one: +/// (Q#DC-4) because a panel result needs only a live frontend *when it +/// really lands in a panel*, so a frontend whose document window has +/// gone can still host one: /// /// * `frontend` — the scope the commit must run in. Always present. /// * `window` — the exact destination; the ambient selected window is @@ -163,9 +164,13 @@ pub enum ResolvedTarget { /// /// Which of those a commit actually requires is the **profile**, chosen /// at `pmacs.window.commit_to` rather than at capture (Q#DC-2/Q#DC-5): -/// the document profile requires all of them, the panel profile requires -/// only a live `frontend`. Capture stays profile-blind so a caller does -/// not have to know at capture time what it will do at commit time. +/// the document profile requires all of them, and the panel profile +/// requires only a live `frontend` **while its result really lands in a +/// panel**. A side request that falls back into a document window *is* a +/// document replacement, and is held to all of them at the placement +/// boundary ([`EditorCore::fallback_commit_refusal`]). Capture stays +/// profile-blind so a caller does not have to know at capture time what +/// it will do at commit time. /// /// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a /// table, the *same* value is handed to every resolver listener in turn, @@ -181,6 +186,55 @@ pub struct ViewDestination { pub buffer: Option, } +/// Which of `commit_to`'s preconditions a body actually depends on +/// (Q#DC-2). +/// +/// A **closed** set of two, not an open string namespace: a third +/// profile is a decision about what a continuation may depend on, not a +/// spelling. Chosen at `commit_to` rather than at capture, because the +/// caller knows what it is about to do only then. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommitProfile { + /// The body replaces the captured window's buffer: **all four** + /// preflight checks apply. This is what an omitted profile means, so + /// every caller written before the profile existed keeps exactly the + /// guarantees it was written against. + Document, + /// The body puts its result in a bottom panel rather than in the + /// captured document window, and so does not depend on checks 2–4 — + /// **for as long as its result really lands in a panel**. When a side + /// request falls back into a document window the relaxation is + /// withdrawn at the placement boundary, which is the only place the + /// fallback is a fact rather than a guess + /// ([`EditorCore::display_buffer`]). + Panel, +} + +/// The contract a `commit_to` body is running under, published on the +/// core for the placement path to consult (Q#DC-2, revision 7). +/// +/// **Why this exists rather than a preflight prediction.** Revision 6 +/// tried to decide at preflight whether a `"panel"` commit's placement +/// could fall back into a document window, on the argument that nothing +/// could change in between because the body cannot `await`. Refusing +/// `await` stops another coroutine interleaving; it says nothing about +/// the body itself, which is arbitrary Lua running synchronously and can +/// change the very state the snapshot measured — obtain the panel, set +/// it `dedicated`, then request a side display. A snapshot cannot bind +/// that. The fact "this asked for a side and landed in a document +/// window" is only ever known where placement resolves, so that is where +/// the document preconditions are enforced. +/// +/// Installed and restored by the same guard that scopes the frontend, so +/// the two can never disagree about whether a commit is on the stack. +#[derive(Clone, Copy, Debug)] +pub struct CommitContract { + /// The destination the continuation captured. + pub destination: ViewDestination, + /// What that continuation declared it depends on. + pub profile: CommitProfile, +} + /// A `display_buffer` request (Q#BP3). /// /// `height` and `dedicated` are deliberately option-valued at the policy @@ -636,6 +690,14 @@ pub struct EditorCore { /// slot; the producer clears any untaken record when the fan-out /// returns. typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, + /// The `commit_to` contract currently on the stack, if any (Q#DC-2). + /// + /// Private and `pub(crate)`-free on purpose: it is installed only by + /// [`crate::editor::ScopedFrontend::enter`]'s guard, which restores + /// the previous value on every exit path including a raising body. + /// Nothing outside this crate can set it, so a `"panel"` profile is + /// not something Lua can claim for a placement it did not commit to. + commit_contract: Option, } impl EditorCore { @@ -690,9 +752,24 @@ impl EditorCore { query_replace: None, typed_edit_pending: None, typed_edit_armed: None, + commit_contract: None, } } + /// Install `contract` for the duration of a `commit_to` body, + /// returning the previous one for the guard to restore. + /// + /// Crate-private and paired with the frontend scope rather than a + /// standalone setter: a contract that could be installed without + /// being restored would outlive its body and silently govern the + /// next unrelated display. + pub(crate) fn enter_commit_contract( + &mut self, + contract: Option, + ) -> Option { + std::mem::replace(&mut self.commit_contract, contract) + } + /// Build a core from raw bytes under `name`. Used by tests. /// Replaces the scratch buffer's content; the active window is /// retained. @@ -3064,11 +3141,13 @@ impl EditorCore { /// **Profile-blind and total**: it records what is there rather than /// what a caller intends to do later, and it never fails while a /// frontend id exists. A frontend with no document window yields a - /// destination carrying only `frontend` — enough for a panel commit, - /// and refused by a document commit with a reason naming the missing - /// window. Returning `None` here instead would push the caller back - /// onto ambient state, which is the misrouting the capture exists to - /// remove. + /// destination carrying only `frontend` — enough for a panel commit + /// that really places in the panel, and refused by a document commit + /// (or by a panel commit that falls back into a document window, see + /// [`Self::fallback_commit_refusal`]) with a reason naming the + /// missing window. Returning `None` here instead would push the + /// caller back onto ambient state, which is the misrouting the + /// capture exists to remove. /// /// The document pair is set or cleared **together**: a window whose /// entry has gone yields neither half, so no consumer has to handle @@ -3095,6 +3174,102 @@ impl EditorCore { } } + /// The document profile's preconditions on a captured destination — + /// Q#DC-2's checks 2, 3 and 4, plus Q#DC-4's missing-pair case. + /// + /// **One rule in one place**, because it is now evaluated from two + /// sites and they must not drift: `commit_to`'s preflight runs it + /// before the body, and [`Self::display_buffer`] runs it again when a + /// `"panel"` commit's side request actually falls back into a + /// document window. A second copy of these three checks is how the + /// backstop ends up subtly weaker than the thing it backs. + /// + /// Check 1 (the requesting frontend still has a layout) is + /// deliberately *not* here: it is shared by both profiles rather than + /// specific to the document one, and the placement path cannot fail + /// it — it is placing into that very frontend. + #[must_use] + pub fn document_destination_refusal(&self, dest: &ViewDestination) -> Option { + let Some(window) = dest.window else { + // The capture found no document window (Q#DC-4). A refusal + // rather than a raise, so it joins the others as one more + // thing the destination can fail to satisfy and an adopter + // handles it the same way. + return Some( + "destination has no document window (capture it from a frontend that has \ + one, or commit with the \"panel\" profile)" + .to_string(), + ); + }; + // 2. The destination window is still live in the frontend. + if !self + .views + .get(&dest.frontend) + .is_some_and(|view| view.layout.iter_ids().contains(&window)) + { + return Some(format!("window {} is gone", window.raw())); + } + // 3. Stale intent (Q#JR14c): the user replaced the buffer while + // the work was in flight. Their action is newer information + // than the request, so the request loses. + if self + .windows + .get(&window) + .is_some_and(|w| Some(w.buffer_id) != dest.buffer) + { + return Some(format!("window {} now shows another buffer", window.raw())); + } + // 4. Replaceability (Q#JR14f). `None` because the replacement + // does not exist yet — passing the captured buffer would + // approve a window dedicated to *it*, and the handler's + // different buffer would be refused later, after mutating. + if !self.window_accepts_buffer(window, None) { + return Some(format!("window {} is dedicated", window.raw())); + } + None + } + + /// `commit_to`'s **preflight**: what a commit under `profile` can be + /// refused for before its body runs at all (Q#DC-2). + /// + /// Ordering is the whole point of preflighting rather than validating + /// at display time: an async body mutates real state (claims a + /// buffer, registers a handle, paints) long before it reaches any + /// call that could refuse, so a late refusal leaves debris behind. + /// + /// **This is an early refusal, NOT the guarantee.** For the panel + /// profile it can only read the state that holds *now*, and the body + /// is arbitrary synchronous Lua that may change it — dedicate the + /// side slot, then request a side display. The guarantee that a + /// `"panel"` commit never replaces a newer document therefore lives + /// at the placement boundary in [`Self::display_buffer`], where the + /// fallback is a fact. What this buys is that the common case — a + /// frontend that simply cannot render a panel — refuses **before** + /// the body allocates anything. + #[must_use] + pub fn commit_destination_refusal( + &self, + dest: &ViewDestination, + profile: CommitProfile, + ) -> Option { + // 1. The requesting frontend still has a layout. Required by + // BOTH profiles, because a frontend that is gone can host + // nothing. + if !self.views.contains_key(&dest.frontend) { + return Some("requesting frontend is gone".to_string()); + } + // 2, 3 and 4 are DELIBERATELY OMITTED for a panel result that + // really lands in a panel, not overlooked (Q#DC-2): it does not + // occupy the captured document window, does not replace its + // buffer, and does not need it to exist, so each would refuse for + // a reason unrelated to what the continuation does. Every one of + // the three is pinned as NOT refusing under this profile. + if profile == CommitProfile::Panel && !self.panel_placement_can_fall_back(dest.frontend) { + return None; + } + self.document_destination_refusal(dest) + } + /// [`Self::primary_document_window`]'s buffer, falling back to the /// focused window's when the layout is degenerate. #[must_use] @@ -3854,6 +4029,11 @@ impl EditorCore { .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? .active; let placement = self.resolve_placement(fid, request)?; + // THE PLACEMENT BOUNDARY (Q#DC-2, revision 7). Refuse before + // `apply_placement` so a refused fallback mutates nothing. + if let Some(reason) = self.fallback_commit_refusal(request, &placement) { + return Err(reason); + } self.apply_placement(fid, request, &placement)?; let select = request .select @@ -3979,6 +4159,112 @@ impl EditorCore { .ok_or_else(|| "display_file: no eligible document window is available".into()) } + /// Whether a `{side = ...}` request in `fid` would fall back into an + /// ordinary document window **given the state right now** (Q#DC-2). + /// + /// Adjacent to [`Self::resolve_placement`] because that is the rule + /// it predicts, and a prediction that drifts from the rule is worse + /// than none. The two fallback arms, in that function's own order: + /// + /// 1. **step 2's capability guard** — `side` is honoured only on a + /// `panel_capable` frontend; without the capability the request + /// falls through to step 3's ordinary policy (Q#BP13). + /// 2. **step 2's dedicated arm** — the one side slot exists but is + /// dedicated, and a second one is never created, so a different + /// buffer falls through instead (Q#BP3 2.iii). + /// + /// **A PREDICTION, AND ONLY USED AS ONE.** This is consulted by + /// [`Self::commit_destination_refusal`] to refuse the statically + /// knowable case *before* a body allocates anything — a frontend that + /// cannot render a panel at all will not acquire the capability + /// mid-body. It is **not** what makes the panel profile safe. A + /// `commit_to` body is arbitrary synchronous Lua and can dedicate the + /// side slot itself between this answer and the placement it + /// describes; refusing `await` prevents another coroutine + /// interleaving, not the body rewriting the state it was measured + /// against. The guarantee is enforced where the fallback is a fact, + /// in [`Self::fallback_commit_refusal`]. + /// + /// Arm 2 is answered **conservatively**: `resolve_placement` falls + /// back only when the arriving buffer differs from the dedicated one, + /// and at preflight the body has not chosen a buffer yet. + /// + /// A frontend with no view answers `false`: where placement would + /// land is moot when there is nothing to place into, and + /// `commit_destination_refusal` has already refused that case by its + /// first check. + #[must_use] + pub fn panel_placement_can_fall_back(&self, fid: FrontendId) -> bool { + let Some(view) = self.views.get(&fid) else { + return false; + }; + if !view.panel_capable { + return true; + } + self.side_window_for(fid) + .and_then(|side| self.windows.get(&side)) + .is_some_and(|side| side.params.dedicated) + } + + /// **The guarantee** behind the `"panel"` commit profile (Q#DC-2, + /// revision 7): a side request that actually fell back into a + /// document window must satisfy the document preconditions. + /// + /// Reaching [`PlacementKind::Ordinary`] while a side was REQUESTED is + /// exactly the fallback [`Self::apply_placement`] documents — not + /// panel-capable, or the one slot is dedicated elsewhere — and the + /// result is then installed into a **document** window. A `"panel"` + /// commit that skipped checks 2–4 on the strength of "a panel never + /// touches a document window" would, right here, replace a document + /// view with no stale-intent guard at all: capture A, the user opens + /// B, the continuation lands, B is gone. That is the failure + /// `commit_to` exists to prevent, arrived at through the profile + /// meant to be the safe one. + /// + /// **Why here and not at preflight.** This is the first moment the + /// fallback is a *fact*. A preflight snapshot cannot bind it: the + /// body is arbitrary synchronous Lua and may create the very + /// condition — take the panel, set it `dedicated`, then ask for a + /// side — after the snapshot was taken. Refusing `await` inside the + /// commit scope stops a *second coroutine* interleaving; it places no + /// restriction on the body's own statements. + /// + /// Three deliberate limits, each of which would be a different + /// decision rather than a stricter version of this one: + /// + /// * **The document profile is untouched.** Its preflight already ran + /// these checks against the same destination, and re-running them + /// here would newly refuse dired's own panel path, which documents + /// and accepts the fallback (`builtin/runtime/dired.lua`). + /// * **Only a fallback, not every document placement.** A panel-profile + /// body that displays into a document window *without asking for a + /// side* has mislabelled its profile; it has not exercised this + /// relaxation. Widening to every [`PlacementKind::Ordinary`] would + /// also refuse a `"panel"` commit whose body calls `display_file`, + /// which is pinned as succeeding. + /// * **Refusing the placement, not the fallback.** Falling back is + /// deliberate graceful degradation for a frontend without panel + /// capability; a `"panel"` commit whose destination is still valid + /// falls back and lands exactly as it does today. The profile + /// relaxes checks; it does not get to move where a result goes. + fn fallback_commit_refusal( + &self, + request: &DisplayRequest, + placement: &Placement, + ) -> Option { + if request.side.is_none() || !matches!(placement.kind, PlacementKind::Ordinary) { + return None; + } + let contract = self.commit_contract.as_ref()?; + if contract.profile != CommitProfile::Panel { + return None; + } + let reason = self.document_destination_refusal(&contract.destination)?; + Some(format!( + "display: this \"panel\" commit fell back to a document window, and {reason}" + )) + } + /// Q#BP3's precedence: exact target, then side affinity, then /// ordinary reuse. Placement affinity precedes generic reuse — /// otherwise a persistent `*compilation*` buffer already visible in a diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index d4386d5..6679507 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -4258,8 +4258,9 @@ fn install_path_module(lua: &Lua) -> mlua::Result
{ /// /// `window()` returns **nil** when the capturing frontend had no /// document window (Q#DC-4) — such a destination is still commitable -/// under the panel profile, so the accessor reports the absence rather -/// than inventing an id. +/// under the panel profile wherever that profile's relaxation actually +/// applies, so the accessor reports the absence rather than inventing an +/// id. pub(crate) struct ViewDestinationLua(pub(crate) crate::editor_core::ViewDestination); impl mlua::UserData for ViewDestinationLua { diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index 11e92b7..d9051a0 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -34,7 +34,9 @@ use mlua::{Lua, Table, Value}; use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined}; -use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome}; +use crate::editor_core::{ + CommitContract, CommitProfile, DisplayOutcome, DisplayRequest, HookKind, QuitOutcome, +}; use crate::protocol::FrontendId; use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId}; @@ -63,27 +65,6 @@ pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { .unwrap_or_else(|| core.borrow().active_frontend_key()) } -/// Which of `commit_to`'s preconditions a body actually depends on -/// (Q#DC-2). -/// -/// A **closed** set of two, not an open string namespace: a third -/// profile is a decision about what a continuation may depend on, not a -/// spelling. Chosen at `commit_to` rather than at capture, because the -/// caller knows what it is about to do only then. -#[derive(Clone, Copy, PartialEq, Eq)] -enum CommitProfile { - /// The body replaces the captured window's buffer: **all four** - /// preflight checks apply. This is what an omitted profile means, - /// so every caller written before the profile existed keeps exactly - /// the guarantees it was written against. - Document, - /// The body puts its result somewhere that is not the captured - /// document window — a bottom panel, typically. Only the "requesting - /// frontend still has a layout" check applies; see the preflight for - /// why each of the other three is *deliberately* omitted. - Panel, -} - /// One message for every bad profile — an unrecognized string and a /// non-string alike (Q#DC-5). /// @@ -106,12 +87,19 @@ const BAD_COMMIT_PROFILE: &str = "pmacs.window.commit_to: profile must be the st /// threading an optional variable produces `commit_to(dest, body, nil)`, /// and a third behaviour there would stay invisible until someone hit /// it. +/// +/// The comparison is on **bytes**, for the same reachability reason one +/// layer down. A Lua string is a byte string, not UTF-8, so +/// `commit_to(dest, body, string.char(255))` fails a `to_str()` +/// conversion and surfaces mlua's generic UTF-8 error *before* the +/// message below is ever constructed. An invalid-UTF-8 profile is a bad +/// profile like any other and gets the documented refusal. fn commit_profile(value: &Value) -> mlua::Result { match value { Value::Nil => Ok(CommitProfile::Document), - Value::String(name) => match &*name.to_str()? { - "document" => Ok(CommitProfile::Document), - "panel" => Ok(CommitProfile::Panel), + Value::String(name) => match name.as_bytes().as_ref() { + b"document" => Ok(CommitProfile::Document), + b"panel" => Ok(CommitProfile::Panel), // An unrecognized profile ERRORS rather than falling back to // the document one: a fallback would silently hand a caller // stricter or looser checks than it asked for, which is the @@ -549,70 +537,22 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result // by reading this signature. let profile = commit_profile(&profile)?; - let refusal = { - let core = cc.borrow(); - // 1. The requesting frontend still has a layout. - // Required by BOTH profiles: it is the whole - // of the panel profile (Q#DC-2), because a - // frontend that is gone can host nothing. - if !core.views.contains_key(&dest.frontend) { - Some("requesting frontend is gone".to_string()) - } else if profile == CommitProfile::Panel { - // 2, 3 and 4 are DELIBERATELY OMITTED here, - // not overlooked (Q#DC-2). A panel result - // does not occupy the captured document - // window, does not replace its buffer, and - // does not need it to exist --- so each of - // those checks would refuse for a reason - // unrelated to what the continuation does, - // and a refusal a user cannot explain is how - // a mechanism gets worked around. Every one - // of the three is pinned as NOT refusing - // under this profile. - None - } else if let Some(window) = dest.window { - if !core - .views - .get(&dest.frontend) - .is_some_and(|view| view.layout.iter_ids().contains(&window)) - { - // 2. The destination window is still live in it. - Some(format!("window {} is gone", window.raw())) - } else if core - .windows - .get(&window) - .is_some_and(|w| Some(w.buffer_id) != dest.buffer) - { - // 3. Stale intent (Q#JR14c): the user - // replaced the buffer while the work was - // in flight. Their action is newer - // information than the request, so the - // request loses. - Some(format!("window {} now shows another buffer", window.raw())) - } else if !core.window_accepts_buffer(window, None) { - // 4. Replaceability (Q#JR14f). `None` - // because the replacement does not exist - // yet — passing the captured buffer would - // approve a window dedicated to *it*, and - // the handler's different buffer would be - // refused later, after mutating. - Some(format!("window {} is dedicated", window.raw())) - } else { - None - } - } else { - // The capture found no document window - // (Q#DC-4). A refusal rather than a raise, so - // it joins the four above as one more thing - // the destination can fail to satisfy and an - // adopter handles it the same way. - Some( - "destination has no document window (capture it from a frontend \ - that has one, or commit with the \"panel\" profile)" - .to_string(), - ) - } - }; + // The preflight itself lives on the core + // (`commit_destination_refusal`), because the panel + // profile's relaxation now has a SECOND evaluation + // site --- the placement boundary, where a fallback + // into a document window stops being a prediction and + // becomes a fact --- and two hand-written copies of + // the same three checks is how the backstop ends up + // weaker than the thing it backs. + // + // What survives here, and only here: an early refusal + // costs the body nothing, so the statically knowable + // case (a frontend that cannot render a panel at all) + // never reaches the body's buffer creation. The + // GUARANTEE is not this call; see + // `EditorCore::fallback_commit_refusal`. + let refusal = cc.borrow().commit_destination_refusal(&dest, profile); if let Some(reason) = refusal { let mut out = mlua::MultiValue::new(); out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?)); @@ -636,13 +576,24 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result ) })? .clone(); - // Both the override and the core's ambient - // `active_frontend` are restored when this guard - // drops -- on the normal return AND on a raising - // callback, which is why the result is captured - // rather than `?`-propagated through the drop. + // The override, the core's ambient `active_frontend`, + // and the CONTRACT below are all restored when this + // guard drops -- on the normal return AND on a + // raising callback, which is why the result is + // captured rather than `?`-propagated through the + // drop. The contract rides with the scope because the + // placement boundary needs to know, for every display + // this body performs, which destination and which + // profile it is running under. let result = { - let _guard = scope.enter(&cc, &commit, dest.frontend); + let _guard = scope.enter( + &cc, + &commit, + CommitContract { + destination: dest, + profile, + }, + ); body.call::(()) }; let mut out = result?; diff --git a/tests/destination_capture_acceptance.rs b/tests/destination_capture_acceptance.rs index 534d90c..842b39e 100644 --- a/tests/destination_capture_acceptance.rs +++ b/tests/destination_capture_acceptance.rs @@ -11,7 +11,7 @@ //! it lands (§8). Every test here therefore drives the Lua surface //! directly rather than through a consumer. //! -//! Two disciplines it keeps: +//! Three disciplines it keeps: //! //! * **Every "not applicable" cell in Q#DC-2's preflight matrix is //! asserted as NOT refusing**, not merely left untested. A check @@ -20,6 +20,19 @@ //! * **A refusal is asserted on its reason**, never on the mere fact //! that something failed. `commit_to` has five distinct refusals and a //! raise; "it errored" would pass on any of the wrong ones. +//! * **The panel profile's relaxation is pinned at BOTH of its +//! evaluation sites** (revision 7). The preflight is an early refusal +//! that spares the body; the guarantee is enforced where placement +//! resolves, because the body is arbitrary synchronous Lua and can +//! create the fallback *after* any snapshot was taken — refusing +//! `await` stops a second coroutine interleaving, not the body's own +//! statements. Three tests carry that split and none subsumes another: +//! `a_panel_commit_that_falls_back_runs_the_document_preflight` (the +//! body must not run), +//! `a_panel_commit_whose_body_creates_the_fallback_is_refused_at_placement` +//! (the result must not land), and +//! `a_panel_commit_that_falls_back_with_a_valid_destination_still_lands` +//! (falling back is still graceful degradation, not an error). //! //! `tests/journey_acceptance.rs` and `tests/dired_acceptance.rs` are the //! preservation half of the same §7 and are run alongside this suite: @@ -68,6 +81,15 @@ fn buffer_in(s: &EditorState, window: WindowId) -> Option { s.core.borrow().windows.get(&window).map(|w| w.buffer_id) } +/// A window's buffer **by name**, so a placement assertion reads as +/// "`*result*` went to the panel" rather than as two opaque ids. +fn name_in(s: &EditorState, window: WindowId) -> String { + let buffer = buffer_in(s, window).expect("window is live"); + let core = s.core.borrow(); + let registry = core.registry.borrow(); + registry.get(buffer).expect("buffer").name().to_string() +} + fn local_window(s: &EditorState) -> WindowId { s.core .borrow() @@ -151,12 +173,15 @@ fn capture(s: &EditorState) { ); } -/// Run `body` under `profile` and report `(ok, reason)`. +/// Run a body that also executes `also` under `profile`, reporting +/// `(ok, reason)`. /// /// `profile` is spliced as a Lua expression, so a caller can pass /// `"nil"`, `"'panel'"`, `"42"` — the argument-shape distinctions -/// Q#DC-5 turns on are exactly what this suite has to vary. -fn commit(s: &EditorState, profile: Option<&str>) { +/// Q#DC-5 turns on are exactly what this suite has to vary. `also` is +/// spliced as Lua statements, for the rows that must observe *where* an +/// accepted commit put its result and not merely that it was accepted. +fn commit_body(s: &EditorState, profile: Option<&str>, also: &str) { let call = match profile { Some(profile) => format!("pmacs.window.commit_to(dest, body, {profile})"), None => "pmacs.window.commit_to(dest, body)".to_string(), @@ -165,7 +190,7 @@ fn commit(s: &EditorState, profile: Option<&str>) { s, &format!( "ran = false - local body = function() ran = true end + local body = function() ran = true; {also} end raised = nil local caught, a, b = pcall(function() return {call} end) if caught then ok, reason = a, b @@ -174,6 +199,11 @@ fn commit(s: &EditorState, profile: Option<&str>) { ); } +/// Run an inert body under `profile` and report `(ok, reason)`. +fn commit(s: &EditorState, profile: Option<&str>) { + commit_body(s, profile, ""); +} + fn ok(s: &EditorState) -> bool { eval(s, "return ok == true") } @@ -413,6 +443,342 @@ fn the_preflight_matrix_holds_in_both_profiles() { } } +// --------------------------------------------------------------------------- +// §7 — the panel profile's relaxation is CONDITIONAL (Q#DC-2, revision 7) +// --------------------------------------------------------------------------- + +/// The Lua a `"panel"` continuation runs: put a result buffer in the +/// bottom panel. It is the shape `listview.open` resolves to by default +/// (`builtin/runtime/listview.lua`), and the shape git's `*git-status*` +/// adoption will take. +const PANEL_BODY: &str = "pmacs.window.display(pmacs.buffer.create('*result*'), \ + { side = 'bottom' })"; + +/// Arrange one of the two reasons a side request falls back into a +/// document window, and assert the arrangement took. +/// +/// The two arms are independent branches of +/// `EditorCore::resolve_placement`, so a fix that handled only one would +/// leave the other live. Every fallback test below drives both. +fn arrange_fallback(s: &EditorState, cause: &str) { + if cause == "not panel-capable" { + // Q#BP13's capability gate: `side` is honoured only on a + // panel-capable frontend. + s.core + .borrow_mut() + .views + .get_mut(&FrontendId::LOCAL) + .expect("LOCAL view") + .panel_capable = false; + } else { + // Q#BP3 2.iii: the one side slot is dedicated to another buffer, + // and a second panel is never created. + exec( + s, + "pmacs.window.display(pmacs.buffer.create('*pinned*'), + { side = 'bottom', dedicated = true, select = false })", + ); + assert!( + s.core.borrow().side_window_for(FrontendId::LOCAL).is_some(), + "{cause}: the arrangement must actually create the side slot" + ); + } +} + +/// **N** — a `"panel"` commit whose placement *already* falls back is +/// refused **before its body runs**, on the stale-intent reason. +/// +/// The defect: the panel column dropped checks 2–4 on the claim that a +/// panel result never touches a document window — but panel placement +/// falls back to an ordinary document window and then *installs the +/// result there* (`EditorCore::apply_placement` says so in its own +/// comment). The relaxation therefore handed a `"panel"` commit +/// permission to overwrite a document view with no stale-intent guard: +/// capture A, the user opens B, the continuation lands and B is gone. +/// +/// **This is the EARLY half, not the guarantee.** It is served by +/// `EditorCore::commit_destination_refusal` consulting +/// `panel_placement_can_fall_back`, which can only read the state that +/// holds *now*. The reason that is worth having anyway is the same reason +/// `commit_to` preflights at all: a body allocates a buffer, registers a +/// handle and paints long before it reaches any call that could refuse, +/// so refusing here leaves no debris. A frontend that cannot render a +/// panel will not acquire the capability mid-body, which is exactly the +/// case this catches. +/// +/// The guarantee — for the case a snapshot **cannot** catch, where the +/// body creates the fallback itself — is +/// `a_panel_commit_whose_body_creates_the_fallback_is_refused_at_placement`. +/// Neither test subsumes the other: this one pins that nothing runs, that +/// one pins that nothing lands. +/// +/// Each row asserts four things: the commit **refuses**, it refuses for +/// the stale-intent reason (not incidentally), the body never ran, and +/// the newer buffer is still there. +/// +/// *Mutation:* delete the `panel_placement_can_fall_back` arm from +/// `commit_destination_refusal`. Both rows fail — the body runs, and the +/// placement backstop then refuses as a *raise*, so `ok`/`ran`/`reason` +/// all move. +#[test] +fn a_panel_commit_that_falls_back_runs_the_document_preflight() { + for cause in ["not panel-capable", "side slot dedicated elsewhere"] { + let s = editor(); + + // Arrange the fallback cause BEFORE capturing, so the preflight + // can see it — which is exactly what distinguishes this test from + // the body-induced one below. + arrange_fallback(&s, cause); + + capture(&s); + let doc = local_window(&s); + assert_eq!( + eval::>(&s, "return dest:window()"), + Some(doc.raw()), + "{cause}: the capture must name the document window, not the panel" + ); + + // The user replaces the captured buffer while the work is in + // flight: `*newer*` is newer information than the request. + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "{cause}: the arrangement must make the captured window stale" + ); + + commit_body(&s, Some("'panel'"), PANEL_BODY); + + assert_eq!( + raised(&s), + None, + "{cause}: a precondition is a refusal, not a raise" + ); + assert!( + !ok(&s), + "{cause}: a \"panel\" commit that lands in a DOCUMENT window must run the \ + document preflight -- the relaxation is conditional on the placement really \ + being a panel" + ); + assert!( + reason(&s).contains("now shows another buffer"), + "{cause}: and refuse on stale intent; got {:?}", + reason(&s) + ); + assert!(!ran(&s), "{cause}: the callback must not run"); + assert_eq!( + name_in(&s, doc), + "*newer*", + "{cause}: the user's newer buffer must survive -- this is the assertion that \ + fails loudest when the guard is removed" + ); + } +} + +/// **N** — the case no preflight snapshot can catch: the **body itself** +/// creates the fallback, and the refusal still fires. +/// +/// This is why the guarantee moved to the placement boundary. Revision 6 +/// argued that a prediction taken at preflight could not go stale, +/// because `commit_to`'s body cannot `await`. Refusing `await` prevents +/// another *coroutine* interleaving; it places no restriction on the body +/// itself, which is arbitrary Lua running synchronously: +/// +/// ```lua +/// pmacs.window.set_params(pmacs.window.panel(), { dedicated = true }) +/// pmacs.window.display(result, { side = "bottom" }) +/// ``` +/// +/// Two statements. The first invalidates the prediction, the second cashes +/// it in. The arrangement here is deliberately the **inverse** of the +/// preflight rows: an undedicated panel exists, so the prediction says +/// "this will land in the panel", the relaxation applies, and the body +/// runs. Only when placement resolves is the fallback a fact. +/// +/// What it asserts, and why each is load-bearing: +/// +/// * the body **did** run — otherwise the test would be re-proving the +/// preflight and this whole case would be untested; +/// * the refusal arrives as a **raise** from `display`, since the body was +/// already running and there is no `(false, reason)` left to return — +/// asserted on content, and it names both the fallback and the +/// stale-intent reason; +/// * `*newer*` is **still in the document window**. That is the actual +/// user-visible guarantee; everything above it is mechanism. +/// +/// *Mutation:* delete the `fallback_commit_refusal` call from +/// `display_buffer`. This test fails on all three; every other test in +/// this file still passes, which is precisely the hole revision 6 left. +#[test] +fn a_panel_commit_whose_body_creates_the_fallback_is_refused_at_placement() { + let s = editor(); + + // A REUSABLE panel: undedicated, so the preflight prediction says + // this frontend places side requests in the panel. + exec( + &s, + "pmacs.window.display(pmacs.buffer.create('*pinned*'), + { side = 'bottom', dedicated = false, select = false })", + ); + capture(&s); + let doc = local_window(&s); + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "the arrangement must make the captured window stale" + ); + + commit_body( + &s, + Some("'panel'"), + &format!( + "pmacs.window.set_params(pmacs.window.panel(), {{ dedicated = true }}) + {PANEL_BODY}" + ), + ); + + assert!( + ran(&s), + "the body must have run -- the preflight could not have known, and a test where \ + it did not run would be re-proving the preflight" + ); + let raised = raised(&s).expect( + "the refusal arrives as a raise: the body was already running, so there is no \ + (false, reason) return left to make", + ); + assert!( + raised.contains("fell back to a document window"), + "the message must name what happened; got {raised:?}" + ); + assert!( + raised.contains("now shows another buffer"), + "and which document precondition failed; got {raised:?}" + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "the user's newer buffer must survive -- this is the guarantee, and it is what a \ + preflight-only design cannot provide" + ); +} + +/// **P** — a `"panel"` commit that falls back with a **still-valid** +/// destination lands in the document window, exactly as it does today. +/// +/// The guard refuses on *staleness*, not on *falling back*. Falling back +/// is deliberate graceful degradation for a frontend that cannot render a +/// panel (`EditorCore::apply_placement`), and turning it into an error +/// would regress every consumer that works today on such a frontend — a +/// much bigger behaviour change than the defect being fixed. +/// +/// Both causes, and asserted on **where the result landed** rather than +/// on the commit merely being accepted: a design that accepted the commit +/// and then dropped the display on the floor would pass a weaker version +/// of this. +/// +/// *Mutation:* make `fallback_commit_refusal` refuse whenever a `"panel"` +/// commit falls back, instead of only when a document precondition fails. +/// Both rows fail here; every refusal test still passes, which is what +/// makes this the pin that stops the fix over-reaching. +#[test] +fn a_panel_commit_that_falls_back_with_a_valid_destination_still_lands() { + for cause in ["not panel-capable", "side slot dedicated elsewhere"] { + let s = editor(); + arrange_fallback(&s, cause); + capture(&s); + let doc = local_window(&s); + + // No staleness: the captured window still holds what it held. + commit_body(&s, Some("'panel'"), PANEL_BODY); + + assert_eq!(raised(&s), None, "{cause}: the commit must not raise"); + assert!( + ok(&s), + "{cause}: a fallback with an intact destination is graceful degradation, not \ + an error; got refusal {:?}", + reason(&s) + ); + assert!(ran(&s), "{cause}: the callback must run"); + assert_eq!( + name_in(&s, doc), + "*result*", + "{cause}: and the result really must land in the document window it fell \ + back to" + ); + } +} + +/// **P** — a `"panel"` commit that really lands in the panel still skips +/// checks 2–4. +/// +/// The other half of the correction, and it is not optional coverage. +/// The cheapest way to close the fallback hole is to make the panel +/// profile run the document preflight unconditionally — which passes +/// every fallback row above while quietly collapsing the two profiles +/// into one, leaving the whole parameterization buying nothing and +/// `git.status` refused for a document-window change unrelated to where +/// its panel goes. +/// +/// Deliberately arranged in the **same stale-intent state** the fallback +/// rows refuse on, so the only difference between this test and those is +/// whether the placement is really a panel. And it asserts *where* the +/// result went, not merely that the commit was accepted: an accepted +/// commit that still overwrote the document window would be the same +/// defect wearing a `true`. +/// +/// *Mutation:* widen the relaxation's condition back — i.e. make +/// `panel_placement_can_fall_back` return `true` unconditionally, or run +/// the document preflight for every `"panel"` commit. This fails on the +/// refusal; the fallback rows above still pass. **This is the pin that +/// makes "collapse the two profiles into one" a visible design change +/// rather than a quiet implementation choice.** +#[test] +fn a_panel_commit_that_really_lands_in_the_panel_keeps_its_relaxation() { + let s = editor(); + capture(&s); + let doc = local_window(&s); + + // Exactly the state the fallback rows refuse on. + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + + commit_body(&s, Some("'panel'"), PANEL_BODY); + + assert!( + ok(&s), + "a panel-capable frontend with no dedicated side slot really places in the \ + panel, so checks 2-4 stay omitted; got refusal {:?}", + reason(&s) + ); + assert!(ran(&s), "and the callback must run"); + + let panel = s + .core + .borrow() + .side_window_for(FrontendId::LOCAL) + .expect("the commit must have created the side window"); + assert_eq!( + name_in(&s, panel), + "*result*", + "the result must land in the PANEL -- an accepted commit that fell back would \ + be the same defect with a `true` in front of it" + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "and the captured document window must be untouched" + ); +} + // --------------------------------------------------------------------------- // §7 — the profile argument (Q#DC-5) // --------------------------------------------------------------------------- @@ -507,8 +873,16 @@ fn an_explicit_nil_profile_is_the_document_profile() { /// against the string case's message, not merely on "an error /// occurred". /// +/// **The `invalid utf-8` row is the same reachability class one layer +/// down.** A Lua string is a *byte* string, so `string.char(255)` is a +/// perfectly ordinary `Value::String` that a `to_str()` inside the body +/// still fails to convert — surfacing mlua's generic UTF-8 error before +/// the documented message is ever constructed. Accepting `Value` is not +/// enough on its own; the comparison has to be on bytes. +/// /// *Mutation:* retype the argument to `Option`. The number and -/// table rows fail. +/// table rows fail. *Second mutation:* compare via `name.to_str()?`. The +/// `invalid utf-8` row fails. #[test] fn a_bad_profile_is_refused_by_one_message_that_names_the_accepted_values() { let mut messages = Vec::new(); @@ -517,6 +891,7 @@ fn a_bad_profile_is_refused_by_one_message_that_names_the_accepted_values() { ("number", "42"), ("table", "{}"), ("boolean", "true"), + ("invalid utf-8", "string.char(255)"), ] { let s = editor(); capture(&s); From b72df34bc68f384781419c243f1782eee83d4c0c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:30:04 +0200 Subject: [PATCH 39/66] docs: destination-capture revision 8 --- refuse the mutation, keep the preflight Revisions 6 and 7 were both wrong, in opposite directions, and review caught each. Revision 6 predicted the fallback at preflight and argued the body could not change it. False: the await refusal stops concurrent interleaving, not the body, which is arbitrary synchronous Lua and can dedicate the side slot itself. Revision 7 moved enforcement to the placement boundary. That breaks the invariant commit_to exists for. Handoff section 748 states it without qualification --- it preflights every precondition BEFORE invoking the callback, because dired mutates handle state, prev and paint long before it reaches anything that could refuse, so validating at display time is four mutations too late. A refusal that arrives after arbitrary Lua has created buffers, handles and paint is not a refusal; it is a partial commit with an error return. So revision 8 does neither. It keeps the preflight where it is and REFUSES the mutations that would invalidate it --- the same shape as the await refusal already in this file, for the identical reason: something that would invalidate the scope guarantee is rejected rather than predicted around. Refusal stays mutation-free on the normal (false, reason) path. The mutation surface is narrow, which is what makes this tight rather than aspirational. dedicated is writable from Lua and is one of only two writable window fields per Q#BP2c; panel_capable has no Lua binding at all, checked across src/lua_bindings. But the implementation must ENUMERATE the body-reachable transitions rather than trust that list --- closing the side window, or any other route to no usable side slot, counts, and I have not proven those two exhaustive. If the enumeration is open-ended, the named fallback is to collapse the two profiles and always run all four checks. Safe, simple, honest, and it makes the parameterization pointless --- which is why it is the fallback and not the answer, and why choosing it needs its own approval. The inside-the-body test is strengthened accordingly. Revision 7 asked it to assert that document B was not replaced, which passes on a design that lets the body mutate freely and merely declines the final installation. It now asserts the dedication call is refused, the slot is still undedicated afterwards, and nothing partial was installed. The refusal must land on the mutation, not on the outcome. The ledger Q#DC-2 summary still repeated the disproved premise verbatim, so a recovering reader met two incompatible answers in one lane entry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 17 +++- docs/destination-capture-framing.md | 123 ++++++++++++++++------------ 2 files changed, 85 insertions(+), 55 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 71b68e9..5667f33 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -464,10 +464,19 @@ authoritative tip** — the ref, not a SHA. Recover with (`listview.open` defaults `display` to `"panel"`, `builtin/runtime/listview.lua:550`); `*git-diff*` replaces a **document** window. `commit_to`'s stale-intent check (Q#JR14c) is - right for the second and wrong for the first — the panel never - touches the captured window's buffer, so refusing on its change is a - refusal unrelated to what the continuation does. One shape - over-refuses the panel or under-checks the document. + right for the second and, *when the placement really is a panel*, + irrelevant to the first. One shape over-refuses the panel or + under-checks the document. + + **DO NOT READ THE OLDER FORM OF THIS BULLET, WHICH SAID "the panel + never touches the captured window's buffer".** That is the claim + revisions 6–8 invalidate: panel placement **falls back** to an + ordinary document window when the frontend is not panel-capable or + its side slot is dedicated. The relaxation is conditional, and the + mutations that could make it fall back are refused inside a + panel-profile commit (revision 8) rather than predicted at preflight + (revision 6) or caught at placement (revision 7, which would refuse + after the callback had already mutated). - **Stop signal recorded in the framing:** if any existing dired test needs editing, the generalization changed Journey Stage 1a's semantics, and that is cause to stop rather than to adjust the test. diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 29c478e..b0c01f3 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,7 +1,7 @@ # A destination capture any async continuation can use -**Status: revision 7. The mechanism is implemented at `0efc8c0`; -revisions 6 and 7 carry an OPEN correctness blocker that is NOT yet +**Status: revision 8. The mechanism is implemented at `0efc8c0`; +revisions 6–8 carry an OPEN correctness blocker that is NOT yet implemented.** *(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the @@ -9,16 +9,16 @@ ledger recorded the lane as approved and implemented. Same contradiction class this document keeps correcting elsewhere, left standing in its own header.)* -**Revision 7 replaces revision 6's fix, which was unsound for the same -reason revision 6's target was.** Revision 6 moved the panel/document -decision to a **preflight prediction**, arguing nothing could change -before placement because the body cannot `await`. The await refusal -stops *concurrent interleaving*; it does not stop the body — arbitrary -synchronous Lua — from dedicating the side slot itself and causing the -very fallback the preflight just ruled out. **Enforcement moves to the -placement boundary**, where the fallback is a fact rather than a -forecast, and §7 gains the inside-the-body test that the two -pre-established-state tests could never catch. +**Revision 8 rejects BOTH of the previous two fixes and takes a third +shape.** Revision 6 predicted the fallback at preflight (the body can +change it). Revision 7 moved enforcement to the placement boundary — +which **breaks the invariant `commit_to` exists for**: handoff §748 +says it preflights *before* the callback because "validating at display +time is four mutations too late", so a placement-time refusal arrives +after arbitrary Lua has created buffers, handles and paint. Revision 8 +keeps the preflight and **refuses the mutations that would invalidate +it**, the same shape as the existing await refusal. Refusal stays +mutation-free on the `(false, reason)` path. **Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave the panel profile only check 1, on the claim that a panel result never @@ -288,42 +288,57 @@ on the placement actually being a panel. Whenever placement **can** fall back to a document window, the panel profile runs the **full document preflight**. -**ENFORCEMENT IS AT THE PLACEMENT BOUNDARY, NOT AT PREFLIGHT — -revision 6 got this wrong too, and the reason is worth stating because -it is a whole class of mistake.** +**PREFLIGHT STAYS WHERE IT IS; THE MUTATION THAT WOULD INVALIDATE IT IS +REFUSED. Revisions 6 and 7 were both wrong, in opposite directions.** -Revision 6 said the two fallback causes are "predictable at preflight", -because `commit_to` refuses `await` so "nothing can change between -preflight and placement". **The await refusal prevents *concurrent -interleaving* — another coroutine mutating state while this one is -parked. It says nothing about the body itself**, which is arbitrary -Lua running synchronously and perfectly able to change the state the -preflight just measured: +Revision 6 predicted the fallback at preflight and argued the body +could not change it. **False**: the await refusal stops *concurrent +interleaving*, not the body, which is arbitrary synchronous Lua and can +dedicate the side slot itself. -> obtain the existing panel → set it `dedicated = true` → request panel -> display +Revision 7 then moved enforcement to the placement boundary. **That +breaks the invariant `commit_to` exists for.** `docs/agent-handoff.md` +§748 states it without qualification: -Preflight sees a reusable panel and relaxes checks 2–4; the body then -causes the fallback; the result replaces a stale document. **No -preflight predicate can close this**, however it is phrased — the -measurement is simply taken before the thing it measures is decided. +> [`commit_to`] preflights every precondition *before* invoking the +> callback — dired mutates handle state, `prev`, and paint long before +> it reaches anything that could refuse, so **validating at display +> time is four mutations too late**. -**So the check moves to where the fact is known.** Placement resolving -to `PlacementKind::Ordinary` for a request that asked for a side *is* -the fallback (`editor_core.rs:4138-4148`). At that point, under an -active panel-profile commit, the document preconditions are evaluated -against the captured destination and refused if they fail. The commit -scope is already Rust-side app data (`CommitScopeActive`), so the -profile and the destination can ride there for the placement path to -consult. +Refusing at placement means refusing *after* arbitrary callback code has +created buffers, handles and paint. A late refusal is not a refusal; it +is a partial commit with an error return. -**And the tempting non-fix, named so nobody reaches for it:** widening -the preflight predicate from "will it fall back" to "*could* it ever". -Since the body can always dedicate the side slot, that predicate is -always true, the panel profile collapses into the document profile, and -the parameterization buys nothing. If collapsing them is genuinely -right, that is a design decision needing its own approval — not a way -to make a broken predicate safe. +**So neither predict nor refuse late — forbid the mutation.** Inside a +panel-profile commit, the operations that could change the placement +outcome are **refused**, exactly as `Handle:await` is refused inside a +commit scope and for the identical reason: something that would +invalidate the scope's guarantee is rejected rather than predicted +around. With them refused, the preflight measurement cannot go stale, +and refusal stays mutation-free on the normal `(false, reason)` path. + +**The mutation surface is narrow, which is what makes this tight rather +than aspirational:** + +- `dedicated` **is** writable from Lua — and it is one of only two + writable window fields (`window_panel.rs:888`, *"Only `fixed_rows` + and `dedicated` are writable (Q#BP2c)"*). +- `panel_capable` has **no Lua binding at all** — checked across + `src/lua_bindings/`. A body cannot make a frontend panel-incapable. + +**The implementation must ENUMERATE the body-reachable transitions +rather than trust that list**, and report the enumeration — closing the +side window, or any other route to "no usable side slot", counts and I +have not proven the two above are exhaustive. This is the same +discipline `gate-protocol-build` applied to Q#GR-1: the fact the design +rests on gets observed. + +**If the enumeration turns out to be open-ended**, the fallback is to +**collapse the two profiles** — run all four checks always, losing the +panel relaxation. That is safe, simple, and honest; it is not the +preferred answer only because it makes the parameterization pointless. +Choosing it is a design decision needing its own approval, not a +silent retreat. **What is NOT the fix: refusing a panel commit that would fall back.** Falling back to an ordinary window is existing, deliberate behaviour @@ -497,14 +512,20 @@ incidental: no arguments is what keeps capture profile-blind. separately — a non-panel-capable frontend, and a dedicated side slot. Each asserts the stale-intent refusal fires: capture A, make B newer, commit `"panel"`, observe the refusal rather than B being replaced. -- **THE FALLBACK STATE IS ALSO ESTABLISHED FROM INSIDE THE BODY**, in - its own test: the callback dedicates the side slot **mid-commit** and - then requests panel display. This is the case that distinguishes - placement-time enforcement from preflight prediction, and **the two - bullets above cannot catch it** — both set up their fallback state - *before* `commit_to` is entered, so a preflight-snapshot design - passes them. A design that passes only those two has not been shown - to work. +- **A BODY THAT TRIES TO CREATE THE FALLBACK IS REFUSED AT THE ATTEMPT**, + in its own test: the callback dedicates the side slot **mid-commit**. + Three assertions, and the second and third are the ones that matter: + the dedication call itself is **refused**; the side slot is **still + undedicated afterwards**; and no partial result was installed. The + two bullets above cannot catch this — both establish their fallback + state *before* `commit_to` is entered, so a preflight-snapshot design + passes them. + + **Asserting only "document B was not replaced" is insufficient**, and + revision 7's version of this test made exactly that mistake: it + passes on a design that lets the body mutate freely and merely + declines the final installation, leaving every other side effect + behind. The refusal must land on the mutation, not on the outcome. - **A `"panel"` commit that really lands in the panel still skips checks 2–4** — otherwise the fix has quietly collapsed the two profiles into one and the parameterization buys nothing. From ccbed7ab558cec6a1e53bbdadd379f78345d128b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:38:41 +0200 Subject: [PATCH 40/66] docs: destination-capture --- a second dedication route, and the ledger head Review found dedication is reachable by a second path. Beyond set_params, a body can call display(buf, { side = "bottom", dedicated = true }), which writes request.dedicated straight into the side window at editor_core.rs:4535, then request a second panel buffer and cause the fallback. An implementation guarding only the named set_params call passes revision 8 test while keeping the original defect. That is the important part, and it is worth more than the route itself: the second route was found in review AFTER the first was specified, which is the evidence that guarding one named call site is not a design. The framing now requires every discovered route recorded and given its OWN acceptance row, states that the two known routes are not asserted exhaustive, and says finding a third is part of the work rather than a later review job. The ledger head still announced revision 7 as implemented and correct, declared the blocker closed, and prescribed placement-boundary enforcement --- the design review had just rejected. I corrected the lower Q#DC-2 paragraph last round and left the authoritative block alone, so recovery met the rejected design first and the correction second. That is the same one-site correction failure this session keeps reproducing, and this time in the file whose entire job is to be the volatile state of record. The head now names all three designs, which two were rejected and why, and that the shipped code implements the rejected one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 58 ++++++++++++++++++----------- docs/destination-capture-framing.md | 31 +++++++++++---- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5667f33..423f9b6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,30 +265,46 @@ 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. -## Destination capture (Q#JR14 generalization) — revision 7 IMPLEMENTED, gate green, no PR yet +## Destination capture (Q#JR14 generalization) — revision 8 OPEN; the shipped code implements the REJECTED revision 7 -**The blocker review re-opened this lane for is CLOSED.** The mechanism -landed at `0efc8c0` with 8 pins green; review of that implementation -found a correctness blocker, framing revisions 6 and 7 carried it, and -revision 7's design is implemented in the commit named below with 12 -pins green. No PR yet — the lane was told not to open one. +**DO NOT PREPARE A PR, AND DO NOT READ THE SHIPPED DESIGN AS CORRECT.** +The mechanism landed at `0efc8c0`; review found a correctness blocker; +`ca72461` implements **revision 7**, which review then **also +rejected**. Framing **revision 8** is the current design and is **not +implemented**. -**The blocker was:** the panel profile skipped checks 2–4 on the claim -that a panel result never touches a document window. **Panel placement -falls back to an ordinary document window** when the frontend is not -panel-capable or its side slot is dedicated -(`src/editor_core.rs`, `apply_placement`), so a `"panel"` commit could -replace a **newer** document with every stale-intent guard skipped. -Reproduced in review. +**The original blocker:** the panel profile skipped checks 2–4 on the +claim that a panel result never touches a document window. **Panel +placement falls back to an ordinary document window** when the frontend +is not panel-capable or its side slot is dedicated, so a `"panel"` +commit could replace a **newer** document with every stale-intent guard +skipped. Reproduced in review. -**Revision 6's fix was itself unsound and revision 7 replaced it, which -is the part most worth not re-learning.** Revision 6 predicted the -fallback at preflight, arguing the body cannot `await`. That stops -concurrent interleaving, not the body: arbitrary synchronous Lua can -dedicate the side slot *inside the callback* and cause the fallback the -preflight just ruled out. **No preflight snapshot can carry this -invariant.** Enforcement is therefore at the **placement boundary**, and -§7's inside-the-body test is what no preflight-snapshot design passes. +**Three designs, two rejected — the sequence is the part worth not +re-learning:** + +1. **Revision 6 — predict at preflight.** Rejected: the `await` refusal + stops concurrent interleaving, not the body, which is arbitrary + synchronous Lua and can create the fallback itself. +2. **Revision 7 — enforce at the placement boundary.** Implemented at + `ca72461`, then rejected: `docs/agent-handoff.md:748` requires + `commit_to` to preflight **before** the callback, because + "validating at display time is four mutations too late". A body has + already created buffers, handles and paint by then, so a + placement-time refusal is a partial commit with an error return. +3. **Revision 8 — keep the preflight, REFUSE the scope-invalidating + mutation.** Current design. Same shape as `Handle:await` being + refused inside a commit scope: the fallback never comes into + existence, and refusal stays mutation-free on `(false, reason)`. + +**The enumeration is the load-bearing part, and it is NOT complete.** +Dedication is reachable by at least two routes — `set_params`, and +`display(buf, { side = …, dedicated = true })`, which writes +`request.dedicated` into the side window (`src/editor_core.rs:4535`). +The second was found in review *after* the first was specified, which +is the evidence that guarding one named call site is not a design. +**Every discovered route must be recorded here and carry its own +acceptance row.** **Also closed:** an invalid-UTF-8 profile (`string.char(255)`) reached `to_str()` and surfaced mlua's generic conversion error instead of the diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index b0c01f3..f936230 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -326,12 +326,26 @@ than aspirational:** - `panel_capable` has **no Lua binding at all** — checked across `src/lua_bindings/`. A body cannot make a frontend panel-incapable. -**The implementation must ENUMERATE the body-reachable transitions -rather than trust that list**, and report the enumeration — closing the -side window, or any other route to "no usable side slot", counts and I -have not proven the two above are exhaustive. This is the same -discipline `gate-protocol-build` applied to Q#GR-1: the fact the design -rests on gets observed. +**AT LEAST TWO ROUTES REACH DEDICATION, and the second was found in +review after the first was specified — which is the evidence that +guarding one named call site is not a design:** + +1. **`set_params`** — the writable-field path (`window_panel.rs:888`). +2. **`display(buf, { side = …, dedicated = true })`** — writes + `request.dedicated` straight into the side window + (`editor_core.rs:4535`). A body can take this route, then request a + second panel buffer and cause the fallback. **An implementation + guarding only route 1 passes revision 8's test while keeping the + original defect.** + +**The implementation must ENUMERATE every body-reachable transition, +record each one here, and give each reachable route its own acceptance +row.** Closing the side window, or any other path to "no usable side +slot", counts. The two above are what review has found so far and are +**not** asserted to be exhaustive — a third would not be surprising, +and finding it is part of the work rather than a later review's job. +This is the discipline `gate-protocol-build` applied to Q#GR-1: the +fact the design rests on gets observed, not assumed. **If the enumeration turns out to be open-ended**, the fallback is to **collapse the two profiles** — run all four checks always, losing the @@ -516,7 +530,10 @@ incidental: no arguments is what keeps capture profile-blind. in its own test: the callback dedicates the side slot **mid-commit**. Three assertions, and the second and third are the ones that matter: the dedication call itself is **refused**; the side slot is **still - undedicated afterwards**; and no partial result was installed. The + undedicated afterwards**; and no partial result was installed. + **One row per route** (§3): `set_params`, and the + `display{side, dedicated = true}` option path. A single row against + one route is what would let the other keep the defect. The two bullets above cannot catch this — both establish their fallback state *before* `commit_to` is entered, so a preflight-snapshot design passes them. From 2fc2985029ba64edd9bfc670ad35f93f5b1aeb42 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 19:02:27 +0200 Subject: [PATCH 41/66] fix(window): refuse the mutation that would invalidate a panel commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision 8 of `docs/destination-capture-framing.md`, replacing the revision-7 design at `ca72461`, plus the invalid-UTF-8 profile hole. The framing now carries §3's enumeration, performed. THE BLOCKER, unchanged. The `"panel"` commit profile skips preflight checks 2-4 on the claim that a panel result never touches a document window. Panel placement FALLS BACK into an ordinary document window when the frontend is not `panel_capable` or its one side slot is dedicated elsewhere, and installs the result there --- so a `"panel"` commit could replace a NEWER document with every stale-intent guard skipped. TWO REJECTED SHAPES, kept in the framing as the record of why not those. Revision 6 predicted the fallback at preflight and argued the body could not change it; false, because refusing `await` stops another COROUTINE interleaving, not the body's own synchronous statements. Revision 7 (`ca72461`) moved enforcement to the placement boundary; that breaks the invariant `commit_to` exists for --- `docs/agent-handoff.md` requires it to preflight BEFORE the callback, because a body creates buffers, handles and paint long before it asks to display anything, so "validating at display time is four mutations too late". A refusal arriving after all of that is a partial commit with an error return. REVISION 8 DOES NEITHER. The preflight stays exactly where it was, and the mutations that would invalidate it are REFUSED AT THE ATTEMPT --- the same shape as `Handle:await` being refused inside a commit scope, for the identical reason: something that would invalidate the scope's guarantee is rejected outright rather than predicted around or caught late. With them refused, the fallback never comes into existence. THE ENUMERATION, PERFORMED --- this is the load-bearing part, and it is closed for a structural reason rather than because inspection ran out of ideas. Full working in the framing §3. `resolve_placement` reaches `Ordinary` from a side request through exactly two branches, so only two pieces of state are levers at all: `panel_capable`, and the one side window's `dedicated`. `panel_capable` is UNREACHABLE from a body: written only where a `FrontendView` is constructed, and nothing in `src/lua_bindings/` constructs, registers or unregisters one --- `register_frontend_view` has callers only in `daemon.rs` and core unit tests. `dedicated` has eight writes. Five are reachable: `apply_placement`'s `Side` created, replacing and non-replacing arms, and `set_params`. Two `Ordinary` arms are harmless --- every `Ordinary` target is filtered `!is_side`, and one only ever clears the flag. One is a unit test. Closing the side window is NOT a route, checked rather than assumed: with no side leaf `side_window_for` returns `None` and placement CREATES a fresh panel instead of falling back. `panel_hidden` is not consulted by placement, and `params.side` is unreachable. `quit_window`'s `QuitAction::Restore { dedicated: true }` is UNREACHABLE, and this was the surprise --- it looked like a route with no `dedicated` argument at the call site at all. `Restore` is stored only on a REPLACING side placement, and a dedicated slot can never be the target of one: a side request with a different buffer falls through to `Ordinary`, and an exact-target request is refused by `window_accepts_buffer`. Guarded anyway, labelled defensive, because its unreachability is emergent from two rules in another function. GUARDS SITED WHERE THE PROPERTY CONVERGES. All three `Side` arms are reached through `apply_placement`, which has EXACTLY ONE caller --- so one guard in `display_buffer` covers every request-driven dedication, including spellings that do not exist yet. `set_params` is a genuinely separate write and is guarded separately; dedication does NOT converge before the field itself, and that is stated rather than papered over. `Window::params.dedicated` is a public field, so the compiler does not enforce the funnel --- the acceptance rows are what would catch a new direct writer. WHAT IS DELIBERATELY NOT REFUSED. The document profile is untouched: constraining its body would newly refuse dired's own documented panel path, a preservation-suite stop signal. Dedicating a DOCUMENT window is still allowed, since it cannot change which of panel-or-document a side request resolves to. And falling back is still allowed --- a frontend that cannot render a panel degrades gracefully exactly as today, because this refuses the mutation that MANUFACTURES a fallback, never the fallback itself. THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings are BYTE strings, so `string.char(255)` hit mlua's generic UTF-8 error before `BAD_COMMIT_PROFILE` was constructed --- the same reachability class as the `Option` defect revision 5 fixed, one layer down. Bytes now, with the row asserting on message content. TESTS: 12 pins. The inside-the-body test is ONE ROW PER REACHABLE WRITE SITE, not per call spelling, because one spelling reaches three different writes: `set_params`, and `display{side, dedicated}` in each of the created, replacing and non-replacing arms. Each asserts the three things revision 8 requires --- the dedication call is refused, the slot is still undedicated afterwards, and nothing partial was installed (no `*result*` buffer, panel unchanged, document unchanged). Mutation-checked per guard: deleting the `display_buffer` guard fails all three display rows, verified INDIVIDUALLY by rotating each to the front so the first failure cannot mask the rest; deleting the `set_params` guard fails only that row. THREE FRAMING CORRECTIONS ride along, all of them cases of the document teaching something it later argues against. Section 3 stated the disproved premise unconditionally --- "the panel case would inherit a check about a window it never touches" --- a hundred lines before correcting it, so a reader met the wrong claim first; it is now qualified at the point of the claim, and section 2 carried the same unconditional form one section earlier ("it lands in the bottom panel") and now says it REQUESTS one. The handoff citation was written "section 748" twice when it is LINE 748, and this document's authority is that its citations can be followed. And the "not asserted exhaustive" hedge on the route list is retired: the enumeration is closed structurally, because `resolve_placement` reaches `Ordinary` from a side request through exactly two branches. `journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 157 ++++++++----- docs/destination-capture-framing.md | 155 +++++++++--- src/editor_core.rs | 167 ++++++++----- src/lua_bindings/window_panel.rs | 18 ++ tests/destination_capture_acceptance.rs | 298 +++++++++++++++++------- 5 files changed, 563 insertions(+), 232 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 423f9b6..0c9e1e4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,13 +265,13 @@ 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. -## Destination capture (Q#JR14 generalization) — revision 8 OPEN; the shipped code implements the REJECTED revision 7 +## Destination capture (Q#JR14 generalization) — revision 8 IMPLEMENTED, gate green, no PR yet -**DO NOT PREPARE A PR, AND DO NOT READ THE SHIPPED DESIGN AS CORRECT.** The mechanism landed at `0efc8c0`; review found a correctness blocker; -`ca72461` implements **revision 7**, which review then **also -rejected**. Framing **revision 8** is the current design and is **not -implemented**. +`ca72461` implemented **revision 7**, which review then **also** +rejected; the commit below replaces it with **revision 8** and its +§3 enumeration is **performed and recorded in the framing**. No PR — the +lane was told not to open one. **The original blocker:** the panel profile skipped checks 2–4 on the claim that a panel result never touches a document window. **Panel @@ -297,14 +297,42 @@ re-learning:** refused inside a commit scope: the fallback never comes into existence, and refusal stays mutation-free on `(false, reason)`. -**The enumeration is the load-bearing part, and it is NOT complete.** -Dedication is reachable by at least two routes — `set_params`, and -`display(buf, { side = …, dedicated = true })`, which writes -`request.dedicated` into the side window (`src/editor_core.rs:4535`). -The second was found in review *after* the first was specified, which -is the evidence that guarding one named call site is not a design. -**Every discovered route must be recorded here and carry its own -acceptance row.** +**THE ENUMERATION IS THE LOAD-BEARING PART, AND IT IS NOW CLOSED — for +a structural reason, not because inspection ran out of ideas.** Full +working in the framing §3; the short form: + +- **Only two pieces of state can matter**, because `resolve_placement` + reaches `Ordinary` from a side request through exactly two branches: + `panel_capable`, and the one side window's `dedicated`. +- **`panel_capable` is unreachable from a body.** It is written only + where a `FrontendView` is constructed, and nothing in + `src/lua_bindings/` constructs, registers or unregisters one — + `register_frontend_view` has callers only in `daemon.rs` and core + unit tests. +- **Eight writes to `dedicated` exist** (`rg 'params\.dedicated\s*=' + src/`); **five are reachable**: `apply_placement`'s `Side` created / + replacing / non-replacing arms, and `set_params`. Two `Ordinary` arms + are harmless (their target is never a side window; one only ever + clears the flag) and one is a unit test. +- **The guards are sited where the property converges, not per caller.** + All three `Side` arms are reached through `apply_placement`, which has + **exactly one caller** — so one guard in `display_buffer` covers every + request-driven dedication, including spellings that do not exist yet. + `set_params` is a genuinely separate write and is guarded separately; + dedication does **not** converge before the field itself, and that is + stated rather than papered over. +- **Closing the side window is NOT a route**, checked rather than + assumed: with no side leaf `side_window_for` returns `None` and + placement **creates** a fresh panel instead of falling back. Hiding is + likewise irrelevant — `panel_hidden` is not consulted by placement. +- **`quit_window`'s `QuitAction::Restore { dedicated: true }` is + UNREACHABLE**, and this was the surprise. `Restore` is stored only on + a *replacing* side placement, and a dedicated slot can never be the + target of one. Guarded anyway, labelled defensive, because its + unreachability is emergent from two rules in another function. +- **What this does not rule out:** the enumeration is closed over the + current tree, not future edits. `params.dedicated` is a public field, + so nothing but the acceptance rows would catch a new direct writer. **Also closed:** an invalid-UTF-8 profile (`string.char(255)`) reached `to_str()` and surfaced mlua's generic conversion error instead of the @@ -320,53 +348,61 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 7.** - Revisions 1–5 were approved over four review rounds; revisions 6 and 7 - are corrections carrying the blocker above, and **revision 7's design - is what the tree implements** — revision 6's preflight prediction is - NOT the shipped mechanism and must not be restored from that document. +- **Framing `docs/destination-capture-framing.md`, revision 8.** + Revisions 1–5 were approved over four review rounds; revisions 6, 7 + and 8 are corrections carrying the blocker above, and **revision 8's + design is what the tree implements**. Revisions 6 and 7 are described + in that document as the record of why *not* those; neither is in the + tree and neither should be restored from it. - **Implemented in three commits.** `779bb02` is the mechanism (`pmacs.window.capture_destination()`, the `ViewDestination` rename, the profile argument); `d5a6170` is - `tests/destination_capture_acceptance.rs`; the revision-7 commit is + `tests/destination_capture_acceptance.rs`; the revision-8 commit is the panel-profile correction plus the invalid-UTF-8 hole. **12 pins**, and both preservation suites pass **unchanged** (journey 47, dired 31) — §7's stop signal not firing rather than being suppressed. -- **HOW THE PANEL PROFILE IS ENFORCED, so revision 6's version does not - get reinstated by someone reading only that document.** - - `EditorCore::display_buffer` refuses **between** `resolve_placement` - and `apply_placement` when a side request resolved to - `PlacementKind::Ordinary` under an active `"panel"` contract whose - destination fails the document preconditions - (`fallback_commit_refusal`). Refusing there means a refused fallback - mutates nothing. +- **HOW THE PANEL PROFILE IS ENFORCED, in one sentence so no earlier + revision gets reinstated by someone reading only that document:** the + preflight stays exactly where it was, and the mutations that would + invalidate it are **refused at the attempt**. + - `EditorCore::panel_commit_dedication_refusal` is the one rule. It + fires while a `"panel"` `CommitContract` is on the core for this + frontend, and is consulted from `display_buffer` (before + `apply_placement`, so a refused attempt mutates nothing), + `pmacs.window.set_params` (before its borrow, so `fixed_rows` in the + same table is not applied either), and `quit_window`. + - **This is the same shape as `Handle:await` being refused inside a + commit scope**, and for the identical reason: something that would + invalidate the scope's guarantee is rejected outright rather than + predicted around or caught late. - The contract (`CommitContract { destination, profile }`) rides on the core, installed and restored by the **same** `ScopedFrontendGuard` that scopes the frontend, so a `"panel"` profile can never outlive the body that declared it. The field is private to the crate — Lua cannot claim a profile for a placement it did not commit to. - - **The preflight predicate survives as an EARLY REFUSAL, not as the - guarantee.** `panel_placement_can_fall_back` still gates the - relaxation in `commit_destination_refusal`, so the statically - knowable case — a frontend that cannot render a panel at all, and - will not acquire the capability mid-body — refuses *before* the body - allocates a buffer, registers a handle and paints. That is the same - reason `commit_to` preflights at all. Both layers are pinned - separately and neither test subsumes the other. + - **`panel_placement_can_fall_back` remains the preflight**, unchanged + in role: it measures whether this frontend places side requests in + the panel *right now*. With the invalidating mutations refused, that + measurement stays true for the life of the body, which is what makes + it a guarantee rather than a forecast. - The four document checks live once, in - `EditorCore::document_destination_refusal`, because they are now - evaluated from two sites and two hand-written copies is how a - backstop ends up weaker than the thing it backs. + `EditorCore::document_destination_refusal`. - **Three deliberate limits**, each a different decision rather than a stricter version of this one: the **document profile is untouched** - (re-running its checks at placement would newly refuse dired's own - documented panel path — a preservation-suite stop signal); only a - **fallback** is guarded, not every `Ordinary` placement (a `"panel"` - body calling `display_file` is pinned as succeeding by - `a_captured_destination_survives_a_frontend_switch`); and the - refusal is of the **placement**, not of falling back — a `"panel"` - commit with an intact destination still degrades gracefully into the - document window. + (constraining its body would newly refuse dired's own documented + panel path — a preservation-suite stop signal); **dedicating a + document window is still allowed** (it cannot change which of + panel-or-document a side request resolves to); and **falling back is + still allowed** — a frontend that cannot render a panel degrades + gracefully exactly as today, because this refuses the mutation that + *manufactures* a fallback, never the fallback itself. +- **Mutation-checked per guard, and the pattern is the evidence the rows + are independent rather than one assertion repeated.** Deleting the + `display_buffer` guard fails the three `display{side, dedicated}` rows + — verified **individually**, by rotating each to the front of the + table, since the first failure otherwise masks the rest. Deleting the + `set_params` guard fails only that row and leaves the display rows + passing. Both leave every other test in the file green. - **Audit: nothing else relied on "a panel never touches a document".** Four doc sites repeated the claim (`ViewDestination`'s own doc twice, `capture_view_destination`, `ViewDestinationLua`) and were corrected; @@ -409,23 +445,26 @@ authoritative tip** — the ref, not a SHA. Recover with frontend scope for the panel profile fails the survives-a-switch pin's panel row; dropping the no-document-window arm fails the Q#DC-4 pair. - **Revision 7's four, each isolating a different way to get it wrong** — - and the pattern of *which* rows survive each is the evidence the layers + **Revision 8's four, each isolating a different way to get it wrong** — + and the pattern of *which* rows survive each is the evidence the parts are independent rather than redundant: - 1. delete the `fallback_commit_refusal` call from `display_buffer` → - **only** the inside-the-body pin fails. Every other test passes, - which is exactly the hole revision 6 would have shipped. - 2. delete the `panel_placement_can_fall_back` arm from + 1. delete the `panel_commit_dedication_refusal` call from + `display_buffer` → the three `display{side, dedicated}` rows fail, + **verified individually** by rotating each to the front of the + table so the first failure cannot mask the rest. Every other test + passes — which is exactly the hole an implementation guarding only + `set_params` would ship. + 2. delete it from `set_params` → **only** that row fails; the three + display rows still pass. + 3. delete the `panel_placement_can_fall_back` arm from `commit_destination_refusal` → **only** the two pre-established - fallback rows fail, and they fail on shape (a raise from the - backstop, with the body having run) rather than on outcome. - 3. make `panel_placement_can_fall_back` unconditionally `true` (the + fallback rows fail, which is the preflight half. + 4. make `panel_placement_can_fall_back` unconditionally `true` (the "widen the predicate" non-fix) → the really-lands-in-the-panel pin, the Q#DC-4 panel pin and the matrix's three panel rows all fail. - That is the profiles collapsing into one, made visible. - 4. make `fallback_commit_refusal` refuse *every* panel fallback → only - the graceful-degradation pin fails, which is the guard - over-reaching. + That is the two profiles collapsing into one, made visible — the + named fallback design, showing up as a test diff rather than + silently. And reverting the byte comparison to `to_str()?` fails the `invalid utf-8` row with mlua's conversion error, on content. diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index f936230..fafa83e 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,8 +1,11 @@ # A destination capture any async continuation can use -**Status: revision 8. The mechanism is implemented at `0efc8c0`; -revisions 6–8 carry an OPEN correctness blocker that is NOT yet -implemented.** +**Status: revision 8. The mechanism is implemented at `0efc8c0`; the +correctness blocker revisions 6–8 carry is IMPLEMENTED, in revision 8's +shape, with §3's enumeration performed and recorded below.** Revisions +6 and 7 proposed fixes that review rejected; **neither is in the tree**, +and the two paragraphs describing them are kept as the record of why +this shape and not those. *(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the ledger recorded the lane as approved and implemented. Same @@ -12,13 +15,14 @@ standing in its own header.)* **Revision 8 rejects BOTH of the previous two fixes and takes a third shape.** Revision 6 predicted the fallback at preflight (the body can change it). Revision 7 moved enforcement to the placement boundary — -which **breaks the invariant `commit_to` exists for**: handoff §748 -says it preflights *before* the callback because "validating at display -time is four mutations too late", so a placement-time refusal arrives -after arbitrary Lua has created buffers, handles and paint. Revision 8 -keeps the preflight and **refuses the mutations that would invalidate -it**, the same shape as the existing await refusal. Refusal stays -mutation-free on the `(false, reason)` path. +which **breaks the invariant `commit_to` exists for**: +`docs/agent-handoff.md:748` says it preflights *before* the callback +because "validating at display time is four mutations too late", so a +placement-time refusal arrives after arbitrary Lua has created buffers, +handles and paint. Revision 8 keeps the preflight and **refuses the +mutations that would invalidate it**, the same shape as the existing +await refusal. Refusal stays mutation-free on the `(false, reason)` +path. **Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave the panel profile only check 1, on the claim that a panel result never @@ -137,8 +141,11 @@ that was declined for the `scripts/gate` repair, for the same reason. the finding that shapes the design: - `*git-status*` goes through `listview.open`, which resolves `display` with a **`"panel"`** default - (`builtin/runtime/listview.lua:550`). It lands in the bottom - panel, **not** in a document window. + (`builtin/runtime/listview.lua:550`). It **requests** the bottom + panel rather than a document window — *requests*, because a side + request FALLS BACK into a document window on a frontend that is not + `panel_capable` or whose one slot is dedicated elsewhere. That + fallback is this lane's blocker; §3 and Q#DC-2 carry it. - `*git-diff*` calls `pmacs.window.display(buf, { select = true })` — the **document** target, deliberately, "so the status panel it was invoked from stays visible beside it" @@ -152,11 +159,25 @@ loses to the user**"* — a user who replaced the buffer while work was in flight is newer information than the request. **That predicate is right for a document replacement and wrong for a -panel.** The git status panel does not replace the captured window's -buffer; it opens in the bottom panel beside it. Refusing to show it -because the user switched files in the document window would be a -refusal with no relationship to what the continuation actually does — -the panel case would inherit a check about a window it never touches. +panel — WHILE THE PANEL REALLY IS A PANEL, which is the qualification +the rest of this document exists to add.** A git status panel that +lands in the bottom panel does not replace the captured window's +buffer; it opens beside it. Refusing to show it because the user +switched files in the document window would be a refusal with no +relationship to what the continuation actually does, and that case +would inherit a check about a window it never touches. + +**Read the previous paragraph with its condition attached, not as a +standing fact.** Panel placement **falls back** into an ordinary +document window when the frontend is not `panel_capable` or its one +side slot is dedicated elsewhere — and then the panel case *does* touch +the captured window, replacing whatever the user put there. That +fallback is this lane's correctness blocker, and the unqualified +version of this claim is precisely what made revision 5's matrix +unsound. The resolution is below, at the end of Q#DC-2: the preflight +measures whether this frontend places side requests in the panel, and +the mutations that would falsify that measurement mid-commit are +refused. Meanwhile the diff case *is* a document replacement, and wants exactly the dired semantics. @@ -298,7 +319,7 @@ dedicate the side slot itself. Revision 7 then moved enforcement to the placement boundary. **That breaks the invariant `commit_to` exists for.** `docs/agent-handoff.md` -§748 states it without qualification: +`docs/agent-handoff.md:748` states it without qualification: > [`commit_to`] preflights every precondition *before* invoking the > callback — dired mutates handle state, `prev`, and paint long before @@ -326,9 +347,11 @@ than aspirational:** - `panel_capable` has **no Lua binding at all** — checked across `src/lua_bindings/`. A body cannot make a frontend panel-incapable. -**AT LEAST TWO ROUTES REACH DEDICATION, and the second was found in -review after the first was specified — which is the evidence that -guarding one named call site is not a design:** +**FIVE WRITES REACH DEDICATION.** Review found the second *after* the +first was specified, which is the evidence that guarding one named call +site is not a design — and the enumeration below, performed against the +tree rather than by recall, found three more. The two review named +first are: 1. **`set_params`** — the writable-field path (`window_panel.rs:888`). 2. **`display(buf, { side = …, dedicated = true })`** — writes @@ -338,21 +361,82 @@ guarding one named call site is not a design:** guarding only route 1 passes revision 8's test while keeping the original defect.** -**The implementation must ENUMERATE every body-reachable transition, -record each one here, and give each reachable route its own acceptance -row.** Closing the side window, or any other path to "no usable side -slot", counts. The two above are what review has found so far and are -**not** asserted to be exhaustive — a third would not be surprising, -and finding it is part of the work rather than a later review's job. -This is the discipline `gate-protocol-build` applied to Q#GR-1: the -fact the design rests on gets observed, not assumed. +**THE ENUMERATION, PERFORMED. It is CLOSED, and it is closed for a +structural reason rather than by inspection stopping when it ran out of +ideas.** Recorded here as the framing required, with what was looked +for, what was found, and what cannot be ruled out. -**If the enumeration turns out to be open-ended**, the fallback is to +*Step 1 — how few pieces of state can matter.* `resolve_placement` +reaches `Ordinary` from a side request through exactly two branches, so +only two pieces of state are levers at all: `FrontendView::panel_capable`, +and the one side window's `Window::params.dedicated`. Everything else a +body can touch is irrelevant by construction, which is what makes the +enumeration finite instead of "every mutation in the editor". + +*Step 2 — `panel_capable` is unreachable, not merely unguarded.* It is +written **only** where a `FrontendView` is constructed, and no +`FrontendView` is constructed, registered or unregistered anywhere in +`src/lua_bindings/` — `register_frontend_view` and +`unregister_frontend_view` have callers only in `daemon.rs` (attach and +detach) and in core unit tests. A body cannot reach it. + +*Step 3 — every write to `dedicated`, from `rg 'params\.dedicated\s*=' +src/`, classified.* Eight sites, no exceptions: + +| # | site | verdict | +|---|---|---| +| 1 | `apply_placement`, `Side` **created** | reachable — `display{side, dedicated}` with no panel yet | +| 2 | `apply_placement`, `Side` **replacing** | reachable — `display{side, dedicated}`, different buffer | +| 3 | `apply_placement`, `Side` **non-replacing** | reachable — `display{side, dedicated}`, same buffer | +| 4 | `apply_placement`, `Ordinary` (`!fell_back`) | harmless — every `Ordinary` target is filtered `!is_side`, so it is never the slot | +| 5 | `apply_placement`, `Ordinary` (clear) | harmless — only ever writes `false` | +| 6 | `set_params` | reachable — the direct write (Q#BP2c) | +| 7 | `quit_window`, `QuitAction::Restore` | **unreachable**, see below | +| 8 | an `EditorCore` unit test | not Lua-reachable | + +*Step 4 — the guards, sited where the property converges rather than at +each caller.* Sites 1, 2, 3 (and 4, 5) are all reached through +`apply_placement`, which has **exactly one caller**, `display_buffer`. +So one guard there covers every request-driven dedication, including +routes that do not exist yet. `set_params` is a genuinely separate write +and is guarded separately — dedication does *not* converge before the +field itself, and that is stated rather than papered over. Two live +guards, five reachable sites. + +*Step 5 — what was looked for and found NOT to be a route.* Closing the +side window is **not** one: with no side leaf `side_window_for` returns +`None` and `resolve_placement` **creates** a fresh panel rather than +falling back, so quitting or hiding the panel mid-commit is safe, and +`panel_hidden` is not consulted by placement at all. `params.side` is +likewise unreachable — `set_params` refuses it and only +`apply_placement`'s created branch writes it, so a body cannot promote +an already-dedicated document window into the slot. + +*Step 6 — site 7 is unreachable, and this is the one finding that +surprised.* `QuitAction::Restore` carries the outgoing `dedicated` flag, +so quitting the panel looked like a route with no `dedicated` argument +at the call site at all. It cannot be constructed: `Restore` is only +ever *stored* on a **replacing** side placement, and a dedicated slot +can never be the target of one — a side request with a different buffer +falls through to `Ordinary`, and an exact-target request is refused by +`window_accepts_buffer`. So `Restore { dedicated: true }` has no +producer. It is guarded anyway, defensively and labelled as such, +because its unreachability is an emergent property of two rules in a +different function. + +**What this does NOT rule out.** The enumeration is closed over the +current tree, not over future edits: relaxing `resolve_placement`'s +dedicated arm, or adding a binding that writes `params.dedicated` +directly, reopens it. `Window::params.dedicated` is a public field, so +the compiler does not enforce the funnel — the acceptance rows are what +would catch a regression, one per reachable site. + +**If the enumeration had turned out open-ended**, the fallback was to **collapse the two profiles** — run all four checks always, losing the panel relaxation. That is safe, simple, and honest; it is not the preferred answer only because it makes the parameterization pointless. Choosing it is a design decision needing its own approval, not a -silent retreat. +silent retreat. **It was not needed.** **What is NOT the fix: refusing a panel commit that would fall back.** Falling back to an ordinary window is existing, deliberate behaviour @@ -531,9 +615,12 @@ incidental: no arguments is what keeps capture profile-blind. Three assertions, and the second and third are the ones that matter: the dedication call itself is **refused**; the side slot is **still undedicated afterwards**; and no partial result was installed. - **One row per route** (§3): `set_params`, and the - `display{side, dedicated = true}` option path. A single row against - one route is what would let the other keep the defect. The + **One row per reachable WRITE SITE** (§3), which is four and not two: + `set_params`, and `display{side, dedicated}` in each of + `apply_placement`'s **created**, **replacing** and **non-replacing** + arms. A single row against one route is what would let another keep + the defect — and rows per *call spelling* would have missed that one + spelling reaches three different writes. The two bullets above cannot catch this — both establish their fallback state *before* `commit_to` is entered, so a preflight-snapshot design passes them. diff --git a/src/editor_core.rs b/src/editor_core.rs index 43c6656..4cc92f1 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3488,6 +3488,23 @@ impl EditorCore { } other => other, }; + // Q#DC-2 (revision 8). A `Restore` carries the OUTGOING + // presentation's `dedicated` flag (see `apply_placement`), so + // quitting the panel can re-dedicate the one slot without any + // `dedicated` argument appearing at the call site. Refused for + // the same reason and at the same point as the other attempts — + // before `quit_window` has touched anything. + if let QuitAction::Restore { + dedicated: true, .. + } = action + && self + .windows + .get(&target) + .is_some_and(crate::window::Window::is_side) + && let Some(reason) = self.panel_commit_dedication_refusal(fid) + { + return Err(format!("window.quit: {reason}")); + } match action { QuitAction::Delete => { // Capture the remembered origin BEFORE the window dies: @@ -4029,10 +4046,19 @@ impl EditorCore { .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? .active; let placement = self.resolve_placement(fid, request)?; - // THE PLACEMENT BOUNDARY (Q#DC-2, revision 7). Refuse before - // `apply_placement` so a refused fallback mutates nothing. - if let Some(reason) = self.fallback_commit_refusal(request, &placement) { - return Err(reason); + // Q#DC-2 (revision 8): dedicating the side slot inside a + // `"panel"` commit is refused AT THE ATTEMPT, so the preflight's + // measurement cannot go stale. `resolve_placement` is pure, so + // this still refuses before anything is mutated. + // + // Note the guard is on the DEDICATION, not on the display: the + // body's ordinary `display(buf, {side = "bottom"})` is exactly + // what a panel continuation is for and always proceeds. + if request.dedicated == Some(true) + && matches!(placement.kind, PlacementKind::Side { .. }) + && let Some(reason) = self.panel_commit_dedication_refusal(fid) + { + return Err(format!("display: {reason}")); } self.apply_placement(fid, request, &placement)?; let select = request @@ -4207,62 +4233,97 @@ impl EditorCore { } /// **The guarantee** behind the `"panel"` commit profile (Q#DC-2, - /// revision 7): a side request that actually fell back into a - /// document window must satisfy the document preconditions. + /// revision 8): inside such a commit, the operations that would make + /// this frontend's side request fall back are **refused at the + /// attempt**. /// - /// Reaching [`PlacementKind::Ordinary`] while a side was REQUESTED is - /// exactly the fallback [`Self::apply_placement`] documents — not - /// panel-capable, or the one slot is dedicated elsewhere — and the - /// result is then installed into a **document** window. A `"panel"` - /// commit that skipped checks 2–4 on the strength of "a panel never - /// touches a document window" would, right here, replace a document - /// view with no stale-intent guard at all: capture A, the user opens - /// B, the continuation lands, B is gone. That is the failure - /// `commit_to` exists to prevent, arrived at through the profile - /// meant to be the safe one. + /// # The defect this closes /// - /// **Why here and not at preflight.** This is the first moment the - /// fallback is a *fact*. A preflight snapshot cannot bind it: the - /// body is arbitrary synchronous Lua and may create the very - /// condition — take the panel, set it `dedicated`, then ask for a - /// side — after the snapshot was taken. Refusing `await` inside the - /// commit scope stops a *second coroutine* interleaving; it places no - /// restriction on the body's own statements. + /// The panel profile skips preflight checks 2–4 on the strength of "a + /// panel result never touches a document window". Panel placement + /// **falls back** into an ordinary document window when the frontend + /// is not `panel_capable` or its one side slot is dedicated elsewhere + /// ([`Self::apply_placement`] says so in its own comment), and then + /// installs the result there. So a `"panel"` commit that reached a + /// fallback would replace a document view with no stale-intent guard: + /// capture A, the user opens B, the continuation lands, B is gone. /// - /// Three deliberate limits, each of which would be a different - /// decision rather than a stricter version of this one: + /// # Why this shape, and not the two that were tried first /// - /// * **The document profile is untouched.** Its preflight already ran - /// these checks against the same destination, and re-running them - /// here would newly refuse dired's own panel path, which documents - /// and accepts the fallback (`builtin/runtime/dired.lua`). - /// * **Only a fallback, not every document placement.** A panel-profile - /// body that displays into a document window *without asking for a - /// side* has mislabelled its profile; it has not exercised this - /// relaxation. Widening to every [`PlacementKind::Ordinary`] would - /// also refuse a `"panel"` commit whose body calls `display_file`, - /// which is pinned as succeeding. - /// * **Refusing the placement, not the fallback.** Falling back is - /// deliberate graceful degradation for a frontend without panel - /// capability; a `"panel"` commit whose destination is still valid - /// falls back and lands exactly as it does today. The profile - /// relaxes checks; it does not get to move where a result goes. - fn fallback_commit_refusal( - &self, - request: &DisplayRequest, - placement: &Placement, - ) -> Option { - if request.side.is_none() || !matches!(placement.kind, PlacementKind::Ordinary) { - return None; - } + /// * **Predicting the fallback at preflight is unsound.** The body is + /// arbitrary *synchronous* Lua and can create the condition itself. + /// Refusing `await` inside the commit scope stops a second + /// coroutine interleaving; it places no restriction on the body's + /// own statements. + /// * **Refusing at the placement boundary is too late.** `commit_to` + /// preflights *before* invoking the callback precisely because a + /// body creates buffers, registers handles and paints long before + /// it asks to display anything — "validating at display time is + /// four mutations too late" (`docs/agent-handoff.md`). A refusal + /// arriving after all of that is not a refusal; it is a partial + /// commit with an error return. + /// + /// So the preflight stays where it is and **the mutation that would + /// invalidate it is rejected** — the same shape as `Handle:await` + /// being refused inside a commit scope, for the identical reason. + /// With these refused, the preflight measurement cannot go stale, the + /// fallback never comes into existence, and nothing needs refusing + /// late. + /// + /// # The enumeration this rests on + /// + /// [`Self::resolve_placement`] can only reach + /// [`PlacementKind::Ordinary`] from a side request in two ways, so + /// only two pieces of state matter: + /// + /// 1. `FrontendView::panel_capable` is false. It is written **only** + /// where a `FrontendView` is constructed, and no `FrontendView` is + /// constructed, registered or unregistered anywhere in + /// `src/lua_bindings/` — that is the daemon's attach path. **A + /// body cannot reach it at all.** + /// 2. The frontend's one side slot exists **and is dedicated** to a + /// different buffer. `Window::params.dedicated` is the only + /// remaining lever, and every write to it is guarded or harmless: + /// the two in `apply_placement`'s `Ordinary` arm target a document + /// window (never a side one — every `Ordinary` target is filtered + /// `!is_side`) and one of them only ever clears the flag; the + /// three in its `Side` arm and the one in `pmacs.window.set_params` + /// are the attempts refused here; and `quit_window` restoring a + /// saved `dedicated: true` presentation is refused too. + /// + /// **Losing the side window is NOT a route** and was checked rather + /// than assumed: with no side leaf, `side_window_for` returns `None` + /// and `resolve_placement` **creates** a fresh panel instead of + /// falling back. Closing or hiding the panel mid-commit is therefore + /// safe, and `panel_hidden` is not consulted by placement at all. + /// `params.side` is likewise unreachable — `set_params` refuses it, + /// and only `apply_placement`'s created branch ever writes it, so a + /// body cannot turn an already-dedicated document window into the + /// side slot. + /// + /// # What is deliberately NOT refused + /// + /// * **The document profile is untouched.** Its preflight already + /// checked the same destination, and constraining its body would + /// newly refuse dired's own documented panel path. + /// * **Dedicating a *document* window is fine.** It cannot change + /// which of panel-or-document a side request resolves to. + /// * **Falling back is still allowed.** A frontend that cannot render + /// a panel degrades gracefully exactly as it does today; this + /// refuses the *mutation that manufactures* a fallback, never the + /// fallback itself. + pub(crate) fn panel_commit_dedication_refusal(&self, fid: FrontendId) -> Option { let contract = self.commit_contract.as_ref()?; - if contract.profile != CommitProfile::Panel { + if contract.profile != CommitProfile::Panel || contract.destination.frontend != fid { return None; } - let reason = self.document_destination_refusal(&contract.destination)?; - Some(format!( - "display: this \"panel\" commit fell back to a document window, and {reason}" - )) + Some( + "cannot dedicate the side window inside a \"panel\" commit_to --- the commit's \ + preflight was relaxed because this frontend places side requests in the panel, \ + and dedicating the one slot would silently redirect the result into a document \ + window instead (dedicate outside the commit, or use the \"document\" profile)" + .to_string(), + ) } /// Q#BP3's precedence: exact target, then side affinity, then diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index d9051a0..9f7f137 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -908,6 +908,24 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result None => None, }; let dedicated = opts.get::>("dedicated")?; + // Q#DC-2 (revision 8). The direct route to the one + // mutation that could make a `"panel"` commit's + // relaxed preflight wrong. Refused BEFORE the borrow + // below, so the attempt changes nothing --- including + // `fixed_rows`, which is in the same option table. + if dedicated == Some(true) { + let core = cc.borrow(); + if core + .windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + && let Some(reason) = core.panel_commit_dedication_refusal(fid) + { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: {reason}" + ))); + } + } { let mut core = cc.borrow_mut(); let window = core.windows.get_mut(&id).ok_or_else(|| { diff --git a/tests/destination_capture_acceptance.rs b/tests/destination_capture_acceptance.rs index 842b39e..fee4ba9 100644 --- a/tests/destination_capture_acceptance.rs +++ b/tests/destination_capture_acceptance.rs @@ -20,17 +20,20 @@ //! * **A refusal is asserted on its reason**, never on the mere fact //! that something failed. `commit_to` has five distinct refusals and a //! raise; "it errored" would pass on any of the wrong ones. -//! * **The panel profile's relaxation is pinned at BOTH of its -//! evaluation sites** (revision 7). The preflight is an early refusal -//! that spares the body; the guarantee is enforced where placement -//! resolves, because the body is arbitrary synchronous Lua and can -//! create the fallback *after* any snapshot was taken — refusing -//! `await` stops a second coroutine interleaving, not the body's own -//! statements. Three tests carry that split and none subsumes another: +//! * **The panel profile's relaxation is pinned as a preflight PLUS the +//! refusal that keeps it true** (revision 8). The preflight measures +//! whether this frontend places side requests in the panel; the body is +//! arbitrary *synchronous* Lua, so refusing `await` — which only stops +//! another coroutine interleaving — does not stop it invalidating that +//! measurement. The answer is neither to predict the body nor to catch +//! it late at placement (by then it has created buffers, handles and +//! paint, which is "four mutations too late" all over again) but to +//! **refuse the mutation at the attempt**, exactly as `await` is +//! refused. Three tests carry it and none subsumes another: //! `a_panel_commit_that_falls_back_runs_the_document_preflight` (the -//! body must not run), -//! `a_panel_commit_whose_body_creates_the_fallback_is_refused_at_placement` -//! (the result must not land), and +//! body must not run at all when the fallback already holds), +//! `a_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt` +//! (the mutation is refused, and nothing partial is left behind), and //! `a_panel_commit_that_falls_back_with_a_valid_destination_still_lands` //! (falling back is still graceful degradation, not an error). //! @@ -90,6 +93,32 @@ fn name_in(s: &EditorState, window: WindowId) -> String { registry.get(buffer).expect("buffer").name().to_string() } +/// Whether `window` is pinned to its buffer (Q#BP2c `dedicated`). +fn dedicated(s: &EditorState, window: WindowId) -> bool { + s.core + .borrow() + .windows + .get(&window) + .is_some_and(|w| w.params.dedicated) +} + +/// Whether a buffer by this name exists at all. +/// +/// The "nothing partial was installed" assertion needs to see a side +/// effect the body would have left *before* reaching any display, and a +/// created-but-never-shown buffer is exactly that. +fn buffer_exists(s: &EditorState, name: &str) -> bool { + eval( + s, + &format!( + "for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == {name:?} then return true end + end + return false" + ), + ) +} + fn local_window(s: &EditorState) -> WindowId { s.core .borrow() @@ -454,6 +483,13 @@ fn the_preflight_matrix_holds_in_both_profiles() { const PANEL_BODY: &str = "pmacs.window.display(pmacs.buffer.create('*result*'), \ { side = 'bottom' })"; +/// A reusable panel: present and **undedicated**, so the preflight +/// measures "this frontend places side requests in the panel" and the +/// relaxation applies. Every mutation row starts from here except the +/// one whose whole point is that no panel exists yet. +const PANEL_ARRANGED: &str = "pmacs.window.display(pmacs.buffer.create('*pinned*'), \ + { side = 'bottom', dedicated = false, select = false })"; + /// Arrange one of the two reasons a side request falls back into a /// document window, and assert the arrangement took. /// @@ -578,95 +614,185 @@ fn a_panel_commit_that_falls_back_runs_the_document_preflight() { } } -/// **N** — the case no preflight snapshot can catch: the **body itself** -/// creates the fallback, and the refusal still fires. +/// **N** — a body that tries to **create** the fallback is refused **at +/// the attempt**, and the refusal lands on the mutation rather than on +/// the outcome. /// -/// This is why the guarantee moved to the placement boundary. Revision 6 -/// argued that a prediction taken at preflight could not go stale, -/// because `commit_to`'s body cannot `await`. Refusing `await` prevents -/// another *coroutine* interleaving; it places no restriction on the body -/// itself, which is arbitrary Lua running synchronously: +/// This is the case no preflight snapshot can catch, and the two rows +/// above cannot reach it: both establish their fallback state *before* +/// `commit_to` is entered. The body is arbitrary **synchronous** Lua, so +/// refusing `await` — which stops another coroutine interleaving — +/// places no restriction on it: /// /// ```lua /// pmacs.window.set_params(pmacs.window.panel(), { dedicated = true }) /// pmacs.window.display(result, { side = "bottom" }) /// ``` /// -/// Two statements. The first invalidates the prediction, the second cashes -/// it in. The arrangement here is deliberately the **inverse** of the -/// preflight rows: an undedicated panel exists, so the prediction says -/// "this will land in the panel", the relaxation applies, and the body -/// runs. Only when placement resolves is the fallback a fact. +/// Two statements: the first invalidates the preflight, the second cashes +/// it in. The arrangement is deliberately the **inverse** of the rows +/// above — the preflight says "this lands in the panel", the relaxation +/// applies, and the body runs. /// -/// What it asserts, and why each is load-bearing: +/// **Asserting only "document B was not replaced" is insufficient**, and +/// an earlier version of this test made exactly that mistake: it passes +/// on a design that lets the body mutate freely and merely declines the +/// final installation, leaving every other side effect behind. So the +/// three assertions that matter are that the **dedication call itself is +/// refused**, the slot is **still undedicated afterwards**, and **nothing +/// partial was installed**. /// -/// * the body **did** run — otherwise the test would be re-proving the -/// preflight and this whole case would be untested; -/// * the refusal arrives as a **raise** from `display`, since the body was -/// already running and there is no `(false, reason)` left to return — -/// asserted on content, and it names both the fallback and the -/// stale-intent reason; -/// * `*newer*` is **still in the document window**. That is the actual -/// user-visible guarantee; everything above it is mechanism. +/// # One row per WRITE SITE, not per call spelling /// -/// *Mutation:* delete the `fallback_commit_refusal` call from -/// `display_buffer`. This test fails on all three; every other test in -/// this file still passes, which is precisely the hole revision 6 left. +/// A single row is exactly what would let a second route keep the +/// defect — which is not hypothetical: review found the +/// `display{side, dedicated}` route *after* `set_params` was specified. +/// So the rows are chosen to hit each distinct write to +/// `Window::params.dedicated` that a side window can receive, rather +/// than each way of phrasing the call: +/// +/// | row | reaches | +/// |---|---| +/// | `set_params` | the direct write in the binding (Q#BP2c) | +/// | `display{side, dedicated}` replacing | `apply_placement`'s **replacing** arm | +/// | `display{side, dedicated}` same buffer | its **non-replacing** arm | +/// | `display{side, dedicated}` with no panel | its **created** arm | +/// +/// The three `display` rows converge on one guard, in `display_buffer` — +/// `apply_placement` has exactly one caller, so every request-driven +/// dedication passes through it. They are still separate rows because +/// that convergence is a property of today's call graph, and a row per +/// arm fails loudly if it stops holding. +/// +/// The rest of the enumeration is **unreachable rather than refused** +/// and is recorded in `EditorCore::panel_commit_dedication_refusal`, +/// because a test cannot express it: `panel_capable` has no Lua binding; +/// **losing** the side window is not a fallback route at all +/// (`resolve_placement` creates a fresh panel instead); and `quit` +/// restoring a `dedicated: true` presentation cannot be constructed, +/// since `QuitAction::Restore` only captures that flag on a *replacing* +/// side placement and a dedicated slot can never be the target of one. +/// +/// *Mutation:* delete the `panel_commit_dedication_refusal` call from +/// either guarded site — `set_params` drops row 1, `display_buffer` +/// drops rows 2–4 — and every other test in this file still passes. #[test] -fn a_panel_commit_whose_body_creates_the_fallback_is_refused_at_placement() { - let s = editor(); - - // A REUSABLE panel: undedicated, so the preflight prediction says - // this frontend places side requests in the panel. - exec( - &s, - "pmacs.window.display(pmacs.buffer.create('*pinned*'), - { side = 'bottom', dedicated = false, select = false })", - ); - capture(&s); - let doc = local_window(&s); - exec( - &s, - "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", - ); - assert_eq!( - name_in(&s, doc), - "*newer*", - "the arrangement must make the captured window stale" - ); - - commit_body( - &s, - Some("'panel'"), - &format!( - "pmacs.window.set_params(pmacs.window.panel(), {{ dedicated = true }}) - {PANEL_BODY}" +fn a_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt() { + // (label, panel arrangement before the capture, attempted mutation) + let routes: [(&str, &str, &str); 4] = [ + ( + "set_params", + PANEL_ARRANGED, + "pmacs.window.set_params(pmacs.window.panel(), { dedicated = true })", ), - ); + ( + "display{side, dedicated} replacing", + PANEL_ARRANGED, + "pmacs.window.display(pmacs.buffer.create('*usurp*'), + { side = 'bottom', dedicated = true, select = false })", + ), + ( + // The same buffer the panel already shows: `replacing` is + // false, so this lands in a DIFFERENT arm of the same + // function, which a row against the replacing arm alone + // would not exercise. + "display{side, dedicated} same buffer", + PANEL_ARRANGED, + "pmacs.window.display(pmacs.window.buffer(pmacs.window.panel()), + { side = 'bottom', dedicated = true, select = false })", + ), + ( + // NO panel at capture time: the preflight relaxes because + // `side_window_for` is None (a side request would CREATE a + // panel, never fall back). The body then creates one + // dedicated, which makes the next side request fall back. + "display{side, dedicated} creating the panel", + "", + "pmacs.window.display(pmacs.buffer.create('*usurp*'), + { side = 'bottom', dedicated = true, select = false })", + ), + ]; - assert!( - ran(&s), - "the body must have run -- the preflight could not have known, and a test where \ - it did not run would be re-proving the preflight" - ); - let raised = raised(&s).expect( - "the refusal arrives as a raise: the body was already running, so there is no \ - (false, reason) return left to make", - ); - assert!( - raised.contains("fell back to a document window"), - "the message must name what happened; got {raised:?}" - ); - assert!( - raised.contains("now shows another buffer"), - "and which document precondition failed; got {raised:?}" - ); - assert_eq!( - name_in(&s, doc), - "*newer*", - "the user's newer buffer must survive -- this is the guarantee, and it is what a \ - preflight-only design cannot provide" - ); + for (label, arrange, attempt) in routes { + let s = editor(); + exec(&s, arrange); + + let panel_before = s.core.borrow().side_window_for(FrontendId::LOCAL); + if let Some(panel) = panel_before { + assert!( + !dedicated(&s, panel), + "{label}: the slot must start UNDEDICATED, or the preflight would have \ + refused and this row would be re-proving the preflight" + ); + } + let panel_buffer_before = panel_before.map(|panel| name_in(&s, panel)); + + capture(&s); + let doc = local_window(&s); + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + + commit_body(&s, Some("'panel'"), &format!("{attempt}\n{PANEL_BODY}")); + + assert!( + ran(&s), + "{label}: the body must have run -- the preflight could not have known" + ); + + // 1. THE MUTATION ITSELF IS REFUSED, on content. + let raised = raised(&s).unwrap_or_else(|| { + panic!("{label}: the attempted mutation must be refused, not merely declined later") + }); + assert!( + raised.contains("cannot dedicate the side window"), + "{label}: the refusal must name the operation it is refusing; got {raised:?}" + ); + assert!( + raised.contains("\"panel\" commit_to"), + "{label}: and why it is refused here specifically; got {raised:?}" + ); + + // 2. THE SLOT IS STILL UNDEDICATED -- including the row where + // the slot would have been created dedicated, which must + // leave no slot at all rather than an undedicated one. + let panel_after = s.core.borrow().side_window_for(FrontendId::LOCAL); + assert_eq!( + panel_after, panel_before, + "{label}: a refused mutation must not have created or removed the side slot" + ); + if let Some(panel) = panel_after { + assert!( + !dedicated(&s, panel), + "{label}: a refused mutation must not have happened -- the whole design \ + rests on the preflight's measurement still being true afterwards" + ); + } + + // 3. NOTHING PARTIAL WAS INSTALLED. + if let (Some(panel), Some(before)) = (panel_after, panel_buffer_before.as_ref()) { + assert_eq!( + &name_in(&s, panel), + before, + "{label}: the panel must still show what it showed" + ); + } + assert_eq!( + name_in(&s, doc), + "*newer*", + "{label}: and the user's newer buffer must survive" + ); + assert!( + !buffer_exists(&s, "*result*"), + "{label}: the refusal must land BEFORE the body's own display -- a `*result*` \ + buffer means the commit got partway and then stopped" + ); + assert!( + !buffer_exists(&s, "*usurp*") || panel_after == panel_before, + "{label}: no usurping presentation may have been installed" + ); + } } /// **P** — a `"panel"` commit that falls back with a **still-valid** From 5f3f38dfd7eeb2cacb9c9df1d8790c20390c6684 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 21:02:10 +0200 Subject: [PATCH 42/66] fix(window): keep a panel commit's restriction across nested scopes Revision 8 refuses, inside a "panel" commit_to, the mutations that would make its relaxed preflight wrong. A nested commit_to REPLACED the enclosing contract with its own and restored it afterwards, so the outer restriction went out of force for the whole inner body: commit_to(outer, function() -- "panel", relaxed preflight commit_to(inner, function() -- "document", MASKS the outer set_params(panel(), { dedicated = true }) -- ...and succeeds end) display(result, { side = "bottom" }) -- ...which now FALLS BACK end, "panel") Every step is legal on its own, and the outer commit then overwrote a newer document buffer --- the P1a failure the lane exists to remove, reached through one extra call. What this invalidated, precisely: NOT the enumeration of dedication write sites. Every site in it is real and still guarded. What was wrong was the claim that the guard was in force for the whole outer body. So the enumeration is inherited and qualified, not redone. Contracts now COMPOSE rather than replace. The core holds a stack; ScopedFrontendGuard pushes on entry and truncates back to its own depth on every exit path; panel_commit_dedication_refusal consults every contract in force rather than the innermost. The strictest active restriction wins. Matching stays per frontend --- a nested commit for a different frontend may dedicate its own side slot, which cannot change where this frontend's side request lands. Nesting itself is NOT forbidden, which was the other candidate fix. It closes the hole by prohibiting a construction no rule objects to: commit_to is public Lua API for saying where a continuation's result belongs, and a body committing to a second destination (a diff beside a status panel) is where #227's adoption is heading. Only the restriction needed preserving. Detecting the dedication when the outer commit resumed was not available either --- that is a late refusal, which is what revision 7 was rejected for. Two pins, and they are a pair rather than one test written twice: * a_nested_commit_cannot_mask_an_outer_panel_restriction drives the same four write-site rows through a nested, entirely valid "document" commit, and asserts the attempt is refused, the slot is still undedicated, and the outer commit's destination is intact. * an_ordinary_nested_commit_still_runs_and_restores_the_outer_restriction pins that nesting without dedication is accepted, that the enclosing restriction is back in force once the nested commit returns, and that outside every commit dedication is ordinary again. Mutation-checked: restoring the guard to the innermost contract (.last(), exactly revision 8's swapped slot) fails only the first of those. The other 13 pins, journey_acceptance (31), dired_acceptance (47) and cargo test --lib (1920) all stay green. The ordinary-nesting pin deliberately survives it --- it exists to fail the other candidate fix. Also sweeps the comments left by revision 7, which revision 8 superseded: no fallback_commit_refusal symbol remains, but six doc sites still described placement-boundary enforcement as the guarantee (ViewDestination, CommitProfile::Panel, CommitContract, capture_view_destination, commit_destination_refusal, panel_placement_can_fall_back), plus two comment blocks in the commit_to binding and one stale mutation note in the acceptance suite. Net rustdoc warnings down three. Framing to revision 9; the active-work lane entry updated in place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 121 ++++++-- docs/destination-capture-framing.md | 109 ++++++- src/editor.rs | 32 +- src/editor_core.rs | 185 +++++++---- src/lua_bindings/window_panel.rs | 41 +-- tests/destination_capture_acceptance.rs | 388 ++++++++++++++++++++---- 6 files changed, 695 insertions(+), 181 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0c9e1e4..c81de48 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,13 +265,14 @@ 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. -## Destination capture (Q#JR14 generalization) — revision 8 IMPLEMENTED, gate green, no PR yet +## Destination capture (Q#JR14 generalization) — revision 9 IMPLEMENTED, gate green, no PR yet The mechanism landed at `0efc8c0`; review found a correctness blocker; `ca72461` implemented **revision 7**, which review then **also** -rejected; the commit below replaces it with **revision 8** and its -§3 enumeration is **performed and recorded in the framing**. No PR — the -lane was told not to open one. +rejected; `469d5c8` replaced it with **revision 8** and its §3 +enumeration is **performed and recorded in the framing**; review then +found a hole in revision 8's guard **scope** and the commit below closes +it as **revision 9**. No PR — the lane was told not to open one. **The original blocker:** the panel profile skipped checks 2–4 on the claim that a panel result never touches a document window. **Panel @@ -280,8 +281,8 @@ is not panel-capable or its side slot is dedicated, so a `"panel"` commit could replace a **newer** document with every stale-intent guard skipped. Reproduced in review. -**Three designs, two rejected — the sequence is the part worth not -re-learning:** +**Four designs, two rejected outright and one corrected — the sequence +is the part worth not re-learning:** 1. **Revision 6 — predict at preflight.** Rejected: the `await` refusal stops concurrent interleaving, not the body, which is arbitrary @@ -293,13 +294,37 @@ re-learning:** already created buffers, handles and paint by then, so a placement-time refusal is a partial commit with an error return. 3. **Revision 8 — keep the preflight, REFUSE the scope-invalidating - mutation.** Current design. Same shape as `Handle:await` being - refused inside a commit scope: the fallback never comes into + mutation.** The shape the tree implements. Same as `Handle:await` + being refused inside a commit scope: the fallback never comes into existence, and refusal stays mutation-free on `(false, reason)`. +4. **Revision 9 — make the refusal hold for the WHOLE body.** Not a new + shape; a correction to revision 8's scope. A nested `commit_to` + **replaced** the enclosing contract and restored it afterwards, so + an outer `"panel"` commit's restriction went out of force for the + inner body's extent: nested `"document"` commit → callback dedicates + the side slot, unrefused → outer commit resumes, falls back, + overwrites a newer document. Reproduced in review. Contracts now + **compose** — the core holds a stack, `commit_to` pushes and pops + rather than swapping, and the guard consults every contract in force, + so the strictest active restriction wins. Nesting itself is **not** + forbidden: only the mutation is refused, so a nested commit that + touches no dedication runs exactly as before. Detecting the + dedication when the outer commit resumed was not available — that is + a late refusal, which is what revision 7 was rejected for. -**THE ENUMERATION IS THE LOAD-BEARING PART, AND IT IS NOW CLOSED — for -a structural reason, not because inspection ran out of ideas.** Full -working in the framing §3; the short form: +**WHAT REVISION 9 DID *NOT* INVALIDATE — read this before re-opening the +enumeration.** The write-site enumeration below survived intact: every +site is real, every one is still guarded, and review of the nesting +defect found no missing route. What was wrong was the *surrounding* +claim — that the guard was in force for the whole outer body. A complete +list of write sites is not a complete argument until the guard's extent +is stated too. The acceptance suite now drives the same rows at **two +depths**, directly and through a nested `commit_to`. + +**THE ENUMERATION IS THE LOAD-BEARING PART, AND IT IS CLOSED AS AN +ENUMERATION OF WRITE SITES — for a structural reason, not because +inspection ran out of ideas.** Full working in the framing §3; the short +form: - **Only two pieces of state can matter**, because `resolve_placement` reaches `Ordinary` from a side request through exactly two branches: @@ -348,38 +373,60 @@ from #171 and #215. authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout destination-capture`. -- **Framing `docs/destination-capture-framing.md`, revision 8.** - Revisions 1–5 were approved over four review rounds; revisions 6, 7 - and 8 are corrections carrying the blocker above, and **revision 8's - design is what the tree implements**. Revisions 6 and 7 are described - in that document as the record of why *not* those; neither is in the - tree and neither should be restored from it. -- **Implemented in three commits.** `779bb02` is the mechanism +- **Framing `docs/destination-capture-framing.md`, revision 9.** + Revisions 1–5 were approved over four review rounds; revisions 6–9 are + corrections carrying the blocker above, and **revision 8's design as + scoped by revision 9 is what the tree implements**. Revisions 6 and 7 + are described in that document as the record of why *not* those; + neither is in the tree and neither should be restored from it. +- **Implemented in four commits.** `779bb02` is the mechanism (`pmacs.window.capture_destination()`, the `ViewDestination` rename, the profile argument); `d5a6170` is - `tests/destination_capture_acceptance.rs`; the revision-8 commit is - the panel-profile correction plus the invalid-UTF-8 hole. **12 pins**, - and both preservation suites pass **unchanged** (journey 47, dired 31) - — §7's stop signal not firing rather than being suppressed. + `tests/destination_capture_acceptance.rs`; `469d5c8` is the + revision-8 panel-profile correction plus the invalid-UTF-8 hole; the + commit below is revision 9's contract stack. **14 pins**, and both + preservation suites pass **unchanged** (journey 47, dired 31) — §7's + stop signal not firing rather than being suppressed. - **HOW THE PANEL PROFILE IS ENFORCED, in one sentence so no earlier revision gets reinstated by someone reading only that document:** the preflight stays exactly where it was, and the mutations that would invalidate it are **refused at the attempt**. - `EditorCore::panel_commit_dedication_refusal` is the one rule. It - fires while a `"panel"` `CommitContract` is on the core for this - frontend, and is consulted from `display_buffer` (before - `apply_placement`, so a refused attempt mutates nothing), - `pmacs.window.set_params` (before its borrow, so `fixed_rows` in the - same table is not applied either), and `quit_window`. + fires while **any** `"panel"` `CommitContract` for this frontend is + in force — every contract on the stack, not the innermost — and is + consulted from `display_buffer` (before `apply_placement`, so a + refused attempt mutates nothing), `pmacs.window.set_params` (before + its borrow, so `fixed_rows` in the same table is not applied + either), and `quit_window`. - **This is the same shape as `Handle:await` being refused inside a commit scope**, and for the identical reason: something that would invalidate the scope's guarantee is rejected outright rather than predicted around or caught late. - The contract (`CommitContract { destination, profile }`) rides on - the core, installed and restored by the **same** `ScopedFrontendGuard` - that scopes the frontend, so a `"panel"` profile can never outlive - the body that declared it. The field is private to the crate — Lua - cannot claim a profile for a placement it did not commit to. + the core in a **stack**, pushed and popped by the **same** + `ScopedFrontendGuard` that scopes the frontend, so a `"panel"` + profile can never outlive the body that declared it. The field is + private to the crate — Lua cannot claim a profile for a placement it + did not commit to. + - **A stack, not a slot, and the distinction is revision 9 (above).** + The frontend override and the ambient frontend are *substitutions*, + so a nested scope rightly replaces them; a contract is a + *restriction*, and replacing one suspends it. The guard stores a + depth and truncates back to it, so an inner exit removes exactly the + contract it added and leaves every enclosing one in force. Matching + is per **frontend**: a nested commit for a different frontend may + dedicate *its* side slot, which cannot change where this frontend's + side request lands. + - **Prohibiting nested `commit_to` was the other candidate and was + rejected.** It closes the hole by forbidding a construction no rule + objects to — `commit_to` is public Lua API for saying where a + continuation's result belongs, and a body committing to a second + destination (a diff beside a status panel) is where #227's adoption + is heading. Only the restriction needed preserving. **No Lua in the + tree nests today** — `builtin/runtime/dired.lua` is the only + `commit_to` consumer and it does not — so this is a decision about + the API's future rather than about a live consumer, which is why it + is recorded rather than left implicit. - **`panel_placement_can_fall_back` remains the preflight**, unchanged in role: it measures whether this frontend places side requests in the panel *right now*. With the invalidating mutations refused, that @@ -468,6 +515,18 @@ authoritative tip** — the ref, not a SHA. Recover with And reverting the byte comparison to `to_str()?` fails the `invalid utf-8` row with mlua's conversion error, on content. + + **Revision 9's, run across all three suites and the lib:** restore + `panel_commit_dedication_refusal` to reading only the innermost + contract (`.last()`, which is exactly revision 8's swapped slot) → + **only** `a_nested_commit_cannot_mask_an_outer_panel_restriction` + fails. The other 13 pins, `journey_acceptance` (31), + `dired_acceptance` (47) and `cargo test --lib` (1920) all stay green, + which is what makes the new test the pin for this defect and not a + restatement of the depth-1 one. Note the ordinary-nesting pin + deliberately survives that mutation — it exists to fail the *other* + candidate fix (prohibit nesting), so the two are a pair rather than + one test written twice. - **The public API #227 adopts against (Q#DC-5), pinned so it is a contract rather than an intention:** `pmacs.window.commit_to(dest, body [, profile])`. Profile is an diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index fafa83e..2e19165 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -1,17 +1,54 @@ # A destination capture any async continuation can use -**Status: revision 8. The mechanism is implemented at `0efc8c0`; the -correctness blocker revisions 6–8 carry is IMPLEMENTED, in revision 8's -shape, with §3's enumeration performed and recorded below.** Revisions -6 and 7 proposed fixes that review rejected; **neither is in the tree**, -and the two paragraphs describing them are kept as the record of why -this shape and not those. +**Status: revision 9. The mechanism is implemented at `0efc8c0`; the +correctness blocker revisions 6–9 carry is IMPLEMENTED, in revision 8's +shape with revision 9's scope correction, and §3's enumeration is +performed and recorded below.** Revisions 6 and 7 proposed fixes that +review rejected; **neither is in the tree**, and the two paragraphs +describing them are kept as the record of why this shape and not those. *(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the ledger recorded the lane as approved and implemented. Same contradiction class this document keeps correcting elsewhere, left standing in its own header.)* +**Revision 9 fixes a hole in revision 8's guard — one that is about the +guard's SCOPE, not about which mutations it names.** Revision 8 refuses, +inside a `"panel"` commit, the mutations that would make its relaxed +preflight wrong. But a **nested `commit_to` REPLACED** the enclosing +contract with its own and restored it afterwards (`src/editor.rs:129`, +`src/lua_bindings/window_panel.rs`), so the outer restriction went out of +force for the whole of the inner body. Review reproduced the sequence: +an outer `"panel"` commit passes the relaxed preflight; a nested +`"document"` commit masks its contract; the nested callback dedicates the +side slot and **is not refused**; the outer commit resumes, its side +request falls back, and it overwrites a newer document — the original +P1a failure, reached through one extra call. + +**What this invalidated, precisely.** *Not* §3's enumeration of +dedication write sites. That enumeration was performed against the tree, +it is still complete, and every site in it is still guarded. What was +wrong was the surrounding claim — that the guard was **in force for the +whole outer body**. §3's "PREFLIGHT STAYS WHERE IT IS" paragraph and the +enumeration that follows it are therefore kept and **qualified**, not +withdrawn. + +**The fix: contracts COMPOSE across nested scopes; the strictest active +restriction wins.** The core holds a *stack* of contracts rather than one +slot: `commit_to` pushes and pops rather than swapping, and the +dedication guard consults **every** contract in force rather than the +innermost. Matching stays per frontend, so a nested commit for a +different frontend may still dedicate *its* side slot — that cannot +change where this frontend's side request lands. The alternative shape, +**prohibiting nested `commit_to` outright**, was rejected: it closes the +hole by forbidding a construction no rule objects to. `commit_to` is +public Lua API for saying where a continuation's result belongs, and a +body that commits to a second destination (a diff beside a status panel) +is where #227's adoption is heading. Only the *restriction* needed +preserving. **Detecting the dedication when the outer commit resumed was +not available**: by then the mutation has happened, which is a late +refusal, which is what revision 7 was rejected for. + **Revision 8 rejects BOTH of the previous two fixes and takes a third shape.** Revision 6 predicted the fallback at preflight (the body can change it). Revision 7 moved enforcement to the placement boundary — @@ -338,6 +375,15 @@ invalidate the scope's guarantee is rejected rather than predicted around. With them refused, the preflight measurement cannot go stale, and refusal stays mutation-free on the normal `(false, reason)` path. +**"Inside a panel-profile commit" MEANS THE WHOLE BODY, INCLUDING ANY +NESTED `commit_to` (revision 9), and the unqualified version of that +phrase is what revision 8 got wrong.** Contracts **compose**: the core +holds a stack, `commit_to` pushes and pops rather than swapping, and the +guard consults every contract in force rather than the innermost. Read +every "inside a `\"panel\"` commit" below with that scope attached. +Nesting itself is *not* refused — only the mutation is, so a nested +commit that touches no dedication runs exactly as it did. + **The mutation surface is narrow, which is what makes this tight rather than aspirational:** @@ -361,10 +407,21 @@ first are: guarding only route 1 passes revision 8's test while keeping the original defect.** -**THE ENUMERATION, PERFORMED. It is CLOSED, and it is closed for a -structural reason rather than by inspection stopping when it ran out of -ideas.** Recorded here as the framing required, with what was looked -for, what was found, and what cannot be ruled out. +**THE ENUMERATION, PERFORMED. It is CLOSED as an enumeration of WRITE +SITES, and it is closed for a structural reason rather than by inspection +stopping when it ran out of ideas.** Recorded here as the framing +required, with what was looked for, what was found, and what cannot be +ruled out. + +**Read "closed" as scoped to the question it answers (revision 9).** It +answers *which writes can dedicate the side slot*, and that answer +survived review of the nesting defect intact — every site below is real +and every one is still guarded. It says nothing about *when the guard is +in force*, and that is the axis revision 8 got wrong: a nested +`commit_to` used to mask the enclosing contract, so all five reachable +sites were momentarily unguarded together. A complete list of write sites +is not a complete argument until the guard's extent is stated too, which +is what the composing-contracts paragraph above now does. *Step 1 — how few pieces of state can matter.* `resolve_placement` reaches `Ordinary` from a side request through exactly two branches, so @@ -431,6 +488,15 @@ directly, reopens it. `Window::params.dedicated` is a public field, so the compiler does not enforce the funnel — the acceptance rows are what would catch a regression, one per reachable site. +**And it never ruled out a defect in the guard's EXTENT, which is what +revision 9 found.** Nothing above is about *when* +`panel_commit_dedication_refusal` answers; a list of write sites cannot +notice that the contract it reads was masked by a nested scope. The +acceptance suite now drives the same write-site rows at **two depths** — +directly in a `"panel"` body, and through a nested `commit_to` — so a +route guarded at one depth and not the other fails loudly rather than +being covered by the enumeration's word "closed". + **If the enumeration had turned out open-ended**, the fallback was to **collapse the two profiles** — run all four checks always, losing the panel relaxation. That is safe, simple, and honest; it is not the @@ -630,6 +696,29 @@ incidental: no arguments is what keeps capture profile-blind. passes on a design that lets the body mutate freely and merely declines the final installation, leaving every other side effect behind. The refusal must land on the mutation, not on the outcome. +- **THE SAME WRITE-SITE ROWS, DRIVEN THROUGH A NESTED `commit_to`** + (revision 9), in their own test: an outer `"panel"` commit whose body + opens a nested **`"document"`** commit — a perfectly valid one, whose + destination is captured fresh inside the outer body so it passes all + four of its own checks and its callback really runs — and *that* + callback attempts the dedication. Asserted: the attempt is **refused**, + the slot is **still undedicated** afterwards, and the outer commit's + destination is **intact** (its result lands in the panel; the user's + newer document buffer survives). The bullet above cannot catch this — + its mutation runs at commit depth 1, where revision 8's single-slot + contract was the right one to read. Rows per write site rather than one + row, because a fix that reinstated the outer contract for only one site + would pass a single-row version. +- **ORDINARY NESTING STILL WORKS**, asserted rather than assumed: a + nested `commit_to` that touches no dedication is accepted, its body + runs, and its return value comes back through both frames. This is the + pin against the other candidate fix — prohibiting nested `commit_to` + outright — which would close the hole by forbidding a shape no rule + objects to. Two further assertions, and the second is the one a + `pop`-shaped fix gets wrong: the enclosing restriction is **back in + force after the nested commit returns** (popped, not cleared), and + **outside every commit dedication is ordinary again**, so the fix + leaked no permanent restriction onto the editor. - **A `"panel"` commit that really lands in the panel still skips checks 2–4** — otherwise the fix has quietly collapsed the two profiles into one and the parameterization buys nothing. diff --git a/src/editor.rs b/src/editor.rs index c3da5bb..285f1f6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -119,13 +119,21 @@ impl ScopedFrontend { } /// Enter a background frontend scope, also swapping the core's - /// ambient `active_frontend` and publishing `contract`. All three are + /// ambient `active_frontend` and **pushing** `contract`. All three are /// restored on drop, on every exit path including a raising callback. /// /// The frontend comes from `contract.destination` rather than being /// passed separately: a scope entered for one frontend while carrying /// another's destination would let the placement guard check the /// wrong window, and there is no caller that wants them to differ. + /// + /// **The contract is pushed, not swapped (Q#DC-2, revision 9).** The + /// frontend override and the ambient frontend are *substitutions* — + /// an inner scope means what it says and the outer one resumes + /// afterwards — but a contract is a *restriction*, and a nested scope + /// masking one would suspend it for the extent of the inner body + /// while the outer commit's relaxed preflight still depended on it. + /// See [`crate::editor_core::EditorCore::push_commit_contract`]. pub(crate) fn enter( &self, core: &SharedCore, @@ -134,11 +142,11 @@ impl ScopedFrontend { ) -> ScopedFrontendGuard { let frontend_id = contract.destination.frontend; let previous = self.0.replace(Some(frontend_id)); - let (previous_active, previous_contract) = { + let (previous_active, contract_depth) = { let mut core = core.borrow_mut(); let was = core.active_frontend; core.active_frontend = frontend_id; - (was, core.enter_commit_contract(Some(contract))) + (was, core.push_commit_contract(contract)) }; let previous_commit = commit_scope.0.replace(true); ScopedFrontendGuard { @@ -146,7 +154,7 @@ impl ScopedFrontend { core: core.clone(), previous, previous_active, - previous_contract, + contract_depth, commit_scope: commit_scope.clone(), previous_commit, } @@ -158,11 +166,15 @@ pub(crate) struct ScopedFrontendGuard { core: SharedCore, previous: Option, previous_active: FrontendId, - /// The contract in force before this commit, restored with the rest - /// (Q#DC-2). Held here rather than on a separate guard so a - /// `"panel"` profile can never outlive the body that declared it and - /// govern an unrelated later display. - previous_contract: Option, + /// Contract-stack depth to truncate back to (Q#DC-2). Held here + /// rather than on a separate guard so a `"panel"` profile can never + /// outlive the body that declared it and govern an unrelated later + /// display. + /// + /// A depth rather than a saved contract because nesting **composes** + /// (revision 9): this scope adds one restriction and removes exactly + /// that one, leaving every enclosing commit's still in force. + contract_depth: usize, /// Cleared together with the scope, so an awaiting callback cannot /// leave `await` refused after the commit ends (Q#JR14b). commit_scope: CommitScopeActive, @@ -175,7 +187,7 @@ impl Drop for ScopedFrontendGuard { { let mut core = self.core.borrow_mut(); core.active_frontend = self.previous_active; - core.enter_commit_contract(self.previous_contract); + core.exit_commit_contract(self.contract_depth); } self.commit_scope.0.set(self.previous_commit); } diff --git a/src/editor_core.rs b/src/editor_core.rs index 4cc92f1..517989c 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -167,8 +167,10 @@ pub enum ResolvedTarget { /// the document profile requires all of them, and the panel profile /// requires only a live `frontend` **while its result really lands in a /// panel**. A side request that falls back into a document window *is* a -/// document replacement, and is held to all of them at the placement -/// boundary ([`EditorCore::fallback_commit_refusal`]). Capture stays +/// document replacement, so the panel profile's relaxed preflight is +/// taken only when the fallback cannot happen, and the mutations that +/// would manufacture one mid-commit are refused at the attempt +/// (`EditorCore::panel_commit_dedication_refusal`). Capture stays /// profile-blind so a caller does not have to know at capture time what /// it will do at commit time. /// @@ -202,16 +204,19 @@ pub enum CommitProfile { Document, /// The body puts its result in a bottom panel rather than in the /// captured document window, and so does not depend on checks 2–4 — - /// **for as long as its result really lands in a panel**. When a side - /// request falls back into a document window the relaxation is - /// withdrawn at the placement boundary, which is the only place the - /// fallback is a fact rather than a guess - /// ([`EditorCore::display_buffer`]). + /// **for as long as its result really lands in a panel**. The + /// preflight grants the relaxation only when a fallback into a + /// document window is impossible ([`EditorCore::commit_destination_refusal`]), + /// and what keeps that measurement true for the body's whole extent + /// is that the mutations which would manufacture a fallback are + /// refused at the attempt + /// (`EditorCore::panel_commit_dedication_refusal`). Panel, } /// The contract a `commit_to` body is running under, published on the -/// core for the placement path to consult (Q#DC-2, revision 7). +/// core so the mutations that could invalidate it can consult it +/// (Q#DC-2, revisions 8 and 9). /// /// **Why this exists rather than a preflight prediction.** Revision 6 /// tried to decide at preflight whether a `"panel"` commit's placement @@ -221,12 +226,17 @@ pub enum CommitProfile { /// the body itself, which is arbitrary Lua running synchronously and can /// change the very state the snapshot measured — obtain the panel, set /// it `dedicated`, then request a side display. A snapshot cannot bind -/// that. The fact "this asked for a side and landed in a document -/// window" is only ever known where placement resolves, so that is where -/// the document preconditions are enforced. +/// that. So the preflight stays where it is and the contract is what +/// lets those mutations be **refused at the attempt**, which is the only +/// point early enough to leave nothing behind +/// (`EditorCore::panel_commit_dedication_refusal`). /// -/// Installed and restored by the same guard that scopes the frontend, so -/// the two can never disagree about whether a commit is on the stack. +/// Pushed and popped by the same guard that scopes the frontend, so the +/// two can never disagree about whether a commit is on the stack. +/// **Pushed** rather than swapped: a contract is a restriction, and a +/// nested `commit_to` must add to the ones in force rather than mask +/// them for the extent of its body +/// (`EditorCore::push_commit_contract`, revision 9). #[derive(Clone, Copy, Debug)] pub struct CommitContract { /// The destination the continuation captured. @@ -690,14 +700,34 @@ pub struct EditorCore { /// slot; the producer clears any untaken record when the fan-out /// returns. typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, - /// The `commit_to` contract currently on the stack, if any (Q#DC-2). + /// Every `commit_to` contract currently on the stack, outermost + /// first (Q#DC-2, revision 9). /// - /// Private and `pub(crate)`-free on purpose: it is installed only by - /// [`crate::editor::ScopedFrontend::enter`]'s guard, which restores - /// the previous value on every exit path including a raising body. - /// Nothing outside this crate can set it, so a `"panel"` profile is - /// not something Lua can claim for a placement it did not commit to. - commit_contract: Option, + /// **A STACK, NOT A SLOT, and that is the whole of revision 9's + /// fix.** Revision 8 held one contract and had a nested `commit_to` + /// replace it for the inner body's extent. That MASKED the enclosing + /// contract: an outer `"panel"` commit took the relaxed preflight, + /// its body opened a nested `"document"` commit, and inside that + /// nested body the very mutation the outer commit's relaxation + /// depends on — dedicating the one side slot — was no longer refused, + /// because the guard consulted only the innermost contract. The outer + /// commit then resumed and fell back into the document window, + /// overwriting a newer buffer, which is exactly the defect the panel + /// profile's relaxation was made safe against. + /// + /// So restrictions **compose** rather than replace: a contract is + /// pushed for its body and popped after, and every restriction + /// pushed by an enclosing commit stays in force for the whole of it, + /// nested scopes included. See + /// [`Self::panel_commit_dedication_refusal`], the one reader. + /// + /// Private and `pub(crate)`-free on purpose: entries are pushed only + /// by [`crate::editor::ScopedFrontend::enter`]'s guard, which + /// truncates back to its own depth on every exit path including a + /// raising body. Nothing outside this crate can push one, so a + /// `"panel"` profile is not something Lua can claim for a placement + /// it did not commit to. + commit_contracts: Vec, } impl EditorCore { @@ -752,22 +782,40 @@ impl EditorCore { query_replace: None, typed_edit_pending: None, typed_edit_armed: None, - commit_contract: None, + commit_contracts: Vec::new(), } } - /// Install `contract` for the duration of a `commit_to` body, - /// returning the previous one for the guard to restore. + /// Push `contract` for the duration of a `commit_to` body, returning + /// the depth [`Self::exit_commit_contract`] must truncate back to. + /// + /// **Pushes rather than replaces (revision 9).** A nested `commit_to` + /// adds its contract to the ones already in force instead of masking + /// them, so an enclosing `"panel"` commit's mutation refusal covers + /// its *whole* body — including the part that runs inside a nested + /// commit of a different profile. Replacing was revision 8's defect: + /// the guard read only the innermost contract, so a nested + /// `"document"` commit was a hole through which the body could + /// dedicate the side slot the outer relaxation rests on. /// /// Crate-private and paired with the frontend scope rather than a /// standalone setter: a contract that could be installed without - /// being restored would outlive its body and silently govern the - /// next unrelated display. - pub(crate) fn enter_commit_contract( - &mut self, - contract: Option, - ) -> Option { - std::mem::replace(&mut self.commit_contract, contract) + /// being popped would outlive its body and silently govern the next + /// unrelated display. + pub(crate) fn push_commit_contract(&mut self, contract: CommitContract) -> usize { + let depth = self.commit_contracts.len(); + self.commit_contracts.push(contract); + depth + } + + /// Drop every contract pushed at or above `depth`. + /// + /// Truncation rather than a bare `pop` so the guard restores exactly + /// the set that was in force when it was entered, whatever happened + /// in between — the same reason the frontend scope saves a value + /// rather than assuming it can invert its own change. + pub(crate) fn exit_commit_contract(&mut self, depth: usize) { + self.commit_contracts.truncate(depth); } /// Build a core from raw bytes under `name`. Used by tests. @@ -3143,8 +3191,9 @@ impl EditorCore { /// frontend id exists. A frontend with no document window yields a /// destination carrying only `frontend` — enough for a panel commit /// that really places in the panel, and refused by a document commit - /// (or by a panel commit that falls back into a document window, see - /// [`Self::fallback_commit_refusal`]) with a reason naming the + /// (or by a panel commit on a frontend where a side request would + /// fall back into a document window, see + /// [`Self::commit_destination_refusal`]) with a reason naming the /// missing window. Returning `None` here instead would push the /// caller back onto ambient state, which is the misrouting the /// capture exists to remove. @@ -3237,15 +3286,16 @@ impl EditorCore { /// buffer, registers a handle, paints) long before it reaches any /// call that could refuse, so a late refusal leaves debris behind. /// - /// **This is an early refusal, NOT the guarantee.** For the panel - /// profile it can only read the state that holds *now*, and the body - /// is arbitrary synchronous Lua that may change it — dedicate the - /// side slot, then request a side display. The guarantee that a - /// `"panel"` commit never replaces a newer document therefore lives - /// at the placement boundary in [`Self::display_buffer`], where the - /// fallback is a fact. What this buys is that the common case — a - /// frontend that simply cannot render a panel — refuses **before** - /// the body allocates anything. + /// **This measurement is only half the guarantee.** For the panel + /// profile it can read only the state that holds *now*, and the body + /// is arbitrary synchronous Lua that could change it — dedicate the + /// side slot, then request a side display. What keeps the + /// measurement true is that those mutations are **refused at the + /// attempt**, for the body's whole extent including any nested + /// `commit_to` (`Self::panel_commit_dedication_refusal`). Refusing + /// at the placement boundary instead was revision 7, and it was + /// rejected: by then the body has allocated buffers, handles and + /// paint, which is the debris this preflight exists to avoid. #[must_use] pub fn commit_destination_refusal( &self, @@ -4199,17 +4249,18 @@ impl EditorCore { /// dedicated, and a second one is never created, so a different /// buffer falls through instead (Q#BP3 2.iii). /// - /// **A PREDICTION, AND ONLY USED AS ONE.** This is consulted by + /// **A MEASUREMENT, AND NOT SELF-SUPPORTING.** This is consulted by /// [`Self::commit_destination_refusal`] to refuse the statically /// knowable case *before* a body allocates anything — a frontend that /// cannot render a panel at all will not acquire the capability - /// mid-body. It is **not** what makes the panel profile safe. A - /// `commit_to` body is arbitrary synchronous Lua and can dedicate the - /// side slot itself between this answer and the placement it - /// describes; refusing `await` prevents another coroutine + /// mid-body. On its own it would **not** make the panel profile safe: + /// a `commit_to` body is arbitrary synchronous Lua and could dedicate + /// the side slot itself between this answer and the placement it + /// describes, and refusing `await` prevents another coroutine /// interleaving, not the body rewriting the state it was measured - /// against. The guarantee is enforced where the fallback is a fact, - /// in [`Self::fallback_commit_refusal`]. + /// against. What holds the measurement true is + /// `Self::panel_commit_dedication_refusal`, which refuses exactly + /// those mutations for the body's whole extent. /// /// Arm 2 is answered **conservatively**: `resolve_placement` falls /// back only when the arriving buffer differs from the dedicated one, @@ -4233,9 +4284,9 @@ impl EditorCore { } /// **The guarantee** behind the `"panel"` commit profile (Q#DC-2, - /// revision 8): inside such a commit, the operations that would make - /// this frontend's side request fall back are **refused at the - /// attempt**. + /// revisions 8 and 9): anywhere inside such a commit — nested + /// `commit_to` scopes included — the operations that would make this + /// frontend's side request fall back are **refused at the attempt**. /// /// # The defect this closes /// @@ -4270,6 +4321,25 @@ impl EditorCore { /// fallback never comes into existence, and nothing needs refusing /// late. /// + /// # Every enclosing contract, not just the innermost (revision 9) + /// + /// This scans the whole contract stack. Revision 8 read a single + /// slot, and a nested `commit_to` replaced it — so an outer + /// `"panel"` commit whose body opened a nested `"document"` commit + /// had its restriction **masked** for that body's extent, and the + /// nested callback could dedicate the side slot the outer relaxation + /// rests on. The outer commit then resumed and fell back into the + /// document window, overwriting a newer buffer: the original defect, + /// reachable through one extra call. Detecting it when the outer + /// commit resumed would have been a late refusal, which revision 7 + /// was already rejected for. The restriction has to hold for the + /// whole body, so **the strictest active restriction wins** and + /// nesting is otherwise untouched. + /// + /// Matching is per **frontend**, not per stack: a nested commit for a + /// *different* frontend may dedicate *its* side slot, because that + /// cannot change where this frontend's side request lands. + /// /// # The enumeration this rests on /// /// [`Self::resolve_placement`] can only reach @@ -4312,9 +4382,18 @@ impl EditorCore { /// a panel degrades gracefully exactly as it does today; this /// refuses the *mutation that manufactures* a fallback, never the /// fallback itself. + /// * **Nesting is untouched.** Only the mutation is refused, not the + /// nested `commit_to` that reaches it, so a nested commit that does + /// not dedicate this frontend's side slot runs exactly as before. + /// Prohibiting nesting outright would have closed the hole by + /// forbidding a shape no rule objects to (revision 9). pub(crate) fn panel_commit_dedication_refusal(&self, fid: FrontendId) -> Option { - let contract = self.commit_contract.as_ref()?; - if contract.profile != CommitProfile::Panel || contract.destination.frontend != fid { + // ANY enclosing contract, not the innermost one: a nested commit + // composes with the restrictions already in force rather than + // masking them (revision 9). + if !self.commit_contracts.iter().any(|contract| { + contract.profile == CommitProfile::Panel && contract.destination.frontend == fid + }) { return None; } Some( diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index 9f7f137..01f4d61 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -538,20 +538,19 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let profile = commit_profile(&profile)?; // The preflight itself lives on the core - // (`commit_destination_refusal`), because the panel - // profile's relaxation now has a SECOND evaluation - // site --- the placement boundary, where a fallback - // into a document window stops being a prediction and - // becomes a fact --- and two hand-written copies of - // the same three checks is how the backstop ends up - // weaker than the thing it backs. + // (`commit_destination_refusal`) rather than being + // hand-written here, so the panel profile's + // relaxation is decided in one place: two copies of + // the same three checks is how one of them ends up + // weaker than the other. // - // What survives here, and only here: an early refusal - // costs the body nothing, so the statically knowable - // case (a frontend that cannot render a panel at all) - // never reaches the body's buffer creation. The - // GUARANTEE is not this call; see - // `EditorCore::fallback_commit_refusal`. + // This call is only HALF the panel guarantee. It + // measures whether this frontend places side requests + // in the panel; what keeps that measurement true + // while the body runs --- nested `commit_to` scopes + // included --- is + // `EditorCore::panel_commit_dedication_refusal`, + // which refuses the mutations that would falsify it. let refusal = cc.borrow().commit_destination_refusal(&dest, profile); if let Some(reason) = refusal { let mut out = mlua::MultiValue::new(); @@ -581,10 +580,18 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result // guard drops -- on the normal return AND on a // raising callback, which is why the result is // captured rather than `?`-propagated through the - // drop. The contract rides with the scope because the - // placement boundary needs to know, for every display - // this body performs, which destination and which - // profile it is running under. + // drop. The contract rides with the scope because + // every mutation this body reaches has to know which + // destination and which profile it is running under. + // + // A NESTED `commit_to` PUSHES its contract onto the + // ones already in force rather than replacing them + // (Q#DC-2, revision 9). Replacing was a hole: an + // outer `"panel"` commit's mutation refusal went out + // of force for the extent of a nested body, which is + // long enough to dedicate the side slot its relaxed + // preflight depends on. Nesting itself is allowed -- + // only the mutation is refused. let result = { let _guard = scope.enter( &cc, diff --git a/tests/destination_capture_acceptance.rs b/tests/destination_capture_acceptance.rs index fee4ba9..54fdf99 100644 --- a/tests/destination_capture_acceptance.rs +++ b/tests/destination_capture_acceptance.rs @@ -473,7 +473,7 @@ fn the_preflight_matrix_holds_in_both_profiles() { } // --------------------------------------------------------------------------- -// §7 — the panel profile's relaxation is CONDITIONAL (Q#DC-2, revision 7) +// §7 — the panel profile's relaxation is CONDITIONAL (Q#DC-2, rev 6–9) // --------------------------------------------------------------------------- /// The Lua a `"panel"` continuation runs: put a result buffer in the @@ -490,6 +490,65 @@ const PANEL_BODY: &str = "pmacs.window.display(pmacs.buffer.create('*result*'), const PANEL_ARRANGED: &str = "pmacs.window.display(pmacs.buffer.create('*pinned*'), \ { side = 'bottom', dedicated = false, select = false })"; +/// Every route by which a `commit_to` body can reach a write to a **side** +/// window's `Window::params.dedicated`, as `(label, arrangement before the +/// capture, the attempted mutation)`. +/// +/// **One row per WRITE SITE, not per call spelling** (§3's enumeration). A +/// single row is exactly what would let a second route keep the defect — +/// which is not hypothetical: review found the `display{side, dedicated}` +/// route *after* `set_params` was specified, and one spelling of it +/// reaches three different writes. +/// +/// | row | reaches | +/// |---|---| +/// | `set_params` | the direct write in the binding (Q#BP2c) | +/// | `display{side, dedicated}` replacing | `apply_placement`'s **replacing** arm | +/// | `display{side, dedicated}` same buffer | its **non-replacing** arm | +/// | `display{side, dedicated}` with no panel | its **created** arm | +/// +/// The three `display` rows converge on one guard, in `display_buffer` — +/// `apply_placement` has exactly one caller, so every request-driven +/// dedication passes through it. They are still separate rows because that +/// convergence is a property of today's call graph, and a row per arm +/// fails loudly if it stops holding. +/// +/// Shared by the two tests that drive them, at commit depth 1 and through +/// a nested commit: a route guarded at one depth and not the other is the +/// defect revision 9 fixes, and a table each would let the two drift. +const DEDICATION_ROUTES: [(&str, &str, &str); 4] = [ + ( + "set_params", + PANEL_ARRANGED, + "pmacs.window.set_params(pmacs.window.panel(), { dedicated = true })", + ), + ( + "display{side, dedicated} replacing", + PANEL_ARRANGED, + "pmacs.window.display(pmacs.buffer.create('*usurp*'), + { side = 'bottom', dedicated = true, select = false })", + ), + ( + // The same buffer the panel already shows: `replacing` is false, + // so this lands in a DIFFERENT arm of the same function, which a + // row against the replacing arm alone would not exercise. + "display{side, dedicated} same buffer", + PANEL_ARRANGED, + "pmacs.window.display(pmacs.window.buffer(pmacs.window.panel()), + { side = 'bottom', dedicated = true, select = false })", + ), + ( + // NO panel at capture time: the preflight relaxes because + // `side_window_for` is None (a side request would CREATE a panel, + // never fall back). The body then creates one dedicated, which + // makes the next side request fall back. + "display{side, dedicated} creating the panel", + "", + "pmacs.window.display(pmacs.buffer.create('*usurp*'), + { side = 'bottom', dedicated = true, select = false })", + ), +]; + /// Arrange one of the two reasons a side request falls back into a /// document window, and assert the arrangement took. /// @@ -644,25 +703,8 @@ fn a_panel_commit_that_falls_back_runs_the_document_preflight() { /// /// # One row per WRITE SITE, not per call spelling /// -/// A single row is exactly what would let a second route keep the -/// defect — which is not hypothetical: review found the -/// `display{side, dedicated}` route *after* `set_params` was specified. -/// So the rows are chosen to hit each distinct write to -/// `Window::params.dedicated` that a side window can receive, rather -/// than each way of phrasing the call: -/// -/// | row | reaches | -/// |---|---| -/// | `set_params` | the direct write in the binding (Q#BP2c) | -/// | `display{side, dedicated}` replacing | `apply_placement`'s **replacing** arm | -/// | `display{side, dedicated}` same buffer | its **non-replacing** arm | -/// | `display{side, dedicated}` with no panel | its **created** arm | -/// -/// The three `display` rows converge on one guard, in `display_buffer` — -/// `apply_placement` has exactly one caller, so every request-driven -/// dedication passes through it. They are still separate rows because -/// that convergence is a property of today's call graph, and a row per -/// arm fails loudly if it stops holding. +/// The rows are `DEDICATION_ROUTES`, which documents why it is a write-site +/// enumeration rather than a list of call spellings. /// /// The rest of the enumeration is **unreachable rather than refused** /// and is recorded in `EditorCore::panel_commit_dedication_refusal`, @@ -678,42 +720,7 @@ fn a_panel_commit_that_falls_back_runs_the_document_preflight() { /// drops rows 2–4 — and every other test in this file still passes. #[test] fn a_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt() { - // (label, panel arrangement before the capture, attempted mutation) - let routes: [(&str, &str, &str); 4] = [ - ( - "set_params", - PANEL_ARRANGED, - "pmacs.window.set_params(pmacs.window.panel(), { dedicated = true })", - ), - ( - "display{side, dedicated} replacing", - PANEL_ARRANGED, - "pmacs.window.display(pmacs.buffer.create('*usurp*'), - { side = 'bottom', dedicated = true, select = false })", - ), - ( - // The same buffer the panel already shows: `replacing` is - // false, so this lands in a DIFFERENT arm of the same - // function, which a row against the replacing arm alone - // would not exercise. - "display{side, dedicated} same buffer", - PANEL_ARRANGED, - "pmacs.window.display(pmacs.window.buffer(pmacs.window.panel()), - { side = 'bottom', dedicated = true, select = false })", - ), - ( - // NO panel at capture time: the preflight relaxes because - // `side_window_for` is None (a side request would CREATE a - // panel, never fall back). The body then creates one - // dedicated, which makes the next side request fall back. - "display{side, dedicated} creating the panel", - "", - "pmacs.window.display(pmacs.buffer.create('*usurp*'), - { side = 'bottom', dedicated = true, select = false })", - ), - ]; - - for (label, arrange, attempt) in routes { + for (label, arrange, attempt) in DEDICATION_ROUTES { let s = editor(); exec(&s, arrange); @@ -795,6 +802,266 @@ fn a_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt() { } } +/// **N** — a **nested** `commit_to` cannot mask the restriction an +/// enclosing `"panel"` commit is relying on (revision 9). +/// +/// # The defect +/// +/// Revision 8 held **one** contract on the core, and entering a commit +/// *replaced* it for the inner body's extent, restoring it afterwards +/// (`ScopedFrontend::enter`). So the guarantee above had a hole exactly +/// one call wide: +/// +/// ```lua +/// pmacs.window.commit_to(outer, function() -- "panel": relaxed preflight +/// pmacs.window.commit_to(inner, function() -- "document": MASKS the outer contract +/// pmacs.window.set_params(pmacs.window.panel(), { dedicated = true }) +/// end) -- ...and succeeds +/// pmacs.window.display(result, { side = "bottom" }) +/// end, "panel") -- ...which now FALLS BACK +/// ``` +/// +/// Every step is legal on its own. The outer commit's relaxed preflight +/// was granted because this frontend places side requests in the panel; +/// the nested commit put the refusal that keeps that true out of force; +/// and the outer commit then resumed and overwrote the user's newer +/// document buffer — the original P1a failure, reached through one extra +/// call rather than through a route the write-site enumeration missed. +/// +/// **What this invalidated, precisely.** Not §3's enumeration of +/// dedication write sites: all four rows below are the same writes, and +/// each is still guarded. What was wrong was the claim that the guard was +/// **in force for the whole outer body**. So the fix composes contracts +/// instead of replacing them — the strictest active restriction wins — +/// and the enumeration is inherited unchanged. +/// +/// **A late refusal would not have been a fix**, and revision 7 was +/// already rejected for being one: by the time the outer commit resumes, +/// the nested callback has already dedicated the slot. The dedication has +/// to be *prevented*, which is why this asserts on the nested attempt and +/// on the slot's state, not merely on where the outer result landed. +/// +/// # Why the rows are the same four +/// +/// A fix that reinstated the outer contract for only one write site would +/// pass a single-row version of this. `DEDICATION_ROUTES` therefore drives +/// both depths, so a route guarded at depth 1 and not through a nested +/// scope fails loudly. +/// +/// *Mutation:* restore `push_commit_contract`/`exit_commit_contract` to a +/// single swapped slot (revision 8's `enter_commit_contract`) and only +/// this test fails. +#[test] +fn a_nested_commit_cannot_mask_an_outer_panel_restriction() { + for (label, arrange, attempt) in DEDICATION_ROUTES { + let s = editor(); + exec(&s, arrange); + + let panel_before = s.core.borrow().side_window_for(FrontendId::LOCAL); + if let Some(panel) = panel_before { + assert!( + !dedicated(&s, panel), + "{label}: the slot must start UNDEDICATED, or the outer preflight would \ + have refused and this row would be re-proving the preflight" + ); + } + + capture(&s); + let doc = local_window(&s); + // The user's newer buffer: what the outer commit overwrites if its + // side request is made to fall back. + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + + // The nested commit is a plain, valid, DOCUMENT-profile commit — + // its destination is captured fresh inside the outer body, so it + // passes all four checks on its own account and its callback + // really runs. Nothing about it is malformed; that is the point. + commit_body( + &s, + Some("'panel'"), + &format!( + "local inner = pmacs.window.capture_destination() + nested_ran = false + local caught, a = pcall(pmacs.window.commit_to, inner, function() + nested_ran = true + {attempt} + end) + nested_raised = (not caught) and tostring(a) or nil + {PANEL_BODY}" + ), + ); + + assert!( + ran(&s), + "{label}: the outer body must have run -- its preflight could not have known" + ); + assert!( + eval::(&s, "return nested_ran"), + "{label}: the nested callback must have run -- a nested commit refused at its \ + own preflight would prove nothing about masking" + ); + + // 1. THE MUTATION IS STILL REFUSED, inside the nested scope. + let nested_raised: Option = eval(&s, "return nested_raised"); + let nested_raised = nested_raised.unwrap_or_else(|| { + panic!( + "{label}: the enclosing \"panel\" restriction must survive the nested \ + commit -- masking it is revision 9's defect" + ) + }); + assert!( + nested_raised.contains("cannot dedicate the side window"), + "{label}: the refusal must name the operation it is refusing; got \ + {nested_raised:?}" + ); + assert!( + nested_raised.contains("\"panel\" commit_to"), + "{label}: and why it is refused here specifically; got {nested_raised:?}" + ); + + // 2. THE SLOT IS STILL UNDEDICATED. Prevention, not detection: + // the outer commit resumes after the nested one returns, so a + // refusal that arrived then would already be too late. + let panel_after = s.core.borrow().side_window_for(FrontendId::LOCAL); + let panel_after = panel_after.unwrap_or_else(|| { + panic!("{label}: the outer body's own side display must have found a panel") + }); + assert!( + !dedicated(&s, panel_after), + "{label}: a refused mutation must not have happened -- the outer commit's \ + relaxed preflight rests on the slot still being free" + ); + if let Some(before) = panel_before { + assert_eq!( + panel_after, before, + "{label}: the refusal must not have replaced the side slot" + ); + } + + // 3. THE OUTER COMMIT'S DESTINATION IS INTACT: its result went to + // the PANEL, and the user's newer document buffer survived. + // This is the assertion that fails loudest on the unfixed + // tree — the outer side request falls back and `*result*` + // lands on top of `*newer*`. + assert!( + ok(&s), + "{label}: the outer commit must still be accepted; got {:?}", + reason(&s) + ); + assert_eq!(raised(&s), None, "{label}: the outer commit must not raise"); + assert_eq!( + name_in(&s, panel_after), + "*result*", + "{label}: the outer \"panel\" commit's result belongs in the panel" + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "{label}: and the user's newer buffer must survive" + ); + } +} + +/// **P** — nesting itself is **not** forbidden: a nested `commit_to` that +/// touches no dedication runs, returns its value, and leaves the enclosing +/// restriction exactly as it found it. +/// +/// The other acceptable shape for revision 9's fix was to refuse a nested +/// `commit_to` outright. That closes the hole by forbidding a construction +/// no rule objects to — `commit_to` is public Lua API whose whole purpose +/// is to let a continuation say where its result belongs, and a body that +/// commits to a *second* destination (a diff beside a status panel, say) +/// is the shape #227's adoption is heading for. Only the **restriction** +/// needed preserving, so only the mutation is refused. +/// +/// Three things are pinned, and the third is the one a `Vec::pop`-shaped +/// fix would get wrong: +/// +/// 1. the nested commit is accepted, its body runs, and its result value +/// comes back through both frames; +/// 2. the enclosing restriction is back in force **after** the nested +/// commit returns — not cleared with it; +/// 3. **outside** every commit, dedication is ordinary and allowed — +/// otherwise the fix would have leaked a permanent restriction onto the +/// editor. +/// +/// *Mutation:* refuse nested `commit_to` at the attempt, and this fails +/// while the masking test above still passes — which is what makes the two +/// a pair rather than one test written twice. +#[test] +fn an_ordinary_nested_commit_still_runs_and_restores_the_outer_restriction() { + let s = editor(); + exec(&s, PANEL_ARRANGED); + let panel = s + .core + .borrow() + .side_window_for(FrontendId::LOCAL) + .expect("the arrangement creates the panel"); + capture(&s); + + commit_body( + &s, + Some("'panel'"), + "local inner = pmacs.window.capture_destination() + -- A nested commit doing ordinary work: no dedication anywhere. + nested_ok, nested_value = pmacs.window.commit_to(inner, function() + pmacs.window.display(pmacs.buffer.create('*nested*'), { select = false }) + return 'inner-result' + end) + -- And the enclosing restriction is back afterwards. + local caught, a = pcall(pmacs.window.set_params, + pmacs.window.panel(), { dedicated = true }) + after_nested_raised = (not caught) and tostring(a) or nil", + ); + + assert_eq!(raised(&s), None, "the outer commit must not raise"); + assert!(ok(&s), "the outer commit must be accepted: {}", reason(&s)); + + // 1. The nested commit ran and its value came back through both frames. + assert!( + eval::(&s, "return nested_ok"), + "a nested commit that touches no dedication must be accepted -- forbidding all \ + nesting when only the restriction needed preserving is a behaviour regression" + ); + assert_eq!( + eval::(&s, "return tostring(nested_value)"), + "inner-result", + "the nested body's return value must come back through both commit frames" + ); + assert!( + buffer_exists(&s, "*nested*"), + "the nested body's own work must have happened" + ); + + // 2. The enclosing restriction is back in force after the nested + // commit returned -- popped, not cleared. + let after: Option = eval(&s, "return after_nested_raised"); + let after = after.expect( + "the enclosing \"panel\" restriction must be back in force once the nested commit \ + returns -- a fix that cleared the stack on the inner exit would leave the rest of \ + the outer body unguarded", + ); + assert!( + after.contains("cannot dedicate the side window"), + "and it must be the same refusal; got {after:?}" + ); + assert!(!dedicated(&s, panel), "the slot must still be undedicated"); + + // 3. OUTSIDE every commit, dedication is ordinary again: the guard + // must not have leaked a permanent restriction onto the editor. + exec( + &s, + "pmacs.window.set_params(pmacs.window.panel(), { dedicated = true })", + ); + assert!( + dedicated(&s, panel), + "outside a commit the field is writable as it always was (Q#BP2c)" + ); +} + /// **P** — a `"panel"` commit that falls back with a **still-valid** /// destination lands in the document window, exactly as it does today. /// @@ -809,10 +1076,11 @@ fn a_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt() { /// and then dropped the display on the floor would pass a weaker version /// of this. /// -/// *Mutation:* make `fallback_commit_refusal` refuse whenever a `"panel"` -/// commit falls back, instead of only when a document precondition fails. -/// Both rows fail here; every refusal test still passes, which is what -/// makes this the pin that stops the fix over-reaching. +/// *Mutation:* make `commit_destination_refusal` refuse outright whenever +/// a `"panel"` commit could fall back, instead of holding it to the +/// document preconditions. Both rows fail here; every refusal test still +/// passes, which is what makes this the pin that stops the fix +/// over-reaching. #[test] fn a_panel_commit_that_falls_back_with_a_valid_destination_still_lands() { for cause in ["not panel-capable", "side slot dedicated elsewhere"] { From 3b8e426f90b77cda25623099f2d69b7584a97dd2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 21:28:30 +0200 Subject: [PATCH 43/66] test(window): pin the cross-frontend exception, and fix two ledger counts panel_commit_dedication_refusal matches on `fid` as well as on the profile: a nested commit for a DIFFERENT frontend may dedicate that frontend's own side slot, because resolve_placement consults only the requesting frontend's panel_capable and its own one side window, so nothing done to B can change where A's side request lands. That promise was documented and unpinned. Both revision 9 nesting tests drive a single frontend, so the comparison is trivially true throughout them: deleting it, and making any outer "panel" contract globally restrictive, passed the whole file. a_nested_commit_for_another_frontend_may_dedicate_its_own_slot runs two frontends. While an outer "panel" commit for A is in force, a nested commit for B dedicates B's slot and is ALLOWED --- and B's slot is asserted really dedicated afterwards, not merely unrefused. The far side runs in the same test: A's slot stays undedicated and A's result still lands in A's panel, so the row cannot pass by having weakened the restriction generally. This is the suite's only POSITIVE row; every other asserts a refusal, which is the shape it was thinnest on. An exception only the doc comment knows about is one review round from being simplified out. Mutation-checked: deleting `&& contract.destination.frontend == fid` fails ONLY this test. Both single-frontend nesting tests pass under it, which is the evidence they are independent of the frontend match rather than merely looking so. journey_acceptance (47), dired_acceptance (31) and cargo test --lib (1920) stay green. Two ledger corrections, both section-local: * "Eight writes exist; five are reachable" then listed four. The fifth is quit_window's QuitAction::Restore --- the site proved unreachable and guarded anyway. It now appears in the list that justifies it, and the bullet counts what actually matters: all five are guarded. * The revision 9 mutation paragraph had the preservation counts REVERSED (journey 31 / dired 47). It is journey 47 / dired 31, matching the bullet further up and measured per target. The same reversal is in 394fa43's commit message; that is left as written rather than rewriting a pushed commit, and the ledger now says so where the numbers are, so a reader following the SHA takes the corrected pair. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 77 +++++++++++----- docs/destination-capture-framing.md | 15 ++++ tests/destination_capture_acceptance.rs | 115 ++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 23 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c81de48..2d8194f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -335,10 +335,15 @@ form: `register_frontend_view` has callers only in `daemon.rs` and core unit tests. - **Eight writes to `dedicated` exist** (`rg 'params\.dedicated\s*=' - src/`); **five are reachable**: `apply_placement`'s `Side` created / - replacing / non-replacing arms, and `set_params`. Two `Ordinary` arms - are harmless (their target is never a side window; one only ever - clears the flag) and one is a unit test. + src/`); **four are reachable and a fifth is guarded defensively** — + `apply_placement`'s `Side` created / replacing / non-replacing arms + and `set_params` are the reachable four, and `quit_window`'s + `QuitAction::Restore` is the fifth, proved unreachable below and + guarded anyway. **All five are guarded**, which is the count that + matters; listing four under the word "five" is what an earlier version + of this bullet did. Two `Ordinary` arms are harmless (their target is + never a side window; one only ever clears the flag) and one is a unit + test. - **The guards are sited where the property converges, not per caller.** All three `Side` arms are reached through `apply_placement`, which has **exactly one caller** — so one guard in `display_buffer` covers every @@ -383,10 +388,11 @@ authoritative tip** — the ref, not a SHA. Recover with (`pmacs.window.capture_destination()`, the `ViewDestination` rename, the profile argument); `d5a6170` is `tests/destination_capture_acceptance.rs`; `469d5c8` is the - revision-8 panel-profile correction plus the invalid-UTF-8 hole; the - commit below is revision 9's contract stack. **14 pins**, and both - preservation suites pass **unchanged** (journey 47, dired 31) — §7's - stop signal not firing rather than being suppressed. + revision-8 panel-profile correction plus the invalid-UTF-8 hole; + `394fa43` is revision 9's contract stack and the commit below adds its + cross-frontend pin. **15 pins**, and both preservation suites pass + **unchanged** (journey 47, dired 31) — §7's stop signal not firing + rather than being suppressed. - **HOW THE PANEL PROFILE IS ENFORCED, in one sentence so no earlier revision gets reinstated by someone reading only that document:** the preflight stays exactly where it was, and the mutations that would @@ -413,10 +419,17 @@ authoritative tip** — the ref, not a SHA. Recover with so a nested scope rightly replaces them; a contract is a *restriction*, and replacing one suspends it. The guard stores a depth and truncates back to it, so an inner exit removes exactly the - contract it added and leaves every enclosing one in force. Matching - is per **frontend**: a nested commit for a different frontend may - dedicate *its* side slot, which cannot change where this frontend's - side request lands. + contract it added and leaves every enclosing one in force. + - **Matching is per FRONTEND as well as per profile, and that is a + deliberate exception with its own positive pin.** A nested commit for + a different frontend may dedicate *its* side slot: `resolve_placement` + consults only the requesting frontend's `panel_capable` and its own + one side window, so nothing done to B can change where A's side + request lands. Pinned by + `a_nested_commit_for_another_frontend_may_dedicate_its_own_slot`, + which is the file's only row asserting that something is **allowed** + — every other asserts a refusal, and an exception only the doc + comment knows about is one review round from being simplified out. - **Prohibiting nested `commit_to` was the other candidate and was rejected.** It closes the hole by forbidding a construction no rule objects to — `commit_to` is public Lua API for saying where a @@ -516,17 +529,35 @@ authoritative tip** — the ref, not a SHA. Recover with And reverting the byte comparison to `to_str()?` fails the `invalid utf-8` row with mlua's conversion error, on content. - **Revision 9's, run across all three suites and the lib:** restore - `panel_commit_dedication_refusal` to reading only the innermost - contract (`.last()`, which is exactly revision 8's swapped slot) → - **only** `a_nested_commit_cannot_mask_an_outer_panel_restriction` - fails. The other 13 pins, `journey_acceptance` (31), - `dired_acceptance` (47) and `cargo test --lib` (1920) all stay green, - which is what makes the new test the pin for this defect and not a - restatement of the depth-1 one. Note the ordinary-nesting pin - deliberately survives that mutation — it exists to fail the *other* - candidate fix (prohibit nesting), so the two are a pair rather than - one test written twice. + **Revision 9's two, each isolating a different half of the rule:** + 1. restore `panel_commit_dedication_refusal` to reading only the + innermost contract (`.last()`, which is exactly revision 8's + swapped slot) → **only** + `a_nested_commit_cannot_mask_an_outer_panel_restriction` fails. + Note the ordinary-nesting pin deliberately survives this — it + exists to fail the *other* candidate fix (prohibit nesting), so the + two are a pair rather than one test written twice. + 2. delete `&& contract.destination.frontend == fid` from the same + scan, making any outer `"panel"` contract **globally** restrictive + → **only** + `a_nested_commit_for_another_frontend_may_dedicate_its_own_slot` + fails. Both single-frontend nesting tests pass under it, which is + the evidence they are independent of the frontend match rather than + merely looking so; the cross-frontend exception had no pin at all + before this row, since every other test in the file drives one + frontend. + + Both were run across all three acceptance suites and the lib: in each + case `journey_acceptance` (47), `dired_acceptance` (31) and + `cargo test --lib` (1920) stay green, along with every other pin in + this file. + + **The counts above are journey 47 / dired 31**, matching the bullet + further up. The mutation paragraph committed at `394fa43` had them + **reversed** in both the ledger and that commit's message; the ledger + is corrected here and the message is left as written, since rewriting + a pushed commit is worse than a footnote. A reader following that SHA + should take these numbers, not those. - **The public API #227 adopts against (Q#DC-5), pinned so it is a contract rather than an intention:** `pmacs.window.commit_to(dest, body [, profile])`. Profile is an diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index 2e19165..d6f868f 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -719,6 +719,21 @@ incidental: no arguments is what keeps capture profile-blind. force after the nested commit returns** (popped, not cleared), and **outside every commit dedication is ordinary again**, so the fix leaked no permanent restriction onto the editor. +- **THE CROSS-FRONTEND EXCEPTION IS PINNED POSITIVELY**, over **two** + frontends: while an outer `"panel"` commit for A is in force, a nested + commit for **B** dedicates **B's** side slot and is **allowed** — and + B's slot is asserted really dedicated afterwards, not merely + unrefused. The far side runs in the same test: A's slot is still + undedicated and A's result still lands in A's panel, so this cannot + pass by having weakened the restriction generally. **This is the one + row asserting that something is permitted**; every other in the suite + asserts a refusal, and without it, deleting the `fid` comparison — + making any outer panel contract *globally* restrictive — passes the + whole file, because both nesting rows above drive a single frontend. + The exception is real and not a convenience: `resolve_placement` + consults only the requesting frontend's `panel_capable` and its own + one side window, so nothing done to B can change where A's side + request lands. - **A `"panel"` commit that really lands in the panel still skips checks 2–4** — otherwise the fix has quietly collapsed the two profiles into one and the parameterization buys nothing. diff --git a/tests/destination_capture_acceptance.rs b/tests/destination_capture_acceptance.rs index 54fdf99..0ae179f 100644 --- a/tests/destination_capture_acceptance.rs +++ b/tests/destination_capture_acceptance.rs @@ -1062,6 +1062,121 @@ fn an_ordinary_nested_commit_still_runs_and_restores_the_outer_restriction() { ); } +/// **P** — the restriction is scoped to its **frontend**: a nested commit +/// for a *different* frontend may still dedicate that frontend's own side +/// slot (revision 9). +/// +/// `panel_commit_dedication_refusal` scans every contract in force, but it +/// matches on `fid` as well as on the profile, and that comparison is a +/// deliberate exception rather than an oversight: frontend B's side slot +/// has no bearing on where **A's** side request lands. `resolve_placement` +/// consults only the requesting frontend's `panel_capable` and its own one +/// side window, so a contract for A cannot be invalidated by anything done +/// to B. +/// +/// **This is a POSITIVE pin, which is the shape this suite is thinnest +/// on** — every other row asserts a refusal. Without it, deleting the +/// `fid` comparison and making any outer `"panel"` contract *globally* +/// restrictive passes the whole file: the two nesting tests above use one +/// frontend, so the comparison is trivially true throughout them. An +/// exception that only the doc comment knows about is one review round +/// away from being "simplified" out. +/// +/// The far side is still asserted in the same run: A's slot stays +/// undedicated and A's commit still lands in A's panel, so this cannot +/// pass by having weakened the restriction generally. +/// +/// *Mutation:* delete `&& contract.destination.frontend == fid` from +/// `panel_commit_dedication_refusal` and only this test fails. +#[test] +fn a_nested_commit_for_another_frontend_may_dedicate_its_own_slot() { + let s = editor(); + + // Frontend B: its own layout, its own undedicated panel, and a + // destination captured while it is the acting frontend. + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + exec( + &s, + "pmacs.window.display(pmacs.buffer.create('*b-panel*'), + { side = 'bottom', select = false }) + dest_b = pmacs.window.capture_destination()", + ); + let b_panel = s + .core + .borrow() + .side_window_for(COMPETITOR) + .expect("the competitor gets its own side slot"); + assert!( + !dedicated(&s, b_panel), + "B's slot must start undedicated, or the row would prove nothing" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + + // Frontend A: an undedicated panel, so its `"panel"` commit takes the + // relaxed preflight and the restriction is really in force. + exec(&s, PANEL_ARRANGED); + let a_panel = s + .core + .borrow() + .side_window_for(FrontendId::LOCAL) + .expect("the arrangement creates A's side slot"); + capture(&s); + let doc = local_window(&s); + exec( + &s, + "pmacs.window.switch_buffer(pmacs.buffer.create('*newer*'))", + ); + + commit_body( + &s, + Some("'panel'"), + &format!( + "b_ok, b_reason = pmacs.window.commit_to(dest_b, function() + pmacs.window.set_params(pmacs.window.panel(), {{ dedicated = true }}) + end) + {PANEL_BODY}" + ), + ); + + // 1. THE CROSS-FRONTEND DEDICATION IS ALLOWED. + assert_eq!( + raised(&s), + None, + "dedicating ANOTHER frontend's side slot must not be refused -- it cannot change \ + where this frontend's side request lands" + ); + assert!( + eval::(&s, "return b_ok == true"), + "the nested commit for B must be accepted: {}", + eval::(&s, "return tostring(b_reason)") + ); + assert!( + dedicated(&s, b_panel), + "B's slot must really be dedicated -- asserting only that nothing was refused \ + would pass on a call that was silently dropped" + ); + + // 2. AND A'S RESTRICTION IS UNWEAKENED: its slot is untouched and its + // commit still lands in its own panel rather than falling back. + assert!(ok(&s), "A's commit must be accepted: {}", reason(&s)); + assert!( + !dedicated(&s, a_panel), + "A's own slot must be untouched -- this row must not pass by having relaxed the \ + restriction for everyone" + ); + assert_eq!( + name_in(&s, a_panel), + "*result*", + "A's \"panel\" commit still belongs in A's panel" + ); + assert_eq!( + name_in(&s, doc), + "*newer*", + "and A's newer document buffer must survive" + ); +} + /// **P** — a `"panel"` commit that falls back with a **still-valid** /// destination lands in the document window, exactly as it does today. /// From 4654b940ffa10962c862e5b5986de3ee290491b6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:02:09 +0200 Subject: [PATCH 44/66] docs: carry the four-plus-one dedication count into the framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fb3974b corrected the ledger --- "eight writes exist; five are reachable" listed four, the fifth being quit_window's QuitAction::Restore, proved unreachable and guarded anyway --- but the framing kept the old count, and the framing is the artifact that outlives the ledger. Swept rather than patched at the two known lines. Every count claim about dedication routes, sites and writes now agrees with the §3 table, in the ledger's phrasing: four are reachable, a fifth is guarded defensively, and ALL FIVE ARE GUARDED --- the last being the count the safety argument actually runs on. * The section heading said "FIVE WRITES REACH DEDICATION". It now says four reach it and a fifth is guarded defensively, and the "found three more" arithmetic is spelled out (two further apply_placement arms plus the unreachable quit_window site) so the total is legible as five GUARDED rather than five reachable. * "all five reachable sites were momentarily unguarded together" (revision 8's masked contract) --- true of all five GUARDED sites, which is what that sentence means; the four reachable ones and the defensive fifth are now named there. * "Two live guards, five reachable sites" --- two live guards cover the four reachable sites; site 7 carries a third, defensive guard. There really are three call sites of panel_commit_dedication_refusal (editor_core.rs display_buffer and quit_window, lua_bindings/window_panel.rs set_params), so the old sentence undercounted guards while overcounting reachability. * Two "every site in it is still guarded" claims were literally false of sites 4, 5 and 8 (two harmless Ordinary arms and a unit test). Narrowed to every site that can dedicate the slot. * Table row 7's verdict now carries "guarded anyway, defensively", so the four-plus-one reads off the table itself. The old count is preserved as history and marked as such --- "not the current count" --- with the correcting SHA, so a reader who saw the earlier text knows which way the correction ran. The miscount had NOT propagated. Repo-wide grep for the phrasing finds it only here: DEDICATION_ROUTES in tests/destination_capture_acceptance.rs is a [_; 4] and its doc comment already said "four and not two"; the framing's own acceptance bullet already said "which is four and not two"; the ledger was fixed in fb3974b. No src/ or tests/ comment claims five reachable routes. (The suite's unrelated "five distinct refusals" of commit_to is a different count and is correct.) Documentation only. Gate run twice with --acceptance destination_capture_acceptance: fmt, clippy, lib-crdt, the destination capture suite, m4 and gpu green both times; diff-check clean. Each run had one wall-clock RATIO test fail under load from concurrent gates in sibling worktrees --- m8_2's 10K-entry render (457ms vs a 200ms budget) on the first, editor's composition_overhead_under_ten_percent (1.169) on the second --- a different test each time, and each passes in isolation on this tree (0.19s and ok respectively). Neither is reachable from a markdown edit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/destination-capture-framing.md | 46 ++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md index d6f868f..530da05 100644 --- a/docs/destination-capture-framing.md +++ b/docs/destination-capture-framing.md @@ -27,11 +27,11 @@ P1a failure, reached through one extra call. **What this invalidated, precisely.** *Not* §3's enumeration of dedication write sites. That enumeration was performed against the tree, -it is still complete, and every site in it is still guarded. What was -wrong was the surrounding claim — that the guard was **in force for the -whole outer body**. §3's "PREFLIGHT STAYS WHERE IT IS" paragraph and the -enumeration that follows it are therefore kept and **qualified**, not -withdrawn. +it is still complete, and every site in it that can dedicate the slot is +still guarded. What was wrong was the surrounding claim — that the guard +was **in force for the whole outer body**. §3's "PREFLIGHT STAYS WHERE +IT IS" paragraph and the enumeration that follows it are therefore kept +and **qualified**, not withdrawn. **The fix: contracts COMPOSE across nested scopes; the strictest active restriction wins.** The core holds a *stack* of contracts rather than one @@ -393,10 +393,18 @@ than aspirational:** - `panel_capable` has **no Lua binding at all** — checked across `src/lua_bindings/`. A body cannot make a frontend panel-incapable. -**FIVE WRITES REACH DEDICATION.** Review found the second *after* the -first was specified, which is the evidence that guarding one named call -site is not a design — and the enumeration below, performed against the -tree rather than by recall, found three more. The two review named +**FOUR WRITES REACH DEDICATION, AND A FIFTH IS GUARDED DEFENSIVELY.** +Review found the second *after* the first was specified, which is the +evidence that guarding one named call site is not a design — and the +enumeration below, performed against the tree rather than by recall, +found three more: two further `apply_placement` arms, plus +`quit_window`'s `QuitAction::Restore`, which step 6 proves *unreachable* +and which is guarded anyway. So **four are reachable, a fifth is guarded +defensively, and all five are guarded** — the last is the count the +safety argument actually runs on. (Historical note, not the current +count: earlier revisions of this section counted all five as +*reachable*. The table below has always said four; the ledger was +corrected in `fb3974b` and this section with it.) The two review named first are: 1. **`set_params`** — the writable-field path (`window_panel.rs:888`). @@ -416,12 +424,14 @@ ruled out. **Read "closed" as scoped to the question it answers (revision 9).** It answers *which writes can dedicate the side slot*, and that answer survived review of the nesting defect intact — every site below is real -and every one is still guarded. It says nothing about *when the guard is -in force*, and that is the axis revision 8 got wrong: a nested -`commit_to` used to mask the enclosing contract, so all five reachable -sites were momentarily unguarded together. A complete list of write sites -is not a complete argument until the guard's extent is stated too, which -is what the composing-contracts paragraph above now does. +and every one that can dedicate the slot is still guarded. It says +nothing about *when the guard is in force*, and that is the axis +revision 8 got wrong: a nested `commit_to` used to mask the enclosing +contract, so all five guarded sites — the four reachable ones and the +defensive fifth — were momentarily unguarded together. A complete list +of write sites is not a complete argument until the guard's extent is +stated too, which is what the composing-contracts paragraph above now +does. *Step 1 — how few pieces of state can matter.* `resolve_placement` reaches `Ordinary` from a side request through exactly two branches, so @@ -448,7 +458,7 @@ src/`, classified.* Eight sites, no exceptions: | 4 | `apply_placement`, `Ordinary` (`!fell_back`) | harmless — every `Ordinary` target is filtered `!is_side`, so it is never the slot | | 5 | `apply_placement`, `Ordinary` (clear) | harmless — only ever writes `false` | | 6 | `set_params` | reachable — the direct write (Q#BP2c) | -| 7 | `quit_window`, `QuitAction::Restore` | **unreachable**, see below | +| 7 | `quit_window`, `QuitAction::Restore` | **unreachable** — guarded anyway, defensively; see below | | 8 | an `EditorCore` unit test | not Lua-reachable | *Step 4 — the guards, sited where the property converges rather than at @@ -458,7 +468,9 @@ So one guard there covers every request-driven dedication, including routes that do not exist yet. `set_params` is a genuinely separate write and is guarded separately — dedication does *not* converge before the field itself, and that is stated rather than papered over. Two live -guards, five reachable sites. +guards over the four reachable sites; site 7 carries a third guard, +defensive because the site is unreachable (step 6), so **all five are +guarded**. *Step 5 — what was looked for and found NOT to be a route.* Closing the side window is **not** one: with no side leaf `side_window_for` returns From dda90a2c379d6bc7f698b0c15b3abc1095093ec9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 14:09:46 +0200 Subject: [PATCH 45/66] docs: frame worker identity Stage 1 (revision 1) COHERENCE.md section 0 names background-work ownership as one of two remaining thin ends of the golden journey, and section 20 puts it outside Priority 1 while conceding it is the last of that priority own work. Section 9 grades the worker model "mechanism without identity". The felt gap is narrower than the arc and is checkable: grep -c for spinner/progress/busy in src/statusline.rs returns 0, so section 3 promise of "visible asynchronous work" is false today unless the user knows to run M-x editor.list-workers. The git Stage 1 lane in flight right now records a deliberate negative section 9 impact for exactly this reason; this lane is the one that repays it. Two scouting findings shaped the staging rather than confirmed it. PendingJob carries eight fields, not the seven the audit lists, and the eighth doc comment cites section 9 by name as the reason identity belongs on the job and not in a side map. So this extends a merged decision instead of introducing one. pmacs.process.list filters to LineOriented, dating to the vterm Stage 1 commit, and three acceptance suites use #pmacs.process.list() as a leak detector. Widening that accessor to show terminal PTYs would inflate all three baselines. Making PTYs visible therefore moves to Stage 2 behind a separate accessor, which is a better answer than editing tests that are correctly detecting a semantic change. NO WIRE CHANGE, and that is load-bearing for scheduling: discovery Stage 2 holds the v22-to-v23 bump slot and git Stage 2 is queued behind it. The activity indicator is a fourth pmacs.statusline.register provider on the existing StatuslineSegments vector. The framing also flags a deliberate deviation from the audit rather than quietly taking it: section 9 names owner/purpose/parent together, and Stage 1 takes only the first two, because an unpopulated parent field reads as "no parent" rather than "not tracked". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 43 ++++ docs/worker-identity-framing.md | 380 ++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 docs/worker-identity-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index ad3851a..958216e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,6 +265,48 @@ 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. +## Worker identity Stage 1 (§9) — BRANCHED, framing in review + +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `worker-identity-stage1`**, base `githubsucks/main` @ +`4bc55e8` (the #225 merge). **`githubsucks/worker-identity-stage1` is +the authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout worker-identity-stage1`. + +- **Framing `docs/worker-identity-framing.md`, revision 1**, in review. + Scope: `COHERENCE.md` §9's "mechanism without identity", and journey + step 11 — the last of Priority 1's own work, sitting in another + section's arc. +- **NO WIRE CHANGE**, which is what lets this run beside the two lanes + already in flight. The statusline activity indicator is a **fourth** + `pmacs.statusline.register` provider (terminal/syntax/lsp are the + three existing adopters), evaluated per frame inside `paint_frame` + (`src/editor.rs:4560`) and riding the existing `StatuslineSegments` + vector. No variant, no bump. +- **Scope:** `owner`/`purpose` on `PendingJob` and `ProcessSpec` + through the single allocation funnel (`src/async_runtime.rs:746`, + which every dispatcher and `register_external` passes through), the + handler name that `pmacs.workers.dispatch` currently discards, the + `*workers*` rendering, and the indicator. +- **Two scouting findings that shaped the design**, both verified: + `PendingJob` carries **eight** fields, not the audit's seven, and the + eighth's doc comment **cites §9 by name** as the reason identity + belongs on the job rather than in a side map — so this extends a + merged decision. And **`pmacs.process.list` filters to + `LineOriented`** (`src/lua_bindings/mod.rs:8980`), with **three + acceptance suites using `#pmacs.process.list()` as a leak detector**, + so making terminal PTYs visible is deferred to Stage 2 with a + separate accessor rather than by widening this one. +- **Deliberate deviation from the audit, flagged for review:** §9 names + owner/purpose/**parent** together as the prerequisite; Stage 1 takes + only the first two. A parent needs an ambient "currently-running job" + context, and an unpopulated `parent` reads as "no parent" rather than + "not tracked" (Q#W-5). +- **Gates:** `scripts/gate --acceptance `. No + `--protocol`. + ## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED** **PR #228** — https://github.com/levineuwirth/pmacs/pull/228. Opened @@ -726,6 +768,7 @@ authoritative tip** — the ref, not a SHA. Recover with sweep step each fail the suite. ||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last) ||||||| parent of 312ec7a (docs: frame Discovery Stage 2 (revision 2) — M-x rows) +||||||| parent of 8f86908 (docs: frame worker identity Stage 1 (revision 1)) ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md new file mode 100644 index 0000000..9f9c38b --- /dev/null +++ b/docs/worker-identity-framing.md @@ -0,0 +1,380 @@ +# Worker identity — Stage 1: what is running, and who asked for it + +**Status: framing pass, revision 1. Pre-implementation. Awaiting +approval.** + +--- + +## 1. Why this, and why now + +`COHERENCE.md` §9 grades the worker model **mechanism without +identity**, and §0 names **step 11 (background-work ownership)** as one +of the two remaining thin ends of the golden journey. §20 Priority 1 is +blunt about where that leaves things: + +> **The remaining thin end is no longer inside this priority.** Step 1 +> is install, which is **P8**; step 11 is background-work ownership, +> which is §9. + +So this is the last of Priority 1's own journey, sitting in another +section's arc. Everything else P1 named has landed. + +**The felt gap is smaller and sharper than the arc.** §9's audit ends +with a claim that is checkable, and I checked it: + +> **No progress indicator exists anywhere** — no statusline spinner, no +> busy count. + +`grep -c -i "spinner\|progress\|busy" src/statusline.rs` returns **0**. +So §3's promise of "visible asynchronous work" is **false today** unless +the user knows to run `M-x editor.list-workers`. Every build, LSP index, +grep, parse and — as of the lane merging beside this one — every `git +status` runs with no indication that anything is happening at all. + +**And the git Stage 1 lane in flight right now makes it worse, by its +own admission.** `docs/git-integration-framing.md` Q#G-5 states it +plainly: git runs as a spawned process, spawned processes do not appear +in `*workers*`, and the lane therefore "adds a fifth thing that runs in +the background and is not attributable from one place". It accepted that +cost because these are short-lived reads. This lane is the one that +repays it. + +## 2. Ground truth + +Scouted in the tree, not recalled from the audit — and the audit has +drifted in one place, recorded below. + +- **The audit's `PendingJob` field list is stale, and the drift is + informative.** §9 lists seven fields; the struct + (`src/async_runtime.rs:367-411`) carries **eight**. The addition is + `resource: Option`, from dired Stage 2a — and **its doc + comment cites `COHERENCE.md` §9 by name** as the reason it is a field + on the job rather than a side map: + + > `COHERENCE.md` §9 is why this is a field on the job and not a side + > map — the parse job→buffer link already lives in a side map and §9 + > names that as the defect. + + So the precedent for putting identity **on the job** is already set, + already argued, and already merged. This lane extends a decision + rather than introducing one. + +- **There is a SINGLE allocation funnel, and that is what makes this + tractable.** Every job in the system is born in `allocate` + (`src/async_runtime.rs:746`), which delegates to + `allocate_with_resource` (`:757`). The ten `dispatch_*` methods + (`:803`–`:980`) and `register_external` (`:1011`, used by MCP and LSP) + all pass through it. An identity field added there reaches every job + by construction — there is no second birth site to miss. + +- **The two-function split is itself a warning.** `allocate_with_resource` + exists only because one prior lane needed one extra parameter. A + second lane doing the same produces + `allocate_with_resource_and_identity`, and a third produces something + worse. This is the point to collapse it (Q#W-1). + +- **`JobKind` is still a closed 12-variant enum** + (`src/async_runtime.rs:305-343`) — Sleep, ComputeSum, EmitN, Grep, + Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove, McpRequest, + LspRequest. Confirmed unchanged since the audit. + +- **A third-party job's own name is retained nowhere.** + `pmacs.workers.dispatch(name, args, opts)` + (`builtin/runtime/async.lua:369`) looks `name` up in a `handlers` + table and calls it; the handler must itself call one of the builtin + dispatchers, so the job records that builtin's `JobKind` and **`name` + is discarded at the call**. The audit's "every third-party job renders + under a builtin's label" is exact, and the fix is cheap: the name is + in hand at the one place that throws it away. + +- **`ProcessSpec` has one identity field and it is a convention** + (`src/process.rs:193-235`): `label: String`, documented as + "human-readable ... surfaced in events and the `pmacs.process.list` + output". No owner, no purpose, no parent. Callers spell it however + they like (`lsp:{name}`, a terminal buffer name). + +And the two findings that actually shape the design: + +- **A statusline provider API already exists, with three Lua adopters.** + `pmacs.statusline.register` is live in `terminal.lua:477`, + `syntax.lua:551` and `lsp.lua:1145`, taking + `{ name, side, priority, face, fn(ctx) }` and returning a string or + `nil`. An activity indicator is a **fourth registration**, not a new + mechanism. + + **And it is evaluated per frame**: `evaluate_statusline` is called + inside `paint_frame` (`src/editor.rs:4560`), before the long mutable + core borrow. So an indicator updates while work is in flight without + any new tick machinery — and, decisively for scheduling, **without + touching the wire**. `EvaluatedStatuslineSegment` is already + `Vec`-valued on an existing message; a fourth provider adds an element, + not a variant. + +- **`pmacs.process.list` deliberately hides terminal PTYs, and + un-hiding them is NOT free.** The binding filters to + `AnsiParserProfile::LineOriented` + (`src/lua_bindings/mod.rs:8980-8984`). `git log -S` dates that filter + to `bbc1f33 feat(vterm): add Stage 1 terminal core` — terminals were + excluded on purpose. + + **Three acceptance suites use `#pmacs.process.list()` as a leak + detector**: `tests/m6_8_multi_repl_acceptance.rs:385`/`:459` ("size + must not grow across cycles"), `tests/compile_mode_acceptance.rs:133`/ + `:458` ("process list returns to baseline"), and + `tests/lean4_stage1_acceptance.rs:327`/`:349`. **Removing the filter + would inflate every one of those baselines by each open terminal.** + + This is why §9's "a terminal PTY appears in no user-visible activity + view" is a real defect with a **non-obvious fix**, and why this lane + does not casually widen the existing accessor (Q#W-4). + +## 3. The staging, and why the line falls where it does + +§9's full statement wants owner, workspace, buffer, parent, children, +latency class, cancellation scope, resource budget, execution location, +progress, and failure attribution. **Two of those cannot be built at +all right now**: `Workspace` is §7, graded *missing*, and `Location` is +§8, graded *missing (architecture ready)*. A lane that added +`workspace: Option` would be adding a field typed on a +thing that does not exist. + +**Stage 1 (this lane): identity on the job and the process, and the +first indicator. NO WIRE CHANGE.** + +- `owner` and `purpose` on `PendingJob`, carried through the single + allocation funnel, and on `ProcessSpec` alongside the existing + `label`. +- `pmacs.workers.dispatch` stops discarding the registered handler name. +- `*workers*` renders owner and purpose. +- **A statusline activity indicator** — the fourth provider + registration, and the part a user feels on day one. + +**Stage 2 (separate lane): join the planes.** One activity view over +jobs, processes, LSP servers and terminals. This is what Stage 1's +identity is *for* — the audit's own conclusion is that "the four views +exist precisely because there is no common key to merge them on". It +also owns the terminal-visibility decision (Q#W-4), because that is a +question about the unified view, not about the accessor. + +**Stage 3 (unscheduled): the tree and scoped cancellation.** +`parent`/`children`, and cancel-by-owner / by-buffer / by-subtree. This +needs an ambient "currently-running job" context so a child dispatched +inside a job can find its parent without every call site threading it — +a real mechanism with its own failure modes, and the reason parent is +**not** in Stage 1 (Q#W-5). + +**Workspace and location are never this arc's**, at any stage. They +arrive from §7 and §8 and this arc consumes them. + +**The line falls at the wire on purpose, and it is again a scheduling +decision.** The discovery Stage 2 lane holds the v22→v23 bump slot, and +git Stage 2 is already queued behind it. `PROTOCOL_VERSION` is a strict +serialization point. Stage 1 here touching no wire is what lets it run +beside both. + +## 4. Coherence impact (§20) + +- **§9 worker ownership — the direct target**, and specifically the + audit's named prerequisite: *"Owner/purpose/parent fields on the job + and process specs are the prerequisite; the unified view and the + ownership tree fall out of them."* Stage 1 takes owner and purpose. +- **Journey step 11 — the direct target.** §0 names background-work + ownership as one of two remaining thin ends. This does not close the + step (Stage 2's unified view is most of that) but it is the first + thing that makes work *visible*, which is what step 11 is about. +- **§3 zero-configuration state:** repairs a claim that is currently + false. "Visible asynchronous work" becomes true by default, with no + configuration and no command to know about. +- **Interaction islands (§6): none added.** The indicator is a + statusline provider; it intercepts no keys and adds no precedence + rung. +- **§14 workbench primitives: untouched.** `*workers*` already exists; + this changes what it renders, not what renders it. +- **Config registry:** one setting at most, and my vote is a *visibility* + toggle only (Q#W-6). +- **The debt this repays is named and dated.** `git-integration-framing.md` + Q#G-5 recorded a deliberate negative §9 impact. This lane does not + fully discharge it — a labelled process is still not in `*workers*` + until Stage 2 — but it makes the label structured rather than + conventional, which is the prerequisite. + +## 5. Open questions + +### Q#W-1 — how is identity supplied at the allocation funnel? + +The existing shape is `allocate(kind, supersede, stream)` delegating to +`allocate_with_resource(kind, supersede, stream, resource)`. Adding two +more positional parameters gives a five-argument function and a +six-argument variant, and the next lane adds a seventh. + +*My vote: **collapse the pair into one funnel taking a struct***, e.g. +`allocate(JobSpec { kind, supersede, stream, resource, identity })`, +with `JobSpec` carrying a `Default`-derived constructor so the ten +dispatchers read as named-field literals rather than positional soup. +Ten call sites plus `register_external` is a bounded, mechanical edit, +and it removes the `_with_resource` wart rather than adding beside it. + +**The counter-argument, which is real:** this touches every dispatcher +in a lane whose subject is identity, which is scope the reviewer did not +ask for. **If review prefers the minimal edit**, the alternative is one +more parameter on the existing pair, and the collapse becomes its own +small lane. I would rather be told than assume. + +### Q#W-2 — what IS an owner? **(the hard one)** + +This is the question that decides whether the field is useful or +decorative, and I do not think it should be answered by whatever is +convenient at the call site. + +Candidates: the **package** that registered the code (P3's +`CurrentlyLoadingPackage` signal already exists and §20 P3 names +owner-carrying registrations as its work unit); the **command** that +the user invoked; or the **subsystem** (lsp, syntax, git, compile). + +*My vote: **`owner` is a package-or-builtin identity, `purpose` is the +human sentence.*** Concretely: `owner = "lsp"` / `purpose = "indexing +src/editor.rs"`. The reasons: + +- It is the only one of the three that a **third party** can be + attributed by, which is the whole point of attribution — a user + wanting to know why their editor is busy is usually asking *whose + code* is doing it. +- It aligns this arc with P3 rather than duplicating it. §20 says P3's + ownership arc "unblocks ... package-scoped task cancellation in §9", + so the two are meant to share a notion of owner. + +**Named risk, stated rather than hidden:** P3 has not been built, so +Stage 1 populates `owner` from a **static per-subsystem constant** at +each dispatcher, not from a live package signal. That is honest for +builtins and gives third-party Lua nothing better than today until P3 +lands. **If review thinks a field that third parties cannot populate is +premature, deferring `owner` and shipping only `purpose` is a coherent +smaller lane** — and it would still fix the indicator, which is the felt +part. + +### Q#W-3 — what does the indicator actually show? + +*My vote: **a count with the busiest purpose, and nothing when idle*** +— e.g. `⋯2 lsp: indexing`, absent entirely at zero. + +- **Absent at zero, not `0 jobs`.** A statusline segment that is always + present costs width forever to say "nothing is happening". The + existing providers already return `nil` to render nothing + (`lsp.lua:1156`), so this is the established idiom. +- **A count, not a spinner.** A spinner needs an animation frame clock + and says only "something"; a count says how much. Per-frame evaluation + makes either possible, so this is a product choice, not a constraint. +- **Not names plural.** One purpose keeps it to a bounded width; the + full list is what `*workers*` is for. + +### Q#W-4 — do terminal PTYs become visible in Stage 1? + +**No — and the reason is evidence, not caution.** `pmacs.process.list` +filters to `LineOriented`, and three acceptance suites assert on +`#pmacs.process.list()` as a leak baseline (§2). Widening that accessor +would inflate all three with every open terminal, and "fix the tests" +is the wrong response to a test that is correctly detecting a semantic +change. + +*My vote: **leave the accessor alone in Stage 1**, and let Stage 2's +unified view introduce a **separate** enumeration that includes PTYs.* +The leak detectors keep asserting what they were written to assert; the +new surface answers the new question. Two accessors with different +contracts is better than one accessor whose meaning silently changed +under its existing callers. + +### Q#W-5 — does `parent` belong in Stage 1? + +*My vote: **no**, and this is where I would most expect to be +overruled.* The audit names owner/purpose/**parent** together as the +prerequisite, so leaving one out is a deviation I should justify. + +The justification: owner and purpose are **values a dispatcher already +knows** at the call site. A parent is not — it is whatever job is +*currently running* when a child is dispatched, which means either an +ambient context (a mechanism, with re-entrancy and cleanup failure +modes) or threading a parameter through every intermediate layer. A +`parent` field that nothing populates is worse than no field: it renders +as `None` everywhere and reads as "this job has no parent" rather than +"this system does not track parents". + +Stage 3 builds the ambient and the field together, where the field can +be tested by a populated case. + +### Q#W-6 — is any of this configurable? + +*My vote: **one boolean, `ui.activity-indicator` (default `true`), +through `pmacs.config.define`.*** §11 grades the registry "partial +(foundation only)" and this document's sibling framings have both +resisted speculative settings — but a permanently-visible statusline +element is different in kind from an internal behaviour: it costs width +on every frame, and "I do not want this in my modeline" is a +preference someone will genuinely hold on day one rather than a +hypothetical. `git.enabled` and `ui.line-wrap` are the precedent shape. + +No setting for owner/purpose capture itself — that is substrate, not +preference. + +## 6. Verification + +- **Every job carries an identity, asserted at the funnel, not per + dispatcher.** The point of a single allocation site is that one + assertion covers all ten dispatchers plus `register_external`; a test + that checks three dispatchers individually would pass while a + fourteenth added later carries nothing. +- **A `pmacs.workers.dispatch("name", ...)` job reports `"name"`**, not + the builtin `JobKind` label underneath it — the exact defect §9 names, + witnessed on a handler registered from Lua. +- **`register_external` jobs carry identity too** (MCP and LSP), since + they bypass the worker pool entirely and are the ones most likely to + be missed. +- **The statusline shows nothing at idle**, asserted as *absent + segment*, not as empty string — a zero-width segment still consumes a + separator. +- **The statusline shows a count while work is in flight**, witnessed + through the real per-frame evaluation path (`paint_frame`), not by + calling the provider function directly. A provider that works in + isolation and never gets evaluated is the failure this must exclude. +- **The indicator honours `ui.activity-indicator = false`** (Q#W-6), + witnessed as an absent segment with work genuinely in flight — the + case that separates "disabled" from "idle". +- **`#pmacs.process.list()` is UNCHANGED for every existing caller** + (Q#W-4). The three leak-detector suites + (`m6_8_multi_repl_acceptance`, `compile_mode_acceptance`, + `lean4_stage1_acceptance`) are the assertion, and they must pass + untouched. **If any of them needs editing, the design is wrong**, and + that is the signal to stop rather than to adjust a baseline. +- **A spawned process carries structured owner/purpose alongside its + existing `label`**, and **`label`'s current callers keep working + unchanged** — `lsp:{name}` and terminal buffer names are live + conventions with existing consumers. +- **Both frontends render the segment**, since it rides the existing + `StatuslineSegments` path — asserted for the grid TUI and + `pmacs-gpu`, because "it is on an existing message" is a claim about + the producer and says nothing about whether a consumer draws it. + +**What this will NOT prove:** that background work is attributable from +one place (that is Stage 2's unified view — this lane makes it +*possible*, not *done*), that a terminal PTY is visible anywhere +(Q#W-4), that cancellation can range over an owner (Stage 3), or that a +third-party package's own identity flows through (Q#W-2 — blocked on +P3). + +Gates via `scripts/gate --acceptance `. **No +`--protocol`**: this lane has no wire change, which is the property that +lets it run beside the two lanes already in flight. + +## 7. Not in scope + +`Workspace` and `Location` fields (§7/§8 — the entities do not exist). +`parent`/`children` and the ownership tree (Stage 3, Q#W-5). Scoped +cancellation of any kind — cancel-all, by-kind, by-buffer, by-owner, +by-subtree (Stage 3; there is nothing to range over until identity +exists). The unified activity view joining the four planes (Stage 2). +Making terminal PTYs visible (Stage 2, Q#W-4). Widening `JobKind` or +making it open — third-party jobs are attributed by `owner`/`purpose`, +which is the point, and reopening a closed wire-adjacent enum is a +separate decision. Latency classes and resource budgets (§9 names them; +neither has a consumer yet). Supersession coverage — §9 notes parse jobs +and MCP requests pass `None`, which is a real defect and a **different** +one. P3's package-ownership signal (Q#W-2 depends on it and says so). From e1ca382ea575a927c12551fbd1c899a1d9d9d080 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 14:34:59 +0200 Subject: [PATCH 46/66] docs: worker identity framing revision 2 --- drop owner, specify the name path Two review blockers, both correct, both about the same failure: a field or a claim that looks like attribution without being it. BLOCKER 1 --- owner is not honest before P3. Revision 1 proposed owner = package-or-builtin while populating it from static per-subsystem constants at each dispatcher. Those disagree. A generic dispatcher has no trustworthy knowledge of who invoked it, and pmacs.process.spawn is callable by any package, so a static "lsp" label is an origin or category and would confidently misattribute third-party work to a builtin at exactly the point section 9 wants attribution. owner is removed rather than renamed. origin or subsystem would be honest wording, but a second string field beside purpose, used to group the view, gets adopted as ownership by the next reader regardless of its name --- and it would squat on the slot P3 has to fill. Stage 2 needs a grouping key and should get a real one. No P3 alignment is claimed any more. BLOCKER 2 --- the handler name needs a mechanism, not a parameter. Revision 1 said the name was "in hand at the one place that throws it away". That was wrong about the call chain, and re-reading it is what showed why: dispatch(name) calls an arbitrary handler, which calls a Lua wrapper, which calls the Rust binding, and name is a parameter of none of them. Worse, async.lua:337-345 documents the wrapper layer as bypassable --- other runtime files are told to call their own raw _dispatch_* primitives --- so capturing in the wrappers would miss exactly the callers attribution exists for. Q#W-2 is rewritten as a contract: a dispatch-name stack owned by the async runtime and read at allocate, the same single funnel Q#W-1 collapses. Seven rules, including the two that decide whether it is better than nothing --- unwind-safe popping, because one erroring handler would otherwise poison every later dispatch with a stale name, and composition rather than replacement of a caller-supplied purpose, because replacing recreates blocker 1 in a new place. It also answers the objection it invites: why is this ambient allowed when Q#W-5 defers parent for needing one. Because they are different mechanisms --- this is a synchronous single-threaded extent with a deterministic pop, and parent needs a lifetime model spanning ticks and post-settlement callbacks. Verification takes the reviewer wording fix: presence is a type obligation now that purpose is non-optional in a private JobSpec, so the compiler proves it and the tests prove semantics at representative entry paths. The handler witness must be a registered handler calling a real dispatcher, not a synthetic funnel test. The title changed too: "who asked for it" overclaimed once owner left. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 22 ++- docs/worker-identity-framing.md | 322 +++++++++++++++++++++++--------- 2 files changed, 254 insertions(+), 90 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 958216e..03c3567 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,21 +275,33 @@ from #171 and #215. the authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout worker-identity-stage1`. -- **Framing `docs/worker-identity-framing.md`, revision 1**, in review. +- **Framing `docs/worker-identity-framing.md`, revision 2**, in review. Scope: `COHERENCE.md` §9's "mechanism without identity", and journey step 11 — the last of Priority 1's own work, sitting in another section's arc. +- **Revision 2 took two blockers.** `owner` is **removed entirely**: + populated from static per-subsystem constants it is an origin, not an + owner, and would misattribute third-party work at the exact point §9 + wants attribution. It is not retained under a safer name either — + `origin`/`subsystem` would be adopted as ownership by use and would + squat on the slot P3 must fill. And the handler-name recovery was + **respecified as a mechanism**: revision 1 claimed the name was "in + hand at the one place that throws it away", which was wrong about the + call chain (`dispatch` → arbitrary handler → Lua wrapper → Rust + binding, with the wrapper layer documented as bypassable). - **NO WIRE CHANGE**, which is what lets this run beside the two lanes already in flight. The statusline activity indicator is a **fourth** `pmacs.statusline.register` provider (terminal/syntax/lsp are the three existing adopters), evaluated per frame inside `paint_frame` (`src/editor.rs:4560`) and riding the existing `StatuslineSegments` vector. No variant, no bump. -- **Scope:** `owner`/`purpose` on `PendingJob` and `ProcessSpec` +- **Scope:** a **required** `purpose` on `PendingJob` and `ProcessSpec` through the single allocation funnel (`src/async_runtime.rs:746`, - which every dispatcher and `register_external` passes through), the - handler name that `pmacs.workers.dispatch` currently discards, the - `*workers*` rendering, and the indicator. + which every dispatcher and `register_external` passes through), a + runtime-owned dispatch-name ambient recovering the handler name that + `pmacs.workers.dispatch` currently discards, the `*workers*` + rendering, and the indicator. Non-optional so the **compiler**, not a + test, proves every caller supplied one. - **Two scouting findings that shaped the design**, both verified: `PendingJob` carries **eight** fields, not the audit's seven, and the eighth's doc comment **cites §9 by name** as the reason identity diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md index 9f9c38b..1b5fdb4 100644 --- a/docs/worker-identity-framing.md +++ b/docs/worker-identity-framing.md @@ -1,8 +1,22 @@ -# Worker identity — Stage 1: what is running, and who asked for it +# Worker identity — Stage 1: what is running, and what it is doing -**Status: framing pass, revision 1. Pre-implementation. Awaiting +*(Revision 1 was subtitled "and who asked for it". With `owner` +removed that title overclaimed the lane: it answers **what**, and — +under `pmacs.workers.dispatch` — **under which registered handler**. +Neither is who owns it.)* + +**Status: framing pass, revision 2. Pre-implementation. Awaiting approval.** +**Revision 2 removes `owner` and respecifies the handler-name path, +after review found the first dishonest and the second unbuildable as +described.** `owner` populated from static per-subsystem constants is +an *origin*, not an owner, and would misattribute third-party work at +exactly the point §9 wants attribution. And "the name is in hand at the +one place that throws it away" was **wrong about the call chain** — it +is thrown away across three layers, one of which callers are documented +to bypass. Both re-scouted in the tree. + --- ## 1. Why this, and why now @@ -78,14 +92,36 @@ drifted in one place, recorded below. Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove, McpRequest, LspRequest. Confirmed unchanged since the audit. -- **A third-party job's own name is retained nowhere.** - `pmacs.workers.dispatch(name, args, opts)` - (`builtin/runtime/async.lua:369`) looks `name` up in a `handlers` - table and calls it; the handler must itself call one of the builtin - dispatchers, so the job records that builtin's `JobKind` and **`name` - is discarded at the call**. The audit's "every third-party job renders - under a builtin's label" is exact, and the fix is cheap: the name is - in hand at the one place that throws it away. +- **A third-party job's own name is retained nowhere, and recovering it + is NOT cheap. Revision 1 said it was, and was wrong about the call + chain.** The full path, read rather than assumed: + + ``` + pmacs.workers.dispatch(name, args, opts) -- async.lua:369 + → handlers[name](args, opts) -- arbitrary Lua + → dispatch_grep(spec, opts) -- Lua wrapper, :312 + → async_mod._dispatch_grep(spec, supersede_key(opts), max_batch) + → the Rust binding → allocate() + ``` + + **`name` is not a parameter of any layer below the first.** The Rust + dispatchers accept job arguments, a supersede key and stream data — + nothing else. So revision 1's "change the allocation funnel and the + name is recovered" is false: changing `allocate` gives the name + nowhere to arrive *from*. + + **And the wrapper layer cannot be the capture point either.** + `async.lua:337-345` deliberately exposes `pmacs.workers._new_handle` / + `_new_stream` so that "other builtin runtime files (`pmacs.fs` in + M8.1, future siblings) can construct handles for ids dispatched + through **their own raw `_dispatch_*` primitives**". A handler that + goes straight to `async_mod._dispatch_*` bypasses `dispatch_grep` and + friends entirely — and those are precisely the callers doing + non-standard work, i.e. the ones attribution is for. + + The audit's "every third-party job renders under a builtin's label" + is exact. The mechanism that fixes it is Q#W-2, and it is a real + mechanism, not a parameter. - **`ProcessSpec` has one identity field and it is a convention** (`src/process.rs:193-235`): `label: String`, documented as @@ -138,17 +174,36 @@ all right now**: `Workspace` is §7, graded *missing*, and `Location` is `workspace: Option` would be adding a field typed on a thing that does not exist. -**Stage 1 (this lane): identity on the job and the process, and the -first indicator. NO WIRE CHANGE.** +**Stage 1 (this lane): a required `purpose` on the job and the process, +and the first indicator. NO WIRE CHANGE. NO `owner`.** -- `owner` and `purpose` on `PendingJob`, carried through the single - allocation funnel, and on `ProcessSpec` alongside the existing +- **`purpose`, non-optional**, on `PendingJob`, carried through the + single allocation funnel, and on `ProcessSpec` alongside the existing `label`. -- `pmacs.workers.dispatch` stops discarding the registered handler name. -- `*workers*` renders owner and purpose. +- **A dispatch-identity ambient** so `pmacs.workers.dispatch` stops + discarding the registered handler name (Q#W-2). +- `*workers*` renders `purpose`. - **A statusline activity indicator** — the fourth provider registration, and the part a user feels on day one. +**`owner` is deliberately absent, and revision 1 was wrong to include +it.** The proposal was `owner = "lsp"` populated from a static +per-subsystem constant at each dispatcher. But a generic dispatcher has +no trustworthy knowledge of who invoked it, and `pmacs.process.spawn` +is callable by any package — so a static subsystem label is an +**origin or category, not an owner**, and it would confidently +misattribute third-party work to a builtin at exactly the point §9 +wants attribution. A field that asserts a falsehood is worse than an +absent one: `*workers*` would *look* attributed while naming the wrong +party. + +**Nor is it retained under a safer name.** Calling it `origin` or +`subsystem` would be honest, but a second string field sitting beside +`purpose` and grouping the view would be *adopted* as ownership by the +next reader regardless of its name — and it would squat on the slot +P3's real package signal has to fill. Stage 2 needs a grouping key; it +should get a real one, not a placeholder promoted by use. + **Stage 2 (separate lane): join the planes.** One activity view over jobs, processes, LSP servers and terminals. This is what Stage 1's identity is *for* — the audit's own conclusion is that "the four views @@ -177,7 +232,11 @@ beside both. - **§9 worker ownership — the direct target**, and specifically the audit's named prerequisite: *"Owner/purpose/parent fields on the job and process specs are the prerequisite; the unified view and the - ownership tree fall out of them."* Stage 1 takes owner and purpose. + ownership tree fall out of them."* **Stage 1 takes ONE of the three + — `purpose`.** `owner` waits for P3 to supply a package signal worth + recording (§3); `parent` waits for Stage 3 (Q#W-5). Taking one of + three named prerequisites is a deviation from the audit, and it is + stated here rather than left to be noticed. - **Journey step 11 — the direct target.** §0 names background-work ownership as one of two remaining thin ends. This does not close the step (Stage 2's unified view is most of that) but it is the first @@ -195,8 +254,12 @@ beside both. - **The debt this repays is named and dated.** `git-integration-framing.md` Q#G-5 recorded a deliberate negative §9 impact. This lane does not fully discharge it — a labelled process is still not in `*workers*` - until Stage 2 — but it makes the label structured rather than - conventional, which is the prerequisite. + until Stage 2 — but it makes the process state *what it is doing* in + a required field rather than a caller-spelled convention. +- **No P3 alignment is claimed.** Revision 1 argued this lane aligned + with P3's ownership arc. With `owner` removed, it does not: P3 stays + entirely ahead of it, and this lane deliberately leaves that slot + empty rather than filling it with something P3 would have to displace. ## 5. Open questions @@ -208,11 +271,18 @@ more positional parameters gives a five-argument function and a six-argument variant, and the next lane adds a seventh. *My vote: **collapse the pair into one funnel taking a struct***, e.g. -`allocate(JobSpec { kind, supersede, stream, resource, identity })`, -with `JobSpec` carrying a `Default`-derived constructor so the ten -dispatchers read as named-field literals rather than positional soup. -Ten call sites plus `register_external` is a bounded, mechanical edit, -and it removes the `_with_resource` wart rather than adding beside it. +`allocate(JobSpec { kind, supersede, stream, resource, purpose })`, so +the ten dispatchers read as named-field literals rather than positional +soup. Ten call sites plus `register_external` is a bounded, mechanical +edit, and it removes the `_with_resource` wart rather than adding +beside it. + +**`JobSpec` is private, and `purpose` is non-optional.** Private +because the public dispatcher APIs should not grow a parameter every +time this arc adds a field; non-optional because that is what makes the +compiler, rather than a test, the thing that proves every caller +supplied one (§6). A `Default` impl would defeat exactly that, so +`purpose` is not defaulted even if other fields are. **The counter-argument, which is real:** this touches every dispatcher in a lane whose subject is identity, which is scope the reviewer did not @@ -220,42 +290,88 @@ ask for. **If review prefers the minimal edit**, the alternative is one more parameter on the existing pair, and the collapse becomes its own small lane. I would rather be told than assume. -### Q#W-2 — what IS an owner? **(the hard one)** +### Q#W-2 — the dispatch identity path **(rewritten in rev 2)** -This is the question that decides whether the field is useful or -decorative, and I do not think it should be answered by whatever is -convenient at the call site. +Revision 1 treated this as a parameter-passing detail. §2 shows it is +not: `name` dies at `pmacs.workers.dispatch` and nothing below it takes +a name, so the value must be carried *out of band* across an arbitrary +handler. -Candidates: the **package** that registered the code (P3's -`CurrentlyLoadingPackage` signal already exists and §20 P3 names -owner-carrying registrations as its work unit); the **command** that -the user invoked; or the **subsystem** (lsp, syntax, git, compile). +**The capture point is Rust, not Lua**, and the reason is the bypass in +§2. If the ambient lived in the Lua wrapper layer, a handler calling +`async_mod._dispatch_*` directly — the documented pattern for runtime +files with their own primitives — would produce an unattributed job, +and those are the callers attribution exists for. Putting it in the +runtime means it is read at `allocate`, **the same single funnel Q#W-1 +is already collapsing**. One mechanism, one site, no path around it. -*My vote: **`owner` is a package-or-builtin identity, `purpose` is the -human sentence.*** Concretely: `owner = "lsp"` / `purpose = "indexing -src/editor.rs"`. The reasons: +*My vote: **a dispatch-name stack owned by the async runtime***, with +`pmacs.workers.dispatch` bracketing its handler call through two +runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`). -- It is the only one of the three that a **third party** can be - attributed by, which is the whole point of attribution — a user - wanting to know why their editor is busy is usually asking *whose - code* is doing it. -- It aligns this arc with P3 rather than duplicating it. §20 says P3's - ownership arc "unblocks ... package-scoped task cancellation in §9", - so the two are meant to share a notion of owner. +**The contract, in full:** -**Named risk, stated rather than hidden:** P3 has not been built, so -Stage 1 populates `owner` from a **static per-subsystem constant** at -each dispatcher, not from a live package signal. That is honest for -builtins and gives third-party Lua nothing better than today until P3 -lands. **If review thinks a field that third parties cannot populate is -premature, deferring `owner` and shipping only `purpose` is a coherent -smaller lane** — and it would still fix the indicator, which is the felt -part. +1. **Extent is the SYNCHRONOUS handler call, and nothing more.** Push + before, pop after. Every job reaching `allocate` during that window + carries the name. +2. **Work dispatched later is NOT covered, deliberately.** A job + dispatched from an `on_complete` callback or a resumed coroutine + runs ticks later, outside the extent, and carries only its own + `purpose`. Pretending otherwise would need the asynchronous + lifetime mechanism this lane defers (Q#W-5). +3. **Nesting is a stack; innermost wins.** Handler `a` calling + `pmacs.workers.dispatch("b", …)` gives jobs allocated inside `b` the + name `b`, and restores `a` on return. +4. **Fan-out shares the name.** A handler dispatching five jobs + produces five jobs named alike. They *were* all dispatched under it; + that is the fact being recorded, not a collision. +5. **Unwind-safe, and this is the one that makes a naive version worse + than none.** A handler that errors must still pop — otherwise one + failure poisons every subsequent dispatch in the session with a + stale name, and the feature silently starts lying. `pmacs.workers. + dispatch` runs the handler under `pcall`, pops, and rethrows. +6. **Precedence over a caller-supplied purpose: COMPOSE, do not + replace.** Where the dispatch site supplied its own purpose, the + recorded value is `": "`; where it did not, the + recorded value is `""`. Replacing would recreate blocker 1 in + a new place — `dispatch_grep` supplies `"grep: …"`, and letting that + win would lose the third party again, while letting the name win + would discard the only description of the actual work. Composition + is capped at the innermost name by rule 3, so no unbounded chain. +7. **Outside any extent, nothing changes.** A builtin invoked directly + records its own `purpose`. + +**A known and accepted property, stated rather than discovered later:** +the ambient captures *causal* extent, not *intent*. If a handler +synchronously triggers unrelated work — an edit that schedules a parse +— that job is inside the window and takes the name. Within a +synchronous extent I think that is the honest reading ("this ran +because that handler ran"), and it is the only definition enforceable +at a single funnel. **If review disagrees, the alternative is +capture-at-the-Lua-wrapper**, which is narrower and misses the raw +`_dispatch_*` callers — a trade of false positives for false negatives, +and I would rather over-attribute inside a synchronous call than +silently drop the third-party case. + +**Why this ambient is admissible while Q#W-5's is not.** They are not +the same mechanism. This one is a synchronous, single-threaded, bounded +dynamic extent with a deterministic pop — a `let` binding in disguise. +A `parent` ambient must span a job's *asynchronous* lifetime, across +ticks, through callbacks that run after the parent settled. The first +is a stack; the second is a lifetime model. ### Q#W-3 — what does the indicator actually show? -*My vote: **a count with the busiest purpose, and nothing when idle*** -— e.g. `⋯2 lsp: indexing`, absent entirely at zero. +*My vote: **a count plus the oldest in-flight job's `purpose`, and +nothing when idle*** — e.g. `⋯2 lsp: indexing`, absent entirely at +zero. With `owner` gone (§3) `purpose` is the only identity there is, +which is also why it is required rather than optional. + +**Oldest, not newest or "busiest".** Revision 1 said "busiest", which +is not a defined quantity — jobs carry no cost estimate. Oldest is +computable from `dispatched_at`, which `PendingJob` already has, and it +answers the question a user actually asks of a stuck editor: *what is +taking so long?* - **Absent at zero, not `0 jobs`.** A statusline segment that is always present costs width forever to say "nothing is happening". The @@ -285,21 +401,31 @@ under its existing callers. ### Q#W-5 — does `parent` belong in Stage 1? -*My vote: **no**, and this is where I would most expect to be -overruled.* The audit names owner/purpose/**parent** together as the -prerequisite, so leaving one out is a deviation I should justify. +*My vote: **no.*** The audit names owner/purpose/**parent** together as +the prerequisite, and after revision 2 this lane takes only `purpose` — +so both omissions need justifying, not just this one. `owner`'s is in +§3; `parent`'s is here. -The justification: owner and purpose are **values a dispatcher already -knows** at the call site. A parent is not — it is whatever job is -*currently running* when a child is dispatched, which means either an -ambient context (a mechanism, with re-entrancy and cleanup failure -modes) or threading a parameter through every intermediate layer. A -`parent` field that nothing populates is worse than no field: it renders -as `None` everywhere and reads as "this job has no parent" rather than -"this system does not track parents". +`purpose` is a **value the dispatcher already knows** at the call site. +A parent is not — it is whatever job is *currently running* when a +child is dispatched. A `parent` field that nothing populates is worse +than no field: it renders as `None` everywhere and reads as "this job +has no parent" rather than "this system does not track parents". -Stage 3 builds the ambient and the field together, where the field can -be tested by a populated case. +**And the objection revision 2 has to answer, since it now builds an +ambient of its own (Q#W-2):** why is one admissible and not the other? +Because they are not the same mechanism. Q#W-2's extent is +synchronous, single-threaded, and bounded by one function call, with a +deterministic pop on both the normal and the error path. A `parent` +ambient must identify the running job *across ticks* — a job dispatched +from an `on_complete` callback should name the job whose completion +fired it, and that callback runs after the parent settled, on the main +thread, outside any dispatch call. That is a lifetime model, not a +stack, and it is Stage 3's subject rather than a field this lane can +add cheaply. + +Stage 3 builds the lifetime model and the field together, where the +field can be tested by a populated case. ### Q#W-6 — is any of this configurable? @@ -312,22 +438,42 @@ on every frame, and "I do not want this in my modeline" is a preference someone will genuinely hold on day one rather than a hypothetical. `git.enabled` and `ui.line-wrap` are the precedent shape. -No setting for owner/purpose capture itself — that is substrate, not +No setting for `purpose` capture itself — that is substrate, not preference. ## 6. Verification -- **Every job carries an identity, asserted at the funnel, not per - dispatcher.** The point of a single allocation site is that one - assertion covers all ten dispatchers plus `register_external`; a test - that checks three dispatchers individually would pass while a - fourteenth added later carries nothing. -- **A `pmacs.workers.dispatch("name", ...)` job reports `"name"`**, not - the builtin `JobKind` label underneath it — the exact defect §9 names, - witnessed on a handler registered from Lua. -- **`register_external` jobs carry identity too** (MCP and LSP), since - they bypass the worker pool entirely and are the ones most likely to - be missed. +- **Presence is enforced by the COMPILER, not by a test.** `purpose` is + non-optional in `JobSpec`, so a dispatcher that supplies none does not + build. Revision 1 claimed a single funnel assertion proved "every job + carries an identity"; **it does not** — a funnel test proves the + funnel stores what it was handed, and says nothing about whether + fourteen callers handed it anything meaningful. Presence is a type + obligation; the tests below are for *semantics*. +- **Representative entry paths assert the semantics**, one per distinct + shape rather than one per dispatcher: a pool dispatcher, an + `register_external` job (MCP/LSP bypass the worker pool entirely and + are the likeliest to be missed), and a spawned process. +- **A `pmacs.workers.dispatch("name", …)` job reports `"name"`**, and + the witness is **a handler registered from Lua that calls a real + dispatcher** — not a synthetic funnel test. A test that pushes the + ambient by hand proves the stack works and leaves the actual defect + (`name` dying in an arbitrary handler) unwitnessed. +- **The ambient survives a failing handler** (Q#W-2 rule 5): a handler + that errors, then a subsequent unrelated dispatch, asserting the + second job does **not** carry the first's name. This is the + regression that would otherwise appear as intermittent + misattribution long after the lane lands. +- **Nesting and fan-out** (rules 3–4): a handler dispatching two jobs + gives both its name; a handler dispatching through another registered + handler gives the inner jobs the inner name and restores the outer. +- **Composition, not replacement** (rule 6): a handler calling a + dispatcher that supplies its own purpose yields `": "` + — asserted for both halves, since a test on the prefix alone passes + when the description is dropped. +- **Work dispatched from an `on_complete` callback carries no handler + name** (rule 2) — the boundary of the extent, asserted deliberately + so it reads as designed rather than broken. - **The statusline shows nothing at idle**, asserted as *absent segment*, not as empty string — a zero-width segment still consumes a separator. @@ -344,7 +490,7 @@ preference. `lean4_stage1_acceptance`) are the assertion, and they must pass untouched. **If any of them needs editing, the design is wrong**, and that is the signal to stop rather than to adjust a baseline. -- **A spawned process carries structured owner/purpose alongside its +- **A spawned process carries a required `purpose` alongside its existing `label`**, and **`label`'s current callers keep working unchanged** — `lsp:{name}` and terminal buffer names are live conventions with existing consumers. @@ -356,9 +502,11 @@ preference. **What this will NOT prove:** that background work is attributable from one place (that is Stage 2's unified view — this lane makes it *possible*, not *done*), that a terminal PTY is visible anywhere -(Q#W-4), that cancellation can range over an owner (Stage 3), or that a -third-party package's own identity flows through (Q#W-2 — blocked on -P3). +(Q#W-4), that cancellation can range over an owner (Stage 3), or **that +any job is attributed to the PACKAGE responsible for it** — `purpose` +records what work is being done and, under `pmacs.workers.dispatch`, +which registered handler it ran under. Neither is package ownership, +which waits for P3 (§3). Gates via `scripts/gate --acceptance `. **No `--protocol`**: this lane has no wire change, which is the property that @@ -366,15 +514,19 @@ lets it run beside the two lanes already in flight. ## 7. Not in scope +**`owner`, in any spelling** — including `origin` or `subsystem` (§3). +The slot stays empty until P3 can fill it with a package signal; +nothing in this lane may be promoted into it later by use. `Workspace` and `Location` fields (§7/§8 — the entities do not exist). `parent`/`children` and the ownership tree (Stage 3, Q#W-5). Scoped cancellation of any kind — cancel-all, by-kind, by-buffer, by-owner, by-subtree (Stage 3; there is nothing to range over until identity exists). The unified activity view joining the four planes (Stage 2). Making terminal PTYs visible (Stage 2, Q#W-4). Widening `JobKind` or -making it open — third-party jobs are attributed by `owner`/`purpose`, -which is the point, and reopening a closed wire-adjacent enum is a -separate decision. Latency classes and resource budgets (§9 names them; +making it open — third-party jobs are described by `purpose`, which is +the point, and reopening a closed wire-adjacent enum is a separate +decision. Latency classes and resource budgets (§9 names them; neither has a consumer yet). Supersession coverage — §9 notes parse jobs and MCP requests pass `None`, which is a real defect and a **different** -one. P3's package-ownership signal (Q#W-2 depends on it and says so). +one. P3's package-ownership signal — §3 defers `owner` to it and makes +no claim of alignment with it. From 7730f87bbad46e1519984e9259fb862afc36d133 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 14:41:38 +0200 Subject: [PATCH 47/66] docs: worker identity framing revision 3 --- the ambient must not yield Review found that revision 2 asserted the property its whole design rested on. It called the dispatch-name extent "synchronous" and never checked. A registered handler is arbitrary Lua running inside pmacs.async and may call Handle:await(), which parks the coroutine with the name still pushed --- so every tick callback and every other coroutine allocating a job in the meantime inherits it. The existing tests already await inside pcall, so this is the ordinary shape of an awaiting handler, not a corner case. Rule 1 now enforces non-yieldability instead of assuming it, and the enforcement was already in this file: Handle:await refuses to run inside pmacs.window.commit_to (async.lua:87-90), with a comment giving exactly this reasoning --- yielding "would restore the scope while this coroutine is still parked". _in_dispatch_name_scope joins _in_commit_scope in the same place. Three details decide whether the guard holds, and all three are pinned: it rejects before the park, not after; it rejects unconditionally rather than only when the handle is incomplete, because a guard keyed on whether the job happened to finish first passes under test and fails intermittently in production; and it covers both yield points. That last one is a finding. pmacs.async.yield_to_next_tick (async.lua:243-245) also yields and is public. Guarding only await would have left the hole open through a second door. Which exposes Q#W-7: the existing commit_to guard has exactly that gap today. yield_to_next_tick carries no _in_commit_scope refusal, so Journey Stage 1a Q#JR14b invariant has a second entrance. Reported rather than patched, and reachability by a real caller is explicitly UNPROVEN --- it is a code reading, not a repro. My vote is to fix it in this lane since the lane already edits that function family, but it is another lane invariant so it is a question. Q#W-5 justification is rewritten rather than left standing. It argued parent was deferrable because its ambient would span asynchronous lifetimes while this one did not --- an argument revision 2 was not entitled to make, since its own ambient could be parked by any awaiting handler. With rule 1 the distinction is real: this extent cannot be suspended, and parent cannot be rescued by refusing to yield because yielding is the mechanism it needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 16 ++- docs/worker-identity-framing.md | 168 +++++++++++++++++++++++++++----- 2 files changed, 158 insertions(+), 26 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 03c3567..ce25317 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. the authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout worker-identity-stage1`. -- **Framing `docs/worker-identity-framing.md`, revision 2**, in review. +- **Framing `docs/worker-identity-framing.md`, revision 3**, in review. Scope: `COHERENCE.md` §9's "mechanism without identity", and journey step 11 — the last of Priority 1's own work, sitting in another section's arc. @@ -289,6 +289,20 @@ the authoritative tip** — the ref, not a SHA. Recover with hand at the one place that throws it away", which was wrong about the call chain (`dispatch` → arbitrary handler → Lua wrapper → Rust binding, with the wrapper layer documented as bypassable). +- **Revision 3 took a third blocker: the ambient's extent is not + synchronous.** A handler may `Handle:await()` and park with the name + still pushed, leaking attribution to unrelated later work. Rule 1 now + **enforces** non-yieldability, modelled on the existing + `_in_commit_scope()` refusal in `Handle:await` + (`builtin/runtime/async.lua:87-90`) — rejecting before the park, + unconditionally rather than only when a yield would occur, and + covering **both** yield points. +- **Q#W-7 — a pre-existing defect found while scouting that guard, and + reported rather than patched.** `pmacs.async.yield_to_next_tick()` + (`async.lua:243-245`) is public, yields, and carries **no** + `_in_commit_scope` refusal — so Journey Stage 1a's Q#JR14b invariant + has a second entrance. Reachability by a real caller is **unproven**. + Awaiting the user's call on whether this lane fixes it. - **NO WIRE CHANGE**, which is what lets this run beside the two lanes already in flight. The statusline activity indicator is a **fourth** `pmacs.statusline.register` provider (terminal/syntax/lsp are the diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md index 1b5fdb4..31f5230 100644 --- a/docs/worker-identity-framing.md +++ b/docs/worker-identity-framing.md @@ -5,9 +5,19 @@ removed that title overclaimed the lane: it answers **what**, and — under `pmacs.workers.dispatch` — **under which registered handler**. Neither is who owns it.)* -**Status: framing pass, revision 2. Pre-implementation. Awaiting +**Status: framing pass, revision 3. Pre-implementation. Awaiting approval.** +**Revision 3 closes a hole in revision 2's ambient: the extent it +called "synchronous" is not.** A registered handler is arbitrary Lua +and may `Handle:await()`, parking the coroutine with the name still +pushed so that unrelated later work inherits it. Rule 1 now **enforces** +non-yieldability rather than assuming it, following the guard this file +already carries for `pmacs.window.commit_to`. Scouting that guard +turned up a second yield point it does not cover — Q#W-7, a +pre-existing defect in another lane's invariant, reported rather than +patched in silence. + **Revision 2 removes `owner` and respecifies the handler-name path, after review found the first dishonest and the second unbuildable as described.** `owner` populated from static per-subsystem constants is @@ -129,6 +139,22 @@ drifted in one place, recorded below. output". No owner, no purpose, no parent. Callers spell it however they like (`lsp:{name}`, a terminal buffer name). +- **A dynamic scope that must not be yielded out of ALREADY EXISTS + here, guard and rationale included.** `Handle:await()` refuses to run + inside `pmacs.window.commit_to` (`builtin/runtime/async.lua:87-90`), + raising *"await: cannot await inside pmacs.window.commit_to; await + first, then commit"*. Its comment states the hazard in general terms: + yielding out of the extent "would restore the scope while this + coroutine is still parked, so the rest of the commit would resume + ambient". `commit_to` itself is "an RAII guard on the Rust stack" — + the same shape this lane needs. + +- **There are TWO yield points, not one.** `Handle:await()` yields at + `async.lua:95`; **`pmacs.async.yield_to_next_tick()` yields at + `async.lua:244`** and is public (`pmacs.async` is `async_public`, + `:247`). Any rule about a non-yieldable extent has to cover both. The + `commit_to` guard covers only the first — see Q#W-7. + And the two findings that actually shape the design: - **A statusline provider API already exists, with three Lua adopters.** @@ -290,13 +316,26 @@ ask for. **If review prefers the minimal edit**, the alternative is one more parameter on the existing pair, and the collapse becomes its own small lane. I would rather be told than assume. -### Q#W-2 — the dispatch identity path **(rewritten in rev 2)** +### Q#W-2 — the dispatch identity path **(rewritten in rev 2, rule 1 added in rev 3)** Revision 1 treated this as a parameter-passing detail. §2 shows it is not: `name` dies at `pmacs.workers.dispatch` and nothing below it takes a name, so the value must be carried *out of band* across an arbitrary handler. +**Revision 2 then called the extent "synchronous" and assumed it. +Review found that it is not.** A registered handler is arbitrary Lua +running inside `pmacs.async`, and it may call `Handle:await()` — a +legal, yieldable path that the existing tests already exercise inside +`pcall`. While a handler is parked, its pushed name **stays on the +stack**, and every tick callback and every other coroutine that +allocates a job in the meantime inherits it. That is not a corner case; +it is the ordinary shape of a handler that awaits. + +So rule 1 below is no longer an observation about how handlers happen +to behave. It is an **enforced** property, and the enforcement already +has a precedent in this exact file (§2a). + **The capture point is Rust, not Lua**, and the reason is the bypass in §2. If the ambient lived in the Lua wrapper layer, a handler calling `async_mod._dispatch_*` directly — the documented pattern for runtime @@ -311,9 +350,35 @@ runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`). **The contract, in full:** -1. **Extent is the SYNCHRONOUS handler call, and nothing more.** Push - before, pop after. Every job reaching `allocate` during that window - carries the name. +1. **THE EXTENT IS NON-YIELDABLE, AND THAT IS ENFORCED, NOT ASSUMED.** + Awaiting inside a dispatch-name scope is **refused**, because + yielding would park the coroutine with the name still pushed and + hand it to whatever allocates next. + + The guard is modelled on the one already in the file (§2): + `_in_dispatch_name_scope()` joins `_in_commit_scope()` as a refusal + in the same place, with the same shape of message and the same + remedy — **await first, then dispatch**. + + Three details that decide whether the guard actually holds: + + - **It rejects BEFORE parking.** The `commit_to` guard is the first + thing in `await`, ahead of the `_is_complete` check and the + `coroutine.yield`. The new one sits beside it, for the same + reason: a guard that fires after the yield has already happened + guards nothing. + - **It rejects UNCONDITIONALLY, not only when the handle is + incomplete.** A guard that fires only when a yield would really + occur has behaviour depending on whether the job happened to + finish first — it would pass under test and fail in production, + intermittently. `commit_to`'s guard is unconditional and this one + matches it. + - **`await` is NOT the only yield point.** + `pmacs.async.yield_to_next_tick()` (`async.lua:243-245`) yields + too, and is public. It gets the same refusal. Guarding only + `await` would leave the hole open through a second door — see + Q#W-7, because the existing `commit_to` guard has exactly that + gap today. 2. **Work dispatched later is NOT covered, deliberately.** A job dispatched from an `on_complete` callback or a resumed coroutine runs ticks later, outside the extent, and carries only its own @@ -343,22 +408,29 @@ runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`). **A known and accepted property, stated rather than discovered later:** the ambient captures *causal* extent, not *intent*. If a handler -synchronously triggers unrelated work — an edit that schedules a parse -— that job is inside the window and takes the name. Within a -synchronous extent I think that is the honest reading ("this ran -because that handler ran"), and it is the only definition enforceable -at a single funnel. **If review disagrees, the alternative is +triggers unrelated work within its extent — an edit that schedules a +parse — that job takes the name. Because rule 1 makes the extent +non-yieldable, that window is bounded by a single un-parked call, and +within such a window I think "this ran because that handler ran" is the +honest reading. It is also the only definition enforceable at a single +funnel. **If review disagrees, the alternative is capture-at-the-Lua-wrapper**, which is narrower and misses the raw `_dispatch_*` callers — a trade of false positives for false negatives, -and I would rather over-attribute inside a synchronous call than -silently drop the third-party case. +and I would rather over-attribute inside a bounded call than silently +drop the third-party case. **Why this ambient is admissible while Q#W-5's is not.** They are not -the same mechanism. This one is a synchronous, single-threaded, bounded -dynamic extent with a deterministic pop — a `let` binding in disguise. -A `parent` ambient must span a job's *asynchronous* lifetime, across -ticks, through callbacks that run after the parent settled. The first -is a stack; the second is a lifetime model. +the same mechanism — **and revision 2 was entitled to that claim only +after rule 1 made it true.** As written in revision 2 the extent could +be parked by any awaiting handler, which is most of the way to the +asynchronous lifetime I used as the reason for deferring `parent`. +With rule 1 the difference is real and enforced: this is a +single-threaded dynamic extent that **cannot** be suspended, with a +deterministic pop on both the normal and the error path. A `parent` +ambient must span a job's asynchronous lifetime by design — across +ticks, through callbacks that run after the parent settled — and cannot +be fixed by refusing to yield, because yielding is the whole point. The +first is a stack; the second is a lifetime model. ### Q#W-3 — what does the indicator actually show? @@ -412,21 +484,54 @@ child is dispatched. A `parent` field that nothing populates is worse than no field: it renders as `None` everywhere and reads as "this job has no parent" rather than "this system does not track parents". -**And the objection revision 2 has to answer, since it now builds an +**And the objection this has to answer, since the lane now builds an ambient of its own (Q#W-2):** why is one admissible and not the other? -Because they are not the same mechanism. Q#W-2's extent is -synchronous, single-threaded, and bounded by one function call, with a -deterministic pop on both the normal and the error path. A `parent` +Because Q#W-2's extent **cannot be suspended** — rule 1 refuses both +yield points, so it is bounded by one un-parked call with a +deterministic pop on the normal and the error path. + +**That distinction is only load-bearing because rule 1 exists.** +Revision 2 asserted this same paragraph while its ambient *could* be +parked by any awaiting handler, which made the two mechanisms far more +alike than the argument admitted. The honest version: a `parent` ambient must identify the running job *across ticks* — a job dispatched from an `on_complete` callback should name the job whose completion -fired it, and that callback runs after the parent settled, on the main -thread, outside any dispatch call. That is a lifetime model, not a -stack, and it is Stage 3's subject rather than a field this lane can -add cheaply. +fired it, and that callback runs after the parent settled, outside any +dispatch call. Refusing to yield cannot rescue it, because yielding is +the mechanism it needs. That is a lifetime model, not a stack, and it +is Stage 3's subject rather than a field this lane can add cheaply. Stage 3 builds the lifetime model and the field together, where the field can be tested by a populated case. +### Q#W-7 — the same hole exists in `commit_to` today **(new in rev 3)** + +Found while scouting rule 1, and reported rather than quietly patched. + +`Handle:await()` refuses to run inside `pmacs.window.commit_to` +(`async.lua:87-90`) precisely so a coroutine cannot park with the +frontend scope pushed. **But `pmacs.async.yield_to_next_tick()` +(`async.lua:243-245`) also yields, is public, and carries no such +refusal.** A coroutine inside `commit_to` can therefore park through +that door and produce exactly the misrouting the `await` guard exists +to prevent. Journey Stage 1a's Q#JR14b invariant has a second entrance. + +I have **not** verified that a real caller does this — the reachability +of the bug is unproven, and I would rather say so than dress a +code-reading up as a repro. + +*My vote: **fix it in this lane, in the same commit as rule 1.*** It is +one refusal in a function this lane is already editing, in the same +family, for the same reason. The alternative — ship a document that +explains the hazard in detail, add the guard for the new scope, and +leave the identical gap open beside it — is how a codebase teaches its +next reader that the rule is optional. + +**But it is another lane's invariant**, so it is a question rather than +an assumption. If review prefers it separate, it should be its own +small lane *before* this one, and this framing should say so; what it +should not be is discovered a third time. + ### Q#W-6 — is any of this configurable? *My vote: **one boolean, `ui.activity-indicator` (default `true`), @@ -459,6 +564,19 @@ preference. dispatcher** — not a synthetic funnel test. A test that pushes the ambient by hand proves the stack works and leaves the actual defect (`name` dying in an arbitrary handler) unwitnessed. +- **Awaiting inside a handler is REFUSED, and the scope restores after + the refusal** (Q#W-2 rule 1). Two assertions, and the second is the + load-bearing one: a guard that raises but leaves the name pushed has + converted a silent misattribution into a silent misattribution plus + an error. The witness dispatches again after the rejection and + asserts the new job carries **no** stale name. +- **`pmacs.async.yield_to_next_tick()` inside a handler is refused + too**, with the same restore-after assertion. Guarding one yield + point and not the other leaves the hole open through a second door + (§2). +- **The refusal fires even when the awaited handle is already + complete** (rule 1) — the case that separates an unconditional guard + from one whose behaviour depends on a race. - **The ambient survives a failing handler** (Q#W-2 rule 5): a handler that errors, then a subsequent unrelated dispatch, asserting the second job does **not** carry the first's name. This is the From 26a26006bb6a76b1f96b1830242c1ece3dffe329 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 15:08:41 +0200 Subject: [PATCH 48/66] docs: worker identity framing revision 4 --- scope rule 1, take Q#W-7 Two changes, both from review, both about claiming exactly what is enforced and no more. Rule 1 said it covered "all yield points". It covers the two supported pmacs yield APIs. Raw coroutine.yield remains reachable: R46 is a convention ("package code uses :await() rather than coroutine.yield", async.lua:26-27), not an enforcement, and the scheduler diagnoses a non-Handle yield only after the fact --- step() resumes at :197 and inspects what came back at :212, by which point the coroutine has already suspended and the enclosing dispatch never returns to run its pop. No refusal sited in a yield helper can intercept that. So the property is stated as what it is: the supported ways to yield are refused inside the scope, and an R46 violation can still leak the name --- loudly, through pmacs.error into *errors*, but unrestored. Section 6 says explicitly that this is NOT asserted, because a test implying coverage the design lacks is worse than the recorded gap. Q#W-7 is approved into this lane rather than split out. Same supported helper, same invariant, same async.lua edit family; splitting would preserve a known hole without reducing integration risk. So yield_to_next_tick gains both refusals --- the new _in_dispatch_name_scope and the missing _in_commit_scope --- and the commit_to gap closes in the same commit as rule 1. Its witnesses are the same pair as rule 1: the refusal fires AND the scope restores, on the reasoning that a guard which raises while leaving the scope pushed trades a silent fault for a loud one and fixes neither. Reachability by a real caller stays UNPROVEN and the framing says so in three places, including here. The defect was found by reading; the tests pin the guard rather than reproducing a user-visible bug. Nobody should later cite this as evidence the bug was observed. The causal-extent paragraph and the Q#W-5 comparison were both re-scoped to match, since both leaned on "non-yieldable" as an unqualified property. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 23 +++++-- docs/worker-identity-framing.md | 118 +++++++++++++++++++++++--------- 2 files changed, 104 insertions(+), 37 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ce25317..b1a2cc7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## Worker identity Stage 1 (§9) — BRANCHED, framing in review +## Worker identity Stage 1 (§9) — BRANCHED, pre-implementation **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -275,7 +275,8 @@ from #171 and #215. the authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout worker-identity-stage1`. -- **Framing `docs/worker-identity-framing.md`, revision 3**, in review. +- **Framing `docs/worker-identity-framing.md`, revision 4, APPROVED + 2026-08-09** after four review rounds. Scope: `COHERENCE.md` §9's "mechanism without identity", and journey step 11 — the last of Priority 1's own work, sitting in another section's arc. @@ -298,11 +299,23 @@ the authoritative tip** — the ref, not a SHA. Recover with unconditionally rather than only when a yield would occur, and covering **both** yield points. - **Q#W-7 — a pre-existing defect found while scouting that guard, and - reported rather than patched.** `pmacs.async.yield_to_next_tick()` + APPROVED for repair in this lane.** `pmacs.async.yield_to_next_tick()` (`async.lua:243-245`) is public, yields, and carries **no** `_in_commit_scope` refusal — so Journey Stage 1a's Q#JR14b invariant - has a second entrance. Reachability by a real caller is **unproven**. - Awaiting the user's call on whether this lane fixes it. + has a second entrance. Same helper, same invariant, same edit family, + so splitting it would have preserved a known hole without reducing + integration risk. **Reachability by a real caller is UNPROVEN** — the + defect was found by reading, and the tests pin the guard rather than + reproducing a user-visible bug. That belongs in the commit message so + nobody later cites this as an observed failure. +- **Revision 4 also scoped rule 1's claim to what it enforces.** + Revision 3 said "all yield points"; it covers **the two supported + pmacs yield APIs**. Raw `coroutine.yield` stays reachable — R46 is a + convention, and the scheduler diagnoses a non-Handle yield only after + the coroutine has suspended (`async.lua:197` resumes, `:212` + inspects), so no refusal in a yield helper can intercept it. Recorded + as a residual, and explicitly **not** covered by a test that would + imply otherwise. - **NO WIRE CHANGE**, which is what lets this run beside the two lanes already in flight. The statusline activity indicator is a **fourth** `pmacs.statusline.register` provider (terminal/syntax/lsp are the diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md index 31f5230..d86d502 100644 --- a/docs/worker-identity-framing.md +++ b/docs/worker-identity-framing.md @@ -5,8 +5,17 @@ removed that title overclaimed the lane: it answers **what**, and — under `pmacs.workers.dispatch` — **under which registered handler**. Neither is who owns it.)* -**Status: framing pass, revision 3. Pre-implementation. Awaiting -approval.** +**Status: revision 4, APPROVED 2026-08-09. Implementation may +proceed.** + +**Revision 4 scopes rule 1's claim to what it can actually enforce, and +takes Q#W-7 into this lane.** Revision 3 said the rule covered "all +yield points"; it covers **the two supported pmacs yield APIs**. Raw +`coroutine.yield` stays reachable — R46 is a convention, and the +scheduler diagnoses a non-Handle yield only *after* the coroutine has +suspended (`async.lua:197` resumes, `:212` inspects), so no refusal +sited in a yield helper can intercept it. The residual is named in §2 +rather than papered over. **Revision 3 closes a hole in revision 2's ambient: the extent it called "synchronous" is not.** A registered handler is arbitrary Lua @@ -14,9 +23,11 @@ and may `Handle:await()`, parking the coroutine with the name still pushed so that unrelated later work inherits it. Rule 1 now **enforces** non-yieldability rather than assuming it, following the guard this file already carries for `pmacs.window.commit_to`. Scouting that guard -turned up a second yield point it does not cover — Q#W-7, a -pre-existing defect in another lane's invariant, reported rather than -patched in silence. +turned up a second supported yield API it does not cover — Q#W-7, a +pre-existing defect in another lane's invariant. Revision 3 reported it +rather than patching it in silence; **revision 4 fixes it here, on +approval**, since it is the same helper, the same invariant and the +same edit family. **Revision 2 removes `owner` and respecifies the handler-name path, after review found the first dishonest and the second unbuildable as @@ -149,12 +160,28 @@ drifted in one place, recorded below. ambient". `commit_to` itself is "an RAII guard on the Rust stack" — the same shape this lane needs. -- **There are TWO yield points, not one.** `Handle:await()` yields at - `async.lua:95`; **`pmacs.async.yield_to_next_tick()` yields at - `async.lua:244`** and is public (`pmacs.async` is `async_public`, +- **There are TWO SUPPORTED yield APIs, not one.** `Handle:await()` + yields at `async.lua:95`; **`pmacs.async.yield_to_next_tick()` yields + at `async.lua:244`** and is public (`pmacs.async` is `async_public`, `:247`). Any rule about a non-yieldable extent has to cover both. The `commit_to` guard covers only the first — see Q#W-7. +- **Raw `coroutine.yield` remains reachable, and NO guard of this shape + can cover it.** R46 is a convention — *"package code uses `:await()` + rather than `coroutine.yield`"* (`async.lua:26-27`) — not an + enforcement. The scheduler does diagnose a non-Handle yield + (`async.lua:217-223`, *"use Handle:await() per R46"*), **but only + after the fact**: `step` calls `coroutine.resume(co)` at `:197` and + inspects what came back at `:212`, by which point the coroutine has + already suspended. A refusal placed in a yield helper is never + consulted, and the enclosing `pmacs.workers.dispatch` never returns + to run its pop. + + So the honest bound is: a package that violates R46 *inside* a + dispatch-name scope can leak the name. It is not silent — the + scheduler raises it through `pmacs.error` into `*errors*` — but the + scope is not restored, and this framing does not claim otherwise. + And the two findings that actually shape the design: - **A statusline provider API already exists, with three Lua adopters.** @@ -373,12 +400,20 @@ runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`). finish first — it would pass under test and fail in production, intermittently. `commit_to`'s guard is unconditional and this one matches it. - - **`await` is NOT the only yield point.** - `pmacs.async.yield_to_next_tick()` (`async.lua:243-245`) yields - too, and is public. It gets the same refusal. Guarding only - `await` would leave the hole open through a second door — see - Q#W-7, because the existing `commit_to` guard has exactly that - gap today. + - **It covers BOTH SUPPORTED YIELD APIs — and that is the exact + extent of the claim.** `pmacs.async.yield_to_next_tick()` + (`async.lua:243-245`) yields too, and is public, so it gets the + same refusal; guarding only `await` would leave the hole open + through a second door (and Q#W-7 is the proof that this happens, + because `commit_to` has exactly that gap today). + + **What rule 1 does NOT cover is raw `coroutine.yield`** (§2). + R46 forbids it to package code by convention only, and the + scheduler's diagnostic fires *after* suspension, so no refusal + sited in a yield helper can intercept it. Revision 3 said "all + yield points" and was overclaiming. The property is: **the + supported ways to yield are refused inside the scope; an R46 + violation can still leak the name, loudly.** 2. **Work dispatched later is NOT covered, deliberately.** A job dispatched from an `on_complete` callback or a resumed coroutine runs ticks later, outside the extent, and carries only its own @@ -409,10 +444,11 @@ runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`). **A known and accepted property, stated rather than discovered later:** the ambient captures *causal* extent, not *intent*. If a handler triggers unrelated work within its extent — an edit that schedules a -parse — that job takes the name. Because rule 1 makes the extent -non-yieldable, that window is bounded by a single un-parked call, and -within such a window I think "this ran because that handler ran" is the -honest reading. It is also the only definition enforceable at a single +parse — that job takes the name. Because rule 1 refuses both supported +yield APIs, that window is bounded by a single un-parked call for any +caller obeying R46, and within such a window I think "this ran because +that handler ran" is the honest reading. (A caller violating R46 is +outside this property, and outside rule 1 — §2.) It is also the only definition enforceable at a single funnel. **If review disagrees, the alternative is capture-at-the-Lua-wrapper**, which is narrower and misses the raw `_dispatch_*` callers — a trade of false positives for false negatives, @@ -504,7 +540,7 @@ is Stage 3's subject rather than a field this lane can add cheaply. Stage 3 builds the lifetime model and the field together, where the field can be tested by a populated case. -### Q#W-7 — the same hole exists in `commit_to` today **(new in rev 3)** +### Q#W-7 — the same hole exists in `commit_to` today — **RESOLVED, fixed here (rev 4)** Found while scouting rule 1, and reported rather than quietly patched. @@ -520,17 +556,22 @@ I have **not** verified that a real caller does this — the reachability of the bug is unproven, and I would rather say so than dress a code-reading up as a repro. -*My vote: **fix it in this lane, in the same commit as rule 1.*** It is -one refusal in a function this lane is already editing, in the same -family, for the same reason. The alternative — ship a document that -explains the hazard in detail, add the guard for the new scope, and -leave the identical gap open beside it — is how a codebase teaches its -next reader that the rule is optional. +**RESOLVED — approved for this lane.** It is the same supported yield +helper, the same invariant, and the same `async.lua` edit family; +splitting it would preserve a known hole without reducing integration +risk. So `yield_to_next_tick` gains **both** refusals — the new +`_in_dispatch_name_scope()` and the missing `_in_commit_scope()` — and +the `commit_to` gap closes in the same commit as rule 1. -**But it is another lane's invariant**, so it is a question rather than -an assumption. If review prefers it separate, it should be its own -small lane *before* this one, and this framing should say so; what it -should not be is discovered a third time. +**Its witnesses are the same pair as rule 1's, not a smoke test:** the +refusal fires, **and** the commit scope is restored afterwards. A guard +that raises while leaving the scope pushed converts a silent misrouting +into a noisy one and fixes nothing. + +Reachability by a real caller stays **unproven** — this is a defect +found by reading, and the tests pin the guard rather than reproducing a +user-visible bug. That distinction belongs in the commit message too, +so nobody later cites this as evidence the bug was observed. ### Q#W-6 — is any of this configurable? @@ -571,9 +612,18 @@ preference. an error. The witness dispatches again after the rejection and asserts the new job carries **no** stale name. - **`pmacs.async.yield_to_next_tick()` inside a handler is refused - too**, with the same restore-after assertion. Guarding one yield - point and not the other leaves the hole open through a second door - (§2). + too**, with the same restore-after assertion. Guarding one supported + yield API and not the other leaves the hole open through a second + door (§2). +- **`yield_to_next_tick` inside `pmacs.window.commit_to` is refused, + and the commit scope restores after the refusal** (Q#W-7) — the + pre-existing gap, closed here. Both halves asserted, for the same + reason as rule 1's: a refusal that leaves the scope pushed has + swapped a silent fault for a loud one. +- **NOT asserted, and deliberately: that a raw `coroutine.yield` + inside either scope is prevented.** It is not (§2). Writing a test + that "proves" coverage this design does not have would be worse than + the gap, and the gap is recorded instead. - **The refusal fires even when the awaited handle is already complete** (rule 1) — the case that separates an unconditional guard from one whose behaviour depends on a race. @@ -632,6 +682,10 @@ lets it run beside the two lanes already in flight. ## 7. Not in scope +**Making raw `coroutine.yield` safe inside either dynamic scope** (§2, +rule 1). R46 forbids it by convention and the scheduler diagnoses it +after the fact; closing it properly means enforcement the runtime does +not have, and this lane claims only the two supported yield APIs. **`owner`, in any spelling** — including `origin` or `subsystem` (§3). The slot stays empty until P3 can fill it with a package signal; nothing in this lane may be promoted into it later by use. From 689fb8333dbefd3a9731fedfc989cb7bad773b0d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:33:41 +0200 Subject: [PATCH 49/66] =?UTF-8?q?feat(workers):=20a=20required=20purpose?= =?UTF-8?q?=20on=20every=20job=20and=20process=20=E2=80=94=20worker=20iden?= =?UTF-8?q?tity=20Stage=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COHERENCE.md` §9 grades the worker model "mechanism without identity", and §0 names step 11 (background-work ownership) as one of the two remaining thin ends of the golden journey. The mechanism half is solid — cancellation, supersession, streaming, frame-aware draining, `*workers*`. The identity half was absent: `PendingJob` carried no description of what it was doing, `pmacs.workers.dispatch` discarded the registered handler name three layers above anything that takes one, and §9's "no progress indicator exists anywhere" was checkable and true. Framing: `docs/worker-identity-framing.md` (revision 4, approved). What lands: **A required `purpose`, on the job and on the process.** Non-optional, with no `Default`, so the compiler — not a test — is what proves every dispatcher supplied one. `allocate` / `allocate_with_resource` collapse into ONE private `JobSpec`-taking funnel (Q#W-1): the two-function split existed only because one prior lane needed one extra parameter, and a second lane doing the same produces `allocate_with_resource_and_identity`. `register_external` gains a `purpose` parameter rather than deriving one, because its `JobKind` is `McpRequest`/`LspRequest` for every method — a category, not a description. **A dispatch-name ambient (Q#W-2), read at that same single funnel.** The capture point is Rust, not the Lua wrapper layer, because a handler reaching straight for `pmacs._async._dispatch_*` bypasses the wrappers entirely — and those are precisely the callers attribution exists for. Seven rules; the ones that decide whether it is honest: - **Rule 1 — the extent is NON-YIELDABLE, and that is ENFORCED.** Both supported yield APIs refuse inside it, modelled on the `commit_to` refusal already in `async.lua`. The guards reject BEFORE parking and reject UNCONDITIONALLY: one placed after `_is_complete` would fire only when a yield really occurred, passing under test and failing intermittently in production. - **A raw `coroutine.yield` is NOT covered, and nothing here claims it is.** R46 is a convention, and the scheduler inspects the yielded value only after `coroutine.resume` returns — by which point the coroutine has already suspended — so no refusal sited in a yield helper is ever consulted. The residual is recorded in the framing §2 and in the suite's module docs rather than papered over with a test that would imply coverage this design lacks. - **Rule 5 — unwind-safe.** A raising handler still pops. A version that did not would let one failure poison every later dispatch in the session with a stale name: the feature would stop failing loudly and start lying silently. The bracketing also has to preserve the tail call it replaced: `dispatch` was `return handler(args, opts)` and propagated EVERY return value, so the pop/rethrow runs behind a varargs boundary rather than a `local ok, result = pcall(...)` that would silently truncate a multi-value handler. Varargs rather than `table.pack`, because that is Lua 5.2 surface and LuaJIT is this project's default backend. - **Rule 6 — compose, do not replace.** `": "`, because letting the dispatcher's purpose win loses the third party again and letting the name win discards the only description of the actual work. **A statusline activity indicator** — the fourth `pmacs.statusline.register` adopter, after `mode`, `terminal` and `lsp`. A count plus the OLDEST in-flight job's purpose ("busiest" is not a defined quantity; jobs carry no cost estimate), and **absent entirely** when idle rather than a zero-width segment that costs modeline width forever to say nothing is happening. Gated by one setting, `ui.activity-indicator` (boolean, default true, Q#W-6) — a permanently-visible modeline element is a preference someone genuinely holds on day one. No setting for purpose capture itself: that is substrate. **NO WIRE CHANGE.** The indicator rides the existing `StatuslineSegments` vector, so a fourth provider adds an element, not a variant. `PROTOCOL_VERSION` and `ADVERTISED_PROTOCOL_VERSION` are untouched — which is the property that lets this run beside the two lanes holding the bump slot. **Q#W-7 — a pre-existing defect, repaired here, and NOT one anybody has observed.** `Handle:await()` refuses inside `pmacs.window.commit_to` precisely so a coroutine cannot park with the frontend scope pushed (Journey Stage 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also yields, is public, and carried no such refusal — so that invariant had a second entrance, and a coroutine could produce exactly the misrouting the `await` guard exists to prevent. It gains both refusals here: the same supported yield helper, the same invariant, the same edit family, so splitting it would have preserved a known hole without reducing integration risk. **Reachability by a real caller is UNPROVEN.** This was found by reading the guard family while scouting rule 1, not by reproducing a fault. No production caller is known to yield through that door inside a commit, and the test pins the guard rather than reproducing a user-visible bug. Nobody should later cite this commit as evidence the bug was observed in the wild. Its witness is a PAIR, like rule 1's: the refusal fires **and** the commit scope is restored afterwards — a guard that raises while leaving the scope pushed converts a silent fault into a loud one and fixes neither. `journey_acceptance` carries the established `commit_to` pins — forged-destination refusal, scope-and-restore on normal return and on raise, the await refusal, delivery to the requesting frontend. It passes **untouched**, which is what says this closed a gap in Journey Stage 1a's semantics rather than altering them. What is deliberately NOT here, and why it is worth saying: - **No `owner`, in any spelling** — not `origin`, not `subsystem` (§3). Populated from static per-subsystem constants it would be an origin, not an owner, and would confidently misattribute third-party work to a builtin at exactly the point §9 wants attribution. A field that asserts a falsehood is worse than an absent one. The slot stays empty until P3 can fill it with a real package signal. - **No `parent`** (Q#W-5). An unpopulated field renders as `None` everywhere and reads as "this job has no parent" rather than "this system does not track parents". Stage 3 builds the lifetime model and the field together. Consequences worth recording: - `ProcessSpec::new` takes a third argument. The 40-odd call sites are almost all tests; the three production ones (LSP, MCP, terminal) supply real descriptions. `pmacs.process.spawn`'s Lua surface keeps `purpose` OPTIONAL, falling back to the label — requiring it there would break every existing caller for no coverage the compiler is not already providing, and a caller's own label is not a fabrication. - `pmacs.process.list` gains a `purpose` KEY on each row and enumerates exactly the same processes (Q#W-4). Terminal PTYs stay hidden: three acceptance suites use `#pmacs.process.list()` as a leak baseline, and widening the accessor would inflate all three. Stage 2's unified view owns that decision. - `statusline_segments_acceptance`'s builtin-provider inventory grows to `["activity", "mode", "terminal", "lsp"]`. That assertion exists to grow when a builtin provider is added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/async.lua | 150 ++++- pmacs-gpu/src/main.rs | 89 +++ src/async_runtime.rs | 378 +++++++++-- src/lsp.rs | 21 +- src/lua_bindings/mod.rs | 98 ++- src/mcp.rs | 38 +- src/process.rs | 95 ++- src/terminal/session.rs | 3 +- src/workers_buffer.rs | 73 +- tests/m4_acceptance.rs | 18 +- tests/statusline_segments_acceptance.rs | 7 +- tests/vterm_stage1_acceptance.rs | 2 +- tests/worker_identity_acceptance.rs | 849 ++++++++++++++++++++++++ 13 files changed, 1682 insertions(+), 139 deletions(-) create mode 100644 tests/worker_identity_acceptance.rs diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index af74cc1..000be49 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -88,6 +88,28 @@ function Handle:await() error("await: cannot await inside pmacs.window.commit_to; " .. "await first, then commit") end + -- Worker identity Stage 1 (Q#W-2 rule 1): `pmacs.workers.dispatch` + -- pushes the registered handler's name for the dynamic extent of the + -- handler call, so that jobs allocated inside it are attributable to + -- the third party that asked for them. Parking here would leave the + -- name pushed while this coroutine is suspended, and every job + -- allocated in the meantime --- in any coroutine, on any later tick + -- --- would inherit it. Same hazard, same shape, same remedy as the + -- commit-scope refusal above. + -- + -- Two properties this placement buys, both load-bearing: + -- + -- * it rejects BEFORE parking (ahead of the `_is_complete` check and + -- the `coroutine.yield`), because a guard consulted after the yield + -- has already happened guards nothing; + -- * it rejects UNCONDITIONALLY, not only when a yield would really + -- occur. A guard that fires only for an incomplete handle would + -- pass or fail depending on whether the job happened to settle + -- first --- green under test, intermittent in production. + if async_mod._in_dispatch_name_scope() then + error("await: cannot await inside pmacs.workers.dispatch; " .. + "await first, then dispatch") + end if not async_mod._is_complete(self._id) then -- Yield self so pmacs.async's step() can park us. R46 carve-out: -- this `coroutine.yield` is runtime code; package code uses @@ -240,7 +262,28 @@ setmetatable(async_public, { end, }) +-- The SECOND supported yield API. `Handle:await()` is the first; any +-- rule about a non-yieldable dynamic extent has to cover both, or the +-- extent stays open through a second door. +-- +-- Both refusals below are that rule. The commit-scope one is a +-- **pre-existing gap being closed** (worker identity framing Q#W-7): +-- Journey Stage 1a's Q#JR14b invariant was enforced on `:await()` only, +-- so a coroutine inside `pmacs.window.commit_to` could park through here +-- and produce exactly the misrouting that guard exists to prevent. +-- +-- Placement is the whole point: both fire *before* the `coroutine.yield` +-- below, and both fire unconditionally. A refusal sited after the yield +-- would never run in the case it exists for. function async_public.yield_to_next_tick() + if async_mod._in_commit_scope() then + error("yield_to_next_tick: cannot yield inside pmacs.window.commit_to; " .. + "yield first, then commit") + end + if async_mod._in_dispatch_name_scope() then + error("yield_to_next_tick: cannot yield inside pmacs.workers.dispatch; " .. + "yield first, then dispatch") + end coroutine.yield({ _is_pmacs_next_tick = true }) end @@ -366,12 +409,63 @@ local handlers = { end, } +-- Worker identity Stage 1 (Q#W-2): `name` used to die here. +-- +-- The audit's "every third-party job renders under a builtin's label" is +-- exact, and the reason is this function: the handler is arbitrary Lua, +-- nothing below it takes a name, and a handler that reaches straight for +-- `pmacs._async._dispatch_*` bypasses the wrapper layer entirely. So the +-- name is pushed onto a runtime-owned stack for the dynamic extent of +-- the handler call and read at `allocate`, the single funnel every job +-- passes through. Seven rules govern it; five are visible here: +-- +-- 1. The extent is NON-YIELDABLE, and that is enforced rather than +-- assumed --- see the refusals in `Handle:await` and +-- `pmacs.async.yield_to_next_tick`. +-- 3. Nesting is a stack; innermost wins. +-- 4. Fan-out shares the name: five jobs dispatched by one handler are +-- five jobs named alike. They *were* all dispatched under it. +-- 5. UNWIND-SAFE, and this is the one that makes a naive version worse +-- than none. A handler that raises must still pop --- otherwise one +-- failure poisons every subsequent dispatch in the session with a +-- stale name, and the feature starts lying silently instead of +-- failing loudly. Hence pcall, pop, rethrow. +-- 7. Outside any extent nothing changes: a builtin invoked directly +-- records its own purpose. +-- +-- Rule 2 (work dispatched later, from an `on_complete` callback or a +-- resumed coroutine, is deliberately NOT covered) and rule 6 +-- (composition, `": "`) live on the Rust side. +-- +-- The pop/rethrow half, hoisted so it is written once and allocates +-- nothing per dispatch. +-- +-- Varargs across a function boundary, NOT `local ok, result = pcall(…)`: +-- this function used to be `return handler(args, opts)`, which +-- propagates EVERY return value, and bracketing it must not silently +-- truncate a handler that returns more than one. `table.pack` / +-- `table.unpack` would say the same thing but are Lua 5.2 surface, and +-- LuaJIT is this project's default backend (`Cargo.toml`: +-- `default = ["luajit"]`). +local function finish_dispatch(ok, ...) + async_mod._pop_dispatch_name() + if not ok then + -- Level 0: the handler's error travels unchanged. R45's structured + -- errors are tables, and a re-raise that appended position info + -- would corrupt a plain-string error and be silently ignored for a + -- table one --- so neither shape is served by the default level. + error((...), 0) + end + return ... +end + function pmacs.workers.dispatch(name, args, opts) local handler = handlers[name] if handler == nil then error("pmacs.workers.dispatch: unknown handler '" .. tostring(name) .. "'") end - return handler(args, opts) + async_mod._push_dispatch_name(name) + return finish_dispatch(pcall(handler, args, opts)) end function pmacs.workers.register(name, handler) @@ -581,6 +675,60 @@ function pmacs._async.tick() end end +-- --------------------------------------------------------------------------- +-- Statusline activity indicator (worker identity Stage 1, Q#W-3/Q#W-6). +-- --------------------------------------------------------------------------- +-- +-- `COHERENCE.md` §9 records that no progress indicator exists anywhere +-- --- no spinner, no busy count --- which makes §3's promise of "visible +-- asynchronous work" false unless the user knows to run +-- `M-x editor.list-workers`. This is the fourth `pmacs.statusline.register` +-- adopter (after `mode`, `terminal` and `lsp`) and the first thing that +-- makes background work visible without a command. +-- +-- No wire change: `pmacs.statusline.register` rides the existing +-- `StatuslineSegments` vector, so a fourth provider adds an ELEMENT, not +-- a variant. That is what lets this lane run beside the two holding the +-- protocol-bump slot. + +-- A visibility toggle, and only that (Q#W-6). A permanently-visible +-- statusline element is different in kind from an internal behaviour: it +-- costs modeline width on every frame, and "I do not want this in my +-- modeline" is a preference someone genuinely holds on day one. There is +-- deliberately NO setting for purpose capture itself --- that is +-- substrate, not preference. +pmacs.config.define { + name = "ui.activity-indicator", + description = "Show a modeline count of in-flight background jobs, with the oldest job's purpose. Absent entirely when nothing is running.", + type = "boolean", + default = true, + mutability = "live", +} + +pmacs.statusline.register { + name = "activity", + side = "right", + -- Above `terminal` (10) and `lsp` (0): when the modeline is too narrow + -- for everything, "the editor is busy, on this" is the segment worth + -- keeping. Right-side display order is priority-ascending, so it also + -- lands nearest the protected cursor/scroll group. + priority = 20, + face = "ui.modeline.activity", + fn = function(_ctx) + if pmacs.config.get("ui.activity-indicator") ~= true then return nil end + -- `_activity_summary` rather than `pmacs.workers.snapshot()`: this + -- runs once per visible window per frame, and a snapshot would clone + -- the whole 64-entry completed ring that the indicator never reads. + local summary = async_mod._activity_summary() + -- nil, not "" and not "0 jobs": the evaluator treats an empty string + -- as "no segment" too, but a zero-count string would be a segment + -- that costs width forever to say nothing is happening. Absence is + -- the design (Q#W-3), so absence is what this returns. + if summary == nil then return nil end + return "⋯" .. tostring(summary.in_flight) .. " " .. summary.purpose + end, +} + -- Diagnostic / test helpers: number of parked coroutines, number of -- pending Rust-side jobs. Used by Rust integration tests to drive the -- runtime to quiescence. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 78eb64a..8f5d817 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -14939,6 +14939,95 @@ mod tests { assert_eq!(after[2].1, Color::rgb(20, 220, 40)); } + /// Worker identity Stage 1 (`docs/worker-identity-framing.md` §6): + /// the GPU half of "both frontends render the segment". + /// + /// The activity indicator adds no wire message — it rides the + /// existing `StatuslineSegments` vector as a fourth provider's + /// element. But that is a claim about the **producer**, and says + /// nothing about whether a consumer draws it, which is why this + /// exists on the consumer side. + /// + /// Two properties specific to this segment, neither of which the + /// existing rich-runs test covers: + /// + /// * its face (`ui.modeline.activity`) is **deliberately absent + /// from `ThemeFacts`** — no theme sets it, and `theme_facts_msg` + /// ships only faces that resolve — so a consumer that dropped + /// segments with an unknown face would silently lose the one + /// thing telling the user the editor is busy; + /// * its text leads with a non-ASCII `⋯`, which a byte-oriented + /// composition step would mangle. + #[test] + fn the_activity_segment_survives_an_unthemed_face_and_a_non_ascii_lead() { + let Some(mut state) = headless_or_skip(500, 280, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + // One themed face, and NOT the activity one: the point is that + // the theme has an opinion about some segments and none about + // this one. + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.lsp", + CellStyle { + fg: CellColor::Rgb(20, 220, 40), + ..CellStyle::default() + }, + )], + ); + apply_statusline( + &mut state, + buffer_id, + Vec::new(), + vec![ + statusline_segment("LSP:rust", "ui.modeline.lsp"), + statusline_segment("⋯2 lsp textDocument/definition", "ui.modeline.activity"), + ], + ); + + let right = state.compose_status_runs(); + let text: String = right.iter().map(|(text, _)| text.as_str()).collect(); + assert!( + text.contains("⋯2 lsp textDocument/definition"), + "the activity segment must reach the composed right runs \ + intact: {text:?}" + ); + let activity = right + .iter() + .find(|(run, _)| run.contains('⋯')) + .expect("activity run"); + assert_eq!( + activity.1, + state.status_right_base_color(), + "an unthemed modeline face falls back to the base colour \ + rather than dropping the segment" + ); + assert_eq!( + right[0].1, + Color::rgb(20, 220, 40), + "and its themed neighbour still takes its own colour" + ); + + // And it survives the real shaping pass, not only composition. + let _ = state.render_offscreen(); + let shaped: String = state + .status_runs + .as_ref() + .expect("right shaped") + .iter() + .map(|(text, _)| text.as_str()) + .collect(); + assert!( + shaped.contains("⋯2 lsp textDocument/definition"), + "{shaped:?}" + ); + } + #[test] fn modal_left_precedence_suppresses_custom_left_but_preserves_right() { let Some(mut state) = headless_or_skip(420, 260, "text") else { diff --git a/src/async_runtime.rs b/src/async_runtime.rs index 3620a32..e60d0ae 100644 --- a/src/async_runtime.rs +++ b/src/async_runtime.rs @@ -408,6 +408,47 @@ struct PendingJob { /// job→buffer link already lives in a side map and §9 names that as /// the defect. resource: Option, + /// What this job is doing, in words a user can read (worker + /// identity Stage 1, `COHERENCE.md` §9). + /// + /// **Not an owner.** It records *what work* is running and — when + /// the job was born inside a `pmacs.workers.dispatch` extent — the + /// registered handler name it ran under. Neither is the package + /// responsible for it; that slot is deliberately empty until P3 can + /// fill it with a real package signal (framing §3). + /// + /// Non-optional by construction: [`JobSpec`] has no `Default`, so a + /// dispatcher that supplies none does not compile. + purpose: String, +} + +/// Everything one job is born with. +/// +/// **Private, and deliberately so** (framing Q#W-1). The two-function +/// `allocate` / `allocate_with_resource` split existed only because one +/// prior lane needed one extra parameter; a second lane doing the same +/// produces `allocate_with_resource_and_identity`. Collapsing the pair +/// into a struct means the next field is a named literal at each of the +/// eleven construction sites rather than another positional parameter on +/// a public signature. +/// +/// **There is no `Default` impl, and that is the point.** `purpose` is +/// what makes the compiler — not a test — the thing that proves every +/// dispatcher supplied one (framing §6). A `Default` would let a new +/// dispatcher write `..Default::default()` and silently ship an empty +/// identity. +struct JobSpec<'a> { + /// Which builtin handler this job runs. + kind: JobKind, + /// Supersede key, if the dispatch opted into supersession. + supersede: Option<&'a str>, + /// `Some(max_batch)` marks this as a streaming dispatch. + stream: Option, + /// Filesystem mutation this job performs, for the settle-time + /// reconcile (dired Stage 2a). + resource: Option, + /// What the job is doing. See [`PendingJob::purpose`]. + purpose: String, } /// A settled filesystem mutation, with the paths the worker consumed @@ -490,6 +531,9 @@ pub struct ActiveJobInfo { /// True if this is a streaming dispatch (`emit_n`, `grep`, ...); /// false if it's request/reply (`sleep`, `compute_sum`). pub is_stream: bool, + /// What this job is doing (worker identity Stage 1). Rendered by + /// `*workers*` and by the statusline activity indicator. + pub purpose: String, } /// One row in the `*workers*` buffer's "completed" section: a job @@ -507,6 +551,8 @@ pub struct CompletedJobInfo { pub settled_age_ms: u64, /// Supersede key (if any) the job was dispatched under. pub supersede_key: Option, + /// What this job was doing (worker identity Stage 1). + pub purpose: String, /// Terminal outcome. `None` is unreachable here --- only /// settled jobs land in the completed ring. pub outcome: JobOutcome, @@ -543,9 +589,33 @@ struct CompletedSlot { dispatched_at: Instant, settled_at: Instant, supersede_key: Option, + purpose: String, outcome: JobOutcome, } +/// What the statusline activity indicator needs, and nothing more +/// (framing Q#W-3). +/// +/// A dedicated read surface rather than [`WorkersSnapshot`]: the +/// indicator is evaluated once per visible window per frame, and a +/// snapshot clones the whole completed ring (up to +/// [`COMPLETED_RING_CAP`] entries) that the indicator never looks at. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActivitySummary { + /// How many jobs are in flight. Always ≥ 1 — an idle runtime + /// returns `None` rather than a zero count, because a segment that + /// is always present costs modeline width forever to say "nothing + /// is happening". + pub in_flight: usize, + /// The **oldest** in-flight job's purpose. + /// + /// Oldest, not newest and not "busiest": jobs carry no cost + /// estimate, so "busiest" is not a defined quantity, while oldest + /// is computable from `dispatched_at` and answers the question a + /// user actually asks of a stuck editor. + pub oldest_purpose: String, +} + /// One frame's worth of streamed items for a single stream id, /// returned by [`AsyncRuntime::take_stream_batches`]. T M3.5. #[derive(Clone, Debug)] @@ -610,6 +680,29 @@ pub struct AsyncRuntime { /// only contended at parse settle/take time --- never inside the /// editor's hot path. T M4.1. parse_handoff: Arc>>>, + /// Registered handler names of the `pmacs.workers.dispatch` calls + /// currently on the stack (worker identity Stage 1, Q#W-2). + /// + /// `pmacs.workers.dispatch(name, …)` looks `name` up, calls the + /// handler, and returns whatever it returns — **`name` is not a + /// parameter of any layer below that call**, and a handler that + /// reaches straight for `pmacs._async._dispatch_*` bypasses the Lua + /// wrapper layer entirely. So the name has to travel out of band, and + /// it is read here, at the one allocation funnel every job passes + /// through. + /// + /// A stack, not a slot: nesting is real (a handler may dispatch + /// through another registered handler) and innermost wins. + /// + /// **The extent is non-yieldable, and `async.lua` enforces it** — + /// both supported yield APIs refuse inside it, because parking a + /// coroutine with a name still pushed hands that name to whatever + /// allocates next. The one hole is a raw `coroutine.yield`, which + /// violates R46 and which no refusal sited in a yield helper can + /// intercept (the scheduler only sees the yielded value after the + /// coroutine has already suspended). That residual is recorded in + /// `docs/worker-identity-framing.md` §2, not claimed closed. + dispatch_names: RefCell>, } /// Default cap on stream items delivered in a single drain. 1024 @@ -650,6 +743,7 @@ impl AsyncRuntime { frame_target_ms: Cell::new(DEFAULT_FRAME_TARGET_MS), completed: RefCell::new(VecDeque::with_capacity(COMPLETED_RING_CAP)), parse_handoff: Arc::new(Mutex::new(HashMap::new())), + dispatch_names: RefCell::new(Vec::new()), } } @@ -733,34 +827,83 @@ impl AsyncRuntime { self.default_max_batch.set(n.clamp(1, 1_000_000)); } + /// Push a `pmacs.workers.dispatch` handler name for the dynamic + /// extent of that handler's call (worker identity Stage 1, Q#W-2). + /// + /// Paired with [`Self::pop_dispatch_name`] by + /// `pmacs.workers.dispatch`, which brackets the handler call under + /// `pcall` so a raising handler still pops. An unpaired push is the + /// failure mode that matters: it would poison every later dispatch + /// in the session with a stale name, and the feature would start + /// lying silently rather than loudly. + pub fn push_dispatch_name(&self, name: impl Into) { + self.dispatch_names.borrow_mut().push(name.into()); + } + + /// Pop the innermost dispatch-handler name. No-op when the stack is + /// already empty — an unbalanced pop is a Lua-side bug, and + /// panicking here would turn it into a torn editor rather than a + /// missing label. + pub fn pop_dispatch_name(&self) { + self.dispatch_names.borrow_mut().pop(); + } + + /// Whether a `pmacs.workers.dispatch` handler is on the stack. + /// + /// Read from Lua as `pmacs._async._in_dispatch_name_scope()`. Both + /// supported yield APIs refuse while it is set (Q#W-2 rule 1), for + /// the same reason `Handle:await` refuses inside + /// `pmacs.window.commit_to`: yielding would park the coroutine with + /// the name still pushed, and the next allocation — in any + /// coroutine, on any later tick — would inherit it. + #[must_use] + pub fn in_dispatch_name_scope(&self) -> bool { + !self.dispatch_names.borrow().is_empty() + } + + /// The innermost dispatch-handler name, if any. Nesting is a stack + /// and innermost wins (Q#W-2 rule 3). + #[must_use] + pub fn current_dispatch_name(&self) -> Option { + self.dispatch_names.borrow().last().cloned() + } + /// Register a fresh pending entry and return its id + cancel /// token. The token is what the worker closure polls; the entry /// is what `tick` updates on reply. /// - /// If `supersede_key` is `Some(key)`, any in-flight predecessor + /// **This is the single allocation funnel**: every job in the + /// system — the ten `dispatch_*` methods and + /// [`Self::register_external`] alike — is born here, which is what + /// makes the identity field reachable by construction rather than by + /// audit. + /// + /// If `spec.supersede` is `Some(key)`, any in-flight predecessor /// under the same key has its cancel token flipped *before* this /// allocation returns, and the `key → id` table is updated to /// point at the new id. The predecessor's pending entry is /// retained --- its worker will produce a `Cancelled` reply that /// `tick` then surfaces. - fn allocate( - &self, - kind: JobKind, - supersede_key: Option<&str>, - stream: Option, - ) -> (JobId, CancellationToken) { - self.allocate_with_resource(kind, supersede_key, stream, None) - } - - /// [`Self::allocate`], plus the filesystem mutation this job - /// performs. Only the two mutating fs dispatchers pass `resource`. - fn allocate_with_resource( - &self, - kind: JobKind, - supersede_key: Option<&str>, - stream: Option, - resource: Option, - ) -> (JobId, CancellationToken) { + /// + /// The recorded purpose **composes** with any dispatch-name ambient + /// rather than replacing it (Q#W-2 rule 6): `": "` + /// where the dispatcher described its own work, `""` where it + /// did not. Letting the dispatcher's purpose win would lose the + /// third-party caller all over again; letting the name win would + /// discard the only description of the actual work. + fn allocate(&self, spec: JobSpec<'_>) -> (JobId, CancellationToken) { + let JobSpec { + kind, + supersede: supersede_key, + stream, + resource, + purpose, + } = spec; + let purpose = match self.current_dispatch_name() { + Some(name) if purpose.is_empty() => name, + Some(name) => format!("{name}: {purpose}"), + None => purpose, + }; let id = self.next_job_id.fetch_add(1, Ordering::Relaxed); let cancel = CancellationToken::new(); if let Some(key) = supersede_key { @@ -788,6 +931,7 @@ impl AsyncRuntime { kind, dispatched_at: Instant::now(), resource, + purpose, }, ); (id, cancel) @@ -801,7 +945,13 @@ impl AsyncRuntime { /// dispatched under `key` is cancelled before this dispatch /// returns. T M3.4 / [spec §6.3]. pub fn dispatch_sleep(&self, ms: i64, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::Sleep, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Sleep, + supersede, + stream: None, + resource: None, + purpose: format!("sleep {}ms", ms.max(0)), + }); let bus = self.workers.clone(); let total = Duration::from_millis(ms.max(0).unsigned_abs()); self.pool.dispatch(move |_pool| { @@ -816,7 +966,13 @@ impl AsyncRuntime { /// the granular cancel boundary. `supersede` follows the same /// rule as [`Self::dispatch_sleep`]. pub fn dispatch_compute_sum(&self, n: u64, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::ComputeSum, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::ComputeSum, + supersede, + stream: None, + resource: None, + purpose: format!("sum 1..{n}"), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_compute_sum(&cancel, n); @@ -842,7 +998,13 @@ impl AsyncRuntime { max_batch: Option, ) -> JobId { let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000)); - let (id, cancel) = self.allocate(JobKind::EmitN, supersede, Some(cap)); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::EmitN, + supersede, + stream: Some(cap), + resource: None, + purpose: format!("emit {count} items"), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { run_emit_n(&cancel, &bus, id, count); @@ -870,7 +1032,13 @@ impl AsyncRuntime { max_batch: Option, ) -> JobId { let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000)); - let (id, cancel) = self.allocate(JobKind::Grep, supersede, Some(cap)); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Grep, + supersede, + stream: Some(cap), + resource: None, + purpose: format!("grep {:?} in {}", spec.pattern, spec.root.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { run_grep(&cancel, &bus, id, spec); @@ -897,7 +1065,13 @@ impl AsyncRuntime { /// in-flight predecessor under the same key has its cancel token /// flipped synchronously. T M4.1 / [spec §6.3]. pub fn dispatch_parse(&self, spec: ParseRequest, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::Parse, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::Parse, + supersede, + stream: None, + resource: None, + purpose: format!("parse {}", spec.language_name), + }); let bus = self.workers.clone(); let handoff = self.parse_handoff.clone(); self.pool.dispatch(move |_pool| { @@ -922,7 +1096,13 @@ impl AsyncRuntime { tolerance: ReadDirTolerance, supersede: Option<&str>, ) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsReadDir, + supersede, + stream: None, + resource: None, + purpose: format!("read_dir {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_read_dir(&cancel, &path, tolerance); @@ -934,7 +1114,13 @@ impl AsyncRuntime { /// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of /// metadata for `path`. T M8.1. pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsStat, + supersede, + stream: None, + resource: None, + purpose: format!("stat {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_stat(&cancel, &path); @@ -948,15 +1134,16 @@ impl AsyncRuntime { pub fn dispatch_fs_rename(&self, from: PathBuf, to: PathBuf, supersede: Option<&str>) -> JobId { // The closure below MOVES both paths; the pending entry is the // only thing that still knows them when the reply lands. - let (id, cancel) = self.allocate_with_resource( - JobKind::FsRename, + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsRename, supersede, - None, - Some(ResourceOp::Rename { + stream: None, + resource: Some(ResourceOp::Rename { from: from.clone(), to: to.clone(), }), - ); + purpose: format!("rename {} -> {}", from.display(), to.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_rename(&cancel, &from, &to); @@ -967,7 +1154,13 @@ impl AsyncRuntime { /// Dispatch a `chmod(path, mode)` job. T M8.1. pub fn dispatch_fs_chmod(&self, path: PathBuf, mode: u32, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None); + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsChmod, + supersede, + stream: None, + resource: None, + purpose: format!("chmod {mode:o} {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_chmod(&cancel, &path, mode); @@ -978,12 +1171,13 @@ impl AsyncRuntime { /// Dispatch a `remove(path)` job. T M8.1. pub fn dispatch_fs_remove(&self, path: PathBuf, supersede: Option<&str>) -> JobId { - let (id, cancel) = self.allocate_with_resource( - JobKind::FsRemove, + let (id, cancel) = self.allocate(JobSpec { + kind: JobKind::FsRemove, supersede, - None, - Some(ResourceOp::Remove { path: path.clone() }), - ); + stream: None, + resource: Some(ResourceOp::Remove { path: path.clone() }), + purpose: format!("remove {}", path.display()), + }); let bus = self.workers.clone(); self.pool.dispatch(move |_pool| { let kind = run_fs_remove(&cancel, &path); @@ -1008,12 +1202,27 @@ impl AsyncRuntime { /// same supervisor (DAP, etc.) reuse this surface. /// /// `supersede` follows the same rule as the worker dispatchers. + /// + /// `purpose` is **required and has no derivable fallback** here, + /// which is why it is a parameter rather than something this method + /// composes for itself. The ten pool dispatchers each know what + /// their own job does; `register_external` knows only a `JobKind` + /// that is `McpRequest` or `LspRequest` — a category, not a + /// description. The caller is the only party that can say + /// `"lsp textDocument/definition"`. pub fn register_external( &self, kind: JobKind, supersede: Option<&str>, + purpose: impl Into, ) -> (JobId, CancellationToken) { - self.allocate(kind, supersede, None) + self.allocate(JobSpec { + kind, + supersede, + stream: None, + resource: None, + purpose: purpose.into(), + }) } /// Settle an externally-registered job with a JSON value. Wakes @@ -1206,6 +1415,7 @@ impl AsyncRuntime { dispatched_at: job.dispatched_at, settled_at: now, supersede_key: job.supersede_key.clone(), + purpose: job.purpose.clone(), outcome, }); } @@ -1243,6 +1453,7 @@ impl AsyncRuntime { supersede_key: j.supersede_key.clone(), cancel_requested: j.cancel.is_cancelled(), is_stream: j.stream_buffer.is_some(), + purpose: j.purpose.clone(), }) .collect(); // Stable order: oldest first. The buffer renderer renders in @@ -1262,12 +1473,51 @@ impl AsyncRuntime { .as_millis() as u64, settled_age_ms: now.saturating_duration_since(c.settled_at).as_millis() as u64, supersede_key: c.supersede_key.clone(), + purpose: c.purpose.clone(), outcome: c.outcome.clone(), }) .collect(); WorkersSnapshot { active, completed } } + /// What the statusline activity indicator shows, or `None` when + /// nothing is in flight (worker identity Stage 1, Q#W-3). + /// + /// `None` at zero is the contract, not an optimization: the + /// indicator renders **no segment at all** when idle, because a + /// statusline element that is always present costs modeline width + /// forever to say "nothing is happening". + /// + /// Scans the pending table rather than reusing + /// [`Self::workers_snapshot`]: this runs once per visible window per + /// frame, and a snapshot would clone the whole completed ring that + /// the indicator never reads. + #[must_use] + pub fn activity_summary(&self) -> Option { + let pending = self.pending.borrow(); + let mut in_flight = 0usize; + let mut oldest: Option<(&Instant, &str)> = None; + for job in pending.values() { + if !matches!(job.state, PendingState::Running) { + continue; + } + in_flight += 1; + // Strictly-earlier wins, so the first job seen holds the + // slot against later ties. `HashMap` iteration order is + // arbitrary, so two jobs dispatched in the same `Instant` + // resolve arbitrarily — a tie between simultaneous jobs has + // no right answer to lose. + if oldest.is_none_or(|(seen, _)| job.dispatched_at < *seen) { + oldest = Some((&job.dispatched_at, job.purpose.as_str())); + } + } + let (_, purpose) = oldest?; + Some(ActivitySummary { + in_flight, + oldest_purpose: purpose.to_owned(), + }) + } + /// Drain the per-stream accumulators into one batch each. Each /// returned batch is bounded by the stream's `max_batch`; items /// beyond the cap stay in the accumulator until the next call. @@ -1876,23 +2126,25 @@ mod tests { fn tick_reports_resources_in_bus_arrival_order_not_allocation_order() { fn run(reverse: bool) -> Vec { let rt = AsyncRuntime::with_pool_size(1); - let (a, _) = rt.allocate_with_resource( - JobKind::FsRename, - None, - None, - Some(ResourceOp::Rename { + let (a, _) = rt.allocate(JobSpec { + kind: JobKind::FsRename, + supersede: None, + stream: None, + resource: Some(ResourceOp::Rename { from: PathBuf::from("/tmp/a-from"), to: PathBuf::from("/tmp/a-to"), }), - ); - let (b, _) = rt.allocate_with_resource( - JobKind::FsRemove, - None, - None, - Some(ResourceOp::Remove { + purpose: "rename a".to_owned(), + }); + let (b, _) = rt.allocate(JobSpec { + kind: JobKind::FsRemove, + supersede: None, + stream: None, + resource: Some(ResourceOp::Remove { path: PathBuf::from("/tmp/b-gone"), }), - ); + purpose: "remove b".to_owned(), + }); let order = if reverse { [b, a] } else { [a, b] }; for id in order { rt.workers @@ -1936,23 +2188,25 @@ mod tests { #[test] fn a_failed_or_cancelled_resource_job_is_not_harvested() { let rt = AsyncRuntime::with_pool_size(1); - let (failed, _) = rt.allocate_with_resource( - JobKind::FsRename, - None, - None, - Some(ResourceOp::Rename { + let (failed, _) = rt.allocate(JobSpec { + kind: JobKind::FsRename, + supersede: None, + stream: None, + resource: Some(ResourceOp::Rename { from: PathBuf::from("/tmp/nope"), to: PathBuf::from("/tmp/also-nope"), }), - ); - let (cancelled, _) = rt.allocate_with_resource( - JobKind::FsRemove, - None, - None, - Some(ResourceOp::Remove { + purpose: "rename nope".to_owned(), + }); + let (cancelled, _) = rt.allocate(JobSpec { + kind: JobKind::FsRemove, + supersede: None, + stream: None, + resource: Some(ResourceOp::Remove { path: PathBuf::from("/tmp/never"), }), - ); + purpose: "remove never".to_owned(), + }); rt.workers .send( ASYNC_REPLY_TOPIC, diff --git a/src/lsp.rs b/src/lsp.rs index f5630b6..43632f9 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -167,7 +167,11 @@ impl LspServerSpec { } fn to_process_spec(&self) -> ProcessSpec { - let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command); + let mut p = ProcessSpec::new( + format!("lsp:{}", self.label), + &self.command, + format!("language server for {}", self.label), + ); p.args.clone_from(&self.args); p.cwd.clone_from(&self.cwd); p.env.clone_from(&self.env); @@ -1587,9 +1591,15 @@ impl LspManager { uri: &str, ) -> JobId { let supersede = format!("lsp:{method}:{}:{uri}", sid.raw()); - let (job_id, token) = self - .runtime - .register_external(JobKind::LspRequest, Some(&supersede)); + // Worker identity Stage 1: `register_external` bypasses the + // worker pool, so its `JobKind` is the undifferentiated + // `LspRequest` for every method. The method and the document are + // the only thing that makes one row distinguishable from another + // in `*workers*`. + let purpose = format!("lsp {method} {uri}"); + let (job_id, token) = + self.runtime + .register_external(JobKind::LspRequest, Some(&supersede), purpose); self.pending_external.insert( (sid, req_id), PendingExternal { @@ -4538,7 +4548,8 @@ mod resource_reconciliation_tests { let runtime = mgr.runtime.clone(); let mut register = |rid: u64, uri: &str| { - let (job_id, token) = runtime.register_external(JobKind::LspRequest, None); + let (job_id, token) = + runtime.register_external(JobKind::LspRequest, None, format!("lsp hover {uri}")); mgr.pending_routes.insert( (a, rid), ResponseRoute::Hover { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b2de320..9535f81 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7566,6 +7566,73 @@ pub fn install_async( })?, )?; + // Worker identity Stage 1 (Q#W-2): the dispatch-name ambient. + // + // `pmacs.workers.dispatch(name, …)` is the one place a third-party + // job's own name exists, and nothing below it takes a name — the + // Rust dispatchers accept job arguments, a supersede key and stream + // data, and a handler reaching straight for `_dispatch_*` bypasses + // the Lua wrapper layer entirely. So the name travels out of band + // and is read at `allocate`, the single funnel every job passes + // through. + // + // Runtime-internal, underscore-prefixed: package code calls + // `pmacs.workers.dispatch`, which brackets these itself under + // `pcall`. A package pushing by hand and failing to pop would poison + // every later dispatch in the session with a stale name. + { + let rt = runtime.clone(); + async_mod.set( + "_push_dispatch_name", + lua.create_function(move |_, name: String| { + rt.push_dispatch_name(name); + Ok(()) + })?, + )?; + } + + { + let rt = runtime.clone(); + async_mod.set( + "_pop_dispatch_name", + lua.create_function(move |_, ()| { + rt.pop_dispatch_name(); + Ok(()) + })?, + )?; + } + + // The refusal predicate, the sibling of `_in_commit_scope` above and + // enforced for the same reason: a coroutine that parks inside the + // extent leaves the name pushed, and every job allocated in the + // meantime — in any coroutine, on any later tick — inherits it. + { + let rt = runtime.clone(); + async_mod.set( + "_in_dispatch_name_scope", + lua.create_function(move |_, ()| Ok(rt.in_dispatch_name_scope()))?, + )?; + } + + // The statusline activity indicator's read surface (Q#W-3). Returns + // `nil` when nothing is in flight — the indicator renders no segment + // at all when idle, so "absent" has to be representable. + { + let rt = runtime.clone(); + async_mod.set( + "_activity_summary", + lua.create_function(move |lua, ()| { + let Some(summary) = rt.activity_summary() else { + return Ok(mlua::Value::Nil); + }; + let t = lua.create_table_with_capacity(0, 2)?; + t.set("in_flight", summary.in_flight)?; + t.set("purpose", summary.oldest_purpose)?; + Ok(mlua::Value::Table(t)) + })?, + )?; + } + { let rt = runtime.clone(); async_mod.set( @@ -7728,7 +7795,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res let out = lua.create_table()?; let active = lua.create_table_with_capacity(snap.active.len(), 0)?; for (i, job) in snap.active.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 6)?; + let row = lua.create_table_with_capacity(0, 7)?; row.set("id", job.id)?; row.set("kind", job.kind.label())?; row.set("age_ms", job.age_ms)?; @@ -7737,12 +7804,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res } row.set("cancel_requested", job.cancel_requested)?; row.set("is_stream", job.is_stream)?; + row.set("purpose", job.purpose.as_str())?; active.set(i + 1, row)?; } out.set("active", active)?; let completed = lua.create_table_with_capacity(snap.completed.len(), 0)?; for (i, job) in snap.completed.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 7)?; + let row = lua.create_table_with_capacity(0, 8)?; row.set("id", job.id)?; row.set("kind", job.kind.label())?; row.set("duration_ms", job.duration_ms)?; @@ -7750,6 +7818,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res if let Some(key) = &job.supersede_key { row.set("supersede", key.as_str())?; } + row.set("purpose", job.purpose.as_str())?; let (status, value): (&'static str, mlua::Value) = match &job.outcome { JobOutcome::Complete(JobResult::Unit) => ("ok", mlua::Value::Nil), JobOutcome::Complete(JobResult::Sum(v)) => ( @@ -8680,6 +8749,21 @@ fn parse_restart(name: &str) -> mlua::Result { fn lua_to_spec(table: &Table) -> mlua::Result { let label: String = table.get("label").unwrap_or_else(|_| "unnamed".to_owned()); let command: String = table.get("command")?; + // Worker identity Stage 1: required on the Rust struct, optional at + // this surface, falling back to the label. + // + // Requiring it here would break every existing `pmacs.process.spawn` + // caller, and the compiler obligation this lane is buying is on the + // *Rust* construction sites — the ones a future field would silently + // skip. A Lua caller that supplies nothing gets its own label back, + // which is what the caller already chose to call this work; it is + // less informative than a real description but it is not a + // fabrication, which is the bar `owner` failed (framing §3). + let purpose: String = table + .get::>("purpose") + .ok() + .flatten() + .unwrap_or_else(|| label.clone()); let args: Vec = table.get("args").unwrap_or_default(); let cwd: Option = table.get("cwd").ok().flatten(); let env_table: Option
= table.get("env").ok().flatten(); @@ -8762,6 +8846,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result { }; Ok(ProcessSpec { label, + purpose, command, args, cwd: cwd.map(std::path::PathBuf::from), @@ -8985,11 +9070,18 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: .collect(); let out = lua.create_table_with_capacity(ids.len(), 0)?; for (i, id) in ids.iter().enumerate() { - let row = lua.create_table_with_capacity(0, 3)?; + let row = lua.create_table_with_capacity(0, 4)?; row.set("id", ProcessIdLua(*id))?; if let Some(spec) = sup.spec(*id) { row.set("label", spec.label.as_str())?; row.set("command", spec.command.as_str())?; + // Worker identity Stage 1: a new KEY on each + // existing row. The row COUNT is deliberately + // untouched — three acceptance suites assert on + // `#pmacs.process.list()` as a leak detector + // (framing Q#W-4), and widening what this + // enumerates would inflate all three baselines. + row.set("purpose", spec.purpose.as_str())?; } if let Some(state) = sup.state(*id) { row.set("state", state_to_lua(lua, state)?)?; diff --git a/src/mcp.rs b/src/mcp.rs index b1db5f4..f1085ca 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -175,7 +175,11 @@ impl McpServerSpec { } fn to_process_spec(&self) -> ProcessSpec { - let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command); + let mut p = ProcessSpec::new( + format!("mcp:{}", self.label), + &self.command, + format!("MCP server {}", self.label), + ); p.args.clone_from(&self.args); p.cwd.clone_from(&self.cwd); p.env.clone_from(&self.env); @@ -873,7 +877,9 @@ impl McpManager { } let req_id = next_request_id(client); let body = make_request(req_id, &method, params); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = + self.runtime + .register_external(JobKind::McpRequest, None, format!("mcp {method}")); client.pending_external.insert( req_id, PendingExternal { @@ -948,7 +954,11 @@ impl McpManager { // (1) Cache hit. if let Some(ResourceCacheState::Cached { result }) = self.resource_cache.get(&key).cloned() { - let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, _token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri} (cached)"), + ); self.runtime.complete_external_ok(job_id, result); return Ok(job_id); } @@ -959,7 +969,11 @@ impl McpManager { // independently. if let Some(ResourceCacheState::InFlight { request_id }) = self.resource_cache.get(&key) { let in_flight_rid = *request_id; - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri}"), + ); if let Some(p) = client.pending_external.get_mut(&in_flight_rid) { p.awaiters.push(Awaiter { job_id, token }); return Ok(job_id); @@ -974,7 +988,11 @@ impl McpManager { // (3) Cache miss: dispatch. let req_id = next_request_id(client); let body = make_request(req_id, "resources/read", json!({ "uri": uri })); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self.runtime.register_external( + JobKind::McpRequest, + None, + format!("mcp resources/read {uri}"), + ); client.pending_external.insert( req_id, PendingExternal { @@ -1063,10 +1081,13 @@ impl McpManager { // than referenced by `json!`); avoids a needless-pass-by- // value clippy complaint and matches `send_request`'s shape. let mut params_map = Map::new(); + let purpose = format!("mcp tools/call {name}"); params_map.insert("name".into(), Value::String(name)); params_map.insert("arguments".into(), arguments); let body = make_request(req_id, "tools/call", Value::Object(params_map)); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self + .runtime + .register_external(JobKind::McpRequest, None, purpose); client.pending_external.insert( req_id, PendingExternal { @@ -1125,10 +1146,13 @@ impl McpManager { } let req_id = next_request_id(client); let mut params_map = Map::new(); + let purpose = format!("mcp prompts/get {name}"); params_map.insert("name".into(), Value::String(name)); params_map.insert("arguments".into(), arguments); let body = make_request(req_id, "prompts/get", Value::Object(params_map)); - let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None); + let (job_id, token) = self + .runtime + .register_external(JobKind::McpRequest, None, purpose); client.pending_external.insert( req_id, PendingExternal { diff --git a/src/process.rs b/src/process.rs index c4a9277..17a5365 100644 --- a/src/process.rs +++ b/src/process.rs @@ -196,6 +196,23 @@ pub struct ProcessSpec { /// so multiple processes can run the same binary with /// distinguishable labels. pub label: String, + /// What this process is doing, in words a user can read (worker + /// identity Stage 1, `COHERENCE.md` §9). + /// + /// **Required, and not the same thing as [`Self::label`].** The + /// label is an *identity* — `lsp:rust-analyzer`, a terminal's buffer + /// name — spelled however the caller likes, so that two processes + /// running the same binary can be told apart. The purpose is a + /// *description*: it answers "what is happening", which is the + /// question §3's promise of visible asynchronous work is about and + /// which a label chosen for uniqueness routinely does not answer. + /// + /// **Not an owner**, in any spelling. It records what the process is + /// doing, not which package asked for it; `pmacs.process.spawn` is + /// callable by any package, so a value derived here would + /// misattribute third-party work to a builtin at exactly the point + /// §9 wants attribution (framing §3). + pub purpose: String, /// Program to execute. Looked up via the system PATH unless an /// absolute path is supplied. pub command: String, @@ -237,10 +254,21 @@ pub struct ProcessSpec { impl ProcessSpec { /// Construct a spec with the bare-minimum fields. Convenience /// for tests and one-off scripts. + /// + /// `purpose` is a parameter rather than something derived from the + /// label because it is a required field with no honest default + /// (worker identity Stage 1): deriving it from the label would make + /// every process claim its identity *is* its description, which is + /// exactly the conflation the field exists to undo. #[must_use] - pub fn new(label: impl Into, command: impl Into) -> Self { + pub fn new( + label: impl Into, + command: impl Into, + purpose: impl Into, + ) -> Self { Self { label: label.into(), + purpose: purpose.into(), command: command.into(), args: Vec::new(), cwd: None, @@ -2722,6 +2750,7 @@ mod tests { let spec = ProcessSpec::new( "unpublished-terminal", "/definitely/not/a/real/pmacs-terminal-program", + "test process", ); assert!(supervisor.spawn_terminal(spec).is_err()); supervisor.tick(); @@ -2732,7 +2761,7 @@ mod tests { #[test] fn spawn_pipes_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("echo-test", "/bin/sh"); + let mut spec = ProcessSpec::new("echo-test", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo hello && exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); @@ -2892,7 +2921,7 @@ mod tests { /// A plain PTY child, for tests that care about the PTY *branch* /// rather than about job control. fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) { - let mut spec = ProcessSpec::new(name, "/bin/sleep"); + let mut spec = ProcessSpec::new(name, "/bin/sleep", "test process"); spec.args = vec!["30".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -2943,7 +2972,7 @@ mod tests { sup: &mut ProcessSupervisor, name: &str, ) -> (ProcessId, u32, i32) { - let mut spec = ProcessSpec::new(name, BASH); + let mut spec = ProcessSpec::new(name, BASH, "test process"); spec.args = vec![ "--noprofile".into(), "--norc".into(), @@ -3194,7 +3223,7 @@ mod tests { #[test] fn a_pipe_child_still_renders_a_bare_leader_target() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep"); + let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep", "test process"); spec.args = vec!["30".into()]; let id = sup.spawn(spec).expect("spawn"); let pid = spawn_started_pid(&mut sup, id); @@ -3227,7 +3256,7 @@ mod tests { let mut reports = Vec::new(); for signal in [Signal::SIGTERM, Signal::SIGUSR1] { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3280,7 +3309,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); let temp = tempfile::TempDir::new().expect("tempdir"); let ready = temp.path().join("usr1-trapped"); - let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh", "test process"); // Ignore USR1 so the successful non-fatal signal cannot end the // child and confuse the state assertion with a real exit — and // then WAIT for the child to say it has done so. `Started` is @@ -3344,7 +3373,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); let temp = tempfile::TempDir::new().expect("tempdir"); let ready = temp.path().join("usr1-trapped"); - let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh", "test process"); spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "sleep 1; ")]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3435,7 +3464,7 @@ mod tests { #[test] fn a_leader_directed_kill_failure_reports_the_fallback_branch() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep"); + let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep", "test process"); spec.args = vec!["30".into()]; let id = sup.spawn(spec).expect("spawn"); let pid = spawn_started_pid(&mut sup, id); @@ -3484,7 +3513,7 @@ mod tests { #[test] fn a_failure_after_the_child_exits_reports_the_leader_as_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-exited", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 3".into()]; let id = sup.spawn(spec).expect("spawn"); // NOT `spawn_started_pid`: draining ticks, and this child exits @@ -3512,7 +3541,7 @@ mod tests { #[test] fn an_injected_failure_changes_no_state_and_arms_no_ledger() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); @@ -3557,7 +3586,7 @@ mod tests { #[test] fn observing_the_leader_does_not_consume_the_exit_event() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh"); + let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 7".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -3599,7 +3628,7 @@ mod tests { let mut sup = ProcessSupervisor::new(); // `sleep 30` is long enough that the test definitely needs // to terminate it deliberately. - let mut spec = ProcessSpec::new("sleeper", "/bin/sh"); + let mut spec = ProcessSpec::new("sleeper", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); // Wait for Started so we have a pid. @@ -3628,7 +3657,7 @@ mod tests { // implementation blocked the caller in `write_all` here — // which in the editor was the main thread, wedging the frame // loop whenever an LSP server fell behind on its stdin. - let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh"); + let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -3654,7 +3683,7 @@ mod tests { // payload back followed by a clean exit proves the writer // thread drains its queue before dropping the pipe (the // flush-then-EOF contract `close_stdin` documents). - let mut spec = ProcessSpec::new("cat-echo", "/bin/sh"); + let mut spec = ProcessSpec::new("cat-echo", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "cat".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -3700,7 +3729,7 @@ mod tests { fn restart_on_crash_respawns_after_nonzero_exit() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(10)); - let mut spec = ProcessSpec::new("crasher", "/bin/sh"); + let mut spec = ProcessSpec::new("crasher", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 7".into()]; spec.restart = RestartPolicy::OnCrash; let id = sup.spawn(spec).expect("spawn"); @@ -3731,7 +3760,7 @@ mod tests { #[test] fn restart_never_does_not_respawn_after_clean_exit() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("oneshot", "/bin/sh"); + let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exited); @@ -3760,7 +3789,7 @@ mod tests { let pid = { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(200)); - let mut spec = ProcessSpec::new("victim", "/bin/sh"); + let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); // Drain until Started so we know the pid. @@ -3798,7 +3827,7 @@ mod tests { #[test] fn pty_mode_child_sees_a_tty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ttytest", "/bin/sh"); + let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "tty".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); @@ -3835,7 +3864,7 @@ mod tests { #[test] fn m6_1_pty_resize_delivers_sigwinch_to_child() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("winch-watch", "/bin/sh"); + let mut spec = ProcessSpec::new("winch-watch", "/bin/sh", "test process"); // Trap WINCH, print READY for synchronization, then loop on // a short sleep so SIGWINCH can interrupt and fire the trap. spec.args = vec![ @@ -3880,7 +3909,7 @@ mod tests { #[test] fn m6_1_pty_mode_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("pty-exit", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-exit", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo done && exit 0".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); @@ -3915,7 +3944,7 @@ mod tests { #[test] fn m6_1_pty_raw_mode_disables_kernel_echo() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("raw-stty", "/bin/sh"); + let mut spec = ProcessSpec::new("raw-stty", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "stty -a".into()]; spec.mode = ProcessMode::default_pty(); // Raw by default. let id = sup.spawn(spec).expect("spawn"); @@ -3937,7 +3966,7 @@ mod tests { #[test] fn m6_1_pty_canonical_mode_keeps_kernel_echo() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("canon-stty", "/bin/sh"); + let mut spec = ProcessSpec::new("canon-stty", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "stty -a".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -3991,7 +4020,7 @@ mod tests { // buffers. const TOTAL: usize = 10 * 1024 * 1024; let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("byte-flood", "/bin/sh"); + let mut spec = ProcessSpec::new("byte-flood", "/bin/sh", "test process"); spec.args = vec!["-c".into(), format!("head -c {TOTAL} /dev/zero")]; let id = sup.spawn(spec).expect("spawn"); @@ -4067,7 +4096,7 @@ mod tests { #[test] fn m6_2_pty_streaming_coalesces_per_tick() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh"); + let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh", "test process"); // 1 MiB of zeros from /dev/zero. The reader thread reads in // [`BYTE_CHUNK_SIZE`] (8 KiB) chunks --- ~128 reads --- all // queued onto the bounded channel within microseconds of @@ -4116,7 +4145,7 @@ mod tests { #[test] fn m6_2_ansi_enabled_pty_emits_structured_events() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh"); + let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()]; spec.mode = ProcessMode::Pty { rows: 24, @@ -4198,7 +4227,7 @@ mod tests { let handle = std::thread::spawn(move || { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(300)); - let mut spec = ProcessSpec::new("forever-flood", "/bin/sh"); + let mut spec = ProcessSpec::new("forever-flood", "/bin/sh", "test process"); // Continuous writer; SIGTERM kills it (no signal handler). spec.args = vec!["-c".into(), "while :; do printf 'X'; done".into()]; let id = sup.spawn(spec).expect("spawn"); @@ -4324,7 +4353,7 @@ mod tests { let handle = std::thread::spawn(move || { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(300)); - let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid"); + let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid", "test process"); // `setsid --fork` forks and the parent exits, so the // *recorded* pid terminates promptly (letting `poll_one` // reach the teardown path) while `cat` survives holding the @@ -4412,7 +4441,7 @@ mod tests { // ----------------------------------------------------------------- fn sh_group_spec(label: &str, script: &str) -> ProcessSpec { - let mut spec = ProcessSpec::new(label, "/bin/sh"); + let mut spec = ProcessSpec::new(label, "/bin/sh", "test process"); spec.args = vec!["-c".into(), script.to_owned()]; spec.stdin = StdinMode::Null; spec.group = true; @@ -4546,7 +4575,7 @@ mod tests { ); // Control: a non-group child inherits the test process's // group instead of leading its own. - let mut plain = ProcessSpec::new("plain", "/bin/sh"); + let mut plain = ProcessSpec::new("plain", "/bin/sh", "test process"); plain.args = vec!["-c".into(), "sleep 30".into()]; let plain_id = sup.spawn(plain).expect("spawn plain"); let plain_events = drain_until(&mut sup, plain_id, Duration::from_secs(2), |evs| { @@ -5017,7 +5046,7 @@ mod tests { fn maybe_restart_inert_once_shut_down() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(30)); - let mut spec = ProcessSpec::new("restarter", "/bin/sh"); + let mut spec = ProcessSpec::new("restarter", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "echo x".into()]; spec.restart = RestartPolicy::Always; let id = sup.spawn(spec).expect("spawn"); @@ -5158,7 +5187,7 @@ mod tests { #[test] fn group_and_null_stdin_rejected_under_pty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("pty-null", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-null", "/bin/sh", "test process"); spec.mode = ProcessMode::default_pty(); spec.stdin = StdinMode::Null; let err = sup @@ -5169,7 +5198,7 @@ mod tests { "error points at pipe mode: {err}" ); - let mut spec = ProcessSpec::new("pty-group", "/bin/sh"); + let mut spec = ProcessSpec::new("pty-group", "/bin/sh", "test process"); spec.mode = ProcessMode::default_pty(); spec.group = true; let err = sup diff --git a/src/terminal/session.rs b/src/terminal/session.rs index c731fb0..47fae56 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -305,7 +305,8 @@ impl TerminalManager { buffer.set_read_only(true); core.registry.borrow_mut().insert(buffer); - let mut process_spec = ProcessSpec::new(buffer_name, spec.command); + let purpose = format!("terminal running {}", spec.command); + let mut process_spec = ProcessSpec::new(buffer_name, spec.command, purpose); process_spec.args = spec.args; process_spec.cwd = spec.cwd; process_spec.env = spec.env; diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 6a6eeb4..02d6d03 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -14,19 +14,34 @@ //! ```text //! Workers (active: 2, completed: 5) //! -//! ID Kind Age Supersede Status -//! ------ ----------- -------- ---------- ---------- -//! #5 grep 412ms search running -//! #6 sleep 18ms running (cancel pending) +//! ID Kind Age Supersede Purpose Status +//! ------ ----------- -------- ---------- ------------------------ ---------- +//! #5 grep 412ms search search: grep "fn" in /x running +//! #6 sleep 18ms sleep 18ms running (cancel pending) //! //! Recent (newest first) //! -//! ID Kind Duration Supersede Outcome -//! ------ ----------- -------- ---------- ---------- -//! #4 grep 1242ms search cancelled (3s ago) -//! #3 compute_sum 2ms ok (3s ago) +//! ID Kind Duration Supersede Purpose Outcome +//! ------ ----------- -------- ---------- ------------------------ ---------- +//! #4 grep 1242ms search search: grep "fn" in /x cancelled (3s ago) +//! #3 compute_sum 2ms sum 1..100 ok (3s ago) //! ``` //! +//! # Purpose (worker identity Stage 1, `COHERENCE.md` §9) +//! +//! The `Purpose` column is what turns "twelve rows named `lsp_request`" +//! into a readable account of what the editor is doing. `Kind` names the +//! builtin dispatcher a job funnelled through, which for every +//! third-party job is a builtin's label rather than the caller's; the +//! purpose carries the work's own description and, under +//! `pmacs.workers.dispatch`, the registered handler name it ran under. +//! +//! It is placed **before** `Status` and padded, because `Status` is +//! variable-width (`running (cancel pending) [stream]`) and two +//! ragged trailing columns render as noise. An over-long purpose pushes +//! `Status` right rather than being truncated: losing the end of a path +//! is a worse failure than an uneven column. +//! //! Lua reads the snapshot via `pmacs.workers.snapshot()`; the //! `pmacs.workers.show()` builtin invokes [`render`] on it and //! returns the buffer id. Auto-refresh hooks into @@ -43,6 +58,11 @@ use crate::buffer_registry::BufferRegistry; /// Canonical name for the workers observability buffer. pub const WORKERS_BUFFER_NAME: &str = "*workers*"; +/// Minimum column width the `Purpose` column is padded to. Purposes +/// longer than this push the trailing column right rather than being +/// truncated (see the module docs). +const PURPOSE_WIDTH: usize = 24; + /// Render `snapshot` into the `*workers*` buffer (creating it if /// absent), replacing its full contents. Returns the buffer id /// and the Edits produced by the replacement (zero, one, or two — @@ -119,13 +139,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String { let _ = writeln!(text); let _ = writeln!( text, - "{:<7} {:<11} {:>9} {:<11} Status", - "ID", "Kind", "Age", "Supersede" + "{:<7} {:<11} {:>9} {:<11} {:9} {:<11} ----------", - "------", "-----------", "---------", "-----------" + "{:<7} {:<11} {:>9} {:<11} {: String { let _ = writeln!(text); let _ = writeln!( text, - "{:<7} {:<11} {:>9} {:<11} Outcome", - "ID", "Kind", "Duration", "Supersede" + "{:<7} {:<11} {:>9} {:<11} {:9} {:<11} ----------", - "------", "-----------", "---------", "-----------" + "{:<7} {:<11} {:>9} {:<11} {:9} {key:<11} {status}"); + let purpose = &job.purpose; + let _ = writeln!( + text, + "{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:9} {key:<11} {outcome} ({age} ago)" + "{id:<7} {kind:<11} {duration:>9} {key:<11} {purpose: bool { #[test] fn m4_4_lifecycle_spawn_and_exit() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("hello", "/bin/sh"); + let mut spec = ProcessSpec::new("hello", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "printf hi && exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exit_event); @@ -983,7 +983,7 @@ fn m4_4_lifecycle_spawn_and_exit() { #[test] fn m4_4_lifecycle_signal_terminates() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("victim", "/bin/sh"); + let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 30".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -1020,7 +1020,11 @@ fn m4_4_lifecycle_signal_terminates() { fn m4_4_lifecycle_crash_surfaces_as_event() { let mut sup = ProcessSupervisor::new(); // Path that will reliably not resolve. - let spec = ProcessSpec::new("ghost", "/this/binary/does/not/exist/pmacs-m4-4"); + let spec = ProcessSpec::new( + "ghost", + "/this/binary/does/not/exist/pmacs-m4-4", + "test process", + ); let _ = sup.spawn(spec); // spawn returns Err but the event is still emitted sup.tick(); let evs = sup.take_all_events(); @@ -1037,7 +1041,7 @@ fn m4_4_lifecycle_crash_surfaces_as_event() { fn m4_4_restart_policy_on_crash_respawns() { let mut sup = ProcessSupervisor::new(); sup.set_restart_backoff(Duration::from_millis(10)); - let mut spec = ProcessSpec::new("flap", "/bin/sh"); + let mut spec = ProcessSpec::new("flap", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 9".into()]; spec.restart = RestartPolicy::OnCrash; let id = sup.spawn(spec).expect("spawn"); @@ -1070,7 +1074,7 @@ fn m4_4_restart_policy_on_crash_respawns() { #[test] fn m4_4_restart_policy_never_does_not_respawn() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("oneshot", "/bin/sh"); + let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "exit 0".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exit_event); @@ -1099,7 +1103,7 @@ fn m4_4_no_zombies_after_editor_drop() { let pid: u32 = { let mut sup = ProcessSupervisor::new(); sup.set_grace_period(Duration::from_millis(200)); - let mut spec = ProcessSpec::new("zombie-test", "/bin/sh"); + let mut spec = ProcessSpec::new("zombie-test", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "sleep 60".into()]; let id = sup.spawn(spec).expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { @@ -1136,7 +1140,7 @@ fn m4_4_no_zombies_after_editor_drop() { #[test] fn m4_4_pty_mode_child_observes_a_tty() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("ttytest", "/bin/sh"); + let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process"); spec.args = vec!["-c".into(), "tty".into()]; spec.mode = ProcessMode::default_pty(); let id = sup.spawn(spec).expect("spawn"); diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index a4dc5f1..c577af4 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -136,7 +136,12 @@ fn a01_04_registry_contract_limits_epochs_and_results() { .iter() .map(|provider| provider.name.as_str()) .collect::>(), - ["mode", "terminal", "lsp"], + // `activity` is worker identity Stage 1's fourth adopter, and it + // sorts first because `async.lua` is loaded before `syntax.lua`, + // `terminal.lua` and `lsp.lua`. This is an INVENTORY assertion: + // it grows when a builtin provider is added, which is exactly + // what it is for. + ["activity", "mode", "terminal", "lsp"], "built-in providers are discoverable in registration order" ); let before_epochs = { diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index 489ca9a..0c7a312 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -398,7 +398,7 @@ fn editor_shutdown_kills_term_ignoring_terminal_child() { #[test] fn terminal_tick_does_not_take_non_terminal_process_events() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); - let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); + let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh", "test process"); process.args = vec!["-c".into(), "printf ordinary".into()]; let ordinary_id = state .process_supervisor diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs new file mode 100644 index 0000000..d7bdbfb --- /dev/null +++ b/tests/worker_identity_acceptance.rs @@ -0,0 +1,849 @@ +// tests/worker_identity_acceptance.rs --- worker identity Stage 1. + +//! Worker identity Stage 1 (`docs/worker-identity-framing.md` §6, +//! `COHERENCE.md` §9). +//! +//! §9 grades the worker model **mechanism without identity**: a job +//! carries a `JobKind` naming the builtin dispatcher it funnelled +//! through, so every third-party job renders under a builtin's label, +//! and no progress indicator exists anywhere. This suite pins what +//! Stage 1 does about that — a required `purpose` on the job and the +//! process, the dispatch-name ambient that stops +//! `pmacs.workers.dispatch` discarding its handler name, and the first +//! indicator a user sees without running a command. +//! +//! # What is NOT here, deliberately +//! +//! **Presence is enforced by the COMPILER, not by anything below.** +//! `JobSpec::purpose` is non-optional and `JobSpec` has no `Default`, so +//! a dispatcher that supplies none does not build. A funnel test would +//! prove only that the funnel stores what it was handed, and would say +//! nothing about whether fourteen callers handed it anything meaningful. +//! Everything below is about *semantics*. +//! +//! **That a raw `coroutine.yield` inside either dynamic scope is +//! prevented — it is not.** R46 forbids package code from yielding +//! raw, but it is a convention, and the scheduler diagnoses a non-Handle +//! yield only *after* the coroutine has suspended (`async.lua` resumes, +//! then inspects what came back), so no refusal sited in a yield helper +//! is ever consulted. Rule 1 claims **the two supported yield APIs** and +//! nothing more. A test that "proved" coverage this design does not have +//! would be worse than the recorded gap, so the gap is recorded instead +//! (framing §2, §6, §7). +//! +//! **That background work is attributable from one place** (Stage 2's +//! unified view), **that a terminal PTY is visible anywhere** (Q#W-4), +//! or **that any job is attributed to the PACKAGE responsible for it**. +//! `purpose` records what work is being done and, under +//! `pmacs.workers.dispatch`, which registered handler it ran under. +//! Neither is package ownership, which waits for P3 — and there is no +//! `owner` field, in any spelling, for it to squat on (framing §3, §7). + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use pmacs::async_runtime::JobKind; +use pmacs::cell::{Cell, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, StatuslineProviderId, + evaluate_statusline, +}; + +#[path = "common/iso.rs"] +mod iso; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn editor() -> EditorState { + let state = EditorState::new_with_roots(&iso::roots()); + exec(&state, "pmacs.lsp.config = {}"); + state +} + +/// Drive the async runtime until nothing is in flight and no coroutine +/// is parked. How many frames that takes is not knowable in advance, so +/// this never counts them. +/// +/// Quiescence is measured as **no `Running` job**, not as an empty +/// pending table. Most jobs here are dispatched and never awaited — +/// that is the shape the indicator exists to describe — and a settled +/// entry stays in the pending table until someone takes its result, so +/// `pending_count() == 0` would never come true. +fn pump(state: &mut EditorState) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let idle: bool = eval( + state, + "return pmacs._async.parked_count() == 0 + and #pmacs.workers.snapshot().active == 0", + ); + if idle { + return; + } + assert!(Instant::now() < deadline, "async pump deadline exceeded"); + state.tick_async(); + } +} + +/// The purposes of every job the runtime currently has in flight. +/// +/// Read through the **Lua** snapshot surface, which is what `*workers*` +/// and any package consume, rather than through the Rust struct. +fn active_purposes(state: &EditorState) -> Vec { + eval( + state, + "local out = {} + for _, job in ipairs(pmacs.workers.snapshot().active) do + out[#out + 1] = job.purpose + end + return out", + ) +} + +fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec { + let mut cells = vec![Cell::default(); (rows * cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: cols, + size: CellSize::new(rows, cols), + }; + let _ = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); + cells +} + +fn row_text(cells: &[Cell], cols: u32, row: u32) -> String { + (0..cols) + .map( + |column| match &cells[(row * cols + column) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .and_then(|text| text.chars().next()) + .unwrap_or(' '), + Glyph::Continuation => ' ', + }, + ) + .collect() +} + +/// The registration handle of the builtin activity provider. +fn activity_provider(state: &EditorState) -> StatuslineProviderId { + state + .statusline_registry + .borrow() + .providers() + .into_iter() + .find(|provider| provider.name == "activity") + .expect("builtin activity provider") + .id +} + +/// The activity provider's segment for `LOCAL`'s only window, or `None` +/// when it produced **no segment at all**. +/// +/// `Option`, never `String`, is the whole point of this helper: +/// "absent" and "empty" must be distinguishable, because a zero-width +/// segment still consumes a separator in the composed modeline. +fn activity_segment(state: &EditorState) -> Option { + let id = activity_provider(state); + let evaluation = evaluate_statusline( + state.lua_host.lua(), + &state.core, + &state.statusline_registry, + StatuslineEvaluationTarget::Grid { + frontend_id: FrontendId::LOCAL, + }, + ); + let StatuslineEvaluationOutcome::Ready(windows) = evaluation.outcome else { + panic!("statusline evaluation must be ready in a single-window editor"); + }; + windows + .iter() + .flat_map(|window| window.left.iter().chain(window.right.iter())) + .find(|segment| segment.provider_id == id) + .map(|segment| segment.text.clone()) +} + +/// Dispatch one job that will still be **in flight** when the caller +/// looks, without sleeping. +/// +/// A pending entry leaves `Running` only inside `AsyncRuntime::tick`, so +/// a dispatch with no intervening tick is in flight by construction — +/// no wall-clock race, and no worker left sleeping past the test. +fn dispatch_one_in_flight(state: &EditorState) { + exec(state, "IN_FLIGHT = pmacs.workers.sleep(50)"); +} + +// --------------------------------------------------------------------------- +// 1 — purpose reaches the three structurally distinct entry paths +// --------------------------------------------------------------------------- + +/// One per distinct **shape**, not one per dispatcher: a pool +/// dispatcher, an `register_external` job, and a spawned process. +/// +/// `register_external` is here because MCP and LSP bypass the worker +/// pool entirely — they are the likeliest paths for a later field to be +/// added to `PendingJob` and quietly missed — and because its `JobKind` +/// is the undifferentiated `LspRequest`/`McpRequest` for every method, +/// so `purpose` is the only thing that tells two of its rows apart. +#[test] +fn every_entry_shape_records_what_its_work_is() { + let mut state = editor(); + + // (a) A pool dispatcher. + dispatch_one_in_flight(&state); + let purposes = active_purposes(&state); + assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}"); + assert_eq!( + purposes[0], "sleep 50ms", + "a pool job records the work, not just its handler's name" + ); + + // (b) An externally-settled job. The purpose is a PARAMETER here + // because `register_external` has nothing to derive one from: its + // kind is a category, not a description. + let (job_id, _token) = state.async_runtime.register_external( + JobKind::LspRequest, + None, + "lsp textDocument/definition file:///tmp/x.rs", + ); + let purposes = active_purposes(&state); + assert!( + purposes + .iter() + .any(|p| p == "lsp textDocument/definition file:///tmp/x.rs"), + "an externally-registered job carries its caller's description: {purposes:?}" + ); + state.async_runtime.complete_external_cancelled(job_id); + + // (c) A spawned process. `label` keeps its existing meaning and its + // existing callers; `purpose` is the new, separate answer to "what + // is this doing". + exec( + &state, + r#"P = pmacs.process.spawn { + label = "sh-1", + purpose = "probing the repository for a build system", + command = "/bin/sh", + args = { "-c", "sleep 5" }, + }"#, + ); + let rows: Vec = eval( + &state, + "local out = {} + for _, row in ipairs(pmacs.process.list()) do + out[#out + 1] = row.label .. ' | ' .. row.purpose + end + return out", + ); + assert!( + rows.iter() + .any(|row| row == "sh-1 | probing the repository for a build system"), + "a spawned process carries a purpose ALONGSIDE its label: {rows:?}" + ); + exec(&state, "pmacs.process.terminate(P)"); + + pump(&mut state); +} + +/// A `pmacs.process.spawn` caller that supplies no purpose keeps +/// working, and gets its own label back rather than an empty field. +/// +/// The Rust struct's field is required — the compiler enforces that at +/// every construction site. This surface is deliberately lenient, +/// because requiring it here would break every existing caller for no +/// coverage the compiler is not already providing. +#[test] +fn a_process_spawned_without_a_purpose_falls_back_to_its_label() { + let mut state = editor(); + exec( + &state, + r#"P = pmacs.process.spawn { + label = "legacy-caller", + command = "/bin/sh", + args = { "-c", "sleep 5" }, + }"#, + ); + let purpose: String = eval( + &state, + "for _, row in ipairs(pmacs.process.list()) do + if row.label == 'legacy-caller' then return row.purpose end + end + return ''", + ); + assert_eq!(purpose, "legacy-caller"); + exec(&state, "pmacs.process.terminate(P)"); + pump(&mut state); +} + +/// Q#W-4's preservation half, pinned here as well as by the three +/// leak-detector suites: `purpose` is a new KEY on each existing row and +/// changes nothing about **which** processes `list()` enumerates. +/// +/// `m6_8_multi_repl_acceptance`, `compile_mode_acceptance` and +/// `lean4_stage1_acceptance` all assert on `#pmacs.process.list()` as a +/// leak baseline. If any of them needs editing, the design is wrong. +#[test] +fn process_list_still_hides_terminal_ptys() { + let mut state = editor(); + let before: usize = eval(&state, "return #pmacs.process.list()"); + exec( + &state, + "T = pmacs.terminal.open { command = '/bin/sh', args = { '-c', 'sleep 5' } }", + ); + let after: usize = eval(&state, "return #pmacs.process.list()"); + assert_eq!( + before, after, + "a terminal PTY must stay invisible to pmacs.process.list (Q#W-4)" + ); + assert!( + eval::(&state, "return pmacs.terminal.is_terminal(T)"), + "precondition: the PTY really was opened" + ); + // No explicit close: terminals have no Lua teardown surface, and + // `EditorState::drop` shuts the supervisor down with SIGTERM then + // SIGKILL, so the child cannot outlive the test. + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 2 — the dispatch-name ambient (Q#W-2) +// --------------------------------------------------------------------------- + +/// **Rule 7 + the defect itself.** A job dispatched through +/// `pmacs.workers.dispatch("name", …)` reports `"name"`. +/// +/// The witness is a handler **registered from Lua that calls a real +/// dispatcher**, not a synthetic push of the ambient. A test that +/// pushed the name by hand would prove the stack works and leave the +/// actual defect — `name` dying inside an arbitrary handler, three +/// layers above anything that takes a name — completely unwitnessed. +#[test] +fn a_dispatched_job_reports_the_registered_handler_name() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + H = pmacs.workers.dispatch('indexer')", + ); + let purposes = active_purposes(&state); + assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}"); + assert!( + purposes[0].starts_with("indexer"), + "the third party's own name must survive the call chain: {purposes:?}" + ); + + // Rule 7: outside any extent, nothing changes. + exec(&state, "DIRECT = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 50ms"), + "a builtin invoked directly records its own purpose: {purposes:?}" + ); + pump(&mut state); +} + +/// **Rule 6 — COMPOSE, do not replace.** Both halves asserted, because +/// a test on the prefix alone passes when the description is dropped, +/// and a test on the description alone passes when the third party is +/// lost again. +#[test] +fn a_dispatched_job_composes_the_handler_name_with_the_work() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + H = pmacs.workers.dispatch('indexer')", + ); + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["indexer: sleep 50ms".to_owned()], + "letting the name win discards the work; letting the work win \ + loses the third party" + ); + pump(&mut state); +} + +/// **Rules 3 and 4 — nesting is a stack (innermost wins) and fan-out +/// shares the name.** +#[test] +fn nesting_takes_the_innermost_name_and_fan_out_shares_it() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('inner', function() + -- Fan-out: two jobs under one handler. + A = pmacs.workers.sleep(50) + B = pmacs.workers.sleep(51) + return A + end) + pmacs.workers.register('outer', function() + pmacs.workers.dispatch('inner') + -- Back in `outer`'s extent: the stack restored on return. + C = pmacs.workers.sleep(52) + return C + end) + pmacs.workers.dispatch('outer')", + ); + let mut purposes = active_purposes(&state); + purposes.sort(); + assert_eq!( + purposes, + vec![ + "inner: sleep 50ms".to_owned(), + "inner: sleep 51ms".to_owned(), + "outer: sleep 52ms".to_owned(), + ], + "innermost wins inside, and the outer name is restored after" + ); + pump(&mut state); +} + +/// **Rule 5 — unwind-safe, and this is the one that makes a naive +/// version worse than none.** +/// +/// A handler that raises must still pop. Otherwise one failure poisons +/// every subsequent dispatch in the session with a stale name, and the +/// feature stops failing loudly and starts lying silently — a +/// regression that would surface as intermittent misattribution long +/// after the lane landed. +#[test] +fn a_raising_handler_still_pops_its_name() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('boom', function() error('handler failed') end) + OK, ERR = pcall(pmacs.workers.dispatch, 'boom')", + ); + assert!( + !eval::(&state, "return OK"), + "the handler's error must still reach the caller" + ); + assert!( + eval::(&state, "return tostring(ERR)").contains("handler failed"), + "and must reach it unchanged" + ); + + exec(&state, "LATER = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["sleep 50ms".to_owned()], + "an unrelated later dispatch must not inherit the failed \ + handler's name: {purposes:?}" + ); + pump(&mut state); +} + +/// **Preservation.** `pmacs.workers.dispatch` was `return +/// handler(args, opts)` — a tail call that propagates **every** return +/// value. Bracketing it must not quietly truncate that. +/// +/// A `local ok, result = pcall(...)` bracketing would pass every other +/// test in this file and lose a two-value handler's second value with no +/// error anywhere, which is the shape of regression that surfaces months +/// later in somebody else's package. +#[test] +fn dispatch_still_propagates_every_value_the_handler_returns() { + let mut state = editor(); + let values: Vec = eval( + &state, + "pmacs.workers.register('multi', function() + return pmacs.workers.sleep(50), 'second', 'third' + end) + local a, b, c = pmacs.workers.dispatch('multi') + return { type(a), tostring(b), tostring(c) }", + ); + assert_eq!( + values, + vec!["table".to_owned(), "second".to_owned(), "third".to_owned()], + "a multi-value handler must survive the bracketing" + ); + pump(&mut state); +} + +/// **Rule 2 — work dispatched LATER is not covered, deliberately.** +/// +/// A job dispatched from an `on_complete` callback runs ticks later, +/// outside the extent, and carries only its own purpose. Asserted so +/// that the boundary reads as designed rather than as broken; covering +/// it would need the asynchronous lifetime mechanism Stage 3 owns +/// (Q#W-5). +#[test] +fn work_dispatched_from_a_completion_callback_carries_no_handler_name() { + let mut state = editor(); + exec( + &state, + "LATE = nil + pmacs.workers.register('deferred', function() + local h = pmacs.workers.sleep(1) + h:on_complete(function() + LATE = pmacs.workers.sleep(50) + end) + return h + end) + pmacs.workers.dispatch('deferred')", + ); + // One tick settles the first job and fires the callback; the job the + // callback dispatches is what this test is about, so do not pump to + // quiescence before reading it. + let deadline = Instant::now() + Duration::from_secs(10); + while !eval::(&state, "return LATE ~= nil") { + assert!(Instant::now() < deadline, "callback never fired"); + state.tick_async(); + } + let purposes = active_purposes(&state); + assert_eq!( + purposes, + vec!["sleep 50ms".to_owned()], + "the extent is the handler CALL, not the job's lifetime: {purposes:?}" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 3 — rule 1: the extent is non-yieldable, and that is ENFORCED +// --------------------------------------------------------------------------- + +/// **Rule 1, first supported yield API.** Two assertions, and the +/// second is the load-bearing one. +/// +/// A guard that raises but leaves the name pushed has converted a silent +/// misattribution into a silent misattribution *plus* an error. So the +/// witness dispatches again after the rejection and asserts the new job +/// carries no stale name. +#[test] +fn awaiting_inside_a_handler_is_refused_and_the_scope_restores() { + let mut state = editor(); + // The awaited handle is created OUTSIDE the extent on purpose: the + // second assertion below is about what a job allocated *after* the + // refusal carries, and a job the handler allocated for itself would + // legitimately wear the handler's name and blur that. + exec( + &state, + "OUTSIDE = pmacs.workers.sleep(1) + REFUSAL = nil + pmacs.workers.register('awaits', function() + local ok, err = pcall(function() return OUTSIDE:await() end) + REFUSAL = (not ok) and tostring(err) or '' + return OUTSIDE + end) + pmacs.async(function() pmacs.workers.dispatch('awaits') end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"), + "the refusal must name the rule it enforces; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "a refused await must still leave the scope popped" + ); + + exec(&state, "AFTER = pmacs.workers.sleep(50)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 50ms"), + "and a later dispatch must carry no stale name: {purposes:?}" + ); + assert!( + !purposes.iter().any(|p| p.starts_with("awaits:")), + "no job allocated after the refusal may inherit the handler's \ + name: {purposes:?}" + ); + pump(&mut state); +} + +/// **Rule 1, unconditionally.** The refusal fires even when the awaited +/// handle has already settled. +/// +/// This is the case that separates an unconditional guard from one whose +/// behaviour depends on a race: a guard placed after the `_is_complete` +/// check would fire only when a yield would really occur, passing under +/// test and failing intermittently in production depending on whether +/// the job happened to finish first. +#[test] +fn the_await_refusal_fires_even_for_an_already_complete_handle() { + let mut state = editor(); + exec(&state, "SETTLED = pmacs.workers.sleep(0)"); + let deadline = Instant::now() + Duration::from_secs(10); + while !eval::(&state, "return SETTLED:is_complete()") { + assert!(Instant::now() < deadline, "the canary never settled"); + state.tick_async(); + } + + exec( + &state, + "REFUSAL = nil + pmacs.workers.register('awaits-settled', function() + local ok, err = pcall(function() return SETTLED:await() end) + REFUSAL = (not ok) and tostring(err) or '' + return pmacs.workers.sleep(50) + end) + pmacs.workers.dispatch('awaits-settled')", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"), + "a settled handle must be refused too, or the guard's behaviour \ + depends on a race; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "and the scope must still be popped" + ); + pump(&mut state); +} + +/// **Rule 1, second supported yield API.** Guarding `:await()` and not +/// `yield_to_next_tick` would leave the extent open through a second +/// door — and Q#W-7 below is the proof that exactly that happens when +/// only one door is guarded. +#[test] +fn yield_to_next_tick_inside_a_handler_is_refused_and_the_scope_restores() { + let mut state = editor(); + exec( + &state, + "REFUSAL = nil + pmacs.workers.register('yields', function() + local ok, err = pcall(pmacs.async.yield_to_next_tick) + REFUSAL = (not ok) and tostring(err) or '' + return pmacs.workers.sleep(50) + end) + pmacs.async(function() pmacs.workers.dispatch('yields') end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot yield inside") && refusal.contains("pmacs.workers.dispatch"), + "the second yield API must refuse too; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "and must leave the scope popped" + ); + + exec(&state, "AFTER = pmacs.workers.sleep(51)"); + let purposes = active_purposes(&state); + assert!( + purposes.iter().any(|p| p == "sleep 51ms"), + "a later dispatch must carry no stale name: {purposes:?}" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 4 — Q#W-7: the same hole in `commit_to`, closed here +// --------------------------------------------------------------------------- + +/// **Q#W-7 — a pre-existing defect, found by reading and repaired in +/// this lane.** +/// +/// `Handle:await()` refuses inside `pmacs.window.commit_to` precisely so +/// a coroutine cannot park with the frontend scope pushed (Journey Stage +/// 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also yields, is +/// public, and carried **no** such refusal — so that invariant had a +/// second entrance. +/// +/// **Reachability by a real caller is UNPROVEN.** No production caller +/// is known to yield through this door inside a commit; this pins the +/// guard rather than reproducing a user-visible bug. +/// +/// Both halves asserted, for the same reason as rule 1's: a refusal that +/// leaves the scope pushed swaps a silent misrouting for a loud one and +/// fixes neither. +#[test] +fn yield_to_next_tick_inside_commit_to_is_refused_and_the_commit_scope_restores() { + let mut state = editor(); + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write"); + + // A GENUINE destination, produced by the production capture: the + // listener claims (returns false), so nothing commits and what lands + // in `dest` is exactly the userdata dired would have received. + // Nothing in a test can construct one. + exec( + &state, + "dest = nil + pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)", + ); + state.open_directory_target(dir.path()); + pump(&mut state); + assert!( + eval::(&state, "return dest ~= nil"), + "the chain must hand listeners a destination" + ); + + exec( + &state, + "REFUSAL = nil + pmacs.async(function() + local ok, err = pcall(pmacs.window.commit_to, dest, function() + pmacs.async.yield_to_next_tick() + end) + REFUSAL = (not ok) and tostring(err) or '' + end)", + ); + let refusal: String = eval(&state, "return REFUSAL"); + assert!( + refusal.contains("cannot yield inside") && refusal.contains("commit_to"), + "the second door into the commit scope must be shut; got {refusal:?}" + ); + assert!( + !eval::(&state, "return pmacs._async._in_commit_scope()"), + "and the commit scope must still be restored afterwards" + ); + pump(&mut state); +} + +// --------------------------------------------------------------------------- +// 5 — the statusline activity indicator (Q#W-3, Q#W-6) +// --------------------------------------------------------------------------- + +/// **Absent at zero, asserted as an absent SEGMENT rather than as an +/// empty string.** A zero-width segment still consumes a separator in +/// the composed modeline, so "returns nothing" and "returns nothing +/// visible" are different claims and only one of them is the design. +#[test] +fn the_indicator_produces_no_segment_at_all_when_nothing_is_running() { + let state = editor(); + assert_eq!( + activity_segment(&state), + None, + "an idle editor must produce NO activity segment" + ); +} + +/// **A count plus the oldest in-flight job's purpose, witnessed through +/// the real per-frame evaluation path.** +/// +/// Driven through `paint_frame`, not by calling the provider function +/// directly: a provider that works in isolation and never gets evaluated +/// is exactly the failure this must exclude. +#[test] +fn the_indicator_shows_a_count_and_the_oldest_purpose_in_a_painted_frame() { + let mut state = editor(); + exec( + &state, + "FIRST = pmacs.workers.sleep(50) + SECOND = pmacs.workers.grep({ root = '/tmp', pattern = 'zzz-no-match' })", + ); + + let cells = paint(&state, 24, 160); + let modeline = row_text(&cells, 160, 22); + assert!( + modeline.contains("⋯2 sleep 50ms"), + "the painted modeline must carry the count and the OLDEST job's \ + purpose (not the newest); got {modeline:?}" + ); + + // And the same value reaches the evaluator's segment vector, which is + // what the semantic frontend ships. + assert_eq!( + activity_segment(&state).as_deref(), + Some("⋯2 sleep 50ms"), + "the segment and the painted row must agree" + ); + + exec(&state, "SECOND:cancel()"); + pump(&mut state); +} + +/// **Q#W-6 — the setting, witnessed with work genuinely in flight.** +/// +/// The discriminating case: an assertion taken on an idle editor cannot +/// tell "disabled" from "nothing is happening", which is the only thing +/// this setting changes. +#[test] +fn the_indicator_honours_its_setting_while_work_is_in_flight() { + let mut state = editor(); + dispatch_one_in_flight(&state); + assert!( + activity_segment(&state).is_some(), + "precondition: work is in flight and the indicator is on" + ); + + exec(&state, "pmacs.config.set('ui.activity-indicator', false)"); + assert_eq!( + activity_segment(&state), + None, + "disabled means NO segment, with work still running" + ); + + exec(&state, "pmacs.config.set('ui.activity-indicator', true)"); + assert!( + activity_segment(&state).is_some(), + "and re-enabling brings it back without a restart" + ); + pump(&mut state); +} + +/// The setting is a real registry entry, not an ad-hoc global: it is +/// discoverable through `pmacs.config.describe` like every other +/// setting, which is what `COHERENCE.md` §11 grades. +#[test] +fn the_setting_is_registered_with_a_true_default() { + let state = editor(); + let (kind, default): (String, bool) = eval( + &state, + "local d = pmacs.config.describe('ui.activity-indicator') + return d.type, d.default", + ); + assert_eq!(kind, "boolean"); + assert!(default, "visible by default — no configuration, no command"); +} + +// --------------------------------------------------------------------------- +// 6 — `*workers*` renders the purpose +// --------------------------------------------------------------------------- + +/// The view §9 already has, now answering §9's question. +/// +/// `Kind` names the builtin dispatcher a job funnelled through, which +/// for a third-party job is a builtin's label rather than the caller's; +/// the `Purpose` column is what carries the caller's own account. +#[test] +fn the_workers_buffer_renders_the_purpose_column() { + let mut state = editor(); + exec( + &state, + "pmacs.workers.register('indexer', function() + return pmacs.workers.sleep(50) + end) + pmacs.workers.dispatch('indexer') + BUF = pmacs.workers.show()", + ); + let text: String = eval(&state, "return BUF:slice(0, BUF:len())"); + assert!( + text.contains("Purpose"), + "the active table must have a Purpose column:\n{text}" + ); + assert!( + text.contains("indexer: sleep 50ms"), + "and the row must render it:\n{text}" + ); + exec(&state, "pmacs.workers.hide()"); + pump(&mut state); +} From 1ca76e055bcaa606f76865ed6fd9abc248c9d8d0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:57:28 +0200 Subject: [PATCH 50/66] docs: the worker-identity lane is implemented, with its gate outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-local update to `docs/active-work.md`'s "Worker identity Stage 1 (§9)" block, which was written pre-implementation with the lane's first commit. It now records what actually shipped rather than what was planned, because the difference is where the reviewable claims are. Three things it records that a status flip alone would not: - **`journey_acceptance` passed UNTOUCHED (47/47).** Q#W-7 edits the `commit_to` guard family, so that suite was the lane's stop signal: any established pin needing an edit would have meant the change altered Journey Stage 1a's semantics rather than closing a gap in them. The same for all three `#pmacs.process.list()` leak detectors, which are Q#W-4's preservation claim. - **One pre-existing assertion did change**, and it is named here so the change is not mistaken for an accommodation: the builtin statusline provider inventory in `statusline_segments_acceptance` grows by the fourth adopter. That assertion exists to grow. - **Two residuals, stated rather than tested around.** A raw `coroutine.yield` inside either dynamic scope still leaks the scope, and Q#W-7's reachability by a real caller is unproven. Also lists the surfaces that changed shape — the collapsed allocation funnel, the two grown constructor signatures, the new required fields — for anyone rebasing a concurrent lane onto this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 55 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b1a2cc7..7a7d649 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## Worker identity Stage 1 (§9) — BRANCHED, pre-implementation +## Worker identity Stage 1 (§9) — IMPLEMENTED, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -343,8 +343,57 @@ the authoritative tip** — the ref, not a SHA. Recover with only the first two. A parent needs an ambient "currently-running job" context, and an unpopulated `parent` reads as "no parent" rather than "not tracked" (Q#W-5). -- **Gates:** `scripts/gate --acceptance `. No - `--protocol`. +- **Gates:** `scripts/gate --acceptance worker_identity_acceptance + --acceptance journey_acceptance --acceptance + statusline_segments_acceptance`. No `--protocol` — no wire change. +- **IMPLEMENTED at `1aca0ee`**, one commit on top of the four framing + revisions. `tests/worker_identity_acceptance.rs` is the new suite: 18 + tests, plus one consumer-side witness beside the private renderer in + `pmacs-gpu`. +- **`journey_acceptance` passed UNTOUCHED (47/47)** — the stop signal + did not fire. Q#W-7 edits the `commit_to` guard family, so any of its + established pins needing an edit would have meant this altered Journey + Stage 1a's semantics rather than closing a gap in them. Its diff + versus `main` is empty, and so is the diff for all three + `#pmacs.process.list()` leak-detector suites + (`m6_8_multi_repl_acceptance`, `compile_mode_acceptance`, + `lean4_stage1_acceptance`) — Q#W-4's preservation claim, checked the + way the framing asked. +- **One pre-existing assertion did change, and it is an inventory + rather than a contract**: `statusline_segments_acceptance`'s builtin + provider list becomes `["activity", "mode", "terminal", "lsp"]`. + `activity` sorts first because `async.lua` is loaded before + `syntax.lua`, `terminal.lua` and `lsp.lua`. That assertion exists to + grow when a builtin provider is added; it is listed here so the change + is not mistaken for an accommodation. +- **20 mutation checks, each test falsified by removing its own fix.** + The ones worth naming: siting the `await` guard *inside* the + `_is_complete` branch (the already-complete case then slips through — + which is the whole reason the guard is unconditional); replacing + `pcall`/pop/rethrow with a bare handler call (a raising handler leaves + the name pushed and the *next* dispatch inherits it); composing + `""` instead of `": "` and vice versa (each half + passes the other's test); `first()` instead of `last()` on the name + stack; oldest→newest in `activity_summary`; and, on the GPU side, + painting an unthemed modeline face as the band colour, which would + have made the indicator invisible without failing anything else. + One of the twenty is a **preservation** check rather than a new + claim: bracketing `pmacs.workers.dispatch` with + `local ok, result = pcall(...)` truncates a handler that returns more + than one value, which every other test in the suite tolerates. +- **Two residuals, stated rather than tested around.** Raw + `coroutine.yield` inside either dynamic scope still leaks the scope — + loudly, through `pmacs.error`, but it leaks; no refusal sited in a + yield helper can intercept it (framing §2). And Q#W-7's reachability + by a real caller stays **unproven**: the commit message says so, and + the test pins the guard rather than reproducing a fault. +- **Surfaces that changed shape, for anyone rebasing onto this:** + `AsyncRuntime::allocate`/`allocate_with_resource` collapsed into one + private `JobSpec`-taking funnel; `register_external` grew a third + parameter; `ProcessSpec::new` grew a third parameter (~40 call sites, + nearly all tests); `ActiveJobInfo`/`CompletedJobInfo`/`ProcessSpec` + each grew a required `purpose` field. `pmacs.process.spawn`'s Lua + surface keeps `purpose` optional, falling back to the label. ## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED** From d01cde9432bb5c0b0f67caad04c79ef68fd6ce08 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:32:10 +0200 Subject: [PATCH 51/66] =?UTF-8?q?fix(process):=20pmacs.process.spawn=20REQ?= =?UTF-8?q?UIRES=20a=20purpose=20=E2=80=94=20review=20blocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on worker identity Stage 1. The lane shipped `purpose` as a required field on `ProcessSpec` but made it OPTIONAL at the `pmacs.process.spawn` Lua surface, defaulting to `label`. **That preserved compatibility and delivered nothing.** `COHERENCE.md` §9's complaint about `ProcessSpec` is precisely that `label` is "caller-supplied, unvalidated convention" — so a purpose defaulting to the label hands every existing caller back the exact convention this lane exists to replace. The approved framing said required; this makes it required where callers actually are. The two fields answer different questions and neither substitutes for the other. `label` IDENTIFIES — `lsp:rust-analyzer`, a terminal's buffer name — so two processes running the same binary can be told apart. `purpose` DESCRIBES: it answers "what is happening", which is what §3's promise of visible asynchronous work is about, and which a label chosen for uniqueness routinely does not answer. **The refusal covers five shapes, not one.** Absent; empty; whitespace-only; wrong type; and metatable-provided. The middle two matter because they satisfy the type and defeat the point exactly as copying the label across would — R42 already rejects whitespace-only `description`s in the config registry for the same reason, and a required field that accepts `""` is not required in any sense a reader benefits from. The read is RAW, matching the posture `stdin` and `group` already document in the same function: a spec table is plain data, so `__index` cannot smuggle a purpose in. Every refusal also asserts **the process list is unchanged**. A validation that rejects after spawning has already done the thing it was rejecting. **This is a BREAKING CHANGE to a public Lua API, taken deliberately and now rather than later.** Weighed and reported rather than decided silently: §10 grades extension trust "missing (one class)" and P7 package lifecycle has not started, so the third-party population calling this binding is ~zero and the cost of the change only rises from here. Checked for a reason that would be wrong and found none — `pmacs.process.spawn` has no API-reference documentation and no stability promise anywhere in `docs/`; the guide's only mentions are an audit-rule classification and a pointer to the bundled REPL, and its semver language governs *packages'* own versioning, not pmacs's Lua surface. `lua_to_spec` has exactly one caller, so the blast radius is this one binding. Eleven executable call sites updated, each with a real description rather than the label copied across — copying it would satisfy the type and defeat the point as surely as the default did: builtin/packages/repl/init.lua "interactive session" builtin/runtime/compile.lua "compiling: " builtin/runtime/lean.lua "checking the Lean toolchain version…" tests/fixtures/pmacs-magit/status.lua the full argv, not just the subcommand the label carries — "git log" and "git log --oneline -20" are one label and different work tests/compile_mode_acceptance.rs (4), tests/m4_acceptance.rs (1), tests/worker_identity_acceptance.rs (2) `lean.lua`'s site is the clearest case for the field: its comment said the label was where "a user wondering why their editor touched `lake` finds an owner" — one string doing identity AND explanation, which is the conflation being undone. The label stays a key; the purpose is now the sentence. Two references are deliberately NOT updated: `src/audit/mod.rs` and `tests/m7_9_acceptance.rs` contain `pmacs.process.spawn("ls")` as **audit fixture source text**. It is lexed by the audit engine, never executed, and editing it would change what those rule tests scan. `required_purpose` is extracted rather than inlined because inlining it pushed `lua_to_spec` past the 100-line clippy bound — the validation has its own rules and its own rationale, so it gets its own function instead of an `#[allow]`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/packages/repl/init.lua | 4 ++ builtin/runtime/compile.lua | 5 ++ builtin/runtime/lean.lua | 10 ++- src/lua_bindings/mod.rs | 72 +++++++++++++++---- tests/compile_mode_acceptance.rs | 12 ++-- tests/fixtures/pmacs-magit/status.lua | 4 ++ tests/m4_acceptance.rs | 1 + tests/worker_identity_acceptance.rs | 99 ++++++++++++++++++++------- 8 files changed, 161 insertions(+), 46 deletions(-) diff --git a/builtin/packages/repl/init.lua b/builtin/packages/repl/init.lua index 3b336f6..df8d5ca 100644 --- a/builtin/packages/repl/init.lua +++ b/builtin/packages/repl/init.lua @@ -259,6 +259,10 @@ function repl.spawn(opts) local spec = { label = name, + -- Worker identity Stage 1: the label is the REPL's session name, + -- which distinguishes two REPLs from each other and says nothing + -- about what is running. The purpose names the interpreter. + purpose = "interactive " .. h._display_name .. " session", command = argv[1], args = args, pty = { rows = rows, cols = cols, mode = "raw" }, diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 22fc643..7645416 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -875,6 +875,11 @@ local function start_run(slot, cmdline, opts) -- stdin, own process group, TERM=dumb. local spec = { label = slot.label, + -- Worker identity Stage 1: the label distinguishes one compile slot + -- from another; the purpose is the command the user actually asked + -- for, which is what they want to see when they wonder why the + -- editor is busy. + purpose = "compiling: " .. cmdline, command = "/bin/sh", args = { "-c", "exec 2>&1; " .. cmdline }, env = { TERM = "dumb" }, diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 09ad280..6ccb9dd 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -494,10 +494,14 @@ local function start_probe(root) -- "lake": a user pointing `command` at an absolute path to lake should -- have THAT probed, not whatever `lake` resolves to on PATH. local spec = { - -- COHERENCE §9: `ProcessSpec.label` is the only identity a process - -- carries, and it is what `pmacs.process.list` renders. A user - -- wondering why their editor touched `lake` finds an owner here. + -- COHERENCE §9: `ProcessSpec.label` identifies the process, and it + -- is what `pmacs.process.list` renders alongside the purpose. A user + -- wondering why their editor touched `lake` finds it here. label = "lean:lake-version-probe", + -- Worker identity Stage 1: the label was carrying both jobs — the + -- identity AND the explanation — which is the conflation the purpose + -- field exists to undo. The label stays a key; this is the sentence. + purpose = "checking the Lean toolchain version before starting a server", command = cfg.command, args = { "--version" }, stdin = "null", diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 9535f81..c47b678 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8746,24 +8746,66 @@ fn parse_restart(name: &str) -> mlua::Result { }) } +/// Read the **required** `purpose` out of a `pmacs.process.spawn` spec +/// (worker identity Stage 1, `COHERENCE.md` §9). +/// +/// An earlier revision of this lane defaulted the field to `label` so +/// that existing callers kept working. That preserved compatibility and +/// delivered nothing: §9's complaint about `ProcessSpec` is precisely +/// that `label` is "caller-supplied, unvalidated convention", so a +/// purpose defaulting to the label hands every caller back the +/// convention this lane exists to replace. +/// +/// The two fields answer different questions and neither substitutes for +/// the other. `label` **identifies** — `lsp:rust-analyzer`, a terminal's +/// buffer name — so that two processes running the same binary can be +/// told apart. `purpose` **describes**: it answers "what is happening", +/// which is the question §3's promise of visible asynchronous work is +/// about, and which a label chosen for uniqueness routinely does not +/// answer. +/// +/// # Errors +/// +/// Absent, empty, whitespace-only, or non-string. Empty and +/// whitespace-only are rejected because they satisfy the type and defeat +/// the point exactly as copying the label across would — R42 already +/// rejects whitespace-only `description`s in the config registry for the +/// same reason. +/// +/// The read is **raw**, matching the posture `stdin` and `group` already +/// document in [`lua_to_spec`]: a spec table is plain data, so a +/// metatable cannot smuggle a purpose in through `__index`. +fn required_purpose(table: &Table) -> mlua::Result { + let purpose = match table.raw_get::("purpose") { + Ok(mlua::Value::String(value)) => value.to_str()?.to_owned(), + Ok(mlua::Value::Nil) => { + return Err(mlua::Error::external( + "pmacs.process.spawn: purpose is required — a short description of what \ + this process is DOING, e.g. purpose = \"running the project's test suite\". \ + It is not the label: the label identifies the process, the purpose says \ + what it is for.", + )); + } + Ok(other) => { + return Err(mlua::Error::external(format!( + "pmacs.process.spawn: purpose must be a string; got {}", + other.type_name() + ))); + } + Err(error) => return Err(error), + }; + if purpose.trim().is_empty() { + return Err(mlua::Error::external( + "pmacs.process.spawn: purpose must not be empty or whitespace-only", + )); + } + Ok(purpose) +} + fn lua_to_spec(table: &Table) -> mlua::Result { let label: String = table.get("label").unwrap_or_else(|_| "unnamed".to_owned()); let command: String = table.get("command")?; - // Worker identity Stage 1: required on the Rust struct, optional at - // this surface, falling back to the label. - // - // Requiring it here would break every existing `pmacs.process.spawn` - // caller, and the compiler obligation this lane is buying is on the - // *Rust* construction sites — the ones a future field would silently - // skip. A Lua caller that supplies nothing gets its own label back, - // which is what the caller already chose to call this work; it is - // less informative than a real description but it is not a - // fabrication, which is the bar `owner` failed (framing §3). - let purpose: String = table - .get::>("purpose") - .ok() - .flatten() - .unwrap_or_else(|| label.clone()); + let purpose = required_purpose(table)?; let args: Vec = table.get("args").unwrap_or_default(); let cwd: Option = table.get("cwd").ok().flatten(); let env_table: Option
= table.get("env").ok().flatten(); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 1801641..54ecf37 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -1836,7 +1836,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() { &s, r#" local ok, err = pcall(pmacs.process.spawn, - { label = "t", command = "/bin/true", stdin = true }) + { label = "t", purpose = "type-check probe", command = "/bin/true", + stdin = true }) return ok, tostring(err) "#, ); @@ -1846,7 +1847,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() { &s, r#" local ok, err = pcall(pmacs.process.spawn, - { label = "t", command = "/bin/true", group = "true" }) + { label = "t", purpose = "type-check probe", command = "/bin/true", + group = "true" }) return ok, tostring(err) "#, ); @@ -2234,7 +2236,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() { &s, r#" local spec = setmetatable( - { label = "mt", command = "/bin/sh", args = { "-c", "sleep 30" } }, + { label = "mt", purpose = "raw-read probe", command = "/bin/sh", + args = { "-c", "sleep 30" } }, { __index = function(_, k) if k == "group" then return true end return nil @@ -2265,7 +2268,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() { &s, r#" local spec = setmetatable( - { label = "mt2", command = "/bin/sh", args = { "-c", "exit 0" } }, + { label = "mt2", purpose = "raw-read probe", command = "/bin/sh", + args = { "-c", "exit 0" } }, { __index = function() error("hostile spec metatable") end }) local ok = pcall(pmacs.process.spawn, spec) return ok diff --git a/tests/fixtures/pmacs-magit/status.lua b/tests/fixtures/pmacs-magit/status.lua index 283b9a6..59077c0 100644 --- a/tests/fixtures/pmacs-magit/status.lua +++ b/tests/fixtures/pmacs-magit/status.lua @@ -54,6 +54,10 @@ function M.run_git(args, opts) opts = opts or {} local id = pmacs.process.spawn { label = "git " .. (args[1] or ""), + -- Worker identity Stage 1: `purpose` is required. The full argument + -- vector, not just the subcommand the label carries -- "git log" and + -- "git log --oneline -20" are the same label and different work. + purpose = "git " .. table.concat(args, " "), command = "git", args = args, cwd = opts.cwd, diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index d182eb5..d706305 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1173,6 +1173,7 @@ fn m4_4_lua_surface_drives_lifecycle() { r#" local id = pmacs.process.spawn { label = "lua-hello", + purpose = "greeting the Lua surface end to end", command = "/bin/sh", args = { "-c", "printf hi-from-lua && exit 0" }, } diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs index d7bdbfb..96decfb 100644 --- a/tests/worker_identity_acceptance.rs +++ b/tests/worker_identity_acceptance.rs @@ -264,33 +264,84 @@ fn every_entry_shape_records_what_its_work_is() { pump(&mut state); } -/// A `pmacs.process.spawn` caller that supplies no purpose keeps -/// working, and gets its own label back rather than an empty field. +/// **`pmacs.process.spawn` REFUSES a spec with no purpose, and spawns +/// nothing.** /// -/// The Rust struct's field is required — the compiler enforces that at -/// every construction site. This surface is deliberately lenient, -/// because requiring it here would break every existing caller for no -/// coverage the compiler is not already providing. +/// An earlier revision of this lane defaulted the field to `label` so +/// that existing callers kept working. That preserved compatibility and +/// delivered nothing: §9's complaint about `ProcessSpec` is precisely +/// that `label` is "caller-supplied, unvalidated convention", so a +/// purpose defaulting to the label hands every caller back the +/// convention this lane exists to replace. +/// +/// Four refusals, each asserted the same way — the call raises, the +/// message names the field, and **the process list is unchanged**, +/// because a validation that rejects after spawning has already done the +/// thing it was rejecting: +/// +/// * absent; +/// * empty, and whitespace-only — these satisfy the type and defeat the +/// point exactly as copying the label would (R42 rejects +/// whitespace-only config descriptions for the same reason); +/// * wrong type; +/// * **metatable-provided**, which is the `stdin`/`group` raw-read +/// posture: a spec table is plain data, so a purpose cannot be +/// smuggled in through `__index`. #[test] -fn a_process_spawned_without_a_purpose_falls_back_to_its_label() { +fn spawning_without_a_real_purpose_is_refused_and_starts_nothing() { let mut state = editor(); - exec( - &state, - r#"P = pmacs.process.spawn { - label = "legacy-caller", - command = "/bin/sh", - args = { "-c", "sleep 5" }, - }"#, - ); - let purpose: String = eval( - &state, - "for _, row in ipairs(pmacs.process.list()) do - if row.label == 'legacy-caller' then return row.purpose end - end - return ''", - ); - assert_eq!(purpose, "legacy-caller"); - exec(&state, "pmacs.process.terminate(P)"); + let baseline: usize = eval(&state, "return #pmacs.process.list()"); + + for (label, spec, expected) in [ + ( + "absent", + r#"{ label = "x", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "purpose is required", + ), + ( + "empty", + r#"{ label = "x", purpose = "", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "must not be empty", + ), + ( + "whitespace-only", + r#"{ label = "x", purpose = " ", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "must not be empty", + ), + ( + "wrong type", + r#"{ label = "x", purpose = 7, command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "purpose must be a string", + ), + ( + "metatable-provided", + r#"setmetatable( + { label = "x", command = "/bin/sh", args = { "-c", "sleep 5" } }, + { __index = function(_, k) + if k == "purpose" then return "smuggled" end + return nil + end })"#, + "purpose is required", + ), + ] { + let (ok, err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.process.spawn, {spec}) + return ok, tostring(err)" + ), + ); + assert!(!ok, "{label}: spawn must refuse"); + assert!( + err.contains(expected), + "{label}: the refusal must name the field and the rule; got {err:?}" + ); + assert_eq!( + eval::(&state, "return #pmacs.process.list()"), + baseline, + "{label}: a refused spawn must start no process" + ); + } pump(&mut state); } From d4a69cae14af22038f0804fab8f8e4c9a5621e03 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:33:55 +0200 Subject: [PATCH 52/66] docs: record review round 1, and name the statusline adopter that is not named after its file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two section-local edits, no reflowing. **`docs/worker-identity-framing.md` §2** named the three existing statusline adopters by FILE — `terminal.lua`, `syntax.lua`, `lsp.lua` — which is accurate and misleading together: `syntax.lua` registers its provider under the name **`"mode"`**, so the registry inventory reads `["mode", "terminal", "lsp"]` and a reader looking for the syntax adopter by name does not find one. That is what made this lane's change to `tests/statusline_segments_acceptance.rs` surprising, and the next reader should not have to rediscover it. Also records that where a fourth registration sorts is decided by **load order**, not by name. **`docs/active-work.md`'s lane block** records review round 1: the `pmacs.process.spawn` blocker and its fix at `2162737`, the five refused shapes, the eleven updated call sites, the two audit-fixture occurrences that are deliberately untouched, the three added mutation checks, and the two acceptance suites the round added to the gate line (`compile_mode_acceptance` and `m8_6_acceptance`, because the round moved their spawn call sites and `m8_6` covers the `pmacs-magit` package fixture). It also records the breaking-change decision with the reasoning that justified it, rather than only the outcome: the binding has no API-reference documentation and no stability promise, `lua_to_spec` has one caller, and §10/P7 put the third-party population at ~zero — so the cost of requiring the field is at its minimum now and rises from here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 47 ++++++++++++++++++++++++++++----- docs/worker-identity-framing.md | 12 +++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7a7d649..474e6a9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -345,9 +345,14 @@ the authoritative tip** — the ref, not a SHA. Recover with "not tracked" (Q#W-5). - **Gates:** `scripts/gate --acceptance worker_identity_acceptance --acceptance journey_acceptance --acceptance - statusline_segments_acceptance`. No `--protocol` — no wire change. -- **IMPLEMENTED at `1aca0ee`**, one commit on top of the four framing - revisions. `tests/worker_identity_acceptance.rs` is the new suite: 18 + statusline_segments_acceptance --acceptance compile_mode_acceptance + --acceptance m8_6_acceptance`. No `--protocol` — no wire change. + `compile_mode` and `m8_6` joined at review round 1, which moved their + spawn call sites; `m8_6` covers the `pmacs-magit` fixture, and a newly + required field is exactly the kind of change that breaks a package + fixture quietly. +- **IMPLEMENTED at `1aca0ee`**, with review round 1's blocker fixed at + `2162737`. `tests/worker_identity_acceptance.rs` is the new suite: 18 tests, plus one consumer-side witness beside the private renderer in `pmacs-gpu`. - **`journey_acceptance` passed UNTOUCHED (47/47)** — the stop signal @@ -366,7 +371,7 @@ the authoritative tip** — the ref, not a SHA. Recover with `syntax.lua`, `terminal.lua` and `lsp.lua`. That assertion exists to grow when a builtin provider is added; it is listed here so the change is not mistaken for an accommodation. -- **20 mutation checks, each test falsified by removing its own fix.** +- **23 mutation checks, each test falsified by removing its own fix.** The ones worth naming: siting the `await` guard *inside* the `_is_complete` branch (the already-complete case then slips through — which is the whole reason the guard is unconditional); replacing @@ -380,20 +385,48 @@ the authoritative tip** — the ref, not a SHA. Recover with One of the twenty is a **preservation** check rather than a new claim: bracketing `pmacs.workers.dispatch` with `local ok, result = pcall(...)` truncates a handler that returns more - than one value, which every other test in the suite tolerates. + than one value, which every other test in the suite tolerates. Round + 1 added three more against the spawn refusal: restoring the + label fallback, accepting an empty/whitespace-only purpose, and + reading the field non-raw so a metatable can smuggle one in. - **Two residuals, stated rather than tested around.** Raw `coroutine.yield` inside either dynamic scope still leaks the scope — loudly, through `pmacs.error`, but it leaks; no refusal sited in a yield helper can intercept it (framing §2). And Q#W-7's reachability by a real caller stays **unproven**: the commit message says so, and the test pins the guard rather than reproducing a fault. +- **Review round 1 blocker — `pmacs.process.spawn` now REQUIRES + `purpose`.** The first implementation made it optional at the Lua + surface, falling back to `label`. That preserved compatibility and + delivered nothing: §9's complaint about `ProcessSpec` is exactly that + `label` is "caller-supplied, unvalidated convention", so a purpose + defaulting to it hands every caller back the convention the lane exists + to replace. Refused on five shapes — absent, empty, whitespace-only, + wrong type, metatable-provided — each asserting the process list is + unchanged, since a validation that rejects after spawning has already + done the thing it rejected. +- **That is a BREAKING CHANGE to a public Lua API, taken now on + purpose.** §10 grades extension trust "missing (one class)" and P7 + package lifecycle has not started, so the third-party population is + ~zero and the cost only rises later. Checked for a reason that would be + wrong and found none: `pmacs.process.spawn` has no API-reference + documentation and no stability promise in `docs/` (the package-author + guide's only mentions are an audit-rule classification and a pointer to + the bundled REPL; its semver language governs packages' own versioning, + not pmacs's Lua surface), and `lua_to_spec` has exactly one caller. + **Eleven executable call sites updated**, each with a real description + rather than the label copied across: `repl/init.lua`, `compile.lua`, + `lean.lua`, the `pmacs-magit` fixture, and seven in tests. The two + `pmacs.process.spawn("ls")` occurrences in `src/audit/mod.rs` and + `tests/m7_9_acceptance.rs` are **audit fixture source text** — lexed, + never executed — and are deliberately untouched. - **Surfaces that changed shape, for anyone rebasing onto this:** `AsyncRuntime::allocate`/`allocate_with_resource` collapsed into one private `JobSpec`-taking funnel; `register_external` grew a third parameter; `ProcessSpec::new` grew a third parameter (~40 call sites, nearly all tests); `ActiveJobInfo`/`CompletedJobInfo`/`ProcessSpec` - each grew a required `purpose` field. `pmacs.process.spawn`'s Lua - surface keeps `purpose` optional, falling back to the label. + each grew a required `purpose` field, and `pmacs.process.spawn` + requires `purpose` in its spec table. ## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED** diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md index d86d502..b2280a4 100644 --- a/docs/worker-identity-framing.md +++ b/docs/worker-identity-framing.md @@ -191,6 +191,18 @@ And the two findings that actually shape the design: `nil`. An activity indicator is a **fourth registration**, not a new mechanism. + **The three are named by FILE above and by NAME in the registry, and + the two do not line up.** `syntax.lua` registers its provider as + **`"mode"`** (it projects the major mode, `syntax.lua:552`), so the + registry inventory reads `["mode", "terminal", "lsp"]` — which is what + `tests/statusline_segments_acceptance.rs` asserts. Recorded because it + is genuinely surprising: a reader looking for the syntax adopter by + name does not find one. A fourth registration therefore changes that + assertion, and where the new name sorts depends on **load order**, not + on the name: `async.lua` is evaluated before `syntax.lua`, + `terminal.lua` and `lsp.lua` (`src/editor.rs`), so a provider + registered there lands first. + **And it is evaluated per frame**: `evaluate_statusline` is called inside `paint_frame` (`src/editor.rs:4560`), before the long mutable core borrow. So an indicator updates while work is in flight without From 37a81227c750d955e5b4ca24ddc8817f8c99c001 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:45:07 +0200 Subject: [PATCH 53/66] docs: R7 has a second occurrence, and this time the fragments were captured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` failed once at this lane's `scripts/gate` **`gpu` step** on 2026-08-09. Judged against this file rather than rerun-and-shrugged. **It matches R7 on all three of its required fragments**, verified rather than inferred: transient sequence must attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message: "Broken pipe" }))) **That capture is the point.** U2 and U3 both record the identical loss — "output was filtered to the `FAILED` line" — and U3 says outright that the recurring mistake was its author's, twice, with a mechanical fix: read the durable log, never the live stream. The gate writes `NN-gpu.log` for exactly this, and reading it turned what would have been a third unjudgeable `U` note into a second occurrence of a row that had one. The flavor is a third one (`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, neither occurrence 1's `--features crdt` sweep nor U3's default-features workspace sweep). Recorded because this file's own R2 worked example treats flavor as outside matching. **The merge-base control R7 asked for was run, and it settles nothing.** 15 runs at `4bc55e8`, green — but the observing branch was green over 30 runs too (15 isolated selector, 15 full suite), so neither side reproduced and the comparison separates nothing. Logged as a null result, not as exculpation. Per the rerun rule, all 45 green runs establish **intermittence only**. **And one causal path is named rather than dismissed:** this lane adds a GPU-heavy `render_offscreen` test to `pmacs-gpu`'s test module. It touches no `attach.rs`, no protocol and no wire — but it does add a concurrent test to the same binary, and the failing test is a socket handshake on a one-second deadline. Contention is a plausible `BrokenPipe` mechanism and 30 green runs do not exclude it. The row now says what the discriminating control would be if there is a third occurrence: remove the added test, not go to the merge base. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 44 ++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 0e4e6a9..c94c73b 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -496,16 +496,46 @@ Stage 4; the lane touches no `pmacs-gpu` code at all. | **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` | | **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load | | **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) | -| **status** | **new incident, unreproduced — causal status UNRESOLVED** | -| **what IS established** | one occurrence at `pmacs-gpu/src/attach.rs:1680`; the test drives a scripted transient-then-success sequence over a real socket pair | +| **status** | **SECOND OCCURRENCE 2026-08-09 — causal status still UNRESOLVED** | +| **what IS established** | **two** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair | | **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** | -| **rerun evidence** | 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Per the rerun rule this establishes **intermittence only** | +| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Per the rerun rule all of this establishes **intermittence only** | | **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe | -**Not attributed to this lane**, and the reasoning is not merely "my -diff looks unrelated": Stage 4 adds no wire surface, no protocol -version change, and touches no file in `pmacs-gpu`. A merge-base -control would settle it if this recurs. +**Not attributed to the observing lane**, and in neither case is the +reasoning merely "my diff looks unrelated": long-lines Stage 4 added no +wire surface, no protocol version change, and touched no file in +`pmacs-gpu`. + +**Second occurrence — worker identity Stage 1, 2026-08-09, local +(Linux).** Recorded at the `scripts/gate` **`gpu` step** +(`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`), which is a **third +flavor**: not the `--features crdt` sweep of occurrence 1, and not U3's +default-features workspace sweep. Two things make it a match rather than +a `U` note: + +* **The fragments were captured this time.** `transient sequence must + attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message: + "Broken pipe" })))` — all three of the row's required fragments, + verified against the durable gate log rather than a filtered live + stream. **That is what U2 and U3 both lost**, and it is why U3 could + not be judged a recurrence. Reading the gate's own `NN-gpu.log` is the + mechanical fix U3 prescribed, and it worked. +* **The merge-base control R7 asked for was run** — 15 runs at `4bc55e8`, + green. It is **non-discriminating**, not exculpatory: the observing + branch was equally green over 30 runs, so neither side reproduced and + the control separates nothing. Recorded as a null result rather than + as evidence. + +**One causal path is NOT excluded and is named here rather than +dismissed.** The observing lane added a test to `pmacs-gpu`'s test module +(`main.rs`) — a GPU-heavy `render_offscreen` case. It touches no +`attach.rs`, no protocol, and no wire, but it does add a concurrent test +to the same binary, and the failing test is a socket handshake with a +one-second deadline. Contention is a plausible mechanism for a +`BrokenPipe`, and 30 green runs do not rule it out. If a third occurrence +lands, **run the control with the added test removed** rather than at the +merge base — that is the discriminating comparison this one was not. ### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence From 70262888b434fee952c3681460dbbe060cb5d16b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 19:33:07 +0200 Subject: [PATCH 54/66] fix(workers): a safe display-text boundary for purpose and handler names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2, findings P2a and P2b, plus P3's stale recovery summaries. Three defects, and the fix is deliberately different in each place because the constraint is. ## P2a — invalid UTF-8 bypassed the `purpose` diagnostic `required_purpose` read the field with `value.to_str()?`. Lua strings are BYTE strings, so `purpose = string.char(255)` is a value a caller can write, and `?` surfaced mlua's generic conversion error BEFORE this lane's own diagnostic was ever constructed: the caller was told neither the field nor the rule. **This is the third time this project has hit the class** — an unowned Lua string converted with `?` ahead of the owned message; the destination-capture lane corrected the same shape two rounds ago. It refused before spawning and nothing leaked, so the defect was the message, not the outcome. The conversion failure is now mapped onto this function's own message, and the new acceptance row asserts on message CONTENT so retyping the read as a bare `?` breaks the test rather than silently degrading the error. Auditing the rest of the lane's diff for the same class turned up exactly one more: `_push_dispatch_name` took `name: String`, so a registered handler name that was not valid UTF-8 failed at first dispatch with mlua's generic message. It now takes `mlua::String` and maps that failure onto an owned diagnostic naming the argument and the rule. Those are the only two Lua-string reads this lane added; every other binding it adds takes `()`. ## P2b — no safe display-text boundary. Two halves, two different fixes ### Handler names are refused at the source `pmacs.workers.register` type-checked its name and nothing more, which was defensible while the name died inside `dispatch`. It no longer dies there: the ambient carries it into every job the handler allocates and composes it into `purpose`, which `*workers*` and the modeline both render. So it now gets `purpose`'s meaningful-value standard — non-empty, not whitespace-only — plus control characters, which have no legitimate place in a registered identifier. ### Purposes are ESCAPED at presentation, not rejected at the registry A purpose may legitimately contain a newline: a filesystem path can, and `pmacs-magit`'s spawn purpose is a whole argv. **This is the shape of the `#228` decision, and it is consistent with it** — the one-line constraint belongs to the surface that has it, not to the registry that does not. There, `Command.description` stays free-form and the two single-row consumers clip with `description_first_line`. Here the equivalent is escaping rather than clipping, because a purpose's later words are load-bearing: an argv's second word says which file, and a clip would drop it silently. `purpose_for_one_row` states the property it exists for: **a row must not be able to forge another row.** It escapes `\n`, `\r`, `\t` and the rest of the Unicode `Cc` class (which covers ESC, so a purpose cannot open a terminal escape sequence either), borrows unchanged when there is nothing to escape — making byte-identity structural rather than asserted — and deliberately does NOT escape backslashes: no number of them produces a second row, and doubling them would cost byte-identity for ordinary text. Two surfaces call it: the `*workers*` rows, and `ActivitySummary`, which exists for one consumer that has exactly one row. `pmacs.workers.snapshot()` is this lane's `describe-command` and stays raw, which is what makes this a rendering decision rather than data loss — asserted, not assumed. ## P3 — two stale recovery summaries `docs/worker-identity-framing.md` still said "Implementation may proceed"; it is implemented. `docs/active-work.md` still said Stage 1 takes the "first two" of owner/purpose/parent — `owner` was REMOVED in revision 2, so it takes one of the three, and the claim the whole `owner` argument overturned was still standing in the volatile state of record. Both fixed section-locally. ## Verification `tests/worker_identity_acceptance.rs`, 18 -> 24 tests: * invalid-UTF-8 purpose refused by THIS lane's message, asserted on content, alongside the absent / empty / whitespace / wrong-type / metatable rows; * a whitespace-only handler name and a control-character one are each refused AT `register`, asserted on the error and on the handler not being installed (dispatch reports `unknown handler`); * a non-UTF-8 handler name is refused before the handler runs, with the dispatch-name stack left empty; * a purpose containing a newline renders as ONE row in `*workers*` and as one line in the modeline — through the real rendering path, the latter through a painted frame as well as the evaluator; * **a purpose crafted to look like a row boundary does not produce a second row** — asserted by counting rows, with the escaped text asserted present so a renderer that dropped the purpose entirely could not pass; * a purpose with no control characters is byte-identical on both surfaces, fixtured with a literal backslash, a literal `\v`, quotes and a non-ASCII character. Mutation-checked, seven guards, each failing its own test and no other: the purpose UTF-8 diagnostic; the `_push_dispatch_name` one; the register whitespace guard; the register control-character guard; the `*workers*` call site; the `ActivitySummary` call site; and `purpose_for_one_row` itself neutered to the identity, which fails both surfaces' tests and nothing else. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/async.lua | 34 ++++ docs/active-work.md | 14 +- docs/worker-identity-framing.md | 5 +- src/async_runtime.rs | 81 +++++++- src/lua_bindings/mod.rs | 48 ++++- src/workers_buffer.rs | 12 +- tests/worker_identity_acceptance.rs | 305 +++++++++++++++++++++++++++- 7 files changed, 476 insertions(+), 23 deletions(-) diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index 000be49..eb813da 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -468,6 +468,26 @@ function pmacs.workers.dispatch(name, args, opts) return finish_dispatch(pcall(handler, args, opts)) end +-- Worker identity Stage 1: the name registered here is DISPLAY TEXT. +-- +-- It used to be type-checked and nothing more, which was defensible +-- while it died inside `dispatch`. It no longer dies there: the ambient +-- carries it into every job the handler allocates, and it is composed +-- into `purpose` as `": "`, which the `*workers*` table +-- and the modeline indicator both render. So it gets the same +-- meaningful-value standard `purpose` already gets in +-- `required_purpose` (`src/lua_bindings/mod.rs`) --- and one rule +-- `purpose` deliberately does NOT get. +-- +-- The asymmetry is the point. A purpose may legitimately contain a +-- newline: a filesystem path can, and `pmacs-magit`'s spawn purpose is a +-- whole argv --- so its one-line constraint is enforced by ESCAPING at +-- the surfaces that have one row (`purpose_for_one_row`), following the +-- `#228` decision on `Command.description`. A registered handler NAME +-- has no such case. It is an identifier a package chooses for itself and +-- passes back to `dispatch`, so a control character in it is a mistake +-- or an attempt at one, and refusing at the source costs nobody +-- anything. function pmacs.workers.register(name, handler) -- Allows future Rust-side modules (or test harnesses) to register -- additional dispatchable names. v0.1 has no plugin loader but the @@ -475,6 +495,20 @@ function pmacs.workers.register(name, handler) if type(name) ~= "string" then error("pmacs.workers.register: name must be a string") end + -- Empty and whitespace-only satisfy the type and say nothing --- the + -- exact pair `required_purpose` rejects, and the exact pair R42 + -- rejects for config descriptions. + if name:match("^%s*$") ~= nil then + error("pmacs.workers.register: name must not be empty or whitespace-only") + end + -- `%c` is the C control class: NUL, the C0 range, DEL. A newline + -- forges a row in `*workers*`, a CR rewrites one on a terminal and an + -- ESC starts a sequence in one. Checked AFTER the whitespace rule so + -- a name that is only "\n" reports the emptier problem, which is the + -- one the caller can act on. + if name:find("%c") ~= nil then + error("pmacs.workers.register: name must not contain control characters") + end if type(handler) ~= "function" then error("pmacs.workers.register: handler must be a function") end diff --git a/docs/active-work.md b/docs/active-work.md index 474e6a9..83f2701 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -339,10 +339,16 @@ the authoritative tip** — the ref, not a SHA. Recover with so making terminal PTYs visible is deferred to Stage 2 with a separate accessor rather than by widening this one. - **Deliberate deviation from the audit, flagged for review:** §9 names - owner/purpose/**parent** together as the prerequisite; Stage 1 takes - only the first two. A parent needs an ambient "currently-running job" - context, and an unpopulated `parent` reads as "no parent" rather than - "not tracked" (Q#W-5). + owner/**purpose**/parent together as the prerequisite; Stage 1 takes + **only `purpose`** — one of the three, not two. `owner` was removed in + revision 2: nothing in the runtime knows which package asked for a + job, so an `owner` field could only have been filled with the same + handler name `purpose` already carries, and an empty one reads as + "unowned" rather than "not tracked". `parent` is out for the matching + reason — it needs an ambient "currently-running job" context, and an + unpopulated `parent` reads as "no parent" rather than "not tracked" + (Q#W-5). The package-ownership slot stays **deliberately empty** until + P3 can fill it with a real signal (framing §3, §7). - **Gates:** `scripts/gate --acceptance worker_identity_acceptance --acceptance journey_acceptance --acceptance statusline_segments_acceptance --acceptance compile_mode_acceptance diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md index b2280a4..aa165e0 100644 --- a/docs/worker-identity-framing.md +++ b/docs/worker-identity-framing.md @@ -5,8 +5,9 @@ removed that title overclaimed the lane: it answers **what**, and — under `pmacs.workers.dispatch` — **under which registered handler**. Neither is who owns it.)* -**Status: revision 4, APPROVED 2026-08-09. Implementation may -proceed.** +**Status: revision 4, APPROVED 2026-08-09. IMPLEMENTED — see +`docs/active-work.md` for the commits, the gate outcome and the review +rounds.** **Revision 4 scopes rule 1's claim to what it can actually enforce, and takes Q#W-7 into this lane.** Revision 3 said the rule covered "all diff --git a/src/async_runtime.rs b/src/async_runtime.rs index e60d0ae..284d2ee 100644 --- a/src/async_runtime.rs +++ b/src/async_runtime.rs @@ -58,8 +58,10 @@ //! search with cooperative cancellation and frame-boundary coalescing. //! Tree-sitter and LSP land in M4 on the same dispatch shape. +use std::borrow::Cow; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; +use std::fmt::Write; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -607,15 +609,87 @@ pub struct ActivitySummary { /// is always present costs modeline width forever to say "nothing /// is happening". pub in_flight: usize, - /// The **oldest** in-flight job's purpose. + /// The **oldest** in-flight job's purpose, already passed through + /// [`purpose_for_one_row`]. /// /// Oldest, not newest and not "busiest": jobs carry no cost /// estimate, so "busiest" is not a defined quantity, while oldest /// is computable from `dispatched_at` and answers the question a /// user actually asks of a stuck editor. + /// + /// Escaped here rather than at the Lua provider because this struct + /// **is** the indicator's read surface — it exists for one consumer, + /// and that consumer has exactly one row. `workers_snapshot` is the + /// free-form path and stays raw. pub oldest_purpose: String, } +/// A `purpose` rendered for a surface that gives it exactly **one row**. +/// +/// # A row must not be able to forge another row +/// +/// That is the property, and it is the only reason this exists. A +/// purpose is free-form text supplied by whoever dispatched the work, +/// and it is legitimately multi-line: a filesystem path may contain a +/// newline, and `pmacs-magit`'s spawn purpose is a whole argv. Rendered +/// raw into a row-per-job table, one such purpose becomes two physical +/// lines — the second of which the reader has no way to tell from a real +/// job row, because a real job row is just text in the same buffer. +/// The same applies to `\r`, which rewrites a rendered line in place on +/// a terminal, and to `\u{1b}`, which starts an escape sequence in one. +/// +/// # Escape, do not reject, and do not clip +/// +/// This follows the `#228` decision recorded on +/// [`crate::command::Command::description`]: the one-line constraint +/// belongs to the **surface that has it**, not to the registry that does +/// not. There, a free-form description is clipped by +/// `Command::description_first_line` at the two single-row consumers +/// while the registry keeps every line. Here the equivalent is escaping +/// rather than clipping, because a purpose's later lines are not +/// decoration — an argv's second word is as load-bearing as its first, +/// and a clip would silently drop the part that says which file. +/// +/// `pmacs.workers.snapshot()` is this lane's `describe-command`: it +/// hands Lua the raw purpose, so nothing is lost, only made safe where +/// a row boundary means something. +/// +/// # What is not escaped +/// +/// A backslash. Escaping it would make a purpose containing no control +/// characters **not** byte-identical after this call, and byte-identity +/// for ordinary text is a property worth more than distinguishing a +/// literal `\n` from an escaped newline — the ambiguity is cosmetic, +/// while forging a row is not, and no amount of literal backslashes +/// produces a second row. +#[must_use] +pub fn purpose_for_one_row(purpose: &str) -> Cow<'_, str> { + // `char::is_control` is the Unicode `Cc` category: C0 (`\0`–`\x1f`), + // `\x7f`, and C1 (`\u{80}`–`\u{9f}`, which includes NEL). Borrowing + // when there is nothing to do keeps the common path allocation-free + // AND makes the byte-identity property structural rather than + // asserted. + if !purpose.contains(char::is_control) { + return Cow::Borrowed(purpose); + } + let mut out = String::with_capacity(purpose.len() + 8); + for ch in purpose.chars() { + match ch { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other if other.is_control() => { + // `\u{1b}`, the same spelling Rust's own `escape_debug` + // uses, so the rendered form is one a reader can paste + // back into either language and get the byte returned. + let _ = write!(out, "\\u{{{:x}}}", other as u32); + } + other => out.push(other), + } + } + Cow::Owned(out) +} + /// One frame's worth of streamed items for a single stream id, /// returned by [`AsyncRuntime::take_stream_batches`]. T M3.5. #[derive(Clone, Debug)] @@ -1514,7 +1588,10 @@ impl AsyncRuntime { let (_, purpose) = oldest?; Some(ActivitySummary { in_flight, - oldest_purpose: purpose.to_owned(), + // The modeline is one row and a segment is one line; + // `purpose_for_one_row` is what keeps a purpose carrying a + // newline (a path, an argv) from breaking it. + oldest_purpose: purpose_for_one_row(purpose).into_owned(), }) } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index c47b678..406a3d6 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7580,12 +7580,27 @@ pub fn install_async( // `pmacs.workers.dispatch`, which brackets these itself under // `pcall`. A package pushing by hand and failing to pop would poison // every later dispatch in the session with a stale name. + // + // `mlua::String`, not `String`: the parameter is a Lua BYTE string, + // so an `mlua`-driven `String` conversion would refuse a non-UTF-8 + // name with a generic message naming neither the argument nor the + // rule. `pmacs.workers.register` enforces the rest of the + // display-text standard (non-empty, no control characters) but + // cannot see UTF-8 validity from Lua 5.1, so the byte-level half is + // enforced here — the one point where Rust sees the name — with a + // message that names both. { let rt = runtime.clone(); async_mod.set( "_push_dispatch_name", - lua.create_function(move |_, name: String| { - rt.push_dispatch_name(name); + lua.create_function(move |_, name: mlua::String| { + let Ok(text) = name.to_str() else { + return Err(mlua::Error::external( + "pmacs.workers.dispatch: handler name must be valid UTF-8 — it is \ + displayed to the user as part of every job's purpose.", + )); + }; + rt.push_dispatch_name(&*text); Ok(()) })?, )?; @@ -8766,18 +8781,35 @@ fn parse_restart(name: &str) -> mlua::Result { /// /// # Errors /// -/// Absent, empty, whitespace-only, or non-string. Empty and -/// whitespace-only are rejected because they satisfy the type and defeat -/// the point exactly as copying the label across would — R42 already -/// rejects whitespace-only `description`s in the config registry for the -/// same reason. +/// Absent, empty, whitespace-only, non-string, or **not valid UTF-8**. +/// Empty and whitespace-only are rejected because they satisfy the type +/// and defeat the point exactly as copying the label across would — R42 +/// already rejects whitespace-only `description`s in the config registry +/// for the same reason. +/// +/// The UTF-8 case is a **reachable input class, not an internal +/// invariant**: Lua strings are byte strings, so `purpose = +/// string.char(255)` is a value a caller can write. Converting it with +/// `?` would surface mlua's generic conversion error *before* any of the +/// diagnostics below is constructed, and the caller would be told +/// neither the field nor the rule — so the conversion failure is mapped +/// onto this function's own message instead. /// /// The read is **raw**, matching the posture `stdin` and `group` already /// document in [`lua_to_spec`]: a spec table is plain data, so a /// metatable cannot smuggle a purpose in through `__index`. fn required_purpose(table: &Table) -> mlua::Result { let purpose = match table.raw_get::("purpose") { - Ok(mlua::Value::String(value)) => value.to_str()?.to_owned(), + Ok(mlua::Value::String(value)) => match value.to_str() { + Ok(text) => text.to_owned(), + Err(_) => { + return Err(mlua::Error::external( + "pmacs.process.spawn: purpose must be valid UTF-8 — it is displayed \ + to the user in *workers* and in the modeline, and arbitrary bytes \ + have no display form there.", + )); + } + }, Ok(mlua::Value::Nil) => { return Err(mlua::Error::external( "pmacs.process.spawn: purpose is required — a short description of what \ diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 02d6d03..2a3bd59 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -42,6 +42,12 @@ //! `Status` right rather than being truncated: losing the end of a path //! is a worse failure than an uneven column. //! +//! This table is **one row per job**, and the purpose is the only free +//! text in it, so every row goes through +//! [`crate::async_runtime::purpose_for_one_row`]: a row must not be able +//! to forge another row. See that function for why the escaping lives +//! here rather than as a rule on the purpose itself. +//! //! Lua reads the snapshot via `pmacs.workers.snapshot()`; the //! `pmacs.workers.show()` builtin invokes [`render`] on it and //! returns the buffer id. Auto-refresh hooks into @@ -50,7 +56,7 @@ use std::fmt::Write; use crate::async_runtime::{ - ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot, + ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot, purpose_for_one_row, }; use crate::buffer::{Buffer, BufferId, EditOp}; use crate::buffer_registry::BufferRegistry; @@ -197,7 +203,7 @@ fn write_active_row(text: &mut String, job: &ActiveJobInfo) { if job.is_stream { status.push_str(" [stream]"); } - let purpose = &job.purpose; + let purpose = purpose_for_one_row(&job.purpose); let _ = writeln!( text, "{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:9} {key:<11} {purpose:(&state, "return RAN"), + "and it must refuse BEFORE running the handler" + ); + assert!( + !eval::(&state, "return pmacs._async._in_dispatch_name_scope()"), + "a push that failed must leave no name on the stack" + ); + pump(&mut state); +} + /// **Rule 7 + the defect itself.** A job dispatched through /// `pmacs.workers.dispatch("name", …)` reports `"name"`. /// @@ -898,3 +1053,145 @@ fn the_workers_buffer_renders_the_purpose_column() { exec(&state, "pmacs.workers.hide()"); pump(&mut state); } + +// --------------------------------------------------------------------------- +// 7 — the display-text boundary: a row must not forge another row +// --------------------------------------------------------------------------- +// +// A purpose is free-form caller text and is legitimately multi-line — a +// filesystem path may contain a newline and `pmacs-magit`'s spawn +// purpose is a whole argv — so the one-line constraint belongs to the +// surfaces that have one line, not to the purpose. That is `#228`'s +// decision on `Command.description`, applied here as escaping rather +// than clipping, because a purpose's later words are load-bearing. +// +// Every test below drives the REAL rendering path. Calling +// `purpose_for_one_row` directly would prove the escaper escapes and say +// nothing about whether either surface calls it. +// +// `register_external` is the witness in all three because it is the one +// entry shape whose purpose is verbatim caller text: the pool +// dispatchers all `format!` their own, and `{:?}` in those formats +// already escapes, so a hostile purpose cannot reach a row through them. + +/// **The spoofing property, and the whole reason the escaping exists.** +/// +/// A purpose crafted to look like a row boundary followed by a plausible +/// job row does not produce a second row. Asserted by COUNTING the rows, +/// not by looking for the escape sequence: a renderer that dropped the +/// purpose entirely would satisfy "no forged row" while destroying the +/// feature, so the escaped text is asserted present in the surviving row +/// as well. +#[test] +fn a_purpose_shaped_like_a_row_boundary_does_not_produce_a_second_row() { + let mut state = editor(); + let (job_id, _token) = state.async_runtime.register_external( + JobKind::LspRequest, + None, + "lsp definition\n#99 grep 0ms forged \ + cancelled by nobody", + ); + exec(&state, "BUF = pmacs.workers.show()"); + let text: String = eval(&state, "return BUF:slice(0, BUF:len())"); + + let job_rows: Vec<&str> = text.lines().filter(|line| line.starts_with('#')).collect(); + assert_eq!( + job_rows.len(), + 1, + "one job must render as exactly ONE row:\n{text}" + ); + assert!( + job_rows[0].contains("lsp definition\\n#99"), + "and the break must be rendered, escaped, INSIDE that row:\n{text}" + ); + assert!( + !text.contains("\n#99"), + "no line may begin with the forged id:\n{text}" + ); + + // `#228`'s other half, and what makes this a rendering decision + // rather than data loss: the free-form surface still hands Lua every + // byte, unescaped. + let raw = active_purposes(&state); + assert!( + raw.iter().any(|purpose| purpose.contains('\n')), + "pmacs.workers.snapshot() is the raw path and must stay raw: {raw:?}" + ); + + exec(&state, "pmacs.workers.hide()"); + state.async_runtime.complete_external_cancelled(job_id); + pump(&mut state); +} + +/// **The modeline is one line, and that is enforced where the modeline +/// reads.** +/// +/// Two assertions, because they exclude different failures: the segment +/// carries no break at all (a composed modeline splicing one would +/// misplace every segment after it), and the escaped text survives the +/// real per-frame paint rather than only the evaluator. +#[test] +fn a_purpose_that_spans_lines_reaches_the_modeline_as_one_line() { + let mut state = editor(); + let (job_id, _token) = state.async_runtime.register_external( + JobKind::LspRequest, + None, + "lsp didOpen\nfile:///tmp/x.rs", + ); + + let segment = activity_segment(&state).expect("work is in flight, so a segment exists"); + assert!( + !segment.contains(['\n', '\r']), + "a modeline segment is ONE line: {segment:?}" + ); + assert_eq!( + segment, "⋯1 lsp didOpen\\nfile:///tmp/x.rs", + "and the break is escaped in place, not clipped away" + ); + + let cells = paint(&state, 24, 160); + let modeline = row_text(&cells, 160, 22); + assert!( + modeline.contains("⋯1 lsp didOpen\\nfile:///tmp/x.rs"), + "the painted modeline must carry it too; got {modeline:?}" + ); + + state.async_runtime.complete_external_cancelled(job_id); + pump(&mut state); +} + +/// **A purpose with no control characters is byte-identical after +/// escaping** — on both surfaces. +/// +/// The fixture is chosen to break a careless escaper: a literal +/// backslash (which a JSON-style escaper would double, and which is +/// deliberately NOT escaped here — no number of backslashes produces a +/// second row), a `\v` that is text rather than a vertical tab, quotes, +/// and a non-ASCII character. +#[test] +fn a_purpose_with_no_control_characters_is_unchanged_by_the_boundary() { + const PURPOSE: &str = r#"grep "fn \d+" in /tmp/pro—ject\v2"#; + + let mut state = editor(); + let (job_id, _token) = + state + .async_runtime + .register_external(JobKind::LspRequest, None, PURPOSE); + + exec(&state, "BUF = pmacs.workers.show()"); + let text: String = eval(&state, "return BUF:slice(0, BUF:len())"); + assert!( + text.contains(PURPOSE), + "the *workers* row must reproduce an ordinary purpose byte for byte:\n{text}" + ); + + assert_eq!( + activity_segment(&state).as_deref(), + Some(format!("⋯1 {PURPOSE}").as_str()), + "and so must the modeline segment" + ); + + exec(&state, "pmacs.workers.hide()"); + state.async_runtime.complete_external_cancelled(job_id); + pump(&mut state); +} From 028016333cb4c0de0f22a969eb7d7c23f5771913 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 19:41:57 +0200 Subject: [PATCH 55/66] docs: record review round 2, and R7's control finally discriminated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two files, no code. ## `docs/active-work.md` — review round 2 The lane's volatile block gains the round-2 record: the three findings, why P2a's fix is a mapped diagnostic and P2b's is escaping at presentation rather than rejection at the registry, and the seven new mutation checks. The gate outcome is recorded with its step counts and the two stop-signal facts — `journey_acceptance` 47/47 and all three `#pmacs.process.list()` leak detectors byte-identical to `main`. It also records what P2a's audit found and did NOT fix: `pmacs.process.spawn`'s other string fields still convert generically. That is pre-existing and out of this lane's diff, and it is named so it is not silently inherited by whoever reads the fixed `purpose` read and assumes the rest matches. ## `docs/ci-red-signatures.md` — R7's third occurrence, and U4 **R7 reproduced, and the control the second-occurrence note prescribed finally discriminated — against its own hypothesis.** Occurrence 2 left exactly one causal path open: the observing lane had added a GPU-heavy `render_offscreen` test to the same binary, and contention with a one-second socket handshake was plausible. That note prescribed the control to run if a third occurrence landed — with the added test removed, not at the merge base. A third occurrence landed, at the gate's `gpu` step, with all three fragments verified against the durable log. The control was run. **Ten full `-p pmacs-gpu` runs with the added test: 10/10 green. Ten with it `#[ignore]`d, nothing else changed: 1 failure in 10, all three fragments present.** Removing the suspect made the failure more frequent, so the concurrent-test path is excluded — no contention story from that test survives that direction. The more useful result is the rate. This is the first rerun in R7's history to reproduce anything at all, and it puts the failure at roughly 1-in-10 under ordinary `-p pmacs-gpu` load. Three sightings were not enough to bisect a handshake; 1-in-10 is. The row now says so, and tells the next agent to instrument which side closes the pipe rather than re-run for green. The lane is still not attributed — now for a measured reason rather than an argument from diff shape: the arm without the lane's only `pmacs-gpu` addition is the arm that went red. **U4** records the other two reds from that same gate run: `criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` and `composition_overhead_under_ten_percent`, both wall-clock budget assertions, failing together in `04-lib-crdt` and both green in isolation and in the next full run. Fragments captured, so unlike U1–U3 it is matchable — it is a `U` row for want of a mechanism, not for want of evidence. The signature named is **the pair**: two budget tests failing in one run and neither in the next is far more likely to be one loaded machine than two simultaneous regressions, and a future run that reds only one of them is a different incident. Neither row claims harmlessness, and the concurrent-worktree load confound is recorded as a rival explanation rather than as a finding, because it was not measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 63 +++++++++++++++++++++++++++++++++++++-- docs/ci-red-signatures.md | 63 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 83f2701..5de19eb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -358,8 +358,9 @@ the authoritative tip** — the ref, not a SHA. Recover with required field is exactly the kind of change that breaks a package fixture quietly. - **IMPLEMENTED at `1aca0ee`**, with review round 1's blocker fixed at - `2162737`. `tests/worker_identity_acceptance.rs` is the new suite: 18 - tests, plus one consumer-side witness beside the private renderer in + `2162737` and review round 2's three findings at `6661125`. + `tests/worker_identity_acceptance.rs` is the new suite: **24 tests**, + plus one consumer-side witness beside the private renderer in `pmacs-gpu`. - **`journey_acceptance` passed UNTOUCHED (47/47)** — the stop signal did not fire. Q#W-7 edits the `commit_to` guard family, so any of its @@ -426,6 +427,64 @@ the authoritative tip** — the ref, not a SHA. Recover with `pmacs.process.spawn("ls")` occurrences in `src/audit/mod.rs` and `tests/m7_9_acceptance.rs` are **audit fixture source text** — lexed, never executed — and are deliberately untouched. +- **Review round 2 — the display-text boundary, fixed at `6661125`.** + Three findings, and the fix is deliberately different in each place + because the constraint is. + - **P2a: invalid UTF-8 bypassed the `purpose` diagnostic.** + `required_purpose` read the field with `value.to_str()?`; Lua strings + are BYTE strings, so `purpose = string.char(255)` surfaced mlua's + generic conversion error before this lane's own message existed. It + refused before spawning, so nothing leaked — the defect was the + message. **Third occurrence of this class in the project** (the + destination-capture lane corrected the same shape two rounds ago), so + the whole diff was audited for it: exactly one more, + `_push_dispatch_name` taking `name: String`, now `mlua::String` with + an owned diagnostic. Those two are the only Lua-string reads this + lane added; every other binding it adds takes `()`. The remaining + `pmacs.process.spawn` fields (`label`, `command`, `args`, `env`, + `cwd`) still convert generically — **pre-existing, untouched, and + named here rather than silently inherited.** + - **P2b, half one: handler names are refused at the source.** + `pmacs.workers.register` type-checked and nothing more, which was + fine while the name died inside `dispatch`. It no longer dies there, + so the name now gets `purpose`'s meaningful-value standard plus + control characters. + - **P2b, half two: purposes are ESCAPED at presentation, not rejected + at the registry — consistent with the `#228` decision.** A purpose + may legitimately contain a newline (a path can; `pmacs-magit`'s spawn + purpose is an argv), so the one-line constraint belongs to the + surface that has one row. `purpose_for_one_row` states the property + it exists for — **a row must not be able to forge another row** — + escapes the Unicode `Cc` class (so ESC cannot open a terminal + sequence either), borrows unchanged when there is nothing to escape + (byte-identity is structural, not asserted), and does **not** escape + backslashes: no number of them makes a second row, and doubling them + would cost byte-identity for ordinary text. Two callers: the + `*workers*` rows and `ActivitySummary`, which exists for one consumer + with exactly one row. `pmacs.workers.snapshot()` is the + `describe-command` of this lane and stays raw — asserted, so a clip + that deleted the text everywhere would fail rather than pass. + - **P3: two stale recovery summaries**, both fixed section-locally — + the framing doc's "Implementation may proceed", and this file's claim + that Stage 1 took the "first two" of owner/purpose/parent. It takes + **one**: `owner` was removed in revision 2, and the claim that + argument overturned was still standing here. + - **Seven more mutation checks, each failing its own test and no + other** (30 for the lane): the two UTF-8 diagnostics, the two + register guards, the two escaping call sites, and + `purpose_for_one_row` neutered to the identity — which fails both + surfaces' tests and nothing else, since it is the shared helper. + - **All 13 gate steps green at `6661125`** (log + `20260809T173314Z-1552101`): lib 1920, lib-crdt 2105, + worker_identity 24, journey **47/47 UNTOUCHED**, statusline 7, + compile_mode 73, m8_6 12, m4 151, gpu 242. The three + `#pmacs.process.list()` leak detectors and `journey_acceptance` are + **byte-identical to `main`** in round 2 — the stop signals did not + fire, and round 2 edited no test outside its own suite. **The + preceding run of the same command was red on three tests and none of + them was this diff's** — R7 for the third time plus two wall-clock + budget tests; recorded in `docs/ci-red-signatures.md` rather than + re-run away silently. - **Surfaces that changed shape, for anyone rebasing onto this:** `AsyncRuntime::allocate`/`allocate_with_resource` collapsed into one private `JobSpec`-taking funnel; `register_external` grew a third diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index c94c73b..f54fb48 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -496,10 +496,10 @@ Stage 4; the lane touches no `pmacs-gpu` code at all. | **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` | | **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load | | **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) | -| **status** | **SECOND OCCURRENCE 2026-08-09 — causal status still UNRESOLVED** | -| **what IS established** | **two** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair | +| **status** | **THIRD OCCURRENCE 2026-08-09 — causal status still UNRESOLVED, but one candidate mechanism is now EXCLUDED** | +| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below | | **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** | -| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Per the rerun rule all of this establishes **intermittence only** | +| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion | | **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe | **Not attributed to the observing lane**, and in neither case is the @@ -537,6 +537,39 @@ one-second deadline. Contention is a plausible mechanism for a lands, **run the control with the added test removed** rather than at the merge base — that is the discriminating comparison this one was not. +**Third occurrence — worker identity Stage 1 review round 2, +2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all +three fragments verified** against the durable gate log +(`20260809T172606Z-1387979/11-gpu.log`): `transient sequence must +attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message: +"Broken pipe" })))`. A match on this file's own rule, not a `U` note. + +**The control the second-occurrence note prescribed was run, and this +time it discriminated — against the hypothesis.** Ten full +`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` runs with the added +`render_offscreen` test present: **10/10 green**. Ten more with that +test `#[ignore]`d, changing nothing else: **1 failure in 10**, carrying +all three required fragments +(`without/run-6.log`, `pmacs-gpu/src/attach.rs:1680`). + +So the concurrent-GPU-test path named above is **excluded**: removing +the suspect made the failure *more* frequent, not less, which no +contention story from that test survives. What the run does establish is +that **the failure reproduces on demand at roughly 1-in-10 under +ordinary `-p pmacs-gpu` load** — the first time any rerun in this row's +history has reproduced it at all. That is a materially better starting +point than three isolated sightings, and it is the fact a diagnosis +should be built on: the rate makes a bisect of `attach.rs`'s handshake +path affordable, where before it was not. + +**It is still not attributed to the observing lane**, and now for a +measured reason rather than an argument from diff shape: the arm without +the lane's only `pmacs-gpu` addition is the arm that went red. + +**What would retire it is unchanged** — the mechanism, not the rate. +The next agent to touch this row should reproduce at 1-in-10 and +instrument which side closes the pipe, rather than re-running for green. + ### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence Has a selector, which U1 lacks — but still no fragments, so it cannot @@ -582,6 +615,30 @@ it again here by piping a sweep through `grep`. The fix is mechanical: stream. A signature that is cheap to capture and impossible to reconstruct should never be traded for terminal brevity. +### U4 — two wall-clock budget tests fail together in one `lib-crdt` step + +Recorded during worker identity Stage 1 review round 2, 2026-08-09, in +the same gate run that produced R7's third occurrence. **Fragments were +captured**, so unlike U1–U3 this one is matchable — it is a `U` row +because it has one occurrence and no mechanism, not because the evidence +was lost. + +| field | value | +|---|---| +| **selector** | `--lib --features crdt optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same run | +| **job / flavor** | local (Linux), `scripts/gate` step `04-lib-crdt`, with sibling worktrees building concurrently | +| **required fragments** | `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms`; and `composition machinery added more than 10% overhead` | +| **status** | **new incident, one occurrence, not reproduced** | +| **what IS established** | both are **wall-clock budget assertions** — 1.264ms against a 1ms budget, and 1.297× against a 1.10× budget — so both are load-sensitive by construction. Both green in an isolated rerun of exactly those two selectors, and both green in the next full gate run of the same command (2105 passed) | +| **what is NOT** | whether the machine's concurrent load caused it. The confound is real (this machine runs one shared `CARGO_TARGET_DIR` and several worktrees) but **was not measured**, so it is a rival explanation, not a finding | +| **rival explanation not excluded** | a genuine regression in either path. Nothing in the observing diff touches the optimistic-echo orchestrator or the composition pipeline, but "my diff looks unrelated" is not evidence, and this row does not treat it as such | + +**Two budget tests failing in one run and neither in the next is the +signature worth matching**, more than either name alone: a real +regression in two unrelated subsystems at once is far less likely than +one loaded machine. If a future run reds **one** of these without the +other, that is a different incident and should be judged as one. + **The retirements are not occurrences and do not close the log.** R1 and R3 stay live, and each retired row keeps its signature so a later red matching one reopens it. From 31352692c86c2aff981abb02e0e73109f9b539bc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:08:18 +0200 Subject: [PATCH 56/66] fix(process): name the surface a process purpose actually reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `required_purpose`'s invalid-UTF-8 refusal told the caller that their process purpose "is displayed to the user in *workers* and in the modeline". Neither is a process surface. Stage 1 deliberately keeps processes out of `*workers*` — which lists async JOBS — and out of the statusline activity indicator; a process's purpose is exposed through `pmacs.process.list` and nowhere else, and joining the two planes is Stage 2's work (framing §3, Q#W-4). The refusal is correct and stays: a purpose that cannot be displayed anywhere should still be refused, and nothing spawns either way. What was wrong is the reason given to the user, which pointed them at two places their process will never appear. A diagnostic that misdescribes the system is worse than a terse one, because it sends the reader looking in the wrong place. The job-side twin diverges rather than converging. `_push_dispatch_name` refuses a non-UTF-8 handler name for the same reason, and there `*workers*` and the modeline are the RIGHT answer — the name is composed into every job's purpose and a job renders in both. It said only "as part of every job's purpose", which names no surface at all, so it now names the two it reaches. The two messages must not collapse into one sentence: whichever wording won would be wrong on the other side. Verification. `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches` asserts both directions, positive AND negative — the process message contains `pmacs.process.list` and NOT `*workers*`/`modeline`, the job message contains both of those and NOT `pmacs.process.list`. The negative halves are the anti-collapse guard; without them a later "unify the wording" edit reintroduces exactly one wrong sentence and passes every other test in the file. The existing row-table assertion in `spawning_without_a_real_purpose_is_refused_and_starts_nothing` now runs as far as the surface name too, so the same edit breaks two tests. Three mutation checks, each red on its own claim: restoring the old process wording fails both content assertions; collapsing the job message onto the process wording fails only the new test (which is the point — the old job test asserted the prefix alone and could not see it); restoring the job message's original vague wording fails it too. The doc comments were fixed with the literals. `required_purpose`'s rustdoc now states which surface its message names and why it names neither of the other two, and the `_push_dispatch_name` comment states the converse. A corrected string whose doc comment still argues the other way is one refactor from reverting itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/lua_bindings/mod.rs | 27 +++++++++-- tests/worker_identity_acceptance.rs | 75 +++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 406a3d6..1a9795a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7589,6 +7589,15 @@ pub fn install_async( // cannot see UTF-8 validity from Lua 5.1, so the byte-level half is // enforced here — the one point where Rust sees the name — with a // message that names both. + // + // And it names the surfaces a JOB reaches, which are `*workers*` and + // the modeline activity indicator. The sibling refusal in + // `required_purpose` deliberately names a different one + // (`pmacs.process.list`), because a spawned process reaches neither + // of these in Stage 1. The two must not converge on one sentence: + // whichever wording won would be wrong on the other side, and a + // diagnostic that misdescribes the system sends the reader looking + // in the wrong place. { let rt = runtime.clone(); async_mod.set( @@ -7597,7 +7606,9 @@ pub fn install_async( let Ok(text) = name.to_str() else { return Err(mlua::Error::external( "pmacs.workers.dispatch: handler name must be valid UTF-8 — it is \ - displayed to the user as part of every job's purpose.", + composed into every job's purpose, which is displayed to the user \ + in *workers* and in the modeline, and arbitrary bytes have no \ + display form there.", )); }; rt.push_dispatch_name(&*text); @@ -8795,6 +8806,16 @@ fn parse_restart(name: &str) -> mlua::Result { /// neither the field nor the rule — so the conversion failure is mapped /// onto this function's own message instead. /// +/// That message names **`pmacs.process.list`**, which is the whole of +/// where a process's purpose surfaces in Stage 1. It deliberately does +/// *not* name `*workers*` or the modeline indicator: both are **job** +/// surfaces, a spawned process appears in neither, and joining the two +/// planes is Stage 2's work (framing §3, Q#W-4). A diagnostic that +/// named them would send the reader looking for their process somewhere +/// it will never appear — worse than a terse one. The job-side twin of +/// this refusal, on `_push_dispatch_name`, names those two surfaces for +/// the matching reason: a job really does reach them. +/// /// The read is **raw**, matching the posture `stdin` and `group` already /// document in [`lua_to_spec`]: a spec table is plain data, so a /// metatable cannot smuggle a purpose in through `__index`. @@ -8805,8 +8826,8 @@ fn required_purpose(table: &Table) -> mlua::Result { Err(_) => { return Err(mlua::Error::external( "pmacs.process.spawn: purpose must be valid UTF-8 — it is displayed \ - to the user in *workers* and in the modeline, and arbitrary bytes \ - have no display form there.", + to the user in pmacs.process.list, and arbitrary bytes have no \ + display form there.", )); } }, diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs index 54fc332..f5dcef7 100644 --- a/tests/worker_identity_acceptance.rs +++ b/tests/worker_identity_acceptance.rs @@ -290,8 +290,11 @@ fn every_entry_shape_records_what_its_work_is() { /// error before this lane's own diagnostic was ever constructed. The /// refusal is not the interesting part — it refuses either way, and /// nothing spawns either way — the MESSAGE is, which is why the -/// assertion is on content. Retyping this read as a bare `?` breaks -/// the row rather than silently degrading the error; +/// assertion is on content, and why the expected text now runs as far +/// as the **surface** the message names. Retyping this read as a bare +/// `?` breaks the row rather than silently degrading the error, and +/// naming the wrong surface breaks it too — see +/// `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`; /// * **metatable-provided**, which is the `stdin`/`group` raw-read /// posture: a spec table is plain data, so a purpose cannot be /// smuggled in through `__index`. @@ -325,7 +328,8 @@ fn spawning_without_a_real_purpose_is_refused_and_starts_nothing() { "invalid UTF-8", r#"{ label = "x", purpose = "run " .. string.char(255), command = "/bin/sh", args = { "-c", "sleep 5" } }"#, - "purpose must be valid UTF-8", + "purpose must be valid UTF-8 — it is displayed to the user in \ + pmacs.process.list", ), ( "metatable-provided", @@ -534,6 +538,71 @@ fn a_handler_name_that_is_not_valid_utf8_is_refused_before_the_handler_runs() { pump(&mut state); } +/// **A diagnostic that names the wrong surface is worse than a terse +/// one, and the two UTF-8 refusals do not name the same surface.** +/// +/// Both messages tell the caller *why* their bytes are refused: the text +/// gets displayed, and arbitrary bytes have no display form. But the two +/// values reach **different** places, and Stage 1 makes that difference +/// deliberately: +/// +/// * a **job**'s purpose — which a handler name is composed into — is +/// rendered by `*workers*` and by the modeline activity indicator; +/// * a **process**'s purpose is exposed through `pmacs.process.list` +/// and nothing else. Processes are kept out of `*workers*` and out of +/// the indicator until Stage 2's unified view (framing §3, Q#W-4). +/// +/// So the process-side message must not send a caller to `*workers*` to +/// look for a process that will never be listed there, and the job-side +/// message must not send them to an accessor that enumerates no jobs. +/// **Both directions are asserted, positive and negative**, because a +/// later edit that "unified the wording" would otherwise reintroduce +/// exactly one wrong sentence in exactly one of the two places and pass +/// every other test in this file. +#[test] +fn the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches() { + let mut state = editor(); + + let (spawned, process_err): (bool, String) = eval( + &state, + r#"local ok, err = pcall(pmacs.process.spawn, { + label = "x", purpose = "run " .. string.char(255), + command = "/bin/sh", args = { "-c", "sleep 5" } }) + return ok, tostring(err)"#, + ); + assert!(!spawned, "precondition: the spawn must refuse"); + assert!( + process_err.contains("pmacs.process.list"), + "a process purpose reaches pmacs.process.list, and the refusal must \ + say so; got {process_err:?}" + ); + assert!( + !process_err.contains("*workers*") && !process_err.contains("modeline"), + "and it must NOT name the job surfaces a process never reaches; \ + got {process_err:?}" + ); + + let (dispatched, job_err): (bool, String) = eval( + &state, + "pmacs.workers.register('bad' .. string.char(255), function() end) + local ok, err = pcall(pmacs.workers.dispatch, 'bad' .. string.char(255)) + return ok, tostring(err)", + ); + assert!(!dispatched, "precondition: the dispatch must refuse"); + assert!( + job_err.contains("*workers*") && job_err.contains("modeline"), + "a handler name reaches both job surfaces, and the refusal must name \ + them; got {job_err:?}" + ); + assert!( + !job_err.contains("pmacs.process.list"), + "and it must NOT name the process accessor, which enumerates no jobs; \ + got {job_err:?}" + ); + + pump(&mut state); +} + /// **Rule 7 + the defect itself.** A job dispatched through /// `pmacs.workers.dispatch("name", …)` reports `"name"`. /// From 1a00d8130d9ec53a5da14b9b10d49de5cb92be87 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:08:43 +0200 Subject: [PATCH 57/66] docs: record review round 3, and a sweep red that moves each run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane entry gains round 3: the wrong-surface diagnostic, why the job and process refusals now say different things, the anti-collapse test and its three mutation checks. Written here rather than left in the commit message because this file is what a recovering agent reads. `docs/ci-red-signatures.md` gains **U5**. Two consecutive `scripts/gate` runs of the same command, on the same tree, red on step `12-sweep` with a DIFFERENT wall-clock render-budget test each time — 224ms and 258ms against a 200ms budget, 114ms against a 100ms budget, at load average 12.9/23.9 with sibling worktrees building. Each passes in an isolated rerun of its own selector, and no selector reds twice. The rotating selector is the signature, and it is a stronger one than any single test name: a regression that moved between three unrelated render paths on an unchanged tree is far less likely than one loaded machine. The observing diff is two string literals, their doc comments and one test, and touches no render path at all. Kept separate from U4 rather than merged. U4 is two budget tests in `04-lib-crdt` failing TOGETHER; this is three render-budget tests in `12-sweep` failing ONE PER RUN. Merging them would assert a shared mechanism nothing in hand shows, and the load confound stays unmeasured in both — a rival explanation, not a finding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 45 +++++++++++++++++++++++++++++++++++++++ docs/ci-red-signatures.md | 22 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 5de19eb..731dfb1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -485,6 +485,51 @@ the authoritative tip** — the ref, not a SHA. Recover with them was this diff's** — R7 for the third time plus two wall-clock budget tests; recorded in `docs/ci-red-signatures.md` rather than re-run away silently. +- **Review round 3 — a diagnostic that named the wrong surface, fixed + at `b2e8efd`.** `required_purpose`'s invalid-UTF-8 refusal told the + caller their process purpose "is displayed to the user in `*workers*` + and in the modeline". **Neither is a process surface.** Stage 1 + deliberately keeps processes out of both (Q#W-4, framing §3) — a + process's purpose is exposed through `pmacs.process.list` and nothing + else — so the message sent the reader looking for their process in two + places it will never appear. The refusal itself is correct and stays: + a purpose with no display form anywhere is still refused. + - **The two UTF-8 refusals now name different surfaces, because they + reach different ones.** The job-side twin (`_push_dispatch_name`) + legitimately names `*workers*` and the modeline — a handler name is + composed into a job's purpose, and a job does render in both — so it + was made to say so explicitly rather than left at the vaguer "as + part of every job's purpose", which named no surface at all and + would have made the divergence unassertable. + - **A new test asserts both directions, positive and negative** + (`the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`, + 26 in the suite): the process message contains `pmacs.process.list` + and **not** `*workers*`/`modeline`; the job message contains both of + those and **not** `pmacs.process.list`. The existing row-table + assertion in `spawning_without_a_real_purpose_is_refused_and_starts_nothing` + now runs as far as the surface name too. Without the negative half a + later "unify the wording" edit reintroduces exactly one wrong + sentence and passes everything else. + - **Three mutation checks, each red on its own claim:** restoring the + old process wording fails both content assertions; collapsing the + job message onto the process wording fails only the new test (which + is the point — the old job test asserted the prefix alone); and + restoring the job message's original vague wording fails it too. + - **The rustdoc carried the same defect risk and was fixed with it** — + `required_purpose` now states which surface it names and why not the + other two, and the `_push_dispatch_name` comment states the + converse. A string literal corrected while its doc comment still + argues the other way is one refactor from reverting itself. + - **Gate: 12 of 13 steps green; step `12-sweep` red on a wall-clock + render-budget test, twice, on a DIFFERENT test each run** (logs + `20260809T195332Z-2113672` and `20260809T200120Z-2427128`, load + average 12.9/23.9 with sibling lanes building). All three pass in + isolated reruns; the diff is two string literals, their doc comments + and one test, and touches no render path. Recorded as **U5** in + `docs/ci-red-signatures.md` rather than re-run away silently. + `journey_acceptance` **47/47 UNTOUCHED** and the three + `#pmacs.process.list()` leak detectors unedited — the stop signals + did not fire. - **Surfaces that changed shape, for anyone rebasing onto this:** `AsyncRuntime::allocate`/`allocate_with_resource` collapsed into one private `JobSpec`-taking funnel; `register_external` grew a third diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index f54fb48..e95fd2b 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -639,6 +639,28 @@ regression in two unrelated subsystems at once is far less likely than one loaded machine. If a future run reds **one** of these without the other, that is a different incident and should be judged as one. +### U5 — a *different* wall-clock render-budget test reds each sweep + +Recorded during worker identity Stage 1 review round 3, 2026-08-09. +**Two consecutive `scripts/gate` runs of the same command, on the same +tree, red on step `12-sweep` with a different test each time** — which +is the signature, and it is a stronger one than any single selector. + +| field | value | +|---|---| +| **selector** | run 1: `--test m8_2_acceptance dired_open_renders_10k_entries_under_200ms` **and** `--test m8_9_acceptance outline_5_level_100_entry_renders_within_100ms`; run 2: `--test dired_acceptance dired_renders_10k_entries_within_200ms` | +| **job / flavor** | local (Linux), `scripts/gate` step `12-sweep` (`cargo test --workspace --no-fail-fast`), **load average 12.9 / 23.9** with sibling worktrees building concurrently | +| **required fragments** | `must render within 200ms; took ` / `open() (parse + render) took ` + `spec budget is 100ms` | +| **status** | **new incident, three selectors, none reproduced** | +| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, and no selector reds twice. The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot | +| **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding | +| **relation to U4** | same shape, different step and different tests: U4 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows | + +**The rotating selector is the thing to match.** A regression that +moved between three unrelated render paths on an unchanged tree is far +less likely than one loaded machine; a future run that reds the *same* +one of these twice is a different incident and should be judged as one. + **The retirements are not occurrences and do not close the log.** R1 and R3 stay live, and each retired row keeps its signature so a later red matching one reopens it. From 3570e1ad42013a2729e940b0d893d27a2274cca4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:14:15 +0200 Subject: [PATCH 58/66] docs: the third gate run is green, and U5 says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two reds recorded a moment ago were followed by a full green run of the same command on the same tree — all 13 steps, log `20260809T200907Z-2672209`. Both the lane entry and U5 now carry that, because a signature row that records only the reds overstates them: the green rerun is part of the evidence, not a reason to delete the row. The row stays live and stays U-classified. Three load-sensitive render budgets going red one per run and then green is consistent with a loaded machine and with nothing else in hand; it is not a measurement of one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 14 ++++++++------ docs/ci-red-signatures.md | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 731dfb1..5a5e58c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -520,12 +520,14 @@ the authoritative tip** — the ref, not a SHA. Recover with other two, and the `_push_dispatch_name` comment states the converse. A string literal corrected while its doc comment still argues the other way is one refactor from reverting itself. - - **Gate: 12 of 13 steps green; step `12-sweep` red on a wall-clock - render-budget test, twice, on a DIFFERENT test each run** (logs - `20260809T195332Z-2113672` and `20260809T200120Z-2427128`, load - average 12.9/23.9 with sibling lanes building). All three pass in - isolated reruns; the diff is two string literals, their doc comments - and one test, and touches no render path. Recorded as **U5** in + - **Gate: all 13 steps green at `cb7730d`** (log + `20260809T200907Z-2672209`). **The two preceding runs of the same + command were red on step `12-sweep`, on a DIFFERENT wall-clock + render-budget test each time** (`20260809T195332Z-2113672`, + `20260809T200120Z-2427128`; load average 12.9/23.9 with sibling + lanes building). All three pass in isolated reruns, none reds twice, + and the diff is two string literals, their doc comments and one + test — no render path is touched. Recorded as **U5** in `docs/ci-red-signatures.md` rather than re-run away silently. `journey_acceptance` **47/47 UNTOUCHED** and the three `#pmacs.process.list()` leak detectors unedited — the stop signals diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index e95fd2b..94b315b 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -652,7 +652,7 @@ is the signature, and it is a stronger one than any single selector. | **job / flavor** | local (Linux), `scripts/gate` step `12-sweep` (`cargo test --workspace --no-fail-fast`), **load average 12.9 / 23.9** with sibling worktrees building concurrently | | **required fragments** | `must render within 200ms; took ` / `open() (parse + render) took ` + `spec budget is 100ms` | | **status** | **new incident, three selectors, none reproduced** | -| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, and no selector reds twice. The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot | +| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, no selector reds twice, and **the third run of the same command on the same tree was green on all 13 steps** (log `20260809T200907Z-2672209`). The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot | | **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding | | **relation to U4** | same shape, different step and different tests: U4 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows | From 80b3dcf8973caaa431dd35966dc6ee709f670995 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:29:20 +0200 Subject: [PATCH 59/66] docs: the suite is 25, not 26 The previous round left the suite at 24 and this round adds one test, so it is 25. grep -c on the test attribute confirms 25 and the run reports 25/25. The bullet said 26, and I repeated it upstream without counting. The entry now shows the arithmetic --- 24 before, plus one --- rather than just a corrected number, and names the earlier figure so a reader who saw it does not treat this as a second suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5a5e58c..8fb9121 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -503,7 +503,9 @@ the authoritative tip** — the ref, not a SHA. Recover with would have made the divergence unassertable. - **A new test asserts both directions, positive and negative** (`the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`, - 26 in the suite): the process message contains `pmacs.process.list` + 25 in the suite — 24 before this round, plus this one; an earlier + revision of this bullet said 26): the process message contains + `pmacs.process.list` and **not** `*workers*`/`modeline`; the job message contains both of those and **not** `pmacs.process.list`. The existing row-table assertion in `spawning_without_a_real_purpose_is_refused_and_starts_nothing` From 7675142d4f47efebdb8ff2deca19797421b93b22 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:40:54 +0200 Subject: [PATCH 60/66] docs: U2 has a second occurrence, and this time the fragments were read The worker-identity tip gate went red on step 03-lib with one failure: m6_1_pty_raw_mode_disables_kernel_echo. U2 already had that exact selector but no fragments, so it could not be matched. Reading the durable gate log rather than filtering a rerun supplies them. The fragment reframes the failure. stty -a returned the EMPTY STRING, not a wrong mode --- so this is not raw mode failing to disable echo, it is stty producing no output at all, which points at PTY or spawn readiness under load rather than termios handling. The assertion own message is misleading on exactly that point, and anyone diagnosing it from the message will look in the wrong place. Occurrence 2 also EXCLUDES the change under test, which occurrence 1 could not. The tree carried zero code change since a 13/13 green run on this same lane --- the only delta was three lines of markdown. A docs edit cannot break a PTY test, so the diff is ruled out as a cause rather than merely doubted. Isolated rerun passes in 0.01s. Still no mechanism, and the row says so. Two occurrences establish intermittence and a load correlation; neither establishes cause. The row now names the discriminating control for a third: loop the selector under synthetic load logging stty output every iteration, since whether stty is empty EVERY time it fails is what separates a readiness race from a termios one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 94b315b..e85ad72 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -570,19 +570,24 @@ the lane's only `pmacs-gpu` addition is the arm that went red. The next agent to touch this row should reproduce at 1-in-10 and instrument which side closes the pipe, rather than re-running for green. -### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence +### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, SECOND occurrence; fragments captured -Has a selector, which U1 lacks — but still no fragments, so it cannot -be matched either. Recorded so a recurrence is recognisable. +Recorded first with a selector but **no fragments**, so it could not be +matched. The second occurrence (2026-08-09, worker-identity lane) was +read from the durable gate log rather than a filtered rerun, so the row +is now matchable — and the fragment changes what the failure looks +like. | field | value | |---|---| | **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` | | **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus | -| **required fragments** | **none captured** — output was filtered to the `FAILED` line | -| **status** | **new incident, unreproduced** | -| **what IS established** | it failed once (`1916 passed; 1 failed`), in no registry row, under a full-corpus run | -| **what is NOT** | any mechanism. Not reproduced in a later full `--tests --no-fail-fast` sweep (108 targets, exit 0) nor in 3 isolated `--lib` runs (1917/0 each) | +| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` — **captured on occurrence 2** | +| **status** | **two occurrences, load-correlated; the diff is EXCLUDED on occurrence 2** | +| **what IS established** | Occurrence 1: failed once (`1916 passed; 1 failed`) under a full-corpus run. Occurrence 2: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s) | +| **what the fragment REFRAMES** | `stty -a` returned the **empty string**, not a wrong mode. So this is not "raw mode failed to disable echo" — it is `stty` producing **no output at all**, which points at PTY/spawn readiness under load rather than at termios handling. The assertion's own message is misleading on this point, and anyone diagnosing it from the message alone will look in the wrong place | +| **what is NOT** | any mechanism, still. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 2. Two occurrences establish intermittence and a load correlation; neither establishes cause | +| **discriminating control if a third lands** | run the selector in a loop under synthetic load with `stty -a` output logged on every iteration — the open question is whether `stty` is empty *every* time it fails, which would separate a readiness race from a termios one | | **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red | ### U3 — the R7 selector again, fragments lost the same way U2's were From 71d262e08d9793ac0addc8be8da951b484f7ff40 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:44:46 +0200 Subject: [PATCH 61/66] docs: U2 was already known, and my fragment claim overreached Two corrections, both mine, both the same failure the row exists to warn about. First, this is at least the THIRD occurrence, not the second, and the fragment was not newly captured. docs/active-work.md records a 2026-08-06 loaded --features crdt run failing this selector AND m6_1_pty_canonical_mode_keeps_kernel_echo with the same stty -a output was: "" --- and it already proposed a mechanism family, read-before- write on the child output, the shape of R4 and R6. So the row claim that no mechanism had been proposed was false of the tree it was written in. The evidence was in this repository the whole time; I wrote a registry row without reading the registry neighbour. Second, the fragment does not show what I said it showed. The test inspects collect_stdout(&evs) after drain_until --- what the SUPERVISOR collected. It cannot distinguish stty never writing from the PTY dropping the bytes from event collection missing them. I wrote "stty produced no output at all", which asserts a mechanism the test cannot see, in the same row that says no mechanism is established. What survives is narrower and still worth having: this is not a termios failure, since nothing observed shows echo configured wrongly. Which of child-never-wrote, delivery-lost, collection-missed is open. The control changes accordingly. Sampling the collected string more times cannot separate those three however often it fails; the next occurrence needs the full process event stream and the child exit disposition captured, cross-checked against the R4/R6 readiness family that the 2026-08-06 entry already implicates. The gate conclusion is unaffected: a markdown-only delta cannot cause a PTY failure, so worker identity code is excluded as a cause. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index e85ad72..0162573 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -570,24 +570,34 @@ the lane's only `pmacs-gpu` addition is the arm that went red. The next agent to touch this row should reproduce at 1-in-10 and instrument which side closes the pipe, rather than re-running for green. -### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, SECOND occurrence; fragments captured +### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, THIRD known occurrence -Recorded first with a selector but **no fragments**, so it could not be -matched. The second occurrence (2026-08-09, worker-identity lane) was -read from the durable gate log rather than a filtered rerun, so the row -is now matchable — and the fragment changes what the failure looks -like. +**Corrected 2026-08-09 after review.** A previous edit of this row +called the 2026-08-09 failure the *second* occurrence and claimed it +captured the fragment for the first time. **Both were wrong**, and the +evidence was already in this repository: +`docs/active-work.md` records a **2026-08-06** loaded `--features crdt` +run failing this selector *and* `m6_1_pty_canonical_mode_keeps_kernel_echo` +with the same `stty -a output was: ""`, and it already proposed a +mechanism family — **read-before-write on the child's output**, the +shape of **R4** (readiness predicate satisfied by an empty file) and +**R6** (readiness file never published). + +So the fragment was captured before, under another feature flavor, and +this row's earlier "no mechanism has been proposed" was false of the +tree it was written in. | field | value | |---|---| | **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` | | **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus | -| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` — **captured on occurrence 2** | -| **status** | **two occurrences, load-correlated; the diff is EXCLUDED on occurrence 2** | +| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` | +| **status** | **at least three occurrences, load-correlated; the diff is EXCLUDED on the 2026-08-09 one** | | **what IS established** | Occurrence 1: failed once (`1916 passed; 1 failed`) under a full-corpus run. Occurrence 2: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s) | -| **what the fragment REFRAMES** | `stty -a` returned the **empty string**, not a wrong mode. So this is not "raw mode failed to disable echo" — it is `stty` producing **no output at all**, which points at PTY/spawn readiness under load rather than at termios handling. The assertion's own message is misleading on this point, and anyone diagnosing it from the message alone will look in the wrong place | +| **what the fragment ACTUALLY shows** | **The supervisor collected empty stdout** — `drain_until` then `collect_stdout(&evs)` (`src/process.rs:3948-3951`); the assertion inspects that string. It does **NOT** establish that `stty` emitted nothing: the bytes could have been lost in PTY delivery or in event collection. An earlier edit of this row said "`stty` produced no output at all", which asserts a mechanism the test cannot see. What is true is narrower and still useful: this is not a *termios* failure — nothing shows echo being configured wrongly — but which of {child never wrote, PTY dropped it, collection missed it} is open. The assertion's message invites the wrong reading, since it prints an empty string as though it were `stty`'s answer | | **what is NOT** | any mechanism, still. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 2. Two occurrences establish intermittence and a load correlation; neither establishes cause | -| **discriminating control if a third lands** | run the selector in a loop under synthetic load with `stty -a` output logged on every iteration — the open question is whether `stty` is empty *every* time it fails, which would separate a readiness race from a termios one | +| **discriminating control for the next occurrence** | capture the **full process event stream and the child's exit disposition**, not only the collected string — that is what separates "child never wrote" from "delivery or collection lost it", and the collected string cannot distinguish them however many times it is sampled. Cross-check against R4/R6's readiness family, which `docs/active-work.md`'s 2026-08-06 entry already implicates | +| **cross-reference** | `docs/active-work.md` — 2026-08-06 occurrence, `--features crdt`, **both** the raw and canonical selectors, same fragment, read-before-write hypothesis | | **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red | ### U3 — the R7 selector again, fragments lost the same way U2's were From 68c6d6732c493a59b8eafef9a68345e977da0f36 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:56:47 +0200 Subject: [PATCH 62/66] docs: U2 enumerates three occurrences, not two The header and the mechanism boundary were corrected last round; the TABLE still numbered two. It called the 2026-08-09 worker run "Occurrence 2", omitted the 2026-08-06 CRDT occurrence from the enumeration entirely, and concluded "Two occurrences establish intermittence" --- in the row I had just rewritten because it omitted that same occurrence. That is the head-and-body split this session keeps reproducing, this time inside a single table, in the row whose whole purpose is to be the authoritative account of what is known. The row now enumerates all three, and says which one carries the most weight: the 2026-08-06 CRDT run, because it shows the failure is not confined to one feature flavor and can take the raw and canonical selectors at once. That is a fact neither of the other two supplies. Also corrected: "what is NOT: any mechanism, still" is now "no mechanism is ESTABLISHED", because one IS proposed --- read-before- write on the child output, the R4/R6 readiness family. Proposed is not confirmed, and the row says so rather than flattening the distinction in either direction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 0162573..0f5cc34 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -593,9 +593,9 @@ tree it was written in. | **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus | | **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` | | **status** | **at least three occurrences, load-correlated; the diff is EXCLUDED on the 2026-08-09 one** | -| **what IS established** | Occurrence 1: failed once (`1916 passed; 1 failed`) under a full-corpus run. Occurrence 2: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s) | +| **what IS established** | **Three occurrences.** **(1)** the original: failed once (`1916 passed; 1 failed`) under a full-corpus `--tests --no-fail-fast` run, fragments not captured. **(2) 2026-08-06**, loaded `--features crdt`: this selector **and** `m6_1_pty_canonical_mode_keeps_kernel_echo` both failed with the same `stty -a output was: ""` — the first capture, and the occurrence that proposed the read-before-write family. **(3) 2026-08-09**, worker-identity tip: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s). **Occurrence 2 is the one that matters most**: it shows the failure is not confined to one feature flavor and can take both selectors at once | | **what the fragment ACTUALLY shows** | **The supervisor collected empty stdout** — `drain_until` then `collect_stdout(&evs)` (`src/process.rs:3948-3951`); the assertion inspects that string. It does **NOT** establish that `stty` emitted nothing: the bytes could have been lost in PTY delivery or in event collection. An earlier edit of this row said "`stty` produced no output at all", which asserts a mechanism the test cannot see. What is true is narrower and still useful: this is not a *termios* failure — nothing shows echo being configured wrongly — but which of {child never wrote, PTY dropped it, collection missed it} is open. The assertion's message invites the wrong reading, since it prints an empty string as though it were `stty`'s answer | -| **what is NOT** | any mechanism, still. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 2. Two occurrences establish intermittence and a load correlation; neither establishes cause | +| **what is NOT** | **No mechanism is ESTABLISHED** — one is *proposed*: read-before-write on the child's output, the R4/R6 readiness family (occurrence 2). Proposed is not confirmed, and nothing here discriminates it from PTY delivery or event-collection loss. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 3. **Three occurrences establish intermittence and a load correlation; none establishes cause** | | **discriminating control for the next occurrence** | capture the **full process event stream and the child's exit disposition**, not only the collected string — that is what separates "child never wrote" from "delivery or collection lost it", and the collected string cannot distinguish them however many times it is sampled. Cross-check against R4/R6's readiness family, which `docs/active-work.md`'s 2026-08-06 entry already implicates | | **cross-reference** | `docs/active-work.md` — 2026-08-06 occurrence, `--features crdt`, **both** the raw and canonical selectors, same fragment, read-before-write hypothesis | | **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red | From 0449d67a7f00385fb8b2d9e267d2b91ec82737d7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 00:02:31 +0200 Subject: [PATCH 63/66] docs: warn that this lane U4/U5 must become U6/U7 on rebase gate-protocol-build independently defines its own U4 and U5 --- a macOS lua54 PTY-resize failure and a Ctrl-C-as-SIGINT failure --- and it merges first, so on main those ids are taken. A rebase that resolves the textual conflict without renumbering leaves two different incidents sharing an id, which is precisely the failure this file matching rule exists to prevent. The registry authority rests on ids meaning one thing. The warning enumerates all four sites rather than saying "renumber the rows", because one of them is a prose cross-reference inside U5 relation-to-U4 field and another is in active-work.md --- both easy to miss when the conflict presenting itself is two adjacent headings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 0f5cc34..51f642d 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -630,6 +630,22 @@ it again here by piping a sweep through `grep`. The fix is mechanical: stream. A signature that is cheap to capture and impossible to reconstruct should never be traded for terminal brevity. +> [!IMPORTANT] +> **RENUMBER THESE TO U6 AND U7 WHEN THIS LANE REBASES.** The +> `gate-protocol-build` lane independently defines its own **U4** and +> **U5** (a macOS `lua54` PTY-resize failure and a Ctrl-C-as-`SIGINT` +> failure), and it merges **first** — so on `main` those ids are taken. +> +> A rebase that resolves the textual conflict without renumbering leaves +> **two different incidents sharing an id**, which is exactly the +> failure this file's matching rule exists to prevent. +> +> **Every site, so none is missed:** +> 1. this heading (`### U4 …`) +> 2. `### U5 …` below +> 3. U5's **relation to U4** field, which names it in prose +> 4. `docs/active-work.md` — "Recorded as **U5**" in the worker lane entry + ### U4 — two wall-clock budget tests fail together in one `lib-crdt` step Recorded during worker identity Stage 1 review round 2, 2026-08-09, in From 6983496b747d772e6a3d2a6059be257b73be3062 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 14:52:49 +0200 Subject: [PATCH 64/66] docs: renumber this lane U4/U5 to U6/U7 --- and git did not warn The pre-rebase warning was right, and the mechanism is worth recording because it is the quiet kind. gate-protocol-build landed its own U4 and U5 in #229. On this rebase git merged docs/ci-red-signatures.md WITHOUT A CONFLICT --- the two lanes appended their rows in different places, so there was nothing textual to resolve --- and produced two ### U4 and two ### U5 headings describing entirely different incidents. No marker, no complaint. That is the failure the matching rule exists to prevent, arriving through the one path a careful conflict resolution would never catch: there was no conflict to resolve. Renumbered across all four sites the warning enumerated: both headings, the prose relation-to-U4 field inside what is now U7, and the active-work.md reference. Ids verified unique afterwards rather than assumed. The warning block itself is retired in place, replaced by a note saying what was done and why, so the next reader sees a completed action rather than an outstanding one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/ci-red-signatures.md | 22 ++++------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8fb9121..7262ab5 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -529,7 +529,7 @@ the authoritative tip** — the ref, not a SHA. Recover with `20260809T200120Z-2427128`; load average 12.9/23.9 with sibling lanes building). All three pass in isolated reruns, none reds twice, and the diff is two string literals, their doc comments and one - test — no render path is touched. Recorded as **U5** in + test — no render path is touched. Recorded as **U7** in `docs/ci-red-signatures.md` rather than re-run away silently. `journey_acceptance` **47/47 UNTOUCHED** and the three `#pmacs.process.list()` leak detectors unedited — the stop signals diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 51f642d..8ef65c7 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -630,23 +630,9 @@ it again here by piping a sweep through `grep`. The fix is mechanical: stream. A signature that is cheap to capture and impossible to reconstruct should never be traded for terminal brevity. -> [!IMPORTANT] -> **RENUMBER THESE TO U6 AND U7 WHEN THIS LANE REBASES.** The -> `gate-protocol-build` lane independently defines its own **U4** and -> **U5** (a macOS `lua54` PTY-resize failure and a Ctrl-C-as-`SIGINT` -> failure), and it merges **first** — so on `main` those ids are taken. -> -> A rebase that resolves the textual conflict without renumbering leaves -> **two different incidents sharing an id**, which is exactly the -> failure this file's matching rule exists to prevent. -> -> **Every site, so none is missed:** -> 1. this heading (`### U4 …`) -> 2. `### U5 …` below -> 3. U5's **relation to U4** field, which names it in prose -> 4. `docs/active-work.md` — "Recorded as **U5**" in the worker lane entry +*(Renumbered from U4/U5 to **U6/U7** on the rebase onto `0857bf4`: `gate-protocol-build` landed its own U4/U5 in #229, and git merged both files **without a conflict**, producing duplicate ids across four sites. The pre-rebase warning is retired here because it has been carried out.)* -### U4 — two wall-clock budget tests fail together in one `lib-crdt` step +### U6 — two wall-clock budget tests fail together in one `lib-crdt` step Recorded during worker identity Stage 1 review round 2, 2026-08-09, in the same gate run that produced R7's third occurrence. **Fragments were @@ -670,7 +656,7 @@ regression in two unrelated subsystems at once is far less likely than one loaded machine. If a future run reds **one** of these without the other, that is a different incident and should be judged as one. -### U5 — a *different* wall-clock render-budget test reds each sweep +### U7 — a *different* wall-clock render-budget test reds each sweep Recorded during worker identity Stage 1 review round 3, 2026-08-09. **Two consecutive `scripts/gate` runs of the same command, on the same @@ -685,7 +671,7 @@ is the signature, and it is a stronger one than any single selector. | **status** | **new incident, three selectors, none reproduced** | | **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, no selector reds twice, and **the third run of the same command on the same tree was green on all 13 steps** (log `20260809T200907Z-2672209`). The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot | | **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding | -| **relation to U4** | same shape, different step and different tests: U4 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows | +| **relation to U6** | same shape, different step and different tests: U6 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows | **The rotating selector is the thing to match.** A regression that moved between three unrelated render paths on an unchanged tree is far From 56e9a6442aed6e23369c6a92582e2afb3a3f17e5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 18:03:56 +0200 Subject: [PATCH 65/66] docs: U8 --- a third macOS selector, and I destroyed its fragments Attempt 5 of the merge-base control at 0190102 failed on acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel, a selector in no registry row. I then reran that job before reading its log, and GitHub keeps only the latest attempt logs for a rerun job, so the assertion text is gone. Recovery was attempted through the jobs API and the attempt-scoped endpoint; it is not recoverable. That leaves the row in U2 original condition --- a selector with no fragments, unmatchable --- produced by exactly the mistake U3 is named for. This is the fourth time this project has lost fragments this way, and the first time I did it while holding the correction in my own hands: I had corrected two other lanes for it earlier in the same session. Numbered U8, not U6, deliberately. U6 and U7 are reserved for the two wall-clock rows on worker-identity-stage1, which renumbered into that range when #229 took U4/U5. Taking U6 here would recreate the duplicate-id collision that rebase already produced once, through the same mechanism --- two lanes appending rows with no textual conflict. The row is kept despite being unmatchable because of what it implies together with U4 and U5: three distinct macOS selectors reddening in one session points at a background failure rate on that platform rather than three independent test bugs. That matters beyond bookkeeping, because it undermines the equal-rate assumption behind any argument about which branch a failure happened to land on --- including the one currently being used to weigh #231. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 0e4e6a9..12a9c15 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -601,3 +601,27 @@ incident, not U4 occurring twice**. | **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 | + +### U8 — `acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel`, macOS `luajit`, one occurrence, **fragments destroyed** + +**Numbered U8 deliberately: U6 and U7 are reserved** for the two +wall-clock rows on `worker-identity-stage1` (PR #232), which renumbered +into that range when #229 took U4/U5. Taking U6 here would recreate the +duplicate-id collision that rebase already produced once. + +**This row exists mostly as an admission.** It surfaced on attempt 5 of +a merge-base control at `0190102`, and **I reran the job before reading +its log**, which discarded it. GitHub keeps only the latest attempt's +logs for a rerun job. So this is U2's original condition exactly — a +selector with no fragments, unmatchable — and it was produced by the +very mistake U3 is named for. + +| field | value | +|---|---| +| **selector** | `--test bottom_panel_stage1_acceptance acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel` | +| **job / flavor** | GitHub Actions, `Test (macos-latest / luajit)`, at base `0190102`, control attempt 5 | +| **required fragments** | **NONE CAPTURED — destroyed by rerunning the job before reading its log.** Recovery attempted via the jobs API and the attempt-scoped jobs endpoint; the log is gone | +| **what IS established** | it failed once (`46 passed; 1 failed`), panicking at `tests/bottom_panel_stage1_acceptance.rs:2454`, on the **exact merge base** — so it is not attributable to any open branch | +| **what is NOT** | everything else. Without the assertion text this cannot be matched against a future occurrence, which is the whole purpose of a row here | +| **why it matters anyway** | it is the **third distinct macOS selector** to red in one session, after U4 (`full_grid_resync`) and U5 (`ctrl_c_during_reconnect`). Three unrelated selectors failing on the macOS legs suggests a **background failure rate on that platform** rather than three independent test bugs — and that materially affects any equal-rate reasoning about which branch a failure "landed on" | +| **next occurrence** | **read the log BEFORE rerunning anything.** That is U3's stated lesson and this row is its fourth violation | From 700037116f743a97e26e6c5a427489746d2e4d30 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 20:18:45 +0200 Subject: [PATCH 66/66] docs: U9 --- the first red in this family with an in-run control The merge gate for this lane failed at `11-sweep` on two selectors, and both had already passed in `03-lib` and `04-lib-crdt` of the SAME gate invocation, minutes earlier, on the same tree and machine. U6 and U7 could only ever compare a red run against a different run; this is the first occurrence in the family where the control is inside the run, and that is what the row is for. Both are near misses against existing rows, and neither is folded in: - The PTY failure carries U2's exact fragment, but U2's selector field names only the *raw* selector. U2's occurrence 2 had raw and canonical failing together; here canonical redded ALONE and raw passed, which U2's evidence has never shown. - `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 instructs in its own text that one-without-the-other is a different incident. It redded without its pair, in a different step, at 1.613x against U6's 1.297x. Judged as instructed. The row also records the first checkable candidate this family has had. `cargo test --workspace` runs many test binaries concurrently while `--lib` runs one, so the passing and failing steps differ in kind and not merely in load average --- with a stated control that separates load from concurrency. U6 and U7 both left the confound atmospheric and unmeasured; this does not measure it either, but it names something that can be. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/ci-red-signatures.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index ea8c36f..414be2f 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -773,3 +773,29 @@ very mistake U3 is named for. | **what is NOT** | everything else. Without the assertion text this cannot be matched against a future occurrence, which is the whole purpose of a row here | | **why it matters anyway** | it is the **third distinct macOS selector** to red in one session, after U4 (`full_grid_resync`) and U5 (`ctrl_c_during_reconnect`). Three unrelated selectors failing on the macOS legs suggests a **background failure rate on that platform** rather than three independent test bugs — and that materially affects any equal-rate reasoning about which branch a failure "landed on" | | **next occurrence** | **read the log BEFORE rerunning anything.** That is U3's stated lesson and this row is its fourth violation | + +### U9 — a PTY test and a budget test red **together** in one `11-sweep`, with an in-run control + +Recorded on the `destination-capture` merge tree, 2026-08-10, in the +gate run that was meant to clear PR #231. + +**This row's value is its control, not its selectors.** U6 and U7 could +only compare a red run against a *different* run. Here both selectors +ran green **inside the same gate invocation**, minutes earlier, on the +same tree and machine — `03-lib` (1928 passed, 0 failed) and +`04-lib-crdt` (2113 passed, 0 failed) — and then failed in `11-sweep`. +Whatever this is, it is not the tree. + +| field | value | +|---|---| +| **selector** | `--lib process::tests::m6_1_pty_canonical_mode_keeps_kernel_echo` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same `11-sweep` step | +| **job / flavor** | local (Linux), `scripts/gate` step `11-sweep` (`cargo test --workspace --no-fail-fast -- --skip basedpyright`), fresh per-lane target dir, no sibling worktrees building | +| **required fragments** | ``canonical mode should leave echo enabled (no `-echo` flag); stty -a output was: ""`` **and** `composition machinery added more than 10% overhead` | +| **NOT fragments** | the measured numbers (`1.613`, `single=191935 ns`, `dispatch=309602 ns`) and every `:LINE` suffix — occurrence-specific | +| **status** | **one occurrence; INTERMITTENT — the identical sweep command on the same tree was green (118 targets, 1928 passed, exit 0)** | +| **what IS established** | intermittence, with the strongest available exclusion of the tree: green in two earlier steps of the **same run**, green isolated afterwards (`2 passed`, 1.70 s), green on a full sweep rerun. Both assertions are **timing-sensitive by construction** — one reads collected child output within a deadline, the other measures wall-clock composition overhead (observed 1.613× against a 1.10× budget; 61.3% dispatch and 124.6% realistic overhead) | +| **what is NOT** | cause, and the load confound is **partially measured but NOT controlled**. The failing sweep ran inside a full gate; the green rerun started at load average 1.98 with the 5-minute figure still at 8.03 from that gate. Different conditions is not a measurement of the mechanism, and this row does not treat it as one | +| **the structural difference worth testing next** | `cargo test --workspace` runs **many test binaries concurrently**; `--lib` runs **one**. That is a difference in kind between the passing steps and the failing one, not merely a difference in load average — and it is the first candidate this family has had that is checkable rather than atmospheric. **Discriminating control:** rerun the sweep with test-binary concurrency pinned to 1, and separately run the `--lib` binary alone under synthetic load. A red under synthetic load at low sweep concurrency implicates load; a red at high concurrency and low load implicates the concurrency itself | +| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible | +| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed | +| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |