Merge main into destination-capture, and correct the U4 row it turns on

Merged rather than rebased. Eighteen commits replayed against a ledger
that three other lanes had rewritten meant eighteen conflict
resolutions in `docs/active-work.md`, each one a chance to lose a lane
entry; merging resolves it once, against the state that actually ships,
and leaves the reviewed commits' SHAs intact. Only one file conflicted.

`docs/ci-red-signatures.md` auto-merged **without a conflict** — the
same silent path that produced duplicate U4/U5 ids when #232 rebased.
Verified by hand afterwards: ids U1-U8 are disjoint. They are out of
numeric order (U6/U7 sit ahead of U4/U5) and are left that way rather
than moved, since the note at the U6 row explains the history and
relocating sixty lines inside a merge commit hides real changes.

Three leftover conflict markers were sitting in `docs/active-work.md`
on `main`, committed by an earlier lane's resolution. `git diff --check`
flags them — but only for a working-tree diff, which is why the gate's
`diff-check` step never saw them and they survived several merges.
Removed here.

The U4 row is corrected on evidence this lane produced:

- **Flavour was wrong as a matching key.** The row was filed from
  #229's `lua54` red and put the flavour in the key; #231 reddened the
  identical selector with the identical three fragments twice on
  `luajit`. Matching as filed would have missed both.
- **A fourth sighting was a deliberate bite, not an occurrence** — the
  defect reintroduced on purpose during the test's own development. It
  is recorded for what it proves instead: the genuine defect and these
  CI reds are signature-indistinguishable, same message class and same
  full-timeout duration.
- **The control experiment is written down with its own bounds** — five
  green base observations against 0/2, 4.8% under an equal-rate model,
  and the two facts that bound it: attempt 5 reddened a different
  selector, and the branch side was never resampled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-10 20:05:07 +02:00
commit a1b931fa0e
No known key found for this signature in database
35 changed files with 5699 additions and 268 deletions

View File

@ -570,9 +570,11 @@ descriptions, indexed by `M-x help`. It needed **no Rust** — the data
was all reachable from Lua, and even the settings completion source is a
Lua function through `CompletionSource::Custom`.
**What is still missing** is itemized below and unchanged by that stage:
`Command` has no title/category/aliases/flags/arg-schema; the predicate
is still never evaluated; M-x rows are still bare name strings; the Rust
**What is still missing** is itemized below. Discovery Stage 2 took one
item — **M-x rows now carry each command's description** — and the rest
is unchanged by both stages: `Command` has no
title/category/aliases/flags/arg-schema; the predicate
is still never evaluated; the Rust
help layer is still orphaned (Stage 1 funnels every command through one
Lua seam so the eventual migration is enumerated per subject rather than
per call site); **packages** have no discovery surface, and workers,
@ -621,12 +623,21 @@ the sharpest instance of §1.1.**
M-x filtering, or the menu. The doc comment's claim that "the command
palette (T M2.7) uses it to gray out unavailable entries" describes
something that never shipped.
- **M-x shows bare name strings.** `CompletionSource::Commands` returns
`Vec<String>` of names; the wire type `MinibufferPrompt.candidates`
is `Vec<String>` (`pmacs-protocol/src/message.rs:994-1006`). No
description, no keybinding, no category alongside candidates — while
`CompletionPopupRow` (`:1231`) already carries `kind` and `detail`,
proving richer rows are a solved wire problem in this codebase.
- **M-x rows carry a description — Discovery Stage 2 (protocol v23).**
`CompletionSource::Commands` still returns `Vec<String>` of names, but
the row the user reads is no longer one. The GPU receives
`InstanceMessage::MinibufferPromptRows` — `MinibufferRow { label,
detail }`, a new type rather than a borrowed `CompletionPopupRow`,
whose `kind` is an LSP code with no honest value for a command — and
the grid TUI renders `[name — description]` inline from the registry
**in-process**, since `src/editor.rs` never consumed the wire variant
at all. The bump is additive: `MinibufferPrompt` is FROZEN and still
sent to every `12..=22` peer (postcard is positional, so widening it
would mis-decode there rather than be ignored), and exactly one of the
two variants reaches any peer.
**What is still missing here:** no keybinding and no category
alongside the candidate — those wait on `Command` gaining the fields
at all.
- **The entire Rust help layer is orphaned** (§1.1). Consequence: two
parallel `*help*` implementations exist — `help.rs`'s
cross-referenced renderer and the Lua `show_help_text` in
@ -684,6 +695,8 @@ the sharpest instance of §1.1.**
provenance in the config registry, (c) a dozen interactive commands and
richer M-x candidate rows over introspection that **already exists**.
This is the highest payoff-per-effort concern in the document.
*(c) is done: Stage 1 shipped the command family, Stage 2 the richer
rows. (a) and (b) remain.*
---

View File

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

View File

@ -88,6 +88,28 @@ function Handle:await()
error("await: cannot await inside pmacs.window.commit_to; " ..
"await first, then commit")
end
-- Worker identity Stage 1 (Q#W-2 rule 1): `pmacs.workers.dispatch`
-- pushes the registered handler's name for the dynamic extent of the
-- handler call, so that jobs allocated inside it are attributable to
-- the third party that asked for them. Parking here would leave the
-- name pushed while this coroutine is suspended, and every job
-- allocated in the meantime --- in any coroutine, on any later tick
-- --- would inherit it. Same hazard, same shape, same remedy as the
-- commit-scope refusal above.
--
-- Two properties this placement buys, both load-bearing:
--
-- * it rejects BEFORE parking (ahead of the `_is_complete` check and
-- the `coroutine.yield`), because a guard consulted after the yield
-- has already happened guards nothing;
-- * it rejects UNCONDITIONALLY, not only when a yield would really
-- occur. A guard that fires only for an incomplete handle would
-- pass or fail depending on whether the job happened to settle
-- first --- green under test, intermittent in production.
if async_mod._in_dispatch_name_scope() then
error("await: cannot await inside pmacs.workers.dispatch; " ..
"await first, then dispatch")
end
if not async_mod._is_complete(self._id) then
-- Yield self so pmacs.async's step() can park us. R46 carve-out:
-- this `coroutine.yield` is runtime code; package code uses
@ -240,7 +262,28 @@ setmetatable(async_public, {
end,
})
-- The SECOND supported yield API. `Handle:await()` is the first; any
-- rule about a non-yieldable dynamic extent has to cover both, or the
-- extent stays open through a second door.
--
-- Both refusals below are that rule. The commit-scope one is a
-- **pre-existing gap being closed** (worker identity framing Q#W-7):
-- Journey Stage 1a's Q#JR14b invariant was enforced on `:await()` only,
-- so a coroutine inside `pmacs.window.commit_to` could park through here
-- and produce exactly the misrouting that guard exists to prevent.
--
-- Placement is the whole point: both fire *before* the `coroutine.yield`
-- below, and both fire unconditionally. A refusal sited after the yield
-- would never run in the case it exists for.
function async_public.yield_to_next_tick()
if async_mod._in_commit_scope() then
error("yield_to_next_tick: cannot yield inside pmacs.window.commit_to; " ..
"yield first, then commit")
end
if async_mod._in_dispatch_name_scope() then
error("yield_to_next_tick: cannot yield inside pmacs.workers.dispatch; " ..
"yield first, then dispatch")
end
coroutine.yield({ _is_pmacs_next_tick = true })
end
@ -366,14 +409,85 @@ local handlers = {
end,
}
-- Worker identity Stage 1 (Q#W-2): `name` used to die here.
--
-- The audit's "every third-party job renders under a builtin's label" is
-- exact, and the reason is this function: the handler is arbitrary Lua,
-- nothing below it takes a name, and a handler that reaches straight for
-- `pmacs._async._dispatch_*` bypasses the wrapper layer entirely. So the
-- name is pushed onto a runtime-owned stack for the dynamic extent of
-- the handler call and read at `allocate`, the single funnel every job
-- passes through. Seven rules govern it; five are visible here:
--
-- 1. The extent is NON-YIELDABLE, and that is enforced rather than
-- assumed --- see the refusals in `Handle:await` and
-- `pmacs.async.yield_to_next_tick`.
-- 3. Nesting is a stack; innermost wins.
-- 4. Fan-out shares the name: five jobs dispatched by one handler are
-- five jobs named alike. They *were* all dispatched under it.
-- 5. UNWIND-SAFE, and this is the one that makes a naive version worse
-- than none. A handler that raises must still pop --- otherwise one
-- failure poisons every subsequent dispatch in the session with a
-- stale name, and the feature starts lying silently instead of
-- failing loudly. Hence pcall, pop, rethrow.
-- 7. Outside any extent nothing changes: a builtin invoked directly
-- records its own purpose.
--
-- Rule 2 (work dispatched later, from an `on_complete` callback or a
-- resumed coroutine, is deliberately NOT covered) and rule 6
-- (composition, `"<name>: <purpose>"`) live on the Rust side.
--
-- The pop/rethrow half, hoisted so it is written once and allocates
-- nothing per dispatch.
--
-- Varargs across a function boundary, NOT `local ok, result = pcall(…)`:
-- this function used to be `return handler(args, opts)`, which
-- propagates EVERY return value, and bracketing it must not silently
-- truncate a handler that returns more than one. `table.pack` /
-- `table.unpack` would say the same thing but are Lua 5.2 surface, and
-- LuaJIT is this project's default backend (`Cargo.toml`:
-- `default = ["luajit"]`).
local function finish_dispatch(ok, ...)
async_mod._pop_dispatch_name()
if not ok then
-- Level 0: the handler's error travels unchanged. R45's structured
-- errors are tables, and a re-raise that appended position info
-- would corrupt a plain-string error and be silently ignored for a
-- table one --- so neither shape is served by the default level.
error((...), 0)
end
return ...
end
function pmacs.workers.dispatch(name, args, opts)
local handler = handlers[name]
if handler == nil then
error("pmacs.workers.dispatch: unknown handler '" .. tostring(name) .. "'")
end
return handler(args, opts)
async_mod._push_dispatch_name(name)
return finish_dispatch(pcall(handler, args, opts))
end
-- Worker identity Stage 1: the name registered here is DISPLAY TEXT.
--
-- It used to be type-checked and nothing more, which was defensible
-- while it died inside `dispatch`. It no longer dies there: the ambient
-- carries it into every job the handler allocates, and it is composed
-- into `purpose` as `"<name>: <purpose>"`, which the `*workers*` table
-- and the modeline indicator both render. So it gets the same
-- meaningful-value standard `purpose` already gets in
-- `required_purpose` (`src/lua_bindings/mod.rs`) --- and one rule
-- `purpose` deliberately does NOT get.
--
-- The asymmetry is the point. A purpose may legitimately contain a
-- newline: a filesystem path can, and `pmacs-magit`'s spawn purpose is a
-- whole argv --- so its one-line constraint is enforced by ESCAPING at
-- the surfaces that have one row (`purpose_for_one_row`), following the
-- `#228` decision on `Command.description`. A registered handler NAME
-- has no such case. It is an identifier a package chooses for itself and
-- passes back to `dispatch`, so a control character in it is a mistake
-- or an attempt at one, and refusing at the source costs nobody
-- anything.
function pmacs.workers.register(name, handler)
-- Allows future Rust-side modules (or test harnesses) to register
-- additional dispatchable names. v0.1 has no plugin loader but the
@ -381,6 +495,20 @@ function pmacs.workers.register(name, handler)
if type(name) ~= "string" then
error("pmacs.workers.register: name must be a string")
end
-- Empty and whitespace-only satisfy the type and say nothing --- the
-- exact pair `required_purpose` rejects, and the exact pair R42
-- rejects for config descriptions.
if name:match("^%s*$") ~= nil then
error("pmacs.workers.register: name must not be empty or whitespace-only")
end
-- `%c` is the C control class: NUL, the C0 range, DEL. A newline
-- forges a row in `*workers*`, a CR rewrites one on a terminal and an
-- ESC starts a sequence in one. Checked AFTER the whitespace rule so
-- a name that is only "\n" reports the emptier problem, which is the
-- one the caller can act on.
if name:find("%c") ~= nil then
error("pmacs.workers.register: name must not contain control characters")
end
if type(handler) ~= "function" then
error("pmacs.workers.register: handler must be a function")
end
@ -581,6 +709,60 @@ function pmacs._async.tick()
end
end
-- ---------------------------------------------------------------------------
-- Statusline activity indicator (worker identity Stage 1, Q#W-3/Q#W-6).
-- ---------------------------------------------------------------------------
--
-- `COHERENCE.md` §9 records that no progress indicator exists anywhere
-- --- no spinner, no busy count --- which makes §3's promise of "visible
-- asynchronous work" false unless the user knows to run
-- `M-x editor.list-workers`. This is the fourth `pmacs.statusline.register`
-- adopter (after `mode`, `terminal` and `lsp`) and the first thing that
-- makes background work visible without a command.
--
-- No wire change: `pmacs.statusline.register` rides the existing
-- `StatuslineSegments` vector, so a fourth provider adds an ELEMENT, not
-- a variant. That is what lets this lane run beside the two holding the
-- protocol-bump slot.
-- A visibility toggle, and only that (Q#W-6). A permanently-visible
-- statusline element is different in kind from an internal behaviour: it
-- costs modeline width on every frame, and "I do not want this in my
-- modeline" is a preference someone genuinely holds on day one. There is
-- deliberately NO setting for purpose capture itself --- that is
-- substrate, not preference.
pmacs.config.define {
name = "ui.activity-indicator",
description = "Show a modeline count of in-flight background jobs, with the oldest job's purpose. Absent entirely when nothing is running.",
type = "boolean",
default = true,
mutability = "live",
}
pmacs.statusline.register {
name = "activity",
side = "right",
-- Above `terminal` (10) and `lsp` (0): when the modeline is too narrow
-- for everything, "the editor is busy, on this" is the segment worth
-- keeping. Right-side display order is priority-ascending, so it also
-- lands nearest the protected cursor/scroll group.
priority = 20,
face = "ui.modeline.activity",
fn = function(_ctx)
if pmacs.config.get("ui.activity-indicator") ~= true then return nil end
-- `_activity_summary` rather than `pmacs.workers.snapshot()`: this
-- runs once per visible window per frame, and a snapshot would clone
-- the whole 64-entry completed ring that the indicator never reads.
local summary = async_mod._activity_summary()
-- nil, not "" and not "0 jobs": the evaluator treats an empty string
-- as "no segment" too, but a zero-count string would be a segment
-- that costs width forever to say nothing is happening. Absence is
-- the design (Q#W-3), so absence is what this returns.
if summary == nil then return nil end
return "" .. tostring(summary.in_flight) .. " " .. summary.purpose
end,
}
-- Diagnostic / test helpers: number of parked coroutines, number of
-- pending Rust-side jobs. Used by Rust integration tests to drive the
-- runtime to quiescence.

View File

@ -875,6 +875,11 @@ local function start_run(slot, cmdline, opts)
-- stdin, own process group, TERM=dumb.
local spec = {
label = slot.label,
-- Worker identity Stage 1: the label distinguishes one compile slot
-- from another; the purpose is the command the user actually asked
-- for, which is what they want to see when they wonder why the
-- editor is busy.
purpose = "compiling: " .. cmdline,
command = "/bin/sh",
args = { "-c", "exec 2>&1; " .. cmdline },
env = { TERM = "dumb" },

View File

@ -494,10 +494,14 @@ local function start_probe(root)
-- "lake": a user pointing `command` at an absolute path to lake should
-- have THAT probed, not whatever `lake` resolves to on PATH.
local spec = {
-- COHERENCE §9: `ProcessSpec.label` is the only identity a process
-- carries, and it is what `pmacs.process.list` renders. A user
-- wondering why their editor touched `lake` finds an owner here.
-- COHERENCE §9: `ProcessSpec.label` identifies the process, and it
-- is what `pmacs.process.list` renders alongside the purpose. A user
-- wondering why their editor touched `lake` finds it here.
label = "lean:lake-version-probe",
-- Worker identity Stage 1: the label was carrying both jobs — the
-- identity AND the explanation — which is the conflation the purpose
-- field exists to undo. The label stays a key; this is the sentence.
purpose = "checking the Lean toolchain version before starting a server",
command = cfg.command,
args = { "--version" },
stdin = "null",

View File

@ -265,14 +265,32 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and
durable facts are in the retired registry row and the handoff §6
census.
## Destination capture (Q#JR14 generalization) — revision 9 IMPLEMENTED, gate green, no PR yet
## 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**. No PR — the lane was told not to open one.
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
@ -642,6 +660,405 @@ authoritative tip** — the ref, not a SHA. Recover with
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
@ -979,8 +1396,6 @@ 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)
||||||| parent of ac1d6cc (docs: frame a general destination capture (revision 1))
## QoL arc retirement — PR #224 OPEN (docs only)

View File

@ -2598,11 +2598,30 @@ cannot advertise 21 without stranding existing v20 clients before
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
`InitialTargetResult`; v21 reserves the panel frame/event family;
v22 = `LineWrapFacts`; v23 = `MinibufferPromptRows`. New wire
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
must be guarded by a byte pin on the PREVIOUS final variant — its own
round-trip cannot detect a discriminant shift.
**A SUPERSEDED variant can be frozen rather than widened, and v23 is the
first case.** Discovery Stage 2 needed richer minibuffer rows.
Widening `MinibufferPrompt` in place was not an option — postcard
encodes fields positionally, so every v12v22 peer would **mis-decode**
the bytes rather than ignore them — and gating the widened form at
`>= 23` would have left those peers with **no minibuffer message at
all**, because there would have been only one variant to gate.
Compatibility requires the old shape to still exist *and still be sent*.
So `MinibufferPrompt` is retained unchanged for `12..=22`,
`MinibufferPromptRows` is appended for `>= 23`, and the daemon gate is a
**range on both sides** so exactly one variant reaches any peer.
Two consequences worth carrying forward: a frozen variant needs a
**literal byte fixture** (`assert_eq!(encoded, LEGACY_BYTES)`), because a
round-trip encodes and decodes with the same types and so freezes
nothing; and the CLOSE message must use the same variant family as the
OPEN, or a session closed by the other family's clear leaves its surface
on screen forever.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
`sighelp`. Use these for capability-matrix tests, not real servers.

View File

@ -496,30 +496,108 @@ Stage 4; the lane touches no `pmacs-gpu` code at all.
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
| **status** | **new incident, unreproduced — causal status UNRESOLVED** |
| **what IS established** | one occurrence at `pmacs-gpu/src/attach.rs:1680`; the test drives a scripted transient-then-success sequence over a real socket pair |
| **status** | **THIRD OCCURRENCE 2026-08-09 — causal status still UNRESOLVED, but one candidate mechanism is now EXCLUDED** |
| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below |
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
| **rerun evidence** | 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Per the rerun rule this establishes **intermittence only** |
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion |
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
**Not attributed to this lane**, and the reasoning is not merely "my
diff looks unrelated": Stage 4 adds no wire surface, no protocol
version change, and touches no file in `pmacs-gpu`. A merge-base
control would settle it if this recurs.
**Not attributed to the observing lane**, and in neither case is the
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
wire surface, no protocol version change, and touched no file in
`pmacs-gpu`.
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence
**Second occurrence — worker identity Stage 1, 2026-08-09, local
(Linux).** Recorded at the `scripts/gate` **`gpu` step**
(`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`), which is a **third
flavor**: not the `--features crdt` sweep of occurrence 1, and not U3's
default-features workspace sweep. Two things make it a match rather than
a `U` note:
Has a selector, which U1 lacks — but still no fragments, so it cannot
be matched either. Recorded so a recurrence is recognisable.
* **The fragments were captured this time.** `transient sequence must
attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message:
"Broken pipe" })))` — all three of the row's required fragments,
verified against the durable gate log rather than a filtered live
stream. **That is what U2 and U3 both lost**, and it is why U3 could
not be judged a recurrence. Reading the gate's own `NN-gpu.log` is the
mechanical fix U3 prescribed, and it worked.
* **The merge-base control R7 asked for was run** — 15 runs at `4bc55e8`,
green. It is **non-discriminating**, not exculpatory: the observing
branch was equally green over 30 runs, so neither side reproduced and
the control separates nothing. Recorded as a null result rather than
as evidence.
**One causal path is NOT excluded and is named here rather than
dismissed.** The observing lane added a test to `pmacs-gpu`'s test module
(`main.rs`) — a GPU-heavy `render_offscreen` case. It touches no
`attach.rs`, no protocol, and no wire, but it does add a concurrent test
to the same binary, and the failing test is a socket handshake with a
one-second deadline. Contention is a plausible mechanism for a
`BrokenPipe`, and 30 green runs do not rule it out. If a third occurrence
lands, **run the control with the added test removed** rather than at the
merge base — that is the discriminating comparison this one was not.
**Third occurrence — worker identity Stage 1 review round 2,
2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all
three fragments verified** against the durable gate log
(`20260809T172606Z-1387979/11-gpu.log`): `transient sequence must
attach: Attach(Handshake(Io(Os { code: 32, kind: BrokenPipe, message:
"Broken pipe" })))`. A match on this file's own rule, not a `U` note.
**The control the second-occurrence note prescribed was run, and this
time it discriminated — against the hypothesis.** Ten full
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` runs with the added
`render_offscreen` test present: **10/10 green**. Ten more with that
test `#[ignore]`d, changing nothing else: **1 failure in 10**, carrying
all three required fragments
(`without/run-6.log`, `pmacs-gpu/src/attach.rs:1680`).
So the concurrent-GPU-test path named above is **excluded**: removing
the suspect made the failure *more* frequent, not less, which no
contention story from that test survives. What the run does establish is
that **the failure reproduces on demand at roughly 1-in-10 under
ordinary `-p pmacs-gpu` load** — the first time any rerun in this row's
history has reproduced it at all. That is a materially better starting
point than three isolated sightings, and it is the fact a diagnosis
should be built on: the rate makes a bisect of `attach.rs`'s handshake
path affordable, where before it was not.
**It is still not attributed to the observing lane**, and now for a
measured reason rather than an argument from diff shape: the arm without
the lane's only `pmacs-gpu` addition is the arm that went red.
**What would retire it is unchanged** — the mechanism, not the rate.
The next agent to touch this row should reproduce at 1-in-10 and
instrument which side closes the pipe, rather than re-running for green.
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, THIRD known occurrence
**Corrected 2026-08-09 after review.** A previous edit of this row
called the 2026-08-09 failure the *second* occurrence and claimed it
captured the fragment for the first time. **Both were wrong**, and the
evidence was already in this repository:
`docs/active-work.md` records a **2026-08-06** loaded `--features crdt`
run failing this selector *and* `m6_1_pty_canonical_mode_keeps_kernel_echo`
with the same `stty -a output was: ""`, and it already proposed a
mechanism family — **read-before-write on the child's output**, the
shape of **R4** (readiness predicate satisfied by an empty file) and
**R6** (readiness file never published).
So the fragment was captured before, under another feature flavor, and
this row's earlier "no mechanism has been proposed" was false of the
tree it was written in.
| field | value |
|---|---|
| **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` |
| **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus |
| **required fragments** | **none captured** — output was filtered to the `FAILED` line |
| **status** | **new incident, unreproduced** |
| **what IS established** | it failed once (`1916 passed; 1 failed`), in no registry row, under a full-corpus run |
| **what is NOT** | any mechanism. Not reproduced in a later full `--tests --no-fail-fast` sweep (108 targets, exit 0) nor in 3 isolated `--lib` runs (1917/0 each) |
| **required fragments** | `panicked at src/process.rs:3953` · `raw mode should disable echo; stty -a output was: ""` |
| **status** | **at least three occurrences, load-correlated; the diff is EXCLUDED on the 2026-08-09 one** |
| **what IS established** | **Three occurrences.** **(1)** the original: failed once (`1916 passed; 1 failed`) under a full-corpus `--tests --no-fail-fast` run, fragments not captured. **(2) 2026-08-06**, loaded `--features crdt`: this selector **and** `m6_1_pty_canonical_mode_keeps_kernel_echo` both failed with the same `stty -a output was: ""` — the first capture, and the occurrence that proposed the read-before-write family. **(3) 2026-08-09**, worker-identity tip: `1919 passed; 1 failed` in `scripts/gate` step `03-lib` at load ~21, and **the tree contained ZERO code change since a 13/13 green run on the same lane** — the only delta was three lines of `docs/active-work.md`. A markdown edit cannot break a PTY test, so the change under test is ruled out as a cause rather than merely doubted. Passes isolated (`1 passed`, 0.01s). **Occurrence 2 is the one that matters most**: it shows the failure is not confined to one feature flavor and can take both selectors at once |
| **what the fragment ACTUALLY shows** | **The supervisor collected empty stdout**`drain_until` then `collect_stdout(&evs)` (`src/process.rs:3948-3951`); the assertion inspects that string. It does **NOT** establish that `stty` emitted nothing: the bytes could have been lost in PTY delivery or in event collection. An earlier edit of this row said "`stty` produced no output at all", which asserts a mechanism the test cannot see. What is true is narrower and still useful: this is not a *termios* failure — nothing shows echo being configured wrongly — but which of {child never wrote, PTY dropped it, collection missed it} is open. The assertion's message invites the wrong reading, since it prints an empty string as though it were `stty`'s answer |
| **what is NOT** | **No mechanism is ESTABLISHED** — one is *proposed*: read-before-write on the child's output, the R4/R6 readiness family (occurrence 2). Proposed is not confirmed, and nothing here discriminates it from PTY delivery or event-collection loss. Not reproduced in a later full sweep (108 targets, exit 0), nor in 3 isolated `--lib` runs (1917/0 each), nor in the isolated rerun after occurrence 3. **Three occurrences establish intermittence and a load correlation; none establishes cause** |
| **discriminating control for the next occurrence** | capture the **full process event stream and the child's exit disposition**, not only the collected string — that is what separates "child never wrote" from "delivery or collection lost it", and the collected string cannot distinguish them however many times it is sampled. Cross-check against R4/R6's readiness family, which `docs/active-work.md`'s 2026-08-06 entry already implicates |
| **cross-reference** | `docs/active-work.md` — 2026-08-06 occurrence, `--features crdt`, **both** the raw and canonical selectors, same fragment, read-before-write hypothesis |
| **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red |
### U3 — the R7 selector again, fragments lost the same way U2's were
@ -552,6 +630,54 @@ it again here by piping a sweep through `grep`. The fix is mechanical:
stream. A signature that is cheap to capture and impossible to
reconstruct should never be traded for terminal brevity.
*(Renumbered from U4/U5 to **U6/U7** on the rebase onto `0857bf4`: `gate-protocol-build` landed its own U4/U5 in #229, and git merged both files **without a conflict**, producing duplicate ids across four sites. The pre-rebase warning is retired here because it has been carried out.)*
### U6 — two wall-clock budget tests fail together in one `lib-crdt` step
Recorded during worker identity Stage 1 review round 2, 2026-08-09, in
the same gate run that produced R7's third occurrence. **Fragments were
captured**, so unlike U1U3 this one is matchable — it is a `U` row
because it has one occurrence and no mechanism, not because the evidence
was lost.
| field | value |
|---|---|
| **selector** | `--lib --features crdt optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` **and** `editor::tests::composition_overhead_under_ten_percent`, failing in the same run |
| **job / flavor** | local (Linux), `scripts/gate` step `04-lib-crdt`, with sibling worktrees building concurrently |
| **required fragments** | `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms`; and `composition machinery added more than 10% overhead` |
| **status** | **new incident, one occurrence, not reproduced** |
| **what IS established** | both are **wall-clock budget assertions** — 1.264ms against a 1ms budget, and 1.297× against a 1.10× budget — so both are load-sensitive by construction. Both green in an isolated rerun of exactly those two selectors, and both green in the next full gate run of the same command (2105 passed) |
| **what is NOT** | whether the machine's concurrent load caused it. The confound is real (this machine runs one shared `CARGO_TARGET_DIR` and several worktrees) but **was not measured**, so it is a rival explanation, not a finding |
| **rival explanation not excluded** | a genuine regression in either path. Nothing in the observing diff touches the optimistic-echo orchestrator or the composition pipeline, but "my diff looks unrelated" is not evidence, and this row does not treat it as such |
**Two budget tests failing in one run and neither in the next is the
signature worth matching**, more than either name alone: a real
regression in two unrelated subsystems at once is far less likely than
one loaded machine. If a future run reds **one** of these without the
other, that is a different incident and should be judged as one.
### U7 — a *different* wall-clock render-budget test reds each sweep
Recorded during worker identity Stage 1 review round 3, 2026-08-09.
**Two consecutive `scripts/gate` runs of the same command, on the same
tree, red on step `12-sweep` with a different test each time** — which
is the signature, and it is a stronger one than any single selector.
| field | value |
|---|---|
| **selector** | run 1: `--test m8_2_acceptance dired_open_renders_10k_entries_under_200ms` **and** `--test m8_9_acceptance outline_5_level_100_entry_renders_within_100ms`; run 2: `--test dired_acceptance dired_renders_10k_entries_within_200ms` |
| **job / flavor** | local (Linux), `scripts/gate` step `12-sweep` (`cargo test --workspace --no-fail-fast`), **load average 12.9 / 23.9** with sibling worktrees building concurrently |
| **required fragments** | `must render within 200ms; took ` / `open() (parse + render) took ` + `spec budget is 100ms` |
| **status** | **new incident, three selectors, none reproduced** |
| **what IS established** | all three are **wall-clock render-budget assertions** (224ms and 258ms against a 200ms budget; 114ms against a 100ms budget), so all three are load-sensitive by construction. Each was green in an isolated rerun of its own selector, no selector reds twice, and **the third run of the same command on the same tree was green on all 13 steps** (log `20260809T200907Z-2672209`). The observing diff is **two string literals, their doc comments and one test** — it touches no render path at all, and cannot |
| **what is NOT** | that load caused it. The one-shared-`CARGO_TARGET_DIR` confound is real and again **unmeasured**, so it stays a rival explanation rather than a finding |
| **relation to U6** | same shape, different step and different tests: U6 is two budget tests in `04-lib-crdt` failing **together**; this is three render-budget tests in `12-sweep` failing **one per run**. Kept separate rather than merged, because merging would assert a shared mechanism nothing here shows |
**The rotating selector is the thing to match.** A regression that
moved between three unrelated render paths on an unchanged tree is far
less likely than one loaded machine; a future run that reds the *same*
one of these twice is a different incident and should be judged as one.
**The retirements are not occurrences and do not close the log.** R1 and
R3 stay live, and each retired row keeps its signature so a later red
matching one reopens it.
@ -567,18 +693,40 @@ not caused by the PRs they appeared on — that PR is **docs-only and its
tree is byte-identical to a green `main`**. It is not evidence that any
of them is harmless.
### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS `lua54`, one occurrence
### U4 — `a_pty_resize_blanks_the_host_before_repainting`, macOS **both flavours**, three occurrences
Surfaced on PR #229's CI.
Surfaced on PR #229's CI; twice more on PR #231's.
**The `lua54` in this row's original title was wrong as a signature
component, and matching on it would have missed two occurrences.** The
row was filed from #229's single `lua54` red and recorded the flavour in
the matching key. #231 then reddened the identical selector with the
identical three fragments **twice on `luajit`** — so flavour is not part
of this signature, and the row's own caution that "a deterministic
defect *can* be Lua-flavour-specific" is now settled in the other
direction: this one is not. Occurrence-keyed by suffix length, the three
are `25 362` (#229, `lua54`), `25 222` (#231 attempt 1, `luajit`) and
`25 054` (#231 attempt 2, `luajit`).
**A fourth sighting of these fragments was NOT an occurrence and must
not be counted as one.** It came from a deliberate bite during this
test's own development — the defect reintroduced on purpose (`consumer
ignores full_grid`), 34 831 bytes, failing in 20.09 s. It earns its
place here for what it proves instead: **the genuine defect and these
CI reds are signature-indistinguishable**, same message class and same
full-timeout duration, so the fragments alone can never tell a real
resync failure from whatever this is.
| field | value |
|---|---|
| **selector** | `--test full_grid_resync_acceptance a_pty_resize_blanks_the_host_before_repainting` |
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)`, `macos-26-arm64` |
| **job / flavor** | GitHub Actions, `Test (macos-latest / lua54)` **and** `Test (macos-latest / luajit)`, `macos-26-arm64`. **Flavour is not a matching key for this row** |
| **required fragments** | `FG-INV: the post-resize resync must blank the host` · `no CSI 2 J appeared in the` · `bytes emitted after the first painted frame` |
| **NOT fragments** | the byte count and the `:LINE` suffix are **occurrence-specific** and must not be matched on — the count is the collected suffix length, which varies per run, and the line moves with the file |
| **status** | **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 |
| **status** | **three occurrences on two branches; INTERMITTENT on #229 (passed on rerun), NOT observed to pass on #231 (0/2)** |
| **the #231 control experiment, and what it does and does not license** | Five valid observations at #231's exact base `0190102``run_attempt` 1, 2, 3, 4 and 6 — **all green on both macOS flavours**, against #231's 0/2. Under an equal-rate model the chance both failures land on the two branch runs is 1/C(7,2) = **4.8%**. Two things bound that number. First, **attempt 5 was discarded** because it reddened a *different* selector (U8) — so the base leg is 5/5 green *for this signature* and 5/6 overall, and "the base never fails" is not what was observed. Second, three unrelated macOS selectors reddening in one session is **a background platform failure rate**, and the equal-rate model the 4.8% assumes is exactly what such a rate violates. **The branch side was never resampled**: 5-vs-2 is an asymmetric experiment, and rerunning #231's failing job three more times at `4654b94` was the outstanding discriminator when it merged |
| **why #231's diff is excluded** | grepping its **entire** `src/` diff for `full_grid\|resize\|resync\|Geometry\|reconcile_panel_layout` matches **one import line** and nothing else; all 721 changed lines are placement, dedication and commit-contract logic. From the other side, `full_grid_resync_acceptance` (191 lines) contains no panel, side-window, dedication, display or directory surface — grep for those matches only a comment about CSI 2 J. #231 merged on this reading **over** the statistical signal above, which is a judgement recorded here so that a fourth occurrence can revisit it rather than re-derive it |
| **why #229's diff is excluded** | #229 changes only `scripts/gate`, `tests/gate_script_acceptance.rs` and documentation — **no `src/`, and the workflow never invokes `scripts/gate`**. Decisively, `full_grid_resync_acceptance` runs **before** the changed gate suite, so even a cross-suite leaked-state path is not available. The `luajit` leg passing on the same commit is **corroboration only** — a deterministic defect *can* be Lua-flavour-specific, so that observation must not be used as a structural exclusion |
| **what IS established** | **no blank was OBSERVED after the mark** within the test's fixed 20-second deadline. The collected suffix was the **entire** post-mark output (`suffix.len()`, 25 362 bytes on this occurrence — not a capped window; only the *displayed* head is truncated to 400 bytes), and that head shows ordinary repaint traffic (`ZQXMARKERQZ` rows with SGR + CUP), so the host was painting |
| **what is NOT** | any mechanism. Whether the blank was never emitted, emitted after the deadline, or lost in transport is **open** — and "it never emitted the blank" is a claim this evidence does not support. **The failing run's ~20 s duration is the fixed `Duration::from_secs(20)` timeout**, so the spread against a fast passing run is mechanically determined and is **not** independent timing evidence |
| **discriminating control — ASYMMETRIC, and only one direction concludes** | the suffix is already complete, so "capture more bytes" is not the gap — arrival time is. Extending the deadline and recording whether `CLEAR_ALL` arrives, and at what offset: **if it arrives, "emitted late" is established.** **If it does not, that establishes only "not observed by the longer deadline"***not* "never emitted", because transport loss produces the same absence. Separating non-emission from transport loss needs **producer-side emission evidence** (did pmacs write the clear?) cross-checked against the collected stream; no deadline, however long, can do it alone |

View File

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

View File

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

View File

@ -44,9 +44,10 @@ use pmacs_protocol::{
CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId,
InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode,
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES,
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, MouseButton as ProtocolMouseButton,
MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment,
StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle,
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers,
MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind,
SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame,
UnderlineStyle,
cell::{Color as CellColor, Style as CellStyle},
is_builtin_pair_char, is_modeline_face_name,
panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload},
@ -2259,16 +2260,25 @@ struct SearchPromptLocal {
invalid: bool,
}
/// The live minibuffer (Q#MB1, protocol v12), mirrored from a
/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw
/// in the bottom band with a caret; `candidates` (a windowed slice) feed
/// the dropdown.
/// The live minibuffer (Q#MB1, protocol v12), mirrored from whichever
/// minibuffer variant this session's negotiated version carries, when
/// its `prompt` was `Some`. The prompt+input draw in the bottom band
/// with a caret; `rows` (a windowed slice) feed the dropdown.
///
/// **One local shape for two wire variants.** A `>= 23` daemon sends
/// `MinibufferPromptRows` with per-row details; a `12..=22` daemon sends
/// the frozen `MinibufferPrompt` with bare strings, which land here as
/// rows whose `detail` is `None`. Both are live: this binary offers its
/// own `PROTOCOL_VERSION` only when the daemon advertises the current
/// baseline, and echoes an older baseline verbatim — so an older daemon
/// still negotiates an older session, and the legacy arm is reachable
/// rather than dead code.
#[derive(Clone, Debug, PartialEq)]
struct MinibufferLocal {
prompt: String,
input: String,
cursor: u32,
candidates: Vec<String>,
rows: Vec<MinibufferRow>,
selected: Option<u32>,
total: u32,
}
@ -5085,7 +5095,10 @@ impl State {
None
}
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
// None` closes it.
// None` closes it. This is the FROZEN legacy variant, which
// only a `12..=22` daemon sends; its candidates carry no
// detail, so they become rows with `detail: None` and render
// exactly as they did before v23.
InstanceMessage::MinibufferPrompt {
prompt,
input,
@ -5098,7 +5111,38 @@ impl State {
prompt,
input,
cursor,
candidates,
rows: candidates
.into_iter()
.map(|label| MinibufferRow {
label,
detail: None,
})
.collect(),
selected,
total,
});
self.request_redraw();
None
}
// Discovery Stage 2 — the v23 rows form of the same surface,
// carrying an optional per-row detail (a command's
// description). `prompt: None` closes it, and the close
// arrives in THIS family because the daemon picks the family
// per peer: a rows session closed by a legacy clear would
// leave the dropdown on screen forever.
InstanceMessage::MinibufferPromptRows {
prompt,
input,
cursor,
rows,
selected,
total,
} => {
self.minibuffer = prompt.map(|prompt| MinibufferLocal {
prompt,
input,
cursor,
rows,
selected,
total,
});
@ -7619,11 +7663,22 @@ impl State {
/// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per
/// candidate, best match first. Empty when there are no candidates.
///
/// Discovery Stage 2: a row with a `detail` renders `label detail`,
/// the same two-space form the completion dropdown already uses. A
/// row without one renders the bare label, so a file-path or
/// buffer-name prompt looks exactly as it did before v23.
fn refresh_mb_buffer(&mut self) {
let text = self
.minibuffer
.as_ref()
.map_or_else(String::new, |mb| mb.candidates.join("\n"));
let text = self.minibuffer.as_ref().map_or_else(String::new, |mb| {
mb.rows
.iter()
.map(|row| match row.detail.as_deref() {
Some(detail) => format!("{} {detail}", row.label),
None => row.label.clone(),
})
.collect::<Vec<_>>()
.join("\n")
});
let family = self.resolved_family.clone();
self.mb_buffer.set_text(
&mut self.font_system,
@ -7644,7 +7699,7 @@ impl State {
let mb = self.minibuffer.as_ref()?;
let band_top = status_band_top(self.config.height, self.fm);
mb_dropdown_window(
mb.candidates.len(),
mb.rows.len(),
mb.selected.map_or(0, |s| s as usize),
band_top,
self.fm,
@ -10868,6 +10923,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
InstanceMessage::MinibufferPromptRows { .. } => "MinibufferPromptRows",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
@ -13766,6 +13822,22 @@ mod tests {
// They skip (not fail) when no wgpu adapter is available — a dev box
// without working Vulkan, or CI without lavapipe.
/// Detail-free minibuffer rows from bare labels — what a `12..=22`
/// daemon's frozen `MinibufferPrompt` lands as.
fn detailless_rows<I, S>(labels: I) -> Vec<MinibufferRow>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
labels
.into_iter()
.map(|label| MinibufferRow {
label: label.into(),
detail: None,
})
.collect()
}
/// Build a headless `State`, or return `None` and log when there's no
/// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set
/// (CI, where lavapipe is installed) a missing adapter is a hard
@ -14867,6 +14939,95 @@ mod tests {
assert_eq!(after[2].1, Color::rgb(20, 220, 40));
}
/// Worker identity Stage 1 (`docs/worker-identity-framing.md` §6):
/// the GPU half of "both frontends render the segment".
///
/// The activity indicator adds no wire message — it rides the
/// existing `StatuslineSegments` vector as a fourth provider's
/// element. But that is a claim about the **producer**, and says
/// nothing about whether a consumer draws it, which is why this
/// exists on the consumer side.
///
/// Two properties specific to this segment, neither of which the
/// existing rich-runs test covers:
///
/// * its face (`ui.modeline.activity`) is **deliberately absent
/// from `ThemeFacts`** — no theme sets it, and `theme_facts_msg`
/// ships only faces that resolve — so a consumer that dropped
/// segments with an unknown face would silently lose the one
/// thing telling the user the editor is busy;
/// * its text leads with a non-ASCII `⋯`, which a byte-oriented
/// composition step would mangle.
#[test]
fn the_activity_segment_survives_an_unthemed_face_and_a_non_ascii_lead() {
let Some(mut state) = headless_or_skip(500, 280, "text") else {
return;
};
let buffer_id = BufferId::next();
state.current_buffer_id = Some(buffer_id);
state.status_facts = Some(status_facts(buffer_id, None));
state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 });
// One themed face, and NOT the activity one: the point is that
// the theme has an opinion about some segments and none about
// this one.
apply_faces(
&mut state,
vec![theme_face(
"ui.modeline.lsp",
CellStyle {
fg: CellColor::Rgb(20, 220, 40),
..CellStyle::default()
},
)],
);
apply_statusline(
&mut state,
buffer_id,
Vec::new(),
vec![
statusline_segment("LSP:rust", "ui.modeline.lsp"),
statusline_segment("⋯2 lsp textDocument/definition", "ui.modeline.activity"),
],
);
let right = state.compose_status_runs();
let text: String = right.iter().map(|(text, _)| text.as_str()).collect();
assert!(
text.contains("⋯2 lsp textDocument/definition"),
"the activity segment must reach the composed right runs \
intact: {text:?}"
);
let activity = right
.iter()
.find(|(run, _)| run.contains('⋯'))
.expect("activity run");
assert_eq!(
activity.1,
state.status_right_base_color(),
"an unthemed modeline face falls back to the base colour \
rather than dropping the segment"
);
assert_eq!(
right[0].1,
Color::rgb(20, 220, 40),
"and its themed neighbour still takes its own colour"
);
// And it survives the real shaping pass, not only composition.
let _ = state.render_offscreen();
let shaped: String = state
.status_runs
.as_ref()
.expect("right shaped")
.iter()
.map(|(text, _)| text.as_str())
.collect();
assert!(
shaped.contains("⋯2 lsp textDocument/definition"),
"{shaped:?}"
);
}
#[test]
fn modal_left_precedence_suppresses_custom_left_but_preserves_right() {
let Some(mut state) = headless_or_skip(420, 260, "text") else {
@ -14888,7 +15049,7 @@ mod tests {
prompt: "M-x ".to_owned(),
input: "find".to_owned(),
cursor: 4,
candidates: Vec::new(),
rows: Vec::new(),
selected: None,
total: 0,
});
@ -15291,7 +15452,7 @@ mod tests {
prompt: "M-x ".into(),
input: "theme".into(),
cursor: 5,
candidates: Vec::new(),
rows: Vec::new(),
selected: None,
total: 0,
});
@ -15335,7 +15496,7 @@ mod tests {
prompt: "M-x ".into(),
input: "the".into(),
cursor: 3,
candidates: vec!["theme-set".into(), "theme-clear".into()],
rows: detailless_rows(["theme-set", "theme-clear"]),
selected: Some(0),
total: 2,
});
@ -15357,6 +15518,112 @@ mod tests {
);
}
/// Discovery Stage 2: a row's `detail` reaches the shaped dropdown
/// line, and BOTH wire families land in the same local shape.
///
/// Driven through `apply_attach_message` rather than by assigning
/// `state.minibuffer` — the mapping from wire variant to local row
/// is exactly what this asserts, so constructing the local value
/// would skip the thing under test. The shaped `layout_runs()` text
/// is what glyphon rasterizes, so a description present there is a
/// description on screen.
#[test]
fn a_minibuffer_row_detail_reaches_the_shaped_dropdown_line() {
let Some(mut state) = headless_or_skip(600, 400, "hello") else {
return;
};
// The v23 rows form: a row with a detail, and a row without.
let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows {
prompt: Some("M-x ".into()),
input: "buf".into(),
cursor: 3,
rows: vec![
MinibufferRow {
label: "buffer.save".into(),
detail: Some("Write the buffer to its file".into()),
},
MinibufferRow {
label: "buffer.kill".into(),
detail: None,
},
],
selected: Some(0),
total: 2,
});
state.refresh_mb_buffer();
let lines: Vec<String> = state
.mb_buffer
.layout_runs()
.map(|run| run.text.to_owned())
.collect();
assert!(
lines
.iter()
.any(|l| l.contains("buffer.save") && l.contains("Write the buffer to its file")),
"the detail must be shaped into the row: {lines:?}"
);
assert_eq!(
lines
.iter()
.find(|l| l.contains("buffer.kill"))
.map(String::as_str),
Some("buffer.kill"),
"a row with no detail renders the bare label, exactly as before v23: {lines:?}"
);
// The geometry invariant the dropdown depends on: it derives
// its height, its visible window and its selection-highlight
// offset from `rows.len()`, so ONE physical line per logical
// row is what keeps those aligned. The daemon clips a detail to
// its first line (`Command::description_first_line`) precisely
// so this holds for an MCP schema block.
assert_eq!(
lines.len(),
state.minibuffer.as_ref().map_or(0, |mb| mb.rows.len()),
"one physical line per candidate row: {lines:?}"
);
// The frozen `12..=22` form, which an older daemon still sends:
// bare strings become detail-free rows.
let _ = state.apply_attach_message(InstanceMessage::MinibufferPrompt {
prompt: Some("M-x ".into()),
input: "buf".into(),
cursor: 3,
candidates: vec!["buffer.save".into()],
selected: Some(0),
total: 1,
});
assert_eq!(
state.minibuffer.as_ref().map(|mb| mb.rows.clone()),
Some(vec![MinibufferRow {
label: "buffer.save".into(),
detail: None,
}]),
"the legacy variant lands as a detail-free row"
);
state.refresh_mb_buffer();
let legacy: Vec<String> = state
.mb_buffer
.layout_runs()
.map(|run| run.text.to_owned())
.collect();
assert_eq!(legacy, vec!["buffer.save".to_owned()]);
// Either family closes the surface with `prompt: None`.
let _ = state.apply_attach_message(InstanceMessage::MinibufferPromptRows {
prompt: None,
input: String::new(),
cursor: 0,
rows: Vec::new(),
selected: None,
total: 0,
});
assert!(
state.minibuffer.is_none(),
"a rows clear closes the surface"
);
}
#[test]
fn headless_diag_face_recolors_band_counter_despite_unchanged_text() {
// Acceptance 22 — the round-1 finding-3 bite. The E: counter
@ -15954,7 +16221,7 @@ mod tests {
prompt: "P: ".into(),
input: String::new(),
cursor: 0,
candidates: vec![long.clone(), long.clone()],
rows: detailless_rows([long.clone(), long.clone()]),
selected: Some(1),
total: 2,
});
@ -16172,7 +16439,7 @@ mod tests {
prompt: "M-x ".into(),
input: String::new(),
cursor: 0,
candidates: (0..30).map(|i| format!("candidate-{i}")).collect(),
rows: detailless_rows((0..30).map(|i| format!("candidate-{i}"))),
selected: Some(1),
total: 30,
});
@ -17095,7 +17362,7 @@ mod tests {
prompt: ":".into(),
input: String::new(),
cursor: 0,
candidates: Vec::new(),
rows: Vec::new(),
selected: None,
total: 0,
});

View File

@ -65,11 +65,12 @@ pub use message::{
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES,
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name,
is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
negotiated_session_version, requested_protocol_version,
};
pub use panel::{
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,

View File

@ -1098,7 +1098,24 @@ pub enum InstanceMessage {
/// v12). The minibuffer is a single *global* core instance, so this
/// is bufferless; the producer still emits it from the active-buffer
/// viewport. `prompt: None` clears the GUI. Cached-compare
/// suppressed like `SearchPrompt`; daemon-gated `>= 12`.
/// suppressed like `SearchPrompt`; daemon-gated `12..=22`.
///
/// # FROZEN — this variant's encoding must not move
///
/// Discovery Stage 2 (v23) needed richer rows, and postcard is not
/// self-describing: enum variants encode by index and fields by
/// position, so widening `candidates` in place would make every
/// v12v22 peer **mis-decode** these bytes rather than ignore them.
/// Gating the widened shape at `>= 23` would not rescue them either
/// — with only one variant to send, they would receive no minibuffer
/// message at all. So the rich form went into a new appended
/// variant, [`Self::MinibufferPromptRows`], and this one is retained
/// unchanged as what a `12..=22` peer receives.
///
/// Its bytes are pinned literally by
/// `minibuffer_prompt_v12_wire_bytes_are_frozen` in
/// `src/protocol.rs` — a round-trip cannot detect a field addition,
/// because both sides simply learn the new shape.
MinibufferPrompt {
/// The prompt string (e.g. `"M-x "`), or `None` when no
/// minibuffer is open.
@ -1299,6 +1316,59 @@ pub enum InstanceMessage {
/// Whether that buffer's long lines wrap.
wrap: bool,
},
/// Discovery Stage 2 (protocol v23): the minibuffer prompt with
/// **structured rows** — a label and an optional one-line detail —
/// instead of bare candidate strings.
///
/// # Why a second variant rather than a wider `MinibufferPrompt`
///
/// `Command.description` already exists and is already rendered by
/// `help.list-commands`; it is missing at the one moment it would
/// change a decision, which is the `M-x` row. Carrying it means
/// widening the minibuffer's candidate shape — and postcard encodes
/// fields **positionally**, so changing `candidates: Vec<String>` in
/// place is a wire break, not an evolution: a v22 peer mis-decodes
/// the bytes rather than skipping them. Gating the changed variant
/// at `>= 23` does not rescue it either, because a `12..=22` peer
/// would then receive no minibuffer message at all. Compatibility
/// requires the old shape to still exist *and still be sent*, so
/// [`Self::MinibufferPrompt`] is frozen and this is appended beside
/// it.
///
/// # Exactly one of the two reaches any peer
///
/// The producer selects on the session's negotiated version and the
/// daemon's write loop gates both directions: `>= 23` receives this
/// and never the legacy variant; `12..=22` receives the legacy
/// variant and never this. Sending both would double-render; sending
/// neither is the bug gating alone would have caused. The close
/// message must use the same family as the open — a rows session
/// closed by a legacy clear leaves a popup on screen forever.
///
/// Otherwise this mirrors [`Self::MinibufferPrompt`] exactly:
/// bufferless (one global core minibuffer), `prompt: None` clears
/// the GUI, cached-compare suppressed, emitted from the
/// active-buffer viewport.
///
/// Appended after [`Self::LineWrapFacts`], the final v22 variant, so
/// no existing postcard discriminant moves.
MinibufferPromptRows {
/// The prompt string (e.g. `"M-x "`), or `None` when no
/// minibuffer is open.
prompt: Option<String>,
/// The text typed so far.
input: String,
/// Codepoints before the cursor within `input` (the caret
/// position).
cursor: u32,
/// A windowed slice of the completion candidates (best-first,
/// already filtered/sorted by the core), `<= MB_VISIBLE`.
rows: Vec<MinibufferRow>,
/// Highlighted row *within* `rows`, or `None`.
selected: Option<u32>,
/// Total candidate count (the window is a slice of this).
total: u32,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1394,6 +1464,35 @@ pub struct CompletionPopupRow {
pub detail: Option<String>,
}
/// One row of the minibuffer's candidate list on the wire
/// ([`InstanceMessage::MinibufferPromptRows`], Discovery Stage 2,
/// protocol v23).
///
/// # Why this is not `CompletionPopupRow`
///
/// Reuse was tempting and is wrong. [`CompletionPopupRow::kind`] is an
/// LSP `CompletionItemKind` code with a documented contract, and an
/// `M-x` command is not an LSP completion item — it has no honest value
/// for that field. Reusing it would mean inventing a fake kind or
/// declaring unknown everywhere: a type whose invariant is "meaningless
/// in half its uses". If a category is wanted later it arrives with
/// `Command.category`, typed as what it actually is rather than
/// borrowed from LSP.
///
/// `detail` is optional **per row** because `pmacs.minibuffer.read`
/// serves many sources — file paths, buffer names, settings — and only
/// some have a natural detail. A source with none leaves it `None` and
/// renders exactly as it did before v23.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MinibufferRow {
/// Display label — the candidate itself, and the value acceptance
/// resolves to.
pub label: String,
/// Optional one-line detail rendered after the label (a command's
/// description, for `M-x`).
pub detail: Option<String>,
}
/// Flat selection state for the wire.
///
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
@ -1731,7 +1830,17 @@ pub enum ResourceBody {
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
/// a side window, because denying only the events would leave its
/// window invisible.
pub const PROTOCOL_VERSION: u32 = 22;
///
/// Discovery Stage 2: bumped 22 → 23 for
/// [`InstanceMessage::MinibufferPromptRows`] — the minibuffer's
/// candidate rows gaining an optional per-row detail. Appended after
/// `LineWrapFacts`, the final v22 variant, so no existing discriminant
/// moves; [`InstanceMessage::MinibufferPrompt`] is retained **frozen**
/// and still sent to `12..=22` peers, because postcard's positional
/// encoding makes an in-place widening a wire break rather than an
/// evolution, and gating the widened form would have left those peers
/// with no minibuffer message at all.
pub const PROTOCOL_VERSION: u32 = 23;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
@ -1905,8 +2014,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
/// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend
/// negotiates v21, never receives the variant, and keeps its own
/// behavior.
///
/// Discovery Stage 2: extended to `[6, ..., 23]` for
/// [`InstanceMessage::MinibufferPromptRows`]. Additive and daemon-gated,
/// and unusually the gate is a **range on both sides**: a `12..=22` peer
/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a
/// `>= 23` peer receives only the rows form, and no peer ever receives
/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
];
/// T M10.5: predicate for the handshake check. Returns `true` if

View File

@ -58,8 +58,10 @@
//! search with cooperative cancellation and frame-boundary coalescing.
//! Tree-sitter and LSP land in M4 on the same dispatch shape.
use std::borrow::Cow;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
@ -408,6 +410,47 @@ struct PendingJob {
/// job→buffer link already lives in a side map and §9 names that as
/// the defect.
resource: Option<ResourceOp>,
/// What this job is doing, in words a user can read (worker
/// identity Stage 1, `COHERENCE.md` §9).
///
/// **Not an owner.** It records *what work* is running and — when
/// the job was born inside a `pmacs.workers.dispatch` extent — the
/// registered handler name it ran under. Neither is the package
/// responsible for it; that slot is deliberately empty until P3 can
/// fill it with a real package signal (framing §3).
///
/// Non-optional by construction: [`JobSpec`] has no `Default`, so a
/// dispatcher that supplies none does not compile.
purpose: String,
}
/// Everything one job is born with.
///
/// **Private, and deliberately so** (framing Q#W-1). The two-function
/// `allocate` / `allocate_with_resource` split existed only because one
/// prior lane needed one extra parameter; a second lane doing the same
/// produces `allocate_with_resource_and_identity`. Collapsing the pair
/// into a struct means the next field is a named literal at each of the
/// eleven construction sites rather than another positional parameter on
/// a public signature.
///
/// **There is no `Default` impl, and that is the point.** `purpose` is
/// what makes the compiler — not a test — the thing that proves every
/// dispatcher supplied one (framing §6). A `Default` would let a new
/// dispatcher write `..Default::default()` and silently ship an empty
/// identity.
struct JobSpec<'a> {
/// Which builtin handler this job runs.
kind: JobKind,
/// Supersede key, if the dispatch opted into supersession.
supersede: Option<&'a str>,
/// `Some(max_batch)` marks this as a streaming dispatch.
stream: Option<usize>,
/// Filesystem mutation this job performs, for the settle-time
/// reconcile (dired Stage 2a).
resource: Option<ResourceOp>,
/// What the job is doing. See [`PendingJob::purpose`].
purpose: String,
}
/// A settled filesystem mutation, with the paths the worker consumed
@ -490,6 +533,9 @@ pub struct ActiveJobInfo {
/// True if this is a streaming dispatch (`emit_n`, `grep`, ...);
/// false if it's request/reply (`sleep`, `compute_sum`).
pub is_stream: bool,
/// What this job is doing (worker identity Stage 1). Rendered by
/// `*workers*` and by the statusline activity indicator.
pub purpose: String,
}
/// One row in the `*workers*` buffer's "completed" section: a job
@ -507,6 +553,8 @@ pub struct CompletedJobInfo {
pub settled_age_ms: u64,
/// Supersede key (if any) the job was dispatched under.
pub supersede_key: Option<String>,
/// What this job was doing (worker identity Stage 1).
pub purpose: String,
/// Terminal outcome. `None` is unreachable here --- only
/// settled jobs land in the completed ring.
pub outcome: JobOutcome,
@ -543,9 +591,105 @@ struct CompletedSlot {
dispatched_at: Instant,
settled_at: Instant,
supersede_key: Option<String>,
purpose: String,
outcome: JobOutcome,
}
/// What the statusline activity indicator needs, and nothing more
/// (framing Q#W-3).
///
/// A dedicated read surface rather than [`WorkersSnapshot`]: the
/// indicator is evaluated once per visible window per frame, and a
/// snapshot clones the whole completed ring (up to
/// [`COMPLETED_RING_CAP`] entries) that the indicator never looks at.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActivitySummary {
/// How many jobs are in flight. Always ≥ 1 — an idle runtime
/// returns `None` rather than a zero count, because a segment that
/// is always present costs modeline width forever to say "nothing
/// is happening".
pub in_flight: usize,
/// The **oldest** in-flight job's purpose, already passed through
/// [`purpose_for_one_row`].
///
/// Oldest, not newest and not "busiest": jobs carry no cost
/// estimate, so "busiest" is not a defined quantity, while oldest
/// is computable from `dispatched_at` and answers the question a
/// user actually asks of a stuck editor.
///
/// Escaped here rather than at the Lua provider because this struct
/// **is** the indicator's read surface — it exists for one consumer,
/// and that consumer has exactly one row. `workers_snapshot` is the
/// free-form path and stays raw.
pub oldest_purpose: String,
}
/// A `purpose` rendered for a surface that gives it exactly **one row**.
///
/// # A row must not be able to forge another row
///
/// That is the property, and it is the only reason this exists. A
/// purpose is free-form text supplied by whoever dispatched the work,
/// and it is legitimately multi-line: a filesystem path may contain a
/// newline, and `pmacs-magit`'s spawn purpose is a whole argv. Rendered
/// raw into a row-per-job table, one such purpose becomes two physical
/// lines — the second of which the reader has no way to tell from a real
/// job row, because a real job row is just text in the same buffer.
/// The same applies to `\r`, which rewrites a rendered line in place on
/// a terminal, and to `\u{1b}`, which starts an escape sequence in one.
///
/// # Escape, do not reject, and do not clip
///
/// This follows the `#228` decision recorded on
/// [`crate::command::Command::description`]: the one-line constraint
/// belongs to the **surface that has it**, not to the registry that does
/// not. There, a free-form description is clipped by
/// `Command::description_first_line` at the two single-row consumers
/// while the registry keeps every line. Here the equivalent is escaping
/// rather than clipping, because a purpose's later lines are not
/// decoration — an argv's second word is as load-bearing as its first,
/// and a clip would silently drop the part that says which file.
///
/// `pmacs.workers.snapshot()` is this lane's `describe-command`: it
/// hands Lua the raw purpose, so nothing is lost, only made safe where
/// a row boundary means something.
///
/// # What is not escaped
///
/// A backslash. Escaping it would make a purpose containing no control
/// characters **not** byte-identical after this call, and byte-identity
/// for ordinary text is a property worth more than distinguishing a
/// literal `\n` from an escaped newline — the ambiguity is cosmetic,
/// while forging a row is not, and no amount of literal backslashes
/// produces a second row.
#[must_use]
pub fn purpose_for_one_row(purpose: &str) -> Cow<'_, str> {
// `char::is_control` is the Unicode `Cc` category: C0 (`\0``\x1f`),
// `\x7f`, and C1 (`\u{80}``\u{9f}`, which includes NEL). Borrowing
// when there is nothing to do keeps the common path allocation-free
// AND makes the byte-identity property structural rather than
// asserted.
if !purpose.contains(char::is_control) {
return Cow::Borrowed(purpose);
}
let mut out = String::with_capacity(purpose.len() + 8);
for ch in purpose.chars() {
match ch {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other if other.is_control() => {
// `\u{1b}`, the same spelling Rust's own `escape_debug`
// uses, so the rendered form is one a reader can paste
// back into either language and get the byte returned.
let _ = write!(out, "\\u{{{:x}}}", other as u32);
}
other => out.push(other),
}
}
Cow::Owned(out)
}
/// One frame's worth of streamed items for a single stream id,
/// returned by [`AsyncRuntime::take_stream_batches`]. T M3.5.
#[derive(Clone, Debug)]
@ -610,6 +754,29 @@ pub struct AsyncRuntime {
/// only contended at parse settle/take time --- never inside the
/// editor's hot path. T M4.1.
parse_handoff: Arc<Mutex<HashMap<JobId, Arc<ParseTreeBundle>>>>,
/// Registered handler names of the `pmacs.workers.dispatch` calls
/// currently on the stack (worker identity Stage 1, Q#W-2).
///
/// `pmacs.workers.dispatch(name, …)` looks `name` up, calls the
/// handler, and returns whatever it returns — **`name` is not a
/// parameter of any layer below that call**, and a handler that
/// reaches straight for `pmacs._async._dispatch_*` bypasses the Lua
/// wrapper layer entirely. So the name has to travel out of band, and
/// it is read here, at the one allocation funnel every job passes
/// through.
///
/// A stack, not a slot: nesting is real (a handler may dispatch
/// through another registered handler) and innermost wins.
///
/// **The extent is non-yieldable, and `async.lua` enforces it** —
/// both supported yield APIs refuse inside it, because parking a
/// coroutine with a name still pushed hands that name to whatever
/// allocates next. The one hole is a raw `coroutine.yield`, which
/// violates R46 and which no refusal sited in a yield helper can
/// intercept (the scheduler only sees the yielded value after the
/// coroutine has already suspended). That residual is recorded in
/// `docs/worker-identity-framing.md` §2, not claimed closed.
dispatch_names: RefCell<Vec<String>>,
}
/// Default cap on stream items delivered in a single drain. 1024
@ -650,6 +817,7 @@ impl AsyncRuntime {
frame_target_ms: Cell::new(DEFAULT_FRAME_TARGET_MS),
completed: RefCell::new(VecDeque::with_capacity(COMPLETED_RING_CAP)),
parse_handoff: Arc::new(Mutex::new(HashMap::new())),
dispatch_names: RefCell::new(Vec::new()),
}
}
@ -733,34 +901,83 @@ impl AsyncRuntime {
self.default_max_batch.set(n.clamp(1, 1_000_000));
}
/// Push a `pmacs.workers.dispatch` handler name for the dynamic
/// extent of that handler's call (worker identity Stage 1, Q#W-2).
///
/// Paired with [`Self::pop_dispatch_name`] by
/// `pmacs.workers.dispatch`, which brackets the handler call under
/// `pcall` so a raising handler still pops. An unpaired push is the
/// failure mode that matters: it would poison every later dispatch
/// in the session with a stale name, and the feature would start
/// lying silently rather than loudly.
pub fn push_dispatch_name(&self, name: impl Into<String>) {
self.dispatch_names.borrow_mut().push(name.into());
}
/// Pop the innermost dispatch-handler name. No-op when the stack is
/// already empty — an unbalanced pop is a Lua-side bug, and
/// panicking here would turn it into a torn editor rather than a
/// missing label.
pub fn pop_dispatch_name(&self) {
self.dispatch_names.borrow_mut().pop();
}
/// Whether a `pmacs.workers.dispatch` handler is on the stack.
///
/// Read from Lua as `pmacs._async._in_dispatch_name_scope()`. Both
/// supported yield APIs refuse while it is set (Q#W-2 rule 1), for
/// the same reason `Handle:await` refuses inside
/// `pmacs.window.commit_to`: yielding would park the coroutine with
/// the name still pushed, and the next allocation — in any
/// coroutine, on any later tick — would inherit it.
#[must_use]
pub fn in_dispatch_name_scope(&self) -> bool {
!self.dispatch_names.borrow().is_empty()
}
/// The innermost dispatch-handler name, if any. Nesting is a stack
/// and innermost wins (Q#W-2 rule 3).
#[must_use]
pub fn current_dispatch_name(&self) -> Option<String> {
self.dispatch_names.borrow().last().cloned()
}
/// Register a fresh pending entry and return its id + cancel
/// token. The token is what the worker closure polls; the entry
/// is what `tick` updates on reply.
///
/// If `supersede_key` is `Some(key)`, any in-flight predecessor
/// **This is the single allocation funnel**: every job in the
/// system — the ten `dispatch_*` methods and
/// [`Self::register_external`] alike — is born here, which is what
/// makes the identity field reachable by construction rather than by
/// audit.
///
/// If `spec.supersede` is `Some(key)`, any in-flight predecessor
/// under the same key has its cancel token flipped *before* this
/// allocation returns, and the `key → id` table is updated to
/// point at the new id. The predecessor's pending entry is
/// retained --- its worker will produce a `Cancelled` reply that
/// `tick` then surfaces.
fn allocate(
&self,
kind: JobKind,
supersede_key: Option<&str>,
stream: Option<usize>,
) -> (JobId, CancellationToken) {
self.allocate_with_resource(kind, supersede_key, stream, None)
}
/// [`Self::allocate`], plus the filesystem mutation this job
/// performs. Only the two mutating fs dispatchers pass `resource`.
fn allocate_with_resource(
&self,
kind: JobKind,
supersede_key: Option<&str>,
stream: Option<usize>,
resource: Option<ResourceOp>,
) -> (JobId, CancellationToken) {
///
/// The recorded purpose **composes** with any dispatch-name ambient
/// rather than replacing it (Q#W-2 rule 6): `"<name>: <purpose>"`
/// where the dispatcher described its own work, `"<name>"` where it
/// did not. Letting the dispatcher's purpose win would lose the
/// third-party caller all over again; letting the name win would
/// discard the only description of the actual work.
fn allocate(&self, spec: JobSpec<'_>) -> (JobId, CancellationToken) {
let JobSpec {
kind,
supersede: supersede_key,
stream,
resource,
purpose,
} = spec;
let purpose = match self.current_dispatch_name() {
Some(name) if purpose.is_empty() => name,
Some(name) => format!("{name}: {purpose}"),
None => purpose,
};
let id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
if let Some(key) = supersede_key {
@ -788,6 +1005,7 @@ impl AsyncRuntime {
kind,
dispatched_at: Instant::now(),
resource,
purpose,
},
);
(id, cancel)
@ -801,7 +1019,13 @@ impl AsyncRuntime {
/// dispatched under `key` is cancelled before this dispatch
/// returns. T M3.4 / [spec §6.3].
pub fn dispatch_sleep(&self, ms: i64, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::Sleep, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::Sleep,
supersede,
stream: None,
resource: None,
purpose: format!("sleep {}ms", ms.max(0)),
});
let bus = self.workers.clone();
let total = Duration::from_millis(ms.max(0).unsigned_abs());
self.pool.dispatch(move |_pool| {
@ -816,7 +1040,13 @@ impl AsyncRuntime {
/// the granular cancel boundary. `supersede` follows the same
/// rule as [`Self::dispatch_sleep`].
pub fn dispatch_compute_sum(&self, n: u64, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::ComputeSum, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::ComputeSum,
supersede,
stream: None,
resource: None,
purpose: format!("sum 1..{n}"),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_compute_sum(&cancel, n);
@ -842,7 +1072,13 @@ impl AsyncRuntime {
max_batch: Option<usize>,
) -> JobId {
let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000));
let (id, cancel) = self.allocate(JobKind::EmitN, supersede, Some(cap));
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::EmitN,
supersede,
stream: Some(cap),
resource: None,
purpose: format!("emit {count} items"),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_emit_n(&cancel, &bus, id, count);
@ -870,7 +1106,13 @@ impl AsyncRuntime {
max_batch: Option<usize>,
) -> JobId {
let cap = max_batch.map_or_else(|| self.default_max_batch.get(), |n| n.clamp(1, 1_000_000));
let (id, cancel) = self.allocate(JobKind::Grep, supersede, Some(cap));
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::Grep,
supersede,
stream: Some(cap),
resource: None,
purpose: format!("grep {:?} in {}", spec.pattern, spec.root.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
run_grep(&cancel, &bus, id, spec);
@ -897,7 +1139,13 @@ impl AsyncRuntime {
/// in-flight predecessor under the same key has its cancel token
/// flipped synchronously. T M4.1 / [spec §6.3].
pub fn dispatch_parse(&self, spec: ParseRequest, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::Parse, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::Parse,
supersede,
stream: None,
resource: None,
purpose: format!("parse {}", spec.language_name),
});
let bus = self.workers.clone();
let handoff = self.parse_handoff.clone();
self.pool.dispatch(move |_pool| {
@ -922,7 +1170,13 @@ impl AsyncRuntime {
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsReadDir,
supersede,
stream: None,
resource: None,
purpose: format!("read_dir {}", path.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path, tolerance);
@ -934,7 +1188,13 @@ impl AsyncRuntime {
/// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of
/// metadata for `path`. T M8.1.
pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsStat,
supersede,
stream: None,
resource: None,
purpose: format!("stat {}", path.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_stat(&cancel, &path);
@ -948,15 +1208,16 @@ impl AsyncRuntime {
pub fn dispatch_fs_rename(&self, from: PathBuf, to: PathBuf, supersede: Option<&str>) -> JobId {
// The closure below MOVES both paths; the pending entry is the
// only thing that still knows them when the reply lands.
let (id, cancel) = self.allocate_with_resource(
JobKind::FsRename,
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsRename,
supersede,
None,
Some(ResourceOp::Rename {
stream: None,
resource: Some(ResourceOp::Rename {
from: from.clone(),
to: to.clone(),
}),
);
purpose: format!("rename {} -> {}", from.display(), to.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_rename(&cancel, &from, &to);
@ -967,7 +1228,13 @@ impl AsyncRuntime {
/// Dispatch a `chmod(path, mode)` job. T M8.1.
pub fn dispatch_fs_chmod(&self, path: PathBuf, mode: u32, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None);
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsChmod,
supersede,
stream: None,
resource: None,
purpose: format!("chmod {mode:o} {}", path.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_chmod(&cancel, &path, mode);
@ -978,12 +1245,13 @@ impl AsyncRuntime {
/// Dispatch a `remove(path)` job. T M8.1.
pub fn dispatch_fs_remove(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate_with_resource(
JobKind::FsRemove,
let (id, cancel) = self.allocate(JobSpec {
kind: JobKind::FsRemove,
supersede,
None,
Some(ResourceOp::Remove { path: path.clone() }),
);
stream: None,
resource: Some(ResourceOp::Remove { path: path.clone() }),
purpose: format!("remove {}", path.display()),
});
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_remove(&cancel, &path);
@ -1008,12 +1276,27 @@ impl AsyncRuntime {
/// same supervisor (DAP, etc.) reuse this surface.
///
/// `supersede` follows the same rule as the worker dispatchers.
///
/// `purpose` is **required and has no derivable fallback** here,
/// which is why it is a parameter rather than something this method
/// composes for itself. The ten pool dispatchers each know what
/// their own job does; `register_external` knows only a `JobKind`
/// that is `McpRequest` or `LspRequest` — a category, not a
/// description. The caller is the only party that can say
/// `"lsp textDocument/definition"`.
pub fn register_external(
&self,
kind: JobKind,
supersede: Option<&str>,
purpose: impl Into<String>,
) -> (JobId, CancellationToken) {
self.allocate(kind, supersede, None)
self.allocate(JobSpec {
kind,
supersede,
stream: None,
resource: None,
purpose: purpose.into(),
})
}
/// Settle an externally-registered job with a JSON value. Wakes
@ -1206,6 +1489,7 @@ impl AsyncRuntime {
dispatched_at: job.dispatched_at,
settled_at: now,
supersede_key: job.supersede_key.clone(),
purpose: job.purpose.clone(),
outcome,
});
}
@ -1243,6 +1527,7 @@ impl AsyncRuntime {
supersede_key: j.supersede_key.clone(),
cancel_requested: j.cancel.is_cancelled(),
is_stream: j.stream_buffer.is_some(),
purpose: j.purpose.clone(),
})
.collect();
// Stable order: oldest first. The buffer renderer renders in
@ -1262,12 +1547,54 @@ impl AsyncRuntime {
.as_millis() as u64,
settled_age_ms: now.saturating_duration_since(c.settled_at).as_millis() as u64,
supersede_key: c.supersede_key.clone(),
purpose: c.purpose.clone(),
outcome: c.outcome.clone(),
})
.collect();
WorkersSnapshot { active, completed }
}
/// What the statusline activity indicator shows, or `None` when
/// nothing is in flight (worker identity Stage 1, Q#W-3).
///
/// `None` at zero is the contract, not an optimization: the
/// indicator renders **no segment at all** when idle, because a
/// statusline element that is always present costs modeline width
/// forever to say "nothing is happening".
///
/// Scans the pending table rather than reusing
/// [`Self::workers_snapshot`]: this runs once per visible window per
/// frame, and a snapshot would clone the whole completed ring that
/// the indicator never reads.
#[must_use]
pub fn activity_summary(&self) -> Option<ActivitySummary> {
let pending = self.pending.borrow();
let mut in_flight = 0usize;
let mut oldest: Option<(&Instant, &str)> = None;
for job in pending.values() {
if !matches!(job.state, PendingState::Running) {
continue;
}
in_flight += 1;
// Strictly-earlier wins, so the first job seen holds the
// slot against later ties. `HashMap` iteration order is
// arbitrary, so two jobs dispatched in the same `Instant`
// resolve arbitrarily — a tie between simultaneous jobs has
// no right answer to lose.
if oldest.is_none_or(|(seen, _)| job.dispatched_at < *seen) {
oldest = Some((&job.dispatched_at, job.purpose.as_str()));
}
}
let (_, purpose) = oldest?;
Some(ActivitySummary {
in_flight,
// The modeline is one row and a segment is one line;
// `purpose_for_one_row` is what keeps a purpose carrying a
// newline (a path, an argv) from breaking it.
oldest_purpose: purpose_for_one_row(purpose).into_owned(),
})
}
/// Drain the per-stream accumulators into one batch each. Each
/// returned batch is bounded by the stream's `max_batch`; items
/// beyond the cap stay in the accumulator until the next call.
@ -1876,23 +2203,25 @@ mod tests {
fn tick_reports_resources_in_bus_arrival_order_not_allocation_order() {
fn run(reverse: bool) -> Vec<ResourceOp> {
let rt = AsyncRuntime::with_pool_size(1);
let (a, _) = rt.allocate_with_resource(
JobKind::FsRename,
None,
None,
Some(ResourceOp::Rename {
let (a, _) = rt.allocate(JobSpec {
kind: JobKind::FsRename,
supersede: None,
stream: None,
resource: Some(ResourceOp::Rename {
from: PathBuf::from("/tmp/a-from"),
to: PathBuf::from("/tmp/a-to"),
}),
);
let (b, _) = rt.allocate_with_resource(
JobKind::FsRemove,
None,
None,
Some(ResourceOp::Remove {
purpose: "rename a".to_owned(),
});
let (b, _) = rt.allocate(JobSpec {
kind: JobKind::FsRemove,
supersede: None,
stream: None,
resource: Some(ResourceOp::Remove {
path: PathBuf::from("/tmp/b-gone"),
}),
);
purpose: "remove b".to_owned(),
});
let order = if reverse { [b, a] } else { [a, b] };
for id in order {
rt.workers
@ -1936,23 +2265,25 @@ mod tests {
#[test]
fn a_failed_or_cancelled_resource_job_is_not_harvested() {
let rt = AsyncRuntime::with_pool_size(1);
let (failed, _) = rt.allocate_with_resource(
JobKind::FsRename,
None,
None,
Some(ResourceOp::Rename {
let (failed, _) = rt.allocate(JobSpec {
kind: JobKind::FsRename,
supersede: None,
stream: None,
resource: Some(ResourceOp::Rename {
from: PathBuf::from("/tmp/nope"),
to: PathBuf::from("/tmp/also-nope"),
}),
);
let (cancelled, _) = rt.allocate_with_resource(
JobKind::FsRemove,
None,
None,
Some(ResourceOp::Remove {
purpose: "rename nope".to_owned(),
});
let (cancelled, _) = rt.allocate(JobSpec {
kind: JobKind::FsRemove,
supersede: None,
stream: None,
resource: Some(ResourceOp::Remove {
path: PathBuf::from("/tmp/never"),
}),
);
purpose: "remove never".to_owned(),
});
rt.workers
.send(
ASYNC_REPLY_TOPIC,

View File

@ -66,7 +66,28 @@ impl SourceLocation {
pub struct Command {
/// Unique name (e.g. `buffer.save`).
pub name: String,
/// One-line human-readable description (R42, required).
/// Human-readable description. Required and non-empty after trim
/// (R42), but otherwise **free-form, and legitimately multi-line**.
///
/// # Do not add a registration-time one-line guard
///
/// This doc used to read "one-line human-readable description",
/// which was an aspiration rather than the contract: MCP tool
/// registration renders a whole schema block in here — the tool's
/// text, a blank line, `Arguments:`, then one line per argument
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
/// `table.concat(lines, "\n")`) — and `m9_6_acceptance.rs:583-598`
/// asserts all four of those lines. Rejecting CR/LF in
/// [`CommandRegistry::define`] was tried, measured, and abandoned:
/// it fails 36 tests across `m9_6`/`m9_7`/`m9_8` and could only be
/// made green by deleting a shipped acceptance criterion.
///
/// The one-line constraint belongs to the **surfaces that have
/// it**, so a consumer rendering into a single row clips with
/// [`Self::description_first_line`] — the minibuffer band and the
/// completion dropdown both do. The full text stays intact for
/// `describe-command` and `help.list-commands`, which is what keeps
/// this a rendering decision rather than data loss.
pub description: String,
/// Where the command was defined.
pub source: SourceLocation,
@ -79,6 +100,53 @@ pub struct Command {
pub predicate: Option<Function>,
}
impl Command {
/// [`Self::description`] clipped to its first line, for a consumer
/// rendering into a surface that has exactly one row.
///
/// The description is free-form and may carry a whole schema block
/// (see that field). Two surfaces cannot show one: the grid TUI
/// writes the selected candidate into a single-row suffix on the
/// minibuffer band, and the GPU dropdown derives its height, its
/// visible window and its selection-highlight offset from
/// `rows.len()` — **one logical row per candidate** — so a detail
/// that shapes into more physical lines than that misaligns every
/// row below it and the highlight with it.
///
/// Clipping here rather than refusing at registration follows the
/// precedent already in this tree: the MCP fixture's result
/// delivery keeps only the first line of a tool result because
/// *"a multi-line `set_status` would corrupt the row layout"*
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:277-285`), leaving
/// width clipping to the frontend. Same hazard class, same
/// resolution.
///
/// **No ellipsis or truncation marker**, matching that precedent
/// and the minibuffer's own width rule, which rejects stub markers
/// for the same reason: the full text is one `describe-command`
/// away, and a marker in a candidate row reads as part of the
/// candidate.
#[must_use]
pub fn description_first_line(&self) -> &str {
first_line(&self.description)
}
}
/// The prefix of `text` before its first line break.
///
/// Breaks on CR **or** LF, not LF alone: a lone CR ends a line on
/// classic-Mac-era input and is the leading half of a CRLF, so an
/// LF-only clip would pass a bare `\r` straight through to a
/// single-row surface — and a CR-only clip would do the same for `\n`.
/// Splitting on the first of either handles all three forms with one
/// scan, since CRLF's `\r` comes first.
fn first_line(text: &str) -> &str {
match text.find(['\n', '\r']) {
Some(break_at) => &text[..break_at],
None => text,
}
}
/// Errors raised by the command registry.
#[derive(Debug, Error)]
pub enum CommandError {
@ -271,6 +339,83 @@ mod tests {
));
}
#[test]
fn a_multi_line_description_registers_and_clips_to_its_first_line() {
// Registration accepts it — MCP tool registration renders a
// whole schema block into `description` and
// `m9_6_acceptance.rs:583-598` asserts four of its lines, so a
// one-line guard here would delete a shipped contract. The
// one-line constraint lives at the single-row surfaces, which
// read `description_first_line`.
//
// All three break forms: a clip that split on `\n` alone would
// pass a bare `\r` through, and one that split on `\r` alone
// would pass `\n` through.
let lua = Lua::new();
for (label, description) in [
(
"LF",
"Greet someone.\n\nArguments:\n name (string, required)",
),
(
"CR",
"Greet someone.\r\rArguments:\r name (string, required)",
),
(
"CRLF",
"Greet someone.\r\n\r\nArguments:\r\n name (string, required)",
),
] {
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "mcp.greet", description))
.unwrap_or_else(|e| panic!("{label}: a schema block must still register: {e}"));
let cmd = r.get("mcp.greet").expect("registered");
assert_eq!(
cmd.description, description,
"{label}: the registry stores the description verbatim — the clip is a \
rendering decision, so `describe-command` must still see every line"
);
assert_eq!(
cmd.description_first_line(),
"Greet someone.",
"{label}: a single-row surface gets the first line only"
);
assert!(
!cmd.description_first_line().contains(['\n', '\r']),
"{label}: the clipped form must carry no break at all"
);
}
}
#[test]
fn a_single_line_description_is_byte_identical_after_the_clip() {
// The other half: the clip must not tighten past its purpose.
// Interior whitespace, punctuation and non-ASCII all survive,
// and there is no ellipsis or truncation marker.
let lua = Lua::new();
let mut r = CommandRegistry::new();
let description = "Write the buffer to its file — with a dash, and \ttabs.";
r.define(make_command(&lua, "buffer.save", description))
.expect("registers");
assert_eq!(
r.get("buffer.save").unwrap().description_first_line(),
description,
"a description with no break is returned unchanged"
);
}
#[test]
fn a_description_whose_first_line_is_empty_clips_to_empty() {
// The case the producer turns into `None` rather than
// `Some("")`: a leading break leaves nothing to render, and a
// `Some("")` detail would draw trailing padding after the label.
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "x", "\nArguments:\n a (string)"))
.expect("registers");
assert_eq!(r.get("x").unwrap().description_first_line(), "");
}
#[test]
fn empty_name_is_rejected() {
let lua = Lua::new();

View File

@ -1420,9 +1420,23 @@ fn dispatcher_loop(
let peer_knows_menu_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11);
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12);
// Q#MB1 / Discovery Stage 2 — the minibuffer is the one
// surface with TWO live variants, and the gate is a
// RANGE on both sides rather than a floor. The legacy
// `MinibufferPrompt` is frozen and belongs to `12..=22`;
// `MinibufferPromptRows` belongs to `>= 23`. Writing the
// legacy gate as a bare `>= 12` would let a v23 peer
// receive both and double-render its dropdown.
let peer_knows_minibuffer_prompt =
session_registry.session_state(*fid).is_some_and(|s| {
(12..crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION)
.contains(&s.negotiated_protocol_version)
});
let peer_knows_minibuffer_rows =
session_registry.session_state(*fid).is_some_and(|s| {
s.negotiated_protocol_version
>= crate::semantic_render::MINIBUFFER_ROWS_MIN_VERSION
});
// UX gutter — `LineNumbers` carries a `LineNumberMode` since
// v14 (was `enabled: bool` in v13); a peer below 14 keeps
// its gutter off rather than mis-decoding the wider shape.
@ -1470,12 +1484,24 @@ fn dispatcher_loop(
continue;
}
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
// simply can't render the GUI minibuffer.
// simply can't render the GUI minibuffer. Discovery
// Stage 2 closed the range at the top: a v23 peer
// gets the rows form instead, never both.
if !peer_knows_minibuffer_prompt
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
{
continue;
}
// Discovery Stage 2 — MinibufferPromptRows gated at
// v23. A `12..=22` peer keeps the frozen legacy
// variant above, which is why gating alone was never
// enough: with one variant it would have lost the
// minibuffer entirely.
if !peer_knows_minibuffer_rows
&& matches!(msg, InstanceMessage::MinibufferPromptRows { .. })
{
continue;
}
if !peer_knows_line_numbers
&& matches!(msg, InstanceMessage::LineNumbers { .. })
{

View File

@ -4770,7 +4770,10 @@ pub fn paint_frame(
paint_search_prompt(grid, core, term_size, &theme);
None
} else if core.minibuffer.is_active() {
Some(paint_minibuffer(grid, core, term_size, &theme))
// The command registry is a separate `RefCell` from the core, so
// this borrow does not contend with the one held above.
let commands = state.lua_host.commands().borrow();
Some(paint_minibuffer(grid, core, &commands, term_size, &theme))
} else {
None
};
@ -5502,9 +5505,42 @@ fn minibuffer_style(theme: &crate::highlight::Theme) -> crate::cell::Style {
})
}
/// The inline candidate suffix for the minibuffer's bottom row, given
/// the columns still free after the prompt and the typed input.
///
/// Discovery Stage 2 §3.4 — three ORDERED steps, and the guarantee is
/// **"never a partial name"**, not "the name always survives". The
/// latter is unachievable: the prompt and the typed input consume the
/// budget first, so the remainder can be too small even for the bare
/// name.
///
/// 1. If the whole name does not fit, emit **nothing**. A truncated
/// `[buffer.sa…]` is worse than no suffix, because it reads as a
/// different command.
/// 2. Only once the whole name fits is a description attempted.
/// 3. If the description does not fit whole, drop it — leaving exactly
/// today's `[name]`. No ellipsis stub.
///
/// Measured in `char`s, matching the painter below: it writes one cell
/// per `char`.
fn minibuffer_candidate_suffix(name: &str, detail: Option<&str>, remaining: u32) -> String {
let bare = format!(" [{name}]");
if bare.chars().count() as u32 > remaining {
return String::new();
}
if let Some(detail) = detail.map(str::trim).filter(|d| !d.is_empty()) {
let full = format!(" [{name}{detail}]");
if full.chars().count() as u32 <= remaining {
return full;
}
}
bare
}
fn paint_minibuffer(
grid: &mut crate::cell::CellGrid<'_>,
core: &EditorCore,
commands: &crate::command::CommandRegistry,
term_size: crate::cell::CellSize,
theme: &crate::highlight::Theme,
) -> u32 {
@ -5515,12 +5551,6 @@ fn paint_minibuffer(
.expect("called only when active");
let prompt = &session.prompt;
let contents = core.minibuffer.contents();
let mut suffix = String::new();
if let Some(idx) = session.selected
&& let Some(cand) = session.candidates.get(idx)
{
suffix = format!(" [{cand}]");
}
let row = term_size.rows - 1;
let mut col: u32 = 0;
let mut written: u32 = 0;
@ -5575,6 +5605,35 @@ fn paint_minibuffer(
cursor_col = prompt_end;
}
// Discovery Stage 2 (§3.4): the selected candidate's suffix now
// carries the command's DESCRIPTION, read from the registry
// in-process. The grid TUI never consumes `MinibufferPrompt` — it
// paints from `core.minibuffer` — so this half of the lane involves
// no wire at all and is independent of the v23 bump.
//
// Q#D2-2: only the command source has a detail. A file-path or
// buffer-name prompt renders exactly as it did before.
//
// FIRST LINE ONLY: this band is a single row, and
// `Command.description` is free-form — MCP registration renders a
// whole schema block into it. The full text stays reachable through
// `describe-command`.
let suffix = match session.selected.and_then(|idx| session.candidates.get(idx)) {
Some(cand) => {
let detail = matches!(
session.source,
crate::minibuffer::CompletionSource::Commands
)
.then(|| {
commands
.get(cand)
.map(crate::command::Command::description_first_line)
})
.flatten();
minibuffer_candidate_suffix(cand, detail, max.saturating_sub(col))
}
None => String::new(),
};
for ch in suffix.chars() {
if col >= max {
break;

View File

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

View File

@ -167,7 +167,11 @@ impl LspServerSpec {
}
fn to_process_spec(&self) -> ProcessSpec {
let mut p = ProcessSpec::new(format!("lsp:{}", self.label), &self.command);
let mut p = ProcessSpec::new(
format!("lsp:{}", self.label),
&self.command,
format!("language server for {}", self.label),
);
p.args.clone_from(&self.args);
p.cwd.clone_from(&self.cwd);
p.env.clone_from(&self.env);
@ -1587,9 +1591,15 @@ impl LspManager {
uri: &str,
) -> JobId {
let supersede = format!("lsp:{method}:{}:{uri}", sid.raw());
let (job_id, token) = self
.runtime
.register_external(JobKind::LspRequest, Some(&supersede));
// Worker identity Stage 1: `register_external` bypasses the
// worker pool, so its `JobKind` is the undifferentiated
// `LspRequest` for every method. The method and the document are
// the only thing that makes one row distinguishable from another
// in `*workers*`.
let purpose = format!("lsp {method} {uri}");
let (job_id, token) =
self.runtime
.register_external(JobKind::LspRequest, Some(&supersede), purpose);
self.pending_external.insert(
(sid, req_id),
PendingExternal {
@ -4538,7 +4548,8 @@ mod resource_reconciliation_tests {
let runtime = mgr.runtime.clone();
let mut register = |rid: u64, uri: &str| {
let (job_id, token) = runtime.register_external(JobKind::LspRequest, None);
let (job_id, token) =
runtime.register_external(JobKind::LspRequest, None, format!("lsp hover {uri}"));
mgr.pending_routes.insert(
(a, rid),
ResponseRoute::Hover {

View File

@ -7576,6 +7576,99 @@ pub fn install_async(
})?,
)?;
// Worker identity Stage 1 (Q#W-2): the dispatch-name ambient.
//
// `pmacs.workers.dispatch(name, …)` is the one place a third-party
// job's own name exists, and nothing below it takes a name — the
// Rust dispatchers accept job arguments, a supersede key and stream
// data, and a handler reaching straight for `_dispatch_*` bypasses
// the Lua wrapper layer entirely. So the name travels out of band
// and is read at `allocate`, the single funnel every job passes
// through.
//
// Runtime-internal, underscore-prefixed: package code calls
// `pmacs.workers.dispatch`, which brackets these itself under
// `pcall`. A package pushing by hand and failing to pop would poison
// every later dispatch in the session with a stale name.
//
// `mlua::String`, not `String`: the parameter is a Lua BYTE string,
// so an `mlua`-driven `String` conversion would refuse a non-UTF-8
// name with a generic message naming neither the argument nor the
// rule. `pmacs.workers.register` enforces the rest of the
// display-text standard (non-empty, no control characters) but
// cannot see UTF-8 validity from Lua 5.1, so the byte-level half is
// enforced here — the one point where Rust sees the name — with a
// message that names both.
//
// And it names the surfaces a JOB reaches, which are `*workers*` and
// the modeline activity indicator. The sibling refusal in
// `required_purpose` deliberately names a different one
// (`pmacs.process.list`), because a spawned process reaches neither
// of these in Stage 1. The two must not converge on one sentence:
// whichever wording won would be wrong on the other side, and a
// diagnostic that misdescribes the system sends the reader looking
// in the wrong place.
{
let rt = runtime.clone();
async_mod.set(
"_push_dispatch_name",
lua.create_function(move |_, name: mlua::String| {
let Ok(text) = name.to_str() else {
return Err(mlua::Error::external(
"pmacs.workers.dispatch: handler name must be valid UTF-8 — it is \
composed into every job's purpose, which is displayed to the user \
in *workers* and in the modeline, and arbitrary bytes have no \
display form there.",
));
};
rt.push_dispatch_name(&*text);
Ok(())
})?,
)?;
}
{
let rt = runtime.clone();
async_mod.set(
"_pop_dispatch_name",
lua.create_function(move |_, ()| {
rt.pop_dispatch_name();
Ok(())
})?,
)?;
}
// The refusal predicate, the sibling of `_in_commit_scope` above and
// enforced for the same reason: a coroutine that parks inside the
// extent leaves the name pushed, and every job allocated in the
// meantime — in any coroutine, on any later tick — inherits it.
{
let rt = runtime.clone();
async_mod.set(
"_in_dispatch_name_scope",
lua.create_function(move |_, ()| Ok(rt.in_dispatch_name_scope()))?,
)?;
}
// The statusline activity indicator's read surface (Q#W-3). Returns
// `nil` when nothing is in flight — the indicator renders no segment
// at all when idle, so "absent" has to be representable.
{
let rt = runtime.clone();
async_mod.set(
"_activity_summary",
lua.create_function(move |lua, ()| {
let Some(summary) = rt.activity_summary() else {
return Ok(mlua::Value::Nil);
};
let t = lua.create_table_with_capacity(0, 2)?;
t.set("in_flight", summary.in_flight)?;
t.set("purpose", summary.oldest_purpose)?;
Ok(mlua::Value::Table(t))
})?,
)?;
}
{
let rt = runtime.clone();
async_mod.set(
@ -7738,7 +7831,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
let out = lua.create_table()?;
let active = lua.create_table_with_capacity(snap.active.len(), 0)?;
for (i, job) in snap.active.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 6)?;
let row = lua.create_table_with_capacity(0, 7)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("age_ms", job.age_ms)?;
@ -7747,12 +7840,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
}
row.set("cancel_requested", job.cancel_requested)?;
row.set("is_stream", job.is_stream)?;
row.set("purpose", job.purpose.as_str())?;
active.set(i + 1, row)?;
}
out.set("active", active)?;
let completed = lua.create_table_with_capacity(snap.completed.len(), 0)?;
for (i, job) in snap.completed.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 7)?;
let row = lua.create_table_with_capacity(0, 8)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("duration_ms", job.duration_ms)?;
@ -7760,6 +7854,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
if let Some(key) = &job.supersede_key {
row.set("supersede", key.as_str())?;
}
row.set("purpose", job.purpose.as_str())?;
let (status, value): (&'static str, mlua::Value) = match &job.outcome {
JobOutcome::Complete(JobResult::Unit) => ("ok", mlua::Value::Nil),
JobOutcome::Complete(JobResult::Sum(v)) => (
@ -8687,9 +8782,93 @@ fn parse_restart(name: &str) -> mlua::Result<RestartPolicy> {
})
}
/// Read the **required** `purpose` out of a `pmacs.process.spawn` spec
/// (worker identity Stage 1, `COHERENCE.md` §9).
///
/// An earlier revision of this lane defaulted the field to `label` so
/// that existing callers kept working. That preserved compatibility and
/// delivered nothing: §9's complaint about `ProcessSpec` is precisely
/// that `label` is "caller-supplied, unvalidated convention", so a
/// purpose defaulting to the label hands every caller back the
/// convention this lane exists to replace.
///
/// The two fields answer different questions and neither substitutes for
/// the other. `label` **identifies** — `lsp:rust-analyzer`, a terminal's
/// buffer name — so that two processes running the same binary can be
/// told apart. `purpose` **describes**: it answers "what is happening",
/// which is the question §3's promise of visible asynchronous work is
/// about, and which a label chosen for uniqueness routinely does not
/// answer.
///
/// # Errors
///
/// Absent, empty, whitespace-only, non-string, or **not valid UTF-8**.
/// Empty and whitespace-only are rejected because they satisfy the type
/// and defeat the point exactly as copying the label across would — R42
/// already rejects whitespace-only `description`s in the config registry
/// for the same reason.
///
/// The UTF-8 case is a **reachable input class, not an internal
/// invariant**: Lua strings are byte strings, so `purpose =
/// string.char(255)` is a value a caller can write. Converting it with
/// `?` would surface mlua's generic conversion error *before* any of the
/// diagnostics below is constructed, and the caller would be told
/// neither the field nor the rule — so the conversion failure is mapped
/// onto this function's own message instead.
///
/// That message names **`pmacs.process.list`**, which is the whole of
/// where a process's purpose surfaces in Stage 1. It deliberately does
/// *not* name `*workers*` or the modeline indicator: both are **job**
/// surfaces, a spawned process appears in neither, and joining the two
/// planes is Stage 2's work (framing §3, Q#W-4). A diagnostic that
/// named them would send the reader looking for their process somewhere
/// it will never appear — worse than a terse one. The job-side twin of
/// this refusal, on `_push_dispatch_name`, names those two surfaces for
/// the matching reason: a job really does reach them.
///
/// The read is **raw**, matching the posture `stdin` and `group` already
/// document in [`lua_to_spec`]: a spec table is plain data, so a
/// metatable cannot smuggle a purpose in through `__index`.
fn required_purpose(table: &Table) -> mlua::Result<String> {
let purpose = match table.raw_get::<mlua::Value>("purpose") {
Ok(mlua::Value::String(value)) => match value.to_str() {
Ok(text) => text.to_owned(),
Err(_) => {
return Err(mlua::Error::external(
"pmacs.process.spawn: purpose must be valid UTF-8 — it is displayed \
to the user in pmacs.process.list, and arbitrary bytes have no \
display form there.",
));
}
},
Ok(mlua::Value::Nil) => {
return Err(mlua::Error::external(
"pmacs.process.spawn: purpose is required — a short description of what \
this process is DOING, e.g. purpose = \"running the project's test suite\". \
It is not the label: the label identifies the process, the purpose says \
what it is for.",
));
}
Ok(other) => {
return Err(mlua::Error::external(format!(
"pmacs.process.spawn: purpose must be a string; got {}",
other.type_name()
)));
}
Err(error) => return Err(error),
};
if purpose.trim().is_empty() {
return Err(mlua::Error::external(
"pmacs.process.spawn: purpose must not be empty or whitespace-only",
));
}
Ok(purpose)
}
fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
let label: String = table.get("label").unwrap_or_else(|_| "unnamed".to_owned());
let command: String = table.get("command")?;
let purpose = required_purpose(table)?;
let args: Vec<String> = table.get("args").unwrap_or_default();
let cwd: Option<String> = table.get("cwd").ok().flatten();
let env_table: Option<Table> = table.get("env").ok().flatten();
@ -8772,6 +8951,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
};
Ok(ProcessSpec {
label,
purpose,
command,
args,
cwd: cwd.map(std::path::PathBuf::from),
@ -8995,11 +9175,18 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua:
.collect();
let out = lua.create_table_with_capacity(ids.len(), 0)?;
for (i, id) in ids.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 3)?;
let row = lua.create_table_with_capacity(0, 4)?;
row.set("id", ProcessIdLua(*id))?;
if let Some(spec) = sup.spec(*id) {
row.set("label", spec.label.as_str())?;
row.set("command", spec.command.as_str())?;
// Worker identity Stage 1: a new KEY on each
// existing row. The row COUNT is deliberately
// untouched — three acceptance suites assert on
// `#pmacs.process.list()` as a leak detector
// (framing Q#W-4), and widening what this
// enumerates would inflate all three baselines.
row.set("purpose", spec.purpose.as_str())?;
}
if let Some(state) = sup.state(*id) {
row.set("state", state_to_lua(lua, state)?)?;

View File

@ -175,7 +175,11 @@ impl McpServerSpec {
}
fn to_process_spec(&self) -> ProcessSpec {
let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command);
let mut p = ProcessSpec::new(
format!("mcp:{}", self.label),
&self.command,
format!("MCP server {}", self.label),
);
p.args.clone_from(&self.args);
p.cwd.clone_from(&self.cwd);
p.env.clone_from(&self.env);
@ -873,7 +877,9 @@ impl McpManager {
}
let req_id = next_request_id(client);
let body = make_request(req_id, &method, params);
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, token) =
self.runtime
.register_external(JobKind::McpRequest, None, format!("mcp {method}"));
client.pending_external.insert(
req_id,
PendingExternal {
@ -948,7 +954,11 @@ impl McpManager {
// (1) Cache hit.
if let Some(ResourceCacheState::Cached { result }) = self.resource_cache.get(&key).cloned()
{
let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, _token) = self.runtime.register_external(
JobKind::McpRequest,
None,
format!("mcp resources/read {uri} (cached)"),
);
self.runtime.complete_external_ok(job_id, result);
return Ok(job_id);
}
@ -959,7 +969,11 @@ impl McpManager {
// independently.
if let Some(ResourceCacheState::InFlight { request_id }) = self.resource_cache.get(&key) {
let in_flight_rid = *request_id;
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, token) = self.runtime.register_external(
JobKind::McpRequest,
None,
format!("mcp resources/read {uri}"),
);
if let Some(p) = client.pending_external.get_mut(&in_flight_rid) {
p.awaiters.push(Awaiter { job_id, token });
return Ok(job_id);
@ -974,7 +988,11 @@ impl McpManager {
// (3) Cache miss: dispatch.
let req_id = next_request_id(client);
let body = make_request(req_id, "resources/read", json!({ "uri": uri }));
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, token) = self.runtime.register_external(
JobKind::McpRequest,
None,
format!("mcp resources/read {uri}"),
);
client.pending_external.insert(
req_id,
PendingExternal {
@ -1063,10 +1081,13 @@ impl McpManager {
// than referenced by `json!`); avoids a needless-pass-by-
// value clippy complaint and matches `send_request`'s shape.
let mut params_map = Map::new();
let purpose = format!("mcp tools/call {name}");
params_map.insert("name".into(), Value::String(name));
params_map.insert("arguments".into(), arguments);
let body = make_request(req_id, "tools/call", Value::Object(params_map));
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, token) = self
.runtime
.register_external(JobKind::McpRequest, None, purpose);
client.pending_external.insert(
req_id,
PendingExternal {
@ -1125,10 +1146,13 @@ impl McpManager {
}
let req_id = next_request_id(client);
let mut params_map = Map::new();
let purpose = format!("mcp prompts/get {name}");
params_map.insert("name".into(), Value::String(name));
params_map.insert("arguments".into(), arguments);
let body = make_request(req_id, "prompts/get", Value::Object(params_map));
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
let (job_id, token) = self
.runtime
.register_external(JobKind::McpRequest, None, purpose);
client.pending_external.insert(
req_id,
PendingExternal {

View File

@ -196,6 +196,23 @@ pub struct ProcessSpec {
/// so multiple processes can run the same binary with
/// distinguishable labels.
pub label: String,
/// What this process is doing, in words a user can read (worker
/// identity Stage 1, `COHERENCE.md` §9).
///
/// **Required, and not the same thing as [`Self::label`].** The
/// label is an *identity* — `lsp:rust-analyzer`, a terminal's buffer
/// name — spelled however the caller likes, so that two processes
/// running the same binary can be told apart. The purpose is a
/// *description*: it answers "what is happening", which is the
/// question §3's promise of visible asynchronous work is about and
/// which a label chosen for uniqueness routinely does not answer.
///
/// **Not an owner**, in any spelling. It records what the process is
/// doing, not which package asked for it; `pmacs.process.spawn` is
/// callable by any package, so a value derived here would
/// misattribute third-party work to a builtin at exactly the point
/// §9 wants attribution (framing §3).
pub purpose: String,
/// Program to execute. Looked up via the system PATH unless an
/// absolute path is supplied.
pub command: String,
@ -237,10 +254,21 @@ pub struct ProcessSpec {
impl ProcessSpec {
/// Construct a spec with the bare-minimum fields. Convenience
/// for tests and one-off scripts.
///
/// `purpose` is a parameter rather than something derived from the
/// label because it is a required field with no honest default
/// (worker identity Stage 1): deriving it from the label would make
/// every process claim its identity *is* its description, which is
/// exactly the conflation the field exists to undo.
#[must_use]
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
pub fn new(
label: impl Into<String>,
command: impl Into<String>,
purpose: impl Into<String>,
) -> Self {
Self {
label: label.into(),
purpose: purpose.into(),
command: command.into(),
args: Vec::new(),
cwd: None,
@ -2722,6 +2750,7 @@ mod tests {
let spec = ProcessSpec::new(
"unpublished-terminal",
"/definitely/not/a/real/pmacs-terminal-program",
"test process",
);
assert!(supervisor.spawn_terminal(spec).is_err());
supervisor.tick();
@ -2732,7 +2761,7 @@ mod tests {
#[test]
fn spawn_pipes_lifecycle_started_then_exited() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("echo-test", "/bin/sh");
let mut spec = ProcessSpec::new("echo-test", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "echo hello && exit 0".into()];
let id = sup.spawn(spec).expect("spawn");
let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
@ -2892,7 +2921,7 @@ mod tests {
/// A plain PTY child, for tests that care about the PTY *branch*
/// rather than about job control.
fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) {
let mut spec = ProcessSpec::new(name, "/bin/sleep");
let mut spec = ProcessSpec::new(name, "/bin/sleep", "test process");
spec.args = vec!["30".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -2943,7 +2972,7 @@ mod tests {
sup: &mut ProcessSupervisor,
name: &str,
) -> (ProcessId, u32, i32) {
let mut spec = ProcessSpec::new(name, BASH);
let mut spec = ProcessSpec::new(name, BASH, "test process");
spec.args = vec![
"--noprofile".into(),
"--norc".into(),
@ -3194,7 +3223,7 @@ mod tests {
#[test]
fn a_pipe_child_still_renders_a_bare_leader_target() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep");
let mut spec = ProcessSpec::new("diag-pipe-leader", "/bin/sleep", "test process");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
@ -3227,7 +3256,7 @@ mod tests {
let mut reports = Vec::new();
for signal in [Signal::SIGTERM, Signal::SIGUSR1] {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh");
let mut spec = ProcessSpec::new("diag-signal-name", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3280,7 +3309,7 @@ mod tests {
let mut sup = ProcessSupervisor::new();
let temp = tempfile::TempDir::new().expect("tempdir");
let ready = temp.path().join("usr1-trapped");
let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh");
let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh", "test process");
// Ignore USR1 so the successful non-fatal signal cannot end the
// child and confuse the state assertion with a real exit — and
// then WAIT for the child to say it has done so. `Started` is
@ -3344,7 +3373,7 @@ mod tests {
let mut sup = ProcessSupervisor::new();
let temp = tempfile::TempDir::new().expect("tempdir");
let ready = temp.path().join("usr1-trapped");
let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh");
let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh", "test process");
spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "sleep 1; ")];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3435,7 +3464,7 @@ mod tests {
#[test]
fn a_leader_directed_kill_failure_reports_the_fallback_branch() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep");
let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep", "test process");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
@ -3484,7 +3513,7 @@ mod tests {
#[test]
fn a_failure_after_the_child_exits_reports_the_leader_as_exited() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-exited", "/bin/sh");
let mut spec = ProcessSpec::new("diag-exited", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 3".into()];
let id = sup.spawn(spec).expect("spawn");
// NOT `spawn_started_pid`: draining ticks, and this child exits
@ -3512,7 +3541,7 @@ mod tests {
#[test]
fn an_injected_failure_changes_no_state_and_arms_no_ledger() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh");
let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
@ -3557,7 +3586,7 @@ mod tests {
#[test]
fn observing_the_leader_does_not_consume_the_exit_event() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh");
let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -3599,7 +3628,7 @@ mod tests {
let mut sup = ProcessSupervisor::new();
// `sleep 30` is long enough that the test definitely needs
// to terminate it deliberately.
let mut spec = ProcessSpec::new("sleeper", "/bin/sh");
let mut spec = ProcessSpec::new("sleeper", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
// Wait for Started so we have a pid.
@ -3628,7 +3657,7 @@ mod tests {
// implementation blocked the caller in `write_all` here —
// which in the editor was the main thread, wedging the frame
// loop whenever an LSP server fell behind on its stdin.
let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh");
let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
@ -3654,7 +3683,7 @@ mod tests {
// payload back followed by a clean exit proves the writer
// thread drains its queue before dropping the pipe (the
// flush-then-EOF contract `close_stdin` documents).
let mut spec = ProcessSpec::new("cat-echo", "/bin/sh");
let mut spec = ProcessSpec::new("cat-echo", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "cat".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
@ -3700,7 +3729,7 @@ mod tests {
fn restart_on_crash_respawns_after_nonzero_exit() {
let mut sup = ProcessSupervisor::new();
sup.set_restart_backoff(Duration::from_millis(10));
let mut spec = ProcessSpec::new("crasher", "/bin/sh");
let mut spec = ProcessSpec::new("crasher", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.restart = RestartPolicy::OnCrash;
let id = sup.spawn(spec).expect("spawn");
@ -3731,7 +3760,7 @@ mod tests {
#[test]
fn restart_never_does_not_respawn_after_clean_exit() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("oneshot", "/bin/sh");
let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 0".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exited);
@ -3760,7 +3789,7 @@ mod tests {
let pid = {
let mut sup = ProcessSupervisor::new();
sup.set_grace_period(Duration::from_millis(200));
let mut spec = ProcessSpec::new("victim", "/bin/sh");
let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
// Drain until Started so we know the pid.
@ -3798,7 +3827,7 @@ mod tests {
#[test]
fn pty_mode_child_sees_a_tty() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("ttytest", "/bin/sh");
let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "tty".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -3835,7 +3864,7 @@ mod tests {
#[test]
fn m6_1_pty_resize_delivers_sigwinch_to_child() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("winch-watch", "/bin/sh");
let mut spec = ProcessSpec::new("winch-watch", "/bin/sh", "test process");
// Trap WINCH, print READY for synchronization, then loop on
// a short sleep so SIGWINCH can interrupt and fire the trap.
spec.args = vec![
@ -3880,7 +3909,7 @@ mod tests {
#[test]
fn m6_1_pty_mode_lifecycle_started_then_exited() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("pty-exit", "/bin/sh");
let mut spec = ProcessSpec::new("pty-exit", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "echo done && exit 0".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -3915,7 +3944,7 @@ mod tests {
#[test]
fn m6_1_pty_raw_mode_disables_kernel_echo() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("raw-stty", "/bin/sh");
let mut spec = ProcessSpec::new("raw-stty", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "stty -a".into()];
spec.mode = ProcessMode::default_pty(); // Raw by default.
let id = sup.spawn(spec).expect("spawn");
@ -3937,7 +3966,7 @@ mod tests {
#[test]
fn m6_1_pty_canonical_mode_keeps_kernel_echo() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("canon-stty", "/bin/sh");
let mut spec = ProcessSpec::new("canon-stty", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "stty -a".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -3991,7 +4020,7 @@ mod tests {
// buffers.
const TOTAL: usize = 10 * 1024 * 1024;
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("byte-flood", "/bin/sh");
let mut spec = ProcessSpec::new("byte-flood", "/bin/sh", "test process");
spec.args = vec!["-c".into(), format!("head -c {TOTAL} /dev/zero")];
let id = sup.spawn(spec).expect("spawn");
@ -4067,7 +4096,7 @@ mod tests {
#[test]
fn m6_2_pty_streaming_coalesces_per_tick() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh");
let mut spec = ProcessSpec::new("chunky-stream", "/bin/sh", "test process");
// 1 MiB of zeros from /dev/zero. The reader thread reads in
// [`BYTE_CHUNK_SIZE`] (8 KiB) chunks --- ~128 reads --- all
// queued onto the bounded channel within microseconds of
@ -4116,7 +4145,7 @@ mod tests {
#[test]
fn m6_2_ansi_enabled_pty_emits_structured_events() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh");
let mut spec = ProcessSpec::new("ansi-stream", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "printf '\\033[31mhi\\033[0m\\n'".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
@ -4198,7 +4227,7 @@ mod tests {
let handle = std::thread::spawn(move || {
let mut sup = ProcessSupervisor::new();
sup.set_grace_period(Duration::from_millis(300));
let mut spec = ProcessSpec::new("forever-flood", "/bin/sh");
let mut spec = ProcessSpec::new("forever-flood", "/bin/sh", "test process");
// Continuous writer; SIGTERM kills it (no signal handler).
spec.args = vec!["-c".into(), "while :; do printf 'X'; done".into()];
let id = sup.spawn(spec).expect("spawn");
@ -4324,7 +4353,7 @@ mod tests {
let handle = std::thread::spawn(move || {
let mut sup = ProcessSupervisor::new();
sup.set_grace_period(Duration::from_millis(300));
let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid");
let mut spec = ProcessSpec::new("orphan-holds-pipe", "setsid", "test process");
// `setsid --fork` forks and the parent exits, so the
// *recorded* pid terminates promptly (letting `poll_one`
// reach the teardown path) while `cat` survives holding the
@ -4412,7 +4441,7 @@ mod tests {
// -----------------------------------------------------------------
fn sh_group_spec(label: &str, script: &str) -> ProcessSpec {
let mut spec = ProcessSpec::new(label, "/bin/sh");
let mut spec = ProcessSpec::new(label, "/bin/sh", "test process");
spec.args = vec!["-c".into(), script.to_owned()];
spec.stdin = StdinMode::Null;
spec.group = true;
@ -4546,7 +4575,7 @@ mod tests {
);
// Control: a non-group child inherits the test process's
// group instead of leading its own.
let mut plain = ProcessSpec::new("plain", "/bin/sh");
let mut plain = ProcessSpec::new("plain", "/bin/sh", "test process");
plain.args = vec!["-c".into(), "sleep 30".into()];
let plain_id = sup.spawn(plain).expect("spawn plain");
let plain_events = drain_until(&mut sup, plain_id, Duration::from_secs(2), |evs| {
@ -5017,7 +5046,7 @@ mod tests {
fn maybe_restart_inert_once_shut_down() {
let mut sup = ProcessSupervisor::new();
sup.set_restart_backoff(Duration::from_millis(30));
let mut spec = ProcessSpec::new("restarter", "/bin/sh");
let mut spec = ProcessSpec::new("restarter", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "echo x".into()];
spec.restart = RestartPolicy::Always;
let id = sup.spawn(spec).expect("spawn");
@ -5158,7 +5187,7 @@ mod tests {
#[test]
fn group_and_null_stdin_rejected_under_pty() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("pty-null", "/bin/sh");
let mut spec = ProcessSpec::new("pty-null", "/bin/sh", "test process");
spec.mode = ProcessMode::default_pty();
spec.stdin = StdinMode::Null;
let err = sup
@ -5169,7 +5198,7 @@ mod tests {
"error points at pipe mode: {err}"
);
let mut spec = ProcessSpec::new("pty-group", "/bin/sh");
let mut spec = ProcessSpec::new("pty-group", "/bin/sh", "test process");
spec.mode = ProcessMode::default_pty();
spec.group = true;
let err = sup

View File

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

View File

@ -38,7 +38,7 @@ use crate::cell::{CellSize, Style};
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, MinibufferRow, PANEL_MIN_VERSION,
StatuslineSegment, StyleSegment, StyleSpan,
};
use crate::statusline::{
@ -95,10 +95,26 @@ type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
/// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// Cached `MinibufferPrompt` payload for cached-compare suppression
/// (Q#MB1): `(prompt, input, cursor, candidates-window, selected, total)`.
/// A `None` prompt means the minibuffer is closed.
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, Option<u32>, u32);
/// Cached minibuffer payload for cached-compare suppression (Q#MB1):
/// `(prompt, input, cursor, rows-window, selected, total)`. A `None`
/// prompt means the minibuffer is closed.
///
/// **ONE cache per peer, not one per variant.** [`SemanticRenderState`]
/// is constructed by [`SemanticRenderState::for_peer`] with the
/// session's negotiated version baked in on attach and dropped on
/// detach, so a cache can never span two negotiated versions and a
/// per-variant key would guard nothing. The rows are the cached form
/// either way: for a `12..=22` peer every `detail` is `None` (the
/// producer does not resolve details it cannot ship), so the cache
/// describes exactly what that peer received.
type MinibufferFacts = (
Option<String>,
String,
u32,
Vec<MinibufferRow>,
Option<u32>,
u32,
);
/// Cached `CompletionPopup` payload for cached-compare suppression
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
@ -115,10 +131,20 @@ type CompletionPopupFacts = (
/// scrolled window around the selection, not the full (≤1024) list.
const MB_VISIBLE: usize = 10;
/// The first protocol version that carries
/// [`InstanceMessage::MinibufferPromptRows`] (Discovery Stage 2).
///
/// Named rather than written as a literal `23` at each site, and NOT
/// derived from `PROTOCOL_VERSION`: the contract is "the version this
/// variant was introduced at", which is an absolute fact, while
/// `PROTOCOL_VERSION` moves with every later bump. Handoff §5 records
/// five defects of exactly that shape from one previous bump.
pub const MINIBUFFER_ROWS_MIN_VERSION: u32 = 23;
/// A window of up to [`MB_VISIBLE`] candidates around `selected`, plus
/// the selection's index *within* that window. Keeps the selected row
/// visible as the user cycles a long list.
fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<String>, Option<u32>) {
fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Vec<T>, Option<u32>) {
if candidates.is_empty() {
return (Vec::new(), None);
}
@ -216,10 +242,18 @@ pub struct SemanticRenderState {
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
/// cached-compare suppression (see [`MenuPromptFacts`]).
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
/// Last emitted `MinibufferPrompt` payload (Q#MB1) — a single value,
/// not per-buffer, because the minibuffer is one global core
/// instance.
/// Last emitted minibuffer payload (Q#MB1) — a single value, not
/// per-buffer, because the minibuffer is one global core instance,
/// and a single value across both wire variants, because this state
/// belongs to one peer at one negotiated version (see
/// [`MinibufferFacts`]).
last_minibuffer: Option<MinibufferFacts>,
/// Whether the peer negotiated protocol >= 23 (Discovery Stage 2).
/// `true` ⇒ it receives `MinibufferPromptRows` and never the legacy
/// variant; `false` ⇒ the frozen `MinibufferPrompt` and never the
/// rows form. Also gates the per-row detail lookup: a peer that
/// cannot carry a detail does not pay to resolve one.
peer_knows_minibuffer_rows: bool,
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
/// Q#C5), for cached-compare suppression (see
/// [`CompletionPopupFacts`]).
@ -478,6 +512,7 @@ impl SemanticRenderState {
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_line_wrap = negotiated_protocol_version >= 22;
s.peer_knows_minibuffer_rows = negotiated_protocol_version >= MINIBUFFER_ROWS_MIN_VERSION;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
@ -500,6 +535,7 @@ impl SemanticRenderState {
last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
peer_knows_minibuffer_rows: true,
last_completion_popup: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
@ -1637,11 +1673,20 @@ impl SemanticRenderState {
Some(msg)
}
/// The `MinibufferPrompt` message for this frame, or `None` when the
/// The minibuffer message for this frame, or `None` when the
/// (global) minibuffer state is unchanged (Q#MB1). Emitted only from
/// the active buffer's viewport so the bufferless message ships once
/// per frame. Closed = `prompt: None`; first sight while closed stays
/// silent. The daemon keeps the variant off wires negotiated `< 12`.
/// silent.
///
/// **Exactly one variant, chosen by the peer's negotiated version**
/// (Discovery Stage 2). `>= 23` gets `MinibufferPromptRows` with
/// per-row details; `12..=22` gets the frozen `MinibufferPrompt`
/// carrying bare labels. Because the choice is made here, the CLOSE
/// necessarily uses the same family as the OPEN — a rows session
/// closed by a legacy clear would leave a popup on screen forever.
/// The daemon's write loop gates both directions again as
/// belt-and-braces.
fn minibuffer_prompt_msg(
&mut self,
state: &EditorState,
@ -1662,13 +1707,61 @@ impl SemanticRenderState {
.take_while(|(i, _)| *i < cursor_byte)
.count() as u32;
let total = session.candidates.len() as u32;
let (candidates, selected) =
let (labels, selected) =
minibuffer_window(&session.candidates, session.selected);
// Q#D2-2: the detail is per row and optional. Only
// the command source has one today; a file-path or
// buffer-name prompt leaves it `None` and renders
// exactly as it did before v23. Resolved only for a
// peer that can carry it, so the cached facts
// describe what that peer actually received.
let detail_source = self.peer_knows_minibuffer_rows
&& matches!(
session.source,
crate::minibuffer::CompletionSource::Commands
);
let rows = if detail_source {
let commands = state.lua_host.commands().borrow();
labels
.into_iter()
.map(|label| {
// FIRST LINE ONLY. `Command.description`
// is free-form and MCP registration puts
// a whole schema block in it, while the
// dropdown sizes itself from
// `rows.len()` — one logical row per
// candidate. Shipping the block would
// shape into more physical lines than
// the geometry accounts for and
// misalign every row below it. The full
// text stays reachable through
// `describe-command`.
let detail = commands
.get(&label)
.map(|command| command.description_first_line().to_owned())
// A description whose first line is
// empty (`"\nArguments:…"`) carries
// nothing to render, so it ships as
// absent rather than as `Some("")`,
// which would draw trailing padding.
.filter(|detail| !detail.is_empty());
MinibufferRow { label, detail }
})
.collect()
} else {
labels
.into_iter()
.map(|label| MinibufferRow {
label,
detail: None,
})
.collect()
};
(
Some(session.prompt.clone()),
input,
cursor,
candidates,
rows,
selected,
total,
)
@ -1684,13 +1777,24 @@ impl SemanticRenderState {
self.last_minibuffer = Some(facts);
return None;
}
let msg = InstanceMessage::MinibufferPrompt {
prompt: facts.0.clone(),
input: facts.1.clone(),
cursor: facts.2,
candidates: facts.3.clone(),
selected: facts.4,
total: facts.5,
let msg = if self.peer_knows_minibuffer_rows {
InstanceMessage::MinibufferPromptRows {
prompt: facts.0.clone(),
input: facts.1.clone(),
cursor: facts.2,
rows: facts.3.clone(),
selected: facts.4,
total: facts.5,
}
} else {
InstanceMessage::MinibufferPrompt {
prompt: facts.0.clone(),
input: facts.1.clone(),
cursor: facts.2,
candidates: facts.3.iter().map(|row| row.label.clone()).collect(),
selected: facts.4,
total: facts.5,
}
};
self.last_minibuffer = Some(facts);
Some(msg)
@ -5773,9 +5877,28 @@ mod tests {
let short: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
assert_eq!(minibuffer_window(&short, Some(2)), (short.clone(), Some(2)));
// Empty.
assert_eq!(minibuffer_window(&[], Some(0)), (Vec::new(), None));
assert_eq!(
minibuffer_window::<String>(&[], Some(0)),
(Vec::new(), None)
);
}
/// The v23 rows form: `(prompt, input, rows)`.
fn minibuffer_rows_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, String, Vec<MinibufferRow>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::MinibufferPromptRows {
prompt,
input,
rows,
..
} => Some((prompt.clone(), input.clone(), rows.clone())),
_ => None,
})
}
/// The frozen `12..=22` form: `(prompt, input, candidates)`.
fn minibuffer_prompt_of(
msgs: &[InstanceMessage],
) -> Option<(Option<String>, String, Vec<String>)> {
@ -5798,7 +5921,7 @@ mod tests {
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
// No minibuffer: the producer stays silent on first sight.
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
// Open an `M-x` prompt (command completion) via the Lua API.
state
@ -5807,28 +5930,133 @@ mod tests {
.load("pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', on_accept = function() end }")
.exec()
.expect("open minibuffer");
let (prompt, input, cands) =
minibuffer_prompt_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
let (prompt, input, rows) =
minibuffer_rows_of(&s.render_frame(&state)).expect("minibuffer prompt emitted");
assert_eq!(prompt.as_deref(), Some("M-x "));
assert_eq!(input, "");
// Empty input matches every command; the wire carries a window.
assert!(!cands.is_empty(), "M-x seeds command candidates");
assert!(cands.len() <= MB_VISIBLE, "candidates ship windowed");
assert!(!rows.is_empty(), "M-x seeds command candidates");
assert!(rows.len() <= MB_VISIBLE, "candidates ship windowed");
// Unchanged → suppressed (cached-compare).
assert!(minibuffer_prompt_of(&s.render_frame(&state)).is_none());
assert!(minibuffer_rows_of(&s.render_frame(&state)).is_none());
// Cancel: the prompt clears (None).
// Cancel: the prompt clears (None), in the SAME family as the
// open — a rows session closed by a legacy clear would leave the
// dropdown on screen forever.
state
.lua_host
.lua()
.load("pmacs.minibuffer.cancel()")
.exec()
.expect("cancel");
let (prompt, _, _) = minibuffer_prompt_of(&s.render_frame(&state)).expect("clear emitted");
let frame = s.render_frame(&state);
assert!(
minibuffer_prompt_of(&frame).is_none(),
"a v23 peer must never see the legacy variant, not even to close"
);
let (prompt, _, _) = minibuffer_rows_of(&frame).expect("clear emitted");
assert!(prompt.is_none(), "cancel clears the minibuffer band");
}
#[test]
fn a_v22_peer_gets_the_frozen_variant_and_a_v23_peer_gets_rows_with_details() {
// The producer half of the exclusivity guarantee, at the two
// versions that straddle the boundary. The real-daemon half —
// two sessions negotiating simultaneously — is in
// `tests/discovery_stage2_acceptance.rs`.
let state = empty_state();
let bid = active_buffer(&state);
state
.lua_host
.lua()
.load(
"pmacs.command.define{ name = 'mb.probe', description = 'Probe the row detail.', \
fn = function() end }",
)
.exec()
.expect("define probe command");
let mut v22 = SemanticRenderState::for_peer(FrontendId::LOCAL, 22);
let mut v23 = SemanticRenderState::for_peer(FrontendId::LOCAL, 23);
for s in [&mut v22, &mut v23] {
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
}
state
.lua_host
.lua()
.load(
"pmacs.minibuffer.read{ prompt = 'M-x ', source = 'commands', \
on_accept = function() end }",
)
.exec()
.expect("open minibuffer");
state
.lua_host
.lua()
.load("pmacs.minibuffer.set_contents('mb.probe')")
.exec()
.expect("narrow to the probe command");
let v22_frame = v22.render_frame(&state);
assert!(
minibuffer_rows_of(&v22_frame).is_none(),
"a v22 peer must never receive the v23 rows variant"
);
let (_, _, candidates) =
minibuffer_prompt_of(&v22_frame).expect("v22 gets the frozen variant");
assert!(
candidates.iter().any(|c| c == "mb.probe"),
"the frozen variant still carries the candidate names: {candidates:?}"
);
let v23_frame = v23.render_frame(&state);
assert!(
minibuffer_prompt_of(&v23_frame).is_none(),
"a v23 peer must never receive the frozen variant"
);
let (_, _, rows) = minibuffer_rows_of(&v23_frame).expect("v23 gets the rows variant");
let probe = rows
.iter()
.find(|r| r.label == "mb.probe")
.expect("the probe command is a candidate");
assert_eq!(
probe.detail.as_deref(),
Some("Probe the row detail."),
"the row carries the command's registered description"
);
}
#[test]
fn a_source_with_no_detail_ships_rows_with_none() {
// Q#D2-2: only the command source has a detail today. A
// buffer-name prompt leaves it `None`, and the GPU then renders
// exactly what it rendered before v23.
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
state
.lua_host
.lua()
.load(
"pmacs.minibuffer.read{ prompt = 'Buffer: ', source = 'buffers', \
on_accept = function() end }",
)
.exec()
.expect("open buffer prompt");
let (_, _, rows) = minibuffer_rows_of(&s.render_frame(&state)).expect("prompt emitted");
assert!(!rows.is_empty(), "the buffer registry seeds candidates");
assert!(
rows.iter().all(|r| r.detail.is_none()),
"a source with no detail leaves every row's detail None: {rows:?}"
);
}
#[test]
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
let state = empty_state();

View File

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

View File

@ -14,19 +14,40 @@
//! ```text
//! Workers (active: 2, completed: 5)
//!
//! ID Kind Age Supersede Status
//! ------ ----------- -------- ---------- ----------
//! #5 grep 412ms search running
//! #6 sleep 18ms running (cancel pending)
//! ID Kind Age Supersede Purpose Status
//! ------ ----------- -------- ---------- ------------------------ ----------
//! #5 grep 412ms search search: grep "fn" in /x running
//! #6 sleep 18ms sleep 18ms running (cancel pending)
//!
//! Recent (newest first)
//!
//! ID Kind Duration Supersede Outcome
//! ------ ----------- -------- ---------- ----------
//! #4 grep 1242ms search cancelled (3s ago)
//! #3 compute_sum 2ms ok (3s ago)
//! ID Kind Duration Supersede Purpose Outcome
//! ------ ----------- -------- ---------- ------------------------ ----------
//! #4 grep 1242ms search search: grep "fn" in /x cancelled (3s ago)
//! #3 compute_sum 2ms sum 1..100 ok (3s ago)
//! ```
//!
//! # Purpose (worker identity Stage 1, `COHERENCE.md` §9)
//!
//! The `Purpose` column is what turns "twelve rows named `lsp_request`"
//! into a readable account of what the editor is doing. `Kind` names the
//! builtin dispatcher a job funnelled through, which for every
//! third-party job is a builtin's label rather than the caller's; the
//! purpose carries the work's own description and, under
//! `pmacs.workers.dispatch`, the registered handler name it ran under.
//!
//! It is placed **before** `Status` and padded, because `Status` is
//! variable-width (`running (cancel pending) [stream]`) and two
//! ragged trailing columns render as noise. An over-long purpose pushes
//! `Status` right rather than being truncated: losing the end of a path
//! is a worse failure than an uneven column.
//!
//! This table is **one row per job**, and the purpose is the only free
//! text in it, so every row goes through
//! [`crate::async_runtime::purpose_for_one_row`]: a row must not be able
//! to forge another row. See that function for why the escaping lives
//! here rather than as a rule on the purpose itself.
//!
//! Lua reads the snapshot via `pmacs.workers.snapshot()`; the
//! `pmacs.workers.show()` builtin invokes [`render`] on it and
//! returns the buffer id. Auto-refresh hooks into
@ -35,7 +56,7 @@
use std::fmt::Write;
use crate::async_runtime::{
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot,
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot, purpose_for_one_row,
};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
@ -43,6 +64,11 @@ use crate::buffer_registry::BufferRegistry;
/// Canonical name for the workers observability buffer.
pub const WORKERS_BUFFER_NAME: &str = "*workers*";
/// Minimum column width the `Purpose` column is padded to. Purposes
/// longer than this push the trailing column right rather than being
/// truncated (see the module docs).
const PURPOSE_WIDTH: usize = 24;
/// Render `snapshot` into the `*workers*` buffer (creating it if
/// absent), replacing its full contents. Returns the buffer id
/// and the Edits produced by the replacement (zero, one, or two —
@ -119,13 +145,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
let _ = writeln!(text);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} Status",
"ID", "Kind", "Age", "Supersede"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Status",
"ID", "Kind", "Age", "Supersede", "Purpose"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
);
if snapshot.active.is_empty() {
let _ = writeln!(text, "(no active jobs)");
@ -139,13 +169,17 @@ fn format_snapshot(snapshot: &WorkersSnapshot) -> String {
let _ = writeln!(text);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} Outcome",
"ID", "Kind", "Duration", "Supersede"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} Outcome",
"ID", "Kind", "Duration", "Supersede", "Purpose"
);
let _ = writeln!(
text,
"{:<7} {:<11} {:>9} {:<11} ----------",
"------", "-----------", "---------", "-----------"
"{:<7} {:<11} {:>9} {:<11} {:<PURPOSE_WIDTH$} ----------",
"------",
"-----------",
"---------",
"-----------",
"-".repeat(PURPOSE_WIDTH)
);
if snapshot.completed.is_empty() {
let _ = writeln!(text, "(no recent completions)");
@ -169,7 +203,11 @@ fn write_active_row(text: &mut String, job: &ActiveJobInfo) {
if job.is_stream {
status.push_str(" [stream]");
}
let _ = writeln!(text, "{id:<7} {kind:<11} {age:>9} {key:<11} {status}");
let purpose = purpose_for_one_row(&job.purpose);
let _ = writeln!(
text,
"{id:<7} {kind:<11} {age:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {status}"
);
}
fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
@ -179,9 +217,10 @@ fn write_completed_row(text: &mut String, job: &CompletedJobInfo) {
let key = job.supersede_key.as_deref().unwrap_or("");
let outcome = format_outcome(&job.outcome);
let age = format_duration_ms(job.settled_age_ms);
let purpose = purpose_for_one_row(&job.purpose);
let _ = writeln!(
text,
"{id:<7} {kind:<11} {duration:>9} {key:<11} {outcome} ({age} ago)"
"{id:<7} {kind:<11} {duration:>9} {key:<11} {purpose:<PURPOSE_WIDTH$} {outcome} ({age} ago)"
);
}
@ -287,6 +326,7 @@ mod tests {
supersede_key: Some("search".to_string()),
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -309,6 +349,7 @@ mod tests {
supersede_key: None,
cancel_requested: true,
is_stream: false,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -326,6 +367,7 @@ mod tests {
duration_ms: 25,
settled_age_ms: 200,
supersede_key: None,
purpose: "sum 1..10".to_string(),
outcome: JobOutcome::Complete(JobResult::Sum(55)),
}],
);
@ -368,6 +410,7 @@ mod tests {
supersede_key: None,
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);

View File

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

View File

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

View File

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

View File

@ -54,6 +54,10 @@ function M.run_git(args, opts)
opts = opts or {}
local id = pmacs.process.spawn {
label = "git " .. (args[1] or ""),
-- Worker identity Stage 1: `purpose` is required. The full argument
-- vector, not just the subcommand the label carries -- "git log" and
-- "git log --oneline -20" are the same label and different work.
purpose = "git " .. table.concat(args, " "),
command = "git",
args = args,
cwd = opts.cwd,

View File

@ -952,7 +952,7 @@ fn has_exit_event(events: &[ProcessEvent]) -> bool {
#[test]
fn m4_4_lifecycle_spawn_and_exit() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("hello", "/bin/sh");
let mut spec = ProcessSpec::new("hello", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "printf hi && exit 0".into()];
let id = sup.spawn(spec).expect("spawn");
let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exit_event);
@ -983,7 +983,7 @@ fn m4_4_lifecycle_spawn_and_exit() {
#[test]
fn m4_4_lifecycle_signal_terminates() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("victim", "/bin/sh");
let mut spec = ProcessSpec::new("victim", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 30".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
@ -1020,7 +1020,11 @@ fn m4_4_lifecycle_signal_terminates() {
fn m4_4_lifecycle_crash_surfaces_as_event() {
let mut sup = ProcessSupervisor::new();
// Path that will reliably not resolve.
let spec = ProcessSpec::new("ghost", "/this/binary/does/not/exist/pmacs-m4-4");
let spec = ProcessSpec::new(
"ghost",
"/this/binary/does/not/exist/pmacs-m4-4",
"test process",
);
let _ = sup.spawn(spec); // spawn returns Err but the event is still emitted
sup.tick();
let evs = sup.take_all_events();
@ -1037,7 +1041,7 @@ fn m4_4_lifecycle_crash_surfaces_as_event() {
fn m4_4_restart_policy_on_crash_respawns() {
let mut sup = ProcessSupervisor::new();
sup.set_restart_backoff(Duration::from_millis(10));
let mut spec = ProcessSpec::new("flap", "/bin/sh");
let mut spec = ProcessSpec::new("flap", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 9".into()];
spec.restart = RestartPolicy::OnCrash;
let id = sup.spawn(spec).expect("spawn");
@ -1070,7 +1074,7 @@ fn m4_4_restart_policy_on_crash_respawns() {
#[test]
fn m4_4_restart_policy_never_does_not_respawn() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("oneshot", "/bin/sh");
let mut spec = ProcessSpec::new("oneshot", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "exit 0".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), has_exit_event);
@ -1099,7 +1103,7 @@ fn m4_4_no_zombies_after_editor_drop() {
let pid: u32 = {
let mut sup = ProcessSupervisor::new();
sup.set_grace_period(Duration::from_millis(200));
let mut spec = ProcessSpec::new("zombie-test", "/bin/sh");
let mut spec = ProcessSpec::new("zombie-test", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "sleep 60".into()];
let id = sup.spawn(spec).expect("spawn");
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
@ -1136,7 +1140,7 @@ fn m4_4_no_zombies_after_editor_drop() {
#[test]
fn m4_4_pty_mode_child_observes_a_tty() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("ttytest", "/bin/sh");
let mut spec = ProcessSpec::new("ttytest", "/bin/sh", "test process");
spec.args = vec!["-c".into(), "tty".into()];
spec.mode = ProcessMode::default_pty();
let id = sup.spawn(spec).expect("spawn");
@ -1169,6 +1173,7 @@ fn m4_4_lua_surface_drives_lifecycle() {
r#"
local id = pmacs.process.spawn {
label = "lua-hello",
purpose = "greeting the Lua surface end to end",
command = "/bin/sh",
args = { "-c", "printf hi-from-lua && exit 0" },
}

View File

@ -136,7 +136,12 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["mode", "terminal", "lsp"],
// `activity` is worker identity Stage 1's fourth adopter, and it
// sorts first because `async.lua` is loaded before `syntax.lua`,
// `terminal.lua` and `lsp.lua`. This is an INVENTORY assertion:
// it grows when a builtin provider is added, which is exactly
// what it is for.
["activity", "mode", "terminal", "lsp"],
"built-in providers are discoverable in registration order"
);
let before_epochs = {
@ -789,7 +794,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
// appended the panel family as v21; long-lines Stage 3 appended
// `LineWrapFacts` as v22. This acceptance owns the STATUSLINE
// `LineWrapFacts` as v22; Discovery Stage 2 appended
// `MinibufferPromptRows` as v23. This acceptance owns the STATUSLINE
// variant's placement and gate, so it tracks the current wire version
// rather than pinning 18: the v18 floor it actually cares about is asserted
// below and in `peer_accepts_statusline_message`.
@ -798,11 +804,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// three lines on purpose. The ceiling assertion is the load-bearing
// one — it says the supported set ENDS here, which is what makes an
// accidentally-widened set a failure rather than a silent pass.
assert_eq!(PROTOCOL_VERSION, 22);
for version in 6..=22 {
assert_eq!(PROTOCOL_VERSION, 23);
for version in 6..=23 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(23));
assert!(!is_supported_protocol_version(24));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

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

View File

@ -888,9 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}");
}
// Tripwire: a wire bump must be a conscious edit here. v22 is
// Tripwire: a wire bump must be a conscious edit here. v23 is
// `MinibufferPromptRows` (Discovery Stage 2); v22 was
// `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 22);
assert_eq!(PROTOCOL_VERSION, 23);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),

File diff suppressed because it is too large Load Diff