Merge main into git-status-stage1 --- the prerequisite has landed

Merged rather than rebased, for the reason #231 was: nineteen commits
replayed against a ledger four lanes had rewritten is nineteen chances
to lose a lane entry, and the reviewed commits keep their SHAs. One
file conflicted.

The conflict was diff3-style with an EMPTY base --- both sides added
lanes at the same offset --- so it resolved by keeping both, this
branch's #227 lane ahead of main's. Worth noting because an empty base
section is exactly what produced the stray `|||||||` markers found
committed in this file earlier today: the base half of a diff3 conflict
reads as content if it is resolved by hand in a hurry.

This unblocks the lane. `pmacs.window.capture_destination` now exists
on the branch (`src/lua_bindings/window_panel.rs:638`), which is what
#227's review found missing and what #231 was split out to provide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-10 21:53:55 +02:00
commit 4109ce6246
No known key found for this signature in database
45 changed files with 11551 additions and 364 deletions

View File

@ -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<String>` of names; the wire type `MinibufferPrompt.candidates`
is `Vec<String>` (`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<String>` 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.*
---

View File

@ -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" },

View File

@ -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, `"<name>: <purpose>"`) 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 `"<name>: <purpose>"`, 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.

View File

@ -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" },

View File

@ -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",

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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 v12v22 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.

View File

@ -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 U1U3 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 |

View File

@ -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 69 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 25 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<String>` *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<String>`.**
**Revision 4 said `Option<String>` 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<WindowId>, buffer:
Option<BufferId> }`.** 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 24** — 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<String>` 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<String>`: 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.

View File

@ -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<String>` (`pmacs-protocol/src/message.rs:1113`).
- **The rich pattern is already proven in a sibling variant.**
`CompletionPopup` carries `rows: Vec<CompletionPopupRow>``label`,
`kind: u8`, `detail: Option<String>` (`: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<MinibufferRow>` where it expects
`Vec<String>` 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 v12v22.** Its
encoding is frozen.
- **`MinibufferPromptRows` is a NEW variant appended to the enum**,
carrying `rows: Vec<MinibufferRow>` 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<String> }`***
— 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<String>` 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.13.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 v12v22 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 v12v22 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 v12v22. 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).

View File

@ -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 `name<TAB>command` 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.

View File

@ -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 14b, 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.<lang>.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).

View File

@ -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<ResourceOp>`, 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<WorkspaceId>` 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 `"<name>: <purpose>"`; where it did not, the
recorded value is `"<name>"`. 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 34): 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 `"<name>: <purpose>"`
— 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 <the new suite>`. **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.

View File

@ -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<String>,
rows: Vec<MinibufferRow>,
selected: Option<u32>,
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::<Vec<_>>()
.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<I, S>(labels: I) -> Vec<MinibufferRow>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<String> = 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<String> = 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,
});

View File

@ -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,

View File

@ -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
/// v12v22 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<String>` 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<String>,
/// 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<MinibufferRow>,
/// Highlighted row *within* `rows`, or `None`.
selected: Option<u32>,
/// 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<String>,
}
/// 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<String>,
}
/// 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

View File

@ -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 `name<TAB>command` 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
# `name<TAB>command` 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=''

View File

