From a2b12dc9d63c04f59096ff66352e69232241d528 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 13 Jul 2026 15:23:20 +0100 Subject: [PATCH] docs+test: gate fixes and handoff snapshot (compile-mode in flight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo fmt over the new files; doc-markdown backticks; is_ok_and in the recompile counter wait; m4_6's M-g n/p pin updated to the Q#CM5 takeover contract (error.next/error.previous with the diag commands as the dispatchers' fallback — the test's no-attachment status behavior is unchanged). Handoff §1: main @ 0efb5cd, compile-mode branch in flight at framing revision 6, themes named as the standing runner-up. Gate results on this machine (laptop, basedpyright live): fmt, clippy --workspace --all-targets, lib 1522, crdt lib 1696, compile_mode_acceptance 34, compile_mode_crdt_acceptance 1, m4_acceptance 101 (no skip), PMACS_REQUIRE_GPU gpu 59, workspace sweep 2482/0, git diff --check. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/agent-handoff.md | 32 +++++-- src/lua_bindings/mod.rs | 5 +- src/process.rs | 48 +++++++--- tests/compile_mode_acceptance.rs | 126 +++++++++++++++++++------- tests/compile_mode_crdt_acceptance.rs | 5 +- tests/m4_acceptance.rs | 13 ++- 6 files changed, 168 insertions(+), 61 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 327c2dd..bcddf1e 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,7 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-12, on the laptop, by the editops session -(post-#111 merge; scripts/bite micro-PR).** This file is the +**Last updated: 2026-07-13, on the laptop, by the compile-mode +session (on-branch snapshot).** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` files ARE your memory. Read this fully before taking on work, seed @@ -9,10 +9,26 @@ your persistent memory from it, and **update this file (and commit it) whenever project state changes materially** — the next machine reads it the way you just did. -## 1. Where the project stands (2026-07-12) +## 1. Where the project stands (2026-07-13) -- `main` @ `f0a05c5` (editops #111 merged), protocol **v15** +- `main` @ `0efb5cd` (scripts/bite #112 merged), protocol **v15** (`SUPPORTED=[6..15]`). +- **IN FLIGHT: compile-mode (Arc 5 stage 1) on branch + `compile-mode`** — the user chose it over themes (Arc 4) in the + decision discussion. Framing `docs/compile-mode-framing.md` at + revision 6 (five pre-branch review rounds; approved for build). + Shape: `compile.run` streams `/bin/sh -c "exec 2>&1; "` + (pipes, `stdin="null"`, `group=true`, TERM=dumb) into an + intercept-read-only `*compilation*` buffer via a Lua-side ANSI + parser; once-per-newline error rules; unified `error.next`/ + `error.previous` dispatcher (M-g n/p taken over from diag with a + behavior-preserving fallback; `` C-x ` ``; M-! shell-command); + buffer-revision external-edit guard with desync marker + anchor + epochs; grep-mode upgrade of `project.search`. New substrate other + code can use: `ProcessSpec.stdin/group` (group lifecycle: reap + ledger, in-drain enforcement, cancellable poll readers), + `buf:revision()`, jump_back now fires `buffer.after-switch`, + `pmacs.errors.claim`. All gates green; PR next. - **Editing-conveniences pack (editops, #111) landed** — the Lua parallel lane. Framing `docs/editing-conveniences-framing.md` at revision 6 (three pre-branch rounds, one adopted post-approval @@ -41,9 +57,11 @@ reads it the way you just did. `buf:path()`, `pmacs.lsp.buffer_language(buf)`, `PMACS_FAKE_LSP_CHANGE_SINK` (fake-LSP doc-sync replay), `TestDaemon::spawn_with_config` (init.lua-carrying daemon fixture). -- **NEXT: the user wants a decision discussion — compile-mode (Arc 5 - stage 1) vs themes (Arc 4). Do not pick unilaterally; frame the - tradeoff and ask.** +- **NEXT after compile-mode merges: themes (Arc 4) is the standing + runner-up from the decision discussion** — scout fresh before + framing (protocol bump v15→16 for a ThemeFacts channel, the + LineNumbers/Q#UX1 control-plane template, glyphon font reload is + the hard part). - Auto-indent (#109) landed earlier: RET binds `edit.newline-and-indent`; plain Enter round-trips on both frontends; shared search invalidation (Q#AI8), empty-selection diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 055bc05..8abb7f3 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12662,7 +12662,10 @@ mod tests { ) .eval() .unwrap(); - assert!(ok, "revision must be strictly monotonic across edit/undo/redo"); + assert!( + ok, + "revision must be strictly monotonic across edit/undo/redo" + ); } #[test] diff --git a/src/process.rs b/src/process.rs index 07d1a8e..5b513cf 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1447,7 +1447,12 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result, -) -> Vec { +fn final_drain_runtime(rt: &RuntimeHandles, group: Option) -> Vec { let deadline = Instant::now() + EXIT_OUTPUT_DRAIN_TIMEOUT; let mut out = Vec::new(); // Group drains get tighter bounds than the plain byte-flush @@ -2813,7 +2815,10 @@ mod tests { // Readiness echo: terminating before the trap is installed // would let plain SIGTERM win and vacuously pass. let id = sup - .spawn(sh_group_spec("trap-test", "trap '' TERM; echo ready; sleep 30")) + .spawn(sh_group_spec( + "trap-test", + "trap '' TERM; echo ready; sleep 30", + )) .expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { stdout_contains(evs, b"ready") @@ -2849,7 +2854,9 @@ mod tests { "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", pidfile.display() ); - let id = sup.spawn(sh_group_spec("survivor", &script)).expect("spawn"); + let id = sup + .spawn(sh_group_spec("survivor", &script)) + .expect("spawn"); let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); assert!( events @@ -2882,7 +2889,10 @@ mod tests { let mut sup = ProcessSupervisor::new(); sup.set_group_term_grace(Duration::from_millis(500)); let id = sup - .spawn(sh_group_spec("re-term", "trap '' TERM; echo ready; sleep 30")) + .spawn(sh_group_spec( + "re-term", + "trap '' TERM; echo ready; sleep 30", + )) .expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { stdout_contains(evs, b"ready") @@ -2923,7 +2933,9 @@ mod tests { "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", pidfile.display() ); - let id = sup.spawn(sh_group_spec("survivor", &script)).expect("spawn"); + let id = sup + .spawn(sh_group_spec("survivor", &script)) + .expect("spawn"); let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); let survivor = wait_pidfile(&pidfile); assert!(pid_alive(survivor), "survivor alive pre-shutdown"); @@ -3075,13 +3087,23 @@ mod tests { let mut spec = ProcessSpec::new("pty-null", "/bin/sh"); spec.mode = ProcessMode::default_pty(); spec.stdin = StdinMode::Null; - let err = sup.spawn(spec).expect_err("stdin=null must be rejected under pty"); - assert!(err.contains("pipe mode"), "error points at pipe mode: {err}"); + let err = sup + .spawn(spec) + .expect_err("stdin=null must be rejected under pty"); + assert!( + err.contains("pipe mode"), + "error points at pipe mode: {err}" + ); let mut spec = ProcessSpec::new("pty-group", "/bin/sh"); spec.mode = ProcessMode::default_pty(); spec.group = true; - let err = sup.spawn(spec).expect_err("group=true must be rejected under pty"); - assert!(err.contains("pipe mode"), "error points at pipe mode: {err}"); + let err = sup + .spawn(spec) + .expect_err("group=true must be rejected under pty"); + assert!( + err.contains("pipe mode"), + "error points at pipe mode: {err}" + ); } } diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 44134bd..fbd2f3c 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -1,7 +1,7 @@ //! Compile-mode acceptance (Arc 5 stage 1, //! docs/compile-mode-framing.md, items 1–33; item 34 lives as unit //! tests in src/process.rs, item 35 in -//! tests/compile_mode_crdt_acceptance.rs). +//! `tests/compile_mode_crdt_acceptance.rs`). //! //! Dispatch-driven: every keybinding claim is exercised through //! `dispatch_key` (never `pmacs.command.invoke`), per the standing @@ -188,7 +188,7 @@ fn pid_alive(pid: i32) -> bool { } /// Errors getter marshalled to a comparable Rust shape (encoded as -/// one line per entry — mlua tuples don't implement FromLua). +/// one line per entry — mlua tuples don't implement `FromLua`). fn compile_errors(s: &EditorState) -> Vec<(String, i64, i64, Option)> { let encoded: String = eval( s, @@ -218,7 +218,7 @@ fn compile_errors(s: &EditorState) -> Vec<(String, i64, i64, Option)> { } /// Rendered cells of the active window (copied from the -/// m4_acceptance grid helper — cross-crate test code can't import). +/// `m4_acceptance` grid helper — cross-crate test code can't import). fn render_active_window_to_grid( state: &mut EditorState, rows: u32, @@ -617,7 +617,11 @@ fn acc14_malformed_rule_containers_fail_closed() { 1, "built-in defaults still parse under a non-table container" ); - assert!(errors_buffer(&s).is_empty(), "no error spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); // (b) an invalid-pattern entry is skipped; a later valid entry // still matches; one status note counts the skip. @@ -644,7 +648,11 @@ fn acc14_malformed_rule_containers_fail_closed() { vec![("b.c".to_owned(), 3, 0, Some("error".to_owned()))], "the valid entry still matches after the malformed one" ); - assert!(errors_buffer(&s).is_empty(), "no error spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no error spam: {}", + errors_buffer(&s) + ); } // --------------------------------------------------------------------------- @@ -738,7 +746,10 @@ fn acc17_chords_walk_compile_errors_across_files() { // C-x ` is the classic chord for the same dispatcher. ctrl(&mut s, 'x'); press(&mut s, KeyCode::Char('`')); - assert!(active_buffer_name(&s).ends_with("two.c"), "C-x ` = error.next"); + assert!( + active_buffer_name(&s).ends_with("two.c"), + "C-x ` = error.next" + ); } #[test] @@ -773,25 +784,24 @@ fn acc19_g_recompiles_and_clears_overlay_spans() { ), dir.path(), ); - assert!(any_styled_cell(&render_active_window_to_grid(&mut s, 8, 60))); + assert!(any_styled_cell(&render_active_window_to_grid( + &mut s, 8, 60 + ))); assert_eq!( std::fs::read_to_string(&counter).unwrap().lines().count(), 1 ); - // g re-runs the stored command. The rerun's output has no - // diagnostics, so any styling would be a stale span. + // g re-runs the stored command. press(&mut s, KeyCode::Char('g')); assert!( pump_until(&mut s, 10_000, |_| { - std::fs::read_to_string(&counter) - .map(|c| c.lines().count() == 2) - .unwrap_or(false) + std::fs::read_to_string(&counter).is_ok_and(|c| c.lines().count() == 2) }), "recompile must actually re-execute the command" ); - // Wait, then override the fixture: the rerun emits the same - // diagnostic (stored command), so instead assert spans were - // cleared by checking the fresh run reached its own marker with + // The rerun executes the SAME stored command, so its output + // carries the same diagnostic; per-run reset is pinned by + // checking the fresh run reached its own marker with // exactly one diagnostic parsed (not accumulated). assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); @@ -924,8 +934,11 @@ fn acc24_command_path_undo_after_completed_run_recovers_immediately() { dir.path().display().to_string() ), ); - assert!(pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*") - .contains("[shell exited with code 0]"))); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*shell-command*" + ) + .contains("[shell exited with code 0]"))); // M-x buffer.undo: the command path rebinding cannot reach. No // pump event will ever arrive — recovery must come from the // buffer.after-edit subscription, synchronously. @@ -938,7 +951,11 @@ fn acc24_command_path_undo_after_completed_run_recovers_immediately() { "desync marker must appear immediately (bite: fails when \ recovery only runs at pump/anchor time); buffer:\n{text}" ); - assert!(errors_buffer(&s).is_empty(), "clean *errors*: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "clean *errors*: {}", + errors_buffer(&s) + ); } #[test] @@ -986,7 +1003,11 @@ fn acc25a_no_hook_shrink_mid_stream_recovers_and_reanchors() { text.find(DESYNC).unwrap() < text.find("two").unwrap(), "streaming continued after the marker:\n{text}" ); - assert!(errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); // Pre-marker anchor dropped: RET on the old row reports. exec(&s, "pmacs.editor.goto_byte(0)"); let target = first_row; @@ -1101,8 +1122,7 @@ fn acc27_killed_buffer_terminates_run_and_recreates() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_run(&s, "echo alive; sleep 30", dir.path()); - assert!(pump_until(&mut s, 5_000, |s| compilation_text(s) - .contains("\nalive\n"))); + assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nalive\n"))); exec( &s, r#" @@ -1117,7 +1137,11 @@ fn acc27_killed_buffer_terminates_run_and_recreates() { pump_until(&mut s, 5_000, |s| process_count(s) == 0), "run terminated and forgotten after buffer death" ); - assert!(errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); // The next run recreates the buffer and completes. compile_and_finish(&mut s, "echo reborn", dir.path()); assert!(compilation_text(&s).contains("reborn")); @@ -1159,7 +1183,10 @@ fn acc28_grep_panel_is_a_locations_buffer() { assert!(pump_until(&mut s, 10_000, search_done), "search completes"); assert_eq!(active_buffer_name(&s), "*search-results*"); let text = named_text(&s, "*search-results*"); - assert!(text.contains("f.txt:2:0:"), "structured match line:\n{text}"); + assert!( + text.contains("f.txt:2:0:"), + "structured match line:\n{text}" + ); // Read-only under dispatch. type_str(&mut s, "x"); assert_eq!(named_text(&s, "*search-results*"), text, "read-only"); @@ -1236,14 +1263,21 @@ fn acc29_grep_kill_mid_search_is_safe_and_masking_is_prevented() { appending (not mask it); tail:\n…{}", &text[text.len().saturating_sub(400)..] ); - assert!(errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); // (b) killing the panel mid-search: no stale-handle writes, and // the next search recreates the buffer. let mut s = editor(); search(&s, "zqxvbn_needle_77", dir.path()); - assert!(pump_until(&mut s, 10_000, |s| named_text(s, "*search-results*") - .contains(":1:0:"))); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*search-results*" + ) + .contains(":1:0:"))); exec( &s, r#" @@ -1256,7 +1290,11 @@ fn acc29_grep_kill_mid_search_is_safe_and_masking_is_prevented() { ); // Drain whatever the worker still delivers. let _ = pump_until(&mut s, 1_000, |_| false); - assert!(errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s)); + assert!( + errors_buffer(&s).is_empty(), + "no spam: {}", + errors_buffer(&s) + ); search(&s, "zqxvbn_needle_77", dir.path()); assert!( pump_until(&mut s, 15_000, |s| named_text(s, "*search-results*") @@ -1280,8 +1318,11 @@ fn acc30_grep_root_retained_across_interactive_supersede() { ), ); exec(&s, "pmacs.project.search('zqxvbn_needle_77')"); - assert!(pump_until(&mut s, 10_000, |s| named_text(s, "*search-results*") - .contains("f.txt:2:0:"))); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*search-results*" + ) + .contains("f.txt:2:0:"))); // Second search issued from inside the pathless panel, no // opts.root: the panel's stored root must be reused (the "." // fallback would search the test process's cwd and find @@ -1349,7 +1390,10 @@ fn acc32_q_restores_previous_buffer_from_all_three() { // *compilation* compile_and_finish(&mut s, "echo x", dir.path()); press(&mut s, KeyCode::Char('q')); - assert!(active_buffer_name(&s).ends_with("home.txt"), "q from compile"); + assert!( + active_buffer_name(&s).ends_with("home.txt"), + "q from compile" + ); // *shell-command* exec( &s, @@ -1358,15 +1402,21 @@ fn acc32_q_restores_previous_buffer_from_all_three() { dir.path().display().to_string() ), ); - assert!(pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*") - .contains("[shell exited"))); + assert!(pump_until(&mut s, 10_000, |s| named_text( + s, + "*shell-command*" + ) + .contains("[shell exited"))); press(&mut s, KeyCode::Char('q')); assert!(active_buffer_name(&s).ends_with("home.txt"), "q from shell"); // *search-results* search(&s, "zqxvbn_needle_77", dir.path()); assert!(pump_until(&mut s, 10_000, search_done)); press(&mut s, KeyCode::Char('q')); - assert!(active_buffer_name(&s).ends_with("home.txt"), "q from search"); + assert!( + active_buffer_name(&s).ends_with("home.txt"), + "q from search" + ); } #[test] @@ -1387,7 +1437,13 @@ fn acc33_round_trip_input_is_set_on_generated_buffers() { dir.path().display().to_string() ), ); - assert!(s.core.borrow().active_buffer_round_trips(), "*shell-command*"); + assert!( + s.core.borrow().active_buffer_round_trips(), + "*shell-command*" + ); search(&s, "zqxvbn_needle_77", dir.path()); - assert!(s.core.borrow().active_buffer_round_trips(), "*search-results*"); + assert!( + s.core.borrow().active_buffer_round_trips(), + "*search-results*" + ); } diff --git a/tests/compile_mode_crdt_acceptance.rs b/tests/compile_mode_crdt_acceptance.rs index 82f0085..9618c1e 100644 --- a/tests/compile_mode_crdt_acceptance.rs +++ b/tests/compile_mode_crdt_acceptance.rs @@ -220,7 +220,10 @@ fn compile_run_converges_and_replica_edit_triggers_recovery() { let src_text = pump_until_text(&mut source, Duration::from_secs(15), "source run", done); let obs_text = pump_until_text(&mut observer, Duration::from_secs(15), "observer run", done); assert_eq!(src_text, obs_text, "byte-identical convergence"); - assert!(src_text.contains("x.c:1:1: error: boom"), "output replicated"); + assert!( + src_text.contains("x.c:1:1: error: boom"), + "output replicated" + ); assert!(src_text.starts_with("$ sh "), "header replicated"); // Synthetic accepted replica edit to the generated buffer: the diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 70d674a..eb05196 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1892,13 +1892,18 @@ fn m4_6_diag_navigate_commands_and_bindings_are_registered() { ) .eval() .expect("keymap.list"); + // Compile-mode (Q#CM5, docs/compile-mode-framing.md) took the + // M-g chords over for the unified dispatchers; the diag commands + // stay registered and remain the dispatchers' fallback when no + // compile/grep run has claimed the error source, so the + // no-LSP-attachment behavior asserted below is unchanged. assert!( - bindings.iter().any(|b| b == "M-g n=>diag.next"), - "M-g n must bind to diag.next; got: {bindings:?}" + bindings.iter().any(|b| b == "M-g n=>error.next"), + "M-g n must bind to error.next; got: {bindings:?}" ); assert!( - bindings.iter().any(|b| b == "M-g p=>diag.previous"), - "M-g p must bind to diag.previous; got: {bindings:?}" + bindings.iter().any(|b| b == "M-g p=>error.previous"), + "M-g p must bind to error.previous; got: {bindings:?}" ); // Without an LSP attachment, the command should surface a status