Merge githubsucks/main (#169, #176) into docs-terminal-copy-mode-landed

Two doc conflicts, both in favour of `main`, and both are this PR's own
findings arriving from the other side:

- **The dired lane.** #169 did exactly what round 5 finding 5 said it
  would: absorbed dired Stage 1 into handoff §1 and removed the ledger
  lane per rule 4. This branch carried a placeholder saying #169 owned
  that text; the placeholder is dropped and #169's version taken whole.
  This is the rule working — one PR, one authority per paragraph.
- **The canonical base.** Both sides edited it. Resolved to `74301d1`,
  now naming #169 and #176, keeping `main`'s added guidance that a
  recovery threshold must move with the base it declares canonical (a
  check that accepts an older commit passes on a tree the file does not
  describe). The threshold moved with it.

Consequences of the merge, folded in:

- **The census is re-measured on the merged tree**: 3,176 vs 3,449 —
  still **273 dark**, 185 of them in the library. #176's six new tests
  are not `crdt`-gated, so both totals moved and the gap did not. The
  per-target table is unchanged.
- **The `crdt` Clippy failure is re-measured too, and the previous count
  was wrong** — it was read off a different checkout. At `74301d1` it is
  seven errors before the build aborts: four in `src/daemon.rs`, three in
  `tests/vterm_stage3_acceptance.rs`. Recorded as a **lower bound**,
  because Clippy abandons the remaining targets once one fails, and an
  older tree showed a further error in
  `tests/auto_indent_crdt_acceptance.rs` this run never reached.
- **The PTY-terminate lane (#176) is marked MERGED and retained**, with a
  note at its head saying why it survives rule 4: no landed-doc PR owns
  moving its facts to the handoff, so removing it now would delete the
  record rather than move it. The ledger preamble's stale-on-purpose
  paragraph is rewritten around that one lane; the Lean 4 and
  GPU-terminal-input headers it used to disclaim no longer exist.
- The flake note's "`src/process.rs` last changed by the Darwin PTY
  signal-name fix" is dropped — #176 changed it. The load-bearing half
  (#178 did not touch that file at all) is what remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
This commit is contained in:
Levi Neuwirth 2026-07-26 16:37:41 -04:00
commit d14fe4c4b9
6 changed files with 967 additions and 191 deletions

View File

@ -120,7 +120,7 @@ asymmetry**, and **per-arc coherence debt**.
Coherence-shaped work already in flight at audit time: find-file / Coherence-shaped work already in flight at audit time: find-file /
dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155), Stage 1 directory view (merged #165), bottom panel Stage 1 (merged #155),
multi-root LSP affinity (merged #161), the config registry foundation multi-root LSP affinity (merged #161), the config registry foundation
(merged #127). (merged #127).
@ -195,7 +195,7 @@ working, unreachable capability:
time; a complete 1,384-line dired existed only as a frozen test time; a complete 1,384-line dired existed only as a frozen test
fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the
browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture browsing view as a builtin (`C-x d` / `C-x C-j`, merged #165). The fixture
stays frozen — its `install_local` + `require` routing *is* the M8 stays frozen — its `install_local` + `require` routing *is* the M8
package-universality proof (Q#DR1) — and shrinking it is scheduled package-universality proof (Q#DR1) — and shrinking it is scheduled
after Stage 3. after Stage 3.
@ -363,11 +363,11 @@ Full verdict table:
|---|---|---|---| |---|---|---|---|
| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime |
| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on |
| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface | | 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (merged #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface |
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | | 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | | 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* | | 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first | | 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
@ -467,7 +467,7 @@ level is the one missing. Audited level-by-level:
**Beginner** (should see: files, buffers, search, diagnostics, terminal, **Beginner** (should see: files, buffers, search, diagnostics, terminal,
build actions, menus, missing-tool guidance): build actions, menus, missing-tool guidance):
- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses; - files ✓ since #162 / #165 (`C-x C-f` opens a path, `C-x d` browses;
neither is advertised anywhere but the keymap) · buffers ✓ (`C-x neither is advertised anywhere but the keymap) · buffers ✓ (`C-x
b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
@ -1254,7 +1254,7 @@ Primitive-by-primitive against the list above:
hierarchy, package dependency graph, worker trees, git status) will hierarchy, package dependency graph, worker trees, git status) will
each need it; building it once *before* dired's directory view and each need it; building it once *before* dired's directory view and
the workers tree harden their own conventions is exactly this the workers tree harden their own conventions is exactly this
section's point. Dired Stage 1 (PR #165) landed **without** inventing section's point. Dired Stage 1 (merged #165) landed **without** inventing
one: its listing is flat (Emacs parity), and the recursive one: its listing is flat (Emacs parity), and the recursive
in-buffer case — `i` insert-subdirectory — is a named deferral in in-buffer case — `i` insert-subdirectory — is a named deferral in
`docs/dired-framing.md` §13, which is where a shared tree primitive `docs/dired-framing.md` §13, which is where a shared tree primitive
@ -1418,7 +1418,8 @@ greets a new user says nothing (`EditorCore::new` sets an empty
status). status).
Note the dependency: five of the ten onboarding steps above currently Note the dependency: five of the ten onboarding steps above currently
lead somewhere broken or invisible (find a file — in flight; inspect a lead somewhere broken or invisible (find a file — the mechanism is fixed
since #162/#165 but is advertised nowhere except the keymap; inspect a
diagnostic — silent-failure risk; view workers — undiscoverable; diagnostic — silent-failure risk; view workers — undiscoverable;
setting provenance — unanswerable). Onboarding is correctly sequenced setting provenance — unanswerable). Onboarding is correctly sequenced
*after* the P1/P4 fixes, but the cheap floor — a welcome buffer in *after* the P1/P4 fixes, but the cheap floor — a welcome buffer in
@ -1482,7 +1483,7 @@ Establish the end-to-end workflow; treat regressions as release
blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually
cheap:** directory-argument handling (the remaining half of step 3 — cheap:** directory-argument handling (the remaining half of step 3 —
dired Stage 1 landed the buffer it should resolve to); a find-file dired Stage 1 landed the buffer it should resolve to); a find-file
surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the surface (**done**: #162 open-by-path, #165 browsing); surfacing the
LSP spawn failure with guidance (§1.2); a LSP spawn failure with guidance (§1.2); a
compile keybinding + `cargo build`/`test` default from the existing compile keybinding + `cargo build`/`test` default from the existing
`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The `ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The
@ -1552,7 +1553,9 @@ implementation — this list is direction, not commitment):
1. **Journey Stage 1** (P1): directory open + compile defaults + 1. **Journey Stage 1** (P1): directory open + compile defaults +
LSP-failure surfacing + bindings + welcome buffer + the first LSP-failure surfacing + bindings + welcome buffer + the first
journey acceptance suite. Rides alongside the in-flight dired arc. journey acceptance suite. Dired Stage 1 has landed (#165), so the
buffer a directory resolves *to* already exists; this arc routes
`pmacs .` into it rather than growing a second directory surface.
2. **Discovery surface** (P4): the describe/list/where-is command 2. **Discovery surface** (P4): the describe/list/where-is command
family, M-x rich rows, help unification, help prefix. family, M-x rich rows, help unification, help prefix.
3. **Transient keymap layer** (§6): the overlay scope + lifetime 3. **Transient keymap layer** (§6): the overlay scope + lifetime

View File

@ -5,6 +5,18 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent entries when their PR merges; do not let this become a second permanent
backlog. backlog.
**One lane below is retained past its merge, and says so at its own
head**: the PTY terminate diagnostic (#176), because no landed-doc PR
owns moving its facts to `docs/agent-handoff.md` yet, and rule 4 removes
a lane only *after* that move. Every other merged lane has been removed —
the Lean 4 and GPU-terminal-input headers this paragraph used to
disclaim are gone, as are the inline-math (#172), dired (#169), and
terminal config + copy mode (#180) lanes.
**Trust the canonical-base line below over any lane header**: if a PR
number appears in `git log --first-parent githubsucks/main`, it has
landed regardless of what a lane says.
## Repository authority ## Repository authority
- Canonical development URL: - Canonical development URL:
@ -14,7 +26,8 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror, machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone. or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: - Canonical base at this snapshot:
`githubsucks/main` @ `fe8b8ba` (terminal copy mode #178 atop the `githubsucks/main` @ `74301d1` (the dired Stage 1 landed docs #169 and
the PTY-terminate diagnostic #176, atop terminal copy mode #178, the
GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179, GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179,
bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175, bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175,
terminal configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a terminal configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a
@ -25,6 +38,8 @@ backlog.
#162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159;
protocol v20). The previous snapshot named `a27f646`; the recovery protocol v20). The previous snapshot named `a27f646`; the recovery
check below accepts it or anything newer. check below accepts it or anything newer.
**Lanes below that name an older base have not been re-based; derive
their integration surface from `git diff <their base>..main`.**
- On the transfer source, `origin/main` named a release mirror at - On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names `d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by the canonical URL. This difference is why all recovery begins by
@ -58,9 +73,106 @@ git worktree list
git status --short --branch git status --short --branch
``` ```
The `git log` command must expose `fe8b8ba` or a newer intentional main. The `git log` command must expose `74301d1` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
describe.
If it does not, stop and repair the remote/fetch configuration. If it does not, stop and repair the remote/fetch configuration.
## PTY terminate diagnostic lane — MERGED (PR #176)
> **Lane retained deliberately, and it is the next one to close.** #176
> merged into `main` @ `bf8878f` (2026-07-26); rule 4 below removes a
> merged lane, but only after its durable facts reach
> `docs/agent-handoff.md`. **That absorption is unowned** — no landed-doc
> PR exists for #176 — so removing the lane now would delete the record
> instead of moving it. Whoever opens that PR removes this section.
- Portable branch: `githubsucks/pty-terminate-eperm`; worktree
`../pmacs-math-slice`. **PR #176**, base `main`, based on `ccf29e3`
with `c93f9ee` (#175) merged in.
- Approved framing: `docs/process-signal-tolerance-framing.md`
**revision 4**, after three review rounds.
- **Diagnostic only. No disposition change.** Every call that failed
before still fails, with no state transition and no reap-ledger
arming. `src/process.rs` is the only source file touched.
- **Why nothing is fixed:** revisions 13 each proposed a *tolerance*
rule and all three were rejected as unsound in the same way — each
concluded something about a process from something that was not about
that process. Rev 1 from an errno alone (EPERM means the caller lacks
permission, not that the id was recycled); rev 2 from `try_wait`,
which observes the spawned **leader** while a PTY signal targets
`-tcgetpgrp(...)`, entities that diverge exactly when job control has
moved the terminal; rev 3 from group-directed **ESRCH**, which proves
only that the selected foreground group vanished.
- **Two facts that killed the original argument.** `group = true` is
*rejected* for PTY mode at spawn (`src/process.rs:1428-1429`), so the
reap ledger never applies to the PTY path at all; and the ledger
comment (`:1075`) says EPERM "cannot happen for our own children" and
drops the entry for **bounded growth** — not a ruling that EPERM means
dead.
- **The CI evidence never established the child had exited.** The probe's
last source statement is a file write and CPython teardown does not
synchronise with it, so no tolerance rule could even be shown to fix
the symptom. That is the whole reason the lane is diagnostic.
- What ships: a failing `kill` now reports five separate facts — target
source, target kind/value, spawn-time group, errno, and the leader's
real `try_wait` state. The test seam injects the **kill result only**,
never the observation, so the real `ChildHandle::try_wait` runs against
the real child.
- **Not "strictly additive".** `try_wait` reaps and caches, so an exited
child may be reaped earlier than otherwise. Safe because
`portable-pty` 0.9.0 returns a `std::process::Child` on Unix and
delegates `try_wait` to it, so `poll_one` still sees the cached
status — pinned by an exactly-one-terminal-event test rather than
assumed.
- Round-1 review fixes: the exited-child tests no longer use a fixed
sleep as proof of exit (nix's `waitid` is unavailable on macOS and
`libc::waitid` needs `unsafe`, which the crate forbids), instead
driving the production diagnostic in a bounded loop until it observes
the exit; and every assertion is now exact message equality built from
the kernel-assigned pid, since the substring forms would have accepted
a hardcoded target or a wrong exit code.
- Bites, all verified rather than assumed: tolerating the failure fails
the disposition test; stubbing the leader observation fails three
tests including the one-event pin; a hardcoded target fails four; a
wrong exit code fails two.
- **The sweep found a real defect in these tests, not a flake.**
`observing_the_leader_does_not_consume_the_exit_event` failed with
"process ProcessId(26) is not running": the pid helper drained for
`Started`, and **`drain_until` ticks**. A tick can observe an
immediately-exiting child and move the record out of `Running`, after
which `signal` never reaches the diagnostic at all, so the bounded
loop spun to its limit. It passed standalone because the drain
returned on `Started` before `poll_one` saw the exit; only load lost
the race. Fast-exiting children now read the pid straight from the
supervisor record (no tick), and the loop fails fast if the record
left `Running`. **Verified under matched load: 0/15 with all 16 cores
saturated, while the old ticking helper fails 1/10 — the fix is
load-bearing.**
- Verification: fmt, `git diff --check`, strict workspace clippy clean;
lib 1,838 + CRDT 2,015 (both +6, exactly the new tests); GPU 202; M4
121; bottom-panel 46; compile-mode 67; vterm 9/6/5; **isolated-config
`--no-fail-fast` sweep 3,258 across 93 suites, zero failures**.
Earlier sweeps on this branch showed two failures and then one; the
totals reconcile (3,256/2 → 3,257/1 → 3,258/0, same test count). The
two that were genuinely unrelated —
`read_dir_supersede_cancels_in_flight_predecessor` (known
pre-existing) and
`headless_snapshot_round_trip_summary_restores_the_minimap` — are
load-contention flakes; the second is structurally unreachable from
this diff, since `pmacs-gpu` depends on `pmacs-protocol` and never on
`pmacs`.
- **Parked, each with its reason:** all tolerance rules (need the
evidence this PR produces); `terminate` idempotence for an
already-reaped process (independent fix, different failure, one
feature per PR); and `signal_target`'s read-then-kill of `tcgetpgrp`
— still the most likely real fix site.
- **The lane closes when this merges.** It does not wait for the flake
to recur; the next occurrence carries its own evidence under whoever's
PR, and a Stage B framing follows then.
## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b, 4a MERGED; 4b is next ## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b, 4a MERGED; 4b is next
- **Stages 1, 2, 3a and 3b are MERGED**#160 (`main` @ `0827dd1`), - **Stages 1, 2, 3a and 3b are MERGED**#160 (`main` @ `0827dd1`),
@ -231,160 +343,6 @@ If it does not, stop and repair the remote/fetch configuration.
--check` clean. --check` clean.
- Stage 4b (the input method) is NOT in this PR and not started. - Stage 4b (the input method) is NOT in this PR and not started.
## Dired lane — Stages 0 and 1 MERGED; Stage 2 framing in review (#171)
> **Lane retained deliberately.** Rule 4 below removes a lane after
> merge, but its durable facts must reach `docs/agent-handoff.md` first,
> and that absorption is the job of the **open landed-doc PR #169**.
> Writing it from here would put two PRs on the same text. #169 removes
> this lane; the Stage 2 framing PR #171 is still open besides.
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
(wdired) each get their own detailed framing after the prior stage lands.
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
2026-07-25, one review round, 12/12 CI green). Durable facts moved to
`docs/agent-handoff.md` §1 per rule 3 below.
- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
superseded `0827dd1` and carried only the framing content #164 already
put on `main`, so merging it would have reconciled two histories of one
document. It is left untouched and carries nothing unmerged.
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `builtin/runtime/dired.lua`: one buffer per directory named
`*dired:<canonical path>*` with the handle-table ownership check;
read-only intercept + `set_round_trip_input`; the `dired` major mode
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
basename cursor re-seating across every wholesale repaint;
`display_file` for file visits and same-window reuse for directory
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
Loaded after `window.lua`.
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
and one walk that either fails on a per-entry condition or records it
(Q#DR6). `src/async_runtime.rs` carries the listing in
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
keys the Lua result **shape** on `errors.is_some()`, so the bare array
the frozen M8.2 fixture consumes with `ipairs` is untouched;
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
keys** (a typo'd `tolerant` used to degrade silently to fatal).
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
`normalize_buffer_path` is `pub` and exposed as
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
mirror exists and Stage 2 owes no mirror removal. This makes B2
("tolerant `read_dir` is the only Rust change") false by one small
binding, deliberately.
- `tests/dired_acceptance.rs`: 22 tests over framing items 116,
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
dedicated dired panel to carry its dedication across a descent.
`display_buffer` never replaces the buffer in a slot dedicated to
another one — it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii), and the exact-window arm errors.
Dired does not unpin the user's panel; both arms are pinned.
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
descent *routing*. Dired holds focus in its own panel, so a raw
`switch_buffer` lands in the same window and every 3c assertion holds
either way. Dedication is the only discriminator, so the
dedicated-panel test is the real pin — and the vacuity is documented at
the assertion rather than relabelled.
- **The pre-existing test dired's first mode-scoped binding broke
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
binding in the stack resolves through `describe.key` context-free, which
held only while the modes table was empty. It now sets the effective
context per binding and explicitly *clears* the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`).
- Durable substrate facts, independent of this arc:
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
buffer before removal, so `kill-when-opening` kills **after** the
replacement is displayed.
- Interactive origin does **not** survive an await: work resumed in
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
acts for the *ambient* active frontend (S1-9).
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
entry says whether a symlink points at a directory; `RET` probes by
trying to list it (S1-8).
- A path-backed buffer's *name* is its full path, not its basename —
worth knowing before writing any name assertion.
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
RET on an empty field opens whatever sorts first, and
RET-on-where-you-are is the gesture the binding exists for. The field
is prefilled instead.
- **Bite verification:** 15 claims, each mutated in place and required to
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
file swap does not apply; every mutation was applied and reverted with
`git checkout --`. One came back VACUOUS and is recorded above.
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
guarded on the active buffer (an ambient `move_to_line` after an await
moved an unrelated buffer's cursor — the buffer-level instance of
S1-9); `fmt_size` keeps the column width past ten digits, because
`_layout` is a contract Stage 3 is planned against; and the symlink
descent dropped its probe, since `open_directory`'s
changed-nothing-on-failure invariant *is* the probe (it was listing the
target directory twice). Plus a consecutive-`readdir`-error cap, because
**nothing cancels a dired listing** — it carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly relied
on. Naming/comment findings taken as-is.
- Durable process lesson, hit twice now: a mutation-bite helper restores
with `git checkout --`, which reverts to **HEAD** — so a fix must be
committed *before* it is bitten. Round 1's fixes were briefly wiped by
exactly that.
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
affinity #161) and again at `b889873` (GPU terminal input #166), both
merged rather than rebased per the #135/#137 precedent so the review
anchors stay addressable. Each conflict was a single doc hunk resolved
as the union: this lane owns COHERENCE's journey step 7 file half, #161
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
Three things worth carrying:
- **A conflicting PR silently stops running CI.** GitHub builds
`pull_request` runs against the merge ref, which does not exist while
the PR conflicts, so no run is created and nothing reports a
failure — the checks list simply stays as it was. Three pushes to
this branch produced no CI at all before the cause was found. Watch
`mergeable` on a long-lived lane, not just the check list.
- #161's own COHERENCE finding **falsified a claim in this lane's
module doc**: `pmacs.error` is never defined in production, so an
uncaught raise inside a `pmacs.async` coroutine does not reach
`*errors*` as the comment said. It reaches a bare `error()` inside
`pmacs._async.tick()`, whose result `tick_async` discards with
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
`set_status` load-bearing rather than tidy, and the comment now says
so.
- **A lane in review against a fast-moving `main` needs its gates rerun
per integration, not per push.** Main advanced twice inside this
review round, and the second time landed while the first
integration's sweep was still running. The numbers below describe the
twice-merged tree.
- Verification on the twice-merged tree (`main` @ `b889873`):
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
default + 2,009 CRDT** library tests; dired acceptance **25 default +
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
vterm Stage 3 5 (both suites main added, green under this lane's
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
suites, zero failures**; `git diff --check` clean. The sweep needs the
isolated config for the reason recorded in the bottom-panel lane
below.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
Priority 1, which names this work explicitly; journey step 7's file half
goes from no surface to a surface; **adds no interaction island** — keys
are a mode-scoped keymap, and wdired will be a mode swap; adopts
`pmacs.config` for `dired.kill-when-opening`; inherits §9's
worker-attribution gap for its `read_dir` jobs without worsening it. The
audited claims this changes are updated in `COHERENCE.md` itself, per its
§25.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here — Stage 1 does **not** fix it. The two meet at
`resolve_target_buffer`; dired supplies the buffer a directory should
resolve *to*, and `pmacs .` should route into it rather than growing a
second directory surface.
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE ## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
- **No branch, no framing yet.** Found while gating #166, then measured - **No branch, no framing yet.** Found while gating #166, then measured
@ -396,8 +354,10 @@ If it does not, stop and repair the remote/fetch configuration.
Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI, Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI,
not merely skipped. not merely skipped.
- **Measured, `--list` under CI's exact flags versus the same flags plus - **Measured, `--list` under CI's exact flags versus the same flags plus
`crdt`: 3,170 vs 3,443 — 273 tests dark.** Re-measured at `fe8b8ba` `crdt`: 3,176 vs 3,449 — 273 tests dark.** Re-measured at `74301d1`
(2026-07-26). **The number moves with every merge and must be (2026-07-26; at `fe8b8ba` it read 3,170 vs 3,443, the same 273 dark —
#176 added six tests, none of them `crdt`-gated). **The number moves
with every merge and must be
re-measured, not quoted.** #168 reported 3,024 vs 3,288 — 264 dark, re-measured, not quoted.** #168 reported 3,024 vs 3,288 — 264 dark,
177 in the library — at `1b6a084`; #178 then added CRDT-only 177 in the library — at `1b6a084`; #178 then added CRDT-only
generated-buffer coverage, and other lanes landed CRDT tests in generated-buffer coverage, and other lanes landed CRDT tests in
@ -405,7 +365,7 @@ If it does not, stop and repair the remote/fetch configuration.
| dark | CI | full | target | | dark | CI | full | target |
|---:|---:|---:|---| |---:|---:|---:|---|
| 185 | 1,842 | 2,027 | **the library itself** (`src/lib.rs`) | | 185 | 1,848 | 2,033 | **the library itself** (`src/lib.rs`) |
| 21 | 15 | 36 | `m5_5_acceptance` | | 21 | 15 | 36 | `m5_5_acceptance` |
| 13 | 1 | 14 | `gpu_invocation_acceptance` | | 13 | 1 | 14 | `gpu_invocation_acceptance` |
| 13 | 1 | 14 | `gpu_initial_target_acceptance` | | 13 | 1 | 14 | `gpu_initial_target_acceptance` |
@ -491,8 +451,8 @@ If it does not, stop and repair the remote/fetch configuration.
`process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`
`active_reader_probe` returning `None` at `process.rs:3179` ("live `active_reader_probe` returning `None` at `process.rs:3179` ("live
runtime probe"). **Pre-existing and unrelated to #178:** that branch runtime probe"). **Pre-existing and unrelated to #178:** that branch
did not touch `src/process.rs` (last changed by the Darwin PTY did not touch `src/process.rs` at all, and the test passed 10/10
signal-name fix), and the test passed 10/10 standalone; the observed standalone; the observed
failures were during parallel full-suite runs. That localizes the failures were during parallel full-suite runs. That localizes the
trigger to suite load or interaction, but does **not** distinguish trigger to suite load or interaction, but does **not** distinguish
parallelism from another full-suite effect — no serial full-suite bite parallelism from another full-suite effect — no serial full-suite bite
@ -505,10 +465,15 @@ If it does not, stop and repair the remote/fetch configuration.
test names were captured. test names were captured.
- **A second standing obstacle for this lane:** `cargo clippy --workspace - **A second standing obstacle for this lane:** `cargo clippy --workspace
--all-targets --features crdt -- -D warnings` **fails on `main`** --all-targets --features crdt -- -D warnings` **fails on `main`**
re-verified at `fe8b8ba`: four errors in `src/daemon.rs` measured at `74301d1`: seven errors before the build aborts, four in
(`useless_conversion` at 3996, missing doc backticks at 4076, `src/daemon.rs` (`useless_conversion` at 3996, missing doc backticks at
`too_many_lines` 112/100 at 4083, an unneeded `mut` at 4965) and one in 4076, `too_many_lines` 112/100 at 4083, an unneeded `mut` at 4965) and
`tests/auto_indent_crdt_acceptance.rs:42` (doc backticks). The three in `tests/vterm_stage3_acceptance.rs` (`too_many_lines` at 637
and 793, a redundant `continue` at 843). **Treat that as a lower
bound, not an inventory:** Clippy abandons the remaining targets once
one fails, and a run on an older tree surfaced a further doc-backticks
error in `tests/auto_indent_crdt_acceptance.rs:42` that this run never
reached. The
standing gate list runs Clippy without `crdt`, so these lints have standing gate list runs Clippy without `crdt`, so these lints have
never been enforced. Any CI job that compiles the `crdt` targets has to never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival. fix them first or it will be red on arrival.
@ -717,6 +682,38 @@ git worktree add --track \
`../pmacs-terminal-config` and `../pmacs-terminal-copy-mode` are `../pmacs-terminal-config` and `../pmacs-terminal-copy-mode` are
retained. The gate-run flake found while gating #178 moved to the CI retained. The gate-run flake found while gating #178 moved to the CI
`crdt`-coverage lane above, which owns its discrimination. `crdt`-coverage lane above, which owns its discrimination.
- **Dired Stage 1 (the directory view) — MERGED as #165** (`main` @
`c8ec8f3`, 2026-07-25, after one review round). pmacs has a directory
surface: `C-x d` / `C-x C-j`, one read-only buffer per directory named
`*dired:<canonical path>*`, a `dired` major mode carrying
`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. No wire change (v20). The Rust is
two things — a per-entry-tolerant `read_dir` (Q#DR6), which had to be
Rust because `read_dir_blocking` fails a whole listing on any of five
per-entry conditions and a tolerant wrapper cannot be written in Lua at
all, and `normalize_buffer_path` going `pub` as
`pmacs.path.canonicalize` (Q#DR2's preferred end state, so no Lua mirror
exists and Stage 2 owes no mirror removal). The frozen m8_1/m8_2/m8_3
counts are unchanged, which is the additivity gate. 15 claims
bite-verified; one came back VACUOUS (acceptance 3c cannot pin descent
routing — dired holds focus in its own panel, so dedication is the only
discriminator) and is documented at the assertion rather than
relabelled. Its branch (`dired-stage1`) and worktree
(`../pmacs-dired-stage1`) are done; the abandoned `dired` branch
(`ffdd642`, `../pmacs-dired-arc`) was superseded by a fresh cut and
carries nothing unmerged. **Stage 2 (marks and operations) and Stage 3
(wdired) each still need their own framing**, and the frozen fixture
shrinks after Stage 3. Durable substrate facts and both new ops lessons
live in `docs/agent-handoff.md` §§1/5; the implementation notes are
`docs/dired-framing.md` §0, S1-1…S1-12. Two named forward items for
Stage 2: `apply_resource_op`'s rename rebind is exact-PathBuf-equality,
first-match-only, looked up with the raw path while stored paths are
normalized — so a directory rename strands every buffer under it, and
`pmacs.fs.rename` has zero production callers, so it can be fixed at
the primitive; and Q#DR5's seam is the main-thread drain
`AsyncRuntime::tick`, not `_take_result`, where rename settles as an
undifferentiated `ReplyKind::FsUnit` and so must be keyed on
`JobKind::FsRename`.
- **GPU terminal input (the double terminal-layout sync) — MERGED as #166** - **GPU terminal input (the double terminal-layout sync) — MERGED as #166**
(`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green (`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green
after a macOS PTY-timing rerun). The dispatcher applied **both** after a macOS PTY-timing rerun). The dispatcher applied **both**

View File

@ -41,8 +41,10 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-26) ## 1. Where the project stands (2026-07-26)
- `main` @ `fe8b8ba` (terminal copy mode #178 atop the GPU-terminal-input - `main` @ `74301d1` (the dired Stage 1 landed docs #169 and the
landed docs #168, Lean 4 Stage 4a #179, bottom-panel Stage 2A PTY-terminate diagnostic #176, atop terminal copy mode #178, the
GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179,
bottom-panel Stage 2A
#177, the bottom-panel Stage 2 framing #175, terminal configuration #177, the bottom-panel Stage 2 framing #175, terminal configuration
Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro
#157, the inline-math landed-doc refresh #172, the bottom-panel #157, the inline-math landed-doc refresh #172, the bottom-panel
@ -231,13 +233,78 @@ commands, read `docs/active-work.md` immediately after this file.
against an open buffer yet fails to load one that is not open — against an open buffer yet fails to load one that is not open —
find-file expands the tilde Lua-side. Loading through the normalized find-file expands the tilde Lua-side. Loading through the normalized
path is a named deferral. path is a named deferral.
- **Stage 1 (the directory view) MERGED as #165** — the builtin - **dired Stage 1 — the directory view — LANDED — #165**
`dired.lua`, the per-entry-tolerant `read_dir` opt, and (`docs/dired-framing.md` §0, S1-1…S1-12; merge `c8ec8f3`; one review
`pmacs.path.canonicalize`. Its durable facts have **not** been round). pmacs now has a directory surface: `C-x d` / `C-x C-j` open a
absorbed here yet: that is the job of the open landed-doc PR read-only listing, one buffer per directory named
**#169**, and duplicating it from this PR would put two authorities `*dired:<canonical path>*`, with a `dired` major mode whose
on the same text. Until #169 merges, `docs/active-work.md`'s dired mode-scoped keymap carries `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`.
lane remains the record. Protocol unchanged at **v20**. **Stage 2 (marks and operations) and
Stage 3 (wdired) each still need their own framing**; the frozen
fixture shrinks after Stage 3.
- **The Rust is confined to two things**: a per-entry-tolerant
`read_dir` (`ReadDirTolerance {Fatal, PerEntry}` →
`FsDirListing {entries, errors}`), because `read_dir_blocking` fails
a whole listing on any of five per-entry conditions and the tolerant
wrapper its own module doc delegates to package authors **cannot be
written in Lua** (one error value, no partial vec); and
`editor_core::normalize_buffer_path` becoming `pub`, exposed as
`pmacs.path.canonicalize`. Only non-UTF-8 **names** stay fatal —
byte-preserving paths would be needed. The Lua result **shape** keys
on `errors.is_some()`, so the bare array the frozen M8.2 fixture
consumes with `ipairs` is untouched.
- **Exposing a core normalizer beat mirroring it in Lua.** A Lua mirror
would have been a second canonical form — the same class of bug as
the five tab-width constants (#137). Applies to any future Lua-side
path reckoning.
- **A fixed-width column must be fixed-width for every input.** The
exported `pmacs.dired._layout` (MARK 0, KIND 2, PERMS 312, SIZE 13,
MTIME 24, NAME 41) is the contract Stage 3 reads offsets from, and
`%10d` overflows at ≥10 GB, silently shifting every column right of
it. Sizes now fall back to a width-clamped magnitude (K/M/G/T/P/E).
- **An ambient action must be gated on the buffer it assumes.** A
revert's cursor re-seat settles a tick or more later, by which time
the user may have switched buffers; the paint names its buffer and is
safe, but seating is ambient. This is the buffer-level instance of
the rule below that interactive origin does not survive an await.
- **A failure IS an answer — don't probe first.** Kinds are lstat-based
in both `read_dir` and `stat`, so nothing in an entry says whether a
symlink points at a directory. `RET` tries to list it and treats the
failure as the answer; an explicit probe was a second full
`read_dir`, so a descent listed twice.
- **Unbounded per-entry error collection needs a cap when nothing
cancels the work.** A dired listing carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly
relied on (`READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS = 1024`).
- **This is the first builtin with mode-scoped keys** (#129's first
non-detection consumer), which broke the pre-existing
`describe_key_identifies_every_default_binding`: it asserted every
binding resolves through `describe.key` context-free, which held only
while the modes table was empty. It now sets the effective context
per binding and explicitly **clears** the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`), plus a floor
assertion that at least one mode-scoped binding exists.
- **A dedicated panel does not carry its dedication across a descent**
— the framing expected it to. `display_buffer` never replaces the
buffer in a slot dedicated to another one; it discards every
side-specific parameter and falls back to the document window (Q#BP3
2.iii), and the exact-window arm errors. Dired does not unpin the
user's panel; both arms are pinned.
- Smaller facts worth knowing before touching this code: a path-backed
buffer's **name is its full path**, not its basename, which matters
for any name assertion; `pmacs.buffer.kill` (not `remove`) redirects
windows off a doomed buffer first, so `dired.kill-when-opening` kills
**after** the replacement is displayed; ownership is checked against
the handle table only, never the buffer name; and `C-x d` takes **no**
completion source on purpose (with one, `RET` on an empty field opens
whatever sorts first, and RET-where-you-are is the gesture the binding
exists for — the field is prefilled instead).
- Verification at merge: 1,832 default + 2,009 CRDT library tests;
dired acceptance 25 + 25 CRDT; the frozen m8_1 10 / m8_2 15 / m8_3 32
unchanged, which is the additivity gate for the `read_dir` change; M4
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
3,205 across 93 suites. 15 claims bite-verified.
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = - Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family). the GPU initial-target semantic bootstrap family).
@ -1125,6 +1192,24 @@ final variant — its own round-trip cannot detect a discriminant shift.
trap-guarded one-file swap over read-only `git show`, with an trap-guarded one-file swap over read-only `git show`, with an
inverted verdict (exit 0 iff the tests FAIL against the old inverted verdict (exit 0 iff the tests FAIL against the old
version), making bite-verification machine-checkable. version), making bite-verification machine-checkable.
- **A fix must be COMMITTED before it is bitten.** `scripts/bite`
restores by `git checkout --`, which reverts the file to **HEAD**, not
to the state it found — so any uncommitted work in a bitten file is
destroyed. A whole review round's fixes were wiped this way during
#165. Corollary for a NEW file: the swap-over-`git show` mode does not
apply at all, so its claims must be bitten by hand-editing, which makes
the commit-first rule load-bearing rather than hygienic.
- **A CONFLICTING PR silently runs no CI at all.** GitHub builds
`pull_request` workflow runs against the PR's **merge ref**, which it
does not create while the branch conflicts with its base. So pushes
land, the branch updates, no run is ever queued, and **nothing reports
the absence** — the checks list simply keeps showing the last
successful run, which reads as current. Three pushes to #165 produced
zero CI before the cause was found, and `gh pr checks` returns nothing
usable here. On any lane that lives through a moving `main`, check
`gh pr view <N> --json mergeable,mergeStateStatus,headRefOid` and
confirm a run exists **for the current head sha**, not merely that a
recent run was green.
- **Stacked PRs**: retarget the child to main BEFORE merging the - **Stacked PRs**: retarget the child to main BEFORE merging the
parent — GitHub auto-closes a PR whose base branch is deleted and parent — GitHub auto-closes a PR whose base branch is deleted and
cannot reopen it (#104 → re-opened as #105). cannot reopen it (#104 → re-opened as #105).

View File

@ -1,7 +1,9 @@
# Dired — framing # Dired — framing
**Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; **Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162;
Stage 1 IN REVIEW as PR #165, review round 1 addressed.** Stage 1 MERGED as #165 (`main` @ `c8ec8f3`, one review round). Stage 2
(marks and operations) and Stage 3 (wdired) each still need their own
framing before implementation; the frozen fixture shrinks after Stage 3.**
Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings;
rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's
implementation falsified in the approved text (§0); rev 5 adds the implementation falsified in the approved text (§0); rev 5 adds the

View File

@ -0,0 +1,258 @@
# Framing — make the PTY terminate failure self-describing (diagnostic only)
**Revision 4.** Status: awaiting review round 4. Lane:
`pty-terminate-eperm`, worktree `../pmacs-math-slice`, based on
`githubsucks/main` @ `ccf29e3`.
**Diagnostic only. No disposition changes, no tolerance rules, no
behavioural fix.** Every rule this document proposed across revisions 1
to 3 is parked (§5). The lane's entire deliverable is that the next
occurrence of the failure explains itself.
## Revision history
**Revision 3 → 4**, after review round 3 (two blocking, one major) and
its scope call. All accepted.
- **Group-directed ESRCH was also unsafe**, for the same reason EPERM
was: it proves the selected *foreground group* vanished, not that the
leader exited. A job-control race — foreground job exits after
`tcgetpgrp` and before `kill`, shell alive and not yet reclaiming the
terminal — would have been reported as success with the leader never
signalled. Rev 3's acceptance 7 pinned that unsafe behaviour. **All
tolerance is parked** (§5).
- **Rev 3's Stage A implemented Stage B.** It declared itself
diagnostic-only, then listed tolerance and bookkeeping acceptances.
Removed.
- **Q#PS6 (already-reaped `terminate` is `Ok`) is parked separately.**
It is an independent behavioural fix answering a different failure;
under one-feature/one-PR it does not ride with instrumentation.
- **"Strictly additive / cannot regress behaviour" was overstated** and
is narrowed (Q#PD3).
- The injected-kill seam is restored as an explicit decision (Q#PD4).
**Rounds 13, for the record.** Rev 1 classified on errno alone and
claimed a live owned child cannot yield EPERM — false. Rev 2 gated on
`try_wait`, which observes the leader while a PTY signal targets the
foreground group — unsound whenever those diverge, and it could not be
shown to fix the observed failure at all. Rev 3 corrected EPERM but left
ESRCH unsafe and mixed the stages. **Three consecutive designs were
wrong in the same direction: each tried to conclude something about a
process from something that was not about that process.**
## 0. Coherence impact (COHERENCE §20)
- **Journey step 8, "Open a terminal"** (§2), teardown half. **No grade
change and no behavioural change** — this lane only improves what a
failure reports.
- **Serves §9 (worker model), failure attribution**, in its most literal
sense: an error that names only an errno cannot be attributed.
- **Interaction islands: none. Config registry: not adopted.
Background-work attribution: unchanged.**
- **No audited claim in COHERENCE.md changes**, so under §25 no
COHERENCE edit rides this PR.
## 1. Ground truth (scouted @ `ccf29e3`, re-verified each revision)
### 1.1 The failure reports an errno and nothing else
`ProcessSupervisor::signal` (`src/process.rs:921`) maps the `kill`
failure to `format!("kill: {e}")` (`:931`). That string is everything a
reader gets.
### 1.2 The signal target is not the observation target
- **Signal target**`signal_target` (`:687`) returns `-pgrp` for a
PTY, where `pgrp = master.process_group_leader()`: the tty's
**current foreground process group**, read at signal time.
- **Observation target**`ChildHandle::try_wait` (`:668`) observes the
**spawned leader**.
They coincide only while the leader owns the terminal. Job control is
precisely the mechanism that makes them diverge, and the PTY path is
**always group-directed by design** — spawn rejects `group = true` for
PTY mode with the rationale that "PTY children already lead their own
session and are signaled group-wide" (`:1428-1429`).
**This is why every tolerance rule across rev 13 failed review**, and
why the diagnostic must record the target and the leader state as
*separate* facts.
### 1.3 The reap ledger is disjoint from this path
`tick_reap_ledger` (`:1075`) treats any probe error as "nothing left we
can reach" for **bounded growth**, asserting EPERM "cannot happen for our
own children". It is armed only for `proc.spec.group`, which PTY mode
cannot set. Rev 1's "asymmetry" argument was a misreading; withdrawn.
### 1.4 The observed failure, and the limits of the evidence
macOS CI, PR #172 (**docs-only** diff), `Test (macos-latest / luajit)`,
`acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel`
([attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30177276839/attempts/1)):
```
in function 'terminate'
cause: ExternalError(Process("kill: EPERM: Operation not permitted"))
```
**Established:** the errno, and the call path
(`pmacs.terminal.terminate` → `session.rs:566``signal`).
**Not established:** that the child had exited (the probe's last source
statement is a file write at
`tests/bottom_panel_stage1_acceptance.rs:2239`; CPython teardown follows
and does not synchronise with it); that any pgid was recycled; or what
the signal target actually was.
**This is the whole reason the lane is diagnostic.** Every candidate fix
needs at least one of those three facts, and none is available.
### 1.5 Caller inventory
| Caller | Disposition |
|---|---|
| `src/lsp.rs:1364`, `:2427` | discards (`let _ =`) |
| `src/mcp.rs:1229`, `:1239`, `:1915` | discards (`let _ =`) |
| `src/terminal/session.rs:319`, `:607`, `:635` | discards (`let _ =`) |
| **`src/terminal/session.rs:566`** (propagating at `:577`) | **propagates** as `TerminalError::Process` |
| supervisor-internal `shutdown` path | discards |
| `src/lua_bindings/mod.rs:8150`, `:8164` | propagates to Lua |
| `src/lua_bindings/mod.rs:8717` | propagates (via `session.rs:566`) |
| `src/daemon.rs:4162` | **test-only** `.expect`, not production |
No test in the repository asserts either error string, so widening the
message breaks nothing.
### 1.6 `portable-pty` caches the exit status on Unix
Pinned `portable-pty 0.9.0`: `spawn_command` returns
`std::process::Child` (`unix.rs:228`), and `impl Child for
std::process::Child::try_wait` delegates to
`std::process::Child::try_wait` (`lib.rs:271-277`), which caches into
`self.status`. Both `ChildHandle` variants therefore cache.
## 2. Decisions
### Q#PD1 — what the widened error records
On a `kill` failure in `signal`, the error carries:
| Field | Why |
|---|---|
| **target source**`tcgetpgrp` vs `group` vs `leader-pid` fallback | which branch of `signal_target` (`:687`) ran |
| **target kind and value**`-pgid` or `pid`, with the number | the entity actually signalled |
| **spawn-time pgid / leader pid** | a divergence from the target is the job-control hypothesis, visible only by comparison |
| **errno** | as today |
| **leader `try_wait` state**`exited(status)` / `live` / `unobservable(e)` | separates "the leader is gone" from "the group we signalled is gone" — the distinction all three failed designs collapsed |
Every candidate Stage B rule is decidable from these five together, and
none is decidable from the errno alone.
### Q#PD2 — the disposition is preserved exactly
The call still fails, with the same `Err`, in every case. No state
transition changes, no ledger arming changes, no tolerance. A reader
diffing behaviour should find none.
### Q#PD3 — the honest claim is "no disposition change", not "strictly additive"
Rev 3 said the diagnostic was only an error-string change and could not
regress behaviour. **That overstated it.** `try_wait` on an exited child
**reaps it and caches the status**, so consulting it in the failure path
is an internal state change: the child may be reaped earlier than it
otherwise would be.
Observably safe, because both variants cache (§1.6) and `poll_one`
(`:1133`) will still see `Ok(Some(_))` and emit its event. But safe by
argument is not safe by assertion, so the terminate-failure-then-tick
event pin is retained (acceptance 5).
### Q#PD4 — the injected-kill seam injects the KILL, never the observation
Acceptance 5 needs a forced `kill` failure while the **real**
`ChildHandle::try_wait` runs against the **real** child. A stubbed
observation would bypass exactly the code path in question.
So the seam is a test-only override of the *kill attempt's result*,
consumed once by the signal path; everything downstream — target
selection, the observation, the error construction — runs for real. This
also makes the diagnostic's own fields testable without racing the
kernel.
### Q#PD5 — nothing else lands here
No tolerance rule, no idempotence change, no `signal_target` change. See
§5.
## 3. Bets (falsifiable)
- **B1 — The five fields are sufficient to discriminate the §1.4
hypotheses.** Falsified if a recurrence carries all five and still
leaves the cause ambiguous — which would itself be a finding worth
having.
- **B2 — Widening the message breaks no caller.** Evidence: §1.5, and no
test asserts the string.
*Retracted across revisions and not reinstated:* rev 1's "a live owned
child cannot yield EPERM"; rev 2's "exit observation suffices"; rev 2's
"this removes the failure class"; rev 3's "group ESRCH is safe to
tolerate".
## 4. Acceptance
1. A group-directed `kill` failure produces an error carrying all five
Q#PD1 fields, with the target rendered as `-pgid` and the leader
state distinct from it.
2. A leader-directed `kill` failure does the same, with the target
rendered as `pid` and the target source recorded as the fallback
branch.
3. The leader state renders each of `exited(status)`, `live`, and
`unobservable(e)` correctly.
4. **The disposition is unchanged**: every injected failure still
returns `Err`, with no state transition and no ledger arming
(Q#PD2). Falsified by revert — flipping any arm to `Ok` fails this.
5. **Forced injected kill failure against the real PTY child
observation**, then tick: exactly one exit event, with the correct
status (Q#PD3/Q#PD4). A fully stubbed observation does not satisfy
this and is rejected as vacuous.
6. The existing suites stay green, pinning "no behavioural change" from
the outside.
## 5. Parked (not deferred-and-forgotten — each needs its own evidence)
- **All tolerance rules.** Group-directed EPERM *and* ESRCH both fail on
the §1.2 entity split; leader-directed tolerance is plausible but
unmotivated until evidence shows the fallback branch is ever taken.
Needs Stage A evidence first.
- **Q#PS6, `terminate` on an already-reaped process returning `Ok`.**
Independent behavioural fix, different failure (§1.6 of rev 3), its
own lane under one-feature/one-PR.
- **`signal_target`'s read-then-kill of `tcgetpgrp`** — still the most
likely real fix site, still unframed.
- `terminate` cancelling pending restarts; PTYs in
`pmacs.process.list`; any change to `C-c` delivery.
## 6. Gates
Full suite per `CLAUDE.md`. Touched suites:
`bottom_panel_stage1_acceptance`, the vterm stages, and
`compile_mode_acceptance`. Sweep with `-- --skip basedpyright`.
## 7. Branch plan
`pty-terminate-eperm`, one PR, diagnostic only. This framing is its first
commit; the instrumentation and its tests are the second.
**The lane then closes.** It does not wait for the flake to recur: the
next occurrence — whenever it happens, under whoever's PR — carries its
own evidence, and Stage B is framed then. Math work proceeds immediately
after this lands.

View File

@ -470,6 +470,13 @@ pub struct ProcessSupervisor {
/// TERM→KILL window used when arming the ledger. Constant /// TERM→KILL window used when arming the ledger. Constant
/// [`GROUP_TERM_GRACE`] in production; overridable in tests. /// [`GROUP_TERM_GRACE`] in production; overridable in tests.
group_term_grace: Duration, group_term_grace: Duration,
/// Q#PD4 test seam: forces the next `kill(2)` attempt in
/// [`Self::signal`] to fail with this errno, consumed once.
/// Always `None` in production — there is no way to set it outside
/// `cfg(test)`. It replaces the *kill result only*, so the leader
/// observation still runs against the real child handle; a stubbed
/// observation would bypass the code path under test.
forced_kill_errno: Option<nix::errno::Errno>,
} }
/// One armed group in the reap ledger. /// One armed group in the reap ledger.
@ -684,7 +691,50 @@ impl ChildHandle {
} }
} }
fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> { /// Which branch of [`signal_target`] chose the target (Q#PD1).
///
/// Recorded on failure because the branches differ in what a failing
/// `kill` can possibly mean: only [`Self::LeaderPid`] aims at the
/// spawned child itself. The other two aim at a *group*, which for a
/// PTY is read from the terminal and can belong to something the
/// supervisor never spawned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetSource {
/// The tty's current foreground process group, read at signal
/// time. Diverges from the leader exactly when job control has
/// moved the terminal.
ForegroundGroup,
/// A `group = true` pipe child leading its own process group.
SpawnGroup,
/// The child's own pid.
LeaderPid,
}
impl TargetSource {
fn as_str(self) -> &'static str {
match self {
Self::ForegroundGroup => "tcgetpgrp",
Self::SpawnGroup => "group",
Self::LeaderPid => "leader-pid",
}
}
/// Whether the target is a process group rather than one process.
fn is_group(self) -> bool {
matches!(self, Self::ForegroundGroup | Self::SpawnGroup)
}
}
/// The entity a signal was actually aimed at, plus the branch that
/// chose it. Carried so a failure can report the target as a fact
/// separate from the leader's state (Q#PD1).
#[derive(Debug, Clone, Copy)]
struct SignalTarget {
pid: Pid,
source: TargetSource,
}
fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<SignalTarget, String> {
if let Some(runtime) = proc.runtime.as_ref() if let Some(runtime) = proc.runtime.as_ref()
&& let ChildHandle::Pty { && let ChildHandle::Pty {
_master: master, .. _master: master, ..
@ -692,7 +742,10 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
&& let Some(pgrp) = master.process_group_leader() && let Some(pgrp) = master.process_group_leader()
&& pgrp > 0 && pgrp > 0
{ {
return Ok(Pid::from_raw(-pgrp)); return Ok(SignalTarget {
pid: Pid::from_raw(-pgrp),
source: TargetSource::ForegroundGroup,
});
} }
// `group = true` pipe children lead a fresh process group // `group = true` pipe children lead a fresh process group
// (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals // (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals
@ -700,11 +753,80 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
// (Q#CM3). // (Q#CM3).
if proc.spec.group { if proc.spec.group {
let pgid = i32::try_from(pid).map_err(|e| e.to_string())?; let pgid = i32::try_from(pid).map_err(|e| e.to_string())?;
return Ok(Pid::from_raw(-pgid)); return Ok(SignalTarget {
pid: Pid::from_raw(-pgid),
source: TargetSource::SpawnGroup,
});
} }
Ok(Pid::from_raw( Ok(SignalTarget {
i32::try_from(pid).map_err(|e| e.to_string())?, pid: Pid::from_raw(i32::try_from(pid).map_err(|e| e.to_string())?),
)) source: TargetSource::LeaderPid,
})
}
/// The spawned leader's state at the moment a `kill` failed (Q#PD1).
///
/// Deliberately reported *beside* the target rather than folded into a
/// verdict: for a PTY the two are different entities whenever job
/// control has moved the terminal, and three successive designs for
/// this code were unsound precisely because they collapsed them.
enum LeaderObservation {
Exited(TermStatus),
Live,
Unobservable(String),
NoRuntime,
}
impl LeaderObservation {
fn render(&self) -> String {
match self {
Self::Exited(TermStatus::Exited(code)) => format!("exited(code {code})"),
Self::Exited(TermStatus::Signaled(sig)) => format!("exited(signal {sig})"),
Self::Live => "live".to_owned(),
Self::Unobservable(e) => format!("unobservable({e})"),
Self::NoRuntime => "no-runtime".to_owned(),
}
}
}
/// Observe the spawned leader. Note this *reaps* an exited child and
/// caches its status; that is why Q#PD3 claims "no disposition change"
/// rather than "strictly additive", and why an event-count test pins
/// that `poll_one` still emits exactly one exit event afterwards.
fn observe_leader(proc: &mut ManagedProcess) -> LeaderObservation {
let Some(runtime) = proc.runtime.as_mut() else {
return LeaderObservation::NoRuntime;
};
match runtime.child.try_wait() {
Ok(Some(status)) => LeaderObservation::Exited(status),
Ok(None) => LeaderObservation::Live,
Err(e) => LeaderObservation::Unobservable(e),
}
}
/// Render a failing `kill` as the five facts of Q#PD1. The disposition
/// is unchanged (Q#PD2) — this only replaces a message that said
/// nothing but the errno.
fn signal_failure_report(
target: SignalTarget,
leader_pid: u32,
errno: nix::errno::Errno,
leader: &LeaderObservation,
) -> String {
let expected = if target.source.is_group() {
match i32::try_from(leader_pid) {
Ok(p) => format!(", expected_group=-{p}"),
Err(_) => String::new(),
}
} else {
String::new()
};
format!(
"kill: {errno} (target={} via {}, leader_pid={leader_pid}{expected}, leader={})",
target.pid.as_raw(),
target.source.as_str(),
leader.render(),
)
} }
/// Termination status of one generation. Internal --- the supervisor /// Termination status of one generation. Internal --- the supervisor
@ -807,9 +929,20 @@ impl ProcessSupervisor {
shut_down: false, shut_down: false,
reap_ledger: HashMap::new(), reap_ledger: HashMap::new(),
group_term_grace: GROUP_TERM_GRACE, group_term_grace: GROUP_TERM_GRACE,
forced_kill_errno: None,
} }
} }
/// Q#PD4 test seam: make the next `kill(2)` attempt in
/// [`Self::signal`] report `errno` instead of calling the kernel.
/// Consumed by that one attempt. Everything downstream — target
/// selection, the leader observation against the real child, and
/// the error construction — runs unmodified.
#[cfg(test)]
fn force_next_kill_errno(&mut self, errno: nix::errno::Errno) {
self.forced_kill_errno = Some(errno);
}
/// Override the SIGTERM-to-SIGKILL grace window. Test helper. /// Override the SIGTERM-to-SIGKILL grace window. Test helper.
pub fn set_grace_period(&mut self, d: Duration) { pub fn set_grace_period(&mut self, d: Duration) {
self.grace_period = d; self.grace_period = d;
@ -928,7 +1061,21 @@ impl ProcessSupervisor {
return Err(format!("process {id} is not running")); return Err(format!("process {id} is not running"));
}; };
let target = signal_target(proc, pid)?; let target = signal_target(proc, pid)?;
nix::sys::signal::kill(target, Some(signal)).map_err(|e| format!("kill: {e}"))?; // Q#PD4: the seam injects the KILL attempt's result only —
// never the observation below — so target selection, the real
// `ChildHandle::try_wait` against the real child, and the error
// construction all run for real. Consumed once.
let kill_result = match self.forced_kill_errno.take() {
Some(errno) => Err(errno),
None => nix::sys::signal::kill(target.pid, Some(signal)),
};
if let Err(errno) = kill_result {
// Q#PD1/Q#PD2: the failure describes itself; the
// disposition is unchanged — this still returns `Err`,
// with no state transition and no ledger arming.
let leader = observe_leader(proc);
return Err(signal_failure_report(target, pid, errno, &leader));
}
if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) { if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) {
proc.state = ProcessState::Exiting { proc.state = ProcessState::Exiting {
pid, pid,
@ -2131,6 +2278,290 @@ mod tests {
); );
} }
/// Spawn a PTY child that leads its own session and stays alive
/// until terminated, returning its id and OS pid.
///
/// `/bin/sleep` directly rather than through a shell: a shell may
/// place the command in a different foreground process group, and
/// these tests assert the exact target the tty reports.
fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) {
let mut spec = ProcessSpec::new(name, "/bin/sleep");
spec.args = vec!["30".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
cols: 80,
mode: TerminalMode::Canonical,
};
let id = sup.spawn(spec).expect("spawn");
(id, spawn_started_pid(sup, id))
}
/// The OS pid straight from the supervisor's own record, WITHOUT
/// ticking.
///
/// `drain_until` ticks, and a tick can observe a fast child's exit
/// and transition the record out of `Running` — after which
/// `signal` returns "is not running" and never reaches the
/// diagnostic at all. Any test whose child exits promptly must read
/// the pid this way. (Found by the parallel workspace sweep: the
/// drain-based helper raced only under load.)
fn record_pid(sup: &ProcessSupervisor, id: ProcessId) -> u32 {
match sup.processes.get(&id).expect("record").state {
ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. } => pid,
ProcessState::Starting => panic!("spawn has not reported a pid yet"),
ProcessState::Terminated(_) => {
panic!("the record already left Running; the pid is unavailable")
}
}
}
/// Drain until `Started` and return the OS pid it carries. Safe
/// only for children that outlive the drain; see [`record_pid`].
fn spawn_started_pid(sup: &mut ProcessSupervisor, id: ProcessId) -> u32 {
let evs = drain_until(sup, id, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, ProcessEventKind::Started { .. }))
});
evs.iter()
.find_map(|e| match e.kind {
ProcessEventKind::Started { pid } => Some(pid),
_ => None,
})
.expect("Started carries a pid")
}
/// Drive the production diagnostic until it observes the leader as
/// exited, bounded by `timeout`.
///
/// A fixed sleep is NOT proof of exit — on a loaded runner the child
/// can still be live, which would turn these tests into false
/// failures. This synchronises on the very observation under test.
/// Each failing attempt leaves the record untouched, because the
/// failure path returns before any bookkeeping (Q#PD2), so looping
/// is side-effect free.
fn terminate_until_leader_exited(
sup: &mut ProcessSupervisor,
id: ProcessId,
timeout: Duration,
) -> String {
let deadline = Instant::now() + timeout;
loop {
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
if err.contains("leader=exited(") {
return err;
}
assert!(
!err.contains("is not running"),
"the record left Running before the diagnostic could run, so \
this test never exercised it: {err}"
);
assert!(
Instant::now() < deadline,
"leader never observed as exited within {timeout:?}: {err}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Q#PD1 acceptance 1 — a group-directed failure names the target,
/// the branch that chose it, the expected group, the errno, and the
/// leader's own state, as five separate facts.
///
/// Asserted as an exact message against the pid the kernel actually
/// assigned, so a hardcoded target could not satisfy it. The leader
/// field is the one that matters: for a PTY the signal goes to the
/// terminal's foreground group, a different entity from the spawned
/// child whenever job control has moved the terminal. Three rejected
/// designs for this code collapsed the two; the report keeps them
/// apart, and here they are asserted to agree only because nothing
/// has moved the terminal.
#[test]
fn a_group_directed_kill_failure_reports_target_and_leader_separately() {
let mut sup = ProcessSupervisor::new();
let (id, pid) = spawn_live_pty(&mut sup, "diag-group");
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
let expected = format!(
"kill: {} (target=-{pid} via tcgetpgrp, leader_pid={pid}, expected_group=-{pid}, leader=live)",
nix::errno::Errno::EPERM
);
assert_eq!(
err, expected,
"the report names the exact target the tty reported, the exact \
leader pid, and observes the leader as live"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD1 acceptance 2 — a leader-directed failure records the
/// fallback branch and a positive target, and omits the group field
/// that would be meaningless for it. Exact message again.
#[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");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
sup.force_next_kill_errno(nix::errno::Errno::ESRCH);
let err = sup.terminate(id).expect_err("injected ESRCH must fail");
let expected = format!(
"kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=live)",
nix::errno::Errno::ESRCH
);
assert_eq!(
err, expected,
"a non-group pipe child targets its own pid, and the group \
field is omitted where it has no meaning"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD1 acceptance 3 — every leader state renders distinctly. The
/// `Unobservable` and `NoRuntime` arms cannot be produced by a real
/// child on demand, so they are pinned directly; `live` and `exited`
/// are pinned through the real path by the tests around this one.
#[test]
fn every_leader_observation_renders_distinctly() {
assert_eq!(
LeaderObservation::Exited(TermStatus::Exited(0)).render(),
"exited(code 0)"
);
assert_eq!(
LeaderObservation::Exited(TermStatus::Signaled("SIGTERM".into())).render(),
"exited(signal SIGTERM)"
);
assert_eq!(LeaderObservation::Live.render(), "live");
assert_eq!(
LeaderObservation::Unobservable("try_wait: boom".into()).render(),
"unobservable(try_wait: boom)"
);
assert_eq!(LeaderObservation::NoRuntime.render(), "no-runtime");
}
/// Q#PD1 acceptance 3, exited arm through the REAL path — the leader
/// has genuinely exited and the report carries its exact code, not
/// merely "some exit".
#[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");
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
// immediately.
let pid = record_pid(&sup, id);
let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10));
let expected = format!(
"kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=exited(code 3))",
nix::errno::Errno::EPERM
);
assert_eq!(
err, expected,
"the exact exit code is observed from the real child, not \
inferred from the errno"
);
}
/// Q#PD2 acceptance 4 — **the disposition is unchanged.** An
/// injected failure still fails, and neither the state transition
/// nor the reap-ledger arming runs. This is the assertion that
/// separates a diagnostic from the tolerance rules three review
/// rounds rejected; flipping any arm to `Ok` fails it.
#[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");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
assert!(
sup.reap_ledger.is_empty(),
"precondition: nothing armed before the attempt"
);
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
let expected = format!(
"kill: {} (target=-{pid} via group, leader_pid={pid}, expected_group=-{pid}, leader=live)",
nix::errno::Errno::EPERM
);
assert_eq!(err, expected, "a group=true pipe child reports via group");
assert!(
matches!(
sup.processes.get(&id).expect("record").state,
ProcessState::Running { .. }
),
"a failed kill must not transition the record to Exiting"
);
assert!(
sup.reap_ledger.is_empty(),
"a failed kill must not arm the reap ledger"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD3/Q#PD4 acceptance 5 — the diagnostic consults the REAL
/// `ChildHandle::try_wait` on the REAL child, which reaps it and
/// caches the status. `poll_one` must still emit exactly one exit
/// event, carrying the exact code.
///
/// A stubbed observation would bypass the double-`try_wait` path
/// entirely and pin nothing, so the injection replaces the kill
/// result only.
#[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");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
cols: 80,
mode: TerminalMode::Canonical,
};
let id = sup.spawn(spec).expect("spawn");
// NOT `spawn_started_pid`: draining ticks, and a tick can reap
// this immediately-exiting child before the diagnostic runs.
let _ = record_pid(&sup, id);
// Drives `observe_leader`, which try_waits the real PTY child
// for the first time and reaps it.
let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10));
assert!(
err.contains("leader=exited(code 7)"),
"the real handle was consulted and carries the exact code: {err}"
);
// The supervisor's own try_wait must still see that status.
let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
let terminal: Vec<i32> = evs
.iter()
.filter_map(|e| match e.kind {
ProcessEventKind::Exited { code, .. } => Some(code),
ProcessEventKind::Signaled { .. } => Some(-1),
_ => None,
})
.collect();
assert_eq!(
terminal,
vec![7],
"exactly one terminal event survives the diagnostic's try_wait, \
carrying the child's real exit code"
);
}
#[test] #[test]
fn signal_terminates_a_running_child() { fn signal_terminates_a_running_child() {
let mut sup = ProcessSupervisor::new(); let mut sup = ProcessSupervisor::new();