feat(workers): a required purpose on every job and process — worker identity Stage 1

`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. The mechanism half is solid —
cancellation, supersession, streaming, frame-aware draining, `*workers*`.
The identity half was absent: `PendingJob` carried no description of what
it was doing, `pmacs.workers.dispatch` discarded the registered handler
name three layers above anything that takes one, and §9's "no progress
indicator exists anywhere" was checkable and true.

Framing: `docs/worker-identity-framing.md` (revision 4, approved).

What lands:

**A required `purpose`, on the job and on the process.** Non-optional,
with no `Default`, so the compiler — not a test — is what proves every
dispatcher supplied one. `allocate` / `allocate_with_resource` collapse
into ONE private `JobSpec`-taking funnel (Q#W-1): the two-function split
existed only because one prior lane needed one extra parameter, and a
second lane doing the same produces `allocate_with_resource_and_identity`.
`register_external` gains a `purpose` parameter rather than deriving one,
because its `JobKind` is `McpRequest`/`LspRequest` for every method — a
category, not a description.

**A dispatch-name ambient (Q#W-2), read at that same single funnel.** The
capture point is Rust, not the Lua wrapper layer, because a handler
reaching straight for `pmacs._async._dispatch_*` bypasses the wrappers
entirely — and those are precisely the callers attribution exists for.
Seven rules; the ones that decide whether it is honest:

- **Rule 1 — the extent is NON-YIELDABLE, and that is ENFORCED.** Both
  supported yield APIs refuse inside it, modelled on the `commit_to`
  refusal already in `async.lua`. The guards reject BEFORE parking and
  reject UNCONDITIONALLY: one placed after `_is_complete` would fire only
  when a yield really occurred, passing under test and failing
  intermittently in production.
- **A raw `coroutine.yield` is NOT covered, and nothing here claims it
  is.** R46 is a convention, and the scheduler inspects the yielded value
  only after `coroutine.resume` returns — by which point the coroutine has
  already suspended — so no refusal sited in a yield helper is ever
  consulted. The residual is recorded in the framing §2 and in the
  suite's module docs rather than papered over with a test that would
  imply coverage this design lacks.
- **Rule 5 — unwind-safe.** A raising handler still pops. A version that
  did not would let one failure poison every later dispatch in the session
  with a stale name: the feature would stop failing loudly and start lying
  silently. The bracketing also has to preserve the tail call it replaced:
  `dispatch` was `return handler(args, opts)` and propagated EVERY return
  value, so the pop/rethrow runs behind a varargs boundary rather than a
  `local ok, result = pcall(...)` that would silently truncate a
  multi-value handler. Varargs rather than `table.pack`, because that is
  Lua 5.2 surface and LuaJIT is this project's default backend.
- **Rule 6 — compose, do not replace.** `"<name>: <purpose>"`, because
  letting the dispatcher's purpose win loses the third party again and
  letting the name win discards the only description of the actual work.

**A statusline activity indicator** — the fourth `pmacs.statusline.register`
adopter, after `mode`, `terminal` and `lsp`. A count plus the OLDEST
in-flight job's purpose ("busiest" is not a defined quantity; jobs carry
no cost estimate), and **absent entirely** when idle rather than a
zero-width segment that costs modeline width forever to say nothing is
happening. Gated by one setting, `ui.activity-indicator` (boolean, default
true, Q#W-6) — a permanently-visible modeline element is a preference
someone genuinely holds on day one. No setting for purpose capture
itself: that is substrate.

**NO WIRE CHANGE.** The indicator rides the existing `StatuslineSegments`
vector, so a fourth provider adds an element, not a variant.
`PROTOCOL_VERSION` and `ADVERTISED_PROTOCOL_VERSION` are untouched — which
is the property that lets this run beside the two lanes holding the bump
slot.

**Q#W-7 — a pre-existing defect, repaired here, and NOT one anybody has
observed.** `Handle:await()` refuses inside `pmacs.window.commit_to`
precisely so a coroutine cannot park with the frontend scope pushed
(Journey Stage 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also
yields, is public, and carried no such refusal — so that invariant had a
second entrance, and a coroutine could produce exactly the misrouting the
`await` guard exists to prevent. It gains both refusals here: the same
supported yield helper, the same invariant, the same edit family, so
splitting it would have preserved a known hole without reducing
integration risk.

**Reachability by a real caller is UNPROVEN.** This was found by reading
the guard family while scouting rule 1, not by reproducing a fault. No
production caller is known to yield through that door inside a commit,
and the test pins the guard rather than reproducing a user-visible bug.
Nobody should later cite this commit as evidence the bug was observed in
the wild. Its witness is a PAIR, like rule 1's: the refusal fires **and**
the commit scope is restored afterwards — a guard that raises while
leaving the scope pushed converts a silent fault into a loud one and
fixes neither.

`journey_acceptance` carries the established `commit_to` pins —
forged-destination refusal, scope-and-restore on normal return and on
raise, the await refusal, delivery to the requesting frontend. It passes
**untouched**, which is what says this closed a gap in Journey Stage 1a's
semantics rather than altering them.

What is deliberately NOT here, and why it is worth saying:

- **No `owner`, in any spelling** — not `origin`, not `subsystem` (§3).
  Populated from static per-subsystem constants it would be an origin,
  not an owner, and 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. The slot stays empty until P3
  can fill it with a real package signal.
- **No `parent`** (Q#W-5). An unpopulated field renders as `None`
  everywhere and reads as "this job has no parent" rather than "this
  system does not track parents". Stage 3 builds the lifetime model and
  the field together.

Consequences worth recording:

- `ProcessSpec::new` takes a third argument. The 40-odd call sites are
  almost all tests; the three production ones (LSP, MCP, terminal) supply
  real descriptions. `pmacs.process.spawn`'s Lua surface keeps `purpose`
  OPTIONAL, falling back to the label — requiring it there would break
  every existing caller for no coverage the compiler is not already
  providing, and a caller's own label is not a fabrication.
- `pmacs.process.list` gains a `purpose` KEY on each row and enumerates
  exactly the same processes (Q#W-4). Terminal PTYs stay hidden: three
  acceptance suites use `#pmacs.process.list()` as a leak baseline, and
  widening the accessor would inflate all three. Stage 2's unified view
  owns that decision.
- `statusline_segments_acceptance`'s builtin-provider inventory grows to
  `["activity", "mode", "terminal", "lsp"]`. That assertion exists to
  grow when a builtin provider is added.

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-09 16:33:41 +02:00
parent 26a26006bb
commit 689fb8333d
No known key found for this signature in database
13 changed files with 1682 additions and 139 deletions

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,12 +409,63 @@ 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
function pmacs.workers.register(name, handler)
@ -581,6 +675,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

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

View File

@ -408,6 +408,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 +531,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 +551,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 +589,33 @@ 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.
///
/// 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.
pub oldest_purpose: String,
}
/// 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 +680,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 +743,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 +827,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 +931,7 @@ impl AsyncRuntime {
kind,
dispatched_at: Instant::now(),
resource,
purpose,
},
);
(id, cancel)
@ -801,7 +945,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 +966,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 +998,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 +1032,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 +1065,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 +1096,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 +1114,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 +1134,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 +1154,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 +1171,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 +1202,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 +1415,7 @@ impl AsyncRuntime {
dispatched_at: job.dispatched_at,
settled_at: now,
supersede_key: job.supersede_key.clone(),
purpose: job.purpose.clone(),
outcome,
});
}
@ -1243,6 +1453,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 +1473,51 @@ 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,
oldest_purpose: purpose.to_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 +2126,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 +2188,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

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

@ -7566,6 +7566,73 @@ 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.
{
let rt = runtime.clone();
async_mod.set(
"_push_dispatch_name",
lua.create_function(move |_, name: String| {
rt.push_dispatch_name(name);
Ok(())
})?,
)?;
}
{
let rt = runtime.clone();
async_mod.set(
"_pop_dispatch_name",
lua.create_function(move |_, ()| {
rt.pop_dispatch_name();
Ok(())
})?,
)?;
}
// The refusal predicate, the sibling of `_in_commit_scope` above and
// enforced for the same reason: a coroutine that parks inside the
// extent leaves the name pushed, and every job allocated in the
// meantime — in any coroutine, on any later tick — inherits it.
{
let rt = runtime.clone();
async_mod.set(
"_in_dispatch_name_scope",
lua.create_function(move |_, ()| Ok(rt.in_dispatch_name_scope()))?,
)?;
}
// The statusline activity indicator's read surface (Q#W-3). Returns
// `nil` when nothing is in flight — the indicator renders no segment
// at all when idle, so "absent" has to be representable.
{
let rt = runtime.clone();
async_mod.set(
"_activity_summary",
lua.create_function(move |lua, ()| {
let Some(summary) = rt.activity_summary() else {
return Ok(mlua::Value::Nil);
};
let t = lua.create_table_with_capacity(0, 2)?;
t.set("in_flight", summary.in_flight)?;
t.set("purpose", summary.oldest_purpose)?;
Ok(mlua::Value::Table(t))
})?,
)?;
}
{
let rt = runtime.clone();
async_mod.set(
@ -7728,7 +7795,7 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
let out = lua.create_table()?;
let active = lua.create_table_with_capacity(snap.active.len(), 0)?;
for (i, job) in snap.active.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 6)?;
let row = lua.create_table_with_capacity(0, 7)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("age_ms", job.age_ms)?;
@ -7737,12 +7804,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
}
row.set("cancel_requested", job.cancel_requested)?;
row.set("is_stream", job.is_stream)?;
row.set("purpose", job.purpose.as_str())?;
active.set(i + 1, row)?;
}
out.set("active", active)?;
let completed = lua.create_table_with_capacity(snap.completed.len(), 0)?;
for (i, job) in snap.completed.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 7)?;
let row = lua.create_table_with_capacity(0, 8)?;
row.set("id", job.id)?;
row.set("kind", job.kind.label())?;
row.set("duration_ms", job.duration_ms)?;
@ -7750,6 +7818,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)) => (
@ -8680,6 +8749,21 @@ fn parse_restart(name: &str) -> mlua::Result<RestartPolicy> {
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")?;
// Worker identity Stage 1: required on the Rust struct, optional at
// this surface, falling back to the label.
//
// Requiring it here would break every existing `pmacs.process.spawn`
// caller, and the compiler obligation this lane is buying is on the
// *Rust* construction sites — the ones a future field would silently
// skip. A Lua caller that supplies nothing gets its own label back,
// which is what the caller already chose to call this work; it is
// less informative than a real description but it is not a
// fabrication, which is the bar `owner` failed (framing §3).
let purpose: String = table
.get::<Option<String>>("purpose")
.ok()
.flatten()
.unwrap_or_else(|| label.clone());
let args: Vec<String> = table.get("args").unwrap_or_default();
let cwd: Option<String> = table.get("cwd").ok().flatten();
let env_table: Option<Table> = table.get("env").ok().flatten();
@ -8762,6 +8846,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
};
Ok(ProcessSpec {
label,
purpose,
command,
args,
cwd: cwd.map(std::path::PathBuf::from),
@ -8985,11 +9070,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

@ -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,34 @@
//! ```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.
//!
//! 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
@ -43,6 +58,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 +139,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 +163,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 +197,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 = &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 +211,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 = &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 +320,7 @@ mod tests {
supersede_key: Some("search".to_string()),
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -309,6 +343,7 @@ mod tests {
supersede_key: None,
cancel_requested: true,
is_stream: false,
purpose: "grep pattern".to_string(),
}],
vec![],
);
@ -326,6 +361,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 +404,7 @@ mod tests {
supersede_key: None,
cancel_requested: false,
is_stream: true,
purpose: "grep pattern".to_string(),
}],
vec![],
);

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

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

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

@ -0,0 +1,849 @@
// tests/worker_identity_acceptance.rs --- worker identity Stage 1.
//! Worker identity Stage 1 (`docs/worker-identity-framing.md` §6,
//! `COHERENCE.md` §9).
//!
//! §9 grades the worker model **mechanism without identity**: a job
//! carries a `JobKind` naming the builtin dispatcher it funnelled
//! through, so every third-party job renders under a builtin's label,
//! and no progress indicator exists anywhere. This suite pins what
//! Stage 1 does about that — a required `purpose` on the job and the
//! process, the dispatch-name ambient that stops
//! `pmacs.workers.dispatch` discarding its handler name, and the first
//! indicator a user sees without running a command.
//!
//! # What is NOT here, deliberately
//!
//! **Presence is enforced by the COMPILER, not by anything below.**
//! `JobSpec::purpose` is non-optional and `JobSpec` has no `Default`, so
//! a dispatcher that supplies none does not build. A funnel test would
//! prove only that the funnel stores what it was handed, and would say
//! nothing about whether fourteen callers handed it anything meaningful.
//! Everything below is about *semantics*.
//!
//! **That a raw `coroutine.yield` inside either dynamic scope is
//! prevented — it is not.** R46 forbids package code from yielding
//! raw, but it is a convention, and the scheduler diagnoses a non-Handle
//! yield only *after* the coroutine has suspended (`async.lua` resumes,
//! then inspects what came back), so no refusal sited in a yield helper
//! is ever consulted. Rule 1 claims **the two supported yield APIs** and
//! nothing more. A test that "proved" coverage this design does not have
//! would be worse than the recorded gap, so the gap is recorded instead
//! (framing §2, §6, §7).
//!
//! **That background work is attributable from one place** (Stage 2's
//! unified view), **that a terminal PTY is visible anywhere** (Q#W-4),
//! or **that any job is attributed to the PACKAGE responsible for it**.
//! `purpose` records what work is being done and, under
//! `pmacs.workers.dispatch`, which registered handler it ran under.
//! Neither is package ownership, which waits for P3 — and there is no
//! `owner` field, in any spelling, for it to squat on (framing §3, §7).
use std::collections::HashMap;
use std::time::{Duration, Instant};
use pmacs::async_runtime::JobKind;
use pmacs::cell::{Cell, CellGrid, CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, StatuslineProviderId,
evaluate_statusline,
};
#[path = "common/iso.rs"]
mod iso;
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
fn exec(state: &EditorState, source: &str) {
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
}
fn editor() -> EditorState {
let state = EditorState::new_with_roots(&iso::roots());
exec(&state, "pmacs.lsp.config = {}");
state
}
/// Drive the async runtime until nothing is in flight and no coroutine
/// is parked. How many frames that takes is not knowable in advance, so
/// this never counts them.
///
/// Quiescence is measured as **no `Running` job**, not as an empty
/// pending table. Most jobs here are dispatched and never awaited —
/// that is the shape the indicator exists to describe — and a settled
/// entry stays in the pending table until someone takes its result, so
/// `pending_count() == 0` would never come true.
fn pump(state: &mut EditorState) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let idle: bool = eval(
state,
"return pmacs._async.parked_count() == 0
and #pmacs.workers.snapshot().active == 0",
);
if idle {
return;
}
assert!(Instant::now() < deadline, "async pump deadline exceeded");
state.tick_async();
}
}
/// The purposes of every job the runtime currently has in flight.
///
/// Read through the **Lua** snapshot surface, which is what `*workers*`
/// and any package consume, rather than through the Rust struct.
fn active_purposes(state: &EditorState) -> Vec<String> {
eval(
state,
"local out = {}
for _, job in ipairs(pmacs.workers.snapshot().active) do
out[#out + 1] = job.purpose
end
return out",
)
}
fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
let mut cells = vec![Cell::default(); (rows * cols) as usize];
let mut grid = CellGrid {
cells: &mut cells,
stride: cols,
size: CellSize::new(rows, cols),
};
let _ = pmacs::editor::paint_frame(
state,
FrontendId::LOCAL,
&HashMap::new(),
&mut grid,
CellSize::new(rows, cols),
);
cells
}
fn row_text(cells: &[Cell], cols: u32, row: u32) -> String {
(0..cols)
.map(
|column| match &cells[(row * cols + column) as usize].glyph {
Glyph::Char(ch) => *ch,
Glyph::Cluster(bytes) => std::str::from_utf8(bytes)
.ok()
.and_then(|text| text.chars().next())
.unwrap_or(' '),
Glyph::Continuation => ' ',
},
)
.collect()
}
/// The registration handle of the builtin activity provider.
fn activity_provider(state: &EditorState) -> StatuslineProviderId {
state
.statusline_registry
.borrow()
.providers()
.into_iter()
.find(|provider| provider.name == "activity")
.expect("builtin activity provider")
.id
}
/// The activity provider's segment for `LOCAL`'s only window, or `None`
/// when it produced **no segment at all**.
///
/// `Option<String>`, never `String`, is the whole point of this helper:
/// "absent" and "empty" must be distinguishable, because a zero-width
/// segment still consumes a separator in the composed modeline.
fn activity_segment(state: &EditorState) -> Option<String> {
let id = activity_provider(state);
let evaluation = evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Grid {
frontend_id: FrontendId::LOCAL,
},
);
let StatuslineEvaluationOutcome::Ready(windows) = evaluation.outcome else {
panic!("statusline evaluation must be ready in a single-window editor");
};
windows
.iter()
.flat_map(|window| window.left.iter().chain(window.right.iter()))
.find(|segment| segment.provider_id == id)
.map(|segment| segment.text.clone())
}
/// Dispatch one job that will still be **in flight** when the caller
/// looks, without sleeping.
///
/// A pending entry leaves `Running` only inside `AsyncRuntime::tick`, so
/// a dispatch with no intervening tick is in flight by construction —
/// no wall-clock race, and no worker left sleeping past the test.
fn dispatch_one_in_flight(state: &EditorState) {
exec(state, "IN_FLIGHT = pmacs.workers.sleep(50)");
}
// ---------------------------------------------------------------------------
// 1 — purpose reaches the three structurally distinct entry paths
// ---------------------------------------------------------------------------
/// One per distinct **shape**, not one per dispatcher: a pool
/// dispatcher, an `register_external` job, and a spawned process.
///
/// `register_external` is here because MCP and LSP bypass the worker
/// pool entirely — they are the likeliest paths for a later field to be
/// added to `PendingJob` and quietly missed — and because its `JobKind`
/// is the undifferentiated `LspRequest`/`McpRequest` for every method,
/// so `purpose` is the only thing that tells two of its rows apart.
#[test]
fn every_entry_shape_records_what_its_work_is() {
let mut state = editor();
// (a) A pool dispatcher.
dispatch_one_in_flight(&state);
let purposes = active_purposes(&state);
assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}");
assert_eq!(
purposes[0], "sleep 50ms",
"a pool job records the work, not just its handler's name"
);
// (b) An externally-settled job. The purpose is a PARAMETER here
// because `register_external` has nothing to derive one from: its
// kind is a category, not a description.
let (job_id, _token) = state.async_runtime.register_external(
JobKind::LspRequest,
None,
"lsp textDocument/definition file:///tmp/x.rs",
);
let purposes = active_purposes(&state);
assert!(
purposes
.iter()
.any(|p| p == "lsp textDocument/definition file:///tmp/x.rs"),
"an externally-registered job carries its caller's description: {purposes:?}"
);
state.async_runtime.complete_external_cancelled(job_id);
// (c) A spawned process. `label` keeps its existing meaning and its
// existing callers; `purpose` is the new, separate answer to "what
// is this doing".
exec(
&state,
r#"P = pmacs.process.spawn {
label = "sh-1",
purpose = "probing the repository for a build system",
command = "/bin/sh",
args = { "-c", "sleep 5" },
}"#,
);
let rows: Vec<String> = eval(
&state,
"local out = {}
for _, row in ipairs(pmacs.process.list()) do
out[#out + 1] = row.label .. ' | ' .. row.purpose
end
return out",
);
assert!(
rows.iter()
.any(|row| row == "sh-1 | probing the repository for a build system"),
"a spawned process carries a purpose ALONGSIDE its label: {rows:?}"
);
exec(&state, "pmacs.process.terminate(P)");
pump(&mut state);
}
/// A `pmacs.process.spawn` caller that supplies no purpose keeps
/// working, and gets its own label back rather than an empty field.
///
/// The Rust struct's field is required — the compiler enforces that at
/// every construction site. This surface is deliberately lenient,
/// because requiring it here would break every existing caller for no
/// coverage the compiler is not already providing.
#[test]
fn a_process_spawned_without_a_purpose_falls_back_to_its_label() {
let mut state = editor();
exec(
&state,
r#"P = pmacs.process.spawn {
label = "legacy-caller",
command = "/bin/sh",
args = { "-c", "sleep 5" },
}"#,
);
let purpose: String = eval(
&state,
"for _, row in ipairs(pmacs.process.list()) do
if row.label == 'legacy-caller' then return row.purpose end
end
return '<absent>'",
);
assert_eq!(purpose, "legacy-caller");
exec(&state, "pmacs.process.terminate(P)");
pump(&mut state);
}
/// Q#W-4's preservation half, pinned here as well as by the three
/// leak-detector suites: `purpose` is a new KEY on each existing row and
/// changes nothing about **which** processes `list()` enumerates.
///
/// `m6_8_multi_repl_acceptance`, `compile_mode_acceptance` and
/// `lean4_stage1_acceptance` all assert on `#pmacs.process.list()` as a
/// leak baseline. If any of them needs editing, the design is wrong.
#[test]
fn process_list_still_hides_terminal_ptys() {
let mut state = editor();
let before: usize = eval(&state, "return #pmacs.process.list()");
exec(
&state,
"T = pmacs.terminal.open { command = '/bin/sh', args = { '-c', 'sleep 5' } }",
);
let after: usize = eval(&state, "return #pmacs.process.list()");
assert_eq!(
before, after,
"a terminal PTY must stay invisible to pmacs.process.list (Q#W-4)"
);
assert!(
eval::<bool>(&state, "return pmacs.terminal.is_terminal(T)"),
"precondition: the PTY really was opened"
);
// No explicit close: terminals have no Lua teardown surface, and
// `EditorState::drop` shuts the supervisor down with SIGTERM then
// SIGKILL, so the child cannot outlive the test.
pump(&mut state);
}
// ---------------------------------------------------------------------------
// 2 — the dispatch-name ambient (Q#W-2)
// ---------------------------------------------------------------------------
/// **Rule 7 + the defect itself.** A job dispatched through
/// `pmacs.workers.dispatch("name", …)` reports `"name"`.
///
/// The witness is a handler **registered from Lua that calls a real
/// dispatcher**, not a synthetic push of the ambient. A test that
/// pushed the name by hand would prove the stack works and leave the
/// actual defect — `name` dying inside an arbitrary handler, three
/// layers above anything that takes a name — completely unwitnessed.
#[test]
fn a_dispatched_job_reports_the_registered_handler_name() {
let mut state = editor();
exec(
&state,
"pmacs.workers.register('indexer', function()
return pmacs.workers.sleep(50)
end)
H = pmacs.workers.dispatch('indexer')",
);
let purposes = active_purposes(&state);
assert_eq!(purposes.len(), 1, "one job in flight: {purposes:?}");
assert!(
purposes[0].starts_with("indexer"),
"the third party's own name must survive the call chain: {purposes:?}"
);
// Rule 7: outside any extent, nothing changes.
exec(&state, "DIRECT = pmacs.workers.sleep(50)");
let purposes = active_purposes(&state);
assert!(
purposes.iter().any(|p| p == "sleep 50ms"),
"a builtin invoked directly records its own purpose: {purposes:?}"
);
pump(&mut state);
}
/// **Rule 6 — COMPOSE, do not replace.** Both halves asserted, because
/// a test on the prefix alone passes when the description is dropped,
/// and a test on the description alone passes when the third party is
/// lost again.
#[test]
fn a_dispatched_job_composes_the_handler_name_with_the_work() {
let mut state = editor();
exec(
&state,
"pmacs.workers.register('indexer', function()
return pmacs.workers.sleep(50)
end)
H = pmacs.workers.dispatch('indexer')",
);
let purposes = active_purposes(&state);
assert_eq!(
purposes,
vec!["indexer: sleep 50ms".to_owned()],
"letting the name win discards the work; letting the work win \
loses the third party"
);
pump(&mut state);
}
/// **Rules 3 and 4 — nesting is a stack (innermost wins) and fan-out
/// shares the name.**
#[test]
fn nesting_takes_the_innermost_name_and_fan_out_shares_it() {
let mut state = editor();
exec(
&state,
"pmacs.workers.register('inner', function()
-- Fan-out: two jobs under one handler.
A = pmacs.workers.sleep(50)
B = pmacs.workers.sleep(51)
return A
end)
pmacs.workers.register('outer', function()
pmacs.workers.dispatch('inner')
-- Back in `outer`'s extent: the stack restored on return.
C = pmacs.workers.sleep(52)
return C
end)
pmacs.workers.dispatch('outer')",
);
let mut purposes = active_purposes(&state);
purposes.sort();
assert_eq!(
purposes,
vec![
"inner: sleep 50ms".to_owned(),
"inner: sleep 51ms".to_owned(),
"outer: sleep 52ms".to_owned(),
],
"innermost wins inside, and the outer name is restored after"
);
pump(&mut state);
}
/// **Rule 5 — unwind-safe, and this is the one that makes a naive
/// version worse than none.**
///
/// A handler that raises must still pop. Otherwise one failure poisons
/// every subsequent dispatch in the session with a stale name, and the
/// feature stops failing loudly and starts lying silently — a
/// regression that would surface as intermittent misattribution long
/// after the lane landed.
#[test]
fn a_raising_handler_still_pops_its_name() {
let mut state = editor();
exec(
&state,
"pmacs.workers.register('boom', function() error('handler failed') end)
OK, ERR = pcall(pmacs.workers.dispatch, 'boom')",
);
assert!(
!eval::<bool>(&state, "return OK"),
"the handler's error must still reach the caller"
);
assert!(
eval::<String>(&state, "return tostring(ERR)").contains("handler failed"),
"and must reach it unchanged"
);
exec(&state, "LATER = pmacs.workers.sleep(50)");
let purposes = active_purposes(&state);
assert_eq!(
purposes,
vec!["sleep 50ms".to_owned()],
"an unrelated later dispatch must not inherit the failed \
handler's name: {purposes:?}"
);
pump(&mut state);
}
/// **Preservation.** `pmacs.workers.dispatch` was `return
/// handler(args, opts)` — a tail call that propagates **every** return
/// value. Bracketing it must not quietly truncate that.
///
/// A `local ok, result = pcall(...)` bracketing would pass every other
/// test in this file and lose a two-value handler's second value with no
/// error anywhere, which is the shape of regression that surfaces months
/// later in somebody else's package.
#[test]
fn dispatch_still_propagates_every_value_the_handler_returns() {
let mut state = editor();
let values: Vec<String> = eval(
&state,
"pmacs.workers.register('multi', function()
return pmacs.workers.sleep(50), 'second', 'third'
end)
local a, b, c = pmacs.workers.dispatch('multi')
return { type(a), tostring(b), tostring(c) }",
);
assert_eq!(
values,
vec!["table".to_owned(), "second".to_owned(), "third".to_owned()],
"a multi-value handler must survive the bracketing"
);
pump(&mut state);
}
/// **Rule 2 — work dispatched LATER is not covered, deliberately.**
///
/// A job dispatched from an `on_complete` callback runs ticks later,
/// outside the extent, and carries only its own purpose. Asserted so
/// that the boundary reads as designed rather than as broken; covering
/// it would need the asynchronous lifetime mechanism Stage 3 owns
/// (Q#W-5).
#[test]
fn work_dispatched_from_a_completion_callback_carries_no_handler_name() {
let mut state = editor();
exec(
&state,
"LATE = nil
pmacs.workers.register('deferred', function()
local h = pmacs.workers.sleep(1)
h:on_complete(function()
LATE = pmacs.workers.sleep(50)
end)
return h
end)
pmacs.workers.dispatch('deferred')",
);
// One tick settles the first job and fires the callback; the job the
// callback dispatches is what this test is about, so do not pump to
// quiescence before reading it.
let deadline = Instant::now() + Duration::from_secs(10);
while !eval::<bool>(&state, "return LATE ~= nil") {
assert!(Instant::now() < deadline, "callback never fired");
state.tick_async();
}
let purposes = active_purposes(&state);
assert_eq!(
purposes,
vec!["sleep 50ms".to_owned()],
"the extent is the handler CALL, not the job's lifetime: {purposes:?}"
);
pump(&mut state);
}
// ---------------------------------------------------------------------------
// 3 — rule 1: the extent is non-yieldable, and that is ENFORCED
// ---------------------------------------------------------------------------
/// **Rule 1, first supported yield API.** Two assertions, and the
/// second is the load-bearing one.
///
/// A guard that raises but leaves the name pushed has converted a silent
/// misattribution into a silent misattribution *plus* an error. So the
/// witness dispatches again after the rejection and asserts the new job
/// carries no stale name.
#[test]
fn awaiting_inside_a_handler_is_refused_and_the_scope_restores() {
let mut state = editor();
// The awaited handle is created OUTSIDE the extent on purpose: the
// second assertion below is about what a job allocated *after* the
// refusal carries, and a job the handler allocated for itself would
// legitimately wear the handler's name and blur that.
exec(
&state,
"OUTSIDE = pmacs.workers.sleep(1)
REFUSAL = nil
pmacs.workers.register('awaits', function()
local ok, err = pcall(function() return OUTSIDE:await() end)
REFUSAL = (not ok) and tostring(err) or '<no raise>'
return OUTSIDE
end)
pmacs.async(function() pmacs.workers.dispatch('awaits') end)",
);
let refusal: String = eval(&state, "return REFUSAL");
assert!(
refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"),
"the refusal must name the rule it enforces; got {refusal:?}"
);
assert!(
!eval::<bool>(&state, "return pmacs._async._in_dispatch_name_scope()"),
"a refused await must still leave the scope popped"
);
exec(&state, "AFTER = pmacs.workers.sleep(50)");
let purposes = active_purposes(&state);
assert!(
purposes.iter().any(|p| p == "sleep 50ms"),
"and a later dispatch must carry no stale name: {purposes:?}"
);
assert!(
!purposes.iter().any(|p| p.starts_with("awaits:")),
"no job allocated after the refusal may inherit the handler's \
name: {purposes:?}"
);
pump(&mut state);
}
/// **Rule 1, unconditionally.** The refusal fires even when the awaited
/// handle has already settled.
///
/// This is the case that separates an unconditional guard from one whose
/// behaviour depends on a race: a guard placed after the `_is_complete`
/// check would fire only when a yield would really occur, passing under
/// test and failing intermittently in production depending on whether
/// the job happened to finish first.
#[test]
fn the_await_refusal_fires_even_for_an_already_complete_handle() {
let mut state = editor();
exec(&state, "SETTLED = pmacs.workers.sleep(0)");
let deadline = Instant::now() + Duration::from_secs(10);
while !eval::<bool>(&state, "return SETTLED:is_complete()") {
assert!(Instant::now() < deadline, "the canary never settled");
state.tick_async();
}
exec(
&state,
"REFUSAL = nil
pmacs.workers.register('awaits-settled', function()
local ok, err = pcall(function() return SETTLED:await() end)
REFUSAL = (not ok) and tostring(err) or '<no raise>'
return pmacs.workers.sleep(50)
end)
pmacs.workers.dispatch('awaits-settled')",
);
let refusal: String = eval(&state, "return REFUSAL");
assert!(
refusal.contains("cannot await inside") && refusal.contains("pmacs.workers.dispatch"),
"a settled handle must be refused too, or the guard's behaviour \
depends on a race; got {refusal:?}"
);
assert!(
!eval::<bool>(&state, "return pmacs._async._in_dispatch_name_scope()"),
"and the scope must still be popped"
);
pump(&mut state);
}
/// **Rule 1, second supported yield API.** Guarding `:await()` and not
/// `yield_to_next_tick` would leave the extent open through a second
/// door — and Q#W-7 below is the proof that exactly that happens when
/// only one door is guarded.
#[test]
fn yield_to_next_tick_inside_a_handler_is_refused_and_the_scope_restores() {
let mut state = editor();
exec(
&state,
"REFUSAL = nil
pmacs.workers.register('yields', function()
local ok, err = pcall(pmacs.async.yield_to_next_tick)
REFUSAL = (not ok) and tostring(err) or '<no raise>'
return pmacs.workers.sleep(50)
end)
pmacs.async(function() pmacs.workers.dispatch('yields') end)",
);
let refusal: String = eval(&state, "return REFUSAL");
assert!(
refusal.contains("cannot yield inside") && refusal.contains("pmacs.workers.dispatch"),
"the second yield API must refuse too; got {refusal:?}"
);
assert!(
!eval::<bool>(&state, "return pmacs._async._in_dispatch_name_scope()"),
"and must leave the scope popped"
);
exec(&state, "AFTER = pmacs.workers.sleep(51)");
let purposes = active_purposes(&state);
assert!(
purposes.iter().any(|p| p == "sleep 51ms"),
"a later dispatch must carry no stale name: {purposes:?}"
);
pump(&mut state);
}
// ---------------------------------------------------------------------------
// 4 — Q#W-7: the same hole in `commit_to`, closed here
// ---------------------------------------------------------------------------
/// **Q#W-7 — a pre-existing defect, found by reading and repaired in
/// this lane.**
///
/// `Handle:await()` refuses inside `pmacs.window.commit_to` precisely so
/// a coroutine cannot park with the frontend scope pushed (Journey Stage
/// 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also yields, is
/// public, and carried **no** such refusal — so that invariant had a
/// second entrance.
///
/// **Reachability by a real caller is UNPROVEN.** No production caller
/// is known to yield through this door inside a commit; this pins the
/// guard rather than reproducing a user-visible bug.
///
/// Both halves asserted, for the same reason as rule 1's: a refusal that
/// leaves the scope pushed swaps a silent misrouting for a loud one and
/// fixes neither.
#[test]
fn yield_to_next_tick_inside_commit_to_is_refused_and_the_commit_scope_restores() {
let mut state = editor();
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write");
// A GENUINE destination, produced by the production capture: the
// listener claims (returns false), so nothing commits and what lands
// in `dest` is exactly the userdata dired would have received.
// Nothing in a test can construct one.
exec(
&state,
"dest = nil
pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)",
);
state.open_directory_target(dir.path());
pump(&mut state);
assert!(
eval::<bool>(&state, "return dest ~= nil"),
"the chain must hand listeners a destination"
);
exec(
&state,
"REFUSAL = nil
pmacs.async(function()
local ok, err = pcall(pmacs.window.commit_to, dest, function()
pmacs.async.yield_to_next_tick()
end)
REFUSAL = (not ok) and tostring(err) or '<no raise>'
end)",
);
let refusal: String = eval(&state, "return REFUSAL");
assert!(
refusal.contains("cannot yield inside") && refusal.contains("commit_to"),
"the second door into the commit scope must be shut; got {refusal:?}"
);
assert!(
!eval::<bool>(&state, "return pmacs._async._in_commit_scope()"),
"and the commit scope must still be restored afterwards"
);
pump(&mut state);
}
// ---------------------------------------------------------------------------
// 5 — the statusline activity indicator (Q#W-3, Q#W-6)
// ---------------------------------------------------------------------------
/// **Absent at zero, asserted as an absent SEGMENT rather than as an
/// empty string.** A zero-width segment still consumes a separator in
/// the composed modeline, so "returns nothing" and "returns nothing
/// visible" are different claims and only one of them is the design.
#[test]
fn the_indicator_produces_no_segment_at_all_when_nothing_is_running() {
let state = editor();
assert_eq!(
activity_segment(&state),
None,
"an idle editor must produce NO activity segment"
);
}
/// **A count plus the oldest in-flight job's purpose, witnessed through
/// the real per-frame evaluation path.**
///
/// Driven through `paint_frame`, not by calling the provider function
/// directly: a provider that works in isolation and never gets evaluated
/// is exactly the failure this must exclude.
#[test]
fn the_indicator_shows_a_count_and_the_oldest_purpose_in_a_painted_frame() {
let mut state = editor();
exec(
&state,
"FIRST = pmacs.workers.sleep(50)
SECOND = pmacs.workers.grep({ root = '/tmp', pattern = 'zzz-no-match' })",
);
let cells = paint(&state, 24, 160);
let modeline = row_text(&cells, 160, 22);
assert!(
modeline.contains("⋯2 sleep 50ms"),
"the painted modeline must carry the count and the OLDEST job's \
purpose (not the newest); got {modeline:?}"
);
// And the same value reaches the evaluator's segment vector, which is
// what the semantic frontend ships.
assert_eq!(
activity_segment(&state).as_deref(),
Some("⋯2 sleep 50ms"),
"the segment and the painted row must agree"
);
exec(&state, "SECOND:cancel()");
pump(&mut state);
}
/// **Q#W-6 — the setting, witnessed with work genuinely in flight.**
///
/// The discriminating case: an assertion taken on an idle editor cannot
/// tell "disabled" from "nothing is happening", which is the only thing
/// this setting changes.
#[test]
fn the_indicator_honours_its_setting_while_work_is_in_flight() {
let mut state = editor();
dispatch_one_in_flight(&state);
assert!(
activity_segment(&state).is_some(),
"precondition: work is in flight and the indicator is on"
);
exec(&state, "pmacs.config.set('ui.activity-indicator', false)");
assert_eq!(
activity_segment(&state),
None,
"disabled means NO segment, with work still running"
);
exec(&state, "pmacs.config.set('ui.activity-indicator', true)");
assert!(
activity_segment(&state).is_some(),
"and re-enabling brings it back without a restart"
);
pump(&mut state);
}
/// The setting is a real registry entry, not an ad-hoc global: it is
/// discoverable through `pmacs.config.describe` like every other
/// setting, which is what `COHERENCE.md` §11 grades.
#[test]
fn the_setting_is_registered_with_a_true_default() {
let state = editor();
let (kind, default): (String, bool) = eval(
&state,
"local d = pmacs.config.describe('ui.activity-indicator')
return d.type, d.default",
);
assert_eq!(kind, "boolean");
assert!(default, "visible by default — no configuration, no command");
}
// ---------------------------------------------------------------------------
// 6 — `*workers*` renders the purpose
// ---------------------------------------------------------------------------
/// The view §9 already has, now answering §9's question.
///
/// `Kind` names the builtin dispatcher a job funnelled through, which
/// for a third-party job is a builtin's label rather than the caller's;
/// the `Purpose` column is what carries the caller's own account.
#[test]
fn the_workers_buffer_renders_the_purpose_column() {
let mut state = editor();
exec(
&state,
"pmacs.workers.register('indexer', function()
return pmacs.workers.sleep(50)
end)
pmacs.workers.dispatch('indexer')
BUF = pmacs.workers.show()",
);
let text: String = eval(&state, "return BUF:slice(0, BUF:len())");
assert!(
text.contains("Purpose"),
"the active table must have a Purpose column:\n{text}"
);
assert!(
text.contains("indexer: sleep 50ms"),
"and the row must render it:\n{text}"
);
exec(&state, "pmacs.workers.hide()");
pump(&mut state);
}