diff --git a/COHERENCE.md b/COHERENCE.md index 08f000d..fc27ce9 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/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/async.lua b/builtin/runtime/async.lua index af74cc1..eb813da 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,14 +409,85 @@ 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 +-- 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 @@ -381,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 @@ -581,6 +709,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/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/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eb4bb25..9ace580 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -260,6 +260,197 @@ 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) + -- 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(base .. "/" .. name, "r") + if f then + local _, err = f:read(1) + f:close() + if err == nil then return true end + end + end + 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 then return nil end + if up == "" then return "/" 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). +-- +-- 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 + 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 +-- 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 + -- 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 + 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/docs/active-work.md b/docs/active-work.md index 7362ab0..0312ffe 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -675,6 +675,1138 @@ missing-binary witness points at a name not on `PATH`. There is no other in-process route to that branch: Rust's `Command` resolves the program against the **parent** process's `PATH`, so a child `env` cannot hide git, and `std::env::set_var` is `unsafe` in edition 2024. +## Destination capture (Q#JR14 generalization) — PR #231 OPEN, revision 9, cleared to merge + +**PR #231** — https://github.com/levineuwirth/pmacs/pull/231. #227 +blocks on this lane. + +The mechanism landed at `0efc8c0`; review found a correctness blocker; +`ca72461` implemented **revision 7**, which review then **also** +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**. + +**The macOS red that blocked this lane, and how it was cleared.** Both +CI attempts at `4654b94` failed `a_pty_resize_blanks_the_host_before_repainting` +on `Test (macos-latest / luajit)`. A control experiment was run at the +exact base commit `0190102`: **five valid observations, all green on +both macOS flavours**, against the branch's 0/2 — 1/C(7,2) = 4.8% under +an equal-rate model. That implicates the branch statistically. **The +diff exonerates it mechanically**: grepping this lane's entire `src/` +diff for `full_grid|resize|resync|Geometry|reconcile_panel_layout` +matches an **import line and nothing else**, and +`full_grid_resync_acceptance` (191 lines) has no panel, side-window, +dedication, display or directory surface at all. Merged on that reading, +with the equal-rate model itself in doubt — see the U4 row, and note a +sixth base attempt reddened on a *third, unrelated* macOS selector +(U8), which is what a background platform failure rate looks like. + +**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. + +**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 + 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.** 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. + +**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: + `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/`); **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 + 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 +documented message naming the accepted values — the same reachability +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. + +**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 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`; `469d5c8` is the + 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 + invalidate it are **refused at the attempt**. + - `EditorCore::panel_commit_dedication_refusal` is the one rule. It + 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 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 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 + 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 + 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`. + - **Three deliberate limits**, each a different decision rather than a + stricter version of this one: 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** (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; + 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. + 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. + + **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 `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, 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 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. + + **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 + 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 + 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 + **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 + (`builtin/runtime/git.lua:609`, `:854`), so a result surfaces in + whichever frontend is active when git exits. +- **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 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`; 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. +- **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, *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. +- **Gates, as the executable line rather than a description:** + + ``` + scripts/gate --acceptance destination_capture_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. + +## Worker identity Stage 1 (§9) — MERGED as #232 (`3cc1b85`) + +**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 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. +- **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). +- **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 + 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. 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 + three existing adopters), evaluated per frame inside `paint_frame` + (`src/editor.rs:4560`) and riding the existing `StatuslineSegments` + vector. No variant, no bump. +- **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), 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 + 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 `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 + --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` 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 + 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. +- **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 + `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. 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. +- **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. +- **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`, + 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` + 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: 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 **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 + 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 + parameter; `ProcessSpec::new` grew a third parameter (~40 call sites, + nearly all tests); `ActiveJobInfo`/`CompletedJobInfo`/`ProcessSpec` + each grew a required `purpose` field, and `pmacs.process.spawn` + requires `purpose` in its spec table. + +## 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. + +**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 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. +- **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 + 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 + 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 + +**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 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 + 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 + 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.** 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 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 + 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). +- **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 + 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`) + +**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 +rule rather than one signature twice. + +**Registry numbering conflict, expected at merge.** +`worker-identity-stage1` independently defines its own **U4** and **U5** +on its branch. This lane merges first, so on `main` the rows above are +U4/U5 and **worker identity must renumber its pair on rebase**. Flagged +here because a rebase that resolves the textual conflict without +renumbering produces two different incidents sharing an id, which is +exactly the failure the registry's matching rule exists to prevent. + +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `gate-protocol-build`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/gate-protocol-build` is the +authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout gate-protocol-build`. + +- **Framing `docs/gate-protocol-build-framing.md`, revision 5.** The + fix itself is implemented and green at `49bc141`; **its regression + witness landed separately at `677fd25`**, after review found the + original witness did not reach the step it named. Narrow by design: + one missing step in one script, plus the boundary question that let + it go missing. No `src/`, no protocol, no feature work. +- **WAS THE OPEN BLOCKER — the witnesses did not reach the step they + name. CLOSED at `677fd25`.** `--print-plan` **strips names** before + printing, so the ordering assertion saw only commands; `--self-test` + **hardcodes** `build-crdt` inside its own synthetic plan. Review + demonstrated the consequence: **renaming the real build step to + `sweep-crdt` left both tests passing.** So the lane had shipped + without the regression guard it was created to provide. §7 requires + **both** real emitter pairs — `build-crdt` and `sweep-crdt`, name + *and* exact command — because the hole is symmetric and revision 4 + closed only half of it. The synthetic `--self-test` stays: it + witnesses the *runner* (failure naming, `FAILED:` list, log paths, + non-zero exit, and continuation via the sentinel), which is a + different thing from attributing the real step, and it may no longer + stand in for it. **What closed it is the "THE WITNESS DID NOT REACH + THE STEP" bullet further down** — `--print-plan-named`, with all four + renames and drifts mutated red. +- **The defect, as found.** `--protocol` adds the CRDT workspace sweep, + whose documented precondition is `cargo build --workspace + --no-default-features --features luajit,crdt` — documented in handoff + **§5** at the time, **§3** now. The plan emitter had **no build step + at all** — read from the source, not inferred from the failure. +- **Why it was latent, and why that makes it urgent rather than tidy.** + Before #225 every worktree shared one `CARGO_TARGET_DIR`, which + almost always already held a `pmacs-gpu` binary, so the precondition + was satisfied **by accident**. Per-worktree target dirs start empty. + The hazard is not the red gate that stops you — it is a **green** + `--protocol` run whose crdt sweep was decided by the state of the + build directory rather than by the diff. That is a gate reporting + coverage it does not have, which is what #225 exists to prevent. +- **Observed on PR #228's first gate run:** twelve + `gpu_invocation_acceptance::crdt::*` failures, all *"build pmacs-gpu + before this acceptance suite"*, with `debug/pmacs-gpu` absent. +- **The durable half is a boundary question.** `scripts/gate`'s header + names handoff **§3** as the owner of its reasoning, and this + precondition lives in **§5** — a coherent cause for the omission, not + mere oversight. + + **Resolved in framing revision 2: §3 becomes the SOLE normative home, + §5 keeps the incident and its signature as history, and the script's + header keeps citing §3 ALONE.** Revision 1 proposed citing both, + which splits one executable contract across two homes and weakens the + script's only clean boundary at the same time as Q#GR-4 declines to + build any automated check for prose drift. This entry recorded that + superseded decision until now; a recovering machine reading the stale + version would have rebuilt revision 1's wrong boundary. +- **Q#GR-1 — SETTLED BY OBSERVATION, 2026-08-09, before any fix was + written.** On a **disposable** target directory (never a live lane's), + with `debug/pmacs-gpu` asserted **absent before each run** as a + recorded precondition, each sweep run **alone** from that same cold + state so neither could have built the binary for the other: + + | sweep | exit | result | `pmacs-gpu` after | + |---|---|---|---| + | default | **0** | green, 114 test targets | **still absent** | + | crdt | **101** | exactly **12** failures, all `gpu_invocation_acceptance::crdt::*`, all *"build pmacs-gpu before this acceptance suite"* | still absent | + + So framing §3's inference **holds** and §4's *"only under + `--protocol`"* is correct — the default sweep never builds the binary + and never needs it. **Mechanism, now established rather than + guessed:** `pmacs-gpu` has no `tests/` directory, so cargo never + uplifts its bin to `debug/pmacs-gpu`; only an explicit `cargo build` + produces it. + + **Found while doing it, and worse than the twelve:** + `bottom_panel_stage2b_gpu` a54 reported **`ok`** in that cold crdt + sweep. Its only path that does not spawn `pmacs-gpu` is its skip + branch, so a test whose whole purpose is real wgpu rendering passed + having rendered nothing. The missing build does not merely fail + twelve tests — it voids coverage in tests that report green. + (`vterm_stage3` a37 has the same shape by source read; cargo captures + passing tests' output, so the skip is invisible in the log.) +- **What landed.** A named `build-crdt` step emitted immediately before + `sweep-crdt` under `--protocol`, carrying the exact §5 invocation — + **not** folded into the sweep command, because `cargo build … && + cargo test …` reports a *build* failure under the name `sweep-crdt`. + Plus **`--self-test`** (Q#GR-5): a hardcoded three-line synthetic plan + — pass, fail-named-`build-crdt`, **pass sentinel** — driven through + the *real* runner loop, which is what makes the **runner's** failure + naming *and* continuation observable at all. (It does **not** witness + the real step's name — see the round-two entry below, which is where + that gap was found and closed.) `PLAN_FILE` is deliberately **not** + injectable: that would turn the runner's `eval` into a general command + executor, the same defect this script's review caught in + `--acceptance`. +- **THE WITNESS DID NOT REACH THE STEP — found in review of the + implementation, closed at `677fd25`.** The lane shipped without the + regression guard it was created to provide, because **neither witness + could see a name**: `--print-plan` renders `emit_plan | cut -f2-`, so + the ordering test compared *commands* with the names cut off, and + `--self-test` hardcodes the string `build-crdt` in its **own + synthetic** plan, so it proves things about the runner and nothing + about the real emitter. Review demonstrated it directly: **renaming + the real build step to `sweep-crdt` left both tests passing** — a plan + that would report a build failure under the sweep's name, sitting + green, which is the exact misattribution the separate step exists to + prevent. + + **The fix is `--print-plan-named`**: a second *rendering* of the same + `emit_plan`, printing the `namecommand` text the runner reads + back from `PLAN_FILE`, asserted by **whole-line equality** so name and + command are pinned together, and `sweep-crdt`'s pair asserted too + (asserting only the build's name leaves the identical hole open in the + other direction). **`PLAN_FILE` remains uninjectable** — a test that + supplied the runner's plan would turn its `eval` into a general + command executor, the defect the `--acceptance` refusal exists to + prevent — and **`--self-test` stays**, witnessing the *runner* (failure + naming, `FAILED:`, log paths, non-zero exit, continuation via the + sentinel), which it may no longer *stand in for* attribution of the + real step. A companion test pins `--print-plan` as that rendering + minus its names, so the two cannot drift into asserting a name the + runner never uses. Both new tests are on the **no-gates** paths. + + **Mutated individually, each now red** (the first is the one the + previous round passed): build renamed `build-crdt` → `sweep-crdt`; + sweep renamed `sweep-crdt` → `crdt-sweep`; build features + `luajit,crdt` → `luajit`; build emitted **after** the sweep. Suite is + 20 tests. +- **AUDITED FOR THE SAME DEFECT ELSEWHERE, and one instance is left + open deliberately.** Renaming **every other** plan step — `fmt`, + `clippy`, `lib`, `m4`, `gpu`, `sweep`, `diff-check`, + `acceptance-` — leaves all 20 tests green: no test asserts any + step name but `build-crdt` and `sweep-crdt`. For most that is only a + log filename and a `FAILED:` entry. **`sweep` is not**: the runner's + end-of-run listing globs `"$LOGDIR"/*-sweep.log` and + `*-sweep-crdt.log`, so renaming that step silently empties the *"read + these, do not re-run and grep"* listing that is the U2/U3 remedy, with + the suite still green. **Not closed here**: the listing only exists on + the *run* path, and every test in this file is deliberately no-gates, + so there is no cheap witness for it — recorded rather than papered + over. +- **Blocks PR #228 (discovery Stage 2).** That lane's `--protocol` + result needs re-establishing on a fresh target dir under the repaired + script. Deliberately **not** folded into that feature branch, and it + happens **after** this lands, not inside it. +- **Acceptance criterion, witnessed 2026-08-09.** + `scripts/gate --acceptance gate_script_acceptance --protocol` on a + target root that **did not exist** (precondition recorded, not + assumed): all eleven steps green, `09 build-crdt ok` producing + `debug/pmacs-gpu`, and `gpu_invocation_acceptance` at **15 passed / + 0 failed** where the same suite was 3/12 without the build step. + Zero occurrences of the *"build pmacs-gpu"* signature in the sweep + log, and a54/a37 ran for real rather than taking their skip branches. + **No manual build anywhere** — which is the thing that was false. +- **UNEXPLAINED RED, recorded rather than swept up.** An earlier + attempt at the same cold run failed step 10 with **36 + 4 + 6 + 4 + failures across `m5_5`/`m5_6`/`m5_7`/`m5_8`**, all real-daemon + suites, all with signature *"daemon exited with exit status: 101 + before socket appeared; socket=/tmp/.tmpXXXX/pmacs.sock — ``"*. **Not** the `pmacs-gpu` signature, and no row in + `docs/ci-red-signatures.md` matches it. Re-running the same test + binary from the same target directory gave 36/36 green, which by + that registry's own rule establishes **intermittence only, never + environmental cause** — so this stays open rather than being + attributed to the load (~25–30 across four concurrent lanes' gates). + **De-implicated from `build-crdt` by construction, not by the green + rerun:** the root crate's `default = ["luajit"]`, so + `--no-default-features --features luajit,crdt` enables *exactly* the + same feature set as the sweep's `--features crdt`. The build step + cannot hand the sweep a differently-featured binary, so it has no + mechanism by which to break a daemon suite. Local, not CI, so not a + registry row; noted here for whoever sees it next. +- **Gates:** `scripts/gate --acceptance gate_script_acceptance`. Note + the recursion — this lane edits the script that runs its own gates, + so `--print-plan`, `--print-plan-named`, `--help` and `--self-test` + were also checked by hand after each edit: a change that breaks the + script cannot be reported honestly by the script. The assertions were + **mutation tested**: wrong features, wrong position, unconditional + emission, an aborting runner, the build folded into `sweep-crdt`, and + — added in the second round — a **rename of either** the build or the + sweep step each fail the suite. + ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 19155ca..ca53c70 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 @@ -2552,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/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index ad050e9..414be2f 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -496,30 +496,108 @@ 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** | **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** | 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**. 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 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`. -### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence +**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: -Has a selector, which U1 lacks — but still no fragments, so it cannot -be matched either. Recorded so a recurrence is recognisable. +* **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. + +**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`, THIRD known occurrence + +**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** | **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: ""` | +| **status** | **at least three occurrences, load-correlated; the diff is EXCLUDED on the 2026-08-09 one** | +| **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** | **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 | ### U3 — the R7 selector again, fragments lost the same way U2's were @@ -552,6 +630,54 @@ 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. +*(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.)* + +### 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 +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. + +### 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 +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, 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 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 +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. @@ -566,3 +692,110 @@ 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 **both flavours**, three occurrences + +Surfaced on PR #229's CI; twice more on PR #231's. + +**The `lua54` in this row's original title was wrong as a signature +component, and matching on it would have missed two occurrences.** The +row was filed from #229's single `lua54` red and recorded the flavour in +the matching key. #231 then reddened the identical selector with the +identical three fragments **twice on `luajit`** — so flavour is not part +of this signature, and the row's own caution that "a deterministic +defect *can* be Lua-flavour-specific" is now settled in the other +direction: this one is not. Occurrence-keyed by suffix length, the three +are `25 362` (#229, `lua54`), `25 222` (#231 attempt 1, `luajit`) and +`25 054` (#231 attempt 2, `luajit`). + +**A fourth sighting of these fragments was NOT an occurrence and must +not be counted as one.** It came from a deliberate bite during this +test's own development — the defect reintroduced on purpose (`consumer +ignores full_grid`), 34 831 bytes, failing in 20.09 s. It earns its +place here for what it proves instead: **the genuine defect and these +CI reds are signature-indistinguishable**, same message class and same +full-timeout duration, so the fragments alone can never tell a real +resync failure from whatever this is. + +| field | value | +|---|---| +| **selector** | `--test full_grid_resync_acceptance a_pty_resize_blanks_the_host_before_repainting` | +| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)` **and** `Test (macos-latest / luajit)`, `macos-26-arm64`. **Flavour is not a matching key for this row** | +| **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** | **three occurrences on two branches; INTERMITTENT on #229 (passed on rerun), NOT observed to pass on #231 (0/2)** | +| **the #231 control experiment, and what it does and does not license** | Five valid observations at #231's exact base `0190102` — `run_attempt` 1, 2, 3, 4 and 6 — **all green on both macOS flavours**, against #231's 0/2. Under an equal-rate model the chance both failures land on the two branch runs is 1/C(7,2) = **4.8%**. Two things bound that number. First, **attempt 5 was discarded** because it reddened a *different* selector (U8) — so the base leg is 5/5 green *for this signature* and 5/6 overall, and "the base never fails" is not what was observed. Second, three unrelated macOS selectors reddening in one session is **a background platform failure rate**, and the equal-rate model the 4.8% assumes is exactly what such a rate violates. **The branch side was never resampled**: 5-vs-2 is an asymmetric experiment, and rerunning #231's failing job three more times at `4654b94` was the outstanding discriminator when it merged | +| **why #231's diff is excluded** | grepping its **entire** `src/` diff for `full_grid\|resize\|resync\|Geometry\|reconcile_panel_layout` matches **one import line** and nothing else; all 721 changed lines are placement, dedication and commit-contract logic. From the other side, `full_grid_resync_acceptance` (191 lines) contains no panel, side-window, dedication, display or directory surface — grep for those matches only a comment about CSI 2 J. #231 merged on this reading **over** the statistical signal above, which is a judgement recorded here so that a fourth occurrence can revisit it rather than re-derive it | +| **why #229's diff is excluded** | #229 changes only `scripts/gate`, `tests/gate_script_acceptance.rs` and documentation — **no `src/`, and the workflow never invokes `scripts/gate`**. Decisively, `full_grid_resync_acceptance` runs **before** the changed gate suite, so even a cross-suite leaked-state path is not available. The `luajit` leg passing on the same commit is **corroboration only** — a deterministic defect *can* be Lua-flavour-specific, so that observation must not be used as a structural exclusion | +| **what IS established** | **no blank was OBSERVED after the mark** within the test's fixed 20-second deadline. The collected suffix was the **entire** post-mark output (`suffix.len()`, 25 362 bytes on this occurrence — not a capped window; only the *displayed* head is truncated to 400 bytes), and that head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was painting | +| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted after the deadline, or lost in transport is **open** — and "it never emitted the blank" is a claim this evidence does not support. **The failing run's ~20 s duration is the fixed `Duration::from_secs(20)` timeout**, so the spread against a fast passing run is mechanically determined and is **not** independent timing evidence | +| **discriminating control — ASYMMETRIC, and only one direction concludes** | the suffix is already complete, so "capture more bytes" is not the gap — arrival time is. Extending the deadline and recording whether `CLEAR_ALL` arrives, and at what offset: **if it arrives, "emitted late" is established.** **If it does not, that establishes only "not observed by the longer deadline"** — *not* "never emitted", because transport loss produces the same absence. Separating non-emission from transport loss needs **producer-side emission evidence** (did pmacs write the clear?) cross-checked against the collected stream; no deadline, however long, can do it alone | + +### U5 — `ctrl_c_during_reconnect_sleep_yields_clean_exit`, macOS `lua54`, one occurrence + +Surfaced on the **rerun** of PR #229's failed job — a *different* +selector from U4, so by this file's matching rule it is a **new +incident, not U4 occurring twice**. + +| field | value | +|---|---| +| **selector** | `--test m5_8_acceptance ctrl_c_during_reconnect_sleep_yields_clean_exit` | +| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, rerun attempt 2 | +| **required fragments** | `Ctrl-C during reconnect sleep should produce a clean exit` · `ExitStatus { code: 1, signal: Some("Interrupt: 2") }` | +| **NOT a fragment** | the `:LINE` suffix — occurrence-specific, moves with the file | +| **status** | **one occurrence, unresolved** | +| **what IS established** | Ctrl-C reached the process **as `SIGINT`** rather than as the raw-mode key event the test drives. That is all the exit status shows | +| **what is NOT** | whether injection preceded raw mode, raw mode was lost, or something else. Three mechanisms remain open and this fragment separates none of them | +| **exclusion strength — WEAKER than U4's, deliberately** | the changed `gate_script_acceptance` ran **earlier in the same job**, and it creates worktrees and directories. No leaked child or persistent signal-state mutation was observed, but "the diff touches no `src/`" is **not** the argument here that it is for U4, because cross-suite leaked state is a path reachability reasoning does not close | +| **control 1 — CROSS-SUITE ATTRIBUTION, and asymmetric** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. **A matching isolated RED proves the gate suite is not necessary** for the failure. **An isolated GREEN proves nothing beyond that run** — the failure is intermittent, so absence under one run is not evidence of dependence. It also does **not** discriminate among the three mechanisms in either direction | +| **control 2 — mechanism** | observe **readiness and raw-mode state at the moment of injection**. Another isolated pass, however many times repeated, cannot separate "injected before raw mode" from "raw mode lost" from a third cause | + +### 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 | + +### 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 | diff --git a/docs/destination-capture-framing.md b/docs/destination-capture-framing.md new file mode 100644 index 0000000..530da05 --- /dev/null +++ b/docs/destination-capture-framing.md @@ -0,0 +1,816 @@ +# A destination capture any async continuation can use + +**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 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 +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 — +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 +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 +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 +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 +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 +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. + +--- + +## 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 **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" + (`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 — 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. + +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, **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). +- **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. 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? + +*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)***, with the profiles spelled out below rather than +left to implementation. + +**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 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**. + +**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 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 then moved enforcement to the placement boundary. **That +breaks the invariant `commit_to` exists for.** `docs/agent-handoff.md` +`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 +> it reaches anything that could refuse, so **validating at display +> time is four mutations too late**. + +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. + +**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. + +**"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:** + +- `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. + +**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`). +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 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 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 +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** — 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 +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 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 +`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. + +**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 +preferred answer only because it makes the parameterization pointless. +Choosing it is a design decision needing its own approval, not a +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 +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 +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? + +*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-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, 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 + `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 +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. **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. +- **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 + +- **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. +- **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. +- **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. +- **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. +- **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. + **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. + + **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. +- **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. +- **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. +- **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 + 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`. +- **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 + 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 + 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. + +## 8. 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. diff --git a/docs/discovery-stage2-framing.md b/docs/discovery-stage2-framing.md new file mode 100644 index 0000000..56da2bf --- /dev/null +++ b/docs/discovery-stage2-framing.md @@ -0,0 +1,348 @@ +# Discovery Stage 2 — M-x rows stop being bare names + +**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 +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 +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. +- **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 + +- **`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, 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. + +**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 §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, 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 +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. +- **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. +- **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** + (§3.4), from the local registry, with **no wire involvement**. +- **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 + 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). diff --git a/docs/gate-protocol-build-framing.md b/docs/gate-protocol-build-framing.md new file mode 100644 index 0000000..2845aa7 --- /dev/null +++ b/docs/gate-protocol-build-framing.md @@ -0,0 +1,336 @@ +# `scripts/gate --protocol` — the build its sweep depends on + +**Status: revision 5. APPROVED and IMPLEMENTED at `49bc141`. The +regression-witness gap found in review of that implementation is +CLOSED at `677fd25` — Q#GR-5's criterion is now witnessed against the +REAL emitter rather than around it.** + +*(Revisions 1-3 read "Pre-implementation. Awaiting approval" while the +ledger recorded this lane as approved and implemented — the exact +contradiction class this project keeps correcting elsewhere.)* + +**Revision 3 fixes a witness that could not fail.** Revision 2's +`--self-test` plan put the failing step **last**, so an aborting runner +and a continuing one produce identical output — the witness for +Q#GR-2's "the suite keeps going" policy would have passed on a runner +doing the opposite. A passing **sentinel after** the failure, asserted +to have written its log, is what separates them. §7 also now pins the +**exact** build command rather than only the step's name and position, +since a `build-crdt` running plain `cargo build` would leave the gate +just as unsound while looking repaired. + +**Revision 2 takes three review findings.** The normative requirement +goes **entirely** into handoff §3 rather than being split across §3 and +§5 (§5, Q#GR-3). Q#GR-1's observation procedure is respecified on a +**disposable** target with the binary's absence asserted before each +run, rather than by deleting a file from a live worktree. And the +build-attribution criterion, which revision 1 stated with **no way to +observe it**, gets a witness — via a hardcoded synthetic plan, not the +plan-file injection that would reintroduce this script's own +`--acceptance` defect (Q#GR-5). + +**A narrow lane, deliberately.** One missing step in one script, plus +the boundary question that let it go missing. No feature work, no +`src/`, no protocol. + +--- + +## 1. The defect + +`scripts/gate --protocol` adds the CRDT workspace sweep. That sweep has +a documented precondition, and **the script does not run it**. + +`docs/agent-handoff.md` §5 (`:532-535`): + +> **The crdt sweep needs `cargo build --workspace` first**, or twelve +> `gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu` +> binary. `cargo build --workspace --no-default-features --features +> luajit,crdt` is the invocation that produces both binaries. + +`scripts/gate`'s plan emitter (`:187-204`) goes +`… → gpu → sweep → sweep-crdt → diff-check`, with **no build step +anywhere**. Read from the source, not inferred from the failure. + +**Observed, not theorised.** PR #228's first gate run failed step 09 +with twelve `gpu_invocation_acceptance::crdt::*` failures, all +*"build pmacs-gpu before this acceptance suite"*, and `debug/pmacs-gpu` +was absent from that worktree's target directory. Running the +documented invocation and re-running the gate turned it green. + +## 2. Why it was latent until now, which is the interesting part + +**#225 caused this to become reachable, and #225 is also what makes it +matter.** + +Before #225 every worktree on this machine resolved to **one shared** +`CARGO_TARGET_DIR`. That directory almost always already contained a +`pmacs-gpu` binary from some earlier build, so the precondition was +**satisfied by accident** on essentially every run. #225 gave each +worktree its own target directory; a fresh one starts empty, and the +omission becomes load-bearing on the very first `--protocol` run in a +new worktree. + +So this is not "a bug #225 introduced". It is a **pre-existing gap in +the documented procedure that #225 stopped hiding** — and the reason it +is urgent rather than tidy is that the failure mode is not a red gate. +A red gate is fine; it stops you. The hazard is the *reverse*: a green +`--protocol` run whose crdt sweep was decided by **what happened to be +in the build directory** rather than by the diff. That is a gate that +reports coverage it does not have, which is precisely what #225 exists +to prevent. + +## 3. The likely mechanism, marked as inference + +The failing tests are namespaced `gpu_invocation_acceptance::crdt::*`, +which suggests they are **feature-gated to `crdt`** and therefore +compile and run only under the crdt sweep. That would explain why the +default sweep passes on a tree with no `pmacs-gpu` binary at all — it +never runs the tests that spawn it. + +**This is inference from the test names and one observation, and it is +not yet verified.** Q#GR-1 makes establishing it part of the work +rather than an assumption the fix rests on. + +## 4. The change + +*My vote: **a named `build-crdt` gate step, emitted immediately before +`sweep-crdt` and only under `--protocol`***, running the invocation +handoff §5 names. + +- **A named step, not a silent prelude.** It gets its own numbered log + alongside the others and fails the suite under its own name. + (It does **not** "appear in `--print-plan`" — that mode prints + commands only. §7 says what witnesses the name.) +- **Not folded into the `sweep-crdt` command.** `cargo build … && cargo + test …` would make a *build* failure appear under the name `sweep-crdt` + in the failure list — a wrong attribution in the one place the script + exists to be trustworthy about. **Q#GR-5 is how that is witnessed**, + which revision 1 asserted without supplying. +- **Only under `--protocol`.** If §3's inference holds, the default + sweep does not need it, and adding an unconditional workspace build + to every gate run is a real cost paid for nothing. + +## 5. The boundary question, which is the durable half + +The script's own header says: + +> `docs/agent-handoff.md` section 3 owns the REASONING for each of +> these … `--print-plan` renders this without running anything, which +> is what makes **drift from section 3** testable. + +**The drift here is from §5, not §3** — and that is a coherent reason +for the omission rather than mere oversight. `scripts/gate` was written +against §3's gate policy; this precondition lives in §5's hazard +register, which the script never claimed to encode. + +So the durable fix is not only the missing line. It is deciding where a +requirement like this belongs. + +**The normative requirement moves ENTIRELY into §3.** It is gate policy +— it decides whether a gate's result means anything — and §3 is already +where such policy lives. §5 keeps the **incident and its signature**, +which is history, not contract. + +**The script header keeps citing §3 and only §3.** Revision 1 also +proposed citing §5, which was wrong twice over: it splits one +executable contract across two sections, and it weakens the single +clean boundary the script has (*"§3 owns the reasoning"*) at the same +time as Q#GR-4 declines to build any automated check for prose drift. +A boundary that is neither enforced nor singular is not a boundary. +One normative home, one citation. Q#GR-3. + +## 6. Open questions + +### Q#GR-1 — what exactly must be built, and does the default sweep need it too? + +§5 names `cargo build --workspace --no-default-features --features +luajit,crdt` and says it "produces both binaries". §3's inference says +the default sweep is unaffected. **Neither is verified by this +document.** + +*Required before implementation, by observation rather than reading.* +Revision 1 said "delete `pmacs-gpu` from a target directory", which is +both unsafe and insufficient: it **mutates a durable worktree's build +directory**, and removing one binary does not establish that the other +artifacts and feature permutations are cold — a stale dependency graph +can satisfy the run for reasons the experiment never sees. + +**The procedure:** + +1. A **disposable** target directory (a scratch `CARGO_TARGET_DIR`, or + a throwaway worktree), never a live lane's. Nothing under + `$HOME/build/pmacs-gate-targets/` belonging to a real branch is + touched. +2. **Assert `debug/pmacs-gpu` is ABSENT before each run**, as a + recorded precondition rather than an assumption. A run whose + starting state was not checked proves nothing about a cold tree. +3. Run the **default** sweep alone. Record pass/fail and, if it fails, + the failing test names. +4. Reset to the same cold state, assert absence again, run the **crdt** + sweep alone. Record the same. + +Each sweep separately, so a result cannot be explained by the other +having built the binary first — which is the exact accident (§2) that +hid this defect for the entire life of the shared target directory. + +If the default sweep also needs the binary, the step is unconditional +and §4's "only under `--protocol`" is wrong. + +**This is the one thing in this lane I would not accept on reasoning.** +The whole defect is a precondition nobody checked; establishing its +replacement by reading would repeat the error at one remove. + +### Q#GR-2 — does a build failure fail the suite, or abort it? + +*My vote: **fail like any other gate***, and let the remaining steps +run. `--no-fail-fast` is the established posture of this suite, and a +sweep that then fails for the missing binary produces a second, +consistent signal rather than a mysterious absence. + +The counter-argument is real: twelve downstream failures with a known +cause is noise. But the script already prints per-gate logs and a +`FAILED:` list, so the cause is named at the top, and suppressing +downstream output is how a tool starts deciding what its user is +allowed to see. + +### Q#GR-3 — where does this requirement live? + +See §5. **§3 gains it normatively; §5 keeps the incident; the script's +header keeps citing §3 alone.** Revision 1 proposed citing both, which +would have split one executable contract across two sections while +Q#GR-4 declines to build any check for prose drift. + +### Q#GR-5 — how is the attribution criterion witnessed at all? **(new in rev 2)** + +Revision 1 asserted that a build failure must be attributed to +`build-crdt` rather than `sweep-crdt`, and gave no way to observe it. +That criterion was unwitnessable as written: `tests/gate_script_acceptance.rs` +deliberately exercises only **no-gates** paths, so plan assertions can +prove a step's name and its order and **nothing about runtime +behaviour**. + +**The obvious seam is a trap.** Making `PLAN_FILE` injectable — let a +test hand the runner its own plan — would work, and it would turn the +script into a general command executor via the `eval` at its runner +loop. That is the **same class of defect this script's own review +already caught in `--acceptance`**, which was fixed with a refusal at +parse time. Reintroducing it one lane later, in the tool whose purpose +is to be trustworthy, is not a trade worth making. + +*My vote: **a `--self-test` mode running a HARDCODED synthetic plan*** +— **three** lines: a passing step, a failing one named `build-crdt`, +and **a passing SENTINEL after it**. + +**The third line is not padding, and revision 2's two-line plan was +broken without it.** With the failure last, a runner that **aborts** on +failure and one that **continues** produce identical output, so the +witness passes either way — and Q#GR-2's whole answer is that the suite +keeps going. A sentinel *after* the failing step, asserted to have run +and written its log, is the only thing that distinguishes them. + +So it asserts: the runner names the failing gate, lists it under +`FAILED:`, writes its log where it says it does, exits non-zero, **and +the sentinel after the failure has its own log** — which is Q#GR-2's +policy made observable rather than declared. + +- **No injection.** The synthetic plan is a literal inside the script; + nothing external supplies a command. +- **Runs no real gate**, so it stays on the cheap no-gates side of the + existing suite. `true`/`false` are the whole workload. +- **It tests the runner, which is the thing under test.** Whether + `cargo build` really fails is `cargo`'s business; whether *this + script names the right gate when a command fails* is the criterion, + and it is orthogonal to which command failed. + +The alternative is a **documented manual witness** — break the build by +hand, run the gate, record the output in the lane. Honest, and it rots: +nothing re-runs it, so it decays into a claim about a past machine. +Named as the fallback if review rejects a new mode. + +### Q#GR-4 — should `--print-plan` be asserted against the handoff? + +Tempting and out of scope. A test that parses prose out of +`agent-handoff.md` and compares it to the plan would be brittle in the +direction that produces false confidence. **Not in this lane**, and +named so it is not mistaken for an oversight. + +## 7. Verification + +- **`--print-plan --protocol` witnesses COMMAND and ORDER only** — it + strips names before printing, so it can show that the build command + appears immediately before the crdt sweep command and nothing about + which step is called what. Earlier revisions of this section said + named steps "appear in `--print-plan`"; they do not, and that wording + is what let the attribution witness drift away from the step it + names. +- **BOTH real emitter pairs are asserted, explicitly and separately:** + - `("build-crdt", "cargo build --workspace --no-default-features --features luajit,crdt")` + - `("sweep-crdt", "cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright")` + + Both, because the rename hole is symmetric: revision 4 closed it for + the build and left the sweep able to be renamed just as invisibly. + Asserted from the emitter, where the name still exists. + + The **command** half matters as much as the name: a `build-crdt` + running plain `cargo build` would leave the gate exactly as unsound + while looking repaired, because the crdt sweep needs *those* + features. +- **`--print-plan` WITHOUT `--protocol` does not emit it** (subject to + Q#GR-1 — if the default sweep turns out to need the binary too, this + assertion inverts and §4 changes with it). +- **A real fresh-target `--protocol` run goes green without a manual + build**, which is the acceptance criterion and the thing that was + false. Witnessed on a target directory with no `pmacs-gpu` in it. +- **A failing gate is attributed to its own name**, witnessed through + `--self-test`'s synthetic plan (Q#GR-5): the run exits non-zero, + prints `build-crdt` as the failing step, lists it under `FAILED:`, + and writes the log path it claims. This is the criterion revision 1 + stated with no way to observe it. +- **THE REAL PLAN'S `(name, command)` PAIR IS ASSERTED** — added in + revision 4, because the two witnesses above **do not connect to the + step they describe**. `--print-plan` strips names before printing, so + the order assertion sees only commands; `--self-test` hardcodes the + string `build-crdt` in its own synthetic plan. Review demonstrated + the gap: **renaming the real build step to `sweep-crdt` left both + tests passing.** The plan's emitted name and its command must be + asserted together, from the real emitter, so a rename cannot pass. + The synthetic failure/continuation test stays — it tests the runner, + which is a different thing — but it can no longer stand in for + attribution of the actual step. + + **Landed at `677fd25` as `--print-plan-named`**: a second *rendering* + of the same `emit_plan`, printing the `namecommand` text the + runner reads back from `PLAN_FILE`, asserted by **whole-line + equality** so the name and the command are pinned together. + **`sweep-crdt`'s own pair is asserted too** — asserting only the + build's name leaves the identical hole open in the other direction. + `PLAN_FILE` stays uninjectable, and a companion test pins that + `--print-plan` *is* that rendering minus its names, so the two cannot + drift and the assertion cannot come to pin a name the runner never + uses. **Mutation tested, each individually red:** the build renamed + to `sweep-crdt` (**the one the previous round passed**), the sweep + renamed, the build's features changed, the build emitted after the + sweep. +- **The suite CONTINUES past a failed gate** (Q#GR-2) — the sentinel + step after `build-crdt` in the synthetic plan has its own log. + **Revision 2's two-line plan could not assert this**: with the + failure last, an aborting runner and a continuing one are + indistinguishable, so the witness would have passed on a runner that + does the opposite of the stated policy. +- **The existing 15 `tests/gate_script_acceptance.rs` tests still + pass**, and the new assertions join them on the **no-gates paths** + (`--print-plan` runs nothing), keeping the suite cheap. + +**What this will NOT prove:** that the plan matches the handoff in +general (Q#GR-4), or that any other §5 hazard is encoded in the script +— this lane fixes one and asks where such requirements belong, it does +not audit §5. + +## 8. Not in scope + +Any feature work. Any `src/` change. Auditing the rest of handoff §5 +for further unencoded preconditions (worth doing; not here). A +plan-versus-handoff consistency test (Q#GR-4). Changing which gates the +suite runs, or the acceptance-suite selection policy — §3 remains +authoritative for both. **Rerunning PR #228's gate**, which is that +lane's unblocking step and happens after this lands, not inside it. diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md new file mode 100644 index 0000000..7a6b55e --- /dev/null +++ b/docs/lsp-language-coverage-framing.md @@ -0,0 +1,273 @@ +# LSP language coverage: LaTeX (and the Haskell/OCaml question) + +**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 +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 +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. + +**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. + +**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 + +### 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). diff --git a/docs/worker-identity-framing.md b/docs/worker-identity-framing.md new file mode 100644 index 0000000..aa165e0 --- /dev/null +++ b/docs/worker-identity-framing.md @@ -0,0 +1,717 @@ +# Worker identity — Stage 1: what is running, and what it is doing + +*(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: 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 +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 +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 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 +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 + +`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, 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 + "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). + +- **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 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.** + `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. + + **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 + 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): a required `purpose` on the job and the process, +and the first indicator. NO WIRE CHANGE. NO `owner`.** + +- **`purpose`, non-optional**, on `PendingJob`, carried through the + single allocation funnel, and on `ProcessSpec` alongside the existing + `label`. +- **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 +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 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 + 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 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 + +### 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, 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 +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, 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 +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: **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`). + +**The contract, in full:** + +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. + - **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 + `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 +triggers unrelated work within its extent — an edit that schedules a +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, +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 — **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? + +*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 + 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.*** 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. + +`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". + +**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 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, 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 — **RESOLVED, fixed here (rev 4)** + +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. + +**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. + +**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? + +*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 `purpose` capture itself — that is substrate, not +preference. + +## 6. Verification + +- **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. +- **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 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. +- **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. +- **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 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. +- **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 +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 +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. +`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 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 — §3 defers `owner` to it and makes +no claim of alignment with it. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 52a2a29..8f5d817 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 @@ -14867,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 { @@ -14888,7 +15049,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 +15452,7 @@ mod tests { prompt: "M-x ".into(), input: "theme".into(), cursor: 5, - candidates: Vec::new(), + rows: Vec::new(), selected: None, total: 0, }); @@ -15335,7 +15496,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 +15518,112 @@ 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 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. + 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 +16221,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 +16439,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 +17362,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/scripts/gate b/scripts/gate index c305f8e..b946f69 100755 --- a/scripts/gate +++ b/scripts/gate @@ -3,11 +3,15 @@ # directory, with isolated ambient roots and durable logs. # # scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] +# scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named # scripts/gate --print-target-dir # scripts/gate --init # scripts/gate --prune [--force] +# scripts/gate --self-test # -# Framing: docs/gate-script-framing.md (revision 4, approved). +# Framing: docs/gate-script-framing.md (revision 4, approved), and +# docs/gate-protocol-build-framing.md (revision 5, approved) for the +# crdt build step, --self-test and --print-plan-named. # # WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one # CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK @@ -37,9 +41,11 @@ set -eu usage() { cat >&2 <<'EOF' usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] + scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named scripts/gate --print-target-dir scripts/gate --init scripts/gate --prune [--force] + scripts/gate --self-test --acceptance SUITE a touched acceptance suite to run (repeatable). docs/agent-handoff.md section 3 stays authoritative @@ -48,8 +54,15 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] working tree, and one that guessed would report coverage it does not have. --protocol the change touches PROTOCOL_VERSION; adds the CRDT - workspace sweep on top of the default one. - --print-plan print the exact gate commands and exit. + workspace sweep on top of the default one, plus + the build that sweep needs (see build-crdt below). + --print-plan print the exact gate commands and exit. Names are + stripped, so every line is runnable as printed. + --print-plan-named print the plan as `namecommand` lines and exit + --- the same text the runner reads. Exists because + a step's NAME is half its contract (a build failure + must be attributed to build-crdt, not sweep-crdt) + and --print-plan cannot show it. --print-target-dir print this worktree's build directory and exit. Creates nothing. --init create the build directory and ownership marker, @@ -57,6 +70,13 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] --prune list managed directories whose worktree is gone. Deletes NOTHING without --force. --force with --prune, actually delete. + --self-test drive the real runner with a HARDCODED synthetic + plan --- true, false, true --- to witness that a + failing gate is named as ITSELF and that the suite + CONTINUES past it. Runs no real gates. EXITS + NON-ZERO BY DESIGN: the middle step fails on + purpose, so a non-zero status is this mode + working, not this mode broken. EOF exit 2 } @@ -198,11 +218,80 @@ emit_plan() { if [ "$PROTOCOL" = 1 ]; then # Section 3: touching PROTOCOL_VERSION STRENGTHENS the sweep # line, it does not replace it. Both sweeps run. + # + # THE BUILD IS A PRECONDITION OF THE SWEEP, not a courtesy. The + # crdt sweep spawns `pmacs-gpu` as a PROCESS, and no `cargo + # test` run produces that binary: pmacs-gpu has no tests/ + # directory, so cargo never uplifts its bin to debug/pmacs-gpu. + # On a cold target directory the sweep therefore fails twelve + # gpu_invocation_acceptance::crdt::* tests on "build pmacs-gpu + # before this acceptance suite" --- and, worse, other crdt tests + # that render through the real binary SKIP THEMSELVES and report + # ok, so the missing build also voids coverage silently. + # + # WHY ONLY UNDER --protocol, measured rather than reasoned. On + # 2026-08-09, on a disposable target directory with + # debug/pmacs-gpu asserted ABSENT before each run and each sweep + # run alone from that cold state: the DEFAULT sweep exited 0 and + # left debug/pmacs-gpu still absent --- it never builds the + # binary and never needs it --- while the crdt sweep exited 101 + # with exactly those twelve failures. So the default gate does + # not pay for this build. + # + # A SEPARATE NAMED STEP, never folded into the sweep command. + # `cargo build ... && cargo test ...` would report a BUILD + # failure under the name `sweep-crdt`, which is a wrong + # attribution in the one place this script exists to be + # trustworthy about. --self-test is what witnesses that the + # runner names the failing gate as itself. + printf 'build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt\n' printf 'sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright\n' fi printf 'diff-check\tgit diff --check\n' } +# --------------------------------------------------------------------- +# The synthetic plan behind --self-test: the runner held to its own +# contract. +# +# WHY A MODE EXISTS AT ALL. tests/gate_script_acceptance.rs drives only +# NO-GATES paths --- a test that ran the real suite would run the gate +# suite inside the gate suite --- so plan assertions can prove a step's +# name and its order and NOTHING about what the runner does when a step +# fails. That left two stated properties with no way to observe them: +# a failing gate is attributed to ITSELF (which is the whole reason +# build-crdt is a separate step rather than `cargo build && cargo +# test`), and the suite CONTINUES past it rather than aborting. Same +# shape of argument as --init: verification needs a path it can drive +# safely, and this one is not a second implementation --- it hands the +# REAL runner loop a different plan file. +# +# THE PLAN IS A LITERAL, and that is the design, not a shortcut. The +# obvious seam --- letting a caller supply PLAN_FILE --- would work, +# and it would turn the runner's `eval` into a general command +# executor. That is the same class of defect this script's own review +# caught in --acceptance and fixed with a refusal at parse time; +# reintroducing it in the tool whose purpose is to be trustworthy is +# not a trade worth making. Nothing external supplies a command here. +# +# THREE LINES, AND THE THIRD IS LOAD-BEARING. With the failure LAST, a +# runner that aborts and a runner that continues produce IDENTICAL +# output, so the witness would pass on a runner doing the opposite of +# the stated policy. The sentinel after the failure, asserted to have +# written its own log, is the only thing that separates them. +# +# `true` and `false` are the entire workload, so this stays on the +# cheap side of the suite. Whether `cargo build` really fails is +# cargo's business; whether THIS SCRIPT names the right gate when a +# command fails is the criterion, and that is orthogonal to which +# command failed. +# --------------------------------------------------------------------- +emit_self_test_plan() { + printf 'self-pass\ttrue\n' + printf 'build-crdt\tfalse\n' + printf 'self-sentinel\ttrue\n' +} + # --------------------------------------------------------------------- # Pruning. # @@ -326,19 +415,65 @@ while [ $# -gt 0 ]; do shift 2 ;; --protocol) PROTOCOL=1; shift ;; --print-plan) MODE=plan; shift ;; + --print-plan-named) MODE=plannamed; shift ;; --print-target-dir) MODE=printdir; shift ;; --init) MODE=init; shift ;; --prune) MODE=prune; shift ;; + --self-test) MODE=selftest; shift ;; --force) FORCE=1; shift ;; -h|--help) usage ;; *) echo "gate: unknown argument: $1" >&2; usage ;; esac done +# --------------------------------------------------------------------- +# Mode dispatch. +# +# TWO RENDERINGS OF ONE PLAN, and the second exists because the first +# hid a defect. `--print-plan` pipes through `cut -f2-` so every printed +# line is a command a reader can copy and run --- and that same cut is +# why the emitted NAMES never reached a test. Not cosmetic: the entire +# reason build-crdt is a separate step is that a build failure must be +# attributed to `build-crdt` rather than to `sweep-crdt`, and with the +# names stripped, RENAMING THE REAL BUILD STEP TO `sweep-crdt` left the +# ordering assertion green. The witness could not reach the step it +# named. --self-test could not either --- it hardcodes the string +# `build-crdt` in its own synthetic plan, which proves things about the +# RUNNER and nothing about this emitter. +# +# --print-plan-named prints emit_plan VERBATIM: the same +# `namecommand` text the runner reads back from PLAN_FILE, so a +# test can assert both halves of a real step together and a rename +# cannot pass. +# +# WHY THIS RATHER THAN THE TWO ALTERNATIVES. +# +# Injecting PLAN_FILE would let a test hand the runner a plan and read +# the names back, and it would turn the runner's `eval` into a general +# command executor --- the same class of defect this script's own +# review caught in --acceptance and fixed with a parse-time refusal. +# Declined there; declined here for the same reason. +# +# Re-deriving the plan test-side (sourcing this file, or parsing +# emit_plan out of it) would be a SECOND implementation of the thing +# under test, which is the exact failure being repaired one level up. +# +# A DISTINCT MODE, not a modifier on --print-plan: there is then no +# `--with-names` without `--print-plan` whose behaviour has to be +# defined, and --print-plan's contract --- runnable lines --- is left +# exactly as it was. Both modes call emit_plan, and so does the runner, +# so neither rendering can drift from what actually executes; +# tests/gate_script_acceptance.rs pins that the stripped rendering is +# the named one minus its names, so this stays true by test and not +# only by reading. +# --------------------------------------------------------------------- case $MODE in plan) emit_plan | cut -f2- exit 0 ;; + plannamed) + emit_plan + exit 0 ;; printdir) target_dir_for "$(canon "$(worktree_root)")" echo @@ -397,11 +532,23 @@ echo "gate: logs $LOGDIR" # trap removes it, so it should be gone once the run finishes. echo "gate: ambient $AMBIENT" [ -n "$ACCEPTANCE" ] && echo "gate: acceptance $ACCEPTANCE" -[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT workspace sweep added)" +[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT build + workspace sweep added)" +if [ "$MODE" = selftest ]; then + echo "gate: SELF-TEST hardcoded synthetic plan --- NO real gate runs." + echo "gate: the middle step fails ON PURPOSE, so a non-zero" + echo "gate: exit is this mode working, not this mode broken." +fi echo +# The self-test hands the REAL runner loop below a different plan file. +# Everything after this point is shared, which is the point: a witness +# that exercised its own copy of the runner would witness nothing. PLAN_FILE="$LOGDIR/plan.txt" -emit_plan > "$PLAN_FILE" +if [ "$MODE" = selftest ]; then + emit_self_test_plan > "$PLAN_FILE" +else + emit_plan > "$PLAN_FILE" +fi N=0 FAILED='' diff --git a/src/async_runtime.rs b/src/async_runtime.rs index 3620a32..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}; @@ -408,6 +410,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 +533,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 +553,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 +591,105 @@ 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, 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)] @@ -610,6 +754,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 +817,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 +901,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 +1005,7 @@ impl AsyncRuntime { kind, dispatched_at: Instant::now(), resource, + purpose, }, ); (id, cancel) @@ -801,7 +1019,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 +1040,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 +1072,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 +1106,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 +1139,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 +1170,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 +1188,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 +1208,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 +1228,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 +1245,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 +1276,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 +1489,7 @@ impl AsyncRuntime { dispatched_at: job.dispatched_at, settled_at: now, supersede_key: job.supersede_key.clone(), + purpose: job.purpose.clone(), outcome, }); } @@ -1243,6 +1527,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 +1547,54 @@ 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, + // 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(), + }) + } + /// 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 +2203,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 +2265,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/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/daemon.rs b/src/daemon.rs index fff5e21..121b469 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 { .. }) { @@ -1801,7 +1827,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 a829714..458751b 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,34 @@ 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 **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, 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, contract_depth) = { let mut core = core.borrow_mut(); let was = core.active_frontend; core.active_frontend = frontend_id; - was + (was, core.push_commit_contract(contract)) }; let previous_commit = commit_scope.0.replace(true); ScopedFrontendGuard { @@ -140,6 +154,7 @@ impl ScopedFrontend { core: core.clone(), previous, previous_active, + contract_depth, commit_scope: commit_scope.clone(), previous_commit, } @@ -151,6 +166,15 @@ pub(crate) struct ScopedFrontendGuard { core: SharedCore, previous: Option, previous_active: FrontendId, + /// 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, @@ -160,7 +184,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.exit_commit_contract(self.contract_depth); + } self.commit_scope.0.set(self.previous_commit); } } @@ -1233,22 +1261,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), }) } @@ -1272,7 +1310,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 = @@ -1302,13 +1340,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}"); @@ -4746,7 +4784,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 }; @@ -5478,9 +5519,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 { @@ -5491,12 +5565,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; @@ -5551,6 +5619,35 @@ 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. + // + // 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(crate::command::Command::description_first_line) + }) + .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/editor_core.rs b/src/editor_core.rs index 0976756..517989c 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -130,39 +130,119 @@ 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 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. +/// * `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, 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, 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. /// /// 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, +} + +/// 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**. 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 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 +/// 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. 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`). +/// +/// 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. + pub destination: ViewDestination, + /// What that continuation declared it depends on. + pub profile: CommitProfile, } /// A `display_buffer` request (Q#BP3). @@ -620,6 +700,34 @@ pub struct EditorCore { /// slot; the producer clears any untaken record when the fan-out /// returns. typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, + /// Every `commit_to` contract currently on the stack, outermost + /// first (Q#DC-2, revision 9). + /// + /// **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 { @@ -674,9 +782,42 @@ impl EditorCore { query_replace: None, typed_edit_pending: None, typed_edit_armed: None, + commit_contracts: Vec::new(), } } + /// 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 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. /// Replaces the scratch buffer's content; the active window is /// retained. @@ -3042,6 +3183,143 @@ 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 + /// that really places in the panel, and refused by a document commit + /// (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. + /// + /// 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 + .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), + } + } + + /// 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 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, + 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] @@ -3260,6 +3538,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: @@ -3801,6 +4096,20 @@ impl EditorCore { .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? .active; let placement = self.resolve_placement(fid, request)?; + // 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 .select @@ -3926,6 +4235,176 @@ 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 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. 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. 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, + /// 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, + /// 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 + /// + /// 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. + /// + /// # Why this shape, and not the two that were tried first + /// + /// * **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. + /// + /// # 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 + /// [`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. + /// * **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 { + // 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( + "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 /// ordinary reuse. Placement affinity precedes generic reuse — /// otherwise a persistent `*compilation*` buffer already visible in a 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/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..0a9fa59 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -4239,25 +4239,35 @@ 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 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 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)) + }); } } @@ -7566,6 +7576,99 @@ 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. + // + // `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. + // + // 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( + "_push_dispatch_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 \ + 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); + 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 +7831,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 +7840,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 +7854,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)) => ( @@ -8677,9 +8782,93 @@ 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, 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. +/// +/// 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`. +fn required_purpose(table: &Table) -> mlua::Result { + let purpose = match table.raw_get::("purpose") { + 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 pmacs.process.list, 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 \ + 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")?; + 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(); @@ -8762,6 +8951,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result { }; Ok(ProcessSpec { label, + purpose, command, args, cwd: cwd.map(std::path::PathBuf::from), @@ -8985,11 +9175,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/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index fe8b758..01f4d61 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,6 +65,51 @@ pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { .unwrap_or_else(|| core.borrow().active_frontend_key()) } +/// 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. +/// +/// 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.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 + // 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 +499,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 +519,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,45 +531,27 @@ 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(); - 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 { - None - } - }; + // The preflight itself lives on the core + // (`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. + // + // 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(); out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?)); @@ -546,13 +575,32 @@ 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 + // 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, &commit, dest.frontend); + let _guard = scope.enter( + &cc, + &commit, + CommitContract { + destination: dest, + profile, + }, + ); body.call::(()) }; let mut out = result?; @@ -563,6 +611,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. // @@ -834,6 +915,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/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/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..3359504 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,61 @@ 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| { + // 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_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() + } else { + labels + .into_iter() + .map(|label| MinibufferRow { + label, + detail: None, + }) + .collect() + }; ( Some(session.prompt.clone()), input, cursor, - candidates, + rows, selected, total, ) @@ -1684,13 +1777,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 +5877,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 +5921,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 +5930,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/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..2a3bd59 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -14,19 +14,40 @@ //! ```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. +//! +//! 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 @@ -35,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; @@ -43,6 +64,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 +145,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 = purpose_for_one_row(&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:(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) +} + +/// 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() +} + +/// 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() + .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 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. `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(), + }; + exec( + s, + &format!( + "ran = false + 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 + else ok, reason, raised = false, nil, tostring(a) end" + ), + ); +} + +/// 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") +} + +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 panel profile's relaxation is CONDITIONAL (Q#DC-2, rev 6–9) +// --------------------------------------------------------------------------- + +/// 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' })"; + +/// 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 })"; + +/// 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. +/// +/// 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** — 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 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 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. +/// +/// **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**. +/// +/// # One row per WRITE SITE, not per call spelling +/// +/// 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`, +/// 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_body_that_tries_to_create_the_fallback_is_refused_at_the_attempt() { + 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 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" + ); + } +} + +/// **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** — 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. +/// +/// 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 `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"] { + 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) +// --------------------------------------------------------------------------- + +/// **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". +/// +/// **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. *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(); + for (label, profile) in [ + ("unknown string", "'documents'"), + ("number", "42"), + ("table", "{}"), + ("boolean", "true"), + ("invalid utf-8", "string.char(255)"), + ] { + 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; diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs new file mode 100644 index 0000000..571c605 --- /dev/null +++ b/tests/discovery_stage2_acceptance.rs @@ -0,0 +1,684 @@ +// 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, ByteRange, InstanceMessage, MinibufferRow, 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, Key, KeyEvent, Modifiers, + SessionBootstrapRequest, +}; +#[cfg(feature = "crdt")] +use pmacs_protocol::transport::{read_message, write_message}; + +#[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 `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 { + 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:?}" + ); +} + +// --------------------------------------------------------------------------- +// 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` + // 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/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/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index ac68b3e..37de5f2 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -127,6 +127,307 @@ fn the_crdt_workspace_sweep_is_added_by_protocol_and_absent_without_it() { ); } +/// **The precondition the plan did not encode**, and the reason a green +/// `--protocol` run could mean nothing. +/// +/// The crdt workspace sweep spawns `pmacs-gpu` as a *process*, and no +/// `cargo test` run produces that binary — `pmacs-gpu` has no `tests/` +/// directory, so cargo never uplifts its bin to `debug/pmacs-gpu`. On a +/// cold target directory the sweep fails twelve +/// `gpu_invocation_acceptance::crdt::*` tests on *"build pmacs-gpu +/// before this acceptance suite"*. Before per-worktree target +/// directories (#225) every worktree shared one that nearly always +/// already held the binary, so the precondition was satisfied **by +/// accident** — and the hazard was never the red gate, it was a green +/// one decided by the build directory rather than by the diff. +/// +/// **The exact command is asserted, not just the step's name and +/// position.** A `build-crdt` running plain `cargo build` would sit in +/// the right place under the right name and leave the gate exactly as +/// unsound: the crdt sweep needs *those* features, and the wrong ones +/// produce a binary the sweep cannot use. +/// +/// **What this test cannot see: the names.** `--print-plan` strips them +/// (`emit_plan | cut -f2-`), so everything below is an assertion about +/// *commands in an order* — renaming the real build step to `sweep-crdt` +/// leaves it green. The step's **name** is asserted by +/// `the_crdt_build_step_carries_its_own_name_and_its_exact_command` +/// below, which reads the plan in the form the runner reads it. +#[test] +fn the_crdt_sweep_is_immediately_preceded_by_the_build_that_produces_its_binary() { + let root = tempfile::tempdir().expect("tempdir"); + let build = "cargo build --workspace --no-default-features --features luajit,crdt"; + let crdt_sweep = "cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright"; + + let (plan, err, ok) = run(root.path(), &["--protocol", "--print-plan"]); + assert!(ok, "--protocol --print-plan must succeed; stderr:\n{err}"); + + let b = plan + .find(build) + .unwrap_or_else(|| panic!("the crdt sweep's build is missing; plan was:\n{plan}")); + let s = plan + .find(crdt_sweep) + .unwrap_or_else(|| panic!("the crdt sweep is missing; plan was:\n{plan}")); + + // Ordering is asserted BEFORE the slice below, which would + // otherwise panic with a byte-offset message ("begin > end (427 > + // 282)") that names neither step. Mutation-tested: emitting the + // build *after* the sweep produced exactly that, and a gate test + // whose failure has to be decoded is a gate test nobody trusts. + assert!( + b < s, + "the build must run BEFORE the crdt sweep, not after it — a sweep \ + that builds its own precondition afterwards has already failed; \ + plan was:\n{plan}" + ); + // IMMEDIATELY before: one newline between them and nothing else. A + // build that merely appears *somewhere* earlier could be separated + // from the sweep by a step that rewrites the same target directory. + assert_eq!( + &plan[b + build.len()..s], + "\n", + "the build must run IMMEDIATELY before the crdt sweep; plan was:\n{plan}" + ); +} + +/// **The witness that reaches the step it names**, and the reason this +/// lane needed a second round. +/// +/// This lane exists to guarantee two things: that the crdt sweep is +/// preceded by the build producing its binary, and that a build failure +/// is attributed to **`build-crdt`** rather than to `sweep-crdt`. The +/// first round shipped with neither guaranteed, because **neither +/// witness could see a name**: +/// +/// - `--print-plan` renders `emit_plan | cut -f2-`, so the ordering test +/// above compares commands and never sees the names beside them. +/// - `--self-test` hardcodes the string `build-crdt` inside its **own +/// synthetic** plan, so it proves things about the *runner* and +/// nothing about the real emitter. +/// +/// Review demonstrated the consequence directly: **renaming the real +/// build step to `sweep-crdt` left both tests passing** — a plan that +/// reports a build failure under the sweep's name, which is exactly the +/// misattribution the separate step exists to prevent, sitting green. +/// +/// So the pair is asserted **together, as one emitted line**, against +/// `--print-plan-named` — the plan in the form the runner reads it back +/// from `PLAN_FILE`. Name and command in the same `assert`, from the +/// real emitter, is what makes a rename unable to pass; either half +/// alone lets the other drift. +/// +/// The mode is a *rendering*, not a seam: `PLAN_FILE` stays +/// uninjectable, because a test that supplied the runner's plan would +/// turn its `eval` into a general command executor — the defect the +/// `--acceptance` refusal below exists to prevent. +#[test] +fn the_crdt_build_step_carries_its_own_name_and_its_exact_command() { + let root = tempfile::tempdir().expect("tempdir"); + let build = "build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt"; + let sweep = + "sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright"; + + let (plan, err, ok) = run(root.path(), &["--protocol", "--print-plan-named"]); + assert!( + ok, + "--protocol --print-plan-named must succeed; stderr:\n{err}" + ); + let lines: Vec<&str> = plan.lines().collect(); + + // Whole-line equality, not `contains`: the name, the tab, and the + // command with nothing appended. A step is its (name, command) pair + // and the plan is where both are decided. + let b = lines.iter().position(|l| *l == build).unwrap_or_else(|| { + panic!( + "no plan line is exactly:\n {build}\nA build step under a \ + different NAME misattributes its own failure; a build step \ + with different FEATURES hands the sweep a binary it cannot \ + use. Plan was:\n{plan}" + ) + }); + // The sweep's own pair, for the same reason in the other direction: + // asserting only the build's name lets a rename of the SWEEP slip + // through the identical hole. + let s = lines + .iter() + .position(|l| *l == sweep) + .unwrap_or_else(|| panic!("no plan line is exactly:\n {sweep}\nPlan was:\n{plan}")); + + assert_eq!( + s, + b + 1, + "the build must be the step IMMEDIATELY before the crdt sweep — a \ + build merely somewhere earlier could be separated from it by a \ + step that rewrites the same target directory. Plan was:\n{plan}" + ); + + // Conditionality, on this rendering too: an ordinary lane must not + // carry the step at all, not merely not carry its command. + let (default_plan, _, ok) = run(root.path(), &["--print-plan-named"]); + assert!(ok, "--print-plan-named must succeed"); + assert!( + !default_plan.contains("build-crdt"), + "the default sweep never builds pmacs-gpu and never needs it, so no \ + ordinary lane may pay for a workspace build; plan was:\n{default_plan}" + ); +} + +/// **The new rendering must be the same plan, or the assertion above +/// pins a string only the test ever reads.** +/// +/// `--print-plan-named` and `--print-plan` both call one emitter, and +/// the runner writes that same emitter to `PLAN_FILE` — so today they +/// cannot disagree. This pins that from outside, where a later edit +/// giving either mode its own plan text would be caught rather than +/// producing a witness that asserts a name the runner never uses. +/// +/// It also pins the **shape** the runner depends on: the loop reads each +/// line with `IFS= read -r name cmd`, so a plan line without its +/// tab would silently run under an empty command. +#[test] +fn the_named_plan_is_the_printed_plan_with_its_names_removed() { + let root = tempfile::tempdir().expect("tempdir"); + + for flags in [ + vec![], + vec!["--protocol"], + vec!["--acceptance", "m4_acceptance"], + ] { + let mut named_args = flags.clone(); + named_args.push("--print-plan-named"); + let mut plain_args = flags.clone(); + plain_args.push("--print-plan"); + + let (named, err, ok_named) = run(root.path(), &named_args); + assert!(ok_named, "{named_args:?} must succeed; stderr:\n{err}"); + let (plain, err, ok_plain) = run(root.path(), &plain_args); + assert!(ok_plain, "{plain_args:?} must succeed; stderr:\n{err}"); + + let mut stripped = String::new(); + for l in named.lines() { + let (_name, cmd) = l.split_once('\t').unwrap_or_else(|| { + panic!( + "every plan line must be `namecommand` — the runner \ + splits on that tab, so a line without one runs an empty \ + command under the whole line's name. Line was:\n {l:?}" + ) + }); + stripped.push_str(cmd); + stripped.push('\n'); + } + + assert_eq!( + stripped, plain, + "the two renderings must be one plan; with {flags:?} they diverged" + ); + } +} + +/// **Conditionality, settled by measurement rather than by reading** — +/// which is the whole methodological point of this lane, since the +/// defect it repairs was a precondition nobody checked. +/// +/// Measured 2026-08-09 on a disposable target directory, with +/// `debug/pmacs-gpu` asserted **absent** before each run and each sweep +/// run alone from that same cold state: the default sweep exited **0** +/// and left `debug/pmacs-gpu` **still absent** — it never builds the +/// binary and never needs it — while the crdt sweep exited **101** with +/// exactly twelve `gpu_invocation_acceptance::crdt::*` failures. +/// +/// So an unconditional build would be a real cost paid for nothing on +/// every ordinary lane. +#[test] +fn the_crdt_build_is_absent_without_protocol() { + let root = tempfile::tempdir().expect("tempdir"); + let (plan, _, ok) = run(root.path(), &["--print-plan"]); + assert!(ok, "--print-plan must succeed"); + assert!( + !plan.contains("cargo build"), + "the default sweep passes on a tree with no pmacs-gpu at all, so a \ + normal lane must not pay for a workspace build; plan was:\n{plan}" + ); +} + +/// **The attribution and continuation criteria, made observable.** +/// +/// Everything else in this file drives a no-gates path, so it can prove +/// a step's name and its order and **nothing** about what the runner +/// does when a step fails. `--self-test` closes that gap by handing the +/// *real* runner loop a hardcoded three-line plan — a passing step, a +/// failing one named `build-crdt`, and a passing sentinel after it. +/// +/// **Why `build-crdt` must be its own step** is exactly what this +/// witnesses: folded into the sweep as `cargo build … && cargo test …`, +/// a *build* failure would be reported under the name `sweep-crdt` — a +/// wrong attribution in the one place this script exists to be +/// trustworthy about. +/// +/// **The sentinel assertion is the load-bearing one.** With the failure +/// last, a runner that aborts and one that continues produce identical +/// output, so a two-line witness would pass on a runner doing the +/// opposite of the stated `--no-fail-fast` policy. The sentinel's own +/// log existing is the only thing that separates them — delete that +/// assertion and this test stops testing continuation at all. +/// +/// The plan is a literal inside the script on purpose. Making +/// `PLAN_FILE` injectable would let this test supply its own commands, +/// and would turn the runner's `eval` into a general command executor — +/// the same defect the `--acceptance` refusal above exists to prevent. +#[test] +fn self_test_names_the_failing_gate_and_the_suite_continues_past_it() { + let root = tempfile::tempdir().expect("tempdir"); + let (out, err, ok) = run(root.path(), &["--self-test"]); + + assert!( + !ok, + "a plan containing a failing step must exit non-zero; stdout:\n{out}stderr:\n{err}" + ); + assert!( + out.contains("build-crdt"), + "the failing gate must be named as it runs; stdout:\n{out}" + ); + assert!( + err.contains("FAILED: build-crdt"), + "the failing gate must be listed under FAILED: by its OWN name; stderr:\n{err}" + ); + + // The runner claims a log path for the failure. Assert the file is + // actually there: a tool that prints a path it did not write is + // worse than one that prints nothing, because the absence is only + // discovered while chasing a real failure. + let claimed = err + .lines() + .find_map(|l| l.split_once("log: ").map(|(_, path)| path.trim())) + .unwrap_or_else(|| panic!("the failing gate's log path must be printed; stderr:\n{err}")); + assert!( + claimed.ends_with("02-build-crdt.log"), + "the log must be numbered and named for the gate that failed; was {claimed}" + ); + assert!( + Path::new(claimed).is_file(), + "the runner must WRITE the log it claims at {claimed}" + ); + + let logdir = Path::new(claimed) + .parent() + .expect("the log lives in a log directory"); + assert!( + logdir.join("01-self-pass.log").is_file(), + "the step before the failure must have its own log; dir was {}", + logdir.display() + ); + // THE ASSERTION THE WHOLE MODE EXISTS FOR. + assert!( + logdir.join("03-self-sentinel.log").is_file(), + "the suite must CONTINUE past a failed gate — the sentinel after \ + build-crdt wrote no log, so this runner ABORTED. Stdout:\n{out}" + ); + assert!( + out.contains("self-sentinel"), + "the sentinel must be reported like any other gate; stdout:\n{out}" + ); +} + /// The seam handoff §3 keeps authority over: a script cannot infer /// which acceptance suites a change touched, so it runs what it is /// handed — each one, in order. diff --git a/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs new file mode 100644 index 0000000..4ccd138 --- /dev/null +++ b/tests/lsp_latex_acceptance.rs @@ -0,0 +1,699 @@ +// 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_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 + // 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" + ); + + // 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"] +mod iso; diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index b106aee..d706305 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -952,7 +952,7 @@ fn has_exit_event(events: &[ProcessEvent]) -> 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"); @@ -1169,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/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b583c83..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 = { @@ -789,7 +794,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 +804,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_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/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"), diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs new file mode 100644 index 0000000..f5dcef7 --- /dev/null +++ b/tests/worker_identity_acceptance.rs @@ -0,0 +1,1266 @@ +// 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); +} + +/// **`pmacs.process.spawn` REFUSES a spec with no purpose, and spawns +/// nothing.** +/// +/// 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. +/// +/// Six refusals, each asserted the same way — the call raises, **the +/// message names the field and the rule**, 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; +/// * **not valid UTF-8**. Lua strings are BYTE strings, so +/// `purpose = string.char(255)` is a value a caller can write, and +/// converting it with `?` would surface mlua's generic conversion +/// 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, 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`. +#[test] +fn spawning_without_a_real_purpose_is_refused_and_starts_nothing() { + let mut state = editor(); + 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", + ), + ( + "invalid UTF-8", + r#"{ label = "x", purpose = "run " .. string.char(255), + command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "purpose must be valid UTF-8 — it is displayed to the user in \ + pmacs.process.list", + ), + ( + "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); +} + +/// 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) +// --------------------------------------------------------------------------- + +/// **A registered handler name is DISPLAY TEXT, and gets `purpose`'s +/// meaningful-value standard.** +/// +/// Before this lane the name died inside `dispatch` and a type check was +/// the whole of what it needed. 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 empty +/// and whitespace-only are refused here for the reason they are refused +/// in `required_purpose` — they satisfy the type and say nothing. +/// +/// **Refused at `register`, and asserted twice**: the call raises with a +/// message naming the rule, *and* nothing is installed under the name — +/// a validation that stored the handler first would have registered the +/// thing it was rejecting. +#[test] +fn a_handler_name_that_says_nothing_is_refused_at_registration() { + let mut state = editor(); + for (label, name_expr) in [("empty", r#""""#), ("whitespace-only", r#"" \t ""#)] { + let (ok, err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.workers.register, {name_expr}, function() end) + return ok, tostring(err)" + ), + ); + assert!(!ok, "{label}: register must refuse"); + assert!( + err.contains("must not be empty or whitespace-only"), + "{label}: the refusal must name the rule; got {err:?}" + ); + let (dispatched, dispatch_err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.workers.dispatch, {name_expr}) + return ok, tostring(err)" + ), + ); + assert!(!dispatched, "{label}: a refused name must install nothing"); + assert!( + dispatch_err.contains("unknown handler"), + "{label}: and the name must be genuinely absent from the table; \ + got {dispatch_err:?}" + ); + } + pump(&mut state); +} + +/// **Control characters in a handler name are refused at the source — +/// and this is the one rule `purpose` deliberately does NOT get.** +/// +/// The asymmetry is the design (`#228`'s shape). 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. +/// A handler name is an identifier a package chooses for itself and +/// hands back to `dispatch`; a control character in one is a mistake or +/// an attempt at one, and refusing costs nobody anything. +/// +/// The rows are chosen to be **not** whitespace-only, so this test +/// cannot pass on the previous guard: a newline mid-word, an ESC (which +/// starts a terminal escape sequence), and a NUL. +#[test] +fn a_handler_name_with_control_characters_is_refused_at_registration() { + let mut state = editor(); + for (label, name_expr) in [ + ("newline", r#""index\ner""#), + ("escape", r#""index" .. string.char(27) .. "[31mer""#), + ("nul", r#""index" .. string.char(0) .. "er""#), + ] { + let (ok, err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.workers.register, {name_expr}, function() end) + return ok, tostring(err)" + ), + ); + assert!(!ok, "{label}: register must refuse"); + assert!( + err.contains("must not contain control characters"), + "{label}: the refusal must name the rule; got {err:?}" + ); + let (dispatched, dispatch_err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.workers.dispatch, {name_expr}) + return ok, tostring(err)" + ), + ); + assert!(!dispatched, "{label}: a refused name must install nothing"); + assert!( + dispatch_err.contains("unknown handler"), + "{label}: and the name must be genuinely absent from the table; \ + got {dispatch_err:?}" + ); + } + pump(&mut state); +} + +/// **The third rule a display-text name needs, enforced at the one +/// place that can see it.** +/// +/// Lua strings are BYTE strings and Lua 5.1 has no `utf8` library, so +/// `register` cannot tell a valid name from arbitrary bytes — +/// `string.char(255)` is neither whitespace nor a control character by +/// `%c`. The name crosses into Rust exactly once, at +/// `_push_dispatch_name`, and that is where the byte-level rule is +/// enforced. **This is the P2a class again**: an `mlua`-driven +/// conversion there would refuse with a generic message naming neither +/// the argument nor the rule, so the conversion failure is mapped onto +/// an owned diagnostic instead. +/// +/// The refusal lands at `dispatch` rather than at `register`, which is +/// later than ideal and is asserted as such — but it is still **before +/// the handler runs**, so a name that cannot be displayed never reaches +/// a job's purpose. +#[test] +fn a_handler_name_that_is_not_valid_utf8_is_refused_before_the_handler_runs() { + let mut state = editor(); + let (ok, err): (bool, String) = eval( + &state, + "RAN = false + pmacs.workers.register('bad' .. string.char(255), function() RAN = true end) + local ok, err = pcall(pmacs.workers.dispatch, 'bad' .. string.char(255)) + return ok, tostring(err)", + ); + assert!(!ok, "dispatch must refuse a name it cannot display"); + assert!( + err.contains("handler name must be valid UTF-8"), + "the refusal must name the argument and the rule; got {err:?}" + ); + assert!( + !eval::(&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); +} + +/// **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"`. +/// +/// 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); +} + +// --------------------------------------------------------------------------- +// 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); +}