@ -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<ResourceOp>,
/// 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<usize>,
/// Filesystem mutation this job performs, for the settle-time
/// reconcile (dired Stage 2a).
resource: Option<ResourceOp>,
/// 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<String>,
/// 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<String>,
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<Mutex<HashMap<JobId, Arc<ParseTreeBundle>>>>,
/// 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<Vec<String>>,
}
/// 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<String>) {
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<String> {
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<usize>,
) -> (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<usize>,
resource: Option<ResourceOp>,
) -> (JobId, CancellationToken) {
///
/// The recorded purpose **composes** with any dispatch-name ambient
/// rather than replacing it (Q#W-2 rule 6): `"<name>: <purpose>"`
/// where the dispatcher described its own work, `"<name>"` 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<usize>,
) -> 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<usize>,
) -> 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<String>,
) -> (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<ActivitySummary> {
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<ResourceOp> {
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,

View File

@ -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<Function>,
}
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();

View File

@ -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);

View File

@ -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<FrontendId>,
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<crate::editor_core::DirectoryDestination> {
) -> Option<crate::editor_core::ViewDestination> {
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;

View File

@ -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<WindowId>,
/// Buffer that window held at capture time (stale-intent check).
pub buffer: BufferId,
pub buffer: Option<BufferId>,
}
/// 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 24 —
/// **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<CommitContract>,
}
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<String> {
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<String> {
// 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 24 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<String> {
// 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

View File

@ -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.

View File

@ -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 {

View File

@ -4239,25 +4239,35 @@ fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
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<M: mlua::UserDataMethods<Self>>(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<RestartPolicy> {
})
}
/// 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<String> {
let purpose = match table.raw_get::<mlua::Value>("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<ProcessSpec> {
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<String> = table.get("args").unwrap_or_default();
let cwd: Option<String> = table.get("cwd").ok().flatten();
let env_table: Option<Table> = table.get("env").ok().flatten();
@ -8762,6 +8951,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
};
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)?)?;

View File

@ -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<String>` **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<CommitProfile> {
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<mlua::MultiValue> {
// 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::<super::DirectoryDestinationLua>().ok()
userdata.borrow::<super::ViewDestinationLua>().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::<mlua::MultiValue>(())
};
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::<Option<bool>>("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(|| {

View File

@ -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 {

View File

@ -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<String>, command: impl Into<String>) -> Self {
pub fn new(
label: impl Into<String>,
command: impl Into<String>,
purpose: impl Into<String>,
) -> 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

View File

@ -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 v12v22 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 v12v22 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

View File

@ -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<String>, Option<u32>, u32, bool, bool);
/// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// 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>, String, u32, Vec<String>, Option<u32>, 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>,
String,
u32,
Vec<MinibufferRow>,
Option<u32>,
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<usize>) -> (Vec<String>, Option<u32>) {
fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Vec<T>, Option<u32>) {
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<BufferId, MenuPromptFacts>,
/// 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<MinibufferFacts>,
/// 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<String> = 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::<String>(&[], Some(0)),
(Vec::new(), None)
);
}
/// The v23 rows form: `(prompt, input, rows)`.
fn minibuffer_rows_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, String, Vec<MinibufferRow>)> {
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>, String, Vec<String>)> {
@ -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();

View File

@ -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;

View File

@ -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} {:<PURPOSE_WIDTH$} Status",
"ID", "Kind", "Age", "Supersede", "Purpose"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
);
if snapshot.active.is_empty() {
let _ = writeln!(text, "(no active jobs)");
@ -139,13 +169,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
let _ = writeln!(text);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} Outcome",
"ID", "Kind", "Duration", "Supersede"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Outcome",
"ID", "Kind", "Duration", "Supersede", "Purpose"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
);
if snapshot.completed.is_empty() {
let _ = writeln!(text, "(no recent completions)");
@ -169,7 +203,11 @@ fn write_active_row(text: &mut String, job: &ActiveJobInfo) {
if job.is_stream {
status.push_str(" [stream]");
}
let _ = writeln!(text, "{id:<7} {kind:<11} {age:>9} {key:<11} {status}");
let purpose = purpose_for_one_row(&job.purpose);
let _ = writeln!(
text,
"{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {status}"
);
}
fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
@ -179,9 +217,10 @@ fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
let key = job.supersede_key.as_deref().unwrap_or("");
let outcome = format_outcome(&job.outcome);
let age = format_duration_ms(job.settled_age_ms);
let purpose = purpose_for_one_row(&job.purpose);
let _ = writeln!(
text,
"{id:<7} {kind:<11} {duration:>9} {key:<11} {outcome} ({age} ago)"
"{id:<7} {kind:<11} {duration:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {outcome} ({age} ago)"
);
}
@ -287,6 +326,7 @@ mod tests {
supersede_key: Some("search".to_string()),
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -309,6 +349,7 @@ mod tests {
supersede_key: None,
cancel_requested: true,
is_stream: false,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -326,6 +367,7 @@ mod tests {
duration_ms: 25,
settled_age_ms: 200,
supersede_key: None,
purpose: "sum 1..10".to_string(),
outcome: JobOutcome::Complete(JobResult::Sum(55)),
}],
);
@ -368,6 +410,7 @@ mod tests {
supersede_key: None,
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);

View File

@ -337,8 +337,9 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
#[test]
fn the_baseline_stays_and_the_counter_offer_activates() {
// A deliberate tripwire: bumping the wire must be a conscious edit
// here, not a silent one. v22 is `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 22);
// here, not a silent one. v23 is `MinibufferPromptRows` (Discovery
// Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 23);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid"
@ -352,10 +353,10 @@ fn the_baseline_stays_and_the_counter_offer_activates() {
// This replaces `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)`,
// which asserted a **coincidence**: panel frames were the newest
// feature when it was written, so their minimum happened to equal
// the current wire. Any later feature falsifies that — v22 is the
// first, and the equality would have had to be edited on every
// subsequent bump while telling a reader something that was never
// the contract.
// the current wire. Any later feature falsifies that — v22 was the
// first and v23 the second, and the equality would have had to be
// edited on every subsequent bump while telling a reader something
// that was never the contract.
// `const` blocks, matching the line above: these are compile-time
// constants, so a runtime `assert!` is both a clippy error and a
// weaker check than the language already offers.

View File

@ -1836,7 +1836,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() {
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", stdin = true })
{ label = "t", purpose = "type-check probe", command = "/bin/true",
stdin = true })
return ok, tostring(err)
"#,
);
@ -1846,7 +1847,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() {
&s,
r#"
local ok, err = pcall(pmacs.process.spawn,
{ label = "t", command = "/bin/true", group = "true" })
{ label = "t", purpose = "type-check probe", command = "/bin/true",
group = "true" })
return ok, tostring(err)
"#,
);
@ -2234,7 +2236,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() {
&s,
r#"
local spec = setmetatable(
{ label = "mt", command = "/bin/sh", args = { "-c", "sleep 30" } },
{ label = "mt", purpose = "raw-read probe", command = "/bin/sh",
args = { "-c", "sleep 30" } },
{ __index = function(_, k)
if k == "group" then return true end
return nil
@ -2265,7 +2268,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() {
&s,
r#"
local spec = setmetatable(
{ label = "mt2", command = "/bin/sh", args = { "-c", "exit 0" } },
{ label = "mt2", purpose = "raw-read probe", command = "/bin/sh",
args = { "-c", "exit 0" } },
{ __index = function() error("hostile spec metatable") end })
local ok = pcall(pmacs.process.spawn, spec)
return ok

File diff suppressed because it is too large Load Diff

View File

@ -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<T: mlua::FromLuaMulti>(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::<String>().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<MinibufferRow> {
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::<usize>(&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::<String>(&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<String>,
candidates: Vec<String>,
},
Rows {
prompt: Option<String>,
rows: Vec<MinibufferRow>,
},
}
#[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<T>(
stream: &mut UnixStream,
what: &str,
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
) -> T {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(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<Mb> {
let mut seen = Vec::new();
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(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:?}"
);
}

View File

@ -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,

View File

@ -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=<tab> 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 `name<TAB>command` — 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.

View File

@ -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<T: mlua::FromLuaMulti>(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<String> {
let got: Option<String> = 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<String> {
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<String> = 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<String> =
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<String> = 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<String> = eval(&state, "return pmacs.lsp.config.latex.root(nil)");
assert_eq!(nil_arg, None, "a pathless buffer declines");
let bare: Option<String> = 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;

View File

@ -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" },
}

View File

@ -136,7 +136,12 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["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 {

View File

@ -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

View File

@ -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"),

File diff suppressed because it is too large Load Diff