Compare commits

..

No commits in common. "main" and "lsp-latex-coverage" have entirely different histories.

47 changed files with 411 additions and 15143 deletions

View File

@ -570,11 +570,9 @@ 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. 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
**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
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,
@ -623,21 +621,12 @@ 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 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.
- **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.
- **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
@ -695,8 +684,6 @@ 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.*
---
@ -1306,17 +1293,6 @@ Primitive-by-primitive against the list above:
that found it (§25). All four remain in `lsp.lua`; per §25 the
symbols are authoritative and the `ad41cf1` line numbers have drifted.
**Updated again: FIVE, and the fifth is the first outside
`lsp.lua`.** Git Stage 1's `*git-status*`
(`builtin/runtime/git.lua`) is the concrete evidence P5 asked for
that the primitive generalizes past its first consumer — the
remediation here was always adoption, not construction. It also
added the primitive's one extension: an optional **`keys`** table on
the open spec, installed once with the panel's buffer and compared
(not re-bound) on reopen, because `Keymap::bind` refuses duplicates
and an async consumer re-opens on every refresh. `*buffer-list*` and
project-search remain the un-migrated hand-rolled pair.
**`*lsp*` is the only one of the four with a working refresh** — it is
the only one supplying `on_refresh`. `g` is bound on all four
unconditionally by `bind_local_keymap`, so the other three carry a
@ -1446,25 +1422,10 @@ What does not:
- **Code actions apply the first action blindly** — no picker (a
roadmap "dark matter" item still true at audit).
- **Git integration reaches status and diff, and no further.** Stage 1
(`docs/git-integration-framing.md`) ships `*git-status*` — a
`listview` panel over `git status --porcelain=v2 --branch -z`, with
RET visiting the file and `d` showing its file-level diff. There is
still **no stage, revert, blame, or gutter marker** anywhere in the
tree; gutter git riders need new `DecorationKind` variants (Stage 2,
which must be scheduled alone), and the `ResourceOffer` diff/blame
family remains a named deferral. The Git affordance list above now has
something to attach to; the affordances themselves are unbuilt, and
the menu's context vocabulary (`src/menu.rs`) has no `git` context to
host them.
The original audit said "there is no Git integration at all … anywhere
in the tree", and that was **literally false when it was written**:
`tests/fixtures/pmacs-magit/` is a tracked, installable package that
spawns git and parses porcelain v2, with a 32-test acceptance suite
(`tests/m8_6_acceptance.rs`). The **product** gap it described was
real; the sentence overstated it, and the framing that found the
overstatement is the one that closed the gap.
- **There is no Git integration at all** — no status, stage, diff,
blame, or gutter markers anywhere in the tree (gutter git riders and
the `ResourceOffer` diff/blame family are named deferrals). The Git
affordance list above has nothing to attach to yet.
- No test run/debug affordances (DAP is a future arc,
`docs/dap-debugging-framing.md`).
- No missing-tool guidance affordances (§1.2 — the diagnostic that

View File

@ -259,10 +259,6 @@ function repl.spawn(opts)
local spec = {
label = name,
-- Worker identity Stage 1: the label is the REPL's session name,
-- which distinguishes two REPLs from each other and says nothing
-- about what is running. The purpose names the interpreter.
purpose = "interactive " .. h._display_name .. " session",
command = argv[1],
args = args,
pty = { rows = rows, cols = cols, mode = "raw" },

View File

@ -88,28 +88,6 @@ 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
@ -262,28 +240,7 @@ 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
@ -409,85 +366,14 @@ 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
async_mod._push_dispatch_name(name)
return finish_dispatch(pcall(handler, args, opts))
return 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
@ -495,20 +381,6 @@ 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
@ -709,60 +581,6 @@ function pmacs._async.tick()
end
end
-- ---------------------------------------------------------------------------
-- Statusline activity indicator (worker identity Stage 1, Q#W-3/Q#W-6).
-- ---------------------------------------------------------------------------
--
-- `COHERENCE.md` §9 records that no progress indicator exists anywhere
-- --- no spinner, no busy count --- which makes §3's promise of "visible
-- asynchronous work" false unless the user knows to run
-- `M-x editor.list-workers`. This is the fourth `pmacs.statusline.register`
-- adopter (after `mode`, `terminal` and `lsp`) and the first thing that
-- makes background work visible without a command.
--
-- No wire change: `pmacs.statusline.register` rides the existing
-- `StatuslineSegments` vector, so a fourth provider adds an ELEMENT, not
-- a variant. That is what lets this lane run beside the two holding the
-- protocol-bump slot.
-- A visibility toggle, and only that (Q#W-6). A permanently-visible
-- statusline element is different in kind from an internal behaviour: it
-- costs modeline width on every frame, and "I do not want this in my
-- modeline" is a preference someone genuinely holds on day one. There is
-- deliberately NO setting for purpose capture itself --- that is
-- substrate, not preference.
pmacs.config.define {
name = "ui.activity-indicator",
description = "Show a modeline count of in-flight background jobs, with the oldest job's purpose. Absent entirely when nothing is running.",
type = "boolean",
default = true,
mutability = "live",
}
pmacs.statusline.register {
name = "activity",
side = "right",
-- Above `terminal` (10) and `lsp` (0): when the modeline is too narrow
-- for everything, "the editor is busy, on this" is the segment worth
-- keeping. Right-side display order is priority-ascending, so it also
-- lands nearest the protected cursor/scroll group.
priority = 20,
face = "ui.modeline.activity",
fn = function(_ctx)
if pmacs.config.get("ui.activity-indicator") ~= true then return nil end
-- `_activity_summary` rather than `pmacs.workers.snapshot()`: this
-- runs once per visible window per frame, and a snapshot would clone
-- the whole 64-entry completed ring that the indicator never reads.
local summary = async_mod._activity_summary()
-- nil, not "" and not "0 jobs": the evaluator treats an empty string
-- as "no segment" too, but a zero-count string would be a segment
-- that costs width forever to say nothing is happening. Absence is
-- the design (Q#W-3), so absence is what this returns.
if summary == nil then return nil end
return "" .. tostring(summary.in_flight) .. " " .. summary.purpose
end,
}
-- Diagnostic / test helpers: number of parked coroutines, number of
-- pending Rust-side jobs. Used by Rust integration tests to drive the
-- runtime to quiescence.

View File

@ -875,11 +875,6 @@ 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" },

File diff suppressed because it is too large Load Diff

View File

@ -494,14 +494,10 @@ 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` 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.
-- 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.
label = "lean:lake-version-probe",
-- Worker identity Stage 1: the label was carrying both jobs — the
-- identity AND the explanation — which is the conflation the purpose
-- field exists to undo. The label stays a key; this is the sentence.
purpose = "checking the Lean toolchain version before starting a server",
command = cfg.command,
args = { "--version" },
stdin = "null",

View File

@ -25,7 +25,6 @@
-- rows = { { text = "src/foo.rs:12:4", item = <any> }, ... },
-- on_visit = function(item) ... end, -- RET/SPC (optional)
-- on_refresh = function() return rows end, -- g (optional)
-- keys = { d = "git.diff-file" }, -- extra buffer-local keys
-- }
pmacs.listview = pmacs.listview or {}
@ -264,193 +263,19 @@ local function seat_cursor(p, line)
end
end
-- The primitive's own key surface, named ONCE so the binder below and
-- the `keys` validator consult the same list. Previously this was a
-- sequence of `bind(...)` calls and the set existed nowhere as data,
-- which is why the git framing had to quote it from the source
-- (docs/git-integration-framing.md Q#G-7).
local FIXED_KEYS = {
{ "RET", "listview.visit" },
{ "SPC", "listview.visit" },
{ "n", "cursor.down" },
{ "<down>", "cursor.down" },
{ "p", "cursor.up" },
{ "<up>", "cursor.up" },
{ "TAB", "listview.toggle" },
{ "g", "listview.refresh" },
{ "q", "listview.quit" },
}
local function bind_local_keymap(buf)
for _, entry in ipairs(FIXED_KEYS) do
pmacs.keymap.bind {
scope = "buffer", buffer = buf, sequence = entry[1], command = entry[2],
}
local function bind(seq, command)
pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command }
end
end
-- ---------------------------------------------------------------------
-- Consumer-supplied keys (Q#G-7)
-- ---------------------------------------------------------------------
--
-- An optional `keys = { <sequence> = <command name> }` on the open
-- spec, bound through the SAME `pmacs.keymap.bind { scope = "buffer" }`
-- path as the fixed set above. It exists because a consumer cannot
-- safely bind its own key from outside: `open` disambiguates a name
-- collision to `<2>`, so the name a consumer passed is not necessarily
-- the buffer it got, and this module is the only place the handle is
-- known. No key is intercepted anywhere — COHERENCE.md §6's shadow
-- count is unchanged by this.
--
-- INSTALL-ONCE, MATCH-ON-REOPEN. `Keymap::bind` refuses duplicates
-- (`KeymapError::DuplicateBinding`, "Refuse rather than silently
-- overwrite", src/keymap_tree.rs), and a consumer built on the async
-- completion model calls `open` again on EVERY refresh. So keys are
-- installed when the buffer is created and a later `open` for a live
-- panel does not re-bind — it COMPARES, and errors on divergence.
-- Silently keeping the old binding would hand the consumer a key that
-- does something other than what it just asked for, which is the dead-
-- or-lying-key defect this module already condemns for `g`.
-- A key sequence's whitespace-separated chord tokens. That is exactly
-- how `parse_sequence` (src/key.rs) splits one, so a prefix relation
-- computed here is the same relation the trie would find.
local function chords_of(sequence)
local out = {}
for token in sequence:gmatch("%S+") do out[#out + 1] = token end
return out
end
-- True when one chord list is a STRICT prefix of the other. Either
-- direction is a conflict: `Keymap` refuses both turning a leaf into a
-- submap (`WouldExtendLeaf`) and shadowing a submap with a leaf
-- (`WouldShadowSubmap`), and a `keys` table must not be able to reach
-- either.
local function prefix_conflict(a, b)
local short, long = a, b
if #a > #b then short, long = b, a end
if #short == 0 or #short == #long then return false end
for i = 1, #short do
if short[i] ~= long[i] then return false end
end
return true
end
-- Normalize `keys` into a sorted array of `{ sequence, command }`.
-- Sorted so the comparison on reopen and every error message are
-- deterministic (`pairs` order is not).
local function normalized_keys(keys)
if keys == nil then return {} end
if type(keys) ~= "table" then
error(string.format(
"listview: `keys` must be a table of sequence -> command name; got %s",
type(keys)))
end
local out = {}
for sequence, command in pairs(keys) do
if type(sequence) ~= "string" or sequence == "" then
error("listview: every `keys` entry must be keyed by a non-empty key sequence")
end
if type(command) ~= "string" or command == "" then
error(string.format(
"listview: `keys[%q]` must be a command NAME (a non-empty string); got %s",
sequence, type(command)))
end
out[#out + 1] = { sequence = sequence, command = command }
end
table.sort(out, function(a, b) return a.sequence < b.sequence end)
return out
end
-- A FIRST-PASS collision check, for a better message than the keymap's.
--
-- It compares RAW TOKENS, and that is deliberately not sufficient: the
-- key parser canonicalizes aliases before it ever reaches the trie
-- (`parse_key_code`, src/key.rs, uppercases and folds `RET`/`RETURN`/
-- `ENTER`, `SPC`/`SPACE`, `ESC`/`ESCAPE`, `BS`/`BACKSPACE`,
-- `DEL`/`DELETE`), so `keys = { RETURN = ... }` is a collision this
-- function cannot see.
--
-- **`Keymap::bind` is the authority, and `ensure_panel` tears the panel
-- down when it refuses.** That is not a fallback for a check that
-- happens to be weak --- it is the only version that cannot go stale. A
-- Lua-side canonicalizer would be a second copy of `parse_key_code`'s
-- alias table, and the day the Rust one gains a name the Lua one would
-- silently stop seeing that alias, reintroducing exactly this bug for
-- it. (There is also no way to canonicalize an arbitrary sequence from
-- Lua today: `display_sequence` is reachable only through
-- `describe.key` and `keymap.list`, which both require the sequence to
-- be BOUND already.)
--
-- So what this buys is diagnosis, not safety: a named "that is the
-- panel's own `g`" instead of a raw `DuplicateBinding`.
local function check_key_collisions(entries)
for i, entry in ipairs(entries) do
local mine = chords_of(entry.sequence)
for _, fixed in ipairs(FIXED_KEYS) do
if entry.sequence == fixed[1] then
error(string.format(
"listview: `keys` may not rebind %q --- it is part of the panel's "
.. "own key surface (RET SPC n <down> p <up> TAB g q), bound to %q",
entry.sequence, fixed[2]))
end
if prefix_conflict(mine, chords_of(fixed[1])) then
error(string.format(
"listview: `keys` entry %q conflicts with the panel's own %q --- "
.. "one is a prefix of the other, which the keymap refuses rather "
.. "than turning a binding into a submap",
entry.sequence, fixed[1]))
end
end
for j = i + 1, #entries do
if prefix_conflict(mine, chords_of(entries[j].sequence)) then
error(string.format(
"listview: `keys` entries %q and %q conflict --- one is a prefix "
.. "of the other", entry.sequence, entries[j].sequence))
end
end
end
end
-- Bind the entries, naming which one the keymap refused.
--
-- It does NOT roll back the keys it already bound: its caller owns
-- teardown, and the caller's teardown is killing the whole buffer,
-- which takes the buffer's entire keymap scope with it
-- (`after_buffer_removed` -> `KeymapStack::remove_buffer`). Unbinding
-- here as well would be a second, weaker cleanup mechanism for the same
-- failure --- and the weaker one is what let a half-built panel survive.
local function install_keys(buf, entries)
for _, entry in ipairs(entries) do
local ok, err = pcall(pmacs.keymap.bind, {
scope = "buffer", buffer = buf,
sequence = entry.sequence, command = entry.command,
})
if not ok then
error(string.format(
"listview: cannot bind %q to %q: %s",
entry.sequence, entry.command, tostring(err)))
end
end
end
local function keys_match(a, b)
if #a ~= #b then return false end
for i = 1, #a do
if a[i].sequence ~= b[i].sequence or a[i].command ~= b[i].command then
return false
end
end
return true
end
local function render_keys(entries)
if #entries == 0 then return "none" end
local parts = {}
for i, entry in ipairs(entries) do
parts[i] = string.format("%s=%s", entry.sequence, entry.command)
end
return table.concat(parts, " ")
bind("RET", "listview.visit")
bind("SPC", "listview.visit")
bind("n", "cursor.down")
bind("<down>", "cursor.down")
bind("p", "cursor.up")
bind("<up>", "cursor.up")
bind("TAB", "listview.toggle")
bind("g", "listview.refresh")
bind("q", "listview.quit")
end
-- Build the persistent panel record for `name`. A user-killed panel
@ -468,23 +293,9 @@ end
-- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises
-- rather than adopting --- the rule terminal.lua:300-305 states and
-- dired.lua:476-504 already implements.
local function ensure_panel(name, key_entries)
local function ensure_panel(name)
local p = panel_for_requested_name(name)
if p then
-- Match-on-reopen (Q#G-7). A live panel keeps the keys it was
-- created with; a DIFFERENT table is a consumer asking for
-- something it will not get, so it is an error rather than a
-- silently ignored request.
if not keys_match(p.keys, key_entries) then
error(string.format(
"listview: %s is already open with keys [%s]; this open asks for "
.. "[%s]. Keys are installed once with the panel's buffer, so the "
.. "second table would be silently ignored --- close the panel "
.. "first, or pass the same keys",
name, render_keys(p.keys), render_keys(key_entries)))
end
return p
end
if p then return p end
local actual = name
if find_buffer_by_name(actual) then
@ -504,64 +315,29 @@ local function ensure_panel(name, key_entries)
local buf = pmacs.buffer.create(actual)
p = { requested_name = name, buffer = buf, line_to_item = {},
line_to_row = {}, collapsed = {}, rows = {}, visible = 0,
keys = key_entries }
-- ALL-OR-NOTHING from here. Everything below mutates a buffer that
-- does not yet belong to a panel, and `install_keys` can genuinely
-- fail: the raw-token preflight cannot see an alias spelling of a
-- fixed key (`RETURN` for `RET`), so `Keymap::bind` is the first thing
-- to notice, and by then the buffer exists, carries a read-only
-- intercept and a round-trip mark, and holds the fixed keymap.
--
-- Leaving it behind is worse than it sounds: it is read-only, it is in
-- no `panels` record so nothing owns or can reach it, and the next
-- `open` for the same name finds it by name and disambiguates itself
-- to `<2>` --- so a rejected `keys` table silently renames the panel.
local built, err = pcall(function()
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
-- error. Kept beside the rope lock, not replaced by it: the layering
-- at terminal.lua:351-366 --- the rope lock protects the daemon copy,
-- this and the round-trip mark protect a semantic frontend's own
-- mirror, and neither substitutes for the other. The intercept lives
-- as long as the buffer; no teardown (the buffer-list precedent for
-- its keymap).
pmacs.buffer.add_intercept(buf, function()
error(actual .. " is read-only")
end)
-- Q#P6: semantic frontends must round-trip keys while this panel
-- is focused (RET = visit, not an optimistic newline).
pmacs.buffer.set_round_trip_input(buf, true)
bind_local_keymap(buf)
install_keys(buf, key_entries)
end)
if not built then
-- `kill` is the whole teardown, not a convenience: it removes the
-- buffer AND, through `after_buffer_removed`, prunes the buffer's
-- keymap scope, its config locals and its folds. Unbinding key by
-- key would leave the buffer itself --- which is the defect.
pcall(pmacs.buffer.kill, buf)
-- Level 0: re-raise the inner message verbatim rather than stacking
-- this line's position onto it.
error(err, 0)
end
-- Registered LAST, deliberately: a failure above must leave no record
-- claiming keys it did not bind. Nothing above needs the panel to be
-- in `panels` --- the intercept, the round-trip mark and the keymap
-- all address the buffer directly.
line_to_row = {}, collapsed = {}, rows = {}, visible = 0 }
panels[#panels + 1] = p
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
-- error. Kept beside the rope lock, not replaced by it: the layering
-- at terminal.lua:351-366 --- the rope lock protects the daemon copy,
-- this and the round-trip mark protect a semantic frontend's own
-- mirror, and neither substitutes for the other. The intercept lives
-- as long as the buffer; no teardown (the buffer-list precedent for
-- its keymap).
pmacs.buffer.add_intercept(buf, function()
error(actual .. " is read-only")
end)
-- Q#P6: semantic frontends must round-trip keys while this panel
-- is focused (RET = visit, not an optimistic newline).
pmacs.buffer.set_round_trip_input(buf, true)
bind_local_keymap(buf)
return p
end
function pmacs.listview.open(spec)
assert(type(spec) == "table" and type(spec.name) == "string",
"listview.open: spec.name (string) required")
-- The cheap checks first, so the common mistakes are named before
-- anything is created. The ones this pass cannot see --- alias
-- spellings --- are caught by `Keymap::bind` inside `ensure_panel`,
-- which tears the panel down rather than leaving it half-built.
local key_entries = normalized_keys(spec.keys)
check_key_collisions(key_entries)
local p = ensure_panel(spec.name, key_entries)
local p = ensure_panel(spec.name)
p.header = spec.header or spec.name
p.on_visit = spec.on_visit
p.on_refresh = spec.on_refresh

View File

@ -1924,8 +1924,7 @@ end
local FILE_WATCH_INTERVAL_MS = 250
-- file_watchers[tostring(sid)][registrationId] = list of watch records
-- ({ cancelled = bool, form = "relative"|"absolute", _sleep = handle? }),
-- one per glob watcher.
-- ({ cancelled = bool, _sleep = handle? }), one per glob watcher.
local file_watchers = {}
-- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it
@ -2059,18 +2058,7 @@ end
local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3
local function start_file_watcher(sid, base, glob, kind_mask, record)
-- Per LSP, a plain-string glob matches the file's ABSOLUTE path,
-- while a RelativePattern's pattern is relative to its base — the
-- record's `form` (from resolve_watcher) picks the match subject.
-- scan_tree always walks in relative terms; only the string handed
-- to the matcher changes.
local match_glob = glob_matcher(glob)
local matches = match_glob
if record.form == "absolute" then
matches = function(rel)
return match_glob(base .. "/" .. rel)
end
end
local matches = glob_matcher(glob)
pmacs.async(function()
local prev = scan_tree(base, matches)
while not record.cancelled and server_is_live(sid) do
@ -2081,29 +2069,6 @@ local function start_file_watcher(sid, base, glob, kind_mask, record)
if record.cancelled or not server_is_live(sid) then break end
local cur = scan_tree(base, matches)
-- The seam that makes the recheck below WITNESSABLE. `scan_tree`
-- suspends on `read_dir` once per directory, and the race is a
-- cancel arriving during one of those suspensions --- which no
-- arrangement of real timing can be made to happen on demand.
-- Same reason `git.lua` exposes `_deliver_status`: the contract is
-- about an interleaving the caller does not choose. Unset in
-- production, so this costs one nil test per tick.
-- `cur` is handed over so a test can cancel on THE SCAN THAT
-- OBSERVED a given change. Cancelling on any other scan is not a
-- witness: the loop would break at the post-sleep check on the
-- next iteration and emit nothing anyway, so the assertion would
-- pass with the recheck below deleted.
if pmacs.lsp._after_scan_for_tests then
pcall(pmacs.lsp._after_scan_for_tests, record, cur)
end
-- RECHECKED AFTER THE SCAN, not only after the sleep (review P2).
-- The coroutine is suspended for most of a tick with `_sleep`
-- already cleared, so a cancel landing there sets `cancelled` and
-- has no sleep to interrupt. Without this line the resumed scan
-- runs on to `did_change_watched_files` below and a SUPERSEDED
-- watcher emits one last batch under its OLD pattern. One batch is
-- enough: it is a wrong-pattern notification the server acts on.
if record.cancelled or not server_is_live(sid) then break end
local changes = {}
for rel, sig in pairs(cur) do
local was = prev[rel]
@ -2132,44 +2097,22 @@ local function start_file_watcher(sid, base, glob, kind_mask, record)
end
-- Resolve a GlobPattern (string | { baseUri, pattern }) to
-- (base_dir, pattern, form). The form must travel with the pair: a
-- RelativePattern's pattern is relative to its baseUri, and dropping
-- that distinction is what made absolute server globs unable to match
-- anything. A bare string with no base falls back to the directory of
-- an attached file on `sid` (best effort).
--
-- THE FORM COMES FROM THE PATTERN, NOT FROM THE UNION ARM (review P1).
-- The first fix for #233 returned `"absolute"` for every string, which
-- is a different bug wearing the same shape: LSP 3.17 defines `Pattern`
-- relative to a base path, and VS Code treats a string watcher as
-- applying across workspace folders, so a bare `*.txt` is a VALID
-- relative pattern. Classifying it absolute matched it against
-- `<base>/foo.txt`, which `^[^/]*%.txt$` can never match --- so that
-- fix silently broke a case that worked before it. A leading `/` is
-- what makes a pattern absolute; the arm it arrived in is not.
-- (base_dir, pattern). A bare string with no base falls back to the
-- directory of an attached file on `sid` (best effort).
local function resolve_watcher(sid, gp)
if type(gp) == "table" and gp.baseUri then
return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**", "relative"
return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**"
end
if type(gp) == "string" then
for _, rec in pairs(attachments) do
if rec.server == sid and rec.uri then
local p = pmacs.lsp.path_for_uri(rec.uri)
local dir = p and p:match("^(.*)/[^/]*$")
if dir then
return dir, gp, (gp:sub(1, 1) == "/") and "absolute" or "relative"
end
if dir then return dir, gp end
end
end
end
return nil, nil, nil
end
local function cancel_watch_records(recs)
for _, r in ipairs(recs or {}) do
r.cancelled = true
if r._sleep then pcall(function() r._sleep:cancel() end) end
end
return nil, nil
end
local function register_file_watchers(sid, registrations)
@ -2177,16 +2120,11 @@ local function register_file_watchers(sid, registrations)
file_watchers[skey] = file_watchers[skey] or {}
for _, reg in ipairs(registrations or {}) do
if reg.method == "workspace/didChangeWatchedFiles" then
-- Re-registering a live id supersedes it (rust-analyzer does
-- this): cancel the outgoing records first, because the table
-- write below drops the only reference to them and an
-- uncancelled record polls until the server dies.
cancel_watch_records(file_watchers[skey][reg.id])
local recs = {}
for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do
local base, pat, form = resolve_watcher(sid, w.globPattern)
local base, pat = resolve_watcher(sid, w.globPattern)
if base and pat then
local r = { cancelled = false, form = form }
local r = { cancelled = false }
recs[#recs + 1] = r
start_file_watcher(sid, base, pat, w.kind or 7, r)
end
@ -2201,7 +2139,10 @@ local function unregister_file_watchers(sid, unregs)
if not byid then return end
for _, u in ipairs(unregs or {}) do
if u.method == "workspace/didChangeWatchedFiles" and byid[u.id] then
cancel_watch_records(byid[u.id])
for _, r in ipairs(byid[u.id]) do
r.cancelled = true
if r._sleep then pcall(function() r._sleep:cancel() end) end
end
byid[u.id] = nil
end
end

View File

@ -5,21 +5,6 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
**Updated 2026-08-11 — two merges and a discharge.** The LSP file
watcher D1+D2 landed as **#234** (`ae84d58`) after one review round
(P1 form-from-the-pattern, P2 cancelled-scan emission — both
bite-verified), and git integration Stage 1 landed as **#227**
(`b867f64`) immediately after, refreshed onto the merged base and
re-gated. The 2026-08-10 hold on #227 is **discharged in order**, not
overridden. Both lanes below are rewritten to their remainders (D3;
git Stage 2), their durable facts absorbed into
`docs/agent-handoff.md` §1. **Next, by user ruling: D3.** The
canonical-base line below and the handoff anchor both moved to
`b867f64`. Several other lane headers still say OPEN for PRs that have
since merged (#224#232) — per this file's own rule, trust the
canonical-base line over any lane header; those absorptions remain
owed by their own lanes.
**Updated later the same day, on a new machine.** Development moved to
the laptop; the recovery path in "Repository authority" below was
exercised from this checkout and the `githubsucks` alias was absent and
@ -126,14 +111,7 @@ lesson, §1 for the two framings).
are identical on every machine. Remote names are otherwise
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: **`githubsucks/main` @ `b867f64`** —
git integration Stage 1 **#227**, atop `ae84d58` the LSP file-watcher
fix **#234**, atop `0e4c58d` destination capture **#231**, `3cc1b85`
worker identity Stage 1 **#232**, `0857bf4` discovery Stage 2
**#228**, `0190102` LSP LaTeX coverage **#230**, `7cf4653` the gate
`--protocol` build step **#229**, `4bc55e8` per-worktree gate target
dirs **#225**, `dcb852e` the R8 fixture fix **#226** and `b833b13`
the QoL docs retirement **#224**. Beneath those, `9a26ac8`:
- Canonical base at this snapshot: **`githubsucks/main` @ `9a26ac8`** —
GPU horizontal scroll **#223**, which **closes the QoL arc**, atop
`2b56d16` TUI horizontal scroll **#222**, `02f3ec3` `ui.line-wrap`
**#221** (protocol v22), `218d2e7` GUI zoom **#220** and `da56bec`
@ -232,43 +210,6 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
## LSP file watcher (issue #233) — D1+D2 MERGED as #234; D3 IS NEXT
**Issue #233** — https://github.com/levineuwirth/pmacs/issues/233,
still OPEN: it closes when D3 does. **PR #234 MERGED 2026-08-11**
(`main` @ `ae84d58`), one review round. The framing is
`docs/lsp-file-watcher-framing.md`, revision 2 — it carries the full
record: the approved design, the answered ruling, and the two review
findings (P1 form-from-the-pattern, P2 cancelled-scan emission) with
their bite results. Durable facts are absorbed in
`docs/agent-handoff.md` §1.
**The #227 hold is DISCHARGED.** The 2026-08-10 ruling held #227
unmerged until this was resolved; #234 merged first and #227 followed
the same day (`b867f64`), refreshed and re-gated on the merged base.
**D3 — the polling cost — is the remainder, and the user has ruled it
is next (2026-08-11).** No branch and no framing yet. What is known,
verified while framing D1/D2:
- After #234 the watcher is *correct* but still walks: `walk` recurses
unconditionally and `matches` gates only recording, so rust-analyzer
walks the whole tree — `.git`, `target`, `node_modules` included —
every 250 ms, six times per tick (was twelve before D2), one async
job per directory. The modeline still shows the churn, at roughly
half the pre-#234 rate.
- **No `notify`/inotify dependency in the tree** — a real
filesystem-notification primitive is a new crate plus a new Rust
primitive plus its Lua binding.
- **No ignore-list infrastructure to reuse**`src/project.rs` knows
`.git` as a *marker* name, not as something to skip.
- Options named in the issue: coalesce a server's watchers into one
scan; root the scan at the workspace root; an ignore list; back off
when nothing changes; a real notification primitive.
- It is a `COHERENCE.md` §9 concern — background work with no
ownership model — and the activity indicator that surfaced it is
§9's own Stage 1. The framing must state its §20 coherence impact.
## `scripts/gate` — PR #225 OPEN (build tooling)
**PR #225** — https://github.com/levineuwirth/pmacs/pull/225. Written
@ -324,834 +265,6 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and
durable facts are in the retired registry row and the handoff §6
census.
## Git integration — STAGE 1 MERGED as #227; Stage 2 must be scheduled alone
**PR #227 MERGED 2026-08-11** (`main` @ `b867f64`), after five review
rounds, a macOS CI round, and a base refresh: it was held unmerged
behind issue #233 by the 2026-08-10 ruling, refreshed onto the merged
base (`e2394c7`, a clean merge whose only file shared with #234 was
this ledger), re-gated 11/11 locally and 14/14 on CI. Durable facts
are absorbed in `docs/agent-handoff.md` §1; the framing
(`docs/git-integration-framing.md`, revision 5) and the PR carry the
full five-round review history.
**What shipped:** `*git-status*` — a `listview` panel over
`git --no-optional-locks -C <dir> status --porcelain=v2 --branch -z`
and `*git-diff*` (file-level, plain generated text), with the
install-once `keys` extension on `listview`. `builtin/runtime/git.lua`,
34 acceptance tests, **no wire change**.
**Stage 2 (gutter markers) is the remainder, and it is NOT freely
schedulable: it needs new `DecorationKind` variants — a
`PROTOCOL_VERSION` bump — so it must run alone**, per the strict
serialization rule on wire changes. No branch, no framing yet.
**Residue that stays live here:**
- **§9 negative impact stands:** git runs as a spawned process, and
spawned processes do not appear in `*workers*`. A fifth
unattributable background thing, labelled honestly; a label is not
attribution. The D3 lane (above) and §9 Stage 2 own the model.
- **The latent macOS sibling:** `tests/gpu_invocation_acceptance.rs`
writes a non-UTF-8 filename to disk inside
`#[cfg(feature = "crdt")]`, and the crdt job is ubuntu-only — it
fails the day that job gains a macOS leg, the same way `g6_2` did
(handoff §1: macOS cannot hold a non-UTF-8 filename).
## Destination capture (Q#JR14 generalization) — PR #231 OPEN, revision 9, cleared to merge
**PR #231** — https://github.com/levineuwirth/pmacs/pull/231. #227
blocks on this lane.
The mechanism landed at `0efc8c0`; review found a correctness blocker;
`ca72461` implemented **revision 7**, which review then **also**
rejected; `469d5c8` replaced it with **revision 8** and its §3
enumeration is **performed and recorded in the framing**; review then
found a hole in revision 8's guard **scope** and the commit below closes
it as **revision 9**.
**The macOS red that blocked this lane, and how it was cleared.** Both
CI attempts at `4654b94` failed `a_pty_resize_blanks_the_host_before_repainting`
on `Test (macos-latest / luajit)`. A control experiment was run at the
exact base commit `0190102`: **five valid observations, all green on
both macOS flavours**, against the branch's 0/2 — 1/C(7,2) = 4.8% under
an equal-rate model. That implicates the branch statistically. **The
diff exonerates it mechanically**: grepping this lane's entire `src/`
diff for `full_grid|resize|resync|Geometry|reconcile_panel_layout`
matches an **import line and nothing else**, and
`full_grid_resync_acceptance` (191 lines) has no panel, side-window,
dedication, display or directory surface at all. Merged on that reading,
with the equal-rate model itself in doubt — see the U4 row, and note a
sixth base attempt reddened on a *third, unrelated* macOS selector
(U8), which is what a background platform failure rate looks like.
**The original blocker:** the panel profile skipped checks 24 on the
claim that a panel result never touches a document window. **Panel
placement falls back to an ordinary document window** when the frontend
is not panel-capable or its side slot is dedicated, so a `"panel"`
commit could replace a **newer** document with every stale-intent guard
skipped. Reproduced in review.
**Four designs, two rejected outright and one corrected — the sequence
is the part worth not re-learning:**
1. **Revision 6 — predict at preflight.** Rejected: the `await` refusal
stops concurrent interleaving, not the body, which is arbitrary
synchronous Lua and can create the fallback itself.
2. **Revision 7 — enforce at the placement boundary.** Implemented at
`ca72461`, then rejected: `docs/agent-handoff.md:748` requires
`commit_to` to preflight **before** the callback, because
"validating at display time is four mutations too late". A body has
already created buffers, handles and paint by then, so a
placement-time refusal is a partial commit with an error return.
3. **Revision 8 — keep the preflight, REFUSE the scope-invalidating
mutation.** The shape the tree implements. Same as `Handle:await`
being refused inside a commit scope: the fallback never comes into
existence, and refusal stays mutation-free on `(false, reason)`.
4. **Revision 9 — make the refusal hold for the WHOLE body.** Not a new
shape; a correction to revision 8's scope. A nested `commit_to`
**replaced** the enclosing contract and restored it afterwards, so
an outer `"panel"` commit's restriction went out of force for the
inner body's extent: nested `"document"` commit → callback dedicates
the side slot, unrefused → outer commit resumes, falls back,
overwrites a newer document. Reproduced in review. Contracts now
**compose** — the core holds a stack, `commit_to` pushes and pops
rather than swapping, and the guard consults every contract in force,
so the strictest active restriction wins. Nesting itself is **not**
forbidden: only the mutation is refused, so a nested commit that
touches no dedication runs exactly as before. Detecting the
dedication when the outer commit resumed was not available — that is
a late refusal, which is what revision 7 was rejected for.
**WHAT REVISION 9 DID *NOT* INVALIDATE — read this before re-opening the
enumeration.** The write-site enumeration below survived intact: every
site is real, every one is still guarded, and review of the nesting
defect found no missing route. What was wrong was the *surrounding*
claim — that the guard was in force for the whole outer body. A complete
list of write sites is not a complete argument until the guard's extent
is stated too. The acceptance suite now drives the same rows at **two
depths**, directly and through a nested `commit_to`.
**THE ENUMERATION IS THE LOAD-BEARING PART, AND IT IS CLOSED AS AN
ENUMERATION OF WRITE SITES — for a structural reason, not because
inspection ran out of ideas.** Full working in the framing §3; the short
form:
- **Only two pieces of state can matter**, because `resolve_placement`
reaches `Ordinary` from a side request through exactly two branches:
`panel_capable`, and the one side window's `dedicated`.
- **`panel_capable` is unreachable from a body.** It is written only
where a `FrontendView` is constructed, and nothing in
`src/lua_bindings/` constructs, registers or unregisters one —
`register_frontend_view` has callers only in `daemon.rs` and core
unit tests.
- **Eight writes to `dedicated` exist** (`rg 'params\.dedicated\s*='
src/`); **four are reachable and a fifth is guarded defensively**
`apply_placement`'s `Side` created / replacing / non-replacing arms
and `set_params` are the reachable four, and `quit_window`'s
`QuitAction::Restore` is the fifth, proved unreachable below and
guarded anyway. **All five are guarded**, which is the count that
matters; listing four under the word "five" is what an earlier version
of this bullet did. Two `Ordinary` arms are harmless (their target is
never a side window; one only ever clears the flag) and one is a unit
test.
- **The guards are sited where the property converges, not per caller.**
All three `Side` arms are reached through `apply_placement`, which has
**exactly one caller** — so one guard in `display_buffer` covers every
request-driven dedication, including spellings that do not exist yet.
`set_params` is a genuinely separate write and is guarded separately;
dedication does **not** converge before the field itself, and that is
stated rather than papered over.
- **Closing the side window is NOT a route**, checked rather than
assumed: with no side leaf `side_window_for` returns `None` and
placement **creates** a fresh panel instead of falling back. Hiding is
likewise irrelevant — `panel_hidden` is not consulted by placement.
- **`quit_window`'s `QuitAction::Restore { dedicated: true }` is
UNREACHABLE**, and this was the surprise. `Restore` is stored only on
a *replacing* side placement, and a dedicated slot can never be the
target of one. Guarded anyway, labelled defensive, because its
unreachability is emergent from two rules in another function.
- **What this does not rule out:** the enumeration is closed over the
current tree, not future edits. `params.dedicated` is a public field,
so nothing but the acceptance rows would catch a new direct writer.
**Also closed:** an invalid-UTF-8 profile (`string.char(255)`) reached
`to_str()` and surfaced mlua's generic conversion error instead of the
documented message naming the accepted values — the same reachability
class as revision 5's `Option<String>` defect, one layer down. The
comparison is on bytes now.
**Written with the lane's first commit**, per the standing correction
from #171 and #215.
**Branch `destination-capture`**, base `githubsucks/main` @ `4bc55e8`
(the #225 merge). **`githubsucks/destination-capture` is the
authoritative tip** — the ref, not a SHA. Recover with
`git fetch githubsucks && git checkout destination-capture`.
- **Framing `docs/destination-capture-framing.md`, revision 9.**
Revisions 15 were approved over four review rounds; revisions 69 are
corrections carrying the blocker above, and **revision 8's design as
scoped by revision 9 is what the tree implements**. Revisions 6 and 7
are described in that document as the record of why *not* those;
neither is in the tree and neither should be restored from it.
- **Implemented in four commits.** `779bb02` is the mechanism
(`pmacs.window.capture_destination()`, the `ViewDestination` rename,
the profile argument); `d5a6170` is
`tests/destination_capture_acceptance.rs`; `469d5c8` is the
revision-8 panel-profile correction plus the invalid-UTF-8 hole;
`394fa43` is revision 9's contract stack and the commit below adds its
cross-frontend pin. **15 pins**, and both preservation suites pass
**unchanged** (journey 47, dired 31) — §7's stop signal not firing
rather than being suppressed.
- **HOW THE PANEL PROFILE IS ENFORCED, in one sentence so no earlier
revision gets reinstated by someone reading only that document:** the
preflight stays exactly where it was, and the mutations that would
invalidate it are **refused at the attempt**.
- `EditorCore::panel_commit_dedication_refusal` is the one rule. It
fires while **any** `"panel"` `CommitContract` for this frontend is
in force — every contract on the stack, not the innermost — and is
consulted from `display_buffer` (before `apply_placement`, so a
refused attempt mutates nothing), `pmacs.window.set_params` (before
its borrow, so `fixed_rows` in the same table is not applied
either), and `quit_window`.
- **This is the same shape as `Handle:await` being refused inside a
commit scope**, and for the identical reason: something that would
invalidate the scope's guarantee is rejected outright rather than
predicted around or caught late.
- The contract (`CommitContract { destination, profile }`) rides on
the core in a **stack**, pushed and popped by the **same**
`ScopedFrontendGuard` that scopes the frontend, so a `"panel"`
profile can never outlive the body that declared it. The field is
private to the crate — Lua cannot claim a profile for a placement it
did not commit to.
- **A stack, not a slot, and the distinction is revision 9 (above).**
The frontend override and the ambient frontend are *substitutions*,
so a nested scope rightly replaces them; a contract is a
*restriction*, and replacing one suspends it. The guard stores a
depth and truncates back to it, so an inner exit removes exactly the
contract it added and leaves every enclosing one in force.
- **Matching is per FRONTEND as well as per profile, and that is a
deliberate exception with its own positive pin.** A nested commit for
a different frontend may dedicate *its* side slot: `resolve_placement`
consults only the requesting frontend's `panel_capable` and its own
one side window, so nothing done to B can change where A's side
request lands. Pinned by
`a_nested_commit_for_another_frontend_may_dedicate_its_own_slot`,
which is the file's only row asserting that something is **allowed**
— every other asserts a refusal, and an exception only the doc
comment knows about is one review round from being simplified out.
- **Prohibiting nested `commit_to` was the other candidate and was
rejected.** It closes the hole by forbidding a construction no rule
objects to — `commit_to` is public Lua API for saying where a
continuation's result belongs, and a body committing to a second
destination (a diff beside a status panel) is where #227's adoption
is heading. Only the restriction needed preserving. **No Lua in the
tree nests today** — `builtin/runtime/dired.lua` is the only
`commit_to` consumer and it does not — so this is a decision about
the API's future rather than about a live consumer, which is why it
is recorded rather than left implicit.
- **`panel_placement_can_fall_back` remains the preflight**, unchanged
in role: it measures whether this frontend places side requests in
the panel *right now*. With the invalidating mutations refused, that
measurement stays true for the life of the body, which is what makes
it a guarantee rather than a forecast.
- The four document checks live once, in
`EditorCore::document_destination_refusal`.
- **Three deliberate limits**, each a different decision rather than a
stricter version of this one: the **document profile is untouched**
(constraining its body would newly refuse dired's own documented
panel path — a preservation-suite stop signal); **dedicating a
document window is still allowed** (it cannot change which of
panel-or-document a side request resolves to); and **falling back is
still allowed** — a frontend that cannot render a panel degrades
gracefully exactly as today, because this refuses the mutation that
*manufactures* a fallback, never the fallback itself.
- **Mutation-checked per guard, and the pattern is the evidence the rows
are independent rather than one assertion repeated.** Deleting the
`display_buffer` guard fails the three `display{side, dedicated}` rows
— verified **individually**, by rotating each to the front of the
table, since the first failure otherwise masks the rest. Deleting the
`set_params` guard fails only that row and leaves the display rows
passing. Both leave every other test in the file green.
- **Audit: nothing else relied on "a panel never touches a document".**
Four doc sites repeated the claim (`ViewDestination`'s own doc twice,
`capture_view_destination`, `ViewDestinationLua`) and were corrected;
no other code depended on it. Dired — the only Lua `commit_to`
consumer — takes the **two-argument document profile**, so all four
checks already applied to it, and it separately documents and accepts
the side-slot fallback (`builtin/runtime/dired.lua`).
`compile.lua`'s `already_in_panel` queries live state rather than
assuming, and the terminal adopter's rollback keys off
`DisplayOutcome::created_side`, already false on a fallback.
- **TWO FRAMING CLAIMS THE TREE DID NOT MATCH.** Neither changed a
decision; both are recorded because the framing says "counted, not
estimated" and a reader will check.
1. **The rename was 11 references across 5 files, not 8 across 4.**
`src/daemon.rs:1804` also calls the capture (the attaching
frontend's directory open), and `editor.rs` holds six references
rather than the counted total. Mechanical either way.
2. **Q#DC-4's "a frontend with no document window" is a DEFENSIVE
branch, not a routine one.** The obvious spelling — a frontend
showing only a bottom panel — is asserted impossible: Q#BP6 says a
layout always retains at least one non-side window, and
`EditorCore::non_side_target` carries a `debug_assert!` that fires
under `cargo test` when one does. So with Q#BP6 held a *registered*
frontend always has a live document window. The decision still
stands (capture stays total; an adopter with nowhere to land gets a
refusal naming that rather than permission to fall back to ambient
state), and the two Q#DC-4 pins drive the reachable spelling of the
same condition — a layout whose document window has gone while the
view remains. **#227 should not expect to hit this refusal**; it is
insurance, not a path.
- **Mutation-tested, since a matrix of deliberate omissions is exactly
what passes vacuously.** Retyping the profile to `Option<String>`
fails the table and boolean rows with mlua's conversion error (the
number row survives — Lua coerces it — which is why the closed set is
witnessed by more than one non-string). Applying all four checks in
both profiles fails the panel column; applying only check 1 in both
fails the document column. Defaulting an omitted profile to `"panel"`
fails **`journey_acceptance`'s two preservation pins**, which is the
contract claim being executable rather than asserted. Dropping the
frontend scope for the panel profile fails the survives-a-switch pin's
panel row; dropping the no-document-window arm fails the Q#DC-4 pair.
**Revision 8's four, each isolating a different way to get it wrong**
and the pattern of *which* rows survive each is the evidence the parts
are independent rather than redundant:
1. delete the `panel_commit_dedication_refusal` call from
`display_buffer` → the three `display{side, dedicated}` rows fail,
**verified individually** by rotating each to the front of the
table so the first failure cannot mask the rest. Every other test
passes — which is exactly the hole an implementation guarding only
`set_params` would ship.
2. delete it from `set_params`**only** that row fails; the three
display rows still pass.
3. delete the `panel_placement_can_fall_back` arm from
`commit_destination_refusal`**only** the two pre-established
fallback rows fail, which is the preflight half.
4. make `panel_placement_can_fall_back` unconditionally `true` (the
"widen the predicate" non-fix) → the really-lands-in-the-panel pin,
the Q#DC-4 panel pin and the matrix's three panel rows all fail.
That is the two profiles collapsing into one, made visible — the
named fallback design, showing up as a test diff rather than
silently.
And reverting the byte comparison to `to_str()?` fails the
`invalid utf-8` row with mlua's conversion error, on content.
**Revision 9's two, each isolating a different half of the rule:**
1. restore `panel_commit_dedication_refusal` to reading only the
innermost contract (`.last()`, which is exactly revision 8's
swapped slot) → **only**
`a_nested_commit_cannot_mask_an_outer_panel_restriction` fails.
Note the ordinary-nesting pin deliberately survives this — it
exists to fail the *other* candidate fix (prohibit nesting), so the
two are a pair rather than one test written twice.
2. delete `&& contract.destination.frontend == fid` from the same
scan, making any outer `"panel"` contract **globally** restrictive
→ **only**
`a_nested_commit_for_another_frontend_may_dedicate_its_own_slot`
fails. Both single-frontend nesting tests pass under it, which is
the evidence they are independent of the frontend match rather than
merely looking so; the cross-frontend exception had no pin at all
before this row, since every other test in the file drives one
frontend.
Both were run across all three acceptance suites and the lib: in each
case `journey_acceptance` (47), `dired_acceptance` (31) and
`cargo test --lib` (1920) stay green, along with every other pin in
this file.
**The counts above are journey 47 / dired 31**, matching the bullet
further up. The mutation paragraph committed at `394fa43` had them
**reversed** in both the ledger and that commit's message; the ledger
is corrected here and the message is left as written, since rewriting
a pushed commit is worse than a footnote. A reader following that SHA
should take these numbers, not those.
- **The public API #227 adopts against (Q#DC-5), pinned so it is a
contract rather than an intention:**
`pmacs.window.commit_to(dest, body [, profile])`. Profile is an
optional trailing argument typed **`mlua::Value`, not
`Option<String>`** — with `Option<String>` mlua rejects a number or
table during argument *conversion*, before the closure runs, making
the promised "accepted values are…" message unreachable. That is the
same trap the existing binding documents for `dest`. Validated in the
body against a **closed** set — `"document"` and
`"panel"`. **Omitted means `"document"`**, so every existing
two-argument caller keeps all four preflight checks *by definition of
the signature*, which is what makes `journey_acceptance` passing
untouched a consequence rather than a hope. An unrecognized or
non-string profile **errors**, naming the accepted values — a silent
fallback would hand a caller different checks than it asked for,
which is the exact failure the parameterization exists to prevent.
Git's mapping is settled here too: `*git-status*` → panel,
`*git-diff*` → document. Revision 2 took three findings: Q#DC-2's parameterization was
incomplete (a panel depends on **none** of checks 24, not just check
3, so the question now carries a full preflight matrix with every
omission testable); `tests/journey_acceptance.rs` joins dired as a
**preservation suite and stop signal**, since it holds the
`commit_to` scope, forged-userdata, preflight and restoration pins
this lane generalizes; and the **coherence-impact section was missing
entirely**, which `CLAUDE.md` and `COHERENCE.md` §25 both require.
- **A PREREQUISITE LANE. PR #227 (git Stage 1) blocks on it.** #227's
P1a review finding is why it exists: git's async completions mutate
and display UI without capturing the initiating frontend
(`builtin/runtime/git.lua:609`, `:854`), so a result surfaces in
whichever frontend is active when git exits.
- **The mechanism existed but was not Lua-reachable** until `779bb02`.
`pmacs.window.commit_to` took a `DirectoryDestinationLua`, which is
**nonconstructible from Lua** by design
(`src/lua_bindings/mod.rs:4256`) and minted only inside the
`path.open-directory` listener dispatch (`src/editor.rs:1311`) from a
`pub(crate)` capture (`:1241`). So no async Lua continuation outside
a directory open could say where its result belongs. Line numbers are
the pre-lane ones, kept because they are what the finding was written
against.
- **Scope:** a Lua-reachable capture, a generic rename
(`DirectoryDestination` → `ViewDestination`; the framing counted 8
references across 4 files, the tree held **11 across 5** — see the
finding above), and the preflight question below.
**No adopter**: git's adoption is #227's work after this lands, since
a prerequisite that converts its own first consumer cannot be
reviewed separately from it.
- **The substantive question (Q#DC-2)** is that git's two continuations
differ in kind. `*git-status*` goes to the **bottom panel**
(`listview.open` defaults `display` to `"panel"`,
`builtin/runtime/listview.lua:550`); `*git-diff*` replaces a
**document** window. `commit_to`'s stale-intent check (Q#JR14c) is
right for the second and, *when the placement really is a panel*,
irrelevant to the first. One shape over-refuses the panel or
under-checks the document.
**DO NOT READ THE OLDER FORM OF THIS BULLET, WHICH SAID "the panel
never touches the captured window's buffer".** That is the claim
revisions 68 invalidate: panel placement **falls back** to an
ordinary document window when the frontend is not panel-capable or
its side slot is dedicated. The relaxation is conditional, and the
mutations that could make it fall back are refused inside a
panel-profile commit (revision 8) rather than predicted at preflight
(revision 6) or caught at placement (revision 7, which would refuse
after the callback had already mutated).
- **Stop signal recorded in the framing:** if any existing dired test
needs editing, the generalization changed Journey Stage 1a's
semantics, and that is cause to stop rather than to adjust the test.
- **Gates, as the executable line rather than a description:**
```
scripts/gate --acceptance destination_capture_acceptance \
--acceptance journey_acceptance \
--acceptance dired_acceptance
```
`--acceptance` is repeatable, so there is no reason for this ledger
to say "plus dired's" and leave the reader to reconstruct it.
**`journey_acceptance` and `dired_acceptance` are preservation suites
and a STOP SIGNAL**: they carry the `commit_to` scope,
forged-userdata, preflight and restoration pins this lane
generalizes, and if either needs editing, the change altered Journey
Stage 1a's semantics rather than closing a gap in them. No
`--protocol` — core and Lua bindings only.
## Worker identity Stage 1 (§9) — MERGED as #232 (`3cc1b85`)
**Written with the lane's first commit**, per the standing correction
from #171 and #215.
**Branch `worker-identity-stage1`**, base `githubsucks/main` @
`4bc55e8` (the #225 merge). **`githubsucks/worker-identity-stage1` is
the authoritative tip** — the ref, not a SHA. Recover with
`git fetch githubsucks && git checkout worker-identity-stage1`.
- **Framing `docs/worker-identity-framing.md`, revision 4, APPROVED
2026-08-09** after four review rounds.
Scope: `COHERENCE.md` §9's "mechanism without identity", and journey
step 11 — the last of Priority 1's own work, sitting in another
section's arc.
- **Revision 2 took two blockers.** `owner` is **removed entirely**:
populated from static per-subsystem constants it is an origin, not an
owner, and would misattribute third-party work at the exact point §9
wants attribution. It is not retained under a safer name either —
`origin`/`subsystem` would be adopted as ownership by use and would
squat on the slot P3 must fill. And the handler-name recovery was
**respecified as a mechanism**: revision 1 claimed the name was "in
hand at the one place that throws it away", which was wrong about the
call chain (`dispatch` → arbitrary handler → Lua wrapper → Rust
binding, with the wrapper layer documented as bypassable).
- **Revision 3 took a third blocker: the ambient's extent is not
synchronous.** A handler may `Handle:await()` and park with the name
still pushed, leaking attribution to unrelated later work. Rule 1 now
**enforces** non-yieldability, modelled on the existing
`_in_commit_scope()` refusal in `Handle:await`
(`builtin/runtime/async.lua:87-90`) — rejecting before the park,
unconditionally rather than only when a yield would occur, and
covering **both** yield points.
- **Q#W-7 — a pre-existing defect found while scouting that guard, and
APPROVED for repair in this lane.** `pmacs.async.yield_to_next_tick()`
(`async.lua:243-245`) is public, yields, and carries **no**
`_in_commit_scope` refusal — so Journey Stage 1a's Q#JR14b invariant
has a second entrance. Same helper, same invariant, same edit family,
so splitting it would have preserved a known hole without reducing
integration risk. **Reachability by a real caller is UNPROVEN** — the
defect was found by reading, and the tests pin the guard rather than
reproducing a user-visible bug. That belongs in the commit message so
nobody later cites this as an observed failure.
- **Revision 4 also scoped rule 1's claim to what it enforces.**
Revision 3 said "all yield points"; it covers **the two supported
pmacs yield APIs**. Raw `coroutine.yield` stays reachable — R46 is a
convention, and the scheduler diagnoses a non-Handle yield only after
the coroutine has suspended (`async.lua:197` resumes, `:212`
inspects), so no refusal in a yield helper can intercept it. Recorded
as a residual, and explicitly **not** covered by a test that would
imply otherwise.
- **NO WIRE CHANGE**, which is what lets this run beside the two lanes
already in flight. The statusline activity indicator is a **fourth**
`pmacs.statusline.register` provider (terminal/syntax/lsp are the
three existing adopters), evaluated per frame inside `paint_frame`
(`src/editor.rs:4560`) and riding the existing `StatuslineSegments`
vector. No variant, no bump.
- **Scope:** a **required** `purpose` on `PendingJob` and `ProcessSpec`
through the single allocation funnel (`src/async_runtime.rs:746`,
which every dispatcher and `register_external` passes through), a
runtime-owned dispatch-name ambient recovering the handler name that
`pmacs.workers.dispatch` currently discards, the `*workers*`
rendering, and the indicator. Non-optional so the **compiler**, not a
test, proves every caller supplied one.
- **Two scouting findings that shaped the design**, both verified:
`PendingJob` carries **eight** fields, not the audit's seven, and the
eighth's doc comment **cites §9 by name** as the reason identity
belongs on the job rather than in a side map — so this extends a
merged decision. And **`pmacs.process.list` filters to
`LineOriented`** (`src/lua_bindings/mod.rs:8980`), with **three
acceptance suites using `#pmacs.process.list()` as a leak detector**,
so making terminal PTYs visible is deferred to Stage 2 with a
separate accessor rather than by widening this one.
- **Deliberate deviation from the audit, flagged for review:** §9 names
owner/**purpose**/parent together as the prerequisite; Stage 1 takes
**only `purpose`** — one of the three, not two. `owner` was removed in
revision 2: nothing in the runtime knows which package asked for a
job, so an `owner` field could only have been filled with the same
handler name `purpose` already carries, and an empty one reads as
"unowned" rather than "not tracked". `parent` is out for the matching
reason — it needs an ambient "currently-running job" context, and an
unpopulated `parent` reads as "no parent" rather than "not tracked"
(Q#W-5). The package-ownership slot stays **deliberately empty** until
P3 can fill it with a real signal (framing §3, §7).
- **Gates:** `scripts/gate --acceptance worker_identity_acceptance
--acceptance journey_acceptance --acceptance
statusline_segments_acceptance --acceptance compile_mode_acceptance
--acceptance m8_6_acceptance`. No `--protocol` — no wire change.
`compile_mode` and `m8_6` joined at review round 1, which moved their
spawn call sites; `m8_6` covers the `pmacs-magit` fixture, and a newly
required field is exactly the kind of change that breaks a package
fixture quietly.
- **IMPLEMENTED at `1aca0ee`**, with review round 1's blocker fixed at
`2162737` and review round 2's three findings at `6661125`.
`tests/worker_identity_acceptance.rs` is the new suite: **24 tests**,
plus one consumer-side witness beside the private renderer in
`pmacs-gpu`.
- **`journey_acceptance` passed UNTOUCHED (47/47)** — the stop signal
did not fire. Q#W-7 edits the `commit_to` guard family, so any of its
established pins needing an edit would have meant this altered Journey
Stage 1a's semantics rather than closing a gap in them. Its diff
versus `main` is empty, and so is the diff for all three
`#pmacs.process.list()` leak-detector suites
(`m6_8_multi_repl_acceptance`, `compile_mode_acceptance`,
`lean4_stage1_acceptance`) — Q#W-4's preservation claim, checked the
way the framing asked.
- **One pre-existing assertion did change, and it is an inventory
rather than a contract**: `statusline_segments_acceptance`'s builtin
provider list becomes `["activity", "mode", "terminal", "lsp"]`.
`activity` sorts first because `async.lua` is loaded before
`syntax.lua`, `terminal.lua` and `lsp.lua`. That assertion exists to
grow when a builtin provider is added; it is listed here so the change
is not mistaken for an accommodation.
- **23 mutation checks, each test falsified by removing its own fix.**
The ones worth naming: siting the `await` guard *inside* the
`_is_complete` branch (the already-complete case then slips through —
which is the whole reason the guard is unconditional); replacing
`pcall`/pop/rethrow with a bare handler call (a raising handler leaves
the name pushed and the *next* dispatch inherits it); composing
`"<name>"` instead of `"<name>: <purpose>"` and vice versa (each half
passes the other's test); `first()` instead of `last()` on the name
stack; oldest→newest in `activity_summary`; and, on the GPU side,
painting an unthemed modeline face as the band colour, which would
have made the indicator invisible without failing anything else.
One of the twenty is a **preservation** check rather than a new
claim: bracketing `pmacs.workers.dispatch` with
`local ok, result = pcall(...)` truncates a handler that returns more
than one value, which every other test in the suite tolerates. Round
1 added three more against the spawn refusal: restoring the
label fallback, accepting an empty/whitespace-only purpose, and
reading the field non-raw so a metatable can smuggle one in.
- **Two residuals, stated rather than tested around.** Raw
`coroutine.yield` inside either dynamic scope still leaks the scope —
loudly, through `pmacs.error`, but it leaks; no refusal sited in a
yield helper can intercept it (framing §2). And Q#W-7's reachability
by a real caller stays **unproven**: the commit message says so, and
the test pins the guard rather than reproducing a fault.
- **Review round 1 blocker — `pmacs.process.spawn` now REQUIRES
`purpose`.** The first implementation made it optional at the Lua
surface, falling back to `label`. That preserved compatibility and
delivered nothing: §9's complaint about `ProcessSpec` is exactly that
`label` is "caller-supplied, unvalidated convention", so a purpose
defaulting to it hands every caller back the convention the lane exists
to replace. Refused on five shapes — absent, empty, whitespace-only,
wrong type, metatable-provided — each asserting the process list is
unchanged, since a validation that rejects after spawning has already
done the thing it rejected.
- **That is a BREAKING CHANGE to a public Lua API, taken now on
purpose.** §10 grades extension trust "missing (one class)" and P7
package lifecycle has not started, so the third-party population is
~zero and the cost only rises later. Checked for a reason that would be
wrong and found none: `pmacs.process.spawn` has no API-reference
documentation and no stability promise in `docs/` (the package-author
guide's only mentions are an audit-rule classification and a pointer to
the bundled REPL; its semver language governs packages' own versioning,
not pmacs's Lua surface), and `lua_to_spec` has exactly one caller.
**Eleven executable call sites updated**, each with a real description
rather than the label copied across: `repl/init.lua`, `compile.lua`,
`lean.lua`, the `pmacs-magit` fixture, and seven in tests. The two
`pmacs.process.spawn("ls")` occurrences in `src/audit/mod.rs` and
`tests/m7_9_acceptance.rs` are **audit fixture source text** — lexed,
never executed — and are deliberately untouched.
- **Review round 2 — the display-text boundary, fixed at `6661125`.**
Three findings, and the fix is deliberately different in each place
because the constraint is.
- **P2a: invalid UTF-8 bypassed the `purpose` diagnostic.**
`required_purpose` read the field with `value.to_str()?`; Lua strings
are BYTE strings, so `purpose = string.char(255)` surfaced mlua's
generic conversion error before this lane's own message existed. It
refused before spawning, so nothing leaked — the defect was the
message. **Third occurrence of this class in the project** (the
destination-capture lane corrected the same shape two rounds ago), so
the whole diff was audited for it: exactly one more,
`_push_dispatch_name` taking `name: String`, now `mlua::String` with
an owned diagnostic. Those two are the only Lua-string reads this
lane added; every other binding it adds takes `()`. The remaining
`pmacs.process.spawn` fields (`label`, `command`, `args`, `env`,
`cwd`) still convert generically — **pre-existing, untouched, and
named here rather than silently inherited.**
- **P2b, half one: handler names are refused at the source.**
`pmacs.workers.register` type-checked and nothing more, which was
fine while the name died inside `dispatch`. It no longer dies there,
so the name now gets `purpose`'s meaningful-value standard plus
control characters.
- **P2b, half two: purposes are ESCAPED at presentation, not rejected
at the registry — consistent with the `#228` decision.** A purpose
may legitimately contain a newline (a path can; `pmacs-magit`'s spawn
purpose is an argv), so the one-line constraint belongs to the
surface that has one row. `purpose_for_one_row` states the property
it exists for — **a row must not be able to forge another row**
escapes the Unicode `Cc` class (so ESC cannot open a terminal
sequence either), borrows unchanged when there is nothing to escape
(byte-identity is structural, not asserted), and does **not** escape
backslashes: no number of them makes a second row, and doubling them
would cost byte-identity for ordinary text. Two callers: the
`*workers*` rows and `ActivitySummary`, which exists for one consumer
with exactly one row. `pmacs.workers.snapshot()` is the
`describe-command` of this lane and stays raw — asserted, so a clip
that deleted the text everywhere would fail rather than pass.
- **P3: two stale recovery summaries**, both fixed section-locally —
the framing doc's "Implementation may proceed", and this file's claim
that Stage 1 took the "first two" of owner/purpose/parent. It takes
**one**: `owner` was removed in revision 2, and the claim that
argument overturned was still standing here.
- **Seven more mutation checks, each failing its own test and no
other** (30 for the lane): the two UTF-8 diagnostics, the two
register guards, the two escaping call sites, and
`purpose_for_one_row` neutered to the identity — which fails both
surfaces' tests and nothing else, since it is the shared helper.
- **All 13 gate steps green at `6661125`** (log
`20260809T173314Z-1552101`): lib 1920, lib-crdt 2105,
worker_identity 24, journey **47/47 UNTOUCHED**, statusline 7,
compile_mode 73, m8_6 12, m4 151, gpu 242. The three
`#pmacs.process.list()` leak detectors and `journey_acceptance` are
**byte-identical to `main`** in round 2 — the stop signals did not
fire, and round 2 edited no test outside its own suite. **The
preceding run of the same command was red on three tests and none of
them was this diff's** — R7 for the third time plus two wall-clock
budget tests; recorded in `docs/ci-red-signatures.md` rather than
re-run away silently.
- **Review round 3 — a diagnostic that named the wrong surface, fixed
at `b2e8efd`.** `required_purpose`'s invalid-UTF-8 refusal told the
caller their process purpose "is displayed to the user in `*workers*`
and in the modeline". **Neither is a process surface.** Stage 1
deliberately keeps processes out of both (Q#W-4, framing §3) — a
process's purpose is exposed through `pmacs.process.list` and nothing
else — so the message sent the reader looking for their process in two
places it will never appear. The refusal itself is correct and stays:
a purpose with no display form anywhere is still refused.
- **The two UTF-8 refusals now name different surfaces, because they
reach different ones.** The job-side twin (`_push_dispatch_name`)
legitimately names `*workers*` and the modeline — a handler name is
composed into a job's purpose, and a job does render in both — so it
was made to say so explicitly rather than left at the vaguer "as
part of every job's purpose", which named no surface at all and
would have made the divergence unassertable.
- **A new test asserts both directions, positive and negative**
(`the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`,
25 in the suite — 24 before this round, plus this one; an earlier
revision of this bullet said 26): the process message contains
`pmacs.process.list`
and **not** `*workers*`/`modeline`; the job message contains both of
those and **not** `pmacs.process.list`. The existing row-table
assertion in `spawning_without_a_real_purpose_is_refused_and_starts_nothing`
now runs as far as the surface name too. Without the negative half a
later "unify the wording" edit reintroduces exactly one wrong
sentence and passes everything else.
- **Three mutation checks, each red on its own claim:** restoring the
old process wording fails both content assertions; collapsing the
job message onto the process wording fails only the new test (which
is the point — the old job test asserted the prefix alone); and
restoring the job message's original vague wording fails it too.
- **The rustdoc carried the same defect risk and was fixed with it**
`required_purpose` now states which surface it names and why not the
other two, and the `_push_dispatch_name` comment states the
converse. A string literal corrected while its doc comment still
argues the other way is one refactor from reverting itself.
- **Gate: all 13 steps green at `cb7730d`** (log
`20260809T200907Z-2672209`). **The two preceding runs of the same
command were red on step `12-sweep`, on a DIFFERENT wall-clock
render-budget test each time** (`20260809T195332Z-2113672`,
`20260809T200120Z-2427128`; load average 12.9/23.9 with sibling
lanes building). All three pass in isolated reruns, none reds twice,
and the diff is two string literals, their doc comments and one
test — no render path is touched. Recorded as **U7** in
`docs/ci-red-signatures.md` rather than re-run away silently.
`journey_acceptance` **47/47 UNTOUCHED** and the three
`#pmacs.process.list()` leak detectors unedited — the stop signals
did not fire.
- **Surfaces that changed shape, for anyone rebasing onto this:**
`AsyncRuntime::allocate`/`allocate_with_resource` collapsed into one
private `JobSpec`-taking funnel; `register_external` grew a third
parameter; `ProcessSpec::new` grew a third parameter (~40 call sites,
nearly all tests); `ActiveJobInfo`/`CompletedJobInfo`/`ProcessSpec`
each grew a required `purpose` field, and `pmacs.process.spawn`
requires `purpose` in its spec table.
## Discovery Stage 2 — PR #228 OPEN, **MERGE-BLOCKED**
**PR #228** — https://github.com/levineuwirth/pmacs/pull/228. Opened
2026-08-09 at `2d298dd`. **Open for review, not for merge.**
**The block is a gate-integrity problem, not backlog hygiene.** This
lane's gate is `scripts/gate --protocol`, which promises the CRDT
workspace sweep. That sweep's documented precondition is
`cargo build --workspace --no-default-features --features luajit,crdt`
(handoff §5), and **the script does not run it** — confirmed by reading
its plan emitter. On a fresh per-worktree target directory the sweep
fails on twelve `gpu_invocation_acceptance` tests missing the
`pmacs-gpu` binary, so a `--protocol` result can be decided by the
state of the build directory rather than by the diff.
Latent until #225 gave each worktree its own target dir — a shared one
usually already had `pmacs-gpu` built, satisfying the precondition by
accident. It surfaced on this branch's first gate run.
**Unblocking requires both:** the `scripts/gate` repair, in its own
narrow framing and its own PR (explicitly **not** folded into this
feature branch), and then a **fresh-target rerun of this branch's
protocol gate** under the repaired script.
**Written with the lane's first commit**, per the standing correction
from #171 and #215.
**Branch `discovery-stage2`**, base `githubsucks/main` @ `4bc55e8`
(the #225 merge). **`githubsucks/discovery-stage2` is the authoritative
tip** — the ref, not a SHA. Recover with
`git fetch githubsucks && git checkout discovery-stage2`.
- **Framing `docs/discovery-stage2-framing.md`, revision 3, APPROVED
2026-08-09** after three review rounds. Each round found the previous
one reasoning about a mechanism instead of reading it — an in-place
field change that postcard cannot make compatible, a TUI that never
reads the message at all, a round-trip test that freezes nothing, a
cache hazard the per-peer render state makes impossible, and a
clipping rule unachievable at narrow widths.
Scope: `COHERENCE.md` §5's "M-x rows are still bare names".
Descriptions already exist on `Command` and are already rendered by
`help.list-commands`; they are missing at the one moment they would
change a decision.
- **PROTOCOL BUMP v22 → v23, and this lane HOLDS THE BUMP SLOT.**
Additive: a new `MinibufferPromptRows` variant **appended** to the
enum, with `MinibufferPrompt` **frozen** for v12v22. An in-place
field change is a wire break — postcard encodes positionally, and
that variant is sent to every peer `>= 12` (`src/daemon.rs:1472`).
- **Git Stage 2 (gutter markers) also needs a bump and must wait for
this to land.** Git Stage 1 is no-wire and runs beside it.
- **Two halves, only one of which is wire work.** `pmacs-gpu` renders
the new variant. **The grid TUI never reads `MinibufferPrompt` at
all** — it paints from `core.minibuffer` and renders
`format!(" [{cand}]")` (`src/editor.rs:5484`), so its half is a
local formatting change reading the registry directly. A multi-row
TUI chooser is explicitly NOT this lane.
- **Gates:** `scripts/gate --protocol --acceptance
discovery_stage2_acceptance --acceptance m9_6_acceptance --acceptance
m9_7_acceptance --acceptance m9_8_acceptance` — the strengthened
two-configuration sweep, which is what `--protocol` exists for. The
three m9 suites are named because the PR #228 review round measured
them as this change's blast radius (see the description-clip bullet);
their continued passing is on the record rather than assumed.
**`--protocol` does NOT run its own documented precondition**
(`cargo build --workspace --no-default-features --features
luajit,crdt`, handoff §5) — run it by hand first or twelve
`gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu`
binary. That omission is the `gate-protocol-build` lane's, not this
one's.
- **IMPLEMENTED.** `PROTOCOL_VERSION` is 23,
`ADVERTISED_PROTOCOL_VERSION` is untouched at 20. New suite
`tests/discovery_stage2_acceptance.rs`; the daemon half is
`crdt`-gated (a semantic session is necessarily a text replica) and
runs one daemon serving a v22 and a v23 session simultaneously.
- **Multi-line descriptions are clipped AT THE SURFACE, and
registration-level rejection was investigated and REJECTED ON
EVIDENCE — do not re-propose it.** PR #228 review found the real
hazard: the GPU dropdown derives its height, visible window and
highlight offset from `rows.len()` (one logical row per candidate),
so a detail carrying a line break misaligns every row below it; the
TUI writes into a single-row band. The obvious fix — reject CR/LF in
`CommandRegistry::define` — was implemented and measured, and it
**fails 36 tests across `m9_6`/`m9_7`/`m9_8`**, because MCP tool
registration renders a whole schema block into `description`
(`tests/fixtures/pmacs-mcp-tools/init.lua:272`,
`table.concat(lines, "\n")`, used at `:496`) and
**`tests/m9_6_acceptance.rs:583-598` asserts four separate lines of
it** — tool text, `Arguments:`, and two per-argument lines. No
single-line rendering satisfies those assertions, so a registry guard
could only go green by deleting a shipped acceptance criterion.
The one-line constraint belongs to the surfaces that have it:
`Command::description_first_line` clips, both single-row consumers
call it, and the full text still reaches `describe-command` /
`help.list-commands` untouched. Precedent already in-tree — the same
MCP fixture clips a tool RESULT to its first line because *"a
multi-line set_status would corrupt the row layout"* (`:277-285`).
**A startup census is not a corpus census**: booting an
`EditorState` and scanning all 180 registered descriptions found zero
offenders, because MCP registers at RUNTIME and builds the string by
concatenation — invisible to both that census and a grep for literals.
The workspace sweep is what caught it.
- **The freeze is enforced by LITERAL byte fixtures**, not a round-trip
`minibuffer_prompt_v12_wire_bytes_are_frozen` in `src/protocol.rs`,
the first such fixture in this repo. Bite-verified: reordering two
fields of `MinibufferPrompt` leaves
`minibuffer_prompt_round_trips_through_postcard` **passing** and fails
the fixture, which is exactly the hazard a round-trip cannot see.
- **Version assertions updated (five, each read before editing):**
`src/protocol.rs` — the `PROTOCOL_VERSION == 22` tripwire (renamed
`protocol_version_is_twenty_three_for_minibuffer_prompt_rows`) and
`supported_protocol_versions_resume_ladder_on_v6_floor`'s
accepted/rejected ranges; `tests/statusline_segments_acceptance.rs`
(version + supported range + the `!supported` ceiling);
`tests/bottom_panel_stage2b_gpu_acceptance.rs`;
`tests/vterm_stage3_acceptance.rs`. **No `ADVERTISED_PROTOCOL_VERSION`
assertion fired**, which is the pin doing its job.
- **No cross-version cache test, deliberately** (framing §3.2/§6):
`SemanticRenderState::for_peer` bakes the negotiated version in at
attach and is dropped at detach, so a cache cannot span two versions.
A test for an impossible condition passes forever while teaching the
next reader that the hazard is real.
## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet
**Written with the lane's first commit**, per the standing correction
@ -1489,7 +602,7 @@ authoritative tip** — the ref, not a SHA. Recover with
emission, an aborting runner, the build folded into `sweep-crdt`, and
— added in the second round — a **rename of either** the build or the
sweep step each fail the suite.
||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last)
## QoL arc retirement — PR #224 OPEN (docs only)

View File

@ -1,20 +1,6 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-08-11.** `main` is **`b867f64`** — git integration
Stage 1 **#227** (`*git-status*` / `*git-diff*`, no wire change), atop
`ae84d58` **#234**, the LSP file-watcher correctness fix (issue #233
D1+D2, one review round; **D3 — the polling cost — is deliberately
unfixed and is the ruled next lane**). Beneath them, in first-parent
order: **#231** destination capture, **#232** worker identity Stage 1,
**#228** discovery Stage 2, **#230** LSP LaTeX coverage, **#229** the
gate `--protocol` build step, **#225** per-worktree gate target dirs,
**#226** the R8 fixture fix, and **#224** the QoL docs retirement.
**Only #227 and #234 are absorbed into §1 at this anchor**; the eight
between carry their facts in their `docs/active-work.md` lanes, several
of whose headers still say OPEN — trust this chain over any lane
header, per the ledger's own rule.
Previously **2026-08-08**: `main` was `9a26ac8` — GPU horizontal
**Last updated: 2026-08-08.** `main` is **`9a26ac8`** — GPU horizontal
scroll **#223**, which **closes the QoL arc** (§1). Beneath it the arc's
other four: **#222** TUI horizontal scroll, **#221** `ui.line-wrap` at
protocol v22, **#220** GUI zoom, **#219** `full_grid` honored by the
@ -100,94 +86,8 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-08-11)
## 1. Where the project stands (2026-08-08)
- **Git integration Stage 1 — MERGED as #227 (2026-08-11).**
`*git-status*` (a `listview` panel over `git --no-optional-locks -C
<dir> status --porcelain=v2 --branch -z`) and `*git-diff*`
(file-level, plain generated text), plus an install-once `keys`
extension on `listview`. No wire change; **Stage 2 (gutter markers)
needs new `DecorationKind` variants — a `PROTOCOL_VERSION` bump — and
must be scheduled alone.** Held unmerged behind issue #233 by user
ruling, then landed the same day as #234, refreshed and re-gated on
the merged base. Durable facts:
- **Capture at invocation, never read at continuation — enforced by
ONE mechanism, not four counters.** Review found FOUR instances of
the same shape (ordering by `rev-parse` completion; `state.root`
read mid-plan; no generation on the diff path; one byte inside a
fix). `new_channel()` tickets answer "is this still the request in
force?" — **two channels, deliberately**: status and diff are
independent things a user asks for, so each gets its own ordering
and what is shared is the mechanism. The async-continuation census
(three continuations, one dispatcher, one synchronous impostor)
lives in the PR and framing.
- **macOS cannot hold a non-UTF-8 filename, and this project will
hit it again.** APFS/HFS+ reject invalid UTF-8 at the syscall
(`EILSEQ`, errno 92); Linux's VFS treats names as opaque bytes. It
cannot be reached around the filesystem either: an index-only
entry still fails `git status`'s lstat with `EILSEQ`, which git
skips — **there is no macOS arrangement in which real `git status`
names a non-UTF-8 path.** The fix pattern: split coverage along
the line the platform draws — behaviour runs everywhere (rows
supplied through the `_deliver_status` seam), only *provenance* is
Linux-gated. A latent sibling in `gpu_invocation_acceptance`'s
crdt module is recorded in the git lane.
- **Both root-parsing bugs lived in a pattern.** `rev-parse
--show-toplevel` gets exactly ONE trailing `\n` removed, by an
explicit last-byte test: `\r` is a legal POSIX name byte, and
`rev-parse` has **no `-z`** — it echoes a literal `-z` onto stdout
with exit 0, checked against the installed git rather than
assumed. `first_line` was deliberately left alone both times: its
three callers feed the single-line status band, where truncation
is right.
- **`{:?}` on a string containing NUL cannot build a `-z` fixture**
— Lua's decimal escape swallows following digits, so payloads are
assembled as raw bytes with three-digit escapes, joined in Lua
with `string.char(0)`.
- **A copy renders `copied from`, but `kind` stays `"rename"` for
both, deliberately.** Every behaviour keyed on it is identical;
splitting would force every present and future consumer to spell
both arms, and a forgotten arm silently degrades copies. The score
byte carries the distinction where presentation needs it.
- **LSP file watcher — D1+D2 MERGED as #234 (2026-08-11); D3 is
next.** Issue #233: with any server that dynamically registers
`workspace/didChangeWatchedFiles`, plain-string globs never matched
(matched **relative** where LSP says **absolute** — rust-analyzer
saw no file change, ever; gopls saw `go.mod` but never `.go`), and
re-registering a live id leaked the previous pollers uncancellably
(rust-analyzer registers twice under one id: 12 pollers, 6
unreachable). Invisible for three months until #232's activity
indicator — §9's instrument doing exactly its job. Durable facts:
- **The GlobPattern form travels with the pattern, and it is read
FROM the pattern, not from the union arm.** `resolve_watcher`
returns `(base, pattern, form)`; a leading `/` is what makes a
string absolute. The first fix classified every string absolute —
repairing rust-analyzer while silently breaking bare `*.txt`, a
case that had worked since May. Review caught it (P1).
- **A scan that completes after cancellation must not emit (P2).**
The watcher coroutine spends most of a tick suspended in
`read_dir` awaits with `_sleep` already cleared, so a cancel
landing there had nothing to interrupt and the resumed scan
emitted one stale batch under the superseded pattern. Cancellation
and liveness are rechecked after the scan;
`pmacs.lsp._after_scan_for_tests` (nil in production, handed the
scan result) exists because no real timing produces that
interleaving on demand — `git.lua`'s `_deliver_status` device
again.
- **F1's lesson fired twice in one lane.** The pre-existing test was
insensitive (`**/` compiles to `.-`, which spans `/`, so it passes
under either match subject) — and then the lane's own flat-pattern
guard constrained the RelativePattern *object* arm while P1's
regression lived in the *string* arm. A guard proves things about
the arm it exercises, nothing more.
- **All six watcher tests are mutation-verified, each bite failing
only its own defect** — the two review fixes re-verified
independently after review.
- **D3 is deliberately unfixed and ruled next**: the walk still
recurses into everything every 250 ms, six jobs per tick for
rust-analyzer. The D3 lane in `docs/active-work.md` carries what
was checked (no notify dependency, no ignore-list infrastructure)
and the option space.
- **QoL arc — CLOSED. All five stages merged (#219, #220, #221, #222,
#223).** From one daily-driver report: terminal zoom broke TUI
rendering and did nothing in the GUI, and a long line was unreadable
@ -2698,30 +2598,11 @@ 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;
v22 = `LineWrapFacts`; v23 = `MinibufferPromptRows`. New wire
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
must be guarded by a byte pin on the PREVIOUS final variant — its own
round-trip cannot detect a discriminant shift.
**A SUPERSEDED variant can be frozen rather than widened, and v23 is the
first case.** Discovery Stage 2 needed richer minibuffer rows.
Widening `MinibufferPrompt` in place was not an option — postcard
encodes fields positionally, so every v12v22 peer would **mis-decode**
the bytes rather than ignore them — and gating the widened form at
`>= 23` would have left those peers with **no minibuffer message at
all**, because there would have been only one variant to gate.
Compatibility requires the old shape to still exist *and still be sent*.
So `MinibufferPrompt` is retained unchanged for `12..=22`,
`MinibufferPromptRows` is appended for `>= 23`, and the daemon gate is a
**range on both sides** so exactly one variant reaches any peer.
Two consequences worth carrying forward: a frozen variant needs a
**literal byte fixture** (`assert_eq!(encoded, LEGACY_BYTES)`), because a
round-trip encodes and decodes with the same types and so freezes
nothing; and the CLOSE message must use the same variant family as the
OPEN, or a session closed by the other family's clear leaves its surface
on screen forever.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
`sighelp`. Use these for capability-matrix tests, not real servers.

View File

@ -496,108 +496,30 @@ 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** | **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 |
| **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 |
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. 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 |
| **rerun evidence** | 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Per the rerun rule this establishes **intermittence only** |
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
**Not attributed to 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`.
**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.
**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:
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence
* **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.
Has a selector, which U1 lacks — but still no fragments, so it cannot
be matched either. Recorded so a recurrence is recognisable.
| field | value |
|---|---|
| **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` |
| **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus |
| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` |
| **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 |
| **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) |
| **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
@ -630,54 +552,6 @@ it again here by piping a sweep through `grep`. The fix is mechanical:
stream. A signature that is cheap to capture and impossible to
reconstruct should never be traded for terminal brevity.
*(Renumbered from U4/U5 to **U6/U7** on the rebase onto `0857bf4`: `gate-protocol-build` landed its own U4/U5 in #229, and git merged both files **without a conflict**, producing duplicate ids across four sites. The pre-rebase warning is retired here because it has been carried out.)*
### U6 — two wall-clock budget tests fail together in one `lib-crdt` step
Recorded during worker identity Stage 1 review round 2, 2026-08-09, in
the same gate run that produced R7's third occurrence. **Fragments were
captured**, so unlike U1U3 this one is matchable — it is a `U` row
because it has one occurrence and no mechanism, not because the evidence
was lost.
| field | value |
|---|---|
| **selector** | `--lib --features crdt optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same run |
| **job / flavor** | local (Linux), `scripts/gate` step `04-lib-crdt`, with sibling worktrees building concurrently |
| **required fragments** | `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms`; and `composition machinery added more than 10% overhead` |
| **status** | **new incident, one occurrence, not reproduced** |
| **what IS established** | both are **wall-clock budget assertions** — 1.264ms against a 1ms budget, and 1.297× against a 1.10× budget — so both are load-sensitive by construction. Both green in an isolated rerun of exactly those two selectors, and both green in the next full gate run of the same command (2105 passed) |
| **what is NOT** | whether the machine's concurrent load caused it. The confound is real (this machine runs one shared `CARGO_TARGET_DIR` and several worktrees) but **was not measured**, so it is a rival explanation, not a finding |
| **rival explanation not excluded** | a genuine regression in either path. Nothing in the observing diff touches the optimistic-echo orchestrator or the composition pipeline, but "my diff looks unrelated" is not evidence, and this row does not treat it as such |
**Two budget tests failing in one run and neither in the next is the
signature worth matching**, more than either name alone: a real
regression in two unrelated subsystems at once is far less likely than
one loaded machine. If a future run reds **one** of these without the
other, that is a different incident and should be judged as one.
### U7 — a *different* wall-clock render-budget test reds each sweep
Recorded during worker identity Stage 1 review round 3, 2026-08-09.
**Two consecutive `scripts/gate` runs of the same command, on the same
tree, red on step `12-sweep` with a different test each time** — which
is the signature, and it is a stronger one than any single selector.
| field | value |
|---|---|
| **selector** | run 1: `--test m8_2_acceptance dired_open_renders_10k_entries_under_200ms` **and** `--test m8_9_acceptance outline_5_level_100_entry_renders_within_100ms`; run 2: `--test dired_acceptance dired_renders_10k_entries_within_200ms` |
| **job / flavor** | local (Linux), `scripts/gate` step `12-sweep` (`cargo test --workspace --no-fail-fast`), **load average 12.9 / 23.9** with sibling worktrees building concurrently |
| **required fragments** | `must render within 200ms; took ` / `open() (parse + render) took ` + `spec budget is 100ms` |
| **status** | **new incident, three selectors, none reproduced** |
| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, no selector reds twice, and **the third run of the same command on the same tree was green on all 13 steps** (log `20260809T200907Z-2672209`). The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot |
| **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding |
| **relation to U6** | same shape, different step and different tests: U6 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows |
**The rotating selector is the thing to match.** A regression that
moved between three unrelated render paths on an unchanged tree is far
less likely than one loaded machine; a future run that reds the *same*
one of these twice is a different incident and should be judged as one.
**The retirements are not occurrences and do not close the log.** R1 and
R3 stay live, and each retired row keeps its signature so a later red
matching one reopens it.
@ -693,40 +567,18 @@ 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
### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS `lua54`, one occurrence
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.
Surfaced on PR #229's CI.
| field | value |
|---|---|
| **selector** | `--test full_grid_resync_acceptance a_pty_resize_blanks_the_host_before_repainting` |
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)` **and** `Test (macos-latest / luajit)`, `macos-26-arm64`. **Flavour is not a matching key for this row** |
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, `macos-26-arm64` |
| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the` · `bytes emitted after the first painted frame` |
| **NOT fragments** | the byte count and the `:LINE` suffix are **occurrence-specific** and must not be matched on — the count is the collected suffix length, which varies per run, and the line moves with the file |
| **status** | **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 |
| **status** | **one occurrence; INTERMITTENT — passed on rerun** |
| **why the diff is excluded** | #229 changes only `scripts/gate`, `tests/gate_script_acceptance.rs` and documentation — **no `src/`, and the workflow never invokes `scripts/gate`**. Decisively, `full_grid_resync_acceptance` runs **before** the changed gate suite, so even a cross-suite leaked-state path is not available. The `luajit` leg passing on the same commit is **corroboration only** — a deterministic defect *can* be Lua-flavour-specific, so that observation must not be used as a structural exclusion |
| **what IS established** | **no blank was OBSERVED after the mark** within the test's fixed 20-second deadline. The collected suffix was the **entire** post-mark output (`suffix.len()`, 25 362 bytes on this occurrence — not a capped window; only the *displayed* head is truncated to 400 bytes), and that head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was painting |
| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted after the deadline, or lost in transport is **open** — and "it never emitted the blank" is a claim this evidence does not support. **The failing run's ~20 s duration is the fixed `Duration::from_secs(20)` timeout**, so the spread against a fast passing run is mechanically determined and is **not** independent timing evidence |
| **discriminating control — ASYMMETRIC, and only one direction concludes** | the suffix is already complete, so "capture more bytes" is not the gap — arrival time is. Extending the deadline and recording whether `CLEAR_ALL` arrives, and at what offset: **if it arrives, "emitted late" is established.** **If it does not, that establishes only "not observed by the longer deadline"***not* "never emitted", because transport loss produces the same absence. Separating non-emission from transport loss needs **producer-side emission evidence** (did pmacs write the clear?) cross-checked against the collected stream; no deadline, however long, can do it alone |
@ -749,53 +601,3 @@ incident, not U4 occurring twice**.
| **exclusion strength — WEAKER than U4's, deliberately** | the changed `gate_script_acceptance` ran **earlier in the same job**, and it creates worktrees and directories. No leaked child or persistent signal-state mutation was observed, but "the diff touches no `src/`" is **not** the argument here that it is for U4, because cross-suite leaked state is a path reachability reasoning does not close |
| **control 1 — CROSS-SUITE ATTRIBUTION, and asymmetric** | run `m5_8_acceptance` alone on macOS `lua54`, without the gate suite ahead of it. **A matching isolated RED proves the gate suite is not necessary** for the failure. **An isolated GREEN proves nothing beyond that run** — the failure is intermittent, so absence under one run is not evidence of dependence. It also does **not** discriminate among the three mechanisms in either direction |
| **control 2 — mechanism** | observe **readiness and raw-mode state at the moment of injection**. Another isolated pass, however many times repeated, cannot separate "injected before raw mode" from "raw mode lost" from a third cause |
### U8 — `acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel`, macOS `luajit`, one occurrence, **fragments destroyed**
**Numbered U8 deliberately: U6 and U7 are reserved** for the two
wall-clock rows on `worker-identity-stage1` (PR #232), which renumbered
into that range when #229 took U4/U5. Taking U6 here would recreate the
duplicate-id collision that rebase already produced once.
**This row exists mostly as an admission.** It surfaced on attempt 5 of
a merge-base control at `0190102`, and **I reran the job before reading
its log**, which discarded it. GitHub keeps only the latest attempt's
logs for a rerun job. So this is U2's original condition exactly — a
selector with no fragments, unmatchable — and it was produced by the
very mistake U3 is named for.
| field | value |
|---|---|
| **selector** | `--test bottom_panel_stage1_acceptance acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel` |
| **job / flavor** | GitHub Actions, `Test (macos-latest / luajit)`, at base `0190102`, control attempt 5 |
| **required fragments** | **NONE CAPTURED — destroyed by rerunning the job before reading its log.** Recovery attempted via the jobs API and the attempt-scoped jobs endpoint; the log is gone |
| **what IS established** | it failed once (`46 passed; 1 failed`), panicking at `tests/bottom_panel_stage1_acceptance.rs:2454`, on the **exact merge base** — so it is not attributable to any open branch |
| **what is NOT** | everything else. Without the assertion text this cannot be matched against a future occurrence, which is the whole purpose of a row here |
| **why it matters anyway** | it is the **third distinct macOS selector** to red in one session, after U4 (`full_grid_resync`) and U5 (`ctrl_c_during_reconnect`). Three unrelated selectors failing on the macOS legs suggests a **background failure rate on that platform** rather than three independent test bugs — and that materially affects any equal-rate reasoning about which branch a failure "landed on" |
| **next occurrence** | **read the log BEFORE rerunning anything.** That is U3's stated lesson and this row is its fourth violation |
### U9 — a PTY test and a budget test red **together** in one `11-sweep`, with an in-run control
Recorded on the `destination-capture` merge tree, 2026-08-10, in the
gate run that was meant to clear PR #231.
**This row's value is its control, not its selectors.** U6 and U7 could
only compare a red run against a *different* run. Here both selectors
ran green **inside the same gate invocation**, minutes earlier, on the
same tree and machine — `03-lib` (1928 passed, 0 failed) and
`04-lib-crdt` (2113 passed, 0 failed) — and then failed in `11-sweep`.
Whatever this is, it is not the tree.
| field | value |
|---|---|
| **selector** | `--lib process::tests::m6_1_pty_canonical_mode_keeps_kernel_echo` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same `11-sweep` step |
| **job / flavor** | local (Linux), `scripts/gate` step `11-sweep` (`cargo test --workspace --no-fail-fast -- --skip basedpyright`), fresh per-lane target dir, no sibling worktrees building |
| **required fragments** | ``canonical mode should leave echo enabled (no `-echo` flag); stty -a output was: ""`` **and** `composition machinery added more than 10% overhead` |
| **NOT fragments** | the measured numbers (`1.613`, `single=191935 ns`, `dispatch=309602 ns`) and every `:LINE` suffix — occurrence-specific |
| **status** | **one occurrence; INTERMITTENT — the identical sweep command on the same tree was green (118 targets, 1928 passed, exit 0)** |
| **what IS established** | intermittence, with the strongest available exclusion of the tree: green in two earlier steps of the **same run**, green isolated afterwards (`2 passed`, 1.70 s), green on a full sweep rerun. Both assertions are **timing-sensitive by construction** — one reads collected child output within a deadline, the other measures wall-clock composition overhead (observed 1.613× against a 1.10× budget; 61.3% dispatch and 124.6% realistic overhead) |
| **what is NOT** | cause, and the load confound is **partially measured but NOT controlled**. The failing sweep ran inside a full gate; the green rerun started at load average 1.98 with the 5-minute figure still at 8.03 from that gate. Different conditions is not a measurement of the mechanism, and this row does not treat it as one |
| **the structural difference worth testing next** | `cargo test --workspace` runs **many test binaries concurrently**; `--lib` runs **one**. That is a difference in kind between the passing steps and the failing one, not merely a difference in load average — and it is the first candidate this family has had that is checkable rather than atmospheric. **Discriminating control:** rerun the sweep with test-binary concurrency pinned to 1, and separately run the `--lib` binary alone under synthetic load. A red under synthetic load at low sweep concurrency implicates load; a red at high concurrency and low load implicates the concurrency itself |
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible |
| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed |
| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |

View File

@ -1,816 +0,0 @@
# A destination capture any async continuation can use
**Status: revision 9. The mechanism is implemented at `0efc8c0`; the
correctness blocker revisions 69 carry is IMPLEMENTED, in revision 8's
shape with revision 9's scope correction, and §3's enumeration is
performed and recorded below.** Revisions 6 and 7 proposed fixes that
review rejected; **neither is in the tree**, and the two paragraphs
describing them are kept as the record of why this shape and not those.
*(Revisions 25 said "Pre-implementation. Awaiting approval" while the
ledger recorded the lane as approved and implemented. Same
contradiction class this document keeps correcting elsewhere, left
standing in its own header.)*
**Revision 9 fixes a hole in revision 8's guard — one that is about the
guard's SCOPE, not about which mutations it names.** Revision 8 refuses,
inside a `"panel"` commit, the mutations that would make its relaxed
preflight wrong. But a **nested `commit_to` REPLACED** the enclosing
contract with its own and restored it afterwards (`src/editor.rs:129`,
`src/lua_bindings/window_panel.rs`), so the outer restriction went out of
force for the whole of the inner body. Review reproduced the sequence:
an outer `"panel"` commit passes the relaxed preflight; a nested
`"document"` commit masks its contract; the nested callback dedicates the
side slot and **is not refused**; the outer commit resumes, its side
request falls back, and it overwrites a newer document — the original
P1a failure, reached through one extra call.
**What this invalidated, precisely.** *Not* §3's enumeration of
dedication write sites. That enumeration was performed against the tree,
it is still complete, and every site in it that can dedicate the slot is
still guarded. What was wrong was the surrounding claim — that the guard
was **in force for the whole outer body**. §3's "PREFLIGHT STAYS WHERE
IT IS" paragraph and the enumeration that follows it are therefore kept
and **qualified**, not withdrawn.
**The fix: contracts COMPOSE across nested scopes; the strictest active
restriction wins.** The core holds a *stack* of contracts rather than one
slot: `commit_to` pushes and pops rather than swapping, and the
dedication guard consults **every** contract in force rather than the
innermost. Matching stays per frontend, so a nested commit for a
different frontend may still dedicate *its* side slot — that cannot
change where this frontend's side request lands. The alternative shape,
**prohibiting nested `commit_to` outright**, was rejected: it closes the
hole by forbidding a construction no rule objects to. `commit_to` is
public Lua API for saying where a continuation's result belongs, and a
body that commits to a second destination (a diff beside a status panel)
is where #227's adoption is heading. Only the *restriction* needed
preserving. **Detecting the dedication when the outer commit resumed was
not available**: by then the mutation has happened, which is a late
refusal, which is what revision 7 was rejected for.
**Revision 8 rejects BOTH of the previous two fixes and takes a third
shape.** Revision 6 predicted the fallback at preflight (the body can
change it). Revision 7 moved enforcement to the placement boundary —
which **breaks the invariant `commit_to` exists for**:
`docs/agent-handoff.md:748` says it preflights *before* the callback
because "validating at display time is four mutations too late", so a
placement-time refusal arrives after arbitrary Lua has created buffers,
handles and paint. Revision 8 keeps the preflight and **refuses the
mutations that would invalidate it**, the same shape as the existing
await refusal. Refusal stays mutation-free on the `(false, reason)`
path.
**Revision 6 fixes an UNSOUND matrix, not a preference.** Q#DC-2 gave
the panel profile only check 1, on the claim that a panel result never
touches a document window. **Panel placement falls back to an ordinary
document window** when the frontend is not panel-capable or its side
slot is dedicated — so a `"panel"` commit could replace a *newer*
document while skipping every stale-intent guard. Reproduced in review.
The relaxation is now conditional on the placement really being a
panel. Revision 6 also closes an invalid-UTF-8 hole in the profile
diagnostic — the same reachability class as revision 5's, one layer
down.
**Revision 5 fixes a binding-level contradiction in revision 4's own
API spec.** It required `profile: Option<String>` *and* a pointed error
naming the accepted values for a non-string — but mlua rejects a
number or table during argument conversion, before the closure runs, so
that message was unreachable. This is the exact trap the existing
binding documents for `dest`, in a comment revision 4 quoted while
repeating the mistake one argument to the right. The profile is now
`mlua::Value`, validated in the body, with `nil` and absence both
meaning `"document"`.
**Revision 4 specifies the call shape the last two revisions kept
referring to without defining.** "The profile is declared at
`commit_to`" named no signature, no value set, no invalid-profile
behaviour, and nothing about the existing two-argument callers — so
#227 had no stable API to adopt and the Journey preservation promise
rested on care rather than contract. Q#DC-5 fixes that:
`commit_to(dest, body [, profile])`, a **closed** two-value set,
**omitted means `"document"`** so every existing call keeps all four
preflight checks by definition, and an unrecognized profile **errors**
rather than falling back.
**Revision 3 decides Q#DC-4, which revision 2 left contradicting
Q#DC-2 — on the primary panel API.** Q#DC-2 concluded a panel needs
only a live frontend; Q#DC-4 still returned `nil` without a document
window and told git to fall back to ambient behaviour, which is the
very bug this lane removes. Resolved: the destination's document pair
is **optional**, `capture_destination()` is **profile-blind and
argument-free**, the profile is declared at `commit_to`, and a
document-profile commit without a document pair is refused. §4 and
Q#DC-1 were updated to match rather than left to disagree.
**Revision 2 takes three review findings.** Q#DC-2's parameterization
was **incomplete** — a panel result does not depend on the captured
document window being live or non-dedicated either, not just on its
buffer, so the question now carries a full **preflight matrix** with
every omission testable. `tests/journey_acceptance.rs` joins dired as a
named **preservation suite and stop signal**; it carries the
`commit_to` scope, forged-userdata, preflight and restoration pins this
lane generalizes, and Journey Stage 1a's framing treats it as a
required gate. And **§5 (coherence impact) was missing entirely**,
which `CLAUDE.md` and `COHERENCE.md` §25 both require of
coherence-affecting work — this lane adds Lua API surface and
generalizes a Journey substrate, so it qualifies twice over.
**A prerequisite lane. PR #227 (git Stage 1) blocks on it**, and its
P1a review finding is the reason this exists.
---
## 1. Why, and why as its own lane
PR #227's review found that git's async completions mutate and display
UI without capturing the initiating frontend
(`builtin/runtime/git.lua:609`, `:854`), so a result can surface in
whichever frontend happens to be active when git exits. Run
`git.status` in frontend A, let frontend B become active, and A's panel
opens in B.
**The finding named the right mechanism.** `pmacs.window.commit_to`
exists for exactly this continuation boundary: Journey Stage 1a's
Q#JR14 built it because "the listing settles a tick or more later, and
by then the ambient frontend, selected window, and active buffer may
all name something else" (`src/editor.rs:1238-1240`).
**But it is not reachable from Lua outside one path**, which is why
this is a lane and not a line in #227:
- `commit_to` takes a `DirectoryDestinationLua`, **nonconstructible
from Lua** by deliberate design (`src/lua_bindings/mod.rs:4256`) —
userdata with no constructor and no setters, so a caller cannot
fabricate a plausible triple.
- The only site that mints one is inside the `path.open-directory`
listener dispatch (`src/editor.rs:1311`), from
`capture_directory_destination`, which is `pub(crate)`
(`src/editor.rs:1241`).
So any async Lua continuation that is **not** a directory open has no
way to say where its result belongs. Git is the first to need it; it
will not be the last.
Landing this inside #227 would put new Lua API surface, over another
lane's merged mechanism, inside a feature branch — the same folding
that was declined for the `scripts/gate` repair, for the same reason.
## 2. Ground truth
- **The captured data is already generic.**
`DirectoryDestination { frontend, window, buffer }`
(`src/editor_core.rs:159-166`) contains nothing directory-specific.
Only its **name** and its **capture site** are.
- **The blast radius of a rename is small**: 8 references across 4
files (`editor_core.rs`, `editor.rs`, `lua_bindings/mod.rs`,
`lua_bindings/window_panel.rs`). Checked, not estimated.
- **`commit_to`'s preflight is four checks**
(`src/lua_bindings/window_panel.rs:488-525`), in order: the
requesting frontend still has a layout; the destination window is
still live in it; **the window still shows the captured buffer**
(Q#JR14c stale intent); and the window is not dedicated (Q#JR14f).
- **`Handle:await` refuses inside a commit scope**
(`builtin/runtime/async.lua:87-90`) — yielding would restore the
scope while the coroutine is still parked. Any adopter awaits
*before* committing, as dired does.
- **Git's two continuations do not have the same shape**, and this is
the finding that shapes the design:
- `*git-status*` goes through `listview.open`, which resolves
`display` with a **`"panel"`** default
(`builtin/runtime/listview.lua:550`). It **requests** the bottom
panel rather than a document window — *requests*, because a side
request FALLS BACK into a document window on a frontend that is not
`panel_capable` or whose one slot is dedicated elsewhere. That
fallback is this lane's blocker; §3 and Q#DC-2 carry it.
- `*git-diff*` calls `pmacs.window.display(buf, { select = true })`
— the **document** target, deliberately, "so the status panel it
was invoked from stays visible beside it"
(`builtin/runtime/git.lua:852-854`).
## 3. The tension this lane has to resolve
`DirectoryDestination.buffer` exists for one purpose, stated at its
definition: *"what that window held at capture time, so **stale intent
loses to the user**"* — a user who replaced the buffer while work was
in flight is newer information than the request.
**That predicate is right for a document replacement and wrong for a
panel — WHILE THE PANEL REALLY IS A PANEL, which is the qualification
the rest of this document exists to add.** A git status panel that
lands in the bottom panel does not replace the captured window's
buffer; it opens beside it. Refusing to show it because the user
switched files in the document window would be a refusal with no
relationship to what the continuation actually does, and that case
would inherit a check about a window it never touches.
**Read the previous paragraph with its condition attached, not as a
standing fact.** Panel placement **falls back** into an ordinary
document window when the frontend is not `panel_capable` or its one
side slot is dedicated elsewhere — and then the panel case *does* touch
the captured window, replacing whatever the user put there. That
fallback is this lane's correctness blocker, and the unqualified
version of this claim is precisely what made revision 5's matrix
unsound. The resolution is below, at the end of Q#DC-2: the preflight
measures whether this frontend places side requests in the panel, and
the mutations that would falsify that measurement mid-commit are
refused.
Meanwhile the diff case *is* a document replacement, and wants exactly
the dired semantics.
So a single one-size destination either **over-refuses** the panel case
or **under-checks** the document case. Q#DC-2 is where that gets
decided, and it is the substance of this lane.
## 4. The change, in outline
- **A Lua-reachable capture**, returning the same nonconstructible
userdata for the *current* frontend, **with** its document window and
buffer when it has one and without them when it does not (Q#DC-4).
The capture takes no arguments and is profile-blind; the profile is
declared at `commit_to`.
- **Generic naming.** `DirectoryDestination` becomes something that
does not lie about a git panel; `capture_directory_destination` and
the userdata type follow. 8 references (§2).
- **The directory path keeps behaving exactly as it does today** — this
lane generalizes the capture, it does not change Journey Stage 1a's
semantics.
- **No adopter in this lane.** Git's adoption is #227's, after this
lands. A prerequisite that also converts its first consumer makes the
two impossible to review separately.
## 5. Coherence impact (§20)
**Revision 1 omitted this section entirely, and it is required.**
`CLAUDE.md` and `COHERENCE.md` §25 both say a framing for
coherence-affecting work must cite the section it serves and state its
impact — and this lane adds **new Lua API surface** and generalizes a
Journey-substrate mechanism, which is coherence-affecting on both
counts. Recording the impacts as neutral where they are neutral is part
of the requirement, not a way around it.
- **§16 semantic frontend — the section this serves.** The defect it
removes is a continuation resolving its target from *ambient* state a
tick after the request, which is precisely the multi-frontend
correctness §16 exists to protect. A capture makes "which frontend
asked" a value rather than a guess.
- **§14 workbench primitives — indirect, and the honest framing is
*enabling*.** This does not add a primitive. It removes the reason an
async adopter would hand-roll frontend tracking, which is the
mechanism by which primitives acquire per-consumer idiosyncrasies.
- **Journey steps touched: none directly, one PROTECTED.** The golden
journey does not gain a step. But Journey Stage 1a's Q#JR14 substrate
is what this generalizes, and §7 makes `tests/journey_acceptance.rs`
a preservation suite precisely so a generalization cannot erode the
step it came from.
- **Interaction islands (§6): none added.** No key interception, no
dispatch precedence rung. `dispatch_key` is untouched.
- **Config registry: no setting.** Where a continuation lands is a
correctness property, not a preference, and a toggle would offer to
turn correctness off.
- **Background-work attribution (§9): NEUTRAL, and worth stating
precisely rather than skipping.** This lane adds no background work
and no new unattributable surface. It also does **not** improve §9 —
knowing which frontend a result belongs to is not knowing who asked
for it or why. That is the worker-identity lane's arc, and the two
should not be confused because both concern async continuations.
- **§10 extension trust — a small positive.** The capture keeps the
Q#JR14d property that a destination is **nonconstructible from Lua**,
so generalizing the mechanism does not widen what extension code can
fabricate. §7 re-asserts the forged-destination refusal after the
rename for exactly this reason.
## 6. Open questions
### Q#DC-1 — what does the capture take as arguments?
*My vote: **no arguments** — capture the acting frontend and its
document window from the ambient state at call time.* That is what the
existing `capture_directory_destination(frontend, window)` is handed by
its one caller, and a Lua-supplied frontend id would reintroduce the
fabrication hole the userdata design closes.
### Q#DC-2 — one destination shape, or a panel/document distinction? **(the substantive one)**
§3 is the problem. Three candidates:
1. **One shape, all four checks.** Simplest; over-refuses the panel
case, and the refusal reason would be about a window the panel does
not touch.
2. **One shape, preflight parameterized by the continuation** — the
caller declares whether it is replacing the captured window's
buffer, and the stale-intent check applies only then.
3. **Two capture kinds**, document and panel, with different preflights.
*My vote: **(2)***, with the profiles spelled out below rather than
left to implementation.
**Revision 1 said only "skip the stale-buffer check for a non-replacing
continuation", and that was incomplete.** Review is right: a panel
result does not depend on the captured **document window** at all. It
does not replace that window's buffer, so check 3 is irrelevant; it
does not occupy that window, so check 4 (dedicated) is irrelevant; and
it does not need that specific window to exist, so check 2 is
irrelevant. Retaining any of the three can reject `git.status` for a
document-window change that has nothing to do with where the panel
goes. But dropping them **without an explicit profile** is how document
replacement quietly loses its guarantees.
**The matrix, stated so every omission is deliberate and testable:**
| # | Precondition (`window_panel.rs:488-525`) | Document replacement | Frontend/panel scope |
|---|---|---|---|
| 1 | Requesting frontend still has a layout | **required** | **required** |
| 2 | Destination window still live in it | **required** | not applicable |
| 3 | Window still shows the captured buffer (Q#JR14c stale intent) | **required** | not applicable |
| 4 | Window is not dedicated (Q#JR14f) | **required** | not applicable |
**Check 1 is the entire panel profile ONLY WHEN THE PLACEMENT REALLY IS
A PANEL — revision 5's matrix was unsound, and this is the correction.**
The matrix rested on "the panel never touches the captured window's
buffer". **That is false when panel placement falls back.**
`editor_core.rs:4138-4148` says so in its own comment: *"Reaching
`Ordinary` while a side was REQUESTED means the request fell back (not
panel-capable, or the one slot is dedicated elsewhere)"* — and the
result is then installed into an ordinary **document** window. So a
`"panel"` commit on a non-panel-capable frontend replaces a document
view while skipping every check that exists to stop it replacing a
*newer* one. That reintroduces exactly the stale-intent failure the
API was built to prevent, which makes it a correctness defect and not
a strictness preference.
**The rule, restated:** the panel profile's relaxation is conditional
on the placement actually being a panel. Whenever placement **can**
fall back to a document window, the panel profile runs the **full
document preflight**.
**PREFLIGHT STAYS WHERE IT IS; THE MUTATION THAT WOULD INVALIDATE IT IS
REFUSED. Revisions 6 and 7 were both wrong, in opposite directions.**
Revision 6 predicted the fallback at preflight and argued the body
could not change it. **False**: the await refusal stops *concurrent
interleaving*, not the body, which is arbitrary synchronous Lua and can
dedicate the side slot itself.
Revision 7 then moved enforcement to the placement boundary. **That
breaks the invariant `commit_to` exists for.** `docs/agent-handoff.md`
`docs/agent-handoff.md:748` states it without qualification:
> [`commit_to`] preflights every precondition *before* invoking the
> callback — dired mutates handle state, `prev`, and paint long before
> it reaches anything that could refuse, so **validating at display
> time is four mutations too late**.
Refusing at placement means refusing *after* arbitrary callback code has
created buffers, handles and paint. A late refusal is not a refusal; it
is a partial commit with an error return.
**So neither predict nor refuse late — forbid the mutation.** Inside a
panel-profile commit, the operations that could change the placement
outcome are **refused**, exactly as `Handle:await` is refused inside a
commit scope and for the identical reason: something that would
invalidate the scope's guarantee is rejected rather than predicted
around. With them refused, the preflight measurement cannot go stale,
and refusal stays mutation-free on the normal `(false, reason)` path.
**"Inside a panel-profile commit" MEANS THE WHOLE BODY, INCLUDING ANY
NESTED `commit_to` (revision 9), and the unqualified version of that
phrase is what revision 8 got wrong.** Contracts **compose**: the core
holds a stack, `commit_to` pushes and pops rather than swapping, and the
guard consults every contract in force rather than the innermost. Read
every "inside a `\"panel\"` commit" below with that scope attached.
Nesting itself is *not* refused — only the mutation is, so a nested
commit that touches no dedication runs exactly as it did.
**The mutation surface is narrow, which is what makes this tight rather
than aspirational:**
- `dedicated` **is** writable from Lua — and it is one of only two
writable window fields (`window_panel.rs:888`, *"Only `fixed_rows`
and `dedicated` are writable (Q#BP2c)"*).
- `panel_capable` has **no Lua binding at all** — checked across
`src/lua_bindings/`. A body cannot make a frontend panel-incapable.
**FOUR WRITES REACH DEDICATION, AND A FIFTH IS GUARDED DEFENSIVELY.**
Review found the second *after* the first was specified, which is the
evidence that guarding one named call site is not a design — and the
enumeration below, performed against the tree rather than by recall,
found three more: two further `apply_placement` arms, plus
`quit_window`'s `QuitAction::Restore`, which step 6 proves *unreachable*
and which is guarded anyway. So **four are reachable, a fifth is guarded
defensively, and all five are guarded** — the last is the count the
safety argument actually runs on. (Historical note, not the current
count: earlier revisions of this section counted all five as
*reachable*. The table below has always said four; the ledger was
corrected in `fb3974b` and this section with it.) The two review named
first are:
1. **`set_params`** — the writable-field path (`window_panel.rs:888`).
2. **`display(buf, { side = …, dedicated = true })`** — writes
`request.dedicated` straight into the side window
(`editor_core.rs:4535`). A body can take this route, then request a
second panel buffer and cause the fallback. **An implementation
guarding only route 1 passes revision 8's test while keeping the
original defect.**
**THE ENUMERATION, PERFORMED. It is CLOSED as an enumeration of WRITE
SITES, and it is closed for a structural reason rather than by inspection
stopping when it ran out of ideas.** Recorded here as the framing
required, with what was looked for, what was found, and what cannot be
ruled out.
**Read "closed" as scoped to the question it answers (revision 9).** It
answers *which writes can dedicate the side slot*, and that answer
survived review of the nesting defect intact — every site below is real
and every one that can dedicate the slot is still guarded. It says
nothing about *when the guard is in force*, and that is the axis
revision 8 got wrong: a nested `commit_to` used to mask the enclosing
contract, so all five guarded sites — the four reachable ones and the
defensive fifth — were momentarily unguarded together. A complete list
of write sites is not a complete argument until the guard's extent is
stated too, which is what the composing-contracts paragraph above now
does.
*Step 1 — how few pieces of state can matter.* `resolve_placement`
reaches `Ordinary` from a side request through exactly two branches, so
only two pieces of state are levers at all: `FrontendView::panel_capable`,
and the one side window's `Window::params.dedicated`. Everything else a
body can touch is irrelevant by construction, which is what makes the
enumeration finite instead of "every mutation in the editor".
*Step 2 — `panel_capable` is unreachable, not merely unguarded.* It is
written **only** where a `FrontendView` is constructed, and no
`FrontendView` is constructed, registered or unregistered anywhere in
`src/lua_bindings/``register_frontend_view` and
`unregister_frontend_view` have callers only in `daemon.rs` (attach and
detach) and in core unit tests. A body cannot reach it.
*Step 3 — every write to `dedicated`, from `rg 'params\.dedicated\s*='
src/`, classified.* Eight sites, no exceptions:
| # | site | verdict |
|---|---|---|
| 1 | `apply_placement`, `Side` **created** | reachable — `display{side, dedicated}` with no panel yet |
| 2 | `apply_placement`, `Side` **replacing** | reachable — `display{side, dedicated}`, different buffer |
| 3 | `apply_placement`, `Side` **non-replacing** | reachable — `display{side, dedicated}`, same buffer |
| 4 | `apply_placement`, `Ordinary` (`!fell_back`) | harmless — every `Ordinary` target is filtered `!is_side`, so it is never the slot |
| 5 | `apply_placement`, `Ordinary` (clear) | harmless — only ever writes `false` |
| 6 | `set_params` | reachable — the direct write (Q#BP2c) |
| 7 | `quit_window`, `QuitAction::Restore` | **unreachable** — guarded anyway, defensively; see below |
| 8 | an `EditorCore` unit test | not Lua-reachable |
*Step 4 — the guards, sited where the property converges rather than at
each caller.* Sites 1, 2, 3 (and 4, 5) are all reached through
`apply_placement`, which has **exactly one caller**, `display_buffer`.
So one guard there covers every request-driven dedication, including
routes that do not exist yet. `set_params` is a genuinely separate write
and is guarded separately — dedication does *not* converge before the
field itself, and that is stated rather than papered over. Two live
guards over the four reachable sites; site 7 carries a third guard,
defensive because the site is unreachable (step 6), so **all five are
guarded**.
*Step 5 — what was looked for and found NOT to be a route.* Closing the
side window is **not** one: with no side leaf `side_window_for` returns
`None` and `resolve_placement` **creates** a fresh panel rather than
falling back, so quitting or hiding the panel mid-commit is safe, and
`panel_hidden` is not consulted by placement at all. `params.side` is
likewise unreachable — `set_params` refuses it and only
`apply_placement`'s created branch writes it, so a body cannot promote
an already-dedicated document window into the slot.
*Step 6 — site 7 is unreachable, and this is the one finding that
surprised.* `QuitAction::Restore` carries the outgoing `dedicated` flag,
so quitting the panel looked like a route with no `dedicated` argument
at the call site at all. It cannot be constructed: `Restore` is only
ever *stored* on a **replacing** side placement, and a dedicated slot
can never be the target of one — a side request with a different buffer
falls through to `Ordinary`, and an exact-target request is refused by
`window_accepts_buffer`. So `Restore { dedicated: true }` has no
producer. It is guarded anyway, defensively and labelled as such,
because its unreachability is an emergent property of two rules in a
different function.
**What this does NOT rule out.** The enumeration is closed over the
current tree, not over future edits: relaxing `resolve_placement`'s
dedicated arm, or adding a binding that writes `params.dedicated`
directly, reopens it. `Window::params.dedicated` is a public field, so
the compiler does not enforce the funnel — the acceptance rows are what
would catch a regression, one per reachable site.
**And it never ruled out a defect in the guard's EXTENT, which is what
revision 9 found.** Nothing above is about *when*
`panel_commit_dedication_refusal` answers; a list of write sites cannot
notice that the contract it reads was masked by a nested scope. The
acceptance suite now drives the same write-site rows at **two depths**
directly in a `"panel"` body, and through a nested `commit_to` — so a
route guarded at one depth and not the other fails loudly rather than
being covered by the enumeration's word "closed".
**If the enumeration had turned out open-ended**, the fallback was to
**collapse the two profiles** — run all four checks always, losing the
panel relaxation. That is safe, simple, and honest; it is not the
preferred answer only because it makes the parameterization pointless.
Choosing it is a design decision needing its own approval, not a
silent retreat. **It was not needed.**
**What is NOT the fix: refusing a panel commit that would fall back.**
Falling back to an ordinary window is existing, deliberate behaviour
for a frontend without panel capability; refusing would turn a
graceful degradation into an error and regress consumers that work
today. The panel profile relaxes checks; it does not get to change
where things land.
**Consequence for the capture, which follows and should not be
discovered later:** if the panel profile needs only the frontend, then
a frontend with **no document window** can still host a panel — so
Q#DC-4's "return `nil`" is right for the document profile and possibly
wrong for the panel one. That interaction is settled as part of
answering this, not after it.
**I hold the *choice* loosely, not the matrix.** (1) has a real
argument — a uniform rule is easier to reason about, and over-refusal
is safe — but it would refuse the git panel for reasons unrelated to
it, and "safe" refusals that users cannot explain are how a mechanism
gets worked around. If review prefers (1) or (3), the matrix above is
what changes, and **every cell marked "not applicable" must still be
tested as deliberately omitted** (§7) so a future reader cannot mistake
an omission for an oversight.
### Q#DC-3 — what is the type called?
*My vote: **`ViewDestination`***, with `pmacs.window.capture_destination()`
as the Lua entry point. It names what it is — a place in a view where a
continuation's result belongs — without claiming a directory or a
buffer kind.
The Q#JR14 doc comments should keep their references intact; a rename
that orphans the rationale is worse than a slightly stale name.
### Q#DC-5 — the exact Lua call shape for the profile **(new in rev 4)**
Revisions 2 and 3 said "the profile is declared at `commit_to`" and
never said **how**. That is not a detail: today's binding accepts
exactly `(dest, body)` (`window_panel.rs:453-456`), so without a
specified form #227 has no stable API to adopt against, and the
promise that existing callers keep their semantics is a hope rather
than a contract.
**The signature:**
```lua
pmacs.window.commit_to(dest, body) -- document profile
pmacs.window.commit_to(dest, body, "panel") -- panel profile
```
- **`profile` is an OPTIONAL THIRD argument, typed `mlua::Value` at
the binding — NOT `Option<String>`.**
**Revision 4 said `Option<String>` and that contradicted its own
error requirement.** mlua rejects a number or table *during argument
conversion*, before the closure body runs, so the promised message
naming `"document"` and `"panel"` would be **unreachable** — a caller
passing `42` would get mlua's generic conversion error instead. This
is the identical trap the existing binding already documented for
`dest`, in a comment revision 4 cited while making the same mistake
one argument to the right:
> Typed as `Value` rather than `AnyUserData` so this message is
> REACHABLE: with the narrower type mlua rejects a table during
> argument conversion, and a caller who fabricated one got "error
> converting Lua table to userdata" — true, but it names neither the
> rule nor how to get a real destination.
So: accept `Value`, and validate in the body.
- **`Nil` or absent → `"document"`.** Both spellings, since
`commit_to(dest, body, nil)` is what a Lua caller threading an
optional variable produces, and it must not be a third behaviour.
- **`String` → must be `"document"` or `"panel"`**, else refused,
naming both accepted values.
- **Anything else → refused by the SAME message**, which now names
the accepted values *and* says a string was expected. That message
only exists if the type is `Value`.
- No arity sniffing and no table-or-function dispatch on argument 2 —
a polymorphic second argument would put the *destination*'s error
message back at risk, which is what that comment was protecting.
- **Trailing, and readable in practice.** A profile after a long inline
closure would read badly, but that is not the call shape in use:
dired defines `local function commit() … end` and calls
`commit_to(opts.dest, commit)` (`builtin/runtime/dired.lua:670,717`).
Against a named body, `commit_to(dest, commit, "panel")` reads fine.
- **The value set is CLOSED: `"document"` and `"panel"`.** Exactly the
two profiles in Q#DC-2's matrix. Not an open string namespace — a
third profile is a decision, not a spelling.
- **Omitted means `"document"`.** This is the load-bearing part: every
existing `commit_to(dest, fn)` call keeps **all four** preflight
checks, unchanged, by definition of the signature. `journey_acceptance`
passing untouched (§7) then follows from the API shape rather than
from care.
- **An unrecognized profile is an ERROR**, naming the accepted values —
**not** a silent fall back to `"document"`. A fallback would hand a
caller stricter or looser checks than it asked for, which is the
failure mode the whole parameterization exists to prevent. A
non-string profile errors the same way.
**Which profile each of git's continuations takes**, so #227's adoption
is decided here rather than rediscovered: `*git-status*` → **panel**
(it lands in the bottom panel, `listview.lua:550`); `*git-diff*`
**document** (it replaces a document window deliberately,
`git.lua:852-854`).
### Q#DC-4 — what happens when there is no document window? **(DECIDED in rev 3)**
**Revision 2 left this contradicting Q#DC-2 and it is the primary panel
API, so it is decided here rather than voted on.** Q#DC-2 concluded a
panel profile depends only on a live frontend — so it can commit with
no document window at all — while this question still said the capture
returns `nil` in exactly that case, and told git to fall back to
ambient behaviour. Those cannot both hold, and the fallback advice was
independently wrong: falling back to ambient **is** the P1a bug this
lane exists to remove.
**The decision:**
- **`ViewDestination { frontend, window: Option<WindowId>, buffer:
Option<BufferId> }`.** The frontend is always present; the document
pair is optional and absent exactly when the frontend has no document
window.
- **`capture_destination()` is NOT profile-aware and takes no
arguments.** It records what is there. Making capture profile-aware
would force the caller to know at *capture* time what it will do at
*commit* time, which is the opposite of why capture exists — the
whole point is to freeze the truth early and decide later.
- **The profile is declared at `commit_to`**, which is where Q#DC-2's
parameterization already lives. One place makes the decision, and it
is the place that knows. **Its exact call shape is Q#DC-5**, which
revisions 2 and 3 left unspecified.
- **A document-profile commit on a destination with no document pair is
REFUSED**, with a reason naming that, joining the four preflight
refusals rather than being a separate failure mode.
- **Capture therefore never returns `nil`** while a frontend exists,
and the "adopter degrades to ambient" advice is **withdrawn**. An
adopter with nowhere to land gets a refusal it can report; it does
not get permission to guess.
**What this changes elsewhere, so the decision does not sit alone:**
§4's outline says the capture returns userdata "for the *current*
frontend and its document window" — it returns one for the current
frontend, **with** its document window when there is one. Q#DC-1's "no
arguments" answer is unchanged and now load-bearing rather than
incidental: no arguments is what keeps capture profile-blind.
## 7. Verification
- **A captured destination survives a frontend switch**: capture in A,
make B active, commit, and assert the result lands in **A**. This is
P1a's actual failure and the reason the lane exists — asserting only
that the API returns userdata would pass on a capture that does
nothing.
- **A fabricated destination is still refused** — the existing Q#JR14d
guarantee, re-asserted after the rename so the generalization cannot
quietly open the hole it was built to close.
- **Every preflight refusal is witnessed by its own case, in BOTH
profiles** (Q#DC-2's matrix): frontend gone, window gone, stale
buffer, dedicated window — each asserted to **refuse** under the
document profile, and each of the three marked "not applicable"
asserted to **NOT refuse** under the panel profile. A deliberately
omitted check that has no test is indistinguishable from a check
someone forgot, and the next reader will restore it.
- **A legacy two-argument `commit_to(dest, body)` gets the DOCUMENT
profile** (Q#DC-5), witnessed by a check the panel profile omits —
a stale-buffer refusal. Asserting merely that it does not error would
pass on a call silently downgraded to the panel profile, which is the
regression that would quietly void Journey Stage 1a's guarantees.
- **A `"panel"` commit that FALLS BACK to a document window is checked
against the document preconditions**, witnessed for **both** causes
separately — a non-panel-capable frontend, and a dedicated side slot.
Each asserts the stale-intent refusal fires: capture A, make B newer,
commit `"panel"`, observe the refusal rather than B being replaced.
- **A BODY THAT TRIES TO CREATE THE FALLBACK IS REFUSED AT THE ATTEMPT**,
in its own test: the callback dedicates the side slot **mid-commit**.
Three assertions, and the second and third are the ones that matter:
the dedication call itself is **refused**; the side slot is **still
undedicated afterwards**; and no partial result was installed.
**One row per reachable WRITE SITE** (§3), which is four and not two:
`set_params`, and `display{side, dedicated}` in each of
`apply_placement`'s **created**, **replacing** and **non-replacing**
arms. A single row against one route is what would let another keep
the defect — and rows per *call spelling* would have missed that one
spelling reaches three different writes. The
two bullets above cannot catch this — both establish their fallback
state *before* `commit_to` is entered, so a preflight-snapshot design
passes them.
**Asserting only "document B was not replaced" is insufficient**, and
revision 7's version of this test made exactly that mistake: it
passes on a design that lets the body mutate freely and merely
declines the final installation, leaving every other side effect
behind. The refusal must land on the mutation, not on the outcome.
- **THE SAME WRITE-SITE ROWS, DRIVEN THROUGH A NESTED `commit_to`**
(revision 9), in their own test: an outer `"panel"` commit whose body
opens a nested **`"document"`** commit — a perfectly valid one, whose
destination is captured fresh inside the outer body so it passes all
four of its own checks and its callback really runs — and *that*
callback attempts the dedication. Asserted: the attempt is **refused**,
the slot is **still undedicated** afterwards, and the outer commit's
destination is **intact** (its result lands in the panel; the user's
newer document buffer survives). The bullet above cannot catch this —
its mutation runs at commit depth 1, where revision 8's single-slot
contract was the right one to read. Rows per write site rather than one
row, because a fix that reinstated the outer contract for only one site
would pass a single-row version.
- **ORDINARY NESTING STILL WORKS**, asserted rather than assumed: a
nested `commit_to` that touches no dedication is accepted, its body
runs, and its return value comes back through both frames. This is the
pin against the other candidate fix — prohibiting nested `commit_to`
outright — which would close the hole by forbidding a shape no rule
objects to. Two further assertions, and the second is the one a
`pop`-shaped fix gets wrong: the enclosing restriction is **back in
force after the nested commit returns** (popped, not cleared), and
**outside every commit dedication is ordinary again**, so the fix
leaked no permanent restriction onto the editor.
- **THE CROSS-FRONTEND EXCEPTION IS PINNED POSITIVELY**, over **two**
frontends: while an outer `"panel"` commit for A is in force, a nested
commit for **B** dedicates **B's** side slot and is **allowed** — and
B's slot is asserted really dedicated afterwards, not merely
unrefused. The far side runs in the same test: A's slot is still
undedicated and A's result still lands in A's panel, so this cannot
pass by having weakened the restriction generally. **This is the one
row asserting that something is permitted**; every other in the suite
asserts a refusal, and without it, deleting the `fid` comparison —
making any outer panel contract *globally* restrictive — passes the
whole file, because both nesting rows above drive a single frontend.
The exception is real and not a convenience: `resolve_placement`
consults only the requesting frontend's `panel_capable` and its own
one side window, so nothing done to B can change where A's side
request lands.
- **A `"panel"` commit that really lands in the panel still skips
checks 24** — otherwise the fix has quietly collapsed the two
profiles into one and the parameterization buys nothing.
- **An unrecognized profile string is REFUSED**, with a message naming
the accepted values — not silently treated as `"document"`.
- **An invalid-UTF-8 profile is refused by that SAME message.** Lua
strings are byte strings, so a `string.char(255)` profile reaches
`to_str()` and produces mlua's generic conversion error *before*
the documented message is ever constructed — the same reachability
class as the `Option<String>` defect, one layer deeper. Compare
bytes, or map the conversion failure onto the message; asserted on
content, in the bad-profile matrix beside the number and table rows.
- **A non-string profile (a number, a table) is refused by that SAME
message**, asserted **on its content**, not merely that an error
occurred. This is the bullet that fails if the argument is ever
retyped to `Option<String>`: mlua would reject the value during
conversion and the assertion on the message would stop matching. The
test is therefore the guard on the type choice, not just on the
behaviour.
- **An explicit `nil` profile takes the document profile**, identical
to omitting it — witnessed separately, because a Lua caller threading
an optional variable produces `nil` rather than absence, and a third
behaviour there would be invisible until someone hit it.
- **Capture SUCCEEDS with no document window** (Q#DC-4), returning a
destination whose document pair is absent — asserted as a successful
capture, not as `nil`.
- **A panel-profile commit on that destination SUCCEEDS**, and a
**document-profile commit on it is REFUSED** with a reason naming the
missing document window. Both halves, because asserting only the
refusal would pass on a capture that refuses everything.
- **The directory path is unchanged** — dired's existing acceptance
coverage passes untouched.
- **`tests/journey_acceptance.rs` passes UNCHANGED**, as a named
preservation suite. It carries the established contract this lane
generalizes — 27 `commit_to` references across nine named pins
including `commit_to_refuses_a_forged_destination`,
`commit_to_scopes_and_restores_on_a_normal_return`,
`commit_to_restores_when_the_callback_raises`,
`commit_to_refuses_an_await_and_restores`,
`commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one`,
`a_declining_listener_cannot_redirect_the_destination`, and two
rows already named `preservation_*`. Journey Stage 1a's own framing
treats this suite as a required gate; a lane that generalizes its
substrate does not get to relax that.
- **STOP SIGNAL, for both suites.** If any existing `dired` or
`journey_acceptance` test needs editing, the generalization changed
Journey Stage 1a's semantics. That is cause to stop and report, not
to adjust the test — a suite edited to accommodate the change under
test has stopped being evidence.
- **`Handle:await` still refuses inside the scope**, including through
`pmacs.async.yield_to_next_tick` if the worker-identity lane's Q#W-7
has landed by then; if it has not, this lane does **not** add that
guard — it belongs to that lane and duplicating it would produce a
conflict for no benefit.
**What this will NOT prove:** that git surfaces in the right frontend —
that is #227's adoption, after this lands. This lane ships the
mechanism and one set of tests for the mechanism.
## 8. Not in scope
**Adopting the capture anywhere**, including git (#227 does that) and
including migrating other async continuations that have the same latent
bug — worth an audit, not this lane's work. Changing Journey Stage 1a's
directory semantics. The `commit_to` scope guard for
`yield_to_next_tick` (worker identity Q#W-7). Any protocol change —
this is entirely core + Lua bindings. Panel geometry or placement
policy, which is the bottom-panel arc's.

View File

@ -1,348 +0,0 @@
# Discovery Stage 2 — M-x rows stop being bare names
**Status: revision 3, APPROVED 2026-08-09. Implementation may
proceed.**
**Revision 3 fixes three things revision 2 asserted without checking
the mechanism it was reasoning about**: a "frozen-shape" test that
freezes nothing, a cache hazard that this architecture makes
impossible, and a clipping rule that is unachievable at narrow enough
widths. All three verified in the tree.
**Revision 2 fixes two claims revision 1 made about compatibility and
about the TUI, both wrong, both checkable.** An in-place field change
cannot preserve v22 — postcard is not self-describing — and "both
frontends render it" was false, because the grid TUI never reads that
message at all. Verified in the tree, not reasoned about.
---
## 1. The gap, stated exactly
`COHERENCE.md` §5 grades unified discoverability **Partial** after
Stage 1 (#207), and names three things left. This lane takes one:
> `Command` still has no title/category/flags, **M-x rows are still
> bare names**, and the Rust help layer is still orphaned.
**The descriptions already exist.** `Command.description` is a required
field (`src/command.rs:69`), and `help.list-commands` already renders
"every registered command **with its description**"
(`builtin/runtime/help.lua:339`). A user who runs `M-x help` can read
what everything does.
**What they cannot do is see it at the moment of choosing.** `M-x`
shows names alone — so the information exists, is already surfaced
elsewhere, and is missing from the one place it would change a
decision. That is §1.1's *substrate without surface* in its purest
form, and it is felt every time the editor is used.
## 2. Ground truth
Scouted:
- **The wire asymmetry is a single field.**
`InstanceMessage::MinibufferPrompt` carries
`candidates: Vec<String>` (`pmacs-protocol/src/message.rs:1113`).
- **The rich pattern is already proven in a sibling variant.**
`CompletionPopup` carries `rows: Vec<CompletionPopupRow>``label`,
`kind: u8`, `detail: Option<String>` (`:1387`) — and both frontends
already render it.
*(Revision note: an earlier read of mine reported two bare-string
sites. There is one. The second grep hit was `CompletionPopup`'s
doc comment, which says "candidates" while the field is `rows`.)*
- **`Command` needs no change for this lane.** `description` is
already there and already required. Title/category/aliases — the
lane's other Stage-2 candidate — would enrich these rows further and
are **deliberately not** in scope: they are a ~175-site change and
this lane can deliver the felt improvement without them.
- **`ADVERTISED_PROTOCOL_VERSION` is pinned at 20**
(`pmacs-protocol/src/message.rs:1767`) and **must not be edited**,
per handoff §3/§5.
- **The transport is postcard** (`pmacs-protocol/src/transport.rs:1`),
which is **not self-describing**: enum variants encode by index and
fields by position. **Changing a field's type in place is a wire
break**, not a compatible evolution — a v22 peer would mis-decode the
bytes rather than ignore them.
- **`MinibufferPrompt` is sent to every peer negotiated `>= 12`**
(`src/daemon.rs:1472`, "Q#MB1 — MinibufferPrompt gated at v12"). So
the population that would break is every frontend from v12 to v22.
- **The grid TUI never reads `MinibufferPrompt`.** `src/editor.rs`
contains **zero** references to it; `paint_minibuffer` reads
`core.minibuffer` directly and renders the selected candidate as an
inline suffix, `format!(" [{cand}]")` (`src/editor.rs:5484`), with
its own `ui.minibuffer.candidate` face. **The rich wire reaches
`pmacs-gpu` only.**
## 3. The change
**This is a protocol change: v22 → v23**, and it is **additive**, not
an edit.
### 3.1 A new variant, because an in-place change cannot be compatible
Revision 1 proposed changing `candidates` in place. **That breaks every
frontend from v12 to v22**: postcard encodes fields positionally, so a
v22 peer decoding a `Vec<MinibufferRow>` where it expects
`Vec<String>` mis-reads the bytes — it does not skip them.
And gating the changed variant at `>= 23` does not rescue it: the peer
would then receive **no minibuffer message at all**, because there is
only one variant to send. Compatibility means *sending the old shape*,
which requires the old shape to still exist.
So:
- **`MinibufferPrompt` is retained, unchanged, for v12v22.** Its
encoding is frozen.
- **`MinibufferPromptRows` is a NEW variant appended to the enum**,
carrying `rows: Vec<MinibufferRow>` and otherwise mirroring
`MinibufferPrompt`'s fields.
- **Appended, not inserted.** Variant indices are positional in
postcard; inserting anywhere but the end renumbers every later
variant and breaks everything at once.
### 3.2 Per-session selection, and the ordering that matters
- **Selection is per peer, decided from its negotiated version**:
`>= 23` receives `MinibufferPromptRows`; `12..=22` receives
`MinibufferPrompt`. This mirrors the existing gates in
`src/daemon.rs:1472`, which already suppress `MenuPrompt`,
`MinibufferPrompt` and `LineNumbers` per peer.
- **Exactly one of the two is sent to any given peer, ever.** Sending
both to a v23 peer would double-render; sending neither is the bug
gating alone would have caused.
- **The selection is a producer gate**, named
`peer_knows_minibuffer_rows`, alongside the existing
`peer_knows_minibuffer_prompt` / `peer_knows_menu_prompt` /
`peer_knows_completion_popup` (`src/daemon.rs:1410-1435`). One new
gate in an established pattern, not a new mechanism.
- **ONE per-peer minibuffer cache, not a per-variant key.**
**Revision 2's rationale for a per-variant key was false**, and the
architecture is why: `SemanticRenderState::for_peer(frontend_id,
negotiated_protocol_version)` is created **per peer, with its version
baked in, on attach** (`src/daemon.rs:2080`) and **removed on
detach** (`:1591`). A cache therefore never spans two negotiated
versions — the v23→v22 reconnect suppression I described **cannot
occur**, because reconnecting creates a fresh state. The
corresponding test is removed rather than written; a test for an
impossible condition passes forever and teaches the next reader that
the hazard is real.
- **The close message must still use the same variant family as the
open** — a `MinibufferPromptRows` session closed by a legacy clear is
the mismatch that leaves a popup on screen forever. That one is
independent of caching and stands.
### 3.3 What each frontend does
- **`pmacs-gpu`** renders label + detail from the new variant.
- **The grid TUI does not consume this message at all** and is
addressed separately in §3.4.
### 3.4 The TUI presentation contract
Revision 1 said "both frontends render label + detail". **The grid TUI
does not read `MinibufferPrompt`** — it paints from `core.minibuffer`
and renders the selected candidate as `format!(" [{cand}]")`
(`src/editor.rs:5484`). The wire change reaches it not at all.
*My vote: **an inline selected form, matching what is already there***:
```
M-x buffer.sa [buffer.save — Write the buffer to its file]
```
- **Source: local.** The TUI is in-process with the core, so it reads
`Command.description` from the registry directly. **No wire
involvement**, which is why this half of the lane is independent of
the bump.
- **Only the selected candidate**, as today. This is a formatting
change to an existing suffix, not a new surface.
- **Clipping, in three ordered steps.** The suffix is already written
against `max = term_size.cols` with a running `written` count, and
the prompt plus typed input consume that budget first — so the
remaining width can be **too small even for the bare name**.
Revision 2 said "the name must survive", which is not achievable at
arbitrary widths and would have forced a partial name. The rule:
1. **If the remaining suffix width cannot fit the WHOLE name, omit
the suffix entirely.** Never emit a partial name — `[buffer.sa…]`
is worse than nothing, because it reads as a different command.
2. **Only once the whole name fits** is a description attempted.
3. **If the description does not fit whole, drop the description**,
leaving today's `[name]`. No ellipsis stub.
So the guarantee is *"never a partial name"*, which is achievable,
rather than *"the name always survives"*, which is not.
- **The `ui.minibuffer.candidate` face already exists** and continues
to cover the suffix.
**A multi-row TUI chooser is explicitly NOT this lane.** It would be a
new interaction surface, a §6 island risk, and materially larger than
the wire work — it is named here so that "make the TUI match the GPU"
does not quietly become that.
### 3.5 Scheduling consequence, which is not incidental
`PROTOCOL_VERSION` is a strict serialization point — two lanes bumping
it collide, and this session recorded eight broken version assertions
from a single bump. So:
- **This lane holds the bump slot.** Git Stage 1 is deliberately
no-wire and runs beside it without contention.
- **Git Stage 2 (gutter markers) also needs a bump and must therefore
wait for this to land.** That ordering should be explicit in the
ledger rather than discovered when the two collide.
## 4. Coherence impact (§20)
- **§5 unified discoverability — the direct target**, and the specific
clause "M-x rows are still bare names".
- **Journey step 4** ("understand the interface"): `COHERENCE.md` P4
says most of it "rides on" discovery. This improves the step without
adding one.
- **§16 semantic frontend:** a clean instance of the architecture —
the instance states *what a candidate is*, each frontend decides how
to draw it. Degradation is the established practice (Q#D2-4).
- **Interaction islands (§6): none added.** No new key interception;
this changes what an existing prompt carries.
- **Config registry:** no new setting. Whether detail rendering is
optional is Q#D2-3, and my vote is no setting at all.
- **Background-work attribution (§9): untouched.** No new background
work.
## 5. Open questions
### Q#D2-1 — reuse `CompletionPopupRow`, or a new type?
Reuse is tempting and I think wrong. `CompletionPopupRow.kind` is an
**LSP `CompletionItemKind` code (1..=25)** with a documented contract;
an M-x command is not an LSP completion item and has no honest value
for that field. Reusing it would mean either inventing a fake kind or
declaring 0/unknown everywhere — a type whose invariant is
"meaningless in half its uses".
*My vote: **a new `MinibufferRow { label, detail: Option<String> }`***
— no `kind`. If a category field is wanted later it arrives with
`Command.category` (the other Stage-2 candidate), typed as what it
actually is rather than borrowed from LSP.
### Q#D2-2 — which prompts get rows?
`pmacs.minibuffer.read` serves many sources, not just M-x: file paths,
buffer names, apropos substrings, settings. Only some have a natural
`detail`.
*My vote: **the field is `Option<String>` per row and the daemon fills
it where it has one.*** Commands get their description; a file-path
prompt leaves it `None` and renders exactly as today. No source is
obliged to invent a detail, and none is prevented from gaining one
later.
### Q#D2-3 — is detail rendering configurable?
*My vote: **no setting.*** §11 grades the registry "partial
(foundation only)"; adding a speculative toggle for a feature nobody
has yet asked to disable is how a registry becomes noise. If somebody
wants it off, that is use evidence and a later one-line addition.
### Q#D2-4 — older frontends — **RESOLVED, in §3.13.2**
No longer open, and the revision-1 answer was wrong. "Gate the richer
form at `>= 23`" would have **removed the minibuffer entirely** from
every v12v22 peer, because there would have been only one variant to
gate. Compatibility requires the legacy shape to still exist and still
be sent — hence the additive `MinibufferPromptRows` variant, a
per-peer `peer_knows_minibuffer_rows` producer gate, **one per-peer
minibuffer cache**, and matched open/close families.
*(Revision 2 said "per-variant cache keys" here. §3.2 corrected that in
revision 3 — the render state is per peer with its version baked in, so
a cache cannot span two versions — and this sentence was left stale.)*
The `CompletionPopup` gate I proposed copying (`daemon-gated >= 15`)
**is** the right precedent for *how to select per peer*; it is not a
precedent for changing a live variant's shape, because that variant was
new when it was gated.
### Q#D2-5 — does this tempt closed-set acceptance? **(a trap)**
The discovery lane's own handoff note warns: **completion is
assistance, not validation** — `resolve_accepted_value` returns the
literal typed text when no candidate is selected, so closed-set
acceptance is unbuilt Rust work.
Richer rows make M-x *look* like a closed set, which invites someone to
make acceptance reject unmatched input. **That is out of scope and
would be a behaviour change**, not a rendering one. Stated here because
the temptation arrives with the feature.
## 6. Verification
- **A command's description reaches the GPU row**, asserted through
the real prompt path rather than by constructing a message.
- **A v22 peer still receives `MinibufferPrompt`, with its old
encoding** — the case revision 1 would have broken. Asserted by
negotiating v22 and observing the legacy variant arrive, **not** by
observing "no error".
- **A v23 peer receives `MinibufferPromptRows` and NOT the legacy
variant** — the double-render guard.
- **LITERAL POSTCARD BYTE FIXTURES for the legacy variant**, open and
clear: `assert_eq!(encoded, LEGACY_BYTES)` against a constant.
**Revision 2 proposed a round-trip and that freezes nothing.** A
round-trip encodes and decodes with the *same* types, so adding a
field to `MinibufferPrompt` leaves it passing — both sides simply
learn the new shape, while every v12v22 peer in the field breaks.
The existing `minibuffer_prompt_round_trips_through_postcard`
(`src/protocol.rs:2363`) is exactly that kind of test, and **there
are no literal byte fixtures anywhere in the protocol tests today** —
checked, not assumed.
Only comparing against bytes captured *now* can fail when the
encoding changes. Two fixtures: an open prompt with candidates and a
selection, and a cleared band — the two shapes the existing semantic
test already covers, so the corpus is not a new judgement call.
- **No cross-version cache test.** Revision 2 required one; it asserts
a condition this architecture makes impossible (§3.2), and a test
that cannot fail passes forever while teaching the next reader that
the hazard is real. What *is* asserted is the producer gate: a v22
peer and a v23 peer attached simultaneously each receive their own
variant and only their own.
- **Close matches open**: a `MinibufferPromptRows` session is closed by
its own family, witnessed by the popup actually clearing.
- **The TUI renders `name — description` for the selected candidate**
(§3.4), from the local registry, with **no wire involvement**.
- **TUI clipping is witnessed at THREE widths** (§3.4): wide enough
for name + description; wide enough for the name only (description
dropped, `[name]` as today); and **too narrow for even the whole
name — the suffix vanishes entirely**. The last is the case revision
2's rule could not express, and the assertion is that no *prefix* of
a name is ever emitted.
- **A source with no detail renders exactly as before** — the
file-path prompt is the witness (Q#D2-2).
- **Typed-but-unmatched input is still accepted** (Q#D2-5) — the
guard against this lane quietly becoming a validation change.
- **The version-bump discipline**: `ADVERTISED_PROTOCOL_VERSION`
unchanged at 20, and the tripwire assertions updated **knowingly**.
Handoff §3 requires the strengthened two-configuration sweep for a
`PROTOCOL_VERSION` change — `scripts/gate --protocol`, which exists
precisely for this.
**What this will not prove:** that `Command` carries title or category
(not in scope), or that predicates are evaluated (Stage 3+).
## 7. Not in scope
`Command` gaining title/category/aliases/flags/arg-schema — the
~175-site change, and the lane's next candidate. **A multi-row TUI
chooser** (§3.4) — a new interaction surface and materially larger than
this lane. **Changing `MinibufferPrompt`'s existing shape** — it is
frozen for v12v22. Predicate evaluation,
which makes commands stop being invocable and needs its own decision at
each call site. Help-layer unification (`src/help.rs` is still
orphaned). The help prefix key — `C-h` is **not** free, since non-kitty
terminals cannot disambiguate Ctrl+Backspace from Ctrl+H (both are
byte 0x08). Closed-set acceptance (Q#D2-5).

View File

@ -1,708 +0,0 @@
# Git integration — Stage 1: seeing what changed
**Status: revision 5, APPROVED 2026-08-09. Implementation may
proceed.**
**Revision 5 completes the unborn-repository policy, which revision 4
wrote as three disjoint rows when a single file can be in two states at
once.** `AM` — staged, then edited again — is not exotic; it is what a
first commit looks like halfway through. The states below were
**enumerated from a real unborn repository**, not reasoned about, and
one of them settles a case by ruling it out entirely.
**Revision 4 fixes two contracts that would have failed in ordinary
use, both verified against real behaviour rather than reasoned about:**
re-binding `d` on every refresh (keymap binds refuse duplicates, so
*every successful refresh* would have errored), and two git exit states
the failure predicate got wrong. Measured, not assumed — the exit codes
below were produced in a scratch repository.
**Revision 3 pins four Stage 1 contracts revision 2 left loose, and two
of those were again claims I made without reading the code I was
crediting.** I attributed selection preservation to `listview` and
`d` to its key surface; neither is true, and both were checkable in the
file I had already cited. The pattern is worth naming since it has now
recurred across three revisions: **I cite a file, then describe what I
expect it to contain.**
**Revision 2 answers four blockers, two of which were factual errors in
revision 1 that scouting should have caught and did not.** I read
`ProjectKind::Git`'s name instead of its doc comment, and I quoted
`COHERENCE.md` §15's "no Git integration anywhere in the tree" without
checking whether it was still true of the tree. It is not.
---
## 1. Why this, and why now
`COHERENCE.md` §15 is blunt about it:
> **There is no Git integration at all** — no status, stage, diff,
> blame, or gutter markers anywhere in the tree (gutter git riders and
> the `ResourceOffer` diff/blame family are named deferrals). The Git
> affordance list above has nothing to attach to yet.
**That sentence is literally false about the tree, and revision 1
repeated it without checking.** `tests/fixtures/pmacs-magit/` is a
tracked, installable package — 1,914 lines across four modules, with
`status.lua` spawning git through `pmacs.process.spawn` and parsing
**`--porcelain=v2 --branch`** into structured sections, plus a 662-line
acceptance suite (`tests/m8_6_acceptance.rs`, 32 tests) covering
status, refresh, staging, commit, push and branch behaviour.
**The PRODUCT gap is real and unchanged** — none of that is bundled
runtime, so a user who installs pmacs gets no git integration. But
"nothing to attach to" understates what exists to *learn from*, and
§15's wording should be corrected when this lands.
For a **daily driver**, this is the largest remaining gap. Not because
git is the most architecturally interesting thing missing — §7
workspaces and §9 worker identity are both deeper — but because it is
the one a user touches *every working hour*, and pmacs currently makes
them leave the editor to answer "what have I changed?".
That is the criterion this lane is chosen against: **frequency of use
per day**, not depth of model.
## 2. Ground truth — what already exists
Scouted, not assumed:
- **`ProjectKind::Git` is NOT general repository detection**, and
revision 1 said it was. Its doc comment is explicit: *"A bare git
repository (no language marker found inside)"* (`src/project.rs:89`).
Markers are ordered and a language marker beside `.git` **wins**
(`src/project.rs:10`), so a normal Rust repository reports
`kind = "rust"` and would have been invisible to a lane that gated on
`kind == "git"`. That gate would have failed on this very repository.
**The rule this lane uses instead: never ask pmacs whether it is a
git repo.** Run git in the **active file's directory** and let git
resolve its own worktree — `git -C <dir> rev-parse --show-toplevel`
establishes the root, and a non-zero exit *is* the "not a repository"
answer. Git's own resolution handles submodules, worktrees, `GIT_DIR`
and `.git` files; a marker walk reimplements a subset of that and
gets it subtly wrong.
- **`pmacs.process.spawn` / `events_take` / `terminate` / `forget`**
is the working model for running an external tool asynchronously;
`builtin/runtime/compile.lua` is a full worked example, including
spawn-failure handling and exit markers.
- **`pmacs.listview.open`** is a real primitive with existing adopters
(`*references*`, `*lsp*`), carrying optional `depth`/`id`,
primitive-owned collapse, and selection re-seated by id. `COHERENCE.md`
P5 says the remaining work there is **adoption, not construction**
a `*git-status*` panel is exactly that.
- **Gutter signs exist in both frontends** — the TUI's leading-column
glyph (`src/diag.rs`) and the GPU's `GUTTER_SIGN_X` bars
(`pmacs-gpu/src/main.rs:420`).
- **A tested porcelain-v2 parser exists as a package fixture** (above).
Its `status.lua` deliberately separates **pure `parse_*` functions
that take a string and return structure** from the spawning around
them — which is the shape that makes a parser testable without a
repository, and it is already proven by 32 tests.
And the constraint that shapes the staging:
- **`DecorationKind` is a CLOSED enum on the wire**
(`pmacs-protocol/src/message.rs:1472`): four diagnostic severities,
`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`.
**Gutter markers for git hunks therefore require new variants, which
is a protocol version bump.** The gutter signs that exist are keyed
on `diagnostic_severity_rank` and have no notion of anything else.
## 3. The staging, and why the line falls where it does
**Stage 1 (this lane): read-only, panel-based, NO WIRE CHANGE.**
- `*git-status*` — a `listview` panel over
`git status --porcelain=v2 --branch -z` (Q#G-6), rows visiting the
file at RET, refreshed by `g` under the completion model in Q#G-1.
- `*git-diff*` — the diff for the **file** under point (Q#G-7), in a
generated buffer rendered as **plain text** (no `diff` grammar
exists). **No hunk model** — hunks are Stage 2's concern.
**Stage 2 (separate lane): gutter markers.** Needs new
`DecorationKind` variants and a `PROTOCOL_VERSION` bump, plus both
frontends' gutter renderers learning a second rider family.
**Stage 3+ (unscheduled): staging, commit, blame.** Staging and commit
are where an editor becomes a git *client*; blame is a lower-frequency
read. Neither belongs in front of the two above.
**The line is drawn at the wire on purpose, and it is a scheduling
decision as much as a design one.** Parallel lanes are about to start,
and `PROTOCOL_VERSION` is a strict serialization point — two lanes
bumping it collide, and this session already recorded what that costs
(eight broken version assertions on CI from a single bump). Stage 1
touching no wire is what lets it run **concurrently** with other work.
Stage 2 must be scheduled alone.
## 4. Coherence impact (§20)
Required by `CLAUDE.md` for coherence-affecting work, and this is
coherence-affecting — it is §15's named gap.
- **Journey steps touched:** none directly. Git is not currently a
journey step; the golden journey runs open → edit → build → test →
navigate. This lane does **not** add a step, and I would rather say
so than inflate the claim.
- **§15 contextual affordances — the direct target.** The audit's git
affordance list ("a Git change stage/revert/diff") has *nothing to
attach to*. Stage 1 creates the thing to attach to; the affordances
themselves follow it, and the menu's context vocabulary
(`src/menu.rs:44`) would need a `git` context to host them — **out of
scope here**, named so it is not forgotten.
- **§14 workbench primitives — adoption, which is the stated need.**
`*git-status*` becomes the **fifth** `listview` call site and the
first outside the LSP panels, which is the concrete evidence P5 asks
for that the primitive generalizes past its first consumer.
- **Interaction islands (§6): none added, and this is a real
constraint.** The panel gets no hardcoded key interception; it uses
`listview`'s existing key handling. §6 records six such shadows and
calls them "weak, and growing" — this lane must not make it seven.
- **Config registry adoption:** at least one setting
(`git.enabled`, Q#G-4), defined through `pmacs.config.define` like
`ui.line-wrap` and the zoom settings, not a bare Lua global.
- **Background-work attribution (§9): NEGATIVE, and named as such.**
Git runs as a spawned process, and spawned processes do **not** appear
in `*workers*` — that view is `async.lua`'s job list; processes live
under `pmacs.process.list` (Q#G-5). This lane therefore adds a fifth
thing running in the background with no single place to see it. The
process is labelled honestly, which is better than anonymous, but
**a label is not attribution and this document does not pretend
otherwise.** Accepted because these are short-lived reads; it would
not be acceptable for Stage 3's push/pull.
## 5. Open questions
### Q#G-1 — is the status panel a snapshot or a live view?
A snapshot is a command that opens a panel; a live view refreshes on
buffer save, on focus, or on a filesystem watch.
*My vote: **snapshot, refreshed explicitly***, with `g` re-running
inside the panel. Live refresh needs a watch mechanism, an invalidation
rule, and a §9 story for the recurring work — all real arcs. A snapshot
is honest, useful the first day, and does not pretend to a currency it
cannot maintain.
**But "explicit refresh" does not fit `listview` unmodified, and
revision 1 missed that.** `listview.refresh` is synchronous:
```lua
local rows = check_ids(p.on_refresh() or {}) -- listview.lua:402
```
The result is consumed immediately. `pmacs.process.spawn` cannot return
rows there — it returns a process id whose output is drained later. So
revision 1's "adopt `listview`" would have produced exactly one of the
two failures the reviewer named: a reimplemented list, or a `g` that
silently does nothing. The primitive's own docs already call a dead `g`
out as a defect it must not repeat (`listview.lua:416`).
**The completion model, specified.** `on_refresh` stays synchronous and
honest:
1. **`on_refresh` returns the CURRENT rows immediately**, with a
`refreshing…` marker row appended, and *kicks off* the spawn. `g` is
therefore never a no-op — it always re-renders and always shows that
work started.
2. **On exit, the completion handler re-opens the panel** via
`listview.open` with the same `name` — **and re-seats the selection
itself.**
Revision 2 credited that to the primitive and was wrong.
`listview.open` **resets collapse** (`p.collapsed = {}`) and
**always seats line 1** (`seat_cursor(p, 1)`,
`builtin/runtime/listview.lua:337-378`). The `listview.lua:82` note
I cited is about **name** disambiguation to `<2>`, not selection.
Only `listview.refresh` preserves a selection, and that is the
synchronous path this model cannot use.
So the contract is explicit and owned here: **capture the selected
row's git id (its current path) before re-opening, and after
re-opening move to the line whose row carries that id**, computed
from the handler's own rows array via `pmacs.editor.move_to_line`.
If the id is gone from the new status — the commonest case, since a
file that stopped being modified drops out — seat line 1 and say
nothing; that is the correct answer, not a failure.
**Collapse state is moot in Stage 1** because the rows are flat: no
`depth`, so nothing to collapse. Stage 2 or a sectioned view would
have to revisit this, and would then face the same reset.
3. **Concurrent refresh is suppressed by a generation counter.** A
second `g` while one is in flight bumps the generation; the older
completion sees a stale generation and **discards its rows** rather
than racing. It does not terminate the first process — reaping is
`pmacs.process.forget`'s job and killing git mid-read buys nothing.
4. **Failure is a row, not a silence.** Non-zero exit or spawn failure
renders a row carrying the exit code and the first stderr line, plus
a status message. §1.2's silence asymmetry.
5. **Panel lifetime.** If the panel's buffer is gone when the process
exits, the handler drops the result. `compile.lua:252` already
handles the buffer-killed case for its own slot; the same shape.
**The alternative — extending `listview` with an async contract — is
the more correct long-term answer** and is deliberately not taken here:
it changes a primitive with four existing adopters, and doing that from
inside its fifth adopter's lane is how a primitive acquires a consumer's
idiosyncrasies. **If review prefers it, it belongs in its own lane
before this one.**
### Q#G-0 — what is the relationship to `pmacs-magit`? **(new in rev 2)**
The reviewer's framing of the choice is right: adopt, replace, or
declare it out-of-product precedent. Doing none of those and quietly
writing a second parser is the option that must not happen.
*My vote: **port its pure `parse_*` functions and its test corpus into
the bundled runtime; leave the fixture itself untouched.***
- **The record TOKENIZER is deliberately rewritten, not ported.** The
fixture parses **newline-delimited** v2; Stage 1 reads **`-z`**, and
those are different grammars — under `-z` a record's fields are
NUL-terminated and a rename carries its two paths as separate fields
rather than tab-joined. Saying "port the parser" would have been
wrong; what ports is the **separation** (pure `parse_*` functions
over a string, testable with no repository) and the **case coverage**
its 32 tests encode. The tokenizer underneath is new, and its
correctness rests on this lane's own corpus.
- **Port, not import.** The fixture's purpose is to prove the *package
system* can host this. If bundled code became its dependency, it
would stop demonstrating an independent package and `m8_6` would test
less than it claims.
- **The duplication is therefore deliberate**, and it is the one place
this framing accepts two copies of a rule after a session spent
removing them. The justification is that they answer different
questions — one is product behaviour, one is package-system
capability — and coupling them weakens the second. **If review
prefers the coupling, that is a defensible call and I will take it**;
what I will not do is leave the duplication unstated.
- **It also settles Q#G-2's format**: the existing, tested parser is
**porcelain v2**, so Stage 1 is v2. Revision 1 said v1 for no reason
beyond familiarity.
### Q#G-2 — `git` the binary, or a library?
*My vote: **the binary**, via `pmacs.process.spawn`. `compile.lua` is
the worked precedent, the daemon already spawns external tools, and a
git library is a dependency with a much larger surface than "run one
command and parse porcelain". `--porcelain=v2` is explicitly a stable
machine format; that is what it is for.
**Named risk:** no `git` on `PATH`. §1.2's *silence asymmetry* says the
failure must be **surfaced with guidance**, not swallowed — the same
lesson #204 landed for a missing language server.
### Q#G-6 — the status data contract **(new in rev 2)**
Revision 1 said "`--porcelain=v1`" and proposed "a path with a space"
as the parsing witness. **Both were inadequate.** Porcelain without
`-z` emits paths in git's **C quoting** for anything non-ASCII or
containing special characters, and rename/copy records carry *two*
paths whose separation is positional. A single space-in-path fixture
proves none of that.
*My vote: the exact invocation*
```
git --no-optional-locks -C <dir> status --porcelain=v2 --branch -z
```
**`--no-optional-locks` is part of the contract, not a nicety.**
`git status` is **not strictly read-only**: it may refresh and write
the index, and git's own documentation recommends this flag for
background scripts precisely so a background reader does not contend
for `index.lock` with the user's real git commands
(<https://git-scm.com/docs/git-status>). This lane runs status
*asynchronously, from an editor, while the user may be running git in a
terminal* — the exact scenario the flag exists for. Revision 2 called
the lane "read-only" and that was wrong about the mechanism.
It is **witnessed structurally** — the assembled argv is asserted to
carry the flag — because observing a lock that was *not* taken is not
something a test can do directly. Verified accepted by the git in use
here.
The rest: `--porcelain=v2 --branch -z`, also verified accepted. NUL delimiting removes C quoting from
the problem **entirely** rather than obliging a hand-written unquoter,
and it makes the two-path rename record unambiguous: the paths are
separate NUL-terminated fields rather than tab-joined inside one.
The rename/copy identity rule to pin: a `2` record carries the current
path **and** its origin, and the panel must show which file it is now
while remembering where it came from — a row whose id is the current
path, since that is what RET visits.
**Witness corpus, not one case:** modified, added, deleted, untracked,
**renamed (both paths)**, **copied**, a path with a space, a path with
a newline, and a non-UTF-8 path. The last two are exactly what `-z`
buys and what a quoted parser gets wrong.
### Q#G-7 — the diff gesture **(new in rev 2)**
Revision 1 wrote "the diff for the file or hunk under point" while also
committing RET to visiting the file. **RET cannot do both, there is no
second binding proposed, and no hunk model exists anywhere in the
tree.**
*My vote:*
- **RET visits the file** — unchanged, and the behaviour a list of
files should have.
- **A named command, `git.diff-file`, bound to `d` inside the panel.**
**`d` is not on `listview`'s key surface**, and revision 2 said it
was. The bound set is exactly `RET SPC n <down> p <up> TAB g q`
(`builtin/runtime/listview.lua:266-279`), bound buffer-locally inside
the primitive, which is the only place the panel's buffer handle is
known. **Looking the buffer up by name from outside is unsafe**
`listview` deliberately disambiguates a collision to `<2>`, so the
name a consumer passed is not necessarily the buffer it got.
*My vote: **a `keys` table on the open spec***, e.g.
`keys = { d = "git.diff-file" }`, bound through the same
`bind_local_keymap` that already binds the fixed set. It is additive,
general to any adopter, keeps binding where the buffer is known, and
adds **no** interception — the §6 constraint holds.
**The registration lifecycle, which revision 3 omitted and which
would have broken the refresh path it depends on.** `Keymap::bind`
**refuses duplicates**`KeymapError::DuplicateBinding`, *"Refuse
rather than silently overwrite"* (`src/keymap_tree.rs:75`) — and the
completion model calls `listview.open` again on **every** refresh. A
naive `keys` implementation therefore errors on the second open, so
**every successful refresh would have failed while re-binding `d`.**
The contract:
1. **Keys are installed once, when the panel's buffer is created**,
and stored on the panel.
2. **A later `open` for a live panel does not re-bind.** It
**compares** the supplied `keys` against the stored table and
**errors on divergence** rather than ignoring it. Silently keeping
the old binding would give the consumer a key that does something
other than what it just asked for — a dead or lying key, which is
the defect `listview` already condemns for `g`.
3. **Collisions are rejected at install time**, against both the
fixed set (`RET SPC n <down> p <up> TAB g q`) and any
**prefix conflict**`Keymap` has a separate error for turning a
leaf into a submap, and a `keys` table must not be able to reach
it.
(The alternative, idempotent re-registration, is tolerable but
strictly weaker: it makes a consumer that changes its keys mid-session
silently wrong instead of loudly wrong.)
**This IS a `listview` modification, and revision 2's "no listview
modification" was false.** I distinguish it from the async-contract
change I deferred: that one alters *when* an existing callback's
result is consumed for four existing adopters; this adds an optional
field that changes nothing for a spec that omits it. **If review
judges any primitive change out of an adopter's lane, the alternative
is `listview.open` returning the panel buffer** so the consumer binds
its own key — smaller still, but it pushes binding to every adopter.
- **No hunk model in Stage 1.** Hunks are precisely what gutter markers
need, and that is Stage 2's protocol work. Introducing a half hunk
model here to serve one gesture would prejudge Stage 2's design from
the wrong side.
**And what `d` actually SHOWS, which revision 2 left unstated.** "File,
not hunk" is a scope, not a contract. A porcelain-v2 row carries an
**XY** pair — X staged (index vs HEAD), Y unstaged (worktree vs index)
— and the three plausible diffs answer three different questions:
`git diff` shows only Y, `--cached` only X, and neither shows an
untracked file at all.
*My vote: **`d` answers the lane's own question — "what have I
changed?" — against `HEAD`:***
| row | `d` runs | why |
|---|---|---|
| staged, unstaged, or both | `git diff HEAD -- <path>` | one view of the total change; splitting X from Y is a staging UI, which is Stage 3 |
| deleted | `git diff HEAD -- <path>` | shows the deletion; no special case needed |
| renamed / copied | `git diff HEAD -- <orig> <current>` | v2 gives both paths; passing both is what lets rename detection render it as a rename rather than an unrelated add+delete |
| **untracked** | `git diff --no-index -- /dev/null <path>` | **a normal diff shows nothing at all** for an untracked file. Without this case `d` is silently dead on the rows a user is most likely to press it on |
| non-UTF-8 path | *refuses, with a message* | see Q#G-8 |
The `HEAD` choice is deliberate and is the one thing here I would most
expect review to push back on: it is the right default for *reading*
what changed, and the wrong one for *staging*, which is why it is
correct for Stage 1 and will need revisiting when Stage 3 arrives.
**The exit-state contract, which revision 3 got wrong in two ways.**
"Non-zero exit renders a failure row" is not correct for `git diff`.
Both cases below were measured in a scratch repository, not inferred:
**(a) `--no-index` implies `--exit-code`.** It exits **1 when it
successfully finds differences** — measured: `exit=1` for an untracked
file against `/dev/null`. Under revision 3's predicate, *every*
untracked diff — the case `--no-index` exists to serve — would have
rendered a failure row instead of the diff it just produced.
So for the untracked path the success predicate is **exit ∈ {0, 1}**,
rendering whatever came out; **exit ≥ 2 is a real failure**. That
asymmetry is confined to the `--no-index` invocation and does not leak
to the others, where non-zero still means failure.
**(b) An unborn repository has no `HEAD`.** Measured:
`git diff HEAD -- <path>` exits **128** with `fatal: bad revision
'HEAD'`. This is not an edge case — it is a freshly `git init`-ed
repository with the first files staged, which is exactly when someone
opens a status panel to see what they are about to commit.
*Policy: **detect once, then split**.*
**Detection needs no extra subprocess.** `--branch` already reports
`# branch.oid (initial)` when `HEAD` is unborn — observed in the
output this lane already parses. Revision 4 proposed a separate
`git rev-parse --verify --quiet HEAD`; that is a second process for a
fact the first one hands over.
**The reachable states, enumerated from a real unborn repository** —
`git init`, stage three files, then edit one, delete one, and `git mv`
one:
```
# branch.oid (initial)
1 AD ... ad.txt
1 AM ... am.txt
1 A. ... r_new.txt <- the `git mv`
? untracked.txt
```
Two findings fall straight out:
- **`AM` and `AD` are ordinary and carry BOTH states**, which is
exactly the gap: `--cached` alone loses the worktree delta, plain
`git diff` alone loses the staged base.
- **Rename and copy CANNOT occur under an unborn `HEAD`.** The
`git mv` produced `1 A. … r_new.txt` — an ordinary add of the new
path, **not** a `2` record. With no `HEAD` there is nothing to
rename *from*, so the rename/copy row class is unreachable here and
needs no unborn policy. That is a case closed by evidence rather than
handled speculatively.
| unborn row | `d` renders |
|---|---|
| `A.` staged only | one patch: `git diff --cached -- <path>` |
| **`AM` staged + edited** | **two labelled patches***staged* `git diff --cached -- <path>`, then *unstaged* `git diff -- <path>` |
| **`AD` staged + deleted** | **two labelled patches**, same pair; the second renders the deletion |
| `.M` / `.D` unstaged only | one patch: `git diff -- <path>` |
| `?` untracked | `git diff --no-index -- /dev/null <path>` (exit ∈ {0,1}) |
| rename / copy | **unreachable** — see above |
All four `--cached` / plain invocations above were run against that
repository and render the expected patches.
**The split is unborn-only, and that asymmetry is deliberate.** Once
`HEAD` exists, `git diff HEAD` gives one total — which is the lane's
question — and splitting it would be a staging UI (Stage 3). The split
appears here only because there is no `HEAD` to total *against*.
The generated buffer carries a **header naming what it is showing**:
*"no commits yet — split view: staged (index) above, unstaged
(worktree) below"*. Revision 4's wording ("showing staged changes")
would have described a single total-against-`HEAD` diff, which is
precisely what this is not. A diff that silently answers a different
question than the one asked is worse than one that says so — and a
header that misdescribes a split view is the same failure in smaller
type.
### Q#G-8 — non-UTF-8 paths: an honest boundary **(new in rev 3)**
Revision 2 listed a non-UTF-8 path in the witness corpus as though it
were an end-to-end case. **It cannot be**, and the boundary is in the
bindings: `pmacs.process.spawn` takes `args: Vec<String>`
(`src/lua_bindings/mod.rs:8683`) and `pmacs.buffer.find_or_open` takes
`path: String` (`:3564`). Both are Rust `String`, i.e. UTF-8 by
construction. A path that is valid bytes but not valid UTF-8 can be
*read* from git's `-z` output and *displayed*, but it cannot be passed
back to `spawn` for a diff, nor opened.
*My vote: **parse it, show it, and refuse the gesture with a
message***:
- the row **appears** in the panel, so the user is not lied to about
what is modified;
- **RET and `d` on that row report** that the path is not representable
and do nothing else — a witnessed refusal, not a stack trace or a
silent no-op;
- **it is removed from the end-to-end promise.** The witness is
parser-and-display **plus the refusal**, and the framing does not
claim visiting works.
Making it work end-to-end means `OsString`/bytes through two binding
boundaries — a real change to the Lua API surface, and not this lane's.
### Q#G-3 — what does the diff view render into?
*My vote: **a generated buffer**, reusing the generated-buffer
immutability work (Stage 1 merged; that lane's Stage 2 is queued).
Diff output is read-only text and that machinery exists.
**RESOLVED in rev 2 — there is no bundled `diff` grammar.**
`BUILTIN_LANGUAGES` (`src/syntax.rs`) has no `diff` entry; checked, not
assumed. **Stage 1 renders plain generated text**, and diff
highlighting is later work needing a grammar first.
### Q#G-4 — what is configurable?
*My vote: **one setting to start**`git.enabled` (boolean, default
`true`), through the config registry. Resist more until there is use
evidence; §11's grade is "partial (foundation only)" and adding five
speculative settings is how a registry becomes noise.
### Q#G-5 — §9 attribution — **RESOLVED, and the answer is negative**
Revision 1 deferred this to implementation. That was wrong: it is
answerable by reading, and deferring it would have meant discovering a
known coherence cost *after* committing to the design.
**A spawned git process does not appear in `*workers*` at all.** That
buffer is `builtin/runtime/async.lua`'s (`:490`) and lists **async
jobs**; spawned processes live separately under `pmacs.process.list`.
They are two of the four disjoint activity views §9 grades as
"mechanism without identity".
So, stated plainly rather than dressed up:
- **This lane adds a fifth thing that runs in the background and is not
attributable from one place.** That is a **negative** coherence impact
against §9, and it is the honest cost of shipping git status before
worker identity exists.
- **Labelling the process is still required** — a clear label under
`pmacs.process.list` is strictly better than an anonymous `git`. But
**a label does not solve attribution**, and this document does not
claim it does. The claim is only: do not make it worse than it has to
be.
- **The mitigation is bounded in time, not in kind.** These are
short-lived reads, not long-running jobs; a `git status` that has not
finished is a bug, not a background task a user needs to supervise.
That is why the cost is acceptable *now* and would not be for
Stage 3's push/pull.
## 6. Verification
- **Parsing, against a corpus rather than a case (Q#G-6):** modified,
added, deleted, untracked, **renamed with both paths**, **copied**, a
path with a space, and **a path with a newline** — the last is what
`-z` buys, and a parser that passes only the space case is the one
that ships broken.
- **A non-UTF-8 path is parsed and displayed, and its gestures refuse
with a message** (Q#G-8) — a witnessed refusal at the binding
boundary, **not** an end-to-end visit.
- **The argv carries `--no-optional-locks`** (Q#G-6), asserted
structurally. A lock not taken cannot be observed directly, so the
invocation is what gets pinned.
- **`d` is witnessed on every row class** (Q#G-7): staged, unstaged,
both, deleted, renamed, and **untracked** — the last because a normal
`git diff` shows nothing there, so a missing `--no-index` case makes
`d` silently dead exactly where it is most used.
- **A copy is reported as a COPY, not a rename** (Q#G-7). Porcelain v2
folds both into the one `2` record, so `kind` stays `"rename"` for
both — every *behaviour* keyed on it is the same — and the
distinction is made where it is a distinction: the diff header reads
the `<Xscore>` field's leading `R`/`C` and says which one happened.
The status row is left alone, because its `XY` prefix already reads
`R.` against `C.`. Both classes are asserted, and so is the **argv**:
the two-path `git diff HEAD -- <orig> <current>` is right for a copy
and a rename alike, so a fix to what the user is *told* must not
reach what runs. **Parser-level, deliberately** — the copy ROW is
supplied through `_deliver_status` while the repository, the panel,
the `d` dispatch and the spawned diff around it are real.
**The reason, narrowed after review.** This bullet used to say real
`git` emits no `2 C` record "even under `status.renames=copies`".
**That is too strong, and git's own documentation contradicts it**
`git-status(1)` lists `C` as *"copied (if config option
status.renames is set to `copies`)"*. What the test measures is
narrower: **for its fixture, whose copy source is left unchanged**,
git reports `1 A.`. That is a fact about the fixture, and it is
sufficient reason to craft the row — a weaker and true justification
in place of a stronger false one. No mechanism is claimed for why an
unchanged source is not offered as a candidate; that was never
established.
- **The untracked diff renders on exit 1**, not a failure row (Q#G-7a)
— the case `--exit-code` semantics would otherwise break, and the
one most likely to be "fixed" later by someone who reads exit 1 as an
error.
- **An unborn repository is witnessed end to end**, and the fixture is
**`AM`** specifically — staged then edited again, the shape a first
commit actually has partway through. `git init`, stage, edit, open
the panel, press `d`, and get **two labelled patches** with the
split-view header — not `fatal: bad revision 'HEAD'`, and not a
single `--cached` patch that silently drops the worktree edit.
**`AD` rides the same fixture**, since one repository can hold both.
- **Unborn detection reads `# branch.oid (initial)`** from the status
output already being parsed — asserted, so nobody later reintroduces
a second `rev-parse` process for a fact already in hand.
- **Rename/copy under an unborn `HEAD` is asserted UNREACHABLE**: the
fixture `git mv`s a staged-but-uncommitted file and the parser sees a
`1 A.` record, never a `2`. Pinned so a future reader does not
"fix" the missing unborn rename policy by inventing one.
- **Re-binding across a refresh does not error** (Q#G-7): two
successive refreshes on a live panel, asserting `d` still works and
no `DuplicateBinding` surfaced. This is the one that would have
broken on every refresh.
- **A `keys` table colliding with the fixed set is rejected at install
time**, as is a prefix conflict.
- **Selection is re-seated by the completion handler** (Q#G-1), across
a refresh that reorders rows, and **falls back to line 1 without
complaint when the selected path drops out of status** — the common
case, not an error.
- **The pure `parse_*` functions are tested without a repository**,
which is the shape `pmacs-magit/status.lua` already proves works and
the reason to port that separation rather than invent one.
- **A repository fixture built with real `git`**, in a tempdir, and
**bounded with `set_search_boundary`** — R8 was retired two commits
ago and is precisely what happens when a fixture lets project
detection escape into the developer's environment.
- **The root rule is witnessed on a repository whose `ProjectKind` is
NOT `Git`** — i.e. an ordinary language project with a `.git` beside
its manifest. That is the case revision 1's `kind == "git"` gate
would have failed, and this repository is one.
- **Missing `git` on `PATH` is witnessed**, not assumed (Q#G-2), and
surfaces guidance rather than silence.
- **`g` is never a no-op** (Q#G-1): it re-renders and marks that work
started, even mid-flight. A dead `g` is a defect `listview` already
names.
- **Concurrent refresh discards the stale generation** rather than
racing — asserted by driving two refreshes and completing them out of
order.
- **Failure renders a row**, carrying exit code and stderr.
- **The panel is a `listview` adopter**, asserted structurally, so a
future re-implementation of list behaviour inside git code fails the
test rather than passing review.
- **No new interaction island**`d` is bound buffer-locally through
`listview`'s own binding path (Q#G-7), not a hardcoded interception.
§6 stays at six shadows.
Gates via `scripts/gate --acceptance <the new suite>`.
**What this will NOT prove:** that background git work is attributable
(Q#G-5 — it is not, by construction), or that the parser handles
porcelain versions other than v2.
## 7. Not in scope
Gutter markers and any `DecorationKind`/`PROTOCOL_VERSION` change
(Stage 2 — must be scheduled alone). Staging, commit, push, pull,
branch operations, merge-conflict resolution. Blame. A `git` context in
the menu vocabulary. Any git *library* dependency. Live refresh
(Q#G-1). Fixing §9's worker identity — this lane makes it marginally
worse and says so (Q#G-5). Any hunk model (Q#G-7). Modifying the
`listview` primitive to carry an async contract — the better long-term
answer, but it belongs in its own lane before this one, not inside its
fifth adopter (Q#G-1). Changing `tests/fixtures/pmacs-magit/` or
`tests/m8_6_acceptance.rs` (Q#G-0).
**A `listview` change IS in scope after all** (Q#G-7): an optional
`keys` table on the open spec. Revision 2 said no primitive
modification; that was false, because `d` cannot be bound from outside
the primitive safely. The async-contract change stays out.
**One correction this lane should carry when it lands:** `COHERENCE.md`
§15's "no Git integration anywhere in the tree" is literally false —
`tests/fixtures/pmacs-magit/` exists. The *product* gap it describes is
real; the sentence needs narrowing to say so.

View File

@ -231,33 +231,12 @@ all share one keymap:
| `RET` / `SPC` | `listview.visit` — act on the item under the cursor |
| `n` / `<down>` | `cursor.down` |
| `p` / `<up>` | `cursor.up` |
| `TAB` | `listview.toggle` — collapse/expand the tree node under the cursor; a panel with no tree rows delegates to `buffer.tab` |
| `g` | `listview.refresh` — re-run the data source and re-render |
| `q` | `listview.quit` — restore the buffer that was active before the panel opened |
(`TAB` arrived with the tree primitive and this table had not recorded
it. Noted rather than quietly added: the omission predates the git lane
that found it.)
Panels currently built on this: `*references*`, `*outline*`,
`*lsp-help*` (hover docs), `*lsp*` (`lsp.status`), and `*git-status*`
(`git.status`). Header text always spells out the panel's own legend
inline.
A panel may add keys of its own through an optional `keys` table on the
open spec, bound through the same buffer-local path — so they are
inspectable by `describe-key` and rebindable from `init.lua`, exactly
like the fixed set. They are installed once with the panel's buffer and
may not collide with the fixed set, nor prefix it. One panel uses this
today:
| Buffer | Key | Command |
|---|---|---|
| `*git-status*` (`git.status`) | `d` | `git.diff-file` — the diff for the file under the cursor, into `*git-diff*` |
`git.status` gets **no global chord**: an opening key is a
command-surface decision the Stage 1 framing did not make, so the entry
point is `M-x git.status`.
`*lsp-help*` (hover docs). Header text always spells out the same
`RET`/`n`/`p`/`g`/`q` legend inline.
`*buffer-list*` (`editor.list-buffers`, `C-x C-b`) uses its own
keymap, layered on the same idiom, in `builtin/commands/default.lua`:

View File

@ -1,240 +0,0 @@
# LSP file watcher — framing
**Status: revision 2 — approved design (2026-08-10), plus two
correctness findings from review OF THE IMPLEMENTATION.** The user ruled
that D1 and D2 proceed with the walking explicitly surviving this lane;
D3 gets its own framing. The acceptance bar for this lane is correctness
and the leak, not "the flipping stops".
**Revision 2 records a review round against the code, not the design.
Both findings are cases where the first fix was itself wrong**, and both
were confirmed against the tree before being acted on:
- **P1 — the form must be read from the PATTERN, not from the union
arm.** `resolve_watcher` returned `"absolute"` for *every* string, so
a bare `*.txt` — a valid relative pattern under LSP 3.17, and how VS
Code treats string watchers across workspace folders — was matched
against `<base>/foo.txt` and could never fire. **That case worked
before this lane touched it**, so the repair for #233 silently broke a
live path while fixing another. A leading `/` is what makes a pattern
absolute; classification now reads the string.
- **P2 — a scan completing after cancellation still emitted.**
`scan_tree` awaits `read_dir` once per directory, so the coroutine
sits suspended for most of a tick with `_sleep` already cleared. A
cancel arriving there sets `cancelled` and has no sleep to interrupt,
and the resumed scan ran on to `did_change_watched_files` — one stale
batch under the superseded pattern, which is a wrong-pattern
notification the server acts on. Cancellation and liveness are now
rechecked after the scan.
**F1's lesson repeated itself inside this lane.** The flat-pattern test
constrains the RelativePattern **object** arm, so it said nothing about
the **string** arm P1's regression lived in — the same
tested-path/exercised-path split this framing opened by naming. Both
findings now have tests, and both tests were mutation-checked: each
fails only its own defect.
**A test seam was added, and is recorded here rather than buried.**
`pmacs.lsp._after_scan_for_tests` is a production hook, nil in normal
operation, that P2's witness requires: the race is a cancel landing
during one of the scan's suspensions, which no arrangement of real
timing produces on demand. Same device and justification as `git.lua`'s
`_deliver_status`. It is handed the scan result deliberately — a test
that cancels on any *other* scan passes with the fix deleted, because
the loop would break at the post-sleep check and emit nothing anyway.
Answers issue #233. **Scope is D1 and D2 only** — the two bug-shaped
defects. D3 (the polling cost) is named here, deferred with reasons, and
gets its own framing.
## What is and is not a regression
**#232 is not at fault and nothing about it should be reverted.** The
statusline activity indicator it added is *correct*: it renders real
in-flight jobs from `AsyncRuntime::activity_summary`, and the jobs it
names (`sleep 250ms`, `read_dir <path>`) are real. What changed on
2026-08-09 is **visibility**, not behaviour.
The behaviour has been there since `1c25730` (2026-05-19). So the user-
facing report — "the modeline flips several times a second" — is a
three-month-old defect that became observable last week, and the fix
belongs to the watcher, not the indicator.
Recorded plainly because the tempting move is to quiet the indicator,
and that would delete the only instrument that found this.
## Verified against the tree at `0e4c58d`
Every claim below was read or executed this session, not carried from
the issue.
- `FILE_WATCH_INTERVAL_MS = 250` (`lsp.lua:1924`); each watcher is one
`pmacs.async` coroutine looping sleep → `scan_tree`
(`lsp.lua:2060-2097`).
- `scan_tree` builds `rel` from an empty prefix and calls
`matches(rel)`**relative** paths (`lsp.lua:2035-2056`).
- **`walk` recurses into every directory unconditionally.** `matches`
gates only whether an entry is *recorded*. A watcher that can never
match still walks the whole tree every tick.
- `resolve_watcher`'s string branch returns the pattern **unchanged**
with the base guessed from an attached file's directory
(`lsp.lua:2102-2116`).
- `register_file_watchers` ends `file_watchers[skey][reg.id] = recs`
with no cancellation of the outgoing list (`lsp.lua:2132`).
- Job purposes are `format!("sleep {}ms", …)` (`async_runtime.rs:1027`)
and `format!("read_dir {}", …)` (`:1178`).
- The fake LSP registers **one** watcher, a `RelativePattern`
`{ baseUri, pattern: "**/*.txt" }`, id `watch-1`
(`pmacs_fake_lsp.rs:312-331`).
### The glob table, reproduced
Ran the tree's own `expand_braces` / `glob_one_to_pattern` /
`glob_matcher` under LuaJIT. Output matches the issue exactly, compiled
patterns included:
| glob | compiled | `main.go` | `go.mod` | absolute |
|---|---|---|---|---|
| `**/*.{mod,work}` | `^.-[^/]*%.mod$` | false | **true** | true |
| `<abs>/goproj/**/*.{go,…}` | `^/tmp/goproj/.-[^/]*%.go$` | false | false | true |
| `<abs>/rsproj/**/*.rs` | `^/tmp/rsproj/.-[^/]*%.rs$` | false | false | true |
## Two findings the issue does not carry, both of which shape the fix
### F1 — the existing test cannot discriminate this fix, in either direction
`**/*.txt` compiles to `^.-[^/]*%.txt$`, and `.-` spans `/`. Measured:
it matches `a.txt`, `sub/a.txt`, `/base/a.txt` **and**
`/base/sub/a.txt`. So `m4_24_workspace_did_change_watched_files` passes
whether the matching subject is relative or absolute.
The issue says the tested path and the exercised path are disjoint. The
sharper statement is that the existing test is **insensitive**: it
cannot fail for D1 and it cannot confirm D1's fix. New coverage must use
a pattern whose two readings disagree, or it will inherit the same
blindness.
### F2 — the fix cannot simply "match absolute"; the form must be carried
Per LSP, a plain-string glob matches the **absolute** path while a
`RelativePattern`'s pattern is relative to **its base**. Matching
everything absolutely breaks the second. Measured on `*.txt`:
| subject | matches |
|---|---|
| `a.txt` (relative, correct for RelativePattern) | **true** |
| `/base/a.txt` (absolute) | **false** |
`resolve_watcher` returns `(base, pattern)` and **discards which form it
came from**, so both callers below it are already unable to tell. The
fix therefore changes that function's contract — a third return value or
an explicit record field — rather than only changing the subject string
at the match site. A fix that ignores this trades rust-analyzer's six
broken globs for every `RelativePattern` whose pattern does not begin
`**/`.
## D1 — plain-string globs never match
**Consequences, as measured in the issue and confirmed by the table
above:** rust-analyzer is never told about any file change (all six
globs absolute); gopls is told about `go.mod`/`go.work` but never `.go`
sources (only its relative glob matches).
**Fix:** match a plain-string glob against `base .. "/" .. rel`; keep a
`RelativePattern` matched against `rel`. `resolve_watcher` gains the
form in its return, and the record carries it.
The leading `**/` in gopls' relative glob compiles to `.-`, which spans
`/`, so that glob keeps matching under the absolute subject — which is
why one server's working case does not regress.
## D2 — re-registration leaks the previous coroutines
`file_watchers[skey][reg.id] = recs` replaces the record list without
setting `cancelled` or cancelling the in-flight `_sleep`. The old
coroutines poll until the server dies and are unreachable by
`unregister_file_watchers`, which can only see what the table now holds.
**Reachable today**: rust-analyzer registers
`workspace/didChangeWatchedFiles` **twice under the same id**, six
watchers each, with no intervening unregister — 12 concurrent
coroutines, six permanently uncancellable. The issue's 44.1/s dir-open
rate against a ~270 ms period implies 12 watchers, so the leak is
measured from outside the process, not only read from the source.
**Fix:** cancel the outgoing list before replacing it, with the same
treatment `unregister_file_watchers` already applies.
## What this lane does NOT fix, stated so the report is not mistaken for closed
**The poll cost survives both fixes.** D1 makes matching correct and D2
halves rust-analyzer's watcher count; neither stops the walk. After this
lane, rust-analyzer still walks the entire tree every 250 ms — six times
per tick instead of twelve — including `.git`, `target` and
`node_modules`, at one async job per directory.
So the modeline will still show activity, at roughly half the rate. **If
the acceptance bar for this lane is "the flipping stops", this lane does
not meet it** and should not be started until D3 is framed. That is a
ruling for the user, not an assumption to make quietly.
**Answered 2026-08-10: the user accepted this scope.** D1 and D2
proceed; the walking is D3's problem, framed separately.
## D3 — deferred, with what was checked
Options named in the issue: coalesce a server's watchers into one scan;
root the scan at the workspace rather than an attached file's directory;
an ignore list; back off when nothing changes; or a real
filesystem-notification primitive.
Checked while framing: **there is no `notify`/inotify dependency in the
tree**, so the last option is a new crate *and* a new Rust primitive
plus its Lua binding — not a small change. There is also **no existing
ignore-list infrastructure** to reuse; `src/project.rs` knows `.git` as
a *marker* name, not as something to skip.
D3 is a `COHERENCE.md` §9 concern — background work with no ownership
model — and §9's own Stage 1 is the indicator that surfaced it.
## Verification
The suite must fail without each fix, which the existing suite cannot
(F1). Planned:
- **A fake-LSP mode registering a plain-string ABSOLUTE glob**, with a
pattern whose relative and absolute readings **disagree** — so the
test fails today and passes after D1.
- **A fake-LSP mode registering a `RelativePattern` whose pattern does
not begin `**/`** (e.g. `*.txt` at the base). This is F2's guard: it
passes today, and fails against a fix that matches everything
absolutely. Without it, the obvious wrong fix is green.
- **A re-registration mode**: the same id twice, no unregister. The
witness is that the superseded watchers **stop**, asserted on
observable polling rather than on internal table shape, since the
defect is precisely that the old records are unreachable.
- Existing `m4_24` kept and expected **unchanged** — it covers the
working branch and its insensitivity is now recorded rather than
mistaken for coverage.
Each new test is mutation-tested against the fix it names.
## Coherence impact (§20)
- **Journey steps**: none added; step 5's editing surface is affected
only in that a correct watcher makes servers see edits they currently
miss.
- **Interaction islands**: none.
- **Config registry**: no new setting. The interval stays a module
constant; making it configurable would offer the user a knob for a
defect rather than a preference, and D3 may remove the poll entirely.
- **Background-work attribution (§9)**: this lane *reduces* unattributed
background work but does not model it. D3 owns that, and the honest
statement is that the indicator worked — it made three months of
invisible churn visible on its first week.
## Gates
`./scripts/gate --acceptance m4_acceptance` plus the touched LSP
acceptance suites; no `--protocol` (no wire change, no
`PROTOCOL_VERSION` bump).

View File

@ -1,717 +0,0 @@
# Worker identity — Stage 1: what is running, and what it is doing
*(Revision 1 was subtitled "and who asked for it". With `owner`
removed that title overclaimed the lane: it answers **what**, and —
under `pmacs.workers.dispatch`**under which registered handler**.
Neither is who owns it.)*
**Status: revision 4, APPROVED 2026-08-09. IMPLEMENTED — see
`docs/active-work.md` for the commits, the gate outcome and the review
rounds.**
**Revision 4 scopes rule 1's claim to what it can actually enforce, and
takes Q#W-7 into this lane.** Revision 3 said the rule covered "all
yield points"; it covers **the two supported pmacs yield APIs**. Raw
`coroutine.yield` stays reachable — R46 is a convention, and the
scheduler diagnoses a non-Handle yield only *after* the coroutine has
suspended (`async.lua:197` resumes, `:212` inspects), so no refusal
sited in a yield helper can intercept it. The residual is named in §2
rather than papered over.
**Revision 3 closes a hole in revision 2's ambient: the extent it
called "synchronous" is not.** A registered handler is arbitrary Lua
and may `Handle:await()`, parking the coroutine with the name still
pushed so that unrelated later work inherits it. Rule 1 now **enforces**
non-yieldability rather than assuming it, following the guard this file
already carries for `pmacs.window.commit_to`. Scouting that guard
turned up a second supported yield API it does not cover — Q#W-7, a
pre-existing defect in another lane's invariant. Revision 3 reported it
rather than patching it in silence; **revision 4 fixes it here, on
approval**, since it is the same helper, the same invariant and the
same edit family.
**Revision 2 removes `owner` and respecifies the handler-name path,
after review found the first dishonest and the second unbuildable as
described.** `owner` populated from static per-subsystem constants is
an *origin*, not an owner, and would misattribute third-party work at
exactly the point §9 wants attribution. And "the name is in hand at the
one place that throws it away" was **wrong about the call chain** — it
is thrown away across three layers, one of which callers are documented
to bypass. Both re-scouted in the tree.
---
## 1. Why this, and why now
`COHERENCE.md` §9 grades the worker model **mechanism without
identity**, and §0 names **step 11 (background-work ownership)** as one
of the two remaining thin ends of the golden journey. §20 Priority 1 is
blunt about where that leaves things:
> **The remaining thin end is no longer inside this priority.** Step 1
> is install, which is **P8**; step 11 is background-work ownership,
> which is §9.
So this is the last of Priority 1's own journey, sitting in another
section's arc. Everything else P1 named has landed.
**The felt gap is smaller and sharper than the arc.** §9's audit ends
with a claim that is checkable, and I checked it:
> **No progress indicator exists anywhere** — no statusline spinner, no
> busy count.
`grep -c -i "spinner\|progress\|busy" src/statusline.rs` returns **0**.
So §3's promise of "visible asynchronous work" is **false today** unless
the user knows to run `M-x editor.list-workers`. Every build, LSP index,
grep, parse and — as of the lane merging beside this one — every `git
status` runs with no indication that anything is happening at all.
**And the git Stage 1 lane in flight right now makes it worse, by its
own admission.** `docs/git-integration-framing.md` Q#G-5 states it
plainly: git runs as a spawned process, spawned processes do not appear
in `*workers*`, and the lane therefore "adds a fifth thing that runs in
the background and is not attributable from one place". It accepted that
cost because these are short-lived reads. This lane is the one that
repays it.
## 2. Ground truth
Scouted in the tree, not recalled from the audit — and the audit has
drifted in one place, recorded below.
- **The audit's `PendingJob` field list is stale, and the drift is
informative.** §9 lists seven fields; the struct
(`src/async_runtime.rs:367-411`) carries **eight**. The addition is
`resource: Option<ResourceOp>`, from dired Stage 2a — and **its doc
comment cites `COHERENCE.md` §9 by name** as the reason it is a field
on the job rather than a side map:
> `COHERENCE.md` §9 is why this is a field on the job and not a side
> map — the parse job→buffer link already lives in a side map and §9
> names that as the defect.
So the precedent for putting identity **on the job** is already set,
already argued, and already merged. This lane extends a decision
rather than introducing one.
- **There is a SINGLE allocation funnel, and that is what makes this
tractable.** Every job in the system is born in `allocate`
(`src/async_runtime.rs:746`), which delegates to
`allocate_with_resource` (`:757`). The ten `dispatch_*` methods
(`:803``:980`) and `register_external` (`:1011`, used by MCP and LSP)
all pass through it. An identity field added there reaches every job
by construction — there is no second birth site to miss.
- **The two-function split is itself a warning.** `allocate_with_resource`
exists only because one prior lane needed one extra parameter. A
second lane doing the same produces
`allocate_with_resource_and_identity`, and a third produces something
worse. This is the point to collapse it (Q#W-1).
- **`JobKind` is still a closed 12-variant enum**
(`src/async_runtime.rs:305-343`) — Sleep, ComputeSum, EmitN, Grep,
Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove, McpRequest,
LspRequest. Confirmed unchanged since the audit.
- **A third-party job's own name is retained nowhere, and recovering it
is NOT cheap. Revision 1 said it was, and was wrong about the call
chain.** The full path, read rather than assumed:
```
pmacs.workers.dispatch(name, args, opts) -- async.lua:369
→ handlers[name](args, opts) -- arbitrary Lua
→ dispatch_grep(spec, opts) -- Lua wrapper, :312
→ async_mod._dispatch_grep(spec, supersede_key(opts), max_batch)
→ the Rust binding → allocate()
```
**`name` is not a parameter of any layer below the first.** The Rust
dispatchers accept job arguments, a supersede key and stream data —
nothing else. So revision 1's "change the allocation funnel and the
name is recovered" is false: changing `allocate` gives the name
nowhere to arrive *from*.
**And the wrapper layer cannot be the capture point either.**
`async.lua:337-345` deliberately exposes `pmacs.workers._new_handle` /
`_new_stream` so that "other builtin runtime files (`pmacs.fs` in
M8.1, future siblings) can construct handles for ids dispatched
through **their own raw `_dispatch_*` primitives**". A handler that
goes straight to `async_mod._dispatch_*` bypasses `dispatch_grep` and
friends entirely — and those are precisely the callers doing
non-standard work, i.e. the ones attribution is for.
The audit's "every third-party job renders under a builtin's label"
is exact. The mechanism that fixes it is Q#W-2, and it is a real
mechanism, not a parameter.
- **`ProcessSpec` has one identity field and it is a convention**
(`src/process.rs:193-235`): `label: String`, documented as
"human-readable ... surfaced in events and the `pmacs.process.list`
output". No owner, no purpose, no parent. Callers spell it however
they like (`lsp:{name}`, a terminal buffer name).
- **A dynamic scope that must not be yielded out of ALREADY EXISTS
here, guard and rationale included.** `Handle:await()` refuses to run
inside `pmacs.window.commit_to` (`builtin/runtime/async.lua:87-90`),
raising *"await: cannot await inside pmacs.window.commit_to; await
first, then commit"*. Its comment states the hazard in general terms:
yielding out of the extent "would restore the scope while this
coroutine is still parked, so the rest of the commit would resume
ambient". `commit_to` itself is "an RAII guard on the Rust stack" —
the same shape this lane needs.
- **There are TWO SUPPORTED yield APIs, not one.** `Handle:await()`
yields at `async.lua:95`; **`pmacs.async.yield_to_next_tick()` yields
at `async.lua:244`** and is public (`pmacs.async` is `async_public`,
`:247`). Any rule about a non-yieldable extent has to cover both. The
`commit_to` guard covers only the first — see Q#W-7.
- **Raw `coroutine.yield` remains reachable, and NO guard of this shape
can cover it.** R46 is a convention — *"package code uses `:await()`
rather than `coroutine.yield`"* (`async.lua:26-27`) — not an
enforcement. The scheduler does diagnose a non-Handle yield
(`async.lua:217-223`, *"use Handle:await() per R46"*), **but only
after the fact**: `step` calls `coroutine.resume(co)` at `:197` and
inspects what came back at `:212`, by which point the coroutine has
already suspended. A refusal placed in a yield helper is never
consulted, and the enclosing `pmacs.workers.dispatch` never returns
to run its pop.
So the honest bound is: a package that violates R46 *inside* a
dispatch-name scope can leak the name. It is not silent — the
scheduler raises it through `pmacs.error` into `*errors*` — but the
scope is not restored, and this framing does not claim otherwise.
And the two findings that actually shape the design:
- **A statusline provider API already exists, with three Lua adopters.**
`pmacs.statusline.register` is live in `terminal.lua:477`,
`syntax.lua:551` and `lsp.lua:1145`, taking
`{ name, side, priority, face, fn(ctx) }` and returning a string or
`nil`. An activity indicator is a **fourth registration**, not a new
mechanism.
**The three are named by FILE above and by NAME in the registry, and
the two do not line up.** `syntax.lua` registers its provider as
**`"mode"`** (it projects the major mode, `syntax.lua:552`), so the
registry inventory reads `["mode", "terminal", "lsp"]` — which is what
`tests/statusline_segments_acceptance.rs` asserts. Recorded because it
is genuinely surprising: a reader looking for the syntax adopter by
name does not find one. A fourth registration therefore changes that
assertion, and where the new name sorts depends on **load order**, not
on the name: `async.lua` is evaluated before `syntax.lua`,
`terminal.lua` and `lsp.lua` (`src/editor.rs`), so a provider
registered there lands first.
**And it is evaluated per frame**: `evaluate_statusline` is called
inside `paint_frame` (`src/editor.rs:4560`), before the long mutable
core borrow. So an indicator updates while work is in flight without
any new tick machinery — and, decisively for scheduling, **without
touching the wire**. `EvaluatedStatuslineSegment` is already
`Vec`-valued on an existing message; a fourth provider adds an element,
not a variant.
- **`pmacs.process.list` deliberately hides terminal PTYs, and
un-hiding them is NOT free.** The binding filters to
`AnsiParserProfile::LineOriented`
(`src/lua_bindings/mod.rs:8980-8984`). `git log -S` dates that filter
to `bbc1f33 feat(vterm): add Stage 1 terminal core` — terminals were
excluded on purpose.
**Three acceptance suites use `#pmacs.process.list()` as a leak
detector**: `tests/m6_8_multi_repl_acceptance.rs:385`/`:459` ("size
must not grow across cycles"), `tests/compile_mode_acceptance.rs:133`/
`:458` ("process list returns to baseline"), and
`tests/lean4_stage1_acceptance.rs:327`/`:349`. **Removing the filter
would inflate every one of those baselines by each open terminal.**
This is why §9's "a terminal PTY appears in no user-visible activity
view" is a real defect with a **non-obvious fix**, and why this lane
does not casually widen the existing accessor (Q#W-4).
## 3. The staging, and why the line falls where it does
§9's full statement wants owner, workspace, buffer, parent, children,
latency class, cancellation scope, resource budget, execution location,
progress, and failure attribution. **Two of those cannot be built at
all right now**: `Workspace` is §7, graded *missing*, and `Location` is
§8, graded *missing (architecture ready)*. A lane that added
`workspace: Option<WorkspaceId>` would be adding a field typed on a
thing that does not exist.
**Stage 1 (this lane): a required `purpose` on the job and the process,
and the first indicator. NO WIRE CHANGE. NO `owner`.**
- **`purpose`, non-optional**, on `PendingJob`, carried through the
single allocation funnel, and on `ProcessSpec` alongside the existing
`label`.
- **A dispatch-identity ambient** so `pmacs.workers.dispatch` stops
discarding the registered handler name (Q#W-2).
- `*workers*` renders `purpose`.
- **A statusline activity indicator** — the fourth provider
registration, and the part a user feels on day one.
**`owner` is deliberately absent, and revision 1 was wrong to include
it.** The proposal was `owner = "lsp"` populated from a static
per-subsystem constant at each dispatcher. But a generic dispatcher has
no trustworthy knowledge of who invoked it, and `pmacs.process.spawn`
is callable by any package — so a static subsystem label is an
**origin or category, not an owner**, and it would confidently
misattribute third-party work to a builtin at exactly the point §9
wants attribution. A field that asserts a falsehood is worse than an
absent one: `*workers*` would *look* attributed while naming the wrong
party.
**Nor is it retained under a safer name.** Calling it `origin` or
`subsystem` would be honest, but a second string field sitting beside
`purpose` and grouping the view would be *adopted* as ownership by the
next reader regardless of its name — and it would squat on the slot
P3's real package signal has to fill. Stage 2 needs a grouping key; it
should get a real one, not a placeholder promoted by use.
**Stage 2 (separate lane): join the planes.** One activity view over
jobs, processes, LSP servers and terminals. This is what Stage 1's
identity is *for* — the audit's own conclusion is that "the four views
exist precisely because there is no common key to merge them on". It
also owns the terminal-visibility decision (Q#W-4), because that is a
question about the unified view, not about the accessor.
**Stage 3 (unscheduled): the tree and scoped cancellation.**
`parent`/`children`, and cancel-by-owner / by-buffer / by-subtree. This
needs an ambient "currently-running job" context so a child dispatched
inside a job can find its parent without every call site threading it —
a real mechanism with its own failure modes, and the reason parent is
**not** in Stage 1 (Q#W-5).
**Workspace and location are never this arc's**, at any stage. They
arrive from §7 and §8 and this arc consumes them.
**The line falls at the wire on purpose, and it is again a scheduling
decision.** The discovery Stage 2 lane holds the v22→v23 bump slot, and
git Stage 2 is already queued behind it. `PROTOCOL_VERSION` is a strict
serialization point. Stage 1 here touching no wire is what lets it run
beside both.
## 4. Coherence impact (§20)
- **§9 worker ownership — the direct target**, and specifically the
audit's named prerequisite: *"Owner/purpose/parent fields on the job
and process specs are the prerequisite; the unified view and the
ownership tree fall out of them."* **Stage 1 takes ONE of the three
`purpose`.** `owner` waits for P3 to supply a package signal worth
recording (§3); `parent` waits for Stage 3 (Q#W-5). Taking one of
three named prerequisites is a deviation from the audit, and it is
stated here rather than left to be noticed.
- **Journey step 11 — the direct target.** §0 names background-work
ownership as one of two remaining thin ends. This does not close the
step (Stage 2's unified view is most of that) but it is the first
thing that makes work *visible*, which is what step 11 is about.
- **§3 zero-configuration state:** repairs a claim that is currently
false. "Visible asynchronous work" becomes true by default, with no
configuration and no command to know about.
- **Interaction islands (§6): none added.** The indicator is a
statusline provider; it intercepts no keys and adds no precedence
rung.
- **§14 workbench primitives: untouched.** `*workers*` already exists;
this changes what it renders, not what renders it.
- **Config registry:** one setting at most, and my vote is a *visibility*
toggle only (Q#W-6).
- **The debt this repays is named and dated.** `git-integration-framing.md`
Q#G-5 recorded a deliberate negative §9 impact. This lane does not
fully discharge it — a labelled process is still not in `*workers*`
until Stage 2 — but it makes the process state *what it is doing* in
a required field rather than a caller-spelled convention.
- **No P3 alignment is claimed.** Revision 1 argued this lane aligned
with P3's ownership arc. With `owner` removed, it does not: P3 stays
entirely ahead of it, and this lane deliberately leaves that slot
empty rather than filling it with something P3 would have to displace.
## 5. Open questions
### Q#W-1 — how is identity supplied at the allocation funnel?
The existing shape is `allocate(kind, supersede, stream)` delegating to
`allocate_with_resource(kind, supersede, stream, resource)`. Adding two
more positional parameters gives a five-argument function and a
six-argument variant, and the next lane adds a seventh.
*My vote: **collapse the pair into one funnel taking a struct***, e.g.
`allocate(JobSpec { kind, supersede, stream, resource, purpose })`, so
the ten dispatchers read as named-field literals rather than positional
soup. Ten call sites plus `register_external` is a bounded, mechanical
edit, and it removes the `_with_resource` wart rather than adding
beside it.
**`JobSpec` is private, and `purpose` is non-optional.** Private
because the public dispatcher APIs should not grow a parameter every
time this arc adds a field; non-optional because that is what makes the
compiler, rather than a test, the thing that proves every caller
supplied one (§6). A `Default` impl would defeat exactly that, so
`purpose` is not defaulted even if other fields are.
**The counter-argument, which is real:** this touches every dispatcher
in a lane whose subject is identity, which is scope the reviewer did not
ask for. **If review prefers the minimal edit**, the alternative is one
more parameter on the existing pair, and the collapse becomes its own
small lane. I would rather be told than assume.
### Q#W-2 — the dispatch identity path **(rewritten in rev 2, rule 1 added in rev 3)**
Revision 1 treated this as a parameter-passing detail. §2 shows it is
not: `name` dies at `pmacs.workers.dispatch` and nothing below it takes
a name, so the value must be carried *out of band* across an arbitrary
handler.
**Revision 2 then called the extent "synchronous" and assumed it.
Review found that it is not.** A registered handler is arbitrary Lua
running inside `pmacs.async`, and it may call `Handle:await()` — a
legal, yieldable path that the existing tests already exercise inside
`pcall`. While a handler is parked, its pushed name **stays on the
stack**, and every tick callback and every other coroutine that
allocates a job in the meantime inherits it. That is not a corner case;
it is the ordinary shape of a handler that awaits.
So rule 1 below is no longer an observation about how handlers happen
to behave. It is an **enforced** property, and the enforcement already
has a precedent in this exact file (§2a).
**The capture point is Rust, not Lua**, and the reason is the bypass in
§2. If the ambient lived in the Lua wrapper layer, a handler calling
`async_mod._dispatch_*` directly — the documented pattern for runtime
files with their own primitives — would produce an unattributed job,
and those are the callers attribution exists for. Putting it in the
runtime means it is read at `allocate`, **the same single funnel Q#W-1
is already collapsing**. One mechanism, one site, no path around it.
*My vote: **a dispatch-name stack owned by the async runtime***, with
`pmacs.workers.dispatch` bracketing its handler call through two
runtime-internal bindings (`_push_dispatch_name` / `_pop_dispatch_name`).
**The contract, in full:**
1. **THE EXTENT IS NON-YIELDABLE, AND THAT IS ENFORCED, NOT ASSUMED.**
Awaiting inside a dispatch-name scope is **refused**, because
yielding would park the coroutine with the name still pushed and
hand it to whatever allocates next.
The guard is modelled on the one already in the file (§2):
`_in_dispatch_name_scope()` joins `_in_commit_scope()` as a refusal
in the same place, with the same shape of message and the same
remedy — **await first, then dispatch**.
Three details that decide whether the guard actually holds:
- **It rejects BEFORE parking.** The `commit_to` guard is the first
thing in `await`, ahead of the `_is_complete` check and the
`coroutine.yield`. The new one sits beside it, for the same
reason: a guard that fires after the yield has already happened
guards nothing.
- **It rejects UNCONDITIONALLY, not only when the handle is
incomplete.** A guard that fires only when a yield would really
occur has behaviour depending on whether the job happened to
finish first — it would pass under test and fail in production,
intermittently. `commit_to`'s guard is unconditional and this one
matches it.
- **It covers BOTH SUPPORTED YIELD APIs — and that is the exact
extent of the claim.** `pmacs.async.yield_to_next_tick()`
(`async.lua:243-245`) yields too, and is public, so it gets the
same refusal; guarding only `await` would leave the hole open
through a second door (and Q#W-7 is the proof that this happens,
because `commit_to` has exactly that gap today).
**What rule 1 does NOT cover is raw `coroutine.yield`** (§2).
R46 forbids it to package code by convention only, and the
scheduler's diagnostic fires *after* suspension, so no refusal
sited in a yield helper can intercept it. Revision 3 said "all
yield points" and was overclaiming. The property is: **the
supported ways to yield are refused inside the scope; an R46
violation can still leak the name, loudly.**
2. **Work dispatched later is NOT covered, deliberately.** A job
dispatched from an `on_complete` callback or a resumed coroutine
runs ticks later, outside the extent, and carries only its own
`purpose`. Pretending otherwise would need the asynchronous
lifetime mechanism this lane defers (Q#W-5).
3. **Nesting is a stack; innermost wins.** Handler `a` calling
`pmacs.workers.dispatch("b", …)` gives jobs allocated inside `b` the
name `b`, and restores `a` on return.
4. **Fan-out shares the name.** A handler dispatching five jobs
produces five jobs named alike. They *were* all dispatched under it;
that is the fact being recorded, not a collision.
5. **Unwind-safe, and this is the one that makes a naive version worse
than none.** A handler that errors must still pop — otherwise one
failure poisons every subsequent dispatch in the session with a
stale name, and the feature silently starts lying. `pmacs.workers.
dispatch` runs the handler under `pcall`, pops, and rethrows.
6. **Precedence over a caller-supplied purpose: COMPOSE, do not
replace.** Where the dispatch site supplied its own purpose, the
recorded value is `"<name>: <purpose>"`; where it did not, the
recorded value is `"<name>"`. Replacing would recreate blocker 1 in
a new place — `dispatch_grep` supplies `"grep: …"`, and letting that
win would lose the third party again, while letting the name win
would discard the only description of the actual work. Composition
is capped at the innermost name by rule 3, so no unbounded chain.
7. **Outside any extent, nothing changes.** A builtin invoked directly
records its own `purpose`.
**A known and accepted property, stated rather than discovered later:**
the ambient captures *causal* extent, not *intent*. If a handler
triggers unrelated work within its extent — an edit that schedules a
parse — that job takes the name. Because rule 1 refuses both supported
yield APIs, that window is bounded by a single un-parked call for any
caller obeying R46, and within such a window I think "this ran because
that handler ran" is the honest reading. (A caller violating R46 is
outside this property, and outside rule 1 — §2.) It is also the only definition enforceable at a single
funnel. **If review disagrees, the alternative is
capture-at-the-Lua-wrapper**, which is narrower and misses the raw
`_dispatch_*` callers — a trade of false positives for false negatives,
and I would rather over-attribute inside a bounded call than silently
drop the third-party case.
**Why this ambient is admissible while Q#W-5's is not.** They are not
the same mechanism — **and revision 2 was entitled to that claim only
after rule 1 made it true.** As written in revision 2 the extent could
be parked by any awaiting handler, which is most of the way to the
asynchronous lifetime I used as the reason for deferring `parent`.
With rule 1 the difference is real and enforced: this is a
single-threaded dynamic extent that **cannot** be suspended, with a
deterministic pop on both the normal and the error path. A `parent`
ambient must span a job's asynchronous lifetime by design — across
ticks, through callbacks that run after the parent settled — and cannot
be fixed by refusing to yield, because yielding is the whole point. The
first is a stack; the second is a lifetime model.
### Q#W-3 — what does the indicator actually show?
*My vote: **a count plus the oldest in-flight job's `purpose`, and
nothing when idle*** — e.g. `⋯2 lsp: indexing`, absent entirely at
zero. With `owner` gone (§3) `purpose` is the only identity there is,
which is also why it is required rather than optional.
**Oldest, not newest or "busiest".** Revision 1 said "busiest", which
is not a defined quantity — jobs carry no cost estimate. Oldest is
computable from `dispatched_at`, which `PendingJob` already has, and it
answers the question a user actually asks of a stuck editor: *what is
taking so long?*
- **Absent at zero, not `0 jobs`.** A statusline segment that is always
present costs width forever to say "nothing is happening". The
existing providers already return `nil` to render nothing
(`lsp.lua:1156`), so this is the established idiom.
- **A count, not a spinner.** A spinner needs an animation frame clock
and says only "something"; a count says how much. Per-frame evaluation
makes either possible, so this is a product choice, not a constraint.
- **Not names plural.** One purpose keeps it to a bounded width; the
full list is what `*workers*` is for.
### Q#W-4 — do terminal PTYs become visible in Stage 1?
**No — and the reason is evidence, not caution.** `pmacs.process.list`
filters to `LineOriented`, and three acceptance suites assert on
`#pmacs.process.list()` as a leak baseline (§2). Widening that accessor
would inflate all three with every open terminal, and "fix the tests"
is the wrong response to a test that is correctly detecting a semantic
change.
*My vote: **leave the accessor alone in Stage 1**, and let Stage 2's
unified view introduce a **separate** enumeration that includes PTYs.*
The leak detectors keep asserting what they were written to assert; the
new surface answers the new question. Two accessors with different
contracts is better than one accessor whose meaning silently changed
under its existing callers.
### Q#W-5 — does `parent` belong in Stage 1?
*My vote: **no.*** The audit names owner/purpose/**parent** together as
the prerequisite, and after revision 2 this lane takes only `purpose`
so both omissions need justifying, not just this one. `owner`'s is in
§3; `parent`'s is here.
`purpose` is a **value the dispatcher already knows** at the call site.
A parent is not — it is whatever job is *currently running* when a
child is dispatched. A `parent` field that nothing populates is worse
than no field: it renders as `None` everywhere and reads as "this job
has no parent" rather than "this system does not track parents".
**And the objection this has to answer, since the lane now builds an
ambient of its own (Q#W-2):** why is one admissible and not the other?
Because Q#W-2's extent **cannot be suspended** — rule 1 refuses both
yield points, so it is bounded by one un-parked call with a
deterministic pop on the normal and the error path.
**That distinction is only load-bearing because rule 1 exists.**
Revision 2 asserted this same paragraph while its ambient *could* be
parked by any awaiting handler, which made the two mechanisms far more
alike than the argument admitted. The honest version: a `parent`
ambient must identify the running job *across ticks* — a job dispatched
from an `on_complete` callback should name the job whose completion
fired it, and that callback runs after the parent settled, outside any
dispatch call. Refusing to yield cannot rescue it, because yielding is
the mechanism it needs. That is a lifetime model, not a stack, and it
is Stage 3's subject rather than a field this lane can add cheaply.
Stage 3 builds the lifetime model and the field together, where the
field can be tested by a populated case.
### Q#W-7 — the same hole exists in `commit_to` today — **RESOLVED, fixed here (rev 4)**
Found while scouting rule 1, and reported rather than quietly patched.
`Handle:await()` refuses to run inside `pmacs.window.commit_to`
(`async.lua:87-90`) precisely so a coroutine cannot park with the
frontend scope pushed. **But `pmacs.async.yield_to_next_tick()`
(`async.lua:243-245`) also yields, is public, and carries no such
refusal.** A coroutine inside `commit_to` can therefore park through
that door and produce exactly the misrouting the `await` guard exists
to prevent. Journey Stage 1a's Q#JR14b invariant has a second entrance.
I have **not** verified that a real caller does this — the reachability
of the bug is unproven, and I would rather say so than dress a
code-reading up as a repro.
**RESOLVED — approved for this lane.** It is the same supported yield
helper, the same invariant, and the same `async.lua` edit family;
splitting it would preserve a known hole without reducing integration
risk. So `yield_to_next_tick` gains **both** refusals — the new
`_in_dispatch_name_scope()` and the missing `_in_commit_scope()` — and
the `commit_to` gap closes in the same commit as rule 1.
**Its witnesses are the same pair as rule 1's, not a smoke test:** the
refusal fires, **and** the commit scope is restored afterwards. A guard
that raises while leaving the scope pushed converts a silent misrouting
into a noisy one and fixes nothing.
Reachability by a real caller stays **unproven** — this is a defect
found by reading, and the tests pin the guard rather than reproducing a
user-visible bug. That distinction belongs in the commit message too,
so nobody later cites this as evidence the bug was observed.
### Q#W-6 — is any of this configurable?
*My vote: **one boolean, `ui.activity-indicator` (default `true`),
through `pmacs.config.define`.*** §11 grades the registry "partial
(foundation only)" and this document's sibling framings have both
resisted speculative settings — but a permanently-visible statusline
element is different in kind from an internal behaviour: it costs width
on every frame, and "I do not want this in my modeline" is a
preference someone will genuinely hold on day one rather than a
hypothetical. `git.enabled` and `ui.line-wrap` are the precedent shape.
No setting for `purpose` capture itself — that is substrate, not
preference.
## 6. Verification
- **Presence is enforced by the COMPILER, not by a test.** `purpose` is
non-optional in `JobSpec`, so a dispatcher that supplies none does not
build. Revision 1 claimed a single funnel assertion proved "every job
carries an identity"; **it does not** — a funnel test proves the
funnel stores what it was handed, and says nothing about whether
fourteen callers handed it anything meaningful. Presence is a type
obligation; the tests below are for *semantics*.
- **Representative entry paths assert the semantics**, one per distinct
shape rather than one per dispatcher: a pool dispatcher, an
`register_external` job (MCP/LSP bypass the worker pool entirely and
are the likeliest to be missed), and a spawned process.
- **A `pmacs.workers.dispatch("name", …)` job reports `"name"`**, and
the witness is **a handler registered from Lua that calls a real
dispatcher** — not a synthetic funnel test. A test that pushes the
ambient by hand proves the stack works and leaves the actual defect
(`name` dying in an arbitrary handler) unwitnessed.
- **Awaiting inside a handler is REFUSED, and the scope restores after
the refusal** (Q#W-2 rule 1). Two assertions, and the second is the
load-bearing one: a guard that raises but leaves the name pushed has
converted a silent misattribution into a silent misattribution plus
an error. The witness dispatches again after the rejection and
asserts the new job carries **no** stale name.
- **`pmacs.async.yield_to_next_tick()` inside a handler is refused
too**, with the same restore-after assertion. Guarding one supported
yield API and not the other leaves the hole open through a second
door (§2).
- **`yield_to_next_tick` inside `pmacs.window.commit_to` is refused,
and the commit scope restores after the refusal** (Q#W-7) — the
pre-existing gap, closed here. Both halves asserted, for the same
reason as rule 1's: a refusal that leaves the scope pushed has
swapped a silent fault for a loud one.
- **NOT asserted, and deliberately: that a raw `coroutine.yield`
inside either scope is prevented.** It is not (§2). Writing a test
that "proves" coverage this design does not have would be worse than
the gap, and the gap is recorded instead.
- **The refusal fires even when the awaited handle is already
complete** (rule 1) — the case that separates an unconditional guard
from one whose behaviour depends on a race.
- **The ambient survives a failing handler** (Q#W-2 rule 5): a handler
that errors, then a subsequent unrelated dispatch, asserting the
second job does **not** carry the first's name. This is the
regression that would otherwise appear as intermittent
misattribution long after the lane lands.
- **Nesting and fan-out** (rules 34): a handler dispatching two jobs
gives both its name; a handler dispatching through another registered
handler gives the inner jobs the inner name and restores the outer.
- **Composition, not replacement** (rule 6): a handler calling a
dispatcher that supplies its own purpose yields `"<name>: <purpose>"`
— asserted for both halves, since a test on the prefix alone passes
when the description is dropped.
- **Work dispatched from an `on_complete` callback carries no handler
name** (rule 2) — the boundary of the extent, asserted deliberately
so it reads as designed rather than broken.
- **The statusline shows nothing at idle**, asserted as *absent
segment*, not as empty string — a zero-width segment still consumes a
separator.
- **The statusline shows a count while work is in flight**, witnessed
through the real per-frame evaluation path (`paint_frame`), not by
calling the provider function directly. A provider that works in
isolation and never gets evaluated is the failure this must exclude.
- **The indicator honours `ui.activity-indicator = false`** (Q#W-6),
witnessed as an absent segment with work genuinely in flight — the
case that separates "disabled" from "idle".
- **`#pmacs.process.list()` is UNCHANGED for every existing caller**
(Q#W-4). The three leak-detector suites
(`m6_8_multi_repl_acceptance`, `compile_mode_acceptance`,
`lean4_stage1_acceptance`) are the assertion, and they must pass
untouched. **If any of them needs editing, the design is wrong**, and
that is the signal to stop rather than to adjust a baseline.
- **A spawned process carries a required `purpose` alongside its
existing `label`**, and **`label`'s current callers keep working
unchanged** — `lsp:{name}` and terminal buffer names are live
conventions with existing consumers.
- **Both frontends render the segment**, since it rides the existing
`StatuslineSegments` path — asserted for the grid TUI and
`pmacs-gpu`, because "it is on an existing message" is a claim about
the producer and says nothing about whether a consumer draws it.
**What this will NOT prove:** that background work is attributable from
one place (that is Stage 2's unified view — this lane makes it
*possible*, not *done*), that a terminal PTY is visible anywhere
(Q#W-4), that cancellation can range over an owner (Stage 3), or **that
any job is attributed to the PACKAGE responsible for it** — `purpose`
records what work is being done and, under `pmacs.workers.dispatch`,
which registered handler it ran under. Neither is package ownership,
which waits for P3 (§3).
Gates via `scripts/gate --acceptance <the new suite>`. **No
`--protocol`**: this lane has no wire change, which is the property that
lets it run beside the two lanes already in flight.
## 7. Not in scope
**Making raw `coroutine.yield` safe inside either dynamic scope** (§2,
rule 1). R46 forbids it by convention and the scheduler diagnoses it
after the fact; closing it properly means enforcement the runtime does
not have, and this lane claims only the two supported yield APIs.
**`owner`, in any spelling** — including `origin` or `subsystem` (§3).
The slot stays empty until P3 can fill it with a package signal;
nothing in this lane may be promoted into it later by use.
`Workspace` and `Location` fields (§7/§8 — the entities do not exist).
`parent`/`children` and the ownership tree (Stage 3, Q#W-5). Scoped
cancellation of any kind — cancel-all, by-kind, by-buffer, by-owner,
by-subtree (Stage 3; there is nothing to range over until identity
exists). The unified activity view joining the four planes (Stage 2).
Making terminal PTYs visible (Stage 2, Q#W-4). Widening `JobKind` or
making it open — third-party jobs are described by `purpose`, which is
the point, and reopening a closed wire-adjacent enum is a separate
decision. Latency classes and resource budgets (§9 names them;
neither has a consumer yet). Supersession coverage — §9 notes parse jobs
and MCP requests pass `None`, which is a real defect and a **different**
one. P3's package-ownership signal — §3 defers `owner` to it and makes
no claim of alignment with it.

View File

@ -44,10 +44,9 @@ 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, MinibufferRow, Modifiers,
MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind,
SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame,
UnderlineStyle,
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, 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},
@ -2260,25 +2259,16 @@ struct SearchPromptLocal {
invalid: bool,
}
/// 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.
/// 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.
#[derive(Clone, Debug, PartialEq)]
struct MinibufferLocal {
prompt: String,
input: String,
cursor: u32,
rows: Vec<MinibufferRow>,
candidates: Vec<String>,
selected: Option<u32>,
total: u32,
}
@ -5095,10 +5085,7 @@ impl State {
None
}
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
// 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.
// None` closes it.
InstanceMessage::MinibufferPrompt {
prompt,
input,
@ -5111,38 +5098,7 @@ impl State {
prompt,
input,
cursor,
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,
candidates,
selected,
total,
});
@ -7663,22 +7619,11 @@ 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.rows
.iter()
.map(|row| match row.detail.as_deref() {
Some(detail) => format!("{} {detail}", row.label),
None => row.label.clone(),
})
.collect::<Vec<_>>()
.join("\n")
});
let text = self
.minibuffer
.as_ref()
.map_or_else(String::new, |mb| mb.candidates.join("\n"));
let family = self.resolved_family.clone();
self.mb_buffer.set_text(
&mut self.font_system,
@ -7699,7 +7644,7 @@ impl State {
let mb = self.minibuffer.as_ref()?;
let band_top = status_band_top(self.config.height, self.fm);
mb_dropdown_window(
mb.rows.len(),
mb.candidates.len(),
mb.selected.map_or(0, |s| s as usize),
band_top,
self.fm,
@ -10923,7 +10868,6 @@ 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",
@ -13822,22 +13766,6 @@ 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
@ -14939,95 +14867,6 @@ 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 {
@ -15049,7 +14888,7 @@ mod tests {
prompt: "M-x ".to_owned(),
input: "find".to_owned(),
cursor: 4,
rows: Vec::new(),
candidates: Vec::new(),
selected: None,
total: 0,
});
@ -15452,7 +15291,7 @@ mod tests {
prompt: "M-x ".into(),
input: "theme".into(),
cursor: 5,
rows: Vec::new(),
candidates: Vec::new(),
selected: None,
total: 0,
});
@ -15496,7 +15335,7 @@ mod tests {
prompt: "M-x ".into(),
input: "the".into(),
cursor: 3,
rows: detailless_rows(["theme-set", "theme-clear"]),
candidates: vec!["theme-set".into(), "theme-clear".into()],
selected: Some(0),
total: 2,
});
@ -15518,112 +15357,6 @@ 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
@ -16221,7 +15954,7 @@ mod tests {
prompt: "P: ".into(),
input: String::new(),
cursor: 0,
rows: detailless_rows([long.clone(), long.clone()]),
candidates: vec![long.clone(), long.clone()],
selected: Some(1),
total: 2,
});
@ -16439,7 +16172,7 @@ mod tests {
prompt: "M-x ".into(),
input: String::new(),
cursor: 0,
rows: detailless_rows((0..30).map(|i| format!("candidate-{i}"))),
candidates: (0..30).map(|i| format!("candidate-{i}")).collect(),
selected: Some(1),
total: 30,
});
@ -17362,7 +17095,7 @@ mod tests {
prompt: ":".into(),
input: String::new(),
cursor: 0,
rows: Vec::new(),
candidates: Vec::new(),
selected: None,
total: 0,
});

View File

@ -65,12 +65,11 @@ 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, 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,
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,
};
pub use panel::{
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,

View File

@ -1098,24 +1098,7 @@ 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..=22`.
///
/// # FROZEN — this variant's encoding must not move
///
/// Discovery Stage 2 (v23) needed richer rows, and postcard is not
/// self-describing: enum variants encode by index and fields by
/// position, so widening `candidates` in place would make every
/// v12v22 peer **mis-decode** these bytes rather than ignore them.
/// Gating the widened shape at `>= 23` would not rescue them either
/// — with only one variant to send, they would receive no minibuffer
/// message at all. So the rich form went into a new appended
/// variant, [`Self::MinibufferPromptRows`], and this one is retained
/// unchanged as what a `12..=22` peer receives.
///
/// Its bytes are pinned literally by
/// `minibuffer_prompt_v12_wire_bytes_are_frozen` in
/// `src/protocol.rs` — a round-trip cannot detect a field addition,
/// because both sides simply learn the new shape.
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
MinibufferPrompt {
/// The prompt string (e.g. `"M-x "`), or `None` when no
/// minibuffer is open.
@ -1316,59 +1299,6 @@ 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
@ -1464,35 +1394,6 @@ 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
@ -1830,17 +1731,7 @@ 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.
///
/// 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;
pub const PROTOCOL_VERSION: u32 = 22;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
@ -2014,15 +1905,8 @@ 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, 23,
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
];
/// T M10.5: predicate for the handshake check. Returns `true` if

View File

@ -58,10 +58,8 @@
//! 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};
@ -410,47 +408,6 @@ 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
@ -533,9 +490,6 @@ 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
@ -553,8 +507,6 @@ 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,
@ -591,105 +543,9 @@ 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)]
@ -754,29 +610,6 @@ 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
@ -817,7 +650,6 @@ 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()),
}
}
@ -901,83 +733,34 @@ 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.
///
/// **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
/// If `supersede_key` 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.
///
/// 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,
};
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) {
let id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
if let Some(key) = supersede_key {
@ -1005,7 +788,6 @@ impl AsyncRuntime {
kind,
dispatched_at: Instant::now(),
resource,
purpose,
},
);
(id, cancel)
@ -1019,13 +801,7 @@ 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(JobSpec {
kind: JobKind::Sleep,
supersede,
stream: None,
resource: None,
purpose: format!("sleep {}ms", ms.max(0)),
});
let (id, cancel) = self.allocate(JobKind::Sleep, supersede, None);
let bus = self.workers.clone();
let total = Duration::from_millis(ms.max(0).unsigned_abs());
self.pool.dispatch(move |_pool| {
@ -1040,13 +816,7 @@ 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(JobSpec {
kind: JobKind::ComputeSum,
supersede,
stream: None,
resource: None,
purpose: format!("sum 1..{n}"),
});
let (id, cancel) = self.allocate(JobKind::ComputeSum, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_compute_sum(&cancel, n);
@ -1072,13 +842,7 @@ 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(JobSpec {
kind: JobKind::EmitN,
supersede,
stream: Some(cap),
resource: None,
purpose: format!("emit {count} items"),
});
let (id, cancel) = self.allocate(JobKind::EmitN, supersede, Some(cap));
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_emit_n(&cancel, &bus, id, count);
@ -1106,13 +870,7 @@ 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(JobSpec {
kind: JobKind::Grep,
supersede,
stream: Some(cap),
resource: None,
purpose: format!("grep {:?} in {}", spec.pattern, spec.root.display()),
});
let (id, cancel) = self.allocate(JobKind::Grep, supersede, Some(cap));
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_grep(&cancel, &bus, id, spec);
@ -1139,13 +897,7 @@ 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(JobSpec {
kind: JobKind::Parse,
supersede,
stream: None,
resource: None,
purpose: format!("parse {}", spec.language_name),
});
let (id, cancel) = self.allocate(JobKind::Parse, supersede, None);
let bus = self.workers.clone();
let handoff = self.parse_handoff.clone();
self.pool.dispatch(move |_pool| {
@ -1170,13 +922,7 @@ impl AsyncRuntime {
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsReadDir,
supersede,
stream: None,
resource: None,
purpose: format!("read_dir {}", path.display()),
});
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path, tolerance);
@ -1188,13 +934,7 @@ 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(JobSpec {
kind: JobKind::FsStat,
supersede,
stream: None,
resource: None,
purpose: format!("stat {}", path.display()),
});
let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_stat(&cancel, &path);
@ -1208,16 +948,15 @@ 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(JobSpec {
kind: JobKind::FsRename,
let (id, cancel) = self.allocate_with_resource(
JobKind::FsRename,
supersede,
stream: None,
resource: Some(ResourceOp::Rename {
None,
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);
@ -1228,13 +967,7 @@ 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(JobSpec {
kind: JobKind::FsChmod,
supersede,
stream: None,
resource: None,
purpose: format!("chmod {mode:o} {}", path.display()),
});
let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_chmod(&cancel, &path, mode);
@ -1245,13 +978,12 @@ 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(JobSpec {
kind: JobKind::FsRemove,
let (id, cancel) = self.allocate_with_resource(
JobKind::FsRemove,
supersede,
stream: None,
resource: Some(ResourceOp::Remove { path: path.clone() }),
purpose: format!("remove {}", path.display()),
});
None,
Some(ResourceOp::Remove { path: path.clone() }),
);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_remove(&cancel, &path);
@ -1276,27 +1008,12 @@ 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(JobSpec {
kind,
supersede,
stream: None,
resource: None,
purpose: purpose.into(),
})
self.allocate(kind, supersede, None)
}
/// Settle an externally-registered job with a JSON value. Wakes
@ -1489,7 +1206,6 @@ impl AsyncRuntime {
dispatched_at: job.dispatched_at,
settled_at: now,
supersede_key: job.supersede_key.clone(),
purpose: job.purpose.clone(),
outcome,
});
}
@ -1527,7 +1243,6 @@ 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
@ -1547,54 +1262,12 @@ 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.
@ -2203,25 +1876,23 @@ 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(JobSpec {
kind: JobKind::FsRename,
supersede: None,
stream: None,
resource: Some(ResourceOp::Rename {
let (a, _) = rt.allocate_with_resource(
JobKind::FsRename,
None,
None,
Some(ResourceOp::Rename {
from: PathBuf::from("/tmp/a-from"),
to: PathBuf::from("/tmp/a-to"),
}),
purpose: "rename a".to_owned(),
});
let (b, _) = rt.allocate(JobSpec {
kind: JobKind::FsRemove,
supersede: None,
stream: None,
resource: Some(ResourceOp::Remove {
);
let (b, _) = rt.allocate_with_resource(
JobKind::FsRemove,
None,
None,
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
@ -2265,25 +1936,23 @@ mod tests {
#[test]
fn a_failed_or_cancelled_resource_job_is_not_harvested() {
let rt = AsyncRuntime::with_pool_size(1);
let (failed, _) = rt.allocate(JobSpec {
kind: JobKind::FsRename,
supersede: None,
stream: None,
resource: Some(ResourceOp::Rename {
let (failed, _) = rt.allocate_with_resource(
JobKind::FsRename,
None,
None,
Some(ResourceOp::Rename {
from: PathBuf::from("/tmp/nope"),
to: PathBuf::from("/tmp/also-nope"),
}),
purpose: "rename nope".to_owned(),
});
let (cancelled, _) = rt.allocate(JobSpec {
kind: JobKind::FsRemove,
supersede: None,
stream: None,
resource: Some(ResourceOp::Remove {
);
let (cancelled, _) = rt.allocate_with_resource(
JobKind::FsRemove,
None,
None,
Some(ResourceOp::Remove {
path: PathBuf::from("/tmp/never"),
}),
purpose: "remove never".to_owned(),
});
);
rt.workers
.send(
ASYNC_REPLY_TOPIC,

View File

@ -332,106 +332,6 @@ fn main() {
});
write_frame(&mut stdout, &req);
}
// Issue #233 D1: `filewatchabs` registers the same watcher
// as a PLAIN-STRING glob — `<base>/**/*.txt`, the form
// rust-analyzer and gopls actually send. Per LSP it matches
// the file's ABSOLUTE path; its relative reading matches
// nothing, so the mode discriminates the match subject.
("initialized", _) if mode == "filewatchabs" => {
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 9301,
"method": "client/registerCapability",
"params": { "registrations": [{
"id": "watch-abs",
"method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{
"globPattern": format!("{base}/**/*.txt"),
"kind": 7
}] }
}] }
});
write_frame(&mut stdout, &req);
}
// Issue #233 review P1 guard: `filewatchbare` registers a
// BARE STRING with no base and no leading `/` — `*.txt`.
// The string arm and the `filewatchflat` arm below carry the
// same pattern deliberately: `flat` proves a
// RelativePattern stays relative, and this proves the
// classification is read from THE PATTERN rather than from
// the union arm it arrived in. The first fix for #233
// called every string absolute, which matched this against
// `<base>/foo.txt` and broke a case that had worked since
// May. Without this mode that regression is invisible.
("initialized", _) if mode == "filewatchbare" => {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 9304,
"method": "client/registerCapability",
"params": { "registrations": [{
"id": "watch-bare",
"method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{
"globPattern": "*.txt",
"kind": 7
}] }
}] }
});
write_frame(&mut stdout, &req);
}
// Issue #233 F2 guard: `filewatchflat` registers a
// RelativePattern whose pattern has no leading `**/`
// (`*.txt` at the base). It matches base-level files
// RELATIVELY and no absolute path at all, so a fix that
// matches every form against the absolute path goes red.
("initialized", _) if mode == "filewatchflat" => {
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 9302,
"method": "client/registerCapability",
"params": { "registrations": [{
"id": "watch-flat",
"method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{
"globPattern": {
"baseUri": format!("file://{base}"),
"pattern": "*.txt"
},
"kind": 7
}] }
}] }
});
write_frame(&mut stdout, &req);
}
// Issue #233 D2: `filewatchrereg` registers the SAME id
// twice with no unregister between — `**/*.old` then
// `**/*.new` — exactly rust-analyzer's shape. The second
// registration must supersede the first: only `.new`
// events may ever reach `.received`.
("initialized", _) if mode == "filewatchrereg" => {
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
for (rid, pattern) in [(9303, "**/*.old"), (9304, "**/*.new")] {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": rid,
"method": "client/registerCapability",
"params": { "registrations": [{
"id": "watch-re",
"method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{
"globPattern": {
"baseUri": format!("file://{base}"),
"pattern": pattern
},
"kind": 7
}] }
}] }
});
write_frame(&mut stdout, &req);
}
}
("initialized", _) => {}
// T M4.5: the client's file-watch notifications. Append
// `type uri` lines to `<base>/.received` as a test

View File

@ -66,28 +66,7 @@ impl SourceLocation {
pub struct Command {
/// Unique name (e.g. `buffer.save`).
pub name: String,
/// 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.
/// One-line human-readable description (R42, required).
pub description: String,
/// Where the command was defined.
pub source: SourceLocation,
@ -100,53 +79,6 @@ 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 {
@ -339,83 +271,6 @@ mod tests {
));
}
#[test]
fn a_multi_line_description_registers_and_clips_to_its_first_line() {
// Registration accepts it — MCP tool registration renders a
// whole schema block into `description` and
// `m9_6_acceptance.rs:583-598` asserts four of its lines, so a
// one-line guard here would delete a shipped contract. The
// one-line constraint lives at the single-row surfaces, which
// read `description_first_line`.
//
// All three break forms: a clip that split on `\n` alone would
// pass a bare `\r` through, and one that split on `\r` alone
// would pass `\n` through.
let lua = Lua::new();
for (label, description) in [
(
"LF",
"Greet someone.\n\nArguments:\n name (string, required)",
),
(
"CR",
"Greet someone.\r\rArguments:\r name (string, required)",
),
(
"CRLF",
"Greet someone.\r\n\r\nArguments:\r\n name (string, required)",
),
] {
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "mcp.greet", description))
.unwrap_or_else(|e| panic!("{label}: a schema block must still register: {e}"));
let cmd = r.get("mcp.greet").expect("registered");
assert_eq!(
cmd.description, description,
"{label}: the registry stores the description verbatim — the clip is a \
rendering decision, so `describe-command` must still see every line"
);
assert_eq!(
cmd.description_first_line(),
"Greet someone.",
"{label}: a single-row surface gets the first line only"
);
assert!(
!cmd.description_first_line().contains(['\n', '\r']),
"{label}: the clipped form must carry no break at all"
);
}
}
#[test]
fn a_single_line_description_is_byte_identical_after_the_clip() {
// The other half: the clip must not tighten past its purpose.
// Interior whitespace, punctuation and non-ASCII all survive,
// and there is no ellipsis or truncation marker.
let lua = Lua::new();
let mut r = CommandRegistry::new();
let description = "Write the buffer to its file — with a dash, and \ttabs.";
r.define(make_command(&lua, "buffer.save", description))
.expect("registers");
assert_eq!(
r.get("buffer.save").unwrap().description_first_line(),
description,
"a description with no break is returned unchanged"
);
}
#[test]
fn a_description_whose_first_line_is_empty_clips_to_empty() {
// The case the producer turns into `None` rather than
// `Some("")`: a leading break leaves nothing to render, and a
// `Some("")` detail would draw trailing padding after the label.
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "x", "\nArguments:\n a (string)"))
.expect("registers");
assert_eq!(r.get("x").unwrap().description_first_line(), "");
}
#[test]
fn empty_name_is_rejected() {
let lua = Lua::new();

View File

@ -1420,23 +1420,9 @@ fn dispatcher_loop(
let peer_knows_menu_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11);
// 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
});
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12);
// 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.
@ -1484,24 +1470,12 @@ fn dispatcher_loop(
continue;
}
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
// 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.
// simply can't render the GUI minibuffer.
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 { .. })
{
@ -1827,7 +1801,7 @@ fn open_initial_target(
let (buffer_id, fire) = match resolved {
crate::editor_core::ResolvedTarget::Directory { path } => {
let dest = editor
.capture_view_destination(frontend_id, origin_window)
.capture_directory_destination(frontend_id, origin_window)
.ok_or_else(|| format!("cannot open {}: no document window", path.display()))?;
editor.dispatch_directory_open(&path, dest);
editor.reconcile_panel_layout(frontend_id);

View File

@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::{CellCoord, CellSize};
use crate::editor_core::{CommitContract, EditorCore, GeometryUpdate};
use crate::editor_core::{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,34 +119,20 @@ impl ScopedFrontend {
}
/// Enter a background frontend scope, also swapping the core's
/// 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`].
/// ambient `active_frontend`. Both are restored on drop, on every
/// exit path including a raising callback.
pub(crate) fn enter(
&self,
core: &SharedCore,
commit_scope: &CommitScopeActive,
contract: CommitContract,
frontend_id: FrontendId,
) -> ScopedFrontendGuard {
let frontend_id = contract.destination.frontend;
let previous = self.0.replace(Some(frontend_id));
let (previous_active, contract_depth) = {
let previous_active = {
let mut core = core.borrow_mut();
let was = core.active_frontend;
core.active_frontend = frontend_id;
(was, core.push_commit_contract(contract))
was
};
let previous_commit = commit_scope.0.replace(true);
ScopedFrontendGuard {
@ -154,7 +140,6 @@ impl ScopedFrontend {
core: core.clone(),
previous,
previous_active,
contract_depth,
commit_scope: commit_scope.clone(),
previous_commit,
}
@ -166,15 +151,6 @@ 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,
@ -184,11 +160,7 @@ pub(crate) struct ScopedFrontendGuard {
impl Drop for ScopedFrontendGuard {
fn drop(&mut self) {
self.scope.0.set(self.previous);
{
let mut core = self.core.borrow_mut();
core.active_frontend = self.previous_active;
core.exit_commit_contract(self.contract_depth);
}
self.core.borrow_mut().active_frontend = self.previous_active;
self.commit_scope.0.set(self.previous_commit);
}
}
@ -797,20 +769,6 @@ impl EditorState {
include_str!("../builtin/runtime/linewrap.lua"),
)
.expect("load linewrap builtin chunk");
// Git integration Stage 1 (docs/git-integration-framing.md):
// `*git-status*` and `*git-diff*`. Loaded after `listview.lua`,
// whose `open` (and whose new optional `keys` table) it drives,
// and after `window.lua`, which owns `window.panel-height` — the
// setting a `display = "panel"` listview resolves. It binds no
// global key: an opening chord is a command-surface decision and
// the framing did not make one, so the entry point is
// `M-x git.status`.
lua_host
.eval(
Some("@pmacs/builtin/runtime/git.lua"),
include_str!("../builtin/runtime/git.lua"),
)
.expect("load git builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -1261,32 +1219,22 @@ impl EditorState {
}
/// Capture the destination a directory open must commit to
/// (Q#JR14), or `None` when `window` is gone.
/// (Q#JR14), or `None` when `frontend` has no document window.
///
/// 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.
///
/// 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(
pub(crate) fn capture_directory_destination(
&self,
frontend: crate::protocol::FrontendId,
window: crate::window::WindowId,
) -> Option<crate::editor_core::ViewDestination> {
) -> Option<crate::editor_core::DirectoryDestination> {
let core = self.core.borrow();
let buffer = core.windows.get(&window)?.buffer_id;
Some(crate::editor_core::ViewDestination {
Some(crate::editor_core::DirectoryDestination {
frontend,
window: Some(window),
buffer: Some(buffer),
window,
buffer,
})
}
@ -1310,7 +1258,7 @@ impl EditorState {
.borrow()
.primary_document_window(crate::protocol::FrontendId::LOCAL);
let dest = window.and_then(|window| {
self.capture_view_destination(crate::protocol::FrontendId::LOCAL, window)
self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window)
});
let Some(dest) = dest else {
self.core.borrow_mut().status =
@ -1340,13 +1288,13 @@ impl EditorState {
pub(crate) fn dispatch_directory_open(
&mut self,
path: &std::path::Path,
dest: crate::editor_core::ViewDestination,
dest: crate::editor_core::DirectoryDestination,
) {
let display = path.display().to_string();
let args = {
let lua = self.lua_host.lua();
let destination =
match lua.create_userdata(crate::lua_bindings::ViewDestinationLua(dest)) {
match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) {
Ok(userdata) => mlua::Value::UserData(userdata),
Err(error) => {
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
@ -4784,10 +4732,7 @@ pub fn paint_frame(
paint_search_prompt(grid, core, term_size, &theme);
None
} else if core.minibuffer.is_active() {
// 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))
Some(paint_minibuffer(grid, core, term_size, &theme))
} else {
None
};
@ -5519,42 +5464,9 @@ 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 {
@ -5565,6 +5477,12 @@ 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;
@ -5619,35 +5537,6 @@ fn paint_minibuffer(
cursor_col = prompt_end;
}
// Discovery Stage 2 (§3.4): the selected candidate's suffix now
// carries the command's DESCRIPTION, read from the registry
// in-process. The grid TUI never consumes `MinibufferPrompt` — it
// paints from `core.minibuffer` — so this half of the lane involves
// no wire at all and is independent of the v23 bump.
//
// Q#D2-2: only the command source has a detail. A file-path or
// buffer-name prompt renders exactly as it did before.
//
// FIRST LINE ONLY: this band is a single row, and
// `Command.description` is free-form — MCP registration renders a
// whole schema block into it. The full text stays reachable through
// `describe-command`.
let suffix = match session.selected.and_then(|idx| session.candidates.get(idx)) {
Some(cand) => {
let detail = matches!(
session.source,
crate::minibuffer::CompletionSource::Commands
)
.then(|| {
commands
.get(cand)
.map(crate::command::Command::description_first_line)
})
.flatten();
minibuffer_candidate_suffix(cand, detail, max.saturating_sub(col))
}
None => String::new(),
};
for ch in suffix.chars() {
if col >= max {
break;

View File

@ -130,119 +130,39 @@ pub enum ResolvedTarget {
},
}
/// Where an asynchronous continuation's result belongs, captured
/// **synchronously** at request time (Journey Stage 1a, Q#JR14;
/// generalized by `docs/destination-capture-framing.md`).
/// Where a directory open was requested, captured **synchronously** at
/// resolve time (Journey Stage 1a, Q#JR14).
///
/// 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.
/// 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 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:
/// All three fields are load-bearing:
///
/// * `frontend` — the scope the commit must run in. Always present.
/// * `frontend` — the scope the commit must run in.
/// * `window` — the exact destination; the ambient selected window is
/// not it. Absent when the frontend had no document window at capture
/// time.
/// not it.
/// * `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 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.
/// buffer while the listing was in flight is newer information than
/// the launch argument, and must not be overwritten.
///
/// 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 ViewDestination {
/// Frontend that requested the work.
pub struct DirectoryDestination {
/// Frontend that requested the directory.
pub frontend: FrontendId,
/// Window the result must land in, when there is one.
pub window: Option<WindowId>,
/// Window the listing must land in.
pub window: WindowId,
/// Buffer that window held at capture time (stale-intent check).
pub buffer: Option<BufferId>,
}
/// Which of `commit_to`'s preconditions a body actually depends on
/// (Q#DC-2).
///
/// A **closed** set of two, not an open string namespace: a third
/// profile is a decision about what a continuation may depend on, not a
/// spelling. Chosen at `commit_to` rather than at capture, because the
/// caller knows what it is about to do only then.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitProfile {
/// The body replaces the captured window's buffer: **all four**
/// preflight checks apply. This is what an omitted profile means, so
/// every caller written before the profile existed keeps exactly the
/// guarantees it was written against.
Document,
/// The body puts its result in a bottom panel rather than in the
/// captured document window, and so does not depend on checks 24 —
/// **for as long as its result really lands in a panel**. The
/// preflight grants the relaxation only when a fallback into a
/// document window is impossible ([`EditorCore::commit_destination_refusal`]),
/// and what keeps that measurement true for the body's whole extent
/// is that the mutations which would manufacture a fallback are
/// refused at the attempt
/// (`EditorCore::panel_commit_dedication_refusal`).
Panel,
}
/// The contract a `commit_to` body is running under, published on the
/// core so the mutations that could invalidate it can consult it
/// (Q#DC-2, revisions 8 and 9).
///
/// **Why this exists rather than a preflight prediction.** Revision 6
/// tried to decide at preflight whether a `"panel"` commit's placement
/// could fall back into a document window, on the argument that nothing
/// could change in between because the body cannot `await`. Refusing
/// `await` stops another coroutine interleaving; it says nothing about
/// the body itself, which is arbitrary Lua running synchronously and can
/// change the very state the snapshot measured — obtain the panel, set
/// it `dedicated`, then request a side display. A snapshot cannot bind
/// that. So the preflight stays where it is and the contract is what
/// lets those mutations be **refused at the attempt**, which is the only
/// point early enough to leave nothing behind
/// (`EditorCore::panel_commit_dedication_refusal`).
///
/// Pushed and popped by the same guard that scopes the frontend, so the
/// two can never disagree about whether a commit is on the stack.
/// **Pushed** rather than swapped: a contract is a restriction, and a
/// nested `commit_to` must add to the ones in force rather than mask
/// them for the extent of its body
/// (`EditorCore::push_commit_contract`, revision 9).
#[derive(Clone, Copy, Debug)]
pub struct CommitContract {
/// The destination the continuation captured.
pub destination: ViewDestination,
/// What that continuation declared it depends on.
pub profile: CommitProfile,
pub buffer: BufferId,
}
/// A `display_buffer` request (Q#BP3).
@ -700,34 +620,6 @@ 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 {
@ -782,42 +674,9 @@ 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.
@ -3183,143 +3042,6 @@ 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]
@ -3538,23 +3260,6 @@ 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:
@ -4096,20 +3801,6 @@ 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
@ -4235,176 +3926,6 @@ impl EditorCore {
.ok_or_else(|| "display_file: no eligible document window is available".into())
}
/// Whether a `{side = ...}` request in `fid` would fall back into an
/// ordinary document window **given the state right now** (Q#DC-2).
///
/// Adjacent to [`Self::resolve_placement`] because that is the rule
/// it predicts, and a prediction that drifts from the rule is worse
/// than none. The two fallback arms, in that function's own order:
///
/// 1. **step 2's capability guard** — `side` is honoured only on a
/// `panel_capable` frontend; without the capability the request
/// falls through to step 3's ordinary policy (Q#BP13).
/// 2. **step 2's dedicated arm** — the one side slot exists but is
/// dedicated, and a second one is never created, so a different
/// buffer falls through instead (Q#BP3 2.iii).
///
/// **A MEASUREMENT, AND NOT SELF-SUPPORTING.** This is consulted by
/// [`Self::commit_destination_refusal`] to refuse the statically
/// knowable case *before* a body allocates anything — a frontend that
/// cannot render a panel at all will not acquire the capability
/// mid-body. On its own it would **not** make the panel profile safe:
/// a `commit_to` body is arbitrary synchronous Lua and could dedicate
/// the side slot itself between this answer and the placement it
/// describes, and refusing `await` prevents another coroutine
/// interleaving, not the body rewriting the state it was measured
/// against. What holds the measurement true is
/// `Self::panel_commit_dedication_refusal`, which refuses exactly
/// those mutations for the body's whole extent.
///
/// Arm 2 is answered **conservatively**: `resolve_placement` falls
/// back only when the arriving buffer differs from the dedicated one,
/// and at preflight the body has not chosen a buffer yet.
///
/// A frontend with no view answers `false`: where placement would
/// land is moot when there is nothing to place into, and
/// `commit_destination_refusal` has already refused that case by its
/// first check.
#[must_use]
pub fn panel_placement_can_fall_back(&self, fid: FrontendId) -> bool {
let Some(view) = self.views.get(&fid) else {
return false;
};
if !view.panel_capable {
return true;
}
self.side_window_for(fid)
.and_then(|side| self.windows.get(&side))
.is_some_and(|side| side.params.dedicated)
}
/// **The guarantee** behind the `"panel"` commit profile (Q#DC-2,
/// revisions 8 and 9): anywhere inside such a commit — nested
/// `commit_to` scopes included — the operations that would make this
/// frontend's side request fall back are **refused at the attempt**.
///
/// # The defect this closes
///
/// The panel profile skips preflight checks 24 on the strength of "a
/// panel result never touches a document window". Panel placement
/// **falls back** into an ordinary document window when the frontend
/// is not `panel_capable` or its one side slot is dedicated elsewhere
/// ([`Self::apply_placement`] says so in its own comment), and then
/// installs the result there. So a `"panel"` commit that reached a
/// fallback would replace a document view with no stale-intent guard:
/// capture A, the user opens B, the continuation lands, B is gone.
///
/// # Why this shape, and not the two that were tried first
///
/// * **Predicting the fallback at preflight is unsound.** The body is
/// arbitrary *synchronous* Lua and can create the condition itself.
/// Refusing `await` inside the commit scope stops a second
/// coroutine interleaving; it places no restriction on the body's
/// own statements.
/// * **Refusing at the placement boundary is too late.** `commit_to`
/// preflights *before* invoking the callback precisely because a
/// body creates buffers, registers handles and paints long before
/// it asks to display anything — "validating at display time is
/// four mutations too late" (`docs/agent-handoff.md`). A refusal
/// arriving after all of that is not a refusal; it is a partial
/// commit with an error return.
///
/// So the preflight stays where it is and **the mutation that would
/// invalidate it is rejected** — the same shape as `Handle:await`
/// being refused inside a commit scope, for the identical reason.
/// With these refused, the preflight measurement cannot go stale, the
/// fallback never comes into existence, and nothing needs refusing
/// late.
///
/// # Every enclosing contract, not just the innermost (revision 9)
///
/// This scans the whole contract stack. Revision 8 read a single
/// slot, and a nested `commit_to` replaced it — so an outer
/// `"panel"` commit whose body opened a nested `"document"` commit
/// had its restriction **masked** for that body's extent, and the
/// nested callback could dedicate the side slot the outer relaxation
/// rests on. The outer commit then resumed and fell back into the
/// document window, overwriting a newer buffer: the original defect,
/// reachable through one extra call. Detecting it when the outer
/// commit resumed would have been a late refusal, which revision 7
/// was already rejected for. The restriction has to hold for the
/// whole body, so **the strictest active restriction wins** and
/// nesting is otherwise untouched.
///
/// Matching is per **frontend**, not per stack: a nested commit for a
/// *different* frontend may dedicate *its* side slot, because that
/// cannot change where this frontend's side request lands.
///
/// # The enumeration this rests on
///
/// [`Self::resolve_placement`] can only reach
/// [`PlacementKind::Ordinary`] from a side request in two ways, so
/// only two pieces of state matter:
///
/// 1. `FrontendView::panel_capable` is false. It is written **only**
/// where a `FrontendView` is constructed, and no `FrontendView` is
/// constructed, registered or unregistered anywhere in
/// `src/lua_bindings/` — that is the daemon's attach path. **A
/// body cannot reach it at all.**
/// 2. The frontend's one side slot exists **and is dedicated** to a
/// different buffer. `Window::params.dedicated` is the only
/// remaining lever, and every write to it is guarded or harmless:
/// the two in `apply_placement`'s `Ordinary` arm target a document
/// window (never a side one — every `Ordinary` target is filtered
/// `!is_side`) and one of them only ever clears the flag; the
/// three in its `Side` arm and the one in `pmacs.window.set_params`
/// are the attempts refused here; and `quit_window` restoring a
/// saved `dedicated: true` presentation is refused too.
///
/// **Losing the side window is NOT a route** and was checked rather
/// than assumed: with no side leaf, `side_window_for` returns `None`
/// and `resolve_placement` **creates** a fresh panel instead of
/// falling back. Closing or hiding the panel mid-commit is therefore
/// safe, and `panel_hidden` is not consulted by placement at all.
/// `params.side` is likewise unreachable — `set_params` refuses it,
/// and only `apply_placement`'s created branch ever writes it, so a
/// body cannot turn an already-dedicated document window into the
/// side slot.
///
/// # What is deliberately NOT refused
///
/// * **The document profile is untouched.** Its preflight already
/// checked the same destination, and constraining its body would
/// newly refuse dired's own documented panel path.
/// * **Dedicating a *document* window is fine.** It cannot change
/// which of panel-or-document a side request resolves to.
/// * **Falling back is still allowed.** A frontend that cannot render
/// a panel degrades gracefully exactly as it does today; this
/// refuses the *mutation that manufactures* a fallback, never the
/// fallback itself.
/// * **Nesting is untouched.** Only the mutation is refused, not the
/// nested `commit_to` that reaches it, so a nested commit that does
/// not dedicate this frontend's side slot runs exactly as before.
/// Prohibiting nesting outright would have closed the hole by
/// forbidding a shape no rule objects to (revision 9).
pub(crate) fn panel_commit_dedication_refusal(&self, fid: FrontendId) -> Option<String> {
// ANY enclosing contract, not the innermost one: a nested commit
// composes with the restrictions already in force rather than
// masking them (revision 9).
if !self.commit_contracts.iter().any(|contract| {
contract.profile == CommitProfile::Panel && contract.destination.frontend == fid
}) {
return None;
}
Some(
"cannot dedicate the side window inside a \"panel\" commit_to --- the commit's \
preflight was relaxed because this frontend places side requests in the panel, \
and dedicating the one slot would silently redirect the result into a document \
window instead (dedicate outside the commit, or use the \"document\" profile)"
.to_string(),
)
}
/// Q#BP3's precedence: exact target, then side affinity, then
/// ordinary reuse. Placement affinity precedes generic reuse —
/// otherwise a persistent `*compilation*` buffer already visible in a

View File

@ -406,10 +406,6 @@ impl Frontend {
// surface; the TUI paints the minibuffer via its own bottom
// row, so it drops this silently too.
| InstanceMessage::MinibufferPrompt { .. }
// Discovery Stage 2 — the v23 rows form of the same surface.
// The TUI reads `Command.description` from the registry
// in-process instead, so this reaches it not at all.
| InstanceMessage::MinibufferPromptRows { .. }
// UX gutter — LineNumbers is the semantic-frontend gutter
// toggle; the cell-grid TUI reads its window's mode directly,
// so it drops this silently like the other semantic families.

View File

@ -167,11 +167,7 @@ impl LspServerSpec {
}
fn to_process_spec(&self) -> ProcessSpec {
let mut p = ProcessSpec::new(
format!("lsp:{}", self.label),
&self.command,
format!("language server for {}", self.label),
);
let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command);
p.args.clone_from(&self.args);
p.cwd.clone_from(&self.cwd);
p.env.clone_from(&self.env);
@ -1591,15 +1587,9 @@ impl LspManager {
uri: &str,
) -> JobId {
let supersede = format!("lsp:{method}:{}:{uri}", sid.raw());
// 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);
let (job_id, token) = self
.runtime
.register_external(JobKind::LspRequest, Some(&supersede));
self.pending_external.insert(
(sid, req_id),
PendingExternal {
@ -4548,8 +4538,7 @@ 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, format!("lsp hover {uri}"));
let (job_id, token) = runtime.register_external(JobKind::LspRequest, None);
mgr.pending_routes.insert(
(a, rid),
ResponseRoute::Hover {

View File

@ -4239,35 +4239,25 @@ fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
Ok(path)
}
/// Lua handle for a captured view destination (Q#JR14d).
/// Lua handle for a captured directory destination (Q#JR14d).
///
/// 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.
/// 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.
///
/// 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.
///
/// `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);
pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination);
impl mlua::UserData for ViewDestinationLua {
impl mlua::UserData for DirectoryDestinationLua {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("window", |_, this, ()| {
Ok(this.0.window.map(crate::window::WindowId::raw))
});
methods.add_method("window", |_, this, ()| Ok(this.0.window.raw()));
}
}
@ -7576,99 +7566,6 @@ 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(
@ -7831,7 +7728,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, 7)?;
let row = lua.create_table_with_capacity(0, 6)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("age_ms", job.age_ms)?;
@ -7840,13 +7737,12 @@ 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, 8)?;
let row = lua.create_table_with_capacity(0, 7)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("duration_ms", job.duration_ms)?;
@ -7854,7 +7750,6 @@ 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)) => (
@ -8782,93 +8677,9 @@ 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();
@ -8951,7 +8762,6 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
};
Ok(ProcessSpec {
label,
purpose,
command,
args,
cwd: cwd.map(std::path::PathBuf::from),
@ -9175,18 +8985,11 @@ 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, 4)?;
let row = lua.create_table_with_capacity(0, 3)?;
row.set("id", ProcessIdLua(*id))?;
if let Some(spec) = sup.spec(*id) {
row.set("label", spec.label.as_str())?;
row.set("command", spec.command.as_str())?;
// Worker identity Stage 1: a new KEY on each
// existing row. The row COUNT is deliberately
// untouched — three acceptance suites assert on
// `#pmacs.process.list()` as a leak detector
// (framing Q#W-4), and widening what this
// enumerates would inflate all three baselines.
row.set("purpose", spec.purpose.as_str())?;
}
if let Some(state) = sup.state(*id) {
row.set("state", state_to_lua(lua, state)?)?;

View File

@ -34,9 +34,7 @@
use mlua::{Lua, Table, Value};
use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined};
use crate::editor_core::{
CommitContract, CommitProfile, DisplayOutcome, DisplayRequest, HookKind, QuitOutcome,
};
use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome};
use crate::protocol::FrontendId;
use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId};
@ -65,51 +63,6 @@ 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).
///
@ -499,7 +452,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
"commit_to",
lua.create_function(
move |lua,
(dest, body, profile): (mlua::Value, mlua::Function, mlua::Value)|
(dest, body): (mlua::Value, mlua::Function)|
-> mlua::Result<mlua::MultiValue> {
// Journey Stage 1a (Q#JR14). Preflight FIRST, then
// scope, then run. The ordering is the whole point:
@ -519,7 +472,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::ViewDestinationLua>().ok()
userdata.borrow::<super::DirectoryDestinationLua>().ok()
}
_ => None,
};
@ -531,27 +484,45 @@ 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)?;
// 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);
// 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
}
};
if let Some(reason) = refusal {
let mut out = mlua::MultiValue::new();
out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?));
@ -575,32 +546,13 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
)
})?
.clone();
// 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.
// 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.
let result = {
let _guard = scope.enter(
&cc,
&commit,
CommitContract {
destination: dest,
profile,
},
);
let _guard = scope.enter(&cc, &commit, dest.frontend);
body.call::<mlua::MultiValue>(())
};
let mut out = result?;
@ -611,39 +563,6 @@ 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.
//
@ -915,24 +834,6 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
None => None,
};
let dedicated = opts.get::<Option<bool>>("dedicated")?;
// Q#DC-2 (revision 8). The direct route to the one
// mutation that could make a `"panel"` commit's
// relaxed preflight wrong. Refused BEFORE the borrow
// below, so the attempt changes nothing --- including
// `fixed_rows`, which is in the same option table.
if dedicated == Some(true) {
let core = cc.borrow();
if core
.windows
.get(&id)
.is_some_and(crate::window::Window::is_side)
&& let Some(reason) = core.panel_commit_dedication_refusal(fid)
{
return Err(mlua::Error::runtime(format!(
"pmacs.window.set_params: {reason}"
)));
}
}
{
let mut core = cc.borrow_mut();
let window = core.windows.get_mut(&id).ok_or_else(|| {

View File

@ -175,11 +175,7 @@ impl McpServerSpec {
}
fn to_process_spec(&self) -> ProcessSpec {
let mut p = ProcessSpec::new(
format!("mcp:{}", self.label),
&self.command,
format!("MCP server {}", self.label),
);
let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command);
p.args.clone_from(&self.args);
p.cwd.clone_from(&self.cwd);
p.env.clone_from(&self.env);
@ -877,9 +873,7 @@ 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, format!("mcp {method}"));
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
client.pending_external.insert(
req_id,
PendingExternal {
@ -954,11 +948,7 @@ 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,
format!("mcp resources/read {uri} (cached)"),
);
let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None);
self.runtime.complete_external_ok(job_id, result);
return Ok(job_id);
}
@ -969,11 +959,7 @@ 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,
format!("mcp resources/read {uri}"),
);
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
if let Some(p) = client.pending_external.get_mut(&in_flight_rid) {
p.awaiters.push(Awaiter { job_id, token });
return Ok(job_id);
@ -988,11 +974,7 @@ 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,
format!("mcp resources/read {uri}"),
);
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
client.pending_external.insert(
req_id,
PendingExternal {
@ -1081,13 +1063,10 @@ 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, purpose);
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
client.pending_external.insert(
req_id,
PendingExternal {
@ -1146,13 +1125,10 @@ 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, purpose);
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
client.pending_external.insert(
req_id,
PendingExternal {

View File

@ -196,23 +196,6 @@ 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,
@ -254,21 +237,10 @@ 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>,
purpose: impl Into<String>,
) -> Self {
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
Self {
label: label.into(),
purpose: purpose.into(),
command: command.into(),
args: Vec::new(),
cwd: None,
@ -2750,7 +2722,6 @@ 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();
@ -2761,7 +2732,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", "test process");
let mut spec = ProcessSpec::new("echo-test", "/bin/sh");
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);
@ -2921,7 +2892,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", "test process");
let mut spec = ProcessSpec::new(name, "/bin/sleep");
spec.args = vec!["30".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -2972,7 +2943,7 @@ mod tests {
sup: &mut ProcessSupervisor,
name: &str,
) -> (ProcessId, u32, i32) {
let mut spec = ProcessSpec::new(name, BASH, "test process");
let mut spec = ProcessSpec::new(name, BASH);
spec.args = vec![
"--noprofile".into(),
"--norc".into(),
@ -3223,7 +3194,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", "test process");
let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
@ -3256,7 +3227,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", "test process");
let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3309,7 +3280,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", "test process");
let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh");
// 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
@ -3373,7 +3344,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", "test process");
let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh");
spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "sleep 1; ")];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3464,7 +3435,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", "test process");
let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
@ -3513,7 +3484,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", "test process");
let mut spec = ProcessSpec::new("diag-exited", "/bin/sh");
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
@ -3541,7 +3512,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", "test process");
let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3586,7 +3557,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", "test process");
let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -3628,7 +3599,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", "test process");
let mut spec = ProcessSpec::new("sleeper", "/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
// Wait for Started so we have a pid.
@ -3657,7 +3628,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", "test process");
let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh");
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| {
@ -3683,7 +3654,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", "test process");
let mut spec = ProcessSpec::new("cat-echo", "/bin/sh");
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| {
@ -3729,7 +3700,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", "test process");
let mut spec = ProcessSpec::new("crasher", "/bin/sh");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.restart = RestartPolicy::OnCrash;
let id = sup.spawn(spec).expect("spawn");
@ -3760,7 +3731,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", "test process");
let mut spec = ProcessSpec::new("oneshot", "/bin/sh");
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);
@ -3789,7 +3760,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", "test process");
let mut spec = ProcessSpec::new("victim", "/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
// Drain until Started so we know the pid.
@ -3827,7 +3798,7 @@ mod tests {
#[test]
fn pty_mode_child_sees_a_tty() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process");
let mut spec = ProcessSpec::new("ttytest", "/bin/sh");
spec.args = vec!["-c".into(), "tty".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -3864,7 +3835,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", "test process");
let mut spec = ProcessSpec::new("winch-watch", "/bin/sh");
// Trap WINCH, print READY for synchronization, then loop on
// a short sleep so SIGWINCH can interrupt and fire the trap.
spec.args = vec![
@ -3909,7 +3880,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", "test process");
let mut spec = ProcessSpec::new("pty-exit", "/bin/sh");
spec.args = vec!["-c".into(), "echo done && exit 0".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -3944,7 +3915,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", "test process");
let mut spec = ProcessSpec::new("raw-stty", "/bin/sh");
spec.args = vec!["-c".into(), "stty -a".into()];
spec.mode = ProcessMode::default_pty(); // Raw by default.
let id = sup.spawn(spec).expect("spawn");
@ -3966,7 +3937,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", "test process");
let mut spec = ProcessSpec::new("canon-stty", "/bin/sh");
spec.args = vec!["-c".into(), "stty -a".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -4020,7 +3991,7 @@ mod tests {
// buffers.
const TOTAL: usize = 10 * 1024 * 1024;
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("byte-flood", "/bin/sh", "test process");
let mut spec = ProcessSpec::new("byte-flood", "/bin/sh");
spec.args = vec!["-c".into(), format!("head -c {TOTAL} /dev/zero")];
let id = sup.spawn(spec).expect("spawn");
@ -4096,7 +4067,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", "test process");
let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh");
// 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
@ -4145,7 +4116,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", "test process");
let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh");
spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -4227,7 +4198,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", "test process");
let mut spec = ProcessSpec::new("forever-flood", "/bin/sh");
// 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");
@ -4353,7 +4324,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", "test process");
let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid");
// `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
@ -4441,7 +4412,7 @@ mod tests {
// -----------------------------------------------------------------
fn sh_group_spec(label: &str, script: &str) -> ProcessSpec {
let mut spec = ProcessSpec::new(label, "/bin/sh", "test process");
let mut spec = ProcessSpec::new(label, "/bin/sh");
spec.args = vec!["-c".into(), script.to_owned()];
spec.stdin = StdinMode::Null;
spec.group = true;
@ -4575,7 +4546,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", "test process");
let mut plain = ProcessSpec::new("plain", "/bin/sh");
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| {
@ -5046,7 +5017,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", "test process");
let mut spec = ProcessSpec::new("restarter", "/bin/sh");
spec.args = vec!["-c".into(), "echo x".into()];
spec.restart = RestartPolicy::Always;
let id = sup.spawn(spec).expect("spawn");
@ -5187,7 +5158,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", "test process");
let mut spec = ProcessSpec::new("pty-null", "/bin/sh");
spec.mode = ProcessMode::default_pty();
spec.stdin = StdinMode::Null;
let err = sup
@ -5198,7 +5169,7 @@ mod tests {
"error points at pipe mode: {err}"
);
let mut spec = ProcessSpec::new("pty-group", "/bin/sh", "test process");
let mut spec = ProcessSpec::new("pty-group", "/bin/sh");
spec.mode = ProcessMode::default_pty();
spec.group = true;
let err = sup

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() {
fn protocol_version_is_twenty_two_for_line_wrap_facts() {
// 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,15 +1732,7 @@ 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.
// Discovery Stage 2 bumps 22→23 (`InstanceMessage::
// MinibufferPromptRows`, daemon-gated, appended after the final
// v22 variant). The first bump to leave the SUPERSEDED variant
// live rather than widening it: postcard is positional, so
// widening `MinibufferPrompt` would break every v12v22 peer,
// and gating the wider form would have left them with no
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
// pinned by literal bytes below.
assert_eq!(PROTOCOL_VERSION, 23);
assert_eq!(PROTOCOL_VERSION, 22);
}
#[test]
@ -1817,18 +1809,17 @@ mod tests {
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// v20 (semantic initial-target bootstrap), v21 (the bottom
// panel band), v22 (`LineWrapFacts`), and v23
// (`MinibufferPromptRows`) all interoperate.
for accepted in 6..=23 {
// panel band), and v22 (`LineWrapFacts`) all interoperate.
for accepted in 6..=22 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v23 binary"
"v{rejected} must be rejected by a v22 binary"
);
}
}
@ -2415,130 +2406,6 @@ mod tests {
}
}
#[test]
fn minibuffer_prompt_v12_wire_bytes_are_frozen() {
// Discovery Stage 2 (v23) froze `MinibufferPrompt` and put the
// richer shape in an appended `MinibufferPromptRows`. THIS is
// what makes the freeze real, and the round-trip above is not:
// a round-trip encodes and decodes with the SAME types, so
// adding a field to `MinibufferPrompt` leaves it passing while
// every v12v22 peer in the field mis-decodes the bytes. Only a
// comparison against bytes captured now can fail when the
// encoding changes.
//
// Two fixtures, the two shapes the producer emits: an open
// prompt with a windowed candidate list and a selection, and a
// cleared band. Discriminant 20, then the fields positionally
// (postcard is not self-describing).
let open = InstanceMessage::MinibufferPrompt {
prompt: Some("M-x ".to_owned()),
input: "ed".to_owned(),
cursor: 2,
candidates: vec!["edit.copy".to_owned(), "edit.cut".to_owned()],
selected: Some(1),
total: 7,
};
assert_eq!(
postcard::to_allocvec(&open).expect("encode open"),
[
20, // InstanceMessage::MinibufferPrompt
1, 4, b'M', b'-', b'x', b' ', // prompt: Some("M-x ")
2, b'e', b'd', // input: "ed"
2, // cursor
2, 9, b'e', b'd', b'i', b't', b'.', b'c', b'o', b'p', b'y', 8, b'e', b'd', b'i',
b't', b'.', b'c', b'u', b't', // candidates
1, 1, // selected: Some(1)
7, // total
],
"MinibufferPrompt's v12 wire bytes changed. It is FROZEN for \
v12..=22 a widening here mis-decodes on every already-shipped \
frontend rather than being ignored. Richer minibuffer rows \
belong in MinibufferPromptRows."
);
let clear = InstanceMessage::MinibufferPrompt {
prompt: None,
input: String::new(),
cursor: 0,
candidates: Vec::new(),
selected: None,
total: 0,
};
assert_eq!(
postcard::to_allocvec(&clear).expect("encode clear"),
[20, 0, 0, 0, 0, 0, 0],
"MinibufferPrompt's cleared-band v12 wire bytes changed — see the \
open-prompt fixture above"
);
}
#[test]
fn line_wrap_facts_encoding_is_unchanged_by_the_v23_build() {
// Discovery Stage 2 placement pin: `MinibufferPromptRows` must
// be APPENDED after `LineWrapFacts` — the final v22 variant,
// whose ordinal moves if anything is inserted before any v22
// variant. The new variant's own round-trip cannot detect a
// shift, which is why the pin sits on the PREVIOUS final variant
// (handoff §4).
let msg = InstanceMessage::LineWrapFacts {
buffer_id: pmacs_protocol::BufferId::from_raw(4),
wrap: true,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[29, 4, 1],
"LineWrapFacts' v22 wire bytes changed — a variant was \
inserted before it; append new InstanceMessage variants \
at the end"
);
}
#[test]
fn minibuffer_prompt_rows_round_trips_and_appends_after_line_wrap_facts() {
// The v23 variant itself: both shapes, a detail present and a
// detail absent (Q#D2-2 — a source with no detail leaves it
// `None` and renders as it always did), plus the cleared band.
let cases = [
(
Some("M-x ".to_owned()),
"ed".to_owned(),
2u32,
vec![
MinibufferRow {
label: "edit.copy".to_owned(),
detail: Some("Copy the region".to_owned()),
},
MinibufferRow {
label: "notes.txt".to_owned(),
detail: None,
},
],
Some(1u32),
7u32,
),
(None, String::new(), 0, Vec::new(), None, 0),
];
for (prompt, input, cursor, rows, selected, total) in cases {
let msg = InstanceMessage::MinibufferPromptRows {
prompt: prompt.clone(),
input: input.clone(),
cursor,
rows: rows.clone(),
selected,
total,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes.first(),
Some(&30),
"MinibufferPromptRows must be appended after v22 LineWrapFacts"
);
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, msg);
}
}
#[test]
fn key_event_to_crossterm_round_trips() {
// Build a protocol KeyEvent, translate to crossterm, translate

View File

@ -38,7 +38,7 @@ use crate::cell::{CellSize, Style};
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, MinibufferRow, PANEL_MIN_VERSION,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
StatuslineSegment, StyleSegment, StyleSpan,
};
use crate::statusline::{
@ -95,26 +95,10 @@ type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
/// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// Cached minibuffer payload for cached-compare suppression (Q#MB1):
/// `(prompt, input, cursor, rows-window, selected, total)`. A `None`
/// prompt means the minibuffer is closed.
///
/// **ONE cache per peer, not one per variant.** [`SemanticRenderState`]
/// is constructed by [`SemanticRenderState::for_peer`] with the
/// session's negotiated version baked in on attach and dropped on
/// detach, so a cache can never span two negotiated versions and a
/// per-variant key would guard nothing. The rows are the cached form
/// either way: for a `12..=22` peer every `detail` is `None` (the
/// producer does not resolve details it cannot ship), so the cache
/// describes exactly what that peer received.
type MinibufferFacts = (
Option<String>,
String,
u32,
Vec<MinibufferRow>,
Option<u32>,
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 `CompletionPopup` payload for cached-compare suppression
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
@ -131,20 +115,10 @@ 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<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Vec<T>, Option<u32>) {
fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<String>, Option<u32>) {
if candidates.is_empty() {
return (Vec::new(), None);
}
@ -242,18 +216,10 @@ 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 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 emitted `MinibufferPrompt` payload (Q#MB1) — a single value,
/// not per-buffer, because the minibuffer is one global core
/// instance.
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`]).
@ -512,7 +478,6 @@ 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;
@ -535,7 +500,6 @@ 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(),
@ -1673,20 +1637,11 @@ impl SemanticRenderState {
Some(msg)
}
/// The minibuffer message for this frame, or `None` when the
/// The `MinibufferPrompt` 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.
///
/// **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.
/// silent. The daemon keeps the variant off wires negotiated `< 12`.
fn minibuffer_prompt_msg(
&mut self,
state: &EditorState,
@ -1707,61 +1662,13 @@ impl SemanticRenderState {
.take_while(|(i, _)| *i < cursor_byte)
.count() as u32;
let total = session.candidates.len() as u32;
let (labels, selected) =
let (candidates, 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,
rows,
candidates,
selected,
total,
)
@ -1777,24 +1684,13 @@ impl SemanticRenderState {
self.last_minibuffer = Some(facts);
return None;
}
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,
}
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,
};
self.last_minibuffer = Some(facts);
Some(msg)
@ -5877,28 +5773,9 @@ 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::<String>(&[], Some(0)),
(Vec::new(), None)
);
assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None));
}
/// The v23 rows form: `(prompt, input, rows)`.
fn minibuffer_rows_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, 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>)> {
@ -5921,7 +5798,7 @@ mod tests {
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
// No minibuffer: the producer stays silent on first sight.
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
// Open an `M-x` prompt (command completion) via the Lua API.
state
@ -5930,133 +5807,28 @@ mod tests {
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
.exec()
.expect("open minibuffer");
let (prompt, input, rows) =
minibuffer_rows_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
let (prompt, input, cands) =
minibuffer_prompt_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!(!rows.is_empty(), "M-x seeds command candidates");
assert!(rows.len() <= MB_VISIBLE, "candidates ship windowed");
assert!(!cands.is_empty(), "M-x seeds command candidates");
assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed");
// Unchanged → suppressed (cached-compare).
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_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.
// Cancel: the prompt clears (None).
state
.lua_host
.lua()
.load("pmacs.minibuffer.cancel()")
.exec()
.expect("cancel");
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");
let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted");
assert!(prompt.is_none(), "cancel clears the minibuffer band");
}
#[test]
fn a_v22_peer_gets_the_frozen_variant_and_a_v23_peer_gets_rows_with_details() {
// The producer half of the exclusivity guarantee, at the two
// versions that straddle the boundary. The real-daemon half —
// two sessions negotiating simultaneously — is in
// `tests/discovery_stage2_acceptance.rs`.
let state = empty_state();
let bid = active_buffer(&state);
state
.lua_host
.lua()
.load(
"pmacs.command.define{ name = 'mb.probe', description = 'Probe the row detail.', \
fn = function() end }",
)
.exec()
.expect("define probe command");
let mut v22 = SemanticRenderState::for_peer(FrontendId::LOCAL, 22);
let mut v23 = SemanticRenderState::for_peer(FrontendId::LOCAL, 23);
for s in [&mut v22, &mut v23] {
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
}
state
.lua_host
.lua()
.load(
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', \
on_accept = function() end }",
)
.exec()
.expect("open minibuffer");
state
.lua_host
.lua()
.load("pmacs.minibuffer.set_contents('mb.probe')")
.exec()
.expect("narrow to the probe command");
let v22_frame = v22.render_frame(&state);
assert!(
minibuffer_rows_of(&v22_frame).is_none(),
"a v22 peer must never receive the v23 rows variant"
);
let (_, _, candidates) =
minibuffer_prompt_of(&v22_frame).expect("v22 gets the frozen variant");
assert!(
candidates.iter().any(|c| c == "mb.probe"),
"the frozen variant still carries the candidate names: {candidates:?}"
);
let v23_frame = v23.render_frame(&state);
assert!(
minibuffer_prompt_of(&v23_frame).is_none(),
"a v23 peer must never receive the frozen variant"
);
let (_, _, rows) = minibuffer_rows_of(&v23_frame).expect("v23 gets the rows variant");
let probe = rows
.iter()
.find(|r| r.label == "mb.probe")
.expect("the probe command is a candidate");
assert_eq!(
probe.detail.as_deref(),
Some("Probe the row detail."),
"the row carries the command's registered description"
);
}
#[test]
fn a_source_with_no_detail_ships_rows_with_none() {
// Q#D2-2: only the command source has a detail today. A
// buffer-name prompt leaves it `None`, and the GPU then renders
// exactly what it rendered before v23.
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
state
.lua_host
.lua()
.load(
"pmacs.minibuffer.read{ prompt = 'Buffer: ', source = 'buffers', \
on_accept = function() end }",
)
.exec()
.expect("open buffer prompt");
let (_, _, rows) = minibuffer_rows_of(&s.render_frame(&state)).expect("prompt emitted");
assert!(!rows.is_empty(), "the buffer registry seeds candidates");
assert!(
rows.iter().all(|r| r.detail.is_none()),
"a source with no detail leaves every row's detail None: {rows:?}"
);
}
#[test]
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
let state = empty_state();

View File

@ -305,8 +305,7 @@ impl TerminalManager {
buffer.set_read_only(true);
core.registry.borrow_mut().insert(buffer);
let purpose = format!("terminal running {}", spec.command);
let mut process_spec = ProcessSpec::new(buffer_name, spec.command, purpose);
let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
process_spec.args = spec.args;
process_spec.cwd = spec.cwd;
process_spec.env = spec.env;

View File

@ -14,40 +14,19 @@
//! ```text
//! Workers (active: 2, completed: 5)
//!
//! ID Kind Age Supersede Purpose Status
//! ------ ----------- -------- ---------- ------------------------ ----------
//! #5 grep 412ms search search: grep "fn" in /x running
//! #6 sleep 18ms sleep 18ms running (cancel pending)
//! ID Kind Age Supersede Status
//! ------ ----------- -------- ---------- ----------
//! #5 grep 412ms search running
//! #6 sleep 18ms running (cancel pending)
//!
//! Recent (newest first)
//!
//! 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)
//! ID Kind Duration Supersede Outcome
//! ------ ----------- -------- ---------- ----------
//! #4 grep 1242ms search cancelled (3s ago)
//! #3 compute_sum 2ms 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
@ -56,7 +35,7 @@
use std::fmt::Write;
use crate::async_runtime::{
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot, purpose_for_one_row,
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot,
};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
@ -64,11 +43,6 @@ 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 —
@ -145,17 +119,13 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
let _ = writeln!(text);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Status",
"ID", "Kind", "Age", "Supersede", "Purpose"
"{:<7} {:<11} {:>9} {:<11} Status",
"ID", "Kind", "Age", "Supersede"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
);
if snapshot.active.is_empty() {
let _ = writeln!(text, "(no active jobs)");
@ -169,17 +139,13 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
let _ = writeln!(text);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Outcome",
"ID", "Kind", "Duration", "Supersede", "Purpose"
"{:<7} {:<11} {:>9} {:<11} Outcome",
"ID", "Kind", "Duration", "Supersede"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
);
if snapshot.completed.is_empty() {
let _ = writeln!(text, "(no recent completions)");
@ -203,11 +169,7 @@ fn write_active_row(text: &mut String, job: &ActiveJobInfo) {
if job.is_stream {
status.push_str(" [stream]");
}
let purpose = purpose_for_one_row(&job.purpose);
let _ = writeln!(
text,
"{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {status}"
);
let _ = writeln!(text, "{id:<7} {kind:<11} {age:>9} {key:<11} {status}");
}
fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
@ -217,10 +179,9 @@ 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} {purpose:<PURPOSE_WIDTH$} {outcome} ({age} ago)"
"{id:<7} {kind:<11} {duration:>9} {key:<11} {outcome} ({age} ago)"
);
}
@ -326,7 +287,6 @@ mod tests {
supersede_key: Some("search".to_string()),
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -349,7 +309,6 @@ mod tests {
supersede_key: None,
cancel_requested: true,
is_stream: false,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -367,7 +326,6 @@ mod tests {
duration_ms: 25,
settled_age_ms: 200,
supersede_key: None,
purpose: "sum 1..10".to_string(),
outcome: JobOutcome::Complete(JobResult::Sum(55)),
}],
);
@ -410,7 +368,6 @@ mod tests {
supersede_key: None,
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);

View File

@ -337,9 +337,8 @@ 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. v23 is `MinibufferPromptRows` (Discovery
// Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 23);
// here, not a silent one. v22 is `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 22);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid"
@ -353,10 +352,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 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.
// 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.
// `const` blocks, matching the line above: these are compile-time
// constants, so a runtime `assert!` is both a clippy error and a
// weaker check than the language already offers.

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,684 +0,0 @@
// discovery_stage2_acceptance.rs --- Discovery Stage 2
// (docs/discovery-stage2-framing.md §6).
//! `M-x` rows stop being bare names.
//!
//! `Command.description` already existed and was already rendered by
//! `help.list-commands`; it was missing at the one moment it would
//! change a decision. Carrying it to the row is two independent halves,
//! and this suite keeps them separate because they fail separately:
//!
//! - **The wire half** is a protocol bump, v22 → v23, and it is
//! *additive*. `MinibufferPrompt` is FROZEN and still sent to every
//! `12..=22` peer, because postcard encodes fields positionally — a
//! widened `candidates` would make those peers mis-decode rather than
//! ignore, and gating the widened form would have left them with no
//! minibuffer message at all. The rich shape lives in an appended
//! `MinibufferPromptRows`, and **exactly one of the two reaches any
//! peer, ever**.
//! - **The TUI half involves no wire at all.** `src/editor.rs` contains
//! zero references to `MinibufferPrompt`: `paint_minibuffer` reads
//! `core.minibuffer` directly and renders the selected candidate as an
//! inline suffix. So it reads `Command.description` from the registry
//! in-process, which is why this half is independent of the bump.
//!
//! The daemon fixtures are `crdt`-gated because a semantic session is
//! necessarily a text replica: a non-CRDT build advertises no
//! `semantic_render` and cannot host one. They run in the
//! `--features crdt` sweep that `scripts/gate --protocol` adds.
mod common;
use std::path::Path;
use pmacs::bootstrap::BootstrapRoots;
use pmacs::editor::EditorState;
use pmacs_protocol::{
ADVERTISED_PROTOCOL_VERSION, ByteRange, InstanceMessage, MinibufferRow, PROTOCOL_VERSION,
is_supported_protocol_version,
};
#[cfg(feature = "crdt")]
use std::os::unix::net::UnixStream;
#[cfg(feature = "crdt")]
use std::time::{Duration, Instant};
#[cfg(feature = "crdt")]
use pmacs_protocol::cell::CellSize;
#[cfg(feature = "crdt")]
use pmacs_protocol::message::{
AttachRequest, FrontendCapabilities, FrontendEvent, Hello, Key, KeyEvent, Modifiers,
SessionBootstrapRequest,
};
#[cfg(feature = "crdt")]
use pmacs_protocol::transport::{read_message, write_message};
#[cfg(feature = "crdt")]
use common::daemon::{TestDaemon, build_default_caps};
// ---------------------------------------------------------------------------
// Version-bump discipline (§6, last bullet)
// ---------------------------------------------------------------------------
/// The bump is deliberate, and the advertised baseline does NOT move.
///
/// `ADVERTISED_PROTOCOL_VERSION` is pinned at 20 and is the one constant
/// that must never be edited (handoff §3/§5): the handshake is
/// server-first, so moving it locks out every already-shipped frontend
/// before it can counter-offer. An additive family never needs it.
#[test]
fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() {
assert_eq!(
PROTOCOL_VERSION, 23,
"v23 is MinibufferPromptRows (Discovery Stage 2)"
);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the counter-offer mechanism exists to avoid"
);
// The whole v12..=22 population this lane is compatible with is
// still supported, and the set ends at the new wire — a widened set
// is a failure rather than a silent pass.
for version in 6..=23 {
assert!(
is_supported_protocol_version(version),
"v{version} must still be supported"
);
}
assert!(!is_supported_protocol_version(24));
}
// ---------------------------------------------------------------------------
// The TUI half: no wire involvement (§3.4, §6)
// ---------------------------------------------------------------------------
fn session(name: &str) -> EditorState {
let base = Path::new(env!("CARGO_TARGET_TMPDIR"))
.join("discovery-stage2")
.join(name);
let _ = std::fs::remove_dir_all(&base);
let roots = BootstrapRoots::isolated_under(&base);
for (_, dir) in roots.child_env() {
std::fs::create_dir_all(&dir).expect("create controlled root");
}
let state = EditorState::new_with_roots(&roots);
state.install_state_dirs();
state
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
/// Render one frame at `cols` columns and return the bottom row's text.
///
/// Through `RenderState` and the wire rather than by calling the painter
/// directly: the spans are what the TUI actually consumes, so this
/// asserts on the cells that reach a screen.
fn bottom_row(s: &EditorState, rows: u32, cols: u32) -> String {
use std::collections::HashMap;
let size = pmacs::cell::CellSize::new(rows, cols);
let mut rs = pmacs::instance_render::RenderState::new(size);
let msgs = rs.render_frame(s, pmacs::protocol::FrontendId::LOCAL, &HashMap::new(), &[]);
let mut row = vec![' '; cols as usize];
for msg in &msgs {
if let pmacs_protocol::InstanceMessage::CellDelta { spans, .. } = msg {
for span in spans {
if span.start.row != rows - 1 {
continue;
}
for (i, cell) in span.cells.iter().enumerate() {
let c = span.start.col as usize + i;
if c < cols as usize
&& let pmacs::cell::Glyph::Char(ch) = cell.glyph
{
row[c] = ch;
}
}
}
}
}
row.into_iter().collect::<String>().trim_end().to_owned()
}
/// Open `M-x` narrowed to `zzprobe` and return the candidate rows the
/// semantic producer ships to a current-wire peer.
///
/// Through `SemanticRenderState` and the real minibuffer session rather
/// than by constructing a message: the clip lives in the producer, so a
/// hand-built row would skip the thing under test.
fn mx_rows(s: &EditorState) -> Vec<MinibufferRow> {
let bid = s.core.borrow().active_buffer_id();
let mut render = pmacs::semantic_render::SemanticRenderState::for_peer(
pmacs::protocol::FrontendId::LOCAL,
PROTOCOL_VERSION,
);
render.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let _ = render.render_frame(s);
exec(
s,
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }",
);
exec(s, "pmacs.minibuffer.set_contents('zzprobe')");
render
.render_frame(s)
.into_iter()
.find_map(|msg| match msg {
InstanceMessage::MinibufferPromptRows { rows, .. } => Some(rows),
_ => None,
})
.expect("the producer ships a rows prompt")
}
/// Open `M-x`, narrowed to exactly one command with a known
/// description, and report the bottom row at `cols` columns.
fn mx_bottom_row(s: &EditorState, cols: u32) -> String {
exec(
s,
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }",
);
exec(s, "pmacs.minibuffer.set_contents('zzprobe')");
bottom_row(s, 24, cols)
}
const PROBE_DESCRIPTION: &str = "Probe the description row.";
fn define_probe(s: &EditorState) {
exec(
s,
&format!(
"pmacs.command.define{{ name = 'zzprobe', description = '{PROBE_DESCRIPTION}', \
fn = function() end }}"
),
);
}
#[test]
fn the_tui_renders_the_description_beside_the_selected_name() {
let s = session("tui-wide");
define_probe(&s);
let row = mx_bottom_row(&s, 120);
assert!(
row.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")),
"the selected candidate carries its description: {row:?}"
);
}
#[test]
fn the_tui_drops_the_description_then_the_whole_suffix_as_width_shrinks() {
// §3.4's three ORDERED steps, at the three widths that separate
// them. The guarantee is "never a PARTIAL name", which is
// achievable; "the name always survives" is not, because the prompt
// and the typed input consume the budget first.
let s = session("tui-clip");
define_probe(&s);
// 1. Wide: name + description.
let wide = mx_bottom_row(&s, 120);
assert!(
wide.contains(&format!("[zzprobe — {PROBE_DESCRIPTION}]")),
"wide: {wide:?}"
);
// 2. Room for the whole name but not the whole description: the
// description is dropped, leaving exactly today's `[name]`. No
// ellipsis stub, and no prefix of the description either.
let medium = mx_bottom_row(&s, 30);
assert!(medium.contains("[zzprobe]"), "medium: {medium:?}");
assert!(
!medium.contains('—'),
"a description that does not fit whole is dropped entirely: {medium:?}"
);
// 3. Too narrow for even the whole name: the suffix vanishes. The
// assertion is that no PREFIX of the name is emitted — `[zzpr`
// would read as a different command, which is worse than nothing.
let narrow = mx_bottom_row(&s, 18);
assert!(
!narrow.contains('['),
"a suffix that cannot hold the whole name is omitted entirely: {narrow:?}"
);
assert!(
narrow.starts_with("M-x zzprobe"),
"the prompt and the typed input still own the row: {narrow:?}"
);
for cut in 1.."zzprobe".len() {
assert!(
!narrow.contains(&format!("[{}", &"zzprobe"[..cut])),
"no prefix of the name may be emitted: {narrow:?}"
);
}
}
#[test]
fn a_source_with_no_detail_renders_exactly_as_before_in_the_tui() {
// Q#D2-2: the file-path prompt is the witness. It has no detail, so
// its suffix is the pre-v23 `[name]` and nothing else.
let s = session("tui-files");
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("discovery-stage2-files");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create file-prompt dir");
std::fs::write(dir.join("zznotes.txt"), b"x").expect("seed a file");
exec(
&s,
&format!(
"pmacs.minibuffer.read{{ prompt = 'File: ', source = 'files', \
source_root = '{}', on_accept = function() end }}",
dir.display()
),
);
exec(&s, "pmacs.minibuffer.set_contents('zznotes.txt')");
let row = bottom_row(&s, 24, 120);
assert!(row.contains("[zznotes.txt]"), "file prompt row: {row:?}");
assert!(
!row.contains('—'),
"a source with no detail gains no separator: {row:?}"
);
}
// ---------------------------------------------------------------------------
// Multi-line descriptions reach single-row surfaces as ONE line
// ---------------------------------------------------------------------------
/// An MCP-shaped description: tool text, blank line, `Arguments:`, then
/// one line per argument.
///
/// This is the real shape, not an invented one —
/// `tests/fixtures/pmacs-mcp-tools/init.lua:272` builds it with
/// `table.concat(lines, "\n")` and `m9_6_acceptance.rs:583-598` asserts
/// four of its lines, which is why registration accepts it and the
/// SURFACES clip instead.
const MCP_SHAPED: &str = "Greet someone.\\n\\nArguments:\\n name (string, required)";
fn define_multiline_probe(s: &EditorState, name: &str, description: &str) {
exec(
s,
&format!(
"pmacs.command.define{{ name = '{name}', description = \"{description}\", \
fn = function() end }}"
),
);
}
#[test]
fn a_multi_line_description_reaches_the_tui_band_as_one_line() {
let s = session("tui-multiline");
define_multiline_probe(&s, "zzprobe", MCP_SHAPED);
let row = mx_bottom_row(&s, 200);
assert!(
row.contains("[zzprobe — Greet someone.]"),
"the band shows the first line only: {row:?}"
);
assert!(
!row.contains("Arguments:"),
"the schema block must not reach a single-row band: {row:?}"
);
// `bottom_row` reads one grid row, so anything below would be lost
// rather than visibly wrong — assert on the registry-side clip too,
// which is what the painter consumed.
let clipped: String = eval(&s, "return pmacs.describe.command('zzprobe').description");
assert!(
clipped.contains("Arguments:"),
"describe-command must still see the WHOLE description, or the clip \
silently deleted the schema block everywhere: {clipped:?}"
);
}
#[test]
fn a_multi_line_description_reaches_the_gpu_row_as_one_physical_line() {
// The geometry hazard, through the real prompt path: the dropdown
// sizes itself from `rows.len()` — one logical row per candidate —
// so a detail carrying a break would shape into more physical lines
// than the geometry accounts for.
//
// All three break forms, since a clip handling only LF would pass a
// bare CR through to the same surface.
for (label, description, tail) in [
("LF", MCP_SHAPED, "Arguments:"),
(
"CR",
"Greet someone.\\r\\rArguments:\\r name (string, required)",
"Arguments:",
),
(
"CRLF",
"Greet someone.\\r\\n\\r\\nArguments:\\r\\n name (string, required)",
"Arguments:",
),
] {
let s = session(&format!("gpu-multiline-{label}"));
define_multiline_probe(&s, "zzprobe", description);
let rows = mx_rows(&s);
let probe = rows
.iter()
.find(|row| row.label == "zzprobe")
.unwrap_or_else(|| panic!("{label}: the probe command is a candidate"));
let detail = probe
.detail
.as_deref()
.unwrap_or_else(|| panic!("{label}: the row carries a detail"));
assert_eq!(
detail, "Greet someone.",
"{label}: the wire row carries the first line only"
);
assert!(
!detail.contains(['\n', '\r']),
"{label}: a row detail must carry no line break: {detail:?}"
);
assert!(
!detail.contains(tail),
"{label}: the schema block must not reach the dropdown"
);
// And the full text is still there for the discoverability
// path, which is what makes this a rendering decision.
let full: String = eval(&s, "return pmacs.describe.command('zzprobe').description");
assert!(
full.contains("name (string, required)"),
"{label}: describe-command must still report every line: {full:?}"
);
}
}
#[test]
fn a_single_line_description_is_unchanged_on_the_wire() {
// The clip did not tighten past its purpose: a description with no
// break reaches the row byte-identical, with no truncation marker.
let s = session("wire-single-line");
define_probe(&s);
let rows = mx_rows(&s);
let probe = rows
.iter()
.find(|row| row.label == "zzprobe")
.expect("the probe command is a candidate");
assert_eq!(probe.detail.as_deref(), Some(PROBE_DESCRIPTION));
}
#[test]
fn typed_but_unmatched_input_is_still_accepted() {
// Q#D2-5, the trap this lane arrives with: richer rows make `M-x`
// LOOK like a closed set, which invites making acceptance reject
// unmatched input. That would be a behaviour change, and it is out
// of scope. `resolve_accepted_value` still returns the literal typed
// text when nothing is selected.
let s = session("open-set");
exec(
&s,
"_G.ACCEPTED = nil
pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands',
on_accept = function(v) _G.ACCEPTED = v end }",
);
exec(
&s,
"pmacs.minibuffer.set_contents('no-such-command-at-all')",
);
assert_eq!(
eval::<usize>(&s, "return #pmacs.minibuffer.candidates()"),
0,
"the probe input must match nothing, or this asserts the wrong thing"
);
exec(&s, "pmacs.minibuffer.accept()");
assert_eq!(
eval::<String>(&s, "return _G.ACCEPTED"),
"no-such-command-at-all",
"completion is assistance, not validation"
);
}
// ---------------------------------------------------------------------------
// The wire half: one real daemon, two negotiated versions (§6)
// ---------------------------------------------------------------------------
/// An `init.lua` that registers the probe command whose description the
/// wire must carry.
#[cfg(feature = "crdt")]
const PROBE_INIT: &str = r#"
pmacs.command.define {
name = "zzprobe",
description = "Probe the description row.",
fn = function() end,
}
"#;
/// A minibuffer message, in whichever family it arrived.
#[cfg(feature = "crdt")]
#[derive(Debug)]
enum Mb {
Legacy {
prompt: Option<String>,
candidates: Vec<String>,
},
Rows {
prompt: Option<String>,
rows: Vec<MinibufferRow>,
},
}
#[cfg(feature = "crdt")]
fn semantic_caps() -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
}
}
/// Attach a semantic session offering exactly `offer`, declare a
/// viewport so the projection producer is live, and hand back the
/// stream plus this session's frontend id.
#[cfg(feature = "crdt")]
fn attach_semantic(daemon: &TestDaemon, offer: u32) -> (UnixStream, pmacs_protocol::FrontendId) {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
"the server-first Hello must stay at the compatibility baseline"
);
let fid = hello.assigned_frontend_id;
write_message(
&mut stream,
&AttachRequest {
protocol_version: offer,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
// A v20-or-later semantic session sends the bootstrap envelope; the
// daemon reads it unconditionally for those, so skipping it would
// desynchronize the stream.
if offer >= 20 {
write_message(
&mut stream,
&SessionBootstrapRequest {
initial_target: None,
},
)
.expect("write bootstrap");
}
let document = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
_ => None,
});
write_message(
&mut stream,
&FrontendEvent::Viewport {
frontend_id: fid,
buffer_id: document,
visible: ByteRange { start: 0, end: 0 },
generation: 0,
},
)
.expect("declare a viewport");
(stream, fid)
}
#[cfg(feature = "crdt")]
fn pump<T>(
stream: &mut UnixStream,
what: &str,
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
) -> T {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(stream) {
Ok(msg) => {
if let Some(found) = want(&msg) {
return found;
}
}
Err(error) => panic!("{what}: read stopped: {error}"),
}
}
panic!("timed out waiting for {what}");
}
/// Collect every minibuffer message this session receives, up to and
/// including the first one `done` accepts.
///
/// Collecting rather than filtering is the point: "a v23 peer receives
/// the rows form" is only half the guarantee, and the other half — that
/// it never receives the legacy form — can only be checked against
/// everything that arrived.
#[cfg(feature = "crdt")]
fn collect_minibuffer(
stream: &mut UnixStream,
what: &str,
mut done: impl FnMut(&Mb) -> bool,
) -> Vec<Mb> {
let mut seen = Vec::new();
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(stream) {
Ok(InstanceMessage::MinibufferPrompt {
prompt, candidates, ..
}) => {
seen.push(Mb::Legacy { prompt, candidates });
}
Ok(InstanceMessage::MinibufferPromptRows { prompt, rows, .. }) => {
seen.push(Mb::Rows { prompt, rows });
}
Ok(_) => continue,
Err(error) => panic!("{what}: read stopped: {error}"),
}
if done(seen.last().expect("just pushed")) {
return seen;
}
}
panic!("timed out waiting for {what}; saw {seen:?}");
}
#[cfg(feature = "crdt")]
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key, mods: Modifiers) {
write_message(
stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key,
mods,
timestamp_ns: 0,
}),
)
.expect("write key");
}
/// The whole exclusivity guarantee, on one live daemon: a v22 peer and a
/// v23 peer attached **simultaneously** each receive their own variant
/// and only their own — open and close alike.
///
/// One daemon rather than two, and both directions in one fixture. Two
/// daemons could each pass their own half while the same build was
/// incapable of serving both, which is the only property that matters;
/// and a test that only proved "v23 gets rows" would pass with the
/// compatibility half broken.
#[cfg(feature = "crdt")]
#[test]
fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() {
let daemon = TestDaemon::spawn_with_config(PROBE_INIT);
// The compatibility half attaches FIRST, deliberately: it is the
// half an over-eager bump destroys, so a regression fails here
// rather than after the interesting half has already passed.
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
assert_eq!(PROTOCOL_VERSION, 23);
// Open the real `M-x` through the real key path, then narrow to the
// probe command by typing it — the candidate window is ten rows out
// of well over a hundred commands, so an unnarrowed prompt would
// assert nothing about the probe.
send_key(&mut current, current_fid, Key::Char('x'), Modifiers::ALT);
for ch in "zzprobe".chars() {
send_key(&mut current, current_fid, Key::Char(ch), Modifiers::NONE);
}
let on_current = collect_minibuffer(&mut current, "v23 open", |mb| match mb {
Mb::Rows { prompt, rows } => {
prompt.is_some() && rows.iter().any(|row| row.label == "zzprobe")
}
Mb::Legacy { .. } => false,
});
assert!(
on_current.iter().all(|mb| matches!(mb, Mb::Rows { .. })),
"a v23 peer must never receive the frozen legacy variant: {on_current:?}"
);
let Some(Mb::Rows { rows, .. }) = on_current.last() else {
unreachable!("collect_minibuffer returns on a Rows match")
};
let probe = rows
.iter()
.find(|row| row.label == "zzprobe")
.expect("the probe command is a candidate");
assert_eq!(
probe.detail.as_deref(),
Some(PROBE_DESCRIPTION),
"the description reaches the row through the real prompt path"
);
// The same session state, seen by the v22 peer, in the frozen shape.
let on_legacy = collect_minibuffer(&mut legacy, "v22 open", |mb| match mb {
Mb::Legacy { prompt, candidates } => {
prompt.is_some() && candidates.iter().any(|c| c == "zzprobe")
}
Mb::Rows { .. } => false,
});
assert!(
on_legacy.iter().all(|mb| matches!(mb, Mb::Legacy { .. })),
"a v22 peer must never receive the v23 rows variant: {on_legacy:?}"
);
// The close must arrive in the SAME family as the open. A rows
// session closed by a legacy clear leaves the dropdown on screen
// forever, and the witness for "it actually cleared" is a `prompt:
// None` in the family the frontend is mirroring.
send_key(&mut current, current_fid, Key::Escape, Modifiers::NONE);
let closed_current = collect_minibuffer(&mut current, "v23 close", |mb| {
matches!(mb, Mb::Rows { prompt: None, .. })
});
assert!(
closed_current
.iter()
.all(|mb| matches!(mb, Mb::Rows { .. })),
"the v23 close must not arrive as a legacy clear: {closed_current:?}"
);
let closed_legacy = collect_minibuffer(&mut legacy, "v22 close", |mb| {
matches!(mb, Mb::Legacy { prompt: None, .. })
});
assert!(
closed_legacy
.iter()
.all(|mb| matches!(mb, Mb::Legacy { .. })),
"the v22 close must stay in the frozen family: {closed_legacy:?}"
);
}

View File

@ -54,10 +54,6 @@ 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,

File diff suppressed because it is too large Load Diff

View File

@ -952,7 +952,7 @@ fn has_exit_event(events: &[ProcessEvent]) -> bool {
#[test]
fn m4_4_lifecycle_spawn_and_exit() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("hello", "/bin/sh", "test process");
let mut spec = ProcessSpec::new("hello", "/bin/sh");
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", "test process");
let mut spec = ProcessSpec::new("victim", "/bin/sh");
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,11 +1020,7 @@ 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",
"test process",
);
let spec = ProcessSpec::new("ghost", "/this/binary/does/not/exist/pmacs-m4-4");
let _ = sup.spawn(spec); // spawn returns Err but the event is still emitted
sup.tick();
let evs = sup.take_all_events();
@ -1041,7 +1037,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", "test process");
let mut spec = ProcessSpec::new("flap", "/bin/sh");
spec.args = vec!["-c".into(), "exit 9".into()];
spec.restart = RestartPolicy::OnCrash;
let id = sup.spawn(spec).expect("spawn");
@ -1074,7 +1070,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", "test process");
let mut spec = ProcessSpec::new("oneshot", "/bin/sh");
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);
@ -1103,7 +1099,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", "test process");
let mut spec = ProcessSpec::new("zombie-test", "/bin/sh");
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| {
@ -1140,7 +1136,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", "test process");
let mut spec = ProcessSpec::new("ttytest", "/bin/sh");
spec.args = vec!["-c".into(), "tty".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -1173,7 +1169,6 @@ 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" },
}
@ -5351,416 +5346,6 @@ fn m4_24_workspace_did_change_watched_files() {
);
}
/// Issue #233 D1 — a PLAIN-STRING `GlobPattern` matches the file's
/// ABSOLUTE path (LSP 3.17), not the walk's relative path. The
/// `filewatchabs` fake registers `<base>/**/*.txt` as a bare string —
/// the form rust-analyzer and gopls actually send. Its relative
/// reading matches nothing (an anchored `^<base>/…` can never match
/// `foo.txt`), so before the fix no event could ever be reported.
/// The watcher's base is guessed from the attached file's directory —
/// the tempdir here, and the production path for bare-string globs.
#[test]
fn m4_24_plain_string_glob_matches_absolute_path() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().to_path_buf();
let base_disp = base.display().to_string();
let a_path = base.join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
let a_disp = a_path.display().to_string();
let received = base.join(".received");
let foo_uri = format!("file://{}", base.join("foo.txt").display());
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{ command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'filewatchabs',
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
// Same warm-up as m4_24: let registerCapability land and the
// watcher take its empty baseline before files appear.
let warm = Instant::now() + Duration::from_millis(900);
while Instant::now() < warm {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
std::fs::write(base.join("bar.md"), b"md\n").expect("write bar.md");
assert!(
pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
"CREATED for foo.txt never reported under a plain-string glob; \
.received = {:?}",
std::fs::read_to_string(&received).unwrap_or_default()
);
assert!(
!std::fs::read_to_string(&received)
.unwrap_or_default()
.contains("bar.md"),
"non-matching .md must be filtered out"
);
}
/// Issue #233 F2 guard — a `RelativePattern` stays relative to its
/// base. The `filewatchflat` fake registers `{ baseUri, pattern =
/// "*.txt" }`, whose pattern has no leading `**/`: it matches
/// base-level files RELATIVELY and cannot match any absolute path
/// (`[^/]*` spans no `/`). Green before and after D1's fix; red
/// against the obvious wrong fix that matches every form absolutely.
/// `sub/nested.txt` pins the other half of the same contract: a
/// base-level pattern must not match into subdirectories.
#[test]
fn m4_24_relative_pattern_without_globstar_stays_relative() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().to_path_buf();
let base_disp = base.display().to_string();
let a_path = base.join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
let a_disp = a_path.display().to_string();
let received = base.join(".received");
let foo_uri = format!("file://{}", base.join("foo.txt").display());
std::fs::create_dir(base.join("sub")).expect("mkdir sub");
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{ command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'filewatchflat',
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
let warm = Instant::now() + Duration::from_millis(900);
while Instant::now() < warm {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
// nested.txt is written BEFORE foo.txt, so a watcher that wrongly
// matched it would report it no later than foo.txt's event — the
// negative assertion after the positive one is race-free.
std::fs::write(base.join("sub").join("nested.txt"), b"deep\n").expect("write nested.txt");
std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
assert!(
pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
"CREATED for base-level foo.txt never reported under a \
RelativePattern without `**/`; .received = {:?}",
std::fs::read_to_string(&received).unwrap_or_default()
);
assert!(
!std::fs::read_to_string(&received)
.unwrap_or_default()
.contains("nested.txt"),
"a base-level `*.txt` RelativePattern must not match into \
subdirectories"
);
}
/// Issue #233 review P2 — a scan that completes AFTER cancellation
/// must not emit.
///
/// `scan_tree` awaits `read_dir` once per directory, so the watcher
/// coroutine spends most of a tick suspended with `_sleep` already
/// cleared. A cancel arriving there — re-registration or unregistration
/// — sets `cancelled` and has no sleep to interrupt, so before the fix
/// the resumed scan ran on and emitted one last batch under the
/// superseded pattern.
///
/// No arrangement of real timing produces that interleaving on demand,
/// so it is driven through `pmacs.lsp._after_scan_for_tests`, the same
/// device `git.lua` uses for out-of-order completions. The hook is
/// handed the scan result and cancels **only on the scan that observed
/// `foo.txt`** — cancelling on any other scan would pass with the fix
/// deleted, because the loop would break at the post-sleep check and
/// emit nothing regardless.
#[test]
fn m4_24_a_scan_finishing_after_cancellation_emits_nothing() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().to_path_buf();
let base_disp = base.display().to_string();
let a_path = base.join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
let a_disp = a_path.display().to_string();
let received = base.join(".received");
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{ command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'filewatch',
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
// Armed BEFORE the file exists, so the cancel cannot land early:
// the hook fires on every scan and only cancels once the scan it is
// inspecting actually contains foo.txt.
state
.lua_host
.lua()
.load(
"pmacs.lsp._after_scan_for_tests = function(record, cur)
if cur and cur['foo.txt'] then record.cancelled = true end
end",
)
.exec()
.expect("install scan hook");
let warm = Instant::now() + Duration::from_millis(900);
while Instant::now() < warm {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
let got = std::fs::read_to_string(&received).unwrap_or_default();
assert!(
!got.contains("foo.txt"),
"a watcher cancelled during its scan emitted a stale batch \
anyway; .received = {got:?}"
);
}
/// Issue #233 review P1 — a BARE-STRING glob with no leading `/` is a
/// relative pattern and must stay one.
///
/// The first fix for #233 classified every string-arm pattern as
/// absolute, so `*.txt` was matched against `<base>/foo.txt` and could
/// never fire — silently breaking a case that had worked since May
/// while fixing the absolute one. `m4_24_relative_pattern_without_globstar_stays_relative`
/// does not cover it: that mode sends the `RelativePattern` OBJECT form,
/// so it constrains the object arm only. This sends the same pattern
/// through the STRING arm, which is the arm the regression lived in.
#[test]
fn m4_24_bare_string_glob_stays_relative() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().to_path_buf();
let base_disp = base.display().to_string();
let a_path = base.join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
let a_disp = a_path.display().to_string();
let received = base.join(".received");
let foo_uri = format!("file://{}", base.join("foo.txt").display());
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{ command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'filewatchbare',
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
let warm = Instant::now() + Duration::from_millis(900);
while Instant::now() < warm {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
assert!(
pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
"CREATED for foo.txt never reported under a bare-string `*.txt` \
glob the string arm is being classified absolute again; \
.received = {:?}",
std::fs::read_to_string(&received).unwrap_or_default()
);
}
/// Issue #233 D2 — re-registering a live id supersedes it. The
/// `filewatchrereg` fake registers `watch-re` TWICE with no
/// unregister between — `**/*.old`, then `**/*.new` — exactly the
/// shape rust-analyzer sends. The superseded watchers must STOP,
/// asserted on observable polling rather than on table shape (the
/// defect is precisely that the replaced records become unreachable
/// while still polling): `f.old` exists on disk before either `.new`
/// event lands, so a leaked first-registration watcher, polling at
/// the same 250 ms cadence, would have reported it by the time the
/// second `.new` positive arrives.
#[test]
fn m4_24_reregistration_supersedes_previous_watchers() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path().to_path_buf();
let base_disp = base.display().to_string();
let a_path = base.join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
let a_disp = a_path.display().to_string();
let received = base.join(".received");
let f_old_uri = format!("file://{}", base.join("f.old").display());
let f_new_uri = format!("file://{}", base.join("f.new").display());
let g_new_uri = format!("file://{}", base.join("g.new").display());
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{ command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'filewatchrereg',
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
let warm = Instant::now() + Duration::from_millis(900);
while Instant::now() < warm {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(15));
}
std::fs::write(base.join("f.old"), b"old\n").expect("write f.old");
std::fs::write(base.join("f.new"), b"new\n").expect("write f.new");
assert!(
pump_until_file_contains(&mut state, &received, &format!("1 {f_new_uri}"), 6),
"CREATED for f.new never reported by the superseding watcher; \
.received = {:?}",
std::fs::read_to_string(&received).unwrap_or_default()
);
// A second positive puts at least one more full poll cycle between
// f.old appearing on disk and the negative assertion below.
std::fs::write(base.join("g.new"), b"new\n").expect("write g.new");
assert!(
pump_until_file_contains(&mut state, &received, &format!("1 {g_new_uri}"), 6),
"CREATED for g.new never reported by the superseding watcher"
);
assert!(
!std::fs::read_to_string(&received)
.unwrap_or_default()
.contains(&f_old_uri),
"the superseded `**/*.old` watcher is still polling after \
re-registration under the same id; .received = {:?}",
std::fs::read_to_string(&received).unwrap_or_default()
);
}
/// Tier 1 single-binary language servers ship pre-configured in the
/// default bundle. Binary-independent: we don't spawn anything, just
/// assert the `pmacs.lsp.config` tables and the `pmacs.lsp.filetypes`

View File

@ -136,12 +136,7 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
// `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"],
["mode", "terminal", "lsp"],
"built-in providers are discoverable in registration order"
);
let before_epochs = {
@ -794,8 +789,7 @@ 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; Discovery Stage 2 appended
// `MinibufferPromptRows` as v23. This acceptance owns the STATUSLINE
// `LineWrapFacts` as v22. 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`.
@ -804,11 +798,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, 23);
for version in 6..=23 {
assert_eq!(PROTOCOL_VERSION, 22);
for version in 6..=22 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(24));
assert!(!is_supported_protocol_version(23));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

@ -398,7 +398,7 @@ fn editor_shutdown_kills_term_ignoring_terminal_child() {
#[test]
fn terminal_tick_does_not_take_non_terminal_process_events() {
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh", "test process");
let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh");
process.args = vec!["-c".into(), "printf ordinary".into()];
let ordinary_id = state
.process_supervisor

View File

@ -888,10 +888,9 @@ 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. v23 is
// `MinibufferPromptRows` (Discovery Stage 2); v22 was
// Tripwire: a wire bump must be a conscious edit here. v22 is
// `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 23);
assert_eq!(PROTOCOL_VERSION, 22);
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