diff --git a/builtin/packages/repl/init.lua b/builtin/packages/repl/init.lua index 3b336f6..df8d5ca 100644 --- a/builtin/packages/repl/init.lua +++ b/builtin/packages/repl/init.lua @@ -259,6 +259,10 @@ function repl.spawn(opts) local spec = { label = name, + -- Worker identity Stage 1: the label is the REPL's session name, + -- which distinguishes two REPLs from each other and says nothing + -- about what is running. The purpose names the interpreter. + purpose = "interactive " .. h._display_name .. " session", command = argv[1], args = args, pty = { rows = rows, cols = cols, mode = "raw" }, diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 22fc643..7645416 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -875,6 +875,11 @@ local function start_run(slot, cmdline, opts) -- stdin, own process group, TERM=dumb. local spec = { label = slot.label, + -- Worker identity Stage 1: the label distinguishes one compile slot + -- from another; the purpose is the command the user actually asked + -- for, which is what they want to see when they wonder why the + -- editor is busy. + purpose = "compiling: " .. cmdline, command = "/bin/sh", args = { "-c", "exec 2>&1; " .. cmdline }, env = { TERM = "dumb" }, diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 09ad280..6ccb9dd 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -494,10 +494,14 @@ local function start_probe(root) -- "lake": a user pointing `command` at an absolute path to lake should -- have THAT probed, not whatever `lake` resolves to on PATH. local spec = { - -- COHERENCE §9: `ProcessSpec.label` is the only identity a process - -- carries, and it is what `pmacs.process.list` renders. A user - -- wondering why their editor touched `lake` finds an owner here. + -- COHERENCE §9: `ProcessSpec.label` identifies the process, and it + -- is what `pmacs.process.list` renders alongside the purpose. A user + -- wondering why their editor touched `lake` finds it here. label = "lean:lake-version-probe", + -- Worker identity Stage 1: the label was carrying both jobs — the + -- identity AND the explanation — which is the conflation the purpose + -- field exists to undo. The label stays a key; this is the sentence. + purpose = "checking the Lean toolchain version before starting a server", command = cfg.command, args = { "--version" }, stdin = "null", diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 9535f81..c47b678 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8746,24 +8746,66 @@ fn parse_restart(name: &str) -> mlua::Result { }) } +/// Read the **required** `purpose` out of a `pmacs.process.spawn` spec +/// (worker identity Stage 1, `COHERENCE.md` §9). +/// +/// An earlier revision of this lane defaulted the field to `label` so +/// that existing callers kept working. That preserved compatibility and +/// delivered nothing: §9's complaint about `ProcessSpec` is precisely +/// that `label` is "caller-supplied, unvalidated convention", so a +/// purpose defaulting to the label hands every caller back the +/// convention this lane exists to replace. +/// +/// The two fields answer different questions and neither substitutes for +/// the other. `label` **identifies** — `lsp:rust-analyzer`, a terminal's +/// buffer name — so that two processes running the same binary can be +/// told apart. `purpose` **describes**: it answers "what is happening", +/// which is the question §3's promise of visible asynchronous work is +/// about, and which a label chosen for uniqueness routinely does not +/// answer. +/// +/// # Errors +/// +/// Absent, empty, whitespace-only, or non-string. Empty and +/// whitespace-only are rejected because they satisfy the type and defeat +/// the point exactly as copying the label across would — R42 already +/// rejects whitespace-only `description`s in the config registry for the +/// same reason. +/// +/// The read is **raw**, matching the posture `stdin` and `group` already +/// document in [`lua_to_spec`]: a spec table is plain data, so a +/// metatable cannot smuggle a purpose in through `__index`. +fn required_purpose(table: &Table) -> mlua::Result { + let purpose = match table.raw_get::("purpose") { + Ok(mlua::Value::String(value)) => value.to_str()?.to_owned(), + Ok(mlua::Value::Nil) => { + return Err(mlua::Error::external( + "pmacs.process.spawn: purpose is required — a short description of what \ + this process is DOING, e.g. purpose = \"running the project's test suite\". \ + It is not the label: the label identifies the process, the purpose says \ + what it is for.", + )); + } + Ok(other) => { + return Err(mlua::Error::external(format!( + "pmacs.process.spawn: purpose must be a string; got {}", + other.type_name() + ))); + } + Err(error) => return Err(error), + }; + if purpose.trim().is_empty() { + return Err(mlua::Error::external( + "pmacs.process.spawn: purpose must not be empty or whitespace-only", + )); + } + Ok(purpose) +} + fn lua_to_spec(table: &Table) -> mlua::Result { 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::>("purpose") - .ok() - .flatten() - .unwrap_or_else(|| label.clone()); + let purpose = required_purpose(table)?; let args: Vec = table.get("args").unwrap_or_default(); let cwd: Option = table.get("cwd").ok().flatten(); let env_table: Option = table.get("env").ok().flatten(); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 1801641..54ecf37 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -1836,7 +1836,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() { &s, r#" local ok, err = pcall(pmacs.process.spawn, - { label = "t", command = "/bin/true", stdin = true }) + { label = "t", purpose = "type-check probe", command = "/bin/true", + stdin = true }) return ok, tostring(err) "#, ); @@ -1846,7 +1847,8 @@ fn r1f6_wrong_spec_types_error_instead_of_defaulting() { &s, r#" local ok, err = pcall(pmacs.process.spawn, - { label = "t", command = "/bin/true", group = "true" }) + { label = "t", purpose = "type-check probe", command = "/bin/true", + group = "true" }) return ok, tostring(err) "#, ); @@ -2234,7 +2236,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() { &s, r#" local spec = setmetatable( - { label = "mt", command = "/bin/sh", args = { "-c", "sleep 30" } }, + { label = "mt", purpose = "raw-read probe", command = "/bin/sh", + args = { "-c", "sleep 30" } }, { __index = function(_, k) if k == "group" then return true end return nil @@ -2265,7 +2268,8 @@ fn r3f3_spec_fields_are_raw_reads_metatables_not_honored() { &s, r#" local spec = setmetatable( - { label = "mt2", command = "/bin/sh", args = { "-c", "exit 0" } }, + { label = "mt2", purpose = "raw-read probe", command = "/bin/sh", + args = { "-c", "exit 0" } }, { __index = function() error("hostile spec metatable") end }) local ok = pcall(pmacs.process.spawn, spec) return ok diff --git a/tests/fixtures/pmacs-magit/status.lua b/tests/fixtures/pmacs-magit/status.lua index 283b9a6..59077c0 100644 --- a/tests/fixtures/pmacs-magit/status.lua +++ b/tests/fixtures/pmacs-magit/status.lua @@ -54,6 +54,10 @@ function M.run_git(args, opts) opts = opts or {} local id = pmacs.process.spawn { label = "git " .. (args[1] or ""), + -- Worker identity Stage 1: `purpose` is required. The full argument + -- vector, not just the subcommand the label carries -- "git log" and + -- "git log --oneline -20" are the same label and different work. + purpose = "git " .. table.concat(args, " "), command = "git", args = args, cwd = opts.cwd, diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index d182eb5..d706305 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1173,6 +1173,7 @@ fn m4_4_lua_surface_drives_lifecycle() { r#" local id = pmacs.process.spawn { label = "lua-hello", + purpose = "greeting the Lua surface end to end", command = "/bin/sh", args = { "-c", "printf hi-from-lua && exit 0" }, } diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs index d7bdbfb..96decfb 100644 --- a/tests/worker_identity_acceptance.rs +++ b/tests/worker_identity_acceptance.rs @@ -264,33 +264,84 @@ fn every_entry_shape_records_what_its_work_is() { 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. +/// **`pmacs.process.spawn` REFUSES a spec with no purpose, and spawns +/// nothing.** /// -/// 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. +/// An earlier revision of this lane defaulted the field to `label` so +/// that existing callers kept working. That preserved compatibility and +/// delivered nothing: §9's complaint about `ProcessSpec` is precisely +/// that `label` is "caller-supplied, unvalidated convention", so a +/// purpose defaulting to the label hands every caller back the +/// convention this lane exists to replace. +/// +/// Four refusals, each asserted the same way — the call raises, the +/// message names the field, and **the process list is unchanged**, +/// because a validation that rejects after spawning has already done the +/// thing it was rejecting: +/// +/// * absent; +/// * empty, and whitespace-only — these satisfy the type and defeat the +/// point exactly as copying the label would (R42 rejects +/// whitespace-only config descriptions for the same reason); +/// * wrong type; +/// * **metatable-provided**, which is the `stdin`/`group` raw-read +/// posture: a spec table is plain data, so a purpose cannot be +/// smuggled in through `__index`. #[test] -fn a_process_spawned_without_a_purpose_falls_back_to_its_label() { +fn spawning_without_a_real_purpose_is_refused_and_starts_nothing() { 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 ''", - ); - assert_eq!(purpose, "legacy-caller"); - exec(&state, "pmacs.process.terminate(P)"); + let baseline: usize = eval(&state, "return #pmacs.process.list()"); + + for (label, spec, expected) in [ + ( + "absent", + r#"{ label = "x", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "purpose is required", + ), + ( + "empty", + r#"{ label = "x", purpose = "", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "must not be empty", + ), + ( + "whitespace-only", + r#"{ label = "x", purpose = " ", command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "must not be empty", + ), + ( + "wrong type", + r#"{ label = "x", purpose = 7, command = "/bin/sh", args = { "-c", "sleep 5" } }"#, + "purpose must be a string", + ), + ( + "metatable-provided", + r#"setmetatable( + { label = "x", command = "/bin/sh", args = { "-c", "sleep 5" } }, + { __index = function(_, k) + if k == "purpose" then return "smuggled" end + return nil + end })"#, + "purpose is required", + ), + ] { + let (ok, err): (bool, String) = eval( + &state, + &format!( + "local ok, err = pcall(pmacs.process.spawn, {spec}) + return ok, tostring(err)" + ), + ); + assert!(!ok, "{label}: spawn must refuse"); + assert!( + err.contains(expected), + "{label}: the refusal must name the field and the rule; got {err:?}" + ); + assert_eq!( + eval::(&state, "return #pmacs.process.list()"), + baseline, + "{label}: a refused spawn must start no process" + ); + } pump(&mut state); }