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:
commit
4109ce6246
31
COHERENCE.md
31
COHERENCE.md
|
|
@ -570,9 +570,11 @@ descriptions, indexed by `M-x help`. It needed **no Rust** — the data
|
|||
was all reachable from Lua, and even the settings completion source is a
|
||||
Lua function through `CompletionSource::Custom`.
|
||||
|
||||
**What is still missing** is itemized below and unchanged by that stage:
|
||||
`Command` has no title/category/aliases/flags/arg-schema; the predicate
|
||||
is still never evaluated; M-x rows are still bare name strings; the Rust
|
||||
**What is still missing** is itemized below. Discovery Stage 2 took one
|
||||
item — **M-x rows now carry each command's description** — and the rest
|
||||
is unchanged by both stages: `Command` has no
|
||||
title/category/aliases/flags/arg-schema; the predicate
|
||||
is still never evaluated; the Rust
|
||||
help layer is still orphaned (Stage 1 funnels every command through one
|
||||
Lua seam so the eventual migration is enumerated per subject rather than
|
||||
per call site); **packages** have no discovery surface, and workers,
|
||||
|
|
@ -621,12 +623,21 @@ the sharpest instance of §1.1.**
|
|||
M-x filtering, or the menu. The doc comment's claim that "the command
|
||||
palette (T M2.7) uses it to gray out unavailable entries" describes
|
||||
something that never shipped.
|
||||
- **M-x shows bare name strings.** `CompletionSource::Commands` returns
|
||||
`Vec<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.*
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1132
docs/active-work.md
1132
docs/active-work.md
File diff suppressed because it is too large
Load Diff
|
|
@ -529,10 +529,21 @@ someone forgot.
|
|||
which is why name-keyed lists are not trustworthy.
|
||||
- **`basedpyright` hangs forever** — always
|
||||
`cargo test --test m4_acceptance -- --skip basedpyright`.
|
||||
- **The crdt sweep needs `cargo build --workspace` first**, or twelve
|
||||
`gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu`
|
||||
binary. `cargo build --workspace --no-default-features --features
|
||||
luajit,crdt` is the invocation that produces both binaries.
|
||||
- **INCIDENT — the crdt sweep's missing build step. §3 now owns this
|
||||
requirement; what stays here is the history and the signature.**
|
||||
Signature: twelve `gpu_invocation_acceptance::crdt::*` failures, each
|
||||
*"build pmacs-gpu before this acceptance suite"*, on a target
|
||||
directory with no `debug/pmacs-gpu` in it. Seen on **PR #228's first
|
||||
gate run** (2026-08-09) — the first `--protocol` run in a *fresh*
|
||||
per-worktree target directory after #225. Latent for the whole life
|
||||
of the shared `CARGO_TARGET_DIR`, which nearly always already held
|
||||
the binary, so the precondition was met by accident. Reproduced
|
||||
deliberately the same day on a disposable cold target: default sweep
|
||||
exit 0, crdt sweep exit 101 with exactly those twelve.
|
||||
**`scripts/gate --protocol` now runs the build as a named
|
||||
`build-crdt` step**, so this signature appearing again means the
|
||||
script was bypassed, not that the requirement moved. The invocation,
|
||||
the measurement and the reasoning are in §3.
|
||||
- **A shared `CARGO_TARGET_DIR` makes concurrent sweeps unattributable.**
|
||||
Every worktree on this machine resolves to the same target directory,
|
||||
so `target/debug/pmacs` is a **shared mutable file**: a
|
||||
|
|
@ -2298,15 +2309,50 @@ eight broken version assertions on CI. When the two disagree, **this
|
|||
list wins**.
|
||||
|
||||
**Touching `PROTOCOL_VERSION` STRENGTHENS the sweep line. It does not
|
||||
replace it:**
|
||||
replace it — and the crdt sweep has a BUILD PRECONDITION:**
|
||||
|
||||
```
|
||||
cargo test --workspace --no-fail-fast -- --skip basedpyright
|
||||
cargo build --workspace --no-default-features --features luajit,crdt
|
||||
cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright
|
||||
```
|
||||
|
||||
Every part is load-bearing:
|
||||
|
||||
- **The build before the crdt sweep is a PRECONDITION, not a courtesy,
|
||||
and it is gate policy rather than an ops tip.** The crdt sweep spawns
|
||||
`pmacs-gpu` as a *process*, and no `cargo test` run produces that
|
||||
binary: `pmacs-gpu` has no `tests/` directory, so cargo never uplifts
|
||||
its bin to `debug/pmacs-gpu`. Omit the build on a cold target
|
||||
directory and twelve `gpu_invocation_acceptance::crdt::*` tests fail
|
||||
on *"build pmacs-gpu before this acceptance suite"* — and, worse,
|
||||
crdt tests that drive the real binary (`vterm_stage3` a37,
|
||||
`bottom_panel_stage2b_gpu` a54) take their skip branch and report
|
||||
**`ok`**, so the missing build also voids coverage *silently*.
|
||||
`scripts/gate --protocol` emits it as its own named `build-crdt`
|
||||
step, never folded into the sweep command, so a build failure is
|
||||
attributed to the build rather than to the sweep.
|
||||
|
||||
**Only the crdt sweep needs it, and that is MEASURED rather than
|
||||
reasoned.** On 2026-08-09, on a disposable target directory with
|
||||
`debug/pmacs-gpu` asserted **absent before each run** and each sweep
|
||||
run alone from that cold state: the **default** sweep exited **0**
|
||||
(114 test targets green) and left `debug/pmacs-gpu` **still absent**
|
||||
— it never builds the binary and never needs it — while the **crdt**
|
||||
sweep exited **101** with exactly those twelve failures. An
|
||||
unconditional build would be a real cost paid for nothing on every
|
||||
ordinary lane.
|
||||
|
||||
**Why this was latent for years.** Before per-worktree target
|
||||
directories (#225) every worktree on this machine shared one, which
|
||||
nearly always already held a `pmacs-gpu` from some earlier build, so
|
||||
the precondition was satisfied **by accident** on essentially every
|
||||
run. The hazard is not the red gate — a red gate stops you. It is a
|
||||
**green** `--protocol` run whose crdt sweep was decided by what
|
||||
happened to be in the build directory rather than by the diff, which
|
||||
is a gate reporting coverage it does not have. §5 keeps the incident
|
||||
and its signature as history; **this section owns the requirement.**
|
||||
|
||||
- **`--workspace`, never `--tests`.** `--tests` selects 108 targets
|
||||
where `--workspace` selects 110, and the two it drops are
|
||||
**`pmacs_protocol` and `pmacs_gpu`**. On a protocol bump, dropping
|
||||
|
|
@ -2552,11 +2598,30 @@ cannot advertise 21 without stranding existing v20 clients before
|
|||
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
|
||||
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
|
||||
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
|
||||
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
|
||||
`InitialTargetResult`; v21 reserves the panel frame/event family;
|
||||
v22 = `LineWrapFacts`; v23 = `MinibufferPromptRows`. New wire
|
||||
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
|
||||
must be guarded by a byte pin on the PREVIOUS final variant — its own
|
||||
round-trip cannot detect a discriminant shift.
|
||||
|
||||
**A SUPERSEDED variant can be frozen rather than widened, and v23 is the
|
||||
first case.** Discovery Stage 2 needed richer minibuffer rows.
|
||||
Widening `MinibufferPrompt` in place was not an option — postcard
|
||||
encodes fields positionally, so every v12–v22 peer would **mis-decode**
|
||||
the bytes rather than ignore them — and gating the widened form at
|
||||
`>= 23` would have left those peers with **no minibuffer message at
|
||||
all**, because there would have been only one variant to gate.
|
||||
Compatibility requires the old shape to still exist *and still be sent*.
|
||||
So `MinibufferPrompt` is retained unchanged for `12..=22`,
|
||||
`MinibufferPromptRows` is appended for `>= 23`, and the daemon gate is a
|
||||
**range on both sides** so exactly one variant reaches any peer.
|
||||
Two consequences worth carrying forward: a frozen variant needs a
|
||||
**literal byte fixture** (`assert_eq!(encoded, LEGACY_BYTES)`), because a
|
||||
round-trip encodes and decodes with the same types and so freezes
|
||||
nothing; and the CLOSE message must use the same variant family as the
|
||||
OPEN, or a session closed by the other family's clear leaves its surface
|
||||
on screen forever.
|
||||
|
||||
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
|
||||
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
|
||||
`sighelp`. Use these for capability-matrix tests, not real servers.
|
||||
|
|
|
|||
|
|
@ -496,30 +496,108 @@ Stage 4; the lane touches no `pmacs-gpu` code at all.
|
|||
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
|
||||
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
|
||||
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
|
||||
| **status** | **new incident, unreproduced — causal status UNRESOLVED** |
|
||||
| **what IS established** | one occurrence at `pmacs-gpu/src/attach.rs:1680`; the test drives a scripted transient-then-success sequence over a real socket pair |
|
||||
| **status** | **THIRD OCCURRENCE 2026-08-09 — causal status still UNRESOLVED, but one candidate mechanism is now EXCLUDED** |
|
||||
| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below |
|
||||
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
|
||||
| **rerun evidence** | 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Per the rerun rule this establishes **intermittence only** |
|
||||
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion |
|
||||
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
|
||||
|
||||
**Not attributed to this lane**, and the reasoning is not merely "my
|
||||
diff looks unrelated": Stage 4 adds no wire surface, no protocol
|
||||
version change, and touches no file in `pmacs-gpu`. A merge-base
|
||||
control would settle it if this recurs.
|
||||
**Not attributed to the observing lane**, and in neither case is the
|
||||
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
|
||||
wire surface, no protocol version change, and touched no file in
|
||||
`pmacs-gpu`.
|
||||
|
||||
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence
|
||||
**Second occurrence — worker identity Stage 1, 2026-08-09, local
|
||||
(Linux).** Recorded at the `scripts/gate` **`gpu` step**
|
||||
(`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`), which is a **third
|
||||
flavor**: not the `--features crdt` sweep of occurrence 1, and not U3's
|
||||
default-features workspace sweep. Two things make it a match rather than
|
||||
a `U` note:
|
||||
|
||||
Has a selector, which U1 lacks — but still no fragments, so it cannot
|
||||
be matched either. Recorded so a recurrence is recognisable.
|
||||
* **The fragments were captured this time.** `transient sequence must
|
||||
attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message:
|
||||
"Broken pipe" })))` — all three of the row's required fragments,
|
||||
verified against the durable gate log rather than a filtered live
|
||||
stream. **That is what U2 and U3 both lost**, and it is why U3 could
|
||||
not be judged a recurrence. Reading the gate's own `NN-gpu.log` is the
|
||||
mechanical fix U3 prescribed, and it worked.
|
||||
* **The merge-base control R7 asked for was run** — 15 runs at `4bc55e8`,
|
||||
green. It is **non-discriminating**, not exculpatory: the observing
|
||||
branch was equally green over 30 runs, so neither side reproduced and
|
||||
the control separates nothing. Recorded as a null result rather than
|
||||
as evidence.
|
||||
|
||||
**One causal path is NOT excluded and is named here rather than
|
||||
dismissed.** The observing lane added a test to `pmacs-gpu`'s test module
|
||||
(`main.rs`) — a GPU-heavy `render_offscreen` case. It touches no
|
||||
`attach.rs`, no protocol, and no wire, but it does add a concurrent test
|
||||
to the same binary, and the failing test is a socket handshake with a
|
||||
one-second deadline. Contention is a plausible mechanism for a
|
||||
`BrokenPipe`, and 30 green runs do not rule it out. If a third occurrence
|
||||
lands, **run the control with the added test removed** rather than at the
|
||||
merge base — that is the discriminating comparison this one was not.
|
||||
|
||||
**Third occurrence — worker identity Stage 1 review round 2,
|
||||
2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all
|
||||
three fragments verified** against the durable gate log
|
||||
(`20260809T172606Z-1387979/11-gpu.log`): `transient sequence must
|
||||
attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message:
|
||||
"Broken pipe" })))`. A match on this file's own rule, not a `U` note.
|
||||
|
||||
**The control the second-occurrence note prescribed was run, and this
|
||||
time it discriminated — against the hypothesis.** Ten full
|
||||
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` runs with the added
|
||||
`render_offscreen` test present: **10/10 green**. Ten more with that
|
||||
test `#[ignore]`d, changing nothing else: **1 failure in 10**, carrying
|
||||
all three required fragments
|
||||
(`without/run-6.log`, `pmacs-gpu/src/attach.rs:1680`).
|
||||
|
||||
So the concurrent-GPU-test path named above is **excluded**: removing
|
||||
the suspect made the failure *more* frequent, not less, which no
|
||||
contention story from that test survives. What the run does establish is
|
||||
that **the failure reproduces on demand at roughly 1-in-10 under
|
||||
ordinary `-p pmacs-gpu` load** — the first time any rerun in this row's
|
||||
history has reproduced it at all. That is a materially better starting
|
||||
point than three isolated sightings, and it is the fact a diagnosis
|
||||
should be built on: the rate makes a bisect of `attach.rs`'s handshake
|
||||
path affordable, where before it was not.
|
||||
|
||||
**It is still not attributed to the observing lane**, and now for a
|
||||
measured reason rather than an argument from diff shape: the arm without
|
||||
the lane's only `pmacs-gpu` addition is the arm that went red.
|
||||
|
||||
**What would retire it is unchanged** — the mechanism, not the rate.
|
||||
The next agent to touch this row should reproduce at 1-in-10 and
|
||||
instrument which side closes the pipe, rather than re-running for green.
|
||||
|
||||
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, THIRD known occurrence
|
||||
|
||||
**Corrected 2026-08-09 after review.** A previous edit of this row
|
||||
called the 2026-08-09 failure the *second* occurrence and claimed it
|
||||
captured the fragment for the first time. **Both were wrong**, and the
|
||||
evidence was already in this repository:
|
||||
`docs/active-work.md` records a **2026-08-06** loaded `--features crdt`
|
||||
run failing this selector *and* `m6_1_pty_canonical_mode_keeps_kernel_echo`
|
||||
with the same `stty -a output was: ""`, and it already proposed a
|
||||
mechanism family — **read-before-write on the child's output**, the
|
||||
shape of **R4** (readiness predicate satisfied by an empty file) and
|
||||
**R6** (readiness file never published).
|
||||
|
||||
So the fragment was captured before, under another feature flavor, and
|
||||
this row's earlier "no mechanism has been proposed" was false of the
|
||||
tree it was written in.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` |
|
||||
| **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus |
|
||||
| **required fragments** | **none captured** — output was filtered to the `FAILED` line |
|
||||
| **status** | **new incident, unreproduced** |
|
||||
| **what IS established** | it failed once (`1916 passed; 1 failed`), in no registry row, under a full-corpus run |
|
||||
| **what is NOT** | any mechanism. Not reproduced in a later full `--tests --no-fail-fast` sweep (108 targets, exit 0) nor in 3 isolated `--lib` runs (1917/0 each) |
|
||||
| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` |
|
||||
| **status** | **at least three occurrences, load-correlated; the diff is EXCLUDED on the 2026-08-09 one** |
|
||||
| **what IS established** | **Three occurrences.** **(1)** the original: failed once (`1916 passed; 1 failed`) under a full-corpus `--tests --no-fail-fast` run, fragments not captured. **(2) 2026-08-06**, loaded `--features crdt`: this selector **and** `m6_1_pty_canonical_mode_keeps_kernel_echo` both failed with the same `stty -a output was: ""` — the first capture, and the occurrence that proposed the read-before-write family. **(3) 2026-08-09**, worker-identity tip: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s). **Occurrence 2 is the one that matters most**: it shows the failure is not confined to one feature flavor and can take both selectors at once |
|
||||
| **what the fragment ACTUALLY shows** | **The supervisor collected empty stdout** — `drain_until` then `collect_stdout(&evs)` (`src/process.rs:3948-3951`); the assertion inspects that string. It does **NOT** establish that `stty` emitted nothing: the bytes could have been lost in PTY delivery or in event collection. An earlier edit of this row said "`stty` produced no output at all", which asserts a mechanism the test cannot see. What is true is narrower and still useful: this is not a *termios* failure — nothing shows echo being configured wrongly — but which of {child never wrote, PTY dropped it, collection missed it} is open. The assertion's message invites the wrong reading, since it prints an empty string as though it were `stty`'s answer |
|
||||
| **what is NOT** | **No mechanism is ESTABLISHED** — one is *proposed*: read-before-write on the child's output, the R4/R6 readiness family (occurrence 2). Proposed is not confirmed, and nothing here discriminates it from PTY delivery or event-collection loss. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 3. **Three occurrences establish intermittence and a load correlation; none establishes cause** |
|
||||
| **discriminating control for the next occurrence** | capture the **full process event stream and the child's exit disposition**, not only the collected string — that is what separates "child never wrote" from "delivery or collection lost it", and the collected string cannot distinguish them however many times it is sampled. Cross-check against R4/R6's readiness family, which `docs/active-work.md`'s 2026-08-06 entry already implicates |
|
||||
| **cross-reference** | `docs/active-work.md` — 2026-08-06 occurrence, `--features crdt`, **both** the raw and canonical selectors, same fragment, read-before-write hypothesis |
|
||||
| **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red |
|
||||
|
||||
### U3 — the R7 selector again, fragments lost the same way U2's were
|
||||
|
|
@ -552,6 +630,54 @@ it again here by piping a sweep through `grep`. The fix is mechanical:
|
|||
stream. A signature that is cheap to capture and impossible to
|
||||
reconstruct should never be traded for terminal brevity.
|
||||
|
||||
*(Renumbered from U4/U5 to **U6/U7** on the rebase onto `0857bf4`: `gate-protocol-build` landed its own U4/U5 in #229, and git merged both files **without a conflict**, producing duplicate ids across four sites. The pre-rebase warning is retired here because it has been carried out.)*
|
||||
|
||||
### U6 — two wall-clock budget tests fail together in one `lib-crdt` step
|
||||
|
||||
Recorded during worker identity Stage 1 review round 2, 2026-08-09, in
|
||||
the same gate run that produced R7's third occurrence. **Fragments were
|
||||
captured**, so unlike U1–U3 this one is matchable — it is a `U` row
|
||||
because it has one occurrence and no mechanism, not because the evidence
|
||||
was lost.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--lib --features crdt optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same run |
|
||||
| **job / flavor** | local (Linux), `scripts/gate` step `04-lib-crdt`, with sibling worktrees building concurrently |
|
||||
| **required fragments** | `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms`; and `composition machinery added more than 10% overhead` |
|
||||
| **status** | **new incident, one occurrence, not reproduced** |
|
||||
| **what IS established** | both are **wall-clock budget assertions** — 1.264ms against a 1ms budget, and 1.297× against a 1.10× budget — so both are load-sensitive by construction. Both green in an isolated rerun of exactly those two selectors, and both green in the next full gate run of the same command (2105 passed) |
|
||||
| **what is NOT** | whether the machine's concurrent load caused it. The confound is real (this machine runs one shared `CARGO_TARGET_DIR` and several worktrees) but **was not measured**, so it is a rival explanation, not a finding |
|
||||
| **rival explanation not excluded** | a genuine regression in either path. Nothing in the observing diff touches the optimistic-echo orchestrator or the composition pipeline, but "my diff looks unrelated" is not evidence, and this row does not treat it as such |
|
||||
|
||||
**Two budget tests failing in one run and neither in the next is the
|
||||
signature worth matching**, more than either name alone: a real
|
||||
regression in two unrelated subsystems at once is far less likely than
|
||||
one loaded machine. If a future run reds **one** of these without the
|
||||
other, that is a different incident and should be judged as one.
|
||||
|
||||
### U7 — a *different* wall-clock render-budget test reds each sweep
|
||||
|
||||
Recorded during worker identity Stage 1 review round 3, 2026-08-09.
|
||||
**Two consecutive `scripts/gate` runs of the same command, on the same
|
||||
tree, red on step `12-sweep` with a different test each time** — which
|
||||
is the signature, and it is a stronger one than any single selector.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | run 1: `--test m8_2_acceptance dired_open_renders_10k_entries_under_200ms` **and** `--test m8_9_acceptance outline_5_level_100_entry_renders_within_100ms`; run 2: `--test dired_acceptance dired_renders_10k_entries_within_200ms` |
|
||||
| **job / flavor** | local (Linux), `scripts/gate` step `12-sweep` (`cargo test --workspace --no-fail-fast`), **load average 12.9 / 23.9** with sibling worktrees building concurrently |
|
||||
| **required fragments** | `must render within 200ms; took ` / `open() (parse + render) took ` + `spec budget is 100ms` |
|
||||
| **status** | **new incident, three selectors, none reproduced** |
|
||||
| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, no selector reds twice, and **the third run of the same command on the same tree was green on all 13 steps** (log `20260809T200907Z-2672209`). The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot |
|
||||
| **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding |
|
||||
| **relation to U6** | same shape, different step and different tests: U6 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows |
|
||||
|
||||
**The rotating selector is the thing to match.** A regression that
|
||||
moved between three unrelated render paths on an unchanged tree is far
|
||||
less likely than one loaded machine; a future run that reds the *same*
|
||||
one of these twice is a different incident and should be judged as one.
|
||||
|
||||
**The retirements are not occurrences and do not close the log.** R1 and
|
||||
R3 stay live, and each retired row keeps its signature so a later red
|
||||
matching one reopens it.
|
||||
|
|
@ -566,3 +692,110 @@ The #214 occurrence is the strongest available evidence that these are
|
|||
not caused by the PRs they appeared on — that PR is **docs-only and its
|
||||
tree is byte-identical to a green `main`**. It is not evidence that any
|
||||
of them is harmless.
|
||||
|
||||
### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS **both flavours**, three occurrences
|
||||
|
||||
Surfaced on PR #229's CI; twice more on PR #231's.
|
||||
|
||||
**The `lua54` in this row's original title was wrong as a signature
|
||||
component, and matching on it would have missed two occurrences.** The
|
||||
row was filed from #229's single `lua54` red and recorded the flavour in
|
||||
the matching key. #231 then reddened the identical selector with the
|
||||
identical three fragments **twice on `luajit`** — so flavour is not part
|
||||
of this signature, and the row's own caution that "a deterministic
|
||||
defect *can* be Lua-flavour-specific" is now settled in the other
|
||||
direction: this one is not. Occurrence-keyed by suffix length, the three
|
||||
are `25 362` (#229, `lua54`), `25 222` (#231 attempt 1, `luajit`) and
|
||||
`25 054` (#231 attempt 2, `luajit`).
|
||||
|
||||
**A fourth sighting of these fragments was NOT an occurrence and must
|
||||
not be counted as one.** It came from a deliberate bite during this
|
||||
test's own development — the defect reintroduced on purpose (`consumer
|
||||
ignores full_grid`), 34 831 bytes, failing in 20.09 s. It earns its
|
||||
place here for what it proves instead: **the genuine defect and these
|
||||
CI reds are signature-indistinguishable**, same message class and same
|
||||
full-timeout duration, so the fragments alone can never tell a real
|
||||
resync failure from whatever this is.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--test full_grid_resync_acceptance a_pty_resize_blanks_the_host_before_repainting` |
|
||||
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)` **and** `Test (macos-latest / luajit)`, `macos-26-arm64`. **Flavour is not a matching key for this row** |
|
||||
| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the` · `bytes emitted after the first painted frame` |
|
||||
| **NOT fragments** | the byte count and the `:LINE` suffix are **occurrence-specific** and must not be matched on — the count is the collected suffix length, which varies per run, and the line moves with the file |
|
||||
| **status** | **three occurrences on two branches; INTERMITTENT on #229 (passed on rerun), NOT observed to pass on #231 (0/2)** |
|
||||
| **the #231 control experiment, and what it does and does not license** | Five valid observations at #231's exact base `0190102` — `run_attempt` 1, 2, 3, 4 and 6 — **all green on both macOS flavours**, against #231's 0/2. Under an equal-rate model the chance both failures land on the two branch runs is 1/C(7,2) = **4.8%**. Two things bound that number. First, **attempt 5 was discarded** because it reddened a *different* selector (U8) — so the base leg is 5/5 green *for this signature* and 5/6 overall, and "the base never fails" is not what was observed. Second, three unrelated macOS selectors reddening in one session is **a background platform failure rate**, and the equal-rate model the 4.8% assumes is exactly what such a rate violates. **The branch side was never resampled**: 5-vs-2 is an asymmetric experiment, and rerunning #231's failing job three more times at `4654b94` was the outstanding discriminator when it merged |
|
||||
| **why #231's diff is excluded** | grepping its **entire** `src/` diff for `full_grid\|resize\|resync\|Geometry\|reconcile_panel_layout` matches **one import line** and nothing else; all 721 changed lines are placement, dedication and commit-contract logic. From the other side, `full_grid_resync_acceptance` (191 lines) contains no panel, side-window, dedication, display or directory surface — grep for those matches only a comment about CSI 2 J. #231 merged on this reading **over** the statistical signal above, which is a judgement recorded here so that a fourth occurrence can revisit it rather than re-derive it |
|
||||
| **why #229's diff is excluded** | #229 changes only `scripts/gate`, `tests/gate_script_acceptance.rs` and documentation — **no `src/`, and the workflow never invokes `scripts/gate`**. Decisively, `full_grid_resync_acceptance` runs **before** the changed gate suite, so even a cross-suite leaked-state path is not available. The `luajit` leg passing on the same commit is **corroboration only** — a deterministic defect *can* be Lua-flavour-specific, so that observation must not be used as a structural exclusion |
|
||||
| **what IS established** | **no blank was OBSERVED after the mark** within the test's fixed 20-second deadline. The collected suffix was the **entire** post-mark output (`suffix.len()`, 25 362 bytes on this occurrence — not a capped window; only the *displayed* head is truncated to 400 bytes), and that head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was painting |
|
||||
| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted after the deadline, or lost in transport is **open** — and "it never emitted the blank" is a claim this evidence does not support. **The failing run's ~20 s duration is the fixed `Duration::from_secs(20)` timeout**, so the spread against a fast passing run is mechanically determined and is **not** independent timing evidence |
|
||||
| **discriminating control — ASYMMETRIC, and only one direction concludes** | the suffix is already complete, so "capture more bytes" is not the gap — arrival time is. Extending the deadline and recording whether `CLEAR_ALL` arrives, and at what offset: **if it arrives, "emitted late" is established.** **If it does not, that establishes only "not observed by the longer deadline"** — *not* "never emitted", because transport loss produces the same absence. Separating non-emission from transport loss needs **producer-side emission evidence** (did pmacs write the clear?) cross-checked against the collected stream; no deadline, however long, can do it alone |
|
||||
|
||||
### U5 — `ctrl_c_during_reconnect_sleep_yields_clean_exit`, macOS `lua54`, one occurrence
|
||||
|
||||
Surfaced on the **rerun** of PR #229's failed job — a *different*
|
||||
selector from U4, so by this file's matching rule it is a **new
|
||||
incident, not U4 occurring twice**.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--test m5_8_acceptance ctrl_c_during_reconnect_sleep_yields_clean_exit` |
|
||||
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, rerun attempt 2 |
|
||||
| **required fragments** | `Ctrl-C during reconnect sleep should produce a clean exit` · `ExitStatus { code: 1, signal: Some("Interrupt: 2") }` |
|
||||
| **NOT a fragment** | the `:LINE` suffix — occurrence-specific, moves with the file |
|
||||
| **status** | **one occurrence, unresolved** |
|
||||
| **what IS established** | Ctrl-C reached the process **as `SIGINT`** rather than as the raw-mode key event the test drives. That is all the exit status shows |
|
||||
| **what is NOT** | whether injection preceded raw mode, raw mode was lost, or something else. Three mechanisms remain open and this fragment separates none of them |
|
||||
| **exclusion strength — WEAKER than U4's, deliberately** | the changed `gate_script_acceptance` ran **earlier in the same job**, and it creates worktrees and directories. No leaked child or persistent signal-state mutation was observed, but "the diff touches no `src/`" is **not** the argument here that it is for U4, because cross-suite leaked state is a path reachability reasoning does not close |
|
||||
| **control 1 — CROSS-SUITE ATTRIBUTION, and asymmetric** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. **A matching isolated RED proves the gate suite is not necessary** for the failure. **An isolated GREEN proves nothing beyond that run** — the failure is intermittent, so absence under one run is not evidence of dependence. It also does **not** discriminate among the three mechanisms in either direction |
|
||||
| **control 2 — mechanism** | observe **readiness and raw-mode state at the moment of injection**. Another isolated pass, however many times repeated, cannot separate "injected before raw mode" from "raw mode lost" from a third cause |
|
||||
|
||||
### U8 — `acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel`, macOS `luajit`, one occurrence, **fragments destroyed**
|
||||
|
||||
**Numbered U8 deliberately: U6 and U7 are reserved** for the two
|
||||
wall-clock rows on `worker-identity-stage1` (PR #232), which renumbered
|
||||
into that range when #229 took U4/U5. Taking U6 here would recreate the
|
||||
duplicate-id collision that rebase already produced once.
|
||||
|
||||
**This row exists mostly as an admission.** It surfaced on attempt 5 of
|
||||
a merge-base control at `0190102`, and **I reran the job before reading
|
||||
its log**, which discarded it. GitHub keeps only the latest attempt's
|
||||
logs for a rerun job. So this is U2's original condition exactly — a
|
||||
selector with no fragments, unmatchable — and it was produced by the
|
||||
very mistake U3 is named for.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--test bottom_panel_stage1_acceptance acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel` |
|
||||
| **job / flavor** | GitHub Actions, `Test (macos-latest / luajit)`, at base `0190102`, control attempt 5 |
|
||||
| **required fragments** | **NONE CAPTURED — destroyed by rerunning the job before reading its log.** Recovery attempted via the jobs API and the attempt-scoped jobs endpoint; the log is gone |
|
||||
| **what IS established** | it failed once (`46 passed; 1 failed`), panicking at `tests/bottom_panel_stage1_acceptance.rs:2454`, on the **exact merge base** — so it is not attributable to any open branch |
|
||||
| **what is NOT** | everything else. Without the assertion text this cannot be matched against a future occurrence, which is the whole purpose of a row here |
|
||||
| **why it matters anyway** | it is the **third distinct macOS selector** to red in one session, after U4 (`full_grid_resync`) and U5 (`ctrl_c_during_reconnect`). Three unrelated selectors failing on the macOS legs suggests a **background failure rate on that platform** rather than three independent test bugs — and that materially affects any equal-rate reasoning about which branch a failure "landed on" |
|
||||
| **next occurrence** | **read the log BEFORE rerunning anything.** That is U3's stated lesson and this row is its fourth violation |
|
||||
|
||||
### U9 — a PTY test and a budget test red **together** in one `11-sweep`, with an in-run control
|
||||
|
||||
Recorded on the `destination-capture` merge tree, 2026-08-10, in the
|
||||
gate run that was meant to clear PR #231.
|
||||
|
||||
**This row's value is its control, not its selectors.** U6 and U7 could
|
||||
only compare a red run against a *different* run. Here both selectors
|
||||
ran green **inside the same gate invocation**, minutes earlier, on the
|
||||
same tree and machine — `03-lib` (1928 passed, 0 failed) and
|
||||
`04-lib-crdt` (2113 passed, 0 failed) — and then failed in `11-sweep`.
|
||||
Whatever this is, it is not the tree.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--lib process::tests::m6_1_pty_canonical_mode_keeps_kernel_echo` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same `11-sweep` step |
|
||||
| **job / flavor** | local (Linux), `scripts/gate` step `11-sweep` (`cargo test --workspace --no-fail-fast -- --skip basedpyright`), fresh per-lane target dir, no sibling worktrees building |
|
||||
| **required fragments** | ``canonical mode should leave echo enabled (no `-echo` flag); stty -a output was: ""`` **and** `composition machinery added more than 10% overhead` |
|
||||
| **NOT fragments** | the measured numbers (`1.613`, `single=191935 ns`, `dispatch=309602 ns`) and every `:LINE` suffix — occurrence-specific |
|
||||
| **status** | **one occurrence; INTERMITTENT — the identical sweep command on the same tree was green (118 targets, 1928 passed, exit 0)** |
|
||||
| **what IS established** | intermittence, with the strongest available exclusion of the tree: green in two earlier steps of the **same run**, green isolated afterwards (`2 passed`, 1.70 s), green on a full sweep rerun. Both assertions are **timing-sensitive by construction** — one reads collected child output within a deadline, the other measures wall-clock composition overhead (observed 1.613× against a 1.10× budget; 61.3% dispatch and 124.6% realistic overhead) |
|
||||
| **what is NOT** | cause, and the load confound is **partially measured but NOT controlled**. The failing sweep ran inside a full gate; the green rerun started at load average 1.98 with the 5-minute figure still at 8.03 from that gate. Different conditions is not a measurement of the mechanism, and this row does not treat it as one |
|
||||
| **the structural difference worth testing next** | `cargo test --workspace` runs **many test binaries concurrently**; `--lib` runs **one**. That is a difference in kind between the passing steps and the failing one, not merely a difference in load average — and it is the first candidate this family has had that is checkable rather than atmospheric. **Discriminating control:** rerun the sweep with test-binary concurrency pinned to 1, and separately run the `--lib` binary alone under synthetic load. A red under synthetic load at low sweep concurrency implicates load; a red at high concurrency and low load implicates the concurrency itself |
|
||||
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible |
|
||||
| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed |
|
||||
| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,816 @@
|
|||
# A destination capture any async continuation can use
|
||||
|
||||
**Status: revision 9. The mechanism is implemented at `0efc8c0`; the
|
||||
correctness blocker revisions 6–9 carry is IMPLEMENTED, in revision 8's
|
||||
shape with revision 9's scope correction, and §3's enumeration is
|
||||
performed and recorded below.** Revisions 6 and 7 proposed fixes that
|
||||
review rejected; **neither is in the tree**, and the two paragraphs
|
||||
describing them are kept as the record of why this shape and not those.
|
||||
|
||||
*(Revisions 2–5 said "Pre-implementation. Awaiting approval" while the
|
||||
ledger recorded the lane as approved and implemented. Same
|
||||
contradiction class this document keeps correcting elsewhere, left
|
||||
standing in its own header.)*
|
||||
|
||||
**Revision 9 fixes a hole in revision 8's guard — one that is about the
|
||||
guard's SCOPE, not about which mutations it names.** Revision 8 refuses,
|
||||
inside a `"panel"` commit, the mutations that would make its relaxed
|
||||
preflight wrong. But a **nested `commit_to` REPLACED** the enclosing
|
||||
contract with its own and restored it afterwards (`src/editor.rs:129`,
|
||||
`src/lua_bindings/window_panel.rs`), so the outer restriction went out of
|
||||
force for the whole of the inner body. Review reproduced the sequence:
|
||||
an outer `"panel"` commit passes the relaxed preflight; a nested
|
||||
`"document"` commit masks its contract; the nested callback dedicates the
|
||||
side slot and **is not refused**; the outer commit resumes, its side
|
||||
request falls back, and it overwrites a newer document — the original
|
||||
P1a failure, reached through one extra call.
|
||||
|
||||
**What this invalidated, precisely.** *Not* §3's enumeration of
|
||||
dedication write sites. That enumeration was performed against the tree,
|
||||
it is still complete, and every site in it that can dedicate the slot is
|
||||
still guarded. What was wrong was the surrounding claim — that the guard
|
||||
was **in force for the whole outer body**. §3's "PREFLIGHT STAYS WHERE
|
||||
IT IS" paragraph and the enumeration that follows it are therefore kept
|
||||
and **qualified**, not withdrawn.
|
||||
|
||||
**The fix: contracts COMPOSE across nested scopes; the strictest active
|
||||
restriction wins.** The core holds a *stack* of contracts rather than one
|
||||
slot: `commit_to` pushes and pops rather than swapping, and the
|
||||
dedication guard consults **every** contract in force rather than the
|
||||
innermost. Matching stays per frontend, so a nested commit for a
|
||||
different frontend may still dedicate *its* side slot — that cannot
|
||||
change where this frontend's side request lands. The alternative shape,
|
||||
**prohibiting nested `commit_to` outright**, was rejected: it closes the
|
||||
hole by forbidding a construction no rule objects to. `commit_to` is
|
||||
public Lua API for saying where a continuation's result belongs, and a
|
||||
body that commits to a second destination (a diff beside a status panel)
|
||||
is where #227's adoption is heading. Only the *restriction* needed
|
||||
preserving. **Detecting the dedication when the outer commit resumed was
|
||||
not available**: by then the mutation has happened, which is a late
|
||||
refusal, which is what revision 7 was rejected for.
|
||||
|
||||
**Revision 8 rejects BOTH of the previous two fixes and takes a third
|
||||
shape.** Revision 6 predicted the fallback at preflight (the body can
|
||||
change it). Revision 7 moved enforcement to the placement boundary —
|
||||
which **breaks the invariant `commit_to` exists for**:
|
||||
`docs/agent-handoff.md:748` says it preflights *before* the callback
|
||||
because "validating at display time is four mutations too late", so a
|
||||
placement-time refusal arrives after arbitrary Lua has created buffers,
|
||||
handles and paint. Revision 8 keeps the preflight and **refuses the
|
||||
mutations that would invalidate it**, the same shape as the existing
|
||||
await refusal. Refusal stays mutation-free on the `(false, reason)`
|
||||
path.
|
||||
|
||||
**Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave
|
||||
the panel profile only check 1, on the claim that a panel result never
|
||||
touches a document window. **Panel placement falls back to an ordinary
|
||||
document window** when the frontend is not panel-capable or its side
|
||||
slot is dedicated — so a `"panel"` commit could replace a *newer*
|
||||
document while skipping every stale-intent guard. Reproduced in review.
|
||||
The relaxation is now conditional on the placement really being a
|
||||
panel. Revision 6 also closes an invalid-UTF-8 hole in the profile
|
||||
diagnostic — the same reachability class as revision 5's, one layer
|
||||
down.
|
||||
|
||||
**Revision 5 fixes a binding-level contradiction in revision 4's own
|
||||
API spec.** It required `profile: Option<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 2–4** — otherwise the fix has quietly collapsed the two
|
||||
profiles into one and the parameterization buys nothing.
|
||||
- **An unrecognized profile string is REFUSED**, with a message naming
|
||||
the accepted values — not silently treated as `"document"`.
|
||||
- **An invalid-UTF-8 profile is refused by that SAME message.** Lua
|
||||
strings are byte strings, so a `string.char(255)` profile reaches
|
||||
`to_str()` and produces mlua's generic conversion error *before*
|
||||
the documented message is ever constructed — the same reachability
|
||||
class as the `Option<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.
|
||||
|
|
@ -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 v12–v22.** 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.1–3.2**
|
||||
|
||||
No longer open, and the revision-1 answer was wrong. "Gate the richer
|
||||
form at `>= 23`" would have **removed the minibuffer entirely** from
|
||||
every v12–v22 peer, because there would have been only one variant to
|
||||
gate. Compatibility requires the legacy shape to still exist and still
|
||||
be sent — hence the additive `MinibufferPromptRows` variant, a
|
||||
per-peer `peer_knows_minibuffer_rows` producer gate, **one per-peer
|
||||
minibuffer cache**, and matched open/close families.
|
||||
|
||||
*(Revision 2 said "per-variant cache keys" here. §3.2 corrected that in
|
||||
revision 3 — the render state is per peer with its version baked in, so
|
||||
a cache cannot span two versions — and this sentence was left stale.)*
|
||||
|
||||
The `CompletionPopup` gate I proposed copying (`daemon-gated >= 15`)
|
||||
**is** the right precedent for *how to select per peer*; it is not a
|
||||
precedent for changing a live variant's shape, because that variant was
|
||||
new when it was gated.
|
||||
|
||||
### Q#D2-5 — does this tempt closed-set acceptance? **(a trap)**
|
||||
|
||||
The discovery lane's own handoff note warns: **completion is
|
||||
assistance, not validation** — `resolve_accepted_value` returns the
|
||||
literal typed text when no candidate is selected, so closed-set
|
||||
acceptance is unbuilt Rust work.
|
||||
|
||||
Richer rows make M-x *look* like a closed set, which invites someone to
|
||||
make acceptance reject unmatched input. **That is out of scope and
|
||||
would be a behaviour change**, not a rendering one. Stated here because
|
||||
the temptation arrives with the feature.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- **A command's description reaches the GPU row**, asserted through
|
||||
the real prompt path rather than by constructing a message.
|
||||
- **A v22 peer still receives `MinibufferPrompt`, with its old
|
||||
encoding** — the case revision 1 would have broken. Asserted by
|
||||
negotiating v22 and observing the legacy variant arrive, **not** by
|
||||
observing "no error".
|
||||
- **A v23 peer receives `MinibufferPromptRows` and NOT the legacy
|
||||
variant** — the double-render guard.
|
||||
- **LITERAL POSTCARD BYTE FIXTURES for the legacy variant**, open and
|
||||
clear: `assert_eq!(encoded, LEGACY_BYTES)` against a constant.
|
||||
|
||||
**Revision 2 proposed a round-trip and that freezes nothing.** A
|
||||
round-trip encodes and decodes with the *same* types, so adding a
|
||||
field to `MinibufferPrompt` leaves it passing — both sides simply
|
||||
learn the new shape, while every v12–v22 peer in the field breaks.
|
||||
The existing `minibuffer_prompt_round_trips_through_postcard`
|
||||
(`src/protocol.rs:2363`) is exactly that kind of test, and **there
|
||||
are no literal byte fixtures anywhere in the protocol tests today** —
|
||||
checked, not assumed.
|
||||
|
||||
Only comparing against bytes captured *now* can fail when the
|
||||
encoding changes. Two fixtures: an open prompt with candidates and a
|
||||
selection, and a cleared band — the two shapes the existing semantic
|
||||
test already covers, so the corpus is not a new judgement call.
|
||||
- **No cross-version cache test.** Revision 2 required one; it asserts
|
||||
a condition this architecture makes impossible (§3.2), and a test
|
||||
that cannot fail passes forever while teaching the next reader that
|
||||
the hazard is real. What *is* asserted is the producer gate: a v22
|
||||
peer and a v23 peer attached simultaneously each receive their own
|
||||
variant and only their own.
|
||||
- **Close matches open**: a `MinibufferPromptRows` session is closed by
|
||||
its own family, witnessed by the popup actually clearing.
|
||||
- **The TUI renders `name — description` for the selected candidate**
|
||||
(§3.4), from the local registry, with **no wire involvement**.
|
||||
- **TUI clipping is witnessed at THREE widths** (§3.4): wide enough
|
||||
for name + description; wide enough for the name only (description
|
||||
dropped, `[name]` as today); and **too narrow for even the whole
|
||||
name — the suffix vanishes entirely**. The last is the case revision
|
||||
2's rule could not express, and the assertion is that no *prefix* of
|
||||
a name is ever emitted.
|
||||
- **A source with no detail renders exactly as before** — the
|
||||
file-path prompt is the witness (Q#D2-2).
|
||||
- **Typed-but-unmatched input is still accepted** (Q#D2-5) — the
|
||||
guard against this lane quietly becoming a validation change.
|
||||
- **The version-bump discipline**: `ADVERTISED_PROTOCOL_VERSION`
|
||||
unchanged at 20, and the tripwire assertions updated **knowingly**.
|
||||
Handoff §3 requires the strengthened two-configuration sweep for a
|
||||
`PROTOCOL_VERSION` change — `scripts/gate --protocol`, which exists
|
||||
precisely for this.
|
||||
|
||||
**What this will not prove:** that `Command` carries title or category
|
||||
(not in scope), or that predicates are evaluated (Stage 3+).
|
||||
|
||||
## 7. Not in scope
|
||||
|
||||
`Command` gaining title/category/aliases/flags/arg-schema — the
|
||||
~175-site change, and the lane's next candidate. **A multi-row TUI
|
||||
chooser** (§3.4) — a new interaction surface and materially larger than
|
||||
this lane. **Changing `MinibufferPrompt`'s existing shape** — it is
|
||||
frozen for v12–v22. Predicate evaluation,
|
||||
which makes commands stop being invocable and needs its own decision at
|
||||
each call site. Help-layer unification (`src/help.rs` is still
|
||||
orphaned). The help prefix key — `C-h` is **not** free, since non-kitty
|
||||
terminals cannot disambiguate Ctrl+Backspace from Ctrl+H (both are
|
||||
byte 0x08). Closed-set acceptance (Q#D2-5).
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
# LSP language coverage: LaTeX (and the Haskell/OCaml question)
|
||||
|
||||
**Status: revision 3. Implemented at `d79afdc`; step zero discharged by observation, and its result corrected two things this document had wrong.**
|
||||
|
||||
*Recorded precisely: the user authorized dispatch after a summary of
|
||||
revision 2's four corrections, rather than returning findings on the
|
||||
document as they did for the other lanes. The §3 verification caveat is
|
||||
therefore still live and binding — it is step zero, not a footnote.*
|
||||
|
||||
**Revision 2 corrects three facts revision 1 got wrong or stale, and
|
||||
answers the question revision 1 named as most likely to make the entry
|
||||
wrong in practice.** Haskell's server *is* installed; Slice 1 is
|
||||
**smaller** than framed because the extension wiring already exists;
|
||||
Q#LX3's deferral argument rests on a `COHERENCE.md` line the same
|
||||
document contradicts twice; and Q#LX2 (the LaTeX root) now has a
|
||||
proposal rather than a shrug.
|
||||
|
||||
**Revision 1 was untracked, on `main`, in one checkout.** Per the
|
||||
handoff's own rule — work is portable only after it is committed and
|
||||
pushed — it did not travel. That is fixed by this branch.
|
||||
|
||||
---
|
||||
|
||||
## 0. What prompted this
|
||||
|
||||
An audit of the host machine against `builtin/runtime/lsp.lua`. pmacs
|
||||
configures LSP for fourteen languages — verified exactly, by extracting
|
||||
the `pmacs.lsp.config.*` keys:
|
||||
|
||||
bash c cmake cpp cuda dockerfile go json lua
|
||||
python rust toml yaml zig
|
||||
|
||||
**Lean is NOT among the gaps, and revision 1's first draft wrongly said
|
||||
it was.** Lean 4 has `builtin/runtime/lean.lua`, `lean_abbrev.lua` and
|
||||
`lean_input.lua` (all three present), an `arborium-lean` grammar, and
|
||||
comment/typed-edit integration — Arc 8 Stages 1–4b, merged. The error
|
||||
came from grepping `lsp.lua` alone, which is the wrong place to look
|
||||
for a language that earned its own module.
|
||||
|
||||
## 1. The gap, re-measured
|
||||
|
||||
| Language | tree-sitter | LSP config | Server on this machine |
|
||||
|---|---|---|---|
|
||||
| LaTeX | ✅ grammar + `builtin/queries/latex/highlights.scm` | ❌ | **`texlab` 5.25.1 — installed** |
|
||||
| Haskell | ❌ | ❌ | **`haskell-language-server` — INSTALLED** |
|
||||
| OCaml | ❌ | ❌ | `ocaml`/`opam`/`dune` yes, `ocaml-lsp-server` **absent** |
|
||||
|
||||
**Correction: revision 1 said Haskell's server was missing.** Both
|
||||
`haskell-language-server` and `haskell-language-server-wrapper` are on
|
||||
this machine. That collapses revision 1's Slice 1 / Slice 2 split,
|
||||
which rested on "Slice 2 needs servers installed first" — only OCaml
|
||||
does now.
|
||||
|
||||
LaTeX remains the sharp case: the grammar work landed, so a `.tex`
|
||||
buffer highlights correctly **and** offers no completion, no
|
||||
diagnostics, no go-to-definition, while `texlab` sits on disk unused.
|
||||
|
||||
## 2. Ground truth — Slice 1 is smaller than revision 1 claimed
|
||||
|
||||
Revision 1 proposed "one `pmacs.lsp.config.latex` entry, **plus**
|
||||
filetype mappings for `.tex`/`.latex`/`.sty`/`.cls`, matching the
|
||||
grammar's existing extension set so highlighting and LSP agree on what
|
||||
a LaTeX file is."
|
||||
|
||||
**The filetype mappings are redundant, and the rationale describes a
|
||||
problem that cannot occur.** Three facts, read rather than assumed:
|
||||
|
||||
- **The grammar already carries exactly those extensions.**
|
||||
`src/syntax.rs:1110-1112` — `name: "latex"`,
|
||||
`extensions: &["tex", "latex", "sty", "cls"]`.
|
||||
- **Grammar-extension detection sits AHEAD of the LSP filetype map.**
|
||||
The merged `docs/latex-grammar-math-substrate-framing.md:166-171`
|
||||
states the chain — *modeline → grammar extension → LSP filetype map →
|
||||
filename map → shebang* — and concludes that adding those extensions
|
||||
"**wires the whole chain with no Lua edit**".
|
||||
- **The filetype map is explicitly a fallback.** `lsp.lua:267-270`:
|
||||
"Every language with an LSP config now also ships a grammar, so this
|
||||
is mainly the **LSP-only fallback** that keeps a language id stable if
|
||||
a grammar is ever dropped, plus the seam for user-added mappings."
|
||||
|
||||
So a `.tex` buffer **already** resolves to language `latex`. They
|
||||
cannot disagree, because the grammar's extension list *is* what drives
|
||||
detection.
|
||||
|
||||
**Slice 1 is therefore one thing: the `pmacs.lsp.config.latex` entry**
|
||||
(plus its root resolver, §3). Filetype-map entries may still be added
|
||||
as the documented drop-a-grammar fallback, but that is belt-and-braces
|
||||
and should be labelled as such rather than sold as making two systems
|
||||
agree.
|
||||
|
||||
## 3. Q#LX2 — the LaTeX project root **(answered in rev 2)**
|
||||
|
||||
Revision 1 called this "the question most likely to make the entry
|
||||
wrong in practice" and left it open. It is the difference between
|
||||
texlab serving a multi-file thesis and serving isolated files, so it is
|
||||
the whole value of the lane for the stated use case.
|
||||
|
||||
**The mechanism exists.** `pmacs.lsp.config.<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).
|
||||
|
|
@ -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 3–4): a handler dispatching two jobs
|
||||
gives both its name; a handler dispatching through another registered
|
||||
handler gives the inner jobs the inner name and restores the outer.
|
||||
- **Composition, not replacement** (rule 6): a handler calling a
|
||||
dispatcher that supplies its own purpose yields `"<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.
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1098,7 +1098,24 @@ pub enum InstanceMessage {
|
|||
/// v12). The minibuffer is a single *global* core instance, so this
|
||||
/// is bufferless; the producer still emits it from the active-buffer
|
||||
/// viewport. `prompt: None` clears the GUI. Cached-compare
|
||||
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
|
||||
/// suppressed like `SearchPrompt`; daemon-gated `12..=22`.
|
||||
///
|
||||
/// # FROZEN — this variant's encoding must not move
|
||||
///
|
||||
/// Discovery Stage 2 (v23) needed richer rows, and postcard is not
|
||||
/// self-describing: enum variants encode by index and fields by
|
||||
/// position, so widening `candidates` in place would make every
|
||||
/// v12–v22 peer **mis-decode** these bytes rather than ignore them.
|
||||
/// Gating the widened shape at `>= 23` would not rescue them either
|
||||
/// — with only one variant to send, they would receive no minibuffer
|
||||
/// message at all. So the rich form went into a new appended
|
||||
/// variant, [`Self::MinibufferPromptRows`], and this one is retained
|
||||
/// unchanged as what a `12..=22` peer receives.
|
||||
///
|
||||
/// Its bytes are pinned literally by
|
||||
/// `minibuffer_prompt_v12_wire_bytes_are_frozen` in
|
||||
/// `src/protocol.rs` — a round-trip cannot detect a field addition,
|
||||
/// because both sides simply learn the new shape.
|
||||
MinibufferPrompt {
|
||||
/// The prompt string (e.g. `"M-x "`), or `None` when no
|
||||
/// minibuffer is open.
|
||||
|
|
@ -1299,6 +1316,59 @@ pub enum InstanceMessage {
|
|||
/// Whether that buffer's long lines wrap.
|
||||
wrap: bool,
|
||||
},
|
||||
/// Discovery Stage 2 (protocol v23): the minibuffer prompt with
|
||||
/// **structured rows** — a label and an optional one-line detail —
|
||||
/// instead of bare candidate strings.
|
||||
///
|
||||
/// # Why a second variant rather than a wider `MinibufferPrompt`
|
||||
///
|
||||
/// `Command.description` already exists and is already rendered by
|
||||
/// `help.list-commands`; it is missing at the one moment it would
|
||||
/// change a decision, which is the `M-x` row. Carrying it means
|
||||
/// widening the minibuffer's candidate shape — and postcard encodes
|
||||
/// fields **positionally**, so changing `candidates: Vec<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
|
||||
|
|
|
|||
157
scripts/gate
157
scripts/gate
|
|
@ -3,11 +3,15 @@
|
|||
# directory, with isolated ambient roots and durable logs.
|
||||
#
|
||||
# scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan]
|
||||
# scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named
|
||||
# scripts/gate --print-target-dir
|
||||
# scripts/gate --init
|
||||
# scripts/gate --prune [--force]
|
||||
# scripts/gate --self-test
|
||||
#
|
||||
# Framing: docs/gate-script-framing.md (revision 4, approved).
|
||||
# Framing: docs/gate-script-framing.md (revision 4, approved), and
|
||||
# docs/gate-protocol-build-framing.md (revision 5, approved) for the
|
||||
# crdt build step, --self-test and --print-plan-named.
|
||||
#
|
||||
# WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one
|
||||
# CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK
|
||||
|
|
@ -37,9 +41,11 @@ set -eu
|
|||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan]
|
||||
scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named
|
||||
scripts/gate --print-target-dir
|
||||
scripts/gate --init
|
||||
scripts/gate --prune [--force]
|
||||
scripts/gate --self-test
|
||||
|
||||
--acceptance SUITE a touched acceptance suite to run (repeatable).
|
||||
docs/agent-handoff.md section 3 stays authoritative
|
||||
|
|
@ -48,8 +54,15 @@ usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan]
|
|||
working tree, and one that guessed would report
|
||||
coverage it does not have.
|
||||
--protocol the change touches PROTOCOL_VERSION; adds the CRDT
|
||||
workspace sweep on top of the default one.
|
||||
--print-plan print the exact gate commands and exit.
|
||||
workspace sweep on top of the default one, plus
|
||||
the build that sweep needs (see build-crdt below).
|
||||
--print-plan print the exact gate commands and exit. Names are
|
||||
stripped, so every line is runnable as printed.
|
||||
--print-plan-named print the plan as `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=''
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
147
src/command.rs
147
src/command.rs
|
|
@ -66,7 +66,28 @@ impl SourceLocation {
|
|||
pub struct Command {
|
||||
/// Unique name (e.g. `buffer.save`).
|
||||
pub name: String,
|
||||
/// One-line human-readable description (R42, required).
|
||||
/// Human-readable description. Required and non-empty after trim
|
||||
/// (R42), but otherwise **free-form, and legitimately multi-line**.
|
||||
///
|
||||
/// # Do not add a registration-time one-line guard
|
||||
///
|
||||
/// This doc used to read "one-line human-readable description",
|
||||
/// which was an aspiration rather than the contract: MCP tool
|
||||
/// registration renders a whole schema block in here — the tool's
|
||||
/// text, a blank line, `Arguments:`, then one line per argument
|
||||
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
|
||||
/// `table.concat(lines, "\n")`) — and `m9_6_acceptance.rs:583-598`
|
||||
/// asserts all four of those lines. Rejecting CR/LF in
|
||||
/// [`CommandRegistry::define`] was tried, measured, and abandoned:
|
||||
/// it fails 36 tests across `m9_6`/`m9_7`/`m9_8` and could only be
|
||||
/// made green by deleting a shipped acceptance criterion.
|
||||
///
|
||||
/// The one-line constraint belongs to the **surfaces that have
|
||||
/// it**, so a consumer rendering into a single row clips with
|
||||
/// [`Self::description_first_line`] — the minibuffer band and the
|
||||
/// completion dropdown both do. The full text stays intact for
|
||||
/// `describe-command` and `help.list-commands`, which is what keeps
|
||||
/// this a rendering decision rather than data loss.
|
||||
pub description: String,
|
||||
/// Where the command was defined.
|
||||
pub source: SourceLocation,
|
||||
|
|
@ -79,6 +100,53 @@ pub struct Command {
|
|||
pub predicate: Option<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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
143
src/editor.rs
143
src/editor.rs
|
|
@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
|
|||
|
||||
use crate::async_runtime::SharedAsyncRuntime;
|
||||
use crate::cell::{CellCoord, CellSize};
|
||||
use crate::editor_core::{EditorCore, GeometryUpdate};
|
||||
use crate::editor_core::{CommitContract, EditorCore, GeometryUpdate};
|
||||
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
||||
use crate::key::{Chord, display_sequence};
|
||||
use crate::keymap_stack::{Action, KeyDispatcher};
|
||||
|
|
@ -119,20 +119,34 @@ impl ScopedFrontend {
|
|||
}
|
||||
|
||||
/// Enter a background frontend scope, also swapping the core's
|
||||
/// ambient `active_frontend`. Both are restored on drop, on every
|
||||
/// exit path including a raising callback.
|
||||
/// ambient `active_frontend` and **pushing** `contract`. All three are
|
||||
/// restored on drop, on every exit path including a raising callback.
|
||||
///
|
||||
/// The frontend comes from `contract.destination` rather than being
|
||||
/// passed separately: a scope entered for one frontend while carrying
|
||||
/// another's destination would let the placement guard check the
|
||||
/// wrong window, and there is no caller that wants them to differ.
|
||||
///
|
||||
/// **The contract is pushed, not swapped (Q#DC-2, revision 9).** The
|
||||
/// frontend override and the ambient frontend are *substitutions* —
|
||||
/// an inner scope means what it says and the outer one resumes
|
||||
/// afterwards — but a contract is a *restriction*, and a nested scope
|
||||
/// masking one would suspend it for the extent of the inner body
|
||||
/// while the outer commit's relaxed preflight still depended on it.
|
||||
/// See [`crate::editor_core::EditorCore::push_commit_contract`].
|
||||
pub(crate) fn enter(
|
||||
&self,
|
||||
core: &SharedCore,
|
||||
commit_scope: &CommitScopeActive,
|
||||
frontend_id: FrontendId,
|
||||
contract: CommitContract,
|
||||
) -> ScopedFrontendGuard {
|
||||
let frontend_id = contract.destination.frontend;
|
||||
let previous = self.0.replace(Some(frontend_id));
|
||||
let previous_active = {
|
||||
let (previous_active, contract_depth) = {
|
||||
let mut core = core.borrow_mut();
|
||||
let was = core.active_frontend;
|
||||
core.active_frontend = frontend_id;
|
||||
was
|
||||
(was, core.push_commit_contract(contract))
|
||||
};
|
||||
let previous_commit = commit_scope.0.replace(true);
|
||||
ScopedFrontendGuard {
|
||||
|
|
@ -140,6 +154,7 @@ impl ScopedFrontend {
|
|||
core: core.clone(),
|
||||
previous,
|
||||
previous_active,
|
||||
contract_depth,
|
||||
commit_scope: commit_scope.clone(),
|
||||
previous_commit,
|
||||
}
|
||||
|
|
@ -151,6 +166,15 @@ pub(crate) struct ScopedFrontendGuard {
|
|||
core: SharedCore,
|
||||
previous: Option<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;
|
||||
|
|
|
|||
|
|
@ -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 2–4 —
|
||||
/// **for as long as its result really lands in a panel**. The
|
||||
/// preflight grants the relaxation only when a fallback into a
|
||||
/// document window is impossible ([`EditorCore::commit_destination_refusal`]),
|
||||
/// and what keeps that measurement true for the body's whole extent
|
||||
/// is that the mutations which would manufacture a fallback are
|
||||
/// refused at the attempt
|
||||
/// (`EditorCore::panel_commit_dedication_refusal`).
|
||||
Panel,
|
||||
}
|
||||
|
||||
/// The contract a `commit_to` body is running under, published on the
|
||||
/// core so the mutations that could invalidate it can consult it
|
||||
/// (Q#DC-2, revisions 8 and 9).
|
||||
///
|
||||
/// **Why this exists rather than a preflight prediction.** Revision 6
|
||||
/// tried to decide at preflight whether a `"panel"` commit's placement
|
||||
/// could fall back into a document window, on the argument that nothing
|
||||
/// could change in between because the body cannot `await`. Refusing
|
||||
/// `await` stops another coroutine interleaving; it says nothing about
|
||||
/// the body itself, which is arbitrary Lua running synchronously and can
|
||||
/// change the very state the snapshot measured — obtain the panel, set
|
||||
/// it `dedicated`, then request a side display. A snapshot cannot bind
|
||||
/// that. So the preflight stays where it is and the contract is what
|
||||
/// lets those mutations be **refused at the attempt**, which is the only
|
||||
/// point early enough to leave nothing behind
|
||||
/// (`EditorCore::panel_commit_dedication_refusal`).
|
||||
///
|
||||
/// Pushed and popped by the same guard that scopes the frontend, so the
|
||||
/// two can never disagree about whether a commit is on the stack.
|
||||
/// **Pushed** rather than swapped: a contract is a restriction, and a
|
||||
/// nested `commit_to` must add to the ones in force rather than mask
|
||||
/// them for the extent of its body
|
||||
/// (`EditorCore::push_commit_contract`, revision 9).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CommitContract {
|
||||
/// The destination the continuation captured.
|
||||
pub destination: ViewDestination,
|
||||
/// What that continuation declared it depends on.
|
||||
pub profile: CommitProfile,
|
||||
}
|
||||
|
||||
/// A `display_buffer` request (Q#BP3).
|
||||
|
|
@ -620,6 +700,34 @@ pub struct EditorCore {
|
|||
/// slot; the producer clears any untaken record when the fan-out
|
||||
/// returns.
|
||||
typed_edit_armed: Option<(FrontendId, TypedEditRecord)>,
|
||||
/// Every `commit_to` contract currently on the stack, outermost
|
||||
/// first (Q#DC-2, revision 9).
|
||||
///
|
||||
/// **A STACK, NOT A SLOT, and that is the whole of revision 9's
|
||||
/// fix.** Revision 8 held one contract and had a nested `commit_to`
|
||||
/// replace it for the inner body's extent. That MASKED the enclosing
|
||||
/// contract: an outer `"panel"` commit took the relaxed preflight,
|
||||
/// its body opened a nested `"document"` commit, and inside that
|
||||
/// nested body the very mutation the outer commit's relaxation
|
||||
/// depends on — dedicating the one side slot — was no longer refused,
|
||||
/// because the guard consulted only the innermost contract. The outer
|
||||
/// commit then resumed and fell back into the document window,
|
||||
/// overwriting a newer buffer, which is exactly the defect the panel
|
||||
/// profile's relaxation was made safe against.
|
||||
///
|
||||
/// So restrictions **compose** rather than replace: a contract is
|
||||
/// pushed for its body and popped after, and every restriction
|
||||
/// pushed by an enclosing commit stays in force for the whole of it,
|
||||
/// nested scopes included. See
|
||||
/// [`Self::panel_commit_dedication_refusal`], the one reader.
|
||||
///
|
||||
/// Private and `pub(crate)`-free on purpose: entries are pushed only
|
||||
/// by [`crate::editor::ScopedFrontend::enter`]'s guard, which
|
||||
/// truncates back to its own depth on every exit path including a
|
||||
/// raising body. Nothing outside this crate can push one, so a
|
||||
/// `"panel"` profile is not something Lua can claim for a placement
|
||||
/// it did not commit to.
|
||||
commit_contracts: Vec<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 2–4 on the strength of "a
|
||||
/// panel result never touches a document window". Panel placement
|
||||
/// **falls back** into an ordinary document window when the frontend
|
||||
/// is not `panel_capable` or its one side slot is dedicated elsewhere
|
||||
/// ([`Self::apply_placement`] says so in its own comment), and then
|
||||
/// installs the result there. So a `"panel"` commit that reached a
|
||||
/// fallback would replace a document view with no stale-intent guard:
|
||||
/// capture A, the user opens B, the continuation lands, B is gone.
|
||||
///
|
||||
/// # Why this shape, and not the two that were tried first
|
||||
///
|
||||
/// * **Predicting the fallback at preflight is unsound.** The body is
|
||||
/// arbitrary *synchronous* Lua and can create the condition itself.
|
||||
/// Refusing `await` inside the commit scope stops a second
|
||||
/// coroutine interleaving; it places no restriction on the body's
|
||||
/// own statements.
|
||||
/// * **Refusing at the placement boundary is too late.** `commit_to`
|
||||
/// preflights *before* invoking the callback precisely because a
|
||||
/// body creates buffers, registers handles and paints long before
|
||||
/// it asks to display anything — "validating at display time is
|
||||
/// four mutations too late" (`docs/agent-handoff.md`). A refusal
|
||||
/// arriving after all of that is not a refusal; it is a partial
|
||||
/// commit with an error return.
|
||||
///
|
||||
/// So the preflight stays where it is and **the mutation that would
|
||||
/// invalidate it is rejected** — the same shape as `Handle:await`
|
||||
/// being refused inside a commit scope, for the identical reason.
|
||||
/// With these refused, the preflight measurement cannot go stale, the
|
||||
/// fallback never comes into existence, and nothing needs refusing
|
||||
/// late.
|
||||
///
|
||||
/// # Every enclosing contract, not just the innermost (revision 9)
|
||||
///
|
||||
/// This scans the whole contract stack. Revision 8 read a single
|
||||
/// slot, and a nested `commit_to` replaced it — so an outer
|
||||
/// `"panel"` commit whose body opened a nested `"document"` commit
|
||||
/// had its restriction **masked** for that body's extent, and the
|
||||
/// nested callback could dedicate the side slot the outer relaxation
|
||||
/// rests on. The outer commit then resumed and fell back into the
|
||||
/// document window, overwriting a newer buffer: the original defect,
|
||||
/// reachable through one extra call. Detecting it when the outer
|
||||
/// commit resumed would have been a late refusal, which revision 7
|
||||
/// was already rejected for. The restriction has to hold for the
|
||||
/// whole body, so **the strictest active restriction wins** and
|
||||
/// nesting is otherwise untouched.
|
||||
///
|
||||
/// Matching is per **frontend**, not per stack: a nested commit for a
|
||||
/// *different* frontend may dedicate *its* side slot, because that
|
||||
/// cannot change where this frontend's side request lands.
|
||||
///
|
||||
/// # The enumeration this rests on
|
||||
///
|
||||
/// [`Self::resolve_placement`] can only reach
|
||||
/// [`PlacementKind::Ordinary`] from a side request in two ways, so
|
||||
/// only two pieces of state matter:
|
||||
///
|
||||
/// 1. `FrontendView::panel_capable` is false. It is written **only**
|
||||
/// where a `FrontendView` is constructed, and no `FrontendView` is
|
||||
/// constructed, registered or unregistered anywhere in
|
||||
/// `src/lua_bindings/` — that is the daemon's attach path. **A
|
||||
/// body cannot reach it at all.**
|
||||
/// 2. The frontend's one side slot exists **and is dedicated** to a
|
||||
/// different buffer. `Window::params.dedicated` is the only
|
||||
/// remaining lever, and every write to it is guarded or harmless:
|
||||
/// the two in `apply_placement`'s `Ordinary` arm target a document
|
||||
/// window (never a side one — every `Ordinary` target is filtered
|
||||
/// `!is_side`) and one of them only ever clears the flag; the
|
||||
/// three in its `Side` arm and the one in `pmacs.window.set_params`
|
||||
/// are the attempts refused here; and `quit_window` restoring a
|
||||
/// saved `dedicated: true` presentation is refused too.
|
||||
///
|
||||
/// **Losing the side window is NOT a route** and was checked rather
|
||||
/// than assumed: with no side leaf, `side_window_for` returns `None`
|
||||
/// and `resolve_placement` **creates** a fresh panel instead of
|
||||
/// falling back. Closing or hiding the panel mid-commit is therefore
|
||||
/// safe, and `panel_hidden` is not consulted by placement at all.
|
||||
/// `params.side` is likewise unreachable — `set_params` refuses it,
|
||||
/// and only `apply_placement`'s created branch ever writes it, so a
|
||||
/// body cannot turn an already-dedicated document window into the
|
||||
/// side slot.
|
||||
///
|
||||
/// # What is deliberately NOT refused
|
||||
///
|
||||
/// * **The document profile is untouched.** Its preflight already
|
||||
/// checked the same destination, and constraining its body would
|
||||
/// newly refuse dired's own documented panel path.
|
||||
/// * **Dedicating a *document* window is fine.** It cannot change
|
||||
/// which of panel-or-document a side request resolves to.
|
||||
/// * **Falling back is still allowed.** A frontend that cannot render
|
||||
/// a panel degrades gracefully exactly as it does today; this
|
||||
/// refuses the *mutation that manufactures* a fallback, never the
|
||||
/// fallback itself.
|
||||
/// * **Nesting is untouched.** Only the mutation is refused, not the
|
||||
/// nested `commit_to` that reaches it, so a nested commit that does
|
||||
/// not dedicate this frontend's side slot runs exactly as before.
|
||||
/// Prohibiting nesting outright would have closed the hole by
|
||||
/// forbidding a shape no rule objects to (revision 9).
|
||||
pub(crate) fn panel_commit_dedication_refusal(&self, fid: FrontendId) -> Option<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
21
src/lsp.rs
21
src/lsp.rs
|
|
@ -167,7 +167,11 @@ impl LspServerSpec {
|
|||
}
|
||||
|
||||
fn to_process_spec(&self) -> ProcessSpec {
|
||||
let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command);
|
||||
let mut p = ProcessSpec::new(
|
||||
format!("lsp:{}", self.label),
|
||||
&self.command,
|
||||
format!("language server for {}", self.label),
|
||||
);
|
||||
p.args.clone_from(&self.args);
|
||||
p.cwd.clone_from(&self.cwd);
|
||||
p.env.clone_from(&self.env);
|
||||
|
|
@ -1587,9 +1591,15 @@ impl LspManager {
|
|||
uri: &str,
|
||||
) -> JobId {
|
||||
let supersede = format!("lsp:{method}:{}:{uri}", sid.raw());
|
||||
let (job_id, token) = self
|
||||
.runtime
|
||||
.register_external(JobKind::LspRequest, Some(&supersede));
|
||||
// Worker identity Stage 1: `register_external` bypasses the
|
||||
// worker pool, so its `JobKind` is the undifferentiated
|
||||
// `LspRequest` for every method. The method and the document are
|
||||
// the only thing that makes one row distinguishable from another
|
||||
// in `*workers*`.
|
||||
let purpose = format!("lsp {method} {uri}");
|
||||
let (job_id, token) =
|
||||
self.runtime
|
||||
.register_external(JobKind::LspRequest, Some(&supersede), purpose);
|
||||
self.pending_external.insert(
|
||||
(sid, req_id),
|
||||
PendingExternal {
|
||||
|
|
@ -4538,7 +4548,8 @@ mod resource_reconciliation_tests {
|
|||
let runtime = mgr.runtime.clone();
|
||||
|
||||
let mut register = |rid: u64, uri: &str| {
|
||||
let (job_id, token) = runtime.register_external(JobKind::LspRequest, None);
|
||||
let (job_id, token) =
|
||||
runtime.register_external(JobKind::LspRequest, None, format!("lsp hover {uri}"));
|
||||
mgr.pending_routes.insert(
|
||||
(a, rid),
|
||||
ResponseRoute::Hover {
|
||||
|
|
|
|||
|
|
@ -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)?)?;
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
|
|
|
|||
38
src/mcp.rs
38
src/mcp.rs
|
|
@ -175,7 +175,11 @@ impl McpServerSpec {
|
|||
}
|
||||
|
||||
fn to_process_spec(&self) -> ProcessSpec {
|
||||
let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command);
|
||||
let mut p = ProcessSpec::new(
|
||||
format!("mcp:{}", self.label),
|
||||
&self.command,
|
||||
format!("MCP server {}", self.label),
|
||||
);
|
||||
p.args.clone_from(&self.args);
|
||||
p.cwd.clone_from(&self.cwd);
|
||||
p.env.clone_from(&self.env);
|
||||
|
|
@ -873,7 +877,9 @@ impl McpManager {
|
|||
}
|
||||
let req_id = next_request_id(client);
|
||||
let body = make_request(req_id, &method, params);
|
||||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, token) =
|
||||
self.runtime
|
||||
.register_external(JobKind::McpRequest, None, format!("mcp {method}"));
|
||||
client.pending_external.insert(
|
||||
req_id,
|
||||
PendingExternal {
|
||||
|
|
@ -948,7 +954,11 @@ impl McpManager {
|
|||
// (1) Cache hit.
|
||||
if let Some(ResourceCacheState::Cached { result }) = self.resource_cache.get(&key).cloned()
|
||||
{
|
||||
let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, _token) = self.runtime.register_external(
|
||||
JobKind::McpRequest,
|
||||
None,
|
||||
format!("mcp resources/read {uri} (cached)"),
|
||||
);
|
||||
self.runtime.complete_external_ok(job_id, result);
|
||||
return Ok(job_id);
|
||||
}
|
||||
|
|
@ -959,7 +969,11 @@ impl McpManager {
|
|||
// independently.
|
||||
if let Some(ResourceCacheState::InFlight { request_id }) = self.resource_cache.get(&key) {
|
||||
let in_flight_rid = *request_id;
|
||||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, token) = self.runtime.register_external(
|
||||
JobKind::McpRequest,
|
||||
None,
|
||||
format!("mcp resources/read {uri}"),
|
||||
);
|
||||
if let Some(p) = client.pending_external.get_mut(&in_flight_rid) {
|
||||
p.awaiters.push(Awaiter { job_id, token });
|
||||
return Ok(job_id);
|
||||
|
|
@ -974,7 +988,11 @@ impl McpManager {
|
|||
// (3) Cache miss: dispatch.
|
||||
let req_id = next_request_id(client);
|
||||
let body = make_request(req_id, "resources/read", json!({ "uri": uri }));
|
||||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, token) = self.runtime.register_external(
|
||||
JobKind::McpRequest,
|
||||
None,
|
||||
format!("mcp resources/read {uri}"),
|
||||
);
|
||||
client.pending_external.insert(
|
||||
req_id,
|
||||
PendingExternal {
|
||||
|
|
@ -1063,10 +1081,13 @@ impl McpManager {
|
|||
// than referenced by `json!`); avoids a needless-pass-by-
|
||||
// value clippy complaint and matches `send_request`'s shape.
|
||||
let mut params_map = Map::new();
|
||||
let purpose = format!("mcp tools/call {name}");
|
||||
params_map.insert("name".into(), Value::String(name));
|
||||
params_map.insert("arguments".into(), arguments);
|
||||
let body = make_request(req_id, "tools/call", Value::Object(params_map));
|
||||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, token) = self
|
||||
.runtime
|
||||
.register_external(JobKind::McpRequest, None, purpose);
|
||||
client.pending_external.insert(
|
||||
req_id,
|
||||
PendingExternal {
|
||||
|
|
@ -1125,10 +1146,13 @@ impl McpManager {
|
|||
}
|
||||
let req_id = next_request_id(client);
|
||||
let mut params_map = Map::new();
|
||||
let purpose = format!("mcp prompts/get {name}");
|
||||
params_map.insert("name".into(), Value::String(name));
|
||||
params_map.insert("arguments".into(), arguments);
|
||||
let body = make_request(req_id, "prompts/get", Value::Object(params_map));
|
||||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||||
let (job_id, token) = self
|
||||
.runtime
|
||||
.register_external(JobKind::McpRequest, None, purpose);
|
||||
client.pending_external.insert(
|
||||
req_id,
|
||||
PendingExternal {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
src/protocol.rs
145
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_twenty_two_for_line_wrap_facts() {
|
||||
fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1732,7 +1732,15 @@ mod tests {
|
|||
// daemon-gated, appended after the final v21 variant). The
|
||||
// GPU lays out locally and would otherwise never hear the wrap
|
||||
// setting; the advertised baseline is deliberately unmoved.
|
||||
assert_eq!(PROTOCOL_VERSION, 22);
|
||||
// Discovery Stage 2 bumps 22→23 (`InstanceMessage::
|
||||
// MinibufferPromptRows`, daemon-gated, appended after the final
|
||||
// v22 variant). The first bump to leave the SUPERSEDED variant
|
||||
// live rather than widening it: postcard is positional, so
|
||||
// widening `MinibufferPrompt` would break every v12–v22 peer,
|
||||
// and gating the wider form would have left them with no
|
||||
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||
// pinned by literal bytes below.
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1809,17 +1817,18 @@ mod tests {
|
|||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||
// panel band), and v22 (`LineWrapFacts`) all interoperate.
|
||||
for accepted in 6..=22 {
|
||||
// panel band), v22 (`LineWrapFacts`), and v23
|
||||
// (`MinibufferPromptRows`) all interoperate.
|
||||
for accepted in 6..=23 {
|
||||
assert!(
|
||||
is_supported_protocol_version(accepted),
|
||||
"v{accepted} must be accepted"
|
||||
);
|
||||
}
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] {
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v22 binary"
|
||||
"v{rejected} must be rejected by a v23 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2406,6 +2415,130 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minibuffer_prompt_v12_wire_bytes_are_frozen() {
|
||||
// Discovery Stage 2 (v23) froze `MinibufferPrompt` and put the
|
||||
// richer shape in an appended `MinibufferPromptRows`. THIS is
|
||||
// what makes the freeze real, and the round-trip above is not:
|
||||
// a round-trip encodes and decodes with the SAME types, so
|
||||
// adding a field to `MinibufferPrompt` leaves it passing while
|
||||
// every v12–v22 peer in the field mis-decodes the bytes. Only a
|
||||
// comparison against bytes captured now can fail when the
|
||||
// encoding changes.
|
||||
//
|
||||
// Two fixtures, the two shapes the producer emits: an open
|
||||
// prompt with a windowed candidate list and a selection, and a
|
||||
// cleared band. Discriminant 20, then the fields positionally
|
||||
// (postcard is not self-describing).
|
||||
let open = InstanceMessage::MinibufferPrompt {
|
||||
prompt: Some("M-x ".to_owned()),
|
||||
input: "ed".to_owned(),
|
||||
cursor: 2,
|
||||
candidates: vec!["edit.copy".to_owned(), "edit.cut".to_owned()],
|
||||
selected: Some(1),
|
||||
total: 7,
|
||||
};
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&open).expect("encode open"),
|
||||
[
|
||||
20, // InstanceMessage::MinibufferPrompt
|
||||
1, 4, b'M', b'-', b'x', b' ', // prompt: Some("M-x ")
|
||||
2, b'e', b'd', // input: "ed"
|
||||
2, // cursor
|
||||
2, 9, b'e', b'd', b'i', b't', b'.', b'c', b'o', b'p', b'y', 8, b'e', b'd', b'i',
|
||||
b't', b'.', b'c', b'u', b't', // candidates
|
||||
1, 1, // selected: Some(1)
|
||||
7, // total
|
||||
],
|
||||
"MinibufferPrompt's v12 wire bytes changed. It is FROZEN for \
|
||||
v12..=22 — a widening here mis-decodes on every already-shipped \
|
||||
frontend rather than being ignored. Richer minibuffer rows \
|
||||
belong in MinibufferPromptRows."
|
||||
);
|
||||
|
||||
let clear = InstanceMessage::MinibufferPrompt {
|
||||
prompt: None,
|
||||
input: String::new(),
|
||||
cursor: 0,
|
||||
candidates: Vec::new(),
|
||||
selected: None,
|
||||
total: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&clear).expect("encode clear"),
|
||||
[20, 0, 0, 0, 0, 0, 0],
|
||||
"MinibufferPrompt's cleared-band v12 wire bytes changed — see the \
|
||||
open-prompt fixture above"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_wrap_facts_encoding_is_unchanged_by_the_v23_build() {
|
||||
// Discovery Stage 2 placement pin: `MinibufferPromptRows` must
|
||||
// be APPENDED after `LineWrapFacts` — the final v22 variant,
|
||||
// whose ordinal moves if anything is inserted before any v22
|
||||
// variant. The new variant's own round-trip cannot detect a
|
||||
// shift, which is why the pin sits on the PREVIOUS final variant
|
||||
// (handoff §4).
|
||||
let msg = InstanceMessage::LineWrapFacts {
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||
wrap: true,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
[29, 4, 1],
|
||||
"LineWrapFacts' v22 wire bytes changed — a variant was \
|
||||
inserted before it; append new InstanceMessage variants \
|
||||
at the end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minibuffer_prompt_rows_round_trips_and_appends_after_line_wrap_facts() {
|
||||
// The v23 variant itself: both shapes, a detail present and a
|
||||
// detail absent (Q#D2-2 — a source with no detail leaves it
|
||||
// `None` and renders as it always did), plus the cleared band.
|
||||
let cases = [
|
||||
(
|
||||
Some("M-x ".to_owned()),
|
||||
"ed".to_owned(),
|
||||
2u32,
|
||||
vec![
|
||||
MinibufferRow {
|
||||
label: "edit.copy".to_owned(),
|
||||
detail: Some("Copy the region".to_owned()),
|
||||
},
|
||||
MinibufferRow {
|
||||
label: "notes.txt".to_owned(),
|
||||
detail: None,
|
||||
},
|
||||
],
|
||||
Some(1u32),
|
||||
7u32,
|
||||
),
|
||||
(None, String::new(), 0, Vec::new(), None, 0),
|
||||
];
|
||||
for (prompt, input, cursor, rows, selected, total) in cases {
|
||||
let msg = InstanceMessage::MinibufferPromptRows {
|
||||
prompt: prompt.clone(),
|
||||
input: input.clone(),
|
||||
cursor,
|
||||
rows: rows.clone(),
|
||||
selected,
|
||||
total,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert_eq!(
|
||||
bytes.first(),
|
||||
Some(&30),
|
||||
"MinibufferPromptRows must be appended after v22 LineWrapFacts"
|
||||
);
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(decoded, msg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_event_to_crossterm_round_trips() {
|
||||
// Build a protocol KeyEvent, translate to crossterm, translate
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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" },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue