Commit Graph

1194 Commits

Author SHA1 Message Date
Levi Neuwirth a2a92bb9bc
Merge pull request #200 from levineuwirth/process-signal-diagnostic-completeness
fix(process): make the signal diagnostic discriminating (Stage B, evidence collection only)
2026-07-30 14:59:14 -04:00
Levi Neuwirth 262b7c0b6f docs(process): stop overclaiming the pre-kill sample; drop a dead comment
Review round 5: one blocking, one major. Both documentation-only; the
runtime fixes from round 4 are unchanged.

**The pre-kill sample was overclaimed.** Acceptance 4 said the measured
group "describes the target that was attempted". It does not. `getpgid`
and `kill` remain separated by the same read-then-act window §1.5 is
built on, so the sample can be stale by the time the signal is
delivered. Moving it earlier removes a POST-HOC reading; it does not
make the reading contemporaneous. The criterion now says it records
pre-kill evidence about the attempted target, and adds an explicit
sentence forbidding any acceptance from claiming otherwise.

This is worth naming rather than quietly editing: the framing's whole
spine is that this telemetry establishes less than it appears to, and
round 4 fixed a real ordering defect and then inflated the fix's meaning
inside the document that governs it.

**`95897f7`'s commit message carries the same overclaim.** It is not
amended — force-pushing a branch under review to rewrite history would
hide the error rather than correct it. This message is the correction,
and the two read in sequence.

**A dead doc comment was documenting a constant.** The original
pre-Stage-B acceptance-1 block survived the rewrite and came to rest
above `const BASH`, so the file explained a string constant with the
sentence this entire lane exists to remove — "here they are asserted to
agree only because nothing has moved the terminal", describing a test
that no longer exists. Removed; only the BASH explanation remains.

Verified the overclaim is gone from the whole tree, not only the cited
line: no hits in `docs/` or `src/`.

Gates: 11 gates, 4,471 tests, zero failures.
2026-07-30 14:41:50 -04:00
Levi Neuwirth 95897f7563 fix(process): sample the group before the kill; make corroboration real
Review round 4: three blocking, one major. All accepted.

**`measured_group` was sampled after the failure.** It ran inside
`signal_failure_report`, after the `kill` and after `observe_leader`,
while the framing and the function's own doc both said before. A
concurrent group change would have made the diagnostic report
post-failure state as evidence about the attempted target. It is now
sampled in `signal` before the kill and passed into the report, so the
field describes the target that was attempted rather than the state the
failure left behind.

**The Linux corroboration did not exercise the production lookup.** Its
helper read `portable_pty::process_group_leader` — the accessor this
lane stopped using on the signal path — so `pty_foreground_group` could
have fallen back on every call with every test still green. Forcing it
to always fall back demonstrates the hole exactly: the corroboration
fails, and the injected pin PASSES, because the injected tests supply
the group themselves and structurally cannot detect a broken lookup.

The helper now calls the production lookup, and the corroboration forces
only the kill so the report is built from a real terminal read. The
residual limitation is recorded rather than left to the green: on macOS
`pty_foreground_group` has no end-to-end coverage, because the platform
cannot produce the precondition.

**The framing did not update its own acceptance contract.** Revision 5
recorded Bet 1's falsification in the revision history and in the bet,
and left the normative criterion demanding the real-shell rewrite — the
"implementation quietly diverges from the contract" shape this project
already recorded as a lesson on #191/#188. Acceptance 1 now describes
the injected pin, 1a adds the corroboration and its macOS limitation,
and 4 states the sampling order. The ledger is synchronized: revision 6,
four commits, 4,471 tests, bash armed on Linux only.

**`TargetSource`'s doc had the wrong classification.** Two of the four
variants target the leader pid, not one, and the pid-versus-group split
does not line up with PTY-versus-pipe — which is why the fallback needed
its own variant rather than reusing `LeaderPid`.

Gates: 11 gates, 4,471 tests, zero failures.
2026-07-30 14:27:46 -04:00
Levi Neuwirth 66f73013fc test(process): take Bet 1's fallback — macOS does not diverge
CI falsified framing Bet 1. Both macOS legs reported

    job control never moved the terminal off the leader
    (leader=8542, foreground groups observed: [8542])

with the terminal staying on the leader for the entire 10s bounded wait.
Linux diverges reliably — 20/20 locally and green on both ubuntu legs —
so this is a platform difference rather than a flake, and rerunning past
it would have been wrong.

The framing named this outcome and prescribed the response, so that is
what ships rather than an improvised fix:

- The divergent case is pinned by INJECTING the foreground group at the
  `signal_target` seam. Deterministic, runs on every platform. The
  injection seam widens from failure-only to either outcome; the branch,
  target choice, leader observation against the real child, and report
  construction all remain production code.
- Verified still discriminating: the `leader_pid`-substitution mutation
  fails it, `target=-1707909` against an expected `-1707910`. That was
  the whole point of the original rewrite and it survives the fallback.
- The real shell is retained as corroboration in
  `job_control_really_diverges_the_foreground_group`, Linux-only. It
  skips on macOS by PLATFORM CHECK rather than by arming: the
  precondition genuinely does not hold there, so running it would assert
  a false claim about macOS instead of finding a bug.
- Framing revision 5 records the falsification and states exactly how
  the injected pin is weaker — it proves the target is read from the
  lookup rather than substituted from the leader; it does not by itself
  prove any real shell produces that divergence.

`PMACS_REQUIRE_BASH` moves to Linux-only. The earlier reasoning for
arming both platforms — macOS is where the failures happen, so Linux-only
leaves it dark where it matters — was right about the diagnostic and
wrong about this test, which cannot produce its precondition on macOS at
all. Arming it there made a missing binary fatal for a test that can
never run. The measurement is recorded in ci.yml and the README so it is
not re-derived.

Gates on this tree: 11 gates, 4471 tests, zero failures.
2026-07-30 14:10:39 -04:00
Levi Neuwirth e1df0cff4d docs(tests): framing revision 4 — close review round 3
One blocking and two major findings, all accepted.

- The isolated gate still inherited `PMACS_STATE_HOME`, which wins over
  `XDG_STATE_HOME` in `user_state_dir` (`src/state.rs:47-68`), so
  controlling the four XDG storage variables did not isolate state on a
  machine that exports it. The contract now names the exact five
  variables rather than counting roots.
- Q#TI5 still called self-spawn the durable regression guard after
  revision 3 had accepted that it proves behaviour, not continued
  adoption. It now names both guards and their different jobs: self-spawn
  is the behavioural proof, the checked source inventory is the adoption
  proof.
- The active-work lane still said revision 2 and still prescribed an
  isolated `XDG_CONFIG_HOME` alone; both are synchronized, and the stale
  config-only gating note elsewhere in the ledger is corrected to say it
  isolates the observed symptom rather than the gate.

Framing only. No code.
2026-07-30 13:57:02 -04:00
Levi Neuwirth 595676cd20 merge: integrate main @ b8e18f6 (ledger absorption #199)
`docs/active-work.md`'s snapshot header was the only conflict; main's
absorption version supersedes this branch's. `docs/agent-handoff.md`
auto-merged, and both sides were verified present afterwards rather than
assumed: the absorption's `4cd4a7b` anchor and its new landed-lane
entries, and this branch's macOS occurrence with its run link.

This lane's ledger entry is updated from "revision 4 awaiting review" to
its actual state — implemented, gated, PR open — since the framing was
approved and both implementation commits have landed on the branch.

The full gate suite is re-run on the merged tree rather than inherited
from the pre-merge head. The absorption is docs-only, so a clean merge
is very likely correct — which is exactly when inheriting a green would
be most tempting and least justified.
2026-07-30 13:49:57 -04:00
Levi Neuwirth b8e18f61ff
Merge pull request #199 from levineuwirth/ledger-absorption-2026-07-30
docs: absorption pass — six merged lanes retired, handoff brought current
2026-07-30 13:45:23 -04:00
Levi Neuwirth e9613c7d94 docs(tests): framing revision 3 — close review round 2
Three blocking and two major findings, all accepted. Two were verified
by running the thing rather than reading it.

- **Journey isolation had no executable mechanism.** Cargo launches each
  integration-test binary with the caller's environment, and a binary
  cannot re-point its own roots before its tests run — the `set_var`
  prohibition applies to itself. Revision 2's "isolated by its launched
  environment" therefore assumed the external wrapper this lane exists
  to delete. Now named and pinned: each journey test re-execs
  `current_exe()` with `--exact`, a marker, and controlled roots; the
  child runs the real body against the ambient `EditorState::open`, so
  the production-entry-point ratchet is untouched while the roots are
  contained. The same helper serves the hostile-environment check.
- **One self-spawning test is not a ratchet.** It proves the seam works
  and cannot notice a raw `EditorState::new()` added to another binary
  later. A checked source inventory, falsifiable by adding an ambient
  constructor, is now acceptance 12.
- **The root list and the gates disagreed with the audit.** The scope is
  now explicit — bootstrap STORAGE roots only (config, data, state,
  cache). `HOME`'s non-storage semantics are excluded by decision, not
  omission: `expand_tilde` resolves user-entered `~` and
  `find_file_acceptance` pins it deliberately, so redirecting `HOME`
  would retarget a user-facing feature. `XDG_RUNTIME_DIR` addresses
  sockets, not stored data.
- **The gate instruction itself was insufficient**, and this is the
  finding with immediate consequences: isolating only `XDG_CONFIG_HOME`
  stops the reads and leaves the write path open. Every local gate run
  in this repository today had that hole.
- **The count was one high and the ledger overstated it further.** 65
  files call the constructor; 66 mention it. The 66th, `m5_6_acceptance`,
  mentions it only to say it deliberately does not use it — making it
  the third place in the suite documenting the `cfg(test)` gap. The
  ledger's "all 96 test files load the real config" was false.
- **The recovery command did not work**, verified by running it:
  `git worktree add <path> <remote-only-branch>` fails with `fatal:
  invalid reference` after a bare fetch. Replaced with the explicit
  tracking-branch form.

Framing only. No code.
2026-07-30 13:45:15 -04:00
Levi Neuwirth f2071ea022 docs(tests): framing revision 2 — close review round 1
Four blocking and two major findings, all accepted, all verified in the
code before acceptance. The lane's scope changes: it is about ambient
roots, not about init.lua.

- The read-only assumption was already false. `EditorState::new`
  materializes bundled packages unconditionally and before config
  loading, into `XDG_DATA_HOME` or `$HOME/.local/share`, and
  `materialize_all` creates directories. Confirmed on the development
  machine: `~/.local/share/pmacs/builtin-packages/` exists with v0.1.0
  and v1.0.0. This upgrades the lane from "local gates lie" to "tests
  write into real user data".
- The population count was wrong and its stated method did not match the
  command that produced it: 18 from a grep for `Editor::new`, which does
  not match the real constructor `EditorState::new`. 66 of 96 files
  construct an editor directly.
- File-level classification cannot work: 5 files are both in-process and
  spawned. Classification moves to construction sites.
- The seam must cover `EditorState::open`, which calls `Self::new()`
  directly, while `journey_acceptance` requires that exact public entry
  point to avoid a dead-production-path test. Resolved by isolating
  journey through its launched environment rather than a different call.
- Isolated construction must still return `is_init_complete() == true`.
  Config loading and `set_init_complete()` share one block, and
  `m8_2_acceptance.rs:75` documents its dependence on integration-test
  construction being init-complete — the `cfg(test)` gap is load-bearing
  in that one respect.
- Revision 1 both proposed and parked a hostile-config CI leg. Resolved
  in favour of a test-binary self-spawn, which travels with the test
  rather than the workflow file, and which now also asserts the hostile
  root is unmodified afterwards.
- The lane is recorded in `docs/active-work.md`, which revision 1 omitted
  despite the volatile-work protocol requiring it.

Also documents six ambient roots where the shared daemon harness sets
two, and why setting HOME only isolates a root whose XDG variable is
unset — the harness's apparent adequacy is a property of one developer's
environment.

Framing only. No code.
2026-07-30 13:36:23 -04:00
Levi Neuwirth e1cf1fc76b docs: absorption pass — six merged lanes retired, handoff brought current
Eight PRs merged on 2026-07-29/30 (#188, #190, #191, #194, #195, #196,
#197, #198) and `docs/active-work.md` had drifted to 1,854 lines, most of
it describing work already on `main`. Rule 4 permits removal once a
lane's facts reach `docs/agent-handoff.md`; this does both halves in one
commit so the precondition is satisfied rather than deferred.

Handoff §1: the head-of-`main` anchor moves from `6c9e765` to `4cd4a7b`
and its date from 2026-07-28 to 2026-07-30. New entries for the three
implementation PRs that had no record — #190's pre-filesystem delete
refusal, #191's Stage 1 adoption of the generated-buffer contract, and
#196's dired Stage 2a — plus #188 recorded as landed rather than
proposed.

Ledger: six merged lanes removed (781 lines). Two lanes are NOT removed
and are rewritten to their remaining plan instead, because their arcs are
unfinished: generated-buffer immutability (Stage 1 merged, Stage 2 not
started) and bottom-panel (Stage 2 complete, Stage 3 ahead). The
bottom-panel block said so in its own text — "this lane is not removed at
2B-3's merge" — so a removal keyed on "the PR merged" would have
discarded live planning. The header now states the rule as ARC-done
rather than PR-merged.

Two method notes are recorded in the ledger because they nearly cost
real work:

- A census by PR number is a proxy, not a measurement. Counting `#NNN`
  in the handoff reported that five of these lanes had no record at all.
  Counting by content found most already documented, with the real gap
  being the implementation PRs while their framings were recorded. The
  absorption written from the first count would have duplicated existing
  entries.
- #194 and #195 keep their lessons in §3 and §5 rather than §1, which is
  why a number search finds them once each. Sufficient under rule 3:
  durable knowledge needs a home, not a required section.

Docs only. No code, no protocol change.
2026-07-30 13:23:32 -04:00
Levi Neuwirth 89ee2f9985 docs(tests): frame the ambient-config isolation defect
Integration tests read the developer's real `~/.config/pmacs/init.lua`.
The suite is green in CI and deterministically red on any machine with a
real config, attributed to whatever branch is checked out — 11 of 67 in
`compile_mode_acceptance` on 2026-07-30, 67/67 with an isolated
`XDG_CONFIG_HOME`.

The mechanism is that `src/editor.rs:770` guards config loading with
`#[cfg(not(test))]`, which is set only when compiling the crate's own
unit tests. An integration test links pmacs as an ordinary dependency, so
the guard is inactive for all 96 of them. The hazard was identified and a
mitigation was written; its scope does not match the threat.

The obvious fix is unavailable: `std::env::set_var` is unsafe and the
crate forbids unsafe, which the repo already knows —
`Installer::root_override` exists for exactly this reason.

Framing only, awaiting review round 1. No code changes.
2026-07-30 13:14:58 -04:00
Levi Neuwirth b27df705bb feat(process): make the signal diagnostic discriminating (Bets 2-4)
Framing acceptances 2, 3, 4, 5, 7 and 8. Evidence collection only: no
tolerance rule, no change to which process is signalled, no disposition
change.

Three distinct failures previously rendered as one string.

**The PTY fallback is now named.** When a PTY's foreground-group lookup
yields no group, the target falls back to the leader — and until now that
rendered "leader-pid", identical to a pipe child that never had a
terminal. `portable-pty::MasterPty::process_group_leader` collapses every
failure into `None` before pmacs can see it, so the errno was gone too.
pmacs now performs the query itself and reports four distinct outcomes:
no master fd, a failed duplicate with its errno, a failed `tcgetpgrp`
with its errno, and a non-positive answer.

Doing that without `unsafe` is the interesting part. `nix::unistd::
tcgetpgrp` needs `AsFd`; `MasterPty` exposes only `Option<RawFd>`; and
every std route between them is `unsafe`, which this crate forbids.
`filedescriptor::OwnedHandle::dup` takes any `AsRawFd` through a safe
blanket impl and returns an owned handle that IS `AsFd`, so a
lifetime-tied view implementing one safe trait is the whole bridge. The
borrow is what makes it sound: the view cannot outlive the master, so the
descriptor cannot close underneath it.

**The report names the signal.** A failed SIGUSR1 and a failed SIGTERM
were the same text. Note this is a reporting gap only — every failed
`kill` returns before the fatal-signal branch, so failed signals are
disposition-identical whatever they are. A separate control pins that the
fatal/non-fatal difference is real for calls that SUCCEED, which is what
gives the first test its meaning.

**`measured_group` is a real observation.** `expected_group` is
`-leader_pid`, and on the spawn-group path the target is `-leader_pid`
too, so the report printed the same number three times and their
agreement was arithmetic rather than evidence. `getpgid` supplies the one
field that can disagree. It establishes no identity — it is read inside
the same read-then-act window, and no portable mechanism closes that for
a group.

Bites, each by an actual revert, all observed to fail:

- collapsing the PTY fallback back into a bare "leader-pid";
- dropping `signal=` from the report;
- making the measured group restate the pid it was handed;
- replacing the job-control fixture with a plain `sleep`, as a positive
  control on the divergence fixture itself.

All four exact-string sites were updated individually, never by a blanket
rewrite: a wholesale rewrite of expected strings is how a format
regression hides. `:2501`'s first-call disposition pin is retained and
updated for the new format rather than replaced.

`nix`'s `process` feature is now declared explicitly. It already arrived
transitively — nix's own `signal` feature depends on it — which is stable
but invisible, and a real requirement resting on another feature's
internals is one refactor away from vanishing. `filedescriptor` is
declared directly for the same reason: pmacs now calls its API.

The reap ledger's comment claiming "EPERM cannot happen for our own
children" is corrected. Its bounded-growth policy is unchanged, but the
justification was wrong: the probe targets a group, and owning the
spawned child says nothing about a group unless the child is still a
member — which nothing measures. The handoff records this together with
the limit of the evidence: the occurrence does NOT establish that the
child itself received EPERM.
2026-07-30 13:08:47 -04:00
Levi Neuwirth 0d2ad49969 test(process): make the group-directed acceptance discriminating (Bet 1)
Framing §3 Bet 1 and acceptance 1. Committed alone, per the branch plan:
this bet decides whether the diagnostic is worth extending at all, so its
result belongs in history before anything depends on it.

The previous version of
`a_group_directed_kill_failure_reports_target_and_leader_separately`
spawned `/bin/sleep` on a PTY and asserted the same pid three times,
conceding in its own doc comment that the values "are asserted to agree
only because nothing has moved the terminal". The entire premise of the
diagnostic is that the terminal's foreground group and the spawned leader
are different entities, and no test exercised a case where they were.

The fixture now drives job control: `bash -m` runs a foreground job in a
fresh process group and hands it the terminal, so `tcgetpgrp` reports a
group that is not the leader. The trailing `; :` is load-bearing — with a
single simple command `bash -c` execs in place, which would leave the
leader owning the terminal and silently restore the agreement.

The bounded wait is also load-bearing rather than defensive. A probe of
this fixture observed the foreground group as the leader FIRST and only
then as the job's group, so measuring immediately would pin the
non-divergent case. The fixture additionally asserts the diverged group
still holds a live member, so a divergent number cannot come from a dead
group.

Falsified in both directions by substituting `leader_pid` for the
`tcgetpgrp` result in `signal_target`:

- the new test FAILS — target=-1020100 (leader) against the expected
  -1020103 (foreground group);
- the OLD test, restored verbatim alongside the same mutation, PASSES.

That pair is the finding: the previous acceptance pinned the
substitution as acceptable.

`/bin/bash` is declared as an optional test dependency and armed with
PMACS_REQUIRE_BASH on BOTH CI platforms, not only Linux — the signal
failures this diagnostic exists to explain have so far occurred only on
macOS. The guard tests the exact path the fixture spawns rather than
`which bash`, because a system with bash on PATH but not at `/bin/bash`
would pass a `which` guard and then fail the spawn.

Verified: both arming arms exercised against an absent path (unarmed
skips, armed fails by name); 20/20 repetitions of the test; `cargo fmt
--check`, `cargo clippy --workspace --all-targets -- -D warnings`, and
`cargo test --lib` (1877 passed) all clean; ci.yml parses.

No production behaviour changes.
2026-07-30 12:49:47 -04:00
Levi Neuwirth df50880762 docs(process): framing revision 4 — correct review assumptions
Integrate canonical main, restore PTY errno capture through the safe
filedescriptor duplication bridge, narrow the macOS occurrence to what
it actually measured, and make the job-control fixture foreground and
discriminating.

Record the portable framing lane and exact recovery commands in the
active-work ledger.
2026-07-30 12:28:48 -04:00
Levi Neuwirth 77887d0683 Merge remote-tracking branch 'githubsucks/main' into process-signal-diagnostic-completeness 2026-07-30 12:21:12 -04:00
Levi Neuwirth 872e4aacad docs(process): framing revision 3 — close review round 2
Three blocking and three major findings, all accepted, all verified in
the code before acceptance.

- The PTY errno proposal is withdrawn. `nix::unistd::tcgetpgrp` needs
  `F: AsFd`; `MasterPty` exposes only `Option<RawFd>`; every route from
  a raw fd to `AsFd` is unsafe and the crate forbids unsafe. The claim is
  reduced to distinguishing the fallback without the errno.
- Revision 2 called `getpgid` ungated. It is not: the gate is a
  block-level `feature!` opened 168 lines above the function, and rev 2
  read the four lines immediately above it. Both calls are available
  because nix's own `signal` feature depends on `process`, verified with
  `cargo tree` and by compiling them. The lane now declares `process`
  explicitly rather than inheriting it.
- Bet 4's `setsid` fixture was impossible: a `spec.group` child is
  already a process-group leader and a group leader's `setsid` fails
  with EPERM. Replaced with a `setpgid`-into-an-existing-group fixture
  with a readiness handshake and explicit cleanup.
- "Recoverable" is withdrawn. `tick_reap_ledger` drops its entry on any
  probe error — including the EPERM this occurrence proves reachable —
  and discards the SIGKILL result while marking the entry killed. The
  claim is now that escalation remains scheduled, and that still-silent
  path is parked as its own lane.
- Revision 2 falsified the wrong sentence. Stage A's disjointness claim
  was about the PTY path and remains true; what the occurrence falsifies
  is the ledger's own EPERM comment.
- Failed SIGUSR1 and SIGTERM are disposition-identical, because every
  failed kill returns before the fatal-signal branch. Acceptance 3 now
  separates the failure-format comparison from a successful-call
  disposition control.
- Bet 5 proposed a test that already exists at src/process.rs:2501. It is
  cited as ground truth and retained, not invented.

No code changes. Framing only, awaiting review round 3.
2026-07-30 12:09:34 -04:00
Levi Neuwirth 4cd4a7bc10
Merge pull request #198 from levineuwirth/bottom-panel-stage2b3
Bottom panel Stage 2B-3: the GPU band, compatible v21 activation, and the negotiated panel_capable flip
2026-07-30 11:40:10 -04:00
Levi Neuwirth 76cb13bda8 docs(process): frame the signal diagnostic completeness lane (rev 2)
Stage B of the lane that merged as PR #176. Stage A parked every
tolerance rule behind one condition — evidence — and the macOS failure on
PR #191 is the first occurrence carrying Stage A's self-describing
format, so the evidence now exists.

It does not support any parked rule, and per review round 1 it does not
support an identity claim either. This lane is evidence collection only:
no tolerance rule, no retargeting, no disposition change.

Round 1 raised two blocking and two major findings. All four are
accepted, and all four were verified against the code first:

- the landed acceptance at src/process.rs:2400 asserts the same pid three
  times, so substituting leader_pid for tcgetpgrp would pass it;
- a numeric pgid cannot establish group identity, and no portable
  mechanism can (pidfd covers processes, not groups; macOS has none);
- the PTY foreground-lookup fallback renders identically to a normal pipe
  child, and portable-pty discards the tcgetpgrp errno before pmacs sees
  it;
- the report never names the signal, though disposition differs between
  fatal and non-fatal ones.

Revision 1 was written to a session scratchpad rather than a branch, so
it was not on githubsucks and review necessarily landed on Stage A's
merged document instead. That is recorded in the revision history: work
is portable only after it is committed and pushed, and this lane broke
that rule on its first step.

No code changes. Framing only, awaiting review round 2.
2026-07-30 11:39:40 -04:00
Levi Neuwirth 7a271f13f5 merge: integrate main @ 391d38a (PRs #197, #196, #191) into Stage 2B-3
`docs/active-work.md` was the only conflicting file, in the same shape
as #191's: `main` inserted the generated-buffer Stage 1 lane immediately
above the bottom-panel header this branch had rewritten. The resolution
keeps both.

Each side's newer text wins where that side owns the fact: `main` carries
the corrected #188 status (MERGED/APPROVED, replacing "OPEN, PROPOSED —
do not implement"), and this branch carries the bottom-panel lane's 2B-3
state and the newer snapshot date, replacing main's "2B-2 MERGED; 2B-3 IS
NEXT" and its 2B-2 boundary paragraphs.

Verified: no conflict markers; every line absent from either parent is a
deliberate supersession by the other, enumerated and checked one by one
rather than counted; all three lane headers present exactly once.
2026-07-30 11:16:23 -04:00
Levi Neuwirth 391d38a2ae
Merge pull request #191 from levineuwirth/generated-buffer-immutability-stage1
Generated-buffer immutability Stage 1: dired and listview adopt the authorized write
2026-07-30 11:14:03 -04:00
Levi Neuwirth 80985ed6dc test(protocol): pin the version-mismatch divergence, not the old constant
`version_mismatch_clean_disconnect` asserted that the `VersionMismatch`
`server` field equals `ADVERTISED_PROTOCOL_VERSION`. Until this stage
that constant and `PROTOCOL_VERSION` were both 20, so the assertion could
not distinguish them and happened to pin the wrong one. Stage 2B-3 splits
them — the advertised value is a compatibility floor, `PROTOCOL_VERSION`
is the ceiling a frontend may counter-offer up to — and the daemon
correctly reports the ceiling, so the stale assertion failed on all four
CI Test legs.

The production behaviour is right and is unchanged here. Only the test
moves, and it now pins the divergence in both directions: the `Hello`
assertion above holds the advertised floor, a new `assert_ne!` holds the
fact that the reported version is deliberately not that floor.

That second assertion is why this is not a one-character edit. Stage
2B-3's own pin for this rule, `an_unsupported_offer_is_refused_by_name`,
is `#[cfg(feature = "crdt")]` and CI never enables `crdt` — so it is dark,
and `m5_5_acceptance` is the only live guard CI runs on this behaviour.

Bite: reverting `src/daemon.rs:757` to `ADVERTISED_PROTOCOL_VERSION`
fails the test with `left: 20, right: 21` and the named message; restored,
it passes. Verified against the whole suite under an isolated
`XDG_CONFIG_HOME` — 3303 tests, 100 binaries, zero failures.
2026-07-30 10:45:27 -04:00
Levi Neuwirth 12e2cff466 merge: integrate main @ c14f2de (PRs #197, #196) into the Stage 1 lane
`docs/active-work.md` was the only conflicting file. #196 added the dired
Stage 2a lane at the position this branch had used to relabel the #188
framing lane header; the resolution keeps both, changing neither side's
wording.

`src/editor_core.rs` auto-merged. Both lanes touch it, so a clean
textual merge is not evidence of a clean semantic one — the gate suite
is re-run in full on the merged tree rather than inherited from the
pre-merge head.

Resolution verified for line loss in both directions: the resolved file
differs from `main` only by this branch's own authored edits, and
differs from this branch only by additions taken from `main`.
2026-07-30 10:13:37 -04:00
Levi Neuwirth c14f2de428
Merge pull request #196 from levineuwirth/dired-stage2-impl
dired Stage 2a: rename and delete reconciliation
2026-07-30 10:03:09 -04:00
Levi Neuwirth f5a146ee0f
Merge pull request #197 from levineuwirth/process-teardown-stdin-deadlock
fix(process): close child stdin before joining reader threads
2026-07-30 10:00:09 -04:00
Levi Neuwirth ed544fab41 fix(process): arm the setsid dependency, correct the orphan account
Review round 2, three findings.

setsid is util-linux, not coreutils, and the standard `cargo test --lib`
gate must not hard-fail on a tool the README does not declare -- a
minimal or BusyBox container would fail without ever testing pmacs. The
hard assert becomes skip-unless-armed via PMACS_REQUIRE_SETSID, which is
the pattern the silent-skip lane already established, so the test cannot
quietly report `ok` having never run where the tool is guaranteed. CI
arms it on Linux; README declares it. Both arms verified against a PATH
with setsid genuinely removed: unarmed skips with its message, armed
FAILS with the diagnostic.

The durable causal account was wrong, and this corrects it in the
framing, the handoff and the ledger. basedpyright's console script runs
bundled node through `subprocess.run` and WAITS
(nodejs_wheel/executable.py:50, verified in the installed 1.39.6). It
does not exit at spawn. What orphans node is pmacs: `shutdown()` SIGTERMs
the recorded pid -- the Python wrapper -- which dies without forwarding
the signal, leaving node at PPid 1 holding the pipes. The refutation was
already in hand: the initialize handshake succeeds, which a wrapper that
exited at spawn could not have done, and the PPid 1 observation was taken
after shutdown had killed it.

The fix is unaffected -- the deadlock and its bite are unchanged -- but
the parked follow-up changes target: not "tolerate servers that
self-orphan" but "stop orphaning them", i.e. signal the process group
rather than a wrapper pid that swallows the signal. Framing section 5 P2
restated.

Also corrects a stale CI-ordering claim: the handoff said pyright must
stay unarmed until the timeout lane lands, but #195 is this PR's base and
gave every job a timeout-minutes. The one live reason is that CI does not
install basedpyright at all. The ci.yml comment asserting the job has no
timeout-minutes was stale for the same reason and is rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:54:06 -04:00
Levi Neuwirth 71db6e98f0 docs(active-work): record round 1 on the bottom-panel lane
Integrated main (#195) first so the lane block is written on top of it
rather than conflicting with it — the one conflict this file always has,
paid at the merge that was happening anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:43:30 -04:00
Levi Neuwirth 02b54d8d73 Merge remote-tracking branch 'githubsucks/main' into bottom-panel-stage2b3 2026-07-29 22:41:38 -04:00
Levi Neuwirth a1fada45e8 docs(bottom-panel): record framing revision 7
Review round 1 widened two contracts this document had stated narrowly, and
a narrow statement is what let the implementation drift inside it:

R7-1 — the stable-probe decision was written about the geometry DECLARATION,
which let painting and hit-testing keep using the document-dependent advance.
Three grids, one asserted. It now covers all three consumers, resolved once
and cached behind the declaration so they agree by construction.

R7-2 — the x=0 full-width contract was stated about `total.cols` and read as
a claim about the declaration alone; it governs the band's content rectangle
too, remainder included.

R7-3 records that splitting the advertised baseline from PROTOCOL_VERSION
makes the VersionMismatch server field load-bearing rather than incidental.

R7-4 names criterion 54's fixture and why it drives the real display="panel"
adopter opt-in instead of opening a terminal and moving it.

A2B-3 and criterion 48 gain the halves that were implicit and therefore
skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:37:41 -04:00
Levi Neuwirth 5fe7eb30b6 fix(bottom-panel): finish the panel port, not one omission at a time
Review round 1: six findings, four sharing one shape — the panel layer was a
partial port of the document/terminal layer, and the tests asserted the
declaration side only, so each omission was invisible. Audited as a port
rather than patched as a list.

GEOMETRY AGREEMENT (P1). Three grids had drifted apart. The declaration
subtracted `TEXT_LEFT` from its width against the parent framing's explicit
contract ("`total.cols` describes the full-width panel grid beginning at
x=0; document `TEXT_LEFT`/gutter padding is unrelated"), while painting and
hit-testing used the document-dependent `mono_advance` and the declaration
used the stable probe. So daemon columns could overflow the surface and a
click could resolve to a different cell than the one painted — and the new
test separated the two advances and then asserted only the declaration, so
it saw none of it.

The fix is structural, not three edits: the advance is cached BEHIND the
declaration (`PanelBand::declared_advance`) and painting and hit-testing read
it. They cannot disagree, because there is one value. The band's rect is now
x = 0 across the full surface width, and the fractional right-edge remainder
is band background that maps to no cell — which is what the framing says and
what `hit_test_cell`'s column bound already enforced.

GESTURES (P1). Only `Move` was sent. Left press never armed, so `Drag(Left)`
was never emitted and panel selection could not work; releases outside the
band were dropped, leaving the daemon holding a button down; right-click and
wheel never consulted the band at all and were applied to the document
underneath.

The root cause is that four handlers each decided for themselves whether the
band owned a pixel, and three did not ask. There is now ONE authority —
`PointerSurface` / `classify_pointer_surface` — and all four route through
it, so a future handler cannot quietly forget the band. `PanelBackground` is
its own arm: the remainder is the band's pixel even though it emits no
`PanelPointer`, so it must not fall through either.

PASSIVE CARET (P1). The producer ships `cursor` for a passive panel too — it
is the window's real point and the daemon does not suppress it — so painting
it unconditionally put a second insertion caret on screen. Gated on
`frame.focused`, the presentation bit Q#BP14b reserves for exactly this.

UNDERLINES (P2). `build_grid` planned them and nobody consumed them. Straight
forms now ride the quad batch and curly rides the squiggle pipeline, the same
split the terminal path makes for the same reason.

VERSION MISMATCH (P2). The daemon reported the advertised baseline as the
server version while its own `PROTOCOL_VERSION` is 21, contradicting the wire
field's own documentation and inverting the upgrade advice. The field doc now
states what each side can know, and the acceptance is re-pinned — it had been
holding the wrong value in place.

Two gaps the audit found beyond the six, same shape:
  * the headless probe never armed the panel wire at all, so no probe could
    ever exercise a band;
  * a disconnect left the band on screen — the frozen, live-looking surface
    the terminal arm already refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:29:24 -04:00
Levi Neuwirth 46ac2074c3 Merge remote-tracking branch 'githubsucks/main' into process-teardown-stdin-deadlock
# Conflicts:
#	docs/active-work.md
2026-07-29 22:16:50 -04:00
Levi Neuwirth 09eaaa5e49
Merge pull request #195 from levineuwirth/ci-timeouts-concurrency
ci: put a timeout on every job, cancel superseded PR runs, lint the protocol
2026-07-29 22:13:29 -04:00
Levi Neuwirth e11c3d4ab8 docs(active-work): record dired Stage 2a review round 1
The four findings were one shape — a failure that left state wrong and
told nobody — so the lane records them as one lesson rather than four
bugs: every one was a `pcall` or a discarded return value, and each
looked like defensive coding.

Also records the round-1 pin that passed with its own bug restored
(acceptance 53's attribution assertion was satisfied by the deleted
path's basename appearing elsewhere in the same message), the refreshed
gate numbers, and that `main` was re-measured after the round and had
not moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:47:32 -04:00
Levi Neuwirth a131c880e2 test(stage2a): make acceptance 53's attribution assertion bite
Bite-verifying the round-1 pins caught one of them passing with the bug
restored. `contains("only.txt")` was satisfied by the status message's
own `deleted only.txt:` prefix — the deleted path's basename — so
stripping the `buffer "…"` attribution changed nothing the assertion
could see.

Both halves now assert the buffer's OWN name, which for a path-backed
buffer is the full path and which only the attribution can produce.
Dropping either name — the refusal reason's or the kept-modified list's
— now fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:34:42 -04:00
Levi Neuwirth 44135fcd73 fix(stage2a): close review round 1 — four unreported failures
All four findings were the same shape: a failure that left state wrong
and told nobody.

**Delete refusals reach the user.** `reconcile_delete_and_fire` returned
`kept_modified` and `refused` and both production callers discarded
them, so a last-buffer refusal or the asynchronous modified-buffer race
left the file gone and the buffer still bound to it — and the next
`C-x C-s` recreates the deleted file. Reporting now happens inside the
shared seam, not at its call sites, for the same reason the
reconciliation does: a caller that has to remember to report is a caller
that will forget. The message names the buffers (capped, with a count
for the rest) and states the consequence, and it is written to
`EditorCore::status`, not `pmacs.error` — that channel is defined only
by a test stub, so a report there would be the same silence.
`reconcile_delete` now prefixes `kill_buffer`'s reason with the buffer
name, because "cannot kill the last remaining buffer" does not say which
buffer is now bound to a deleted path.

**The LSP subscribers stop swallowing their own failures.** Ignored
`pcall`s around `did_close`, `forget_uri`, `did_open` and overlay
re-rooting made the callback return successfully, so the
`all-must-succeed` logger had nothing to log — concretely, a stale server
made `forget_uri` raise while the callback carried on with the old
stores, routes and `documents` entry all live. A shared failure sink
attributes each step, reports on both channels, and raises **after** the
loop, so one unreachable server cannot leave every other attachment
unreconciled.

**`forget_uri` abandons requests through the established path.** It
purged `pending_routes` and `pending_external` but not the same ids
`send_request` put in `LspClient.pending`, and recorded nothing in
`cancelled_rids`. The per-rid work is extracted from
`drain_cancelled_externals` as `abandon_request` and reused, rather than
a second incomplete copy: route, client pending, cancelled record and
`$/cancelRequest` now happen together.

**Acceptance 35 is pinned.** With a plain delete the forbidden fallback
was unobservable — `find_or_open` raises out of `load_file` and the
`pcall` swallows it — so both assertions passed with the fallback
present. The plan now deletes the origin's file and recreates it, which
gives the fallback something to open and makes "restores nothing"
falsifiable. The corrected G1 explanation also reaches the production
comments, which still repeated the false `resolve_target_buffer::NotFound`
story.

New pins: acceptance 53 and 53b assert the status channel; a stale-server
row asserts attribution on both channels *and* that the healthy
attachment still reconciles; an `lsp.rs` unit test asserts the client-side
abandonment with an unrelated request as its control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:28:16 -04:00
Levi Neuwirth d5a1dc2660 docs(active-work): record the CI-falsified reproduction and the acc28 flake
Round 2 facts for the lane-4 entry: the dash/bash divergence that
falsified the `<&0` form and how the positive control caught it, the
eleven-suite Bet 2 result, and the evidence that acc28 on macos/lua54 was
a flake -- a rerun of the same job on the identical head, not an
assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:09:35 -04:00
Levi Neuwirth 9b1cf3d6c9 fix(process): reproduce the deadlock without a shell
CI falsified rev 2 of the framing. The synthetic reproduction used
`sh -c 'cat <&0 & exit 0'`, and `<&0` does not defeat the POSIX rule it
was chosen to defeat: /dev/null is assigned to an asynchronous list's
stdin *before any explicit redirections*, so by the time `<&0` runs, fd 0
already IS /dev/null and the redirect duplicates it onto itself. bash
happens to skip the default when a stdin redirect is present; dash --
Ubuntu's /bin/sh, and CI's -- does not. It passed locally and failed on
three CI legs.

Control 2 caught it and named its own cause. That is the fourth vacuous
reproduction in this lane and the first found by a control rather than by
a reviewer -- which is the argument for the controls, so the lesson is
recorded that way in the handoff.

The reproduction now uses `setsid --fork cat`: it forks, the parent
exits, and the child inherits stdin/stdout/stderr untouched. No shell, no
asynchronous list, no /dev/null rule, no implementation variance.
setsid(1) presence is asserted rather than skipped -- a skip would
reintroduce the silent-green shape the arming lane removed.

The fix under test is unchanged. Bite re-verified by revert on the new
form: ok in 2.03s with `stdin.take()`, FAILED at 10.00s on the
recv_timeout without it, both controls passing first.

Also adds bottom_panel_stage1_acceptance to the framing's Bet 2 falsifier
list. It holds PTY-in-panel tests and its absence from rev 1 was a real
gap, not a judgement call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 20:58:52 -04:00
Levi Neuwirth e00cdd7ca6 fix(bottom-panel): satisfy the strict clippy gate in both configurations
Three float comparisons became explicit epsilon checks, one const-valued
assertion moved into a const block, and the crdt-only half of the new
acceptance suite is now gated import-by-import.

That last one is the interesting part: the negotiation-rule tests are pure
and run in BOTH configurations, while everything needing a real daemon
needs the crdt feature — a semantic session is necessarily a text replica,
so a non-CRDT build cannot host one at all. Splitting the imports along
that line is what keeps the default clippy configuration clean while
leaving the version-ladder assertions where CI can actually reach them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 20:28:41 -04:00
Levi Neuwirth 36a37f6086 docs(active-work): add the lane-4 process-teardown deadlock entry
Integrated late, immediately before push, per the ledger-contention
rule. Records the measured base, the recovery command, the defect, the
reproduce-first diagnosis method, the full gate table with the
revert-verified bite, and what is deliberately parked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:29:31 -04:00
Levi Neuwirth eb9b36387c fix(process): close child stdin before joining reader threads
`RuntimeHandles::drop` joined its reader threads in the `Drop` body,
which runs before any field drops. The `ChildStdin` sink lives inside
`StdinWriter` in the `stdin` FIELD, so it could only be released after
the join returned -- and the join was waiting on readers blocked in
`read()` on pipes whose write ends the child still held, because the
child never received the stdin EOF that would have made it exit.

A closed cycle, entirely inside one function. Teardown hung forever.

This is the root cause of `m4_5_basedpyright_initializes_and_negotiates_
encoding` hanging indefinitely -- diagnosed with gdb stacks plus /proc fd
forensics on a wedged process, reproduced 5/5 deterministically. It also
explains why the hang looked intermittent and machine-local: a
shim-launched server orphans its real process (basedpyright's console
script spawns bundled `node` and exits, leaving it at `PPid 1`), so
nothing teardown signals can reach it, while a direct binary like clangd
or gopls is a genuine child whose pipes close on reap.

`spawn_reader`'s `cancel` flag does not help: it is consulted between
reads and around `send_timeout`, never while `read` is blocked. The
existing comment's premise -- "dropping the master closes the kernel pipe
and unblocks `read`" -- holds for a PTY master but not for pipe mode,
where `read` returns only once *every* write end closes.

The fix reuses `close_stdin`'s existing, already-idempotent mechanism at
the one site missing it. Reordering the struct's fields cannot work: a
type's `Drop::drop` body runs before all of its fields regardless of
declaration order.

Bounded claim: this delivers EOF, so it fixes children that drain stdin
to EOF -- which stdio language servers do. A child that ignores EOF, or
that stops draining while bytes are queued (the writer's `write_all` is
blocking), still wedges the join. Making the `read` itself cancellable
via the poll path already used by `spawn_group_reader` is the standing
deferral that covers those, and is deliberately not in this change.

Test: `teardown_closes_stdin_before_joining_readers`, in `--lib` so it
runs in the standard gate. It models the real shape with an orphaned
grandchild, and carries two positive controls, because this lane wrote
three reproductions that passed against the unfixed tree before one
bit. The `<&0` redirect is load-bearing: POSIX XCU 2.9.3 assigns
`/dev/null` to an asynchronous list's stdin when job control is off, so a
bare `cat &` exits immediately and proves nothing. Teardown runs on a
worker thread behind `recv_timeout` so a regression FAILS in 10s rather
than hanging -- a hanging test would reproduce the hazard being removed.

Bite verified by revert: with the fix `ok` in 2.03s; with the single
`stdin.take()` line commented out, FAILED at 10.00s on the timeout, both
controls having passed first.

Docs: framing doc added; handoff gains the drop-body-before-fields lesson
and the reproduction-needs-a-control generalization, and its section 3
caveat is corrected -- the desktop's basedpyright binary was never
broken. The `--skip basedpyright` gate entry stays for now; dropping it
is a separate proposal owed evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:22:59 -04:00
Levi Neuwirth 8ee08d93e5 docs(bottom-panel): record Stage 2B-3 and close Stage 2
Handoff section 1 gains the 2B-3 bullet and the protocol anchor moves to
v21 with the rule that matters stated once: advertise the baseline,
negotiate up from the frontend's AttachRequest, and reserve moving the
advertised version for a change that cannot be expressed additively at
all. 2B-1's forward-looking constraint is marked discharged rather than
deleted, because its acceptance still passes unchanged and that is the
evidence.

COHERENCE section 14 grades the bottom/side panel primitive as complete on
BOTH frontends rather than 'Stage 2 pending its own framing', section 20
P5 follows it, and the section-19 protocol bullet records that the v21
family is live in production without an incompatible handshake change.

The active-work lane is rewritten to the shipped slice, including the
rejected activation alternatives and why the server-first shape forces
each one out, and the one-way compatibility window it leaves open.

The 2B-2 acceptance suite's header said production keeps panel_capable
false for every semantic session. That is no longer true, and its
assertions did not change — which is the point, so the header now says so
rather than being quietly left stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:12:36 -04:00
Levi Neuwirth d5fadf4120 docs(active-work): add the dired Stage 2a lane
Rides this branch rather than a standalone ledger PR: with several PRs
open, a lane written on `main` for work that lands elsewhere
re-conflicts on every merge.

Records the measured merge-base as pasted output, what 2b and 2c still
owe so the split boundary is auditable, the two re-pinned m4 rows, the
one framing claim found wrong, the two bites that were vacuous as
specified and why, the gate numbers, and the §16 ownership warning
against starting Journey Stage 1b while this is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:12:15 -04:00
Levi Neuwirth 71cc31bfbb test(bottom-panel): close three vacuous assertions the bites exposed
23 falsifying mutations, each executed. Three of the first-pass assertions
were VACUOUS and the mutation runs are what found them:

  * The contrast assertion compared status_band_top before and after
    installing a panel — a FIXED POINT. The blanket rewrite the framing
    exists to prevent (subtract the band from the status boundary too)
    moved both readings together and passed. It is now anchored to an
    independent formula: the physical window bottom minus the band height.

  * The criterion-46 pixel test only checked that no pixel moved above the
    band and none below it. Installing a panel reshapes the document to the
    smaller height, and THAT produced the whole diff — so the test passed
    with the band painting nothing at all. It now counts differing pixels
    in the divider row and the band's cell rows directly: content produced,
    not an invariant preserved.

  * A2B-3's fixture compared two ASCII documents, which in a monospace
    family have identical glyph advances — so it could not tell the stable
    probe from the document-glyph fallback. It now separates the two
    derivations explicitly and asserts they produce different column counts
    in the fixture, so the claim about which one the declaration uses is
    discriminating.

And one about the CODE, not the tests: 'a v20 semantic session receives no
panel frame' is defence in depth, not the placement gate. The producer's
peer flag and the write-loop filter both suppress PanelFrame below the
panel version independently of panel_capable, so that claim passed with
the capability gate removed entirely. The load-bearing claim is placement:
the adopter's buffer must land in the pre-panel session's own DOCUMENT
window, because a side window it cannot render is simply invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:06:33 -04:00
Levi Neuwirth f6af3f7335 test(m4): re-pin two rows whose parked behaviour Stage 2a discharges
`rd9` and `rd14` pinned #190's deliberate restraint on the
`apply_resource_op` delete arm: descendants stay orphaned, and only the
first of two duplicate path-bound buffers is reconciled. Both doc
comments gave the same reason — widening would have routed N buffers
through `remove_buffer_and_fire`, which is phase 2 without phase 1, so a
tree delete would have left up to N windows on removed ids.

`EditorCore::reconcile_delete` composes both phases, so that constraint
is discharged and the old assertions are no longer merely obsolete: an
orphaned buffer whose next `C-x C-s` recreates a file the user deleted
is the defect. Each row now asserts the new contract in BOTH directions
— the buffer is reconciled away, AND no window holds a removed id — so
neither an exact-path/first-match regression nor a widening that skips
phase 1 can pass. Each direction is bite-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:05:51 -04:00
Levi Neuwirth 5e30e97f17 style(stage2a): drop an unused mut the new delete row left behind
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:54:22 -04:00
Levi Neuwirth 57be2bacd1 test(bottom-panel): acceptance for the band, the activation, and the flip
Placed by what each assertion needs: the band's pixel geometry, the epoch
latch, the probe-derived columns, and the three-boundary contrast
assertion live in pmacs-gpu's own tests because they need a real State and
a real surface; the handshake, the negotiation, and the capability flip
live in bottom_panel_stage2b_gpu_acceptance because they need a real
daemon.

Every acceptance runs both directions in one fixture. The activation test
uses ONE daemon for all three halves — a shipped v20 client reaching its
initial grid, a v21 counter-offer receiving a Present band, and a v20
semantic session that is never sent a panel frame and keeps a live
document window. Two daemons could each pass their own half while the same
build was incapable of serving both, which is the only property that
matters.

Two real defects the new tests caught in my own implementation:

  * edge_scroll_direction has no upper bound, so moving its boundary was
    necessary but not sufficient — a pixel inside the band still read as
    'further down the document' and armed the document's auto-scroll. That
    is the exact named symptom of leaving that consumer on the old bottom.
    The falsification keeps both answers: the probe pixel is inside the
    unmoved boundary's own edge strip, so the two genuinely differ, and a
    third assertion proves the feature is not simply switched off.

  * apply_panel_payload ignored the exhaustion latch, so a latched session
    kept storing frames and reporting 'changed'. That left presented() as
    the only thing between a disowned declaration and a painted band, and
    spent a reshape on every arriving frame for the rest of the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:51:55 -04:00
Levi Neuwirth edfb52f29c test(stage2a): make three bites bite, and correct one framing claim
Running the bites found three that did not falsify anything.

Item 28's rename row cannot pin the walk's containment rule:
`reconcile_rename` calls `Path::strip_prefix` to rebuild a descendant's
tail, and that is component-aware too, so a string-prefix walk is
silently corrected a second time. Deletion has no such second guard —
the walk's verdict IS the kill list — so the row moves there, and a
string prefix now provably destroys a buffer on `foobar.txt` when
`foo/` is deleted.

Item 30's composition-order assertion was a tautology: the LSP attach
leaves `diagnostic` LAST in the stack, and moving the last element to
the end is a no-op, so a remove-and-re-push was indistinguishable from
an in-place mutation. The row now pushes one more overlay after it and
asserts that precondition explicitly.

Item 34 needed both a restructure and a correction. §5's G1 says a
stale captured path "materializes a phantom" via
`resolve_target_buffer`'s `NotFound` arm.
It does not: `pmacs.buffer.find_or_open` calls `file_io::load_file`
directly and maps the error, so a missing path RAISES, and the
`NotFound` arm belongs to `resolve_target_buffer`, which serves
`pmacs.window.display_file` and the startup target rather than this
binding. The real defect is smaller and still real — the `pcall`
swallows the raise and the user is stranded wherever the last applied
op left them — so the plan now edits another file first, which is what
makes the restore observable at all. The correction is recorded at the
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:51:41 -04:00
Levi Neuwirth d2c3e6099e feat(bottom-panel): paint and drive the GPU panel band
Bottom-panel Stage 2B-3, part 3 of 3: the band a user can see.

Declaration. `FrontendCellGeometry` ships on attach — deliberately without
a side window, because the daemon needs columns before it can paint a
first frame and gating on panel presence would deadlock the first open —
and again on resize and on every font/scale transaction. The two triggers
differ in exactly one way: `Surface` dedups an identical `CellSize`,
`Metrics` never does, because the cells can be identical while the pixels
behind them are not. That is the case daemon-side value dedup cannot see,
and it is why the epoch is frontend-owned.

Columns come from the stable normal-face probe, never `mono_advance`'s
document-glyph fallback: that fallback would make the panel's width
depend on the first glyph of whatever file is open, so two frontends with
identical metrics and different documents would derive different totals.
A probe that returns no width declares zero usable geometry rather than
reaching for a document sample.

Receipt. `Absent` is authoritative and clears; silence retains. A frame
is validated before any state is touched, so rejection is atomic and the
previous valid frame survives it. A duplicate does no work at all — no
plan rebuild, no reshape, no redraw. `Absent` does NOT discard the
geometry declaration: the frame capacity is unchanged by a panel closing.

Paint. The divider strip and the band's cells ride the existing quad and
glyph layers, in BOTH document and terminal modes — gating the band on
`terminal_mode` would make it vanish exactly when it is hosting the
output the user asked for. The strip's painted rect IS its hover/drag hit
rect, so the icon cannot advertise a target the press would miss.

Input. The band claims gestures before either document path: divider
press starts a drag, motion sends `PanelResizeRows` only when the
requested ROW count changes, bare motion inside the band is a `Move` that
neither focuses nor claims. A drag whose epochs no longer match the
panel on screen is dropped rather than applied to its successor — the
same rule the daemon enforces on receipt, checked on both sides because
neither may depend on the other having done it.

Outbox. Four more tail-only coalescing tags. Geometry is latest-wins
because epochs need only increase, not be consecutive. Resize coalesces
over the complete event including its epochs. Panel `Down`/`Up`/wheel/
context stay lossless and ordered: repeated left `Down`s are what the
daemon reads as a multi-click.

Exhaustion latches, and the latch is load-bearing beyond dropping the
frame: an old `Present` whose epoch still matched would otherwise
resurrect a band under geometry this frontend has disowned.

One pre-existing gap found and left alone: the terminal glyph layer
paints every run in one fixed color, dropping `TextRun::color`. The
panel layer resolves its runs properly rather than mirroring that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:40:46 -04:00
Levi Neuwirth 3c66370b8c test(stage2a): the reconciliation acceptance suite
`tests/resource_reconciliation_acceptance.rs`, 23 rows, no dired
content — items 23–37 and 50–55 driven through the real entry points:
`pmacs.fs.rename` / `pmacs.fs.remove` fire-and-forget for the drain
harvest, `pmacs.buffer.apply_resource_op` for the synchronous arm, and
the fake server's `workspace/applyEdit` for the applier.

The rows that took design rather than transcription:

Item 27 opens two descendants AND two buffers on one exact path, since
one child would not defeat a first-match lookup. Item 29 tests name
provenance in both directions, including a name explicitly set to a
string that normalizes to the file's own path — the case a
path-equivalence heuristic gets wrong. Item 30 paints a real frame and
counts diagnostic underlines per window rect, because
`DiagnosticView.uri` is private and a store assertion would prove
nothing about re-rooting; it also pins each overlay's index in the
composition order, which is what a remove-and-re-push breaks. Item 53b
states its three assertions individually, since a compound check can
pass on two of the three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:40:38 -04:00
Levi Neuwirth 431d844322 feat(bottom-panel): split the GPU document bottom into three boundaries
Bottom-panel Stage 2B-3, part 2 of 3: the pixel substrate for the band.

`text_area_bottom` was three boundaries wearing one name — its own doc
comment called it "the single source for every bottom-of-text
computation" — and once a band can be installed they must diverge:

  status_band_top          = max(0, height - status_band_height)
  geometry_capacity_bottom = max(0, status_band_top - divider_height)
  document_text_bottom     = max(0, status_band_top - installed_band)

The census is 29 matches: 20 production call sites, 1 definition, 8 test
sites. All 20 were read in their enclosing function and classified
individually — 8 status-owned, 12 document-owned. A blanket rewrite that
subtracted the band from all of them would move the status chrome with
the document and pass an "everything moved" assertion, which is why the
classification is per site and the criterion asserts both directions.

The three easiest to get wrong keep their named symptoms: document
completion placement is document-owned (status-owned would overlap the
band), minibuffer candidate clipping is status-owned (the minibuffer is
global bufferless chrome anchored to the band, and clipping it at the
document boundary would cut it off), and edge scrolling is document-owned
(left on the old bottom it would auto-scroll from inside the panel).

`geometry_capacity_bottom` reserves the divider even while the panel is
absent. That asymmetry is what breaks the first-open cycle: the daemon
sizes a panel from the capacity it was told about, so a capacity that
ignored the divider would grant a first panel that does not fit once the
divider appears beside it. The document loses no pixels until a `Present`
frame is really on screen.

`PanelBandInset` is a newtype, not an `f32`, because three boundaries here
take a pixel height and only one takes this one.

Alongside it, the band's own machinery: `PanelBand` with ONE derivation of
"is a panel on screen" (`presented()` — retained valid frame, matching
geometry epoch, latch clear), the frontend-owned epoch state machine with
its fail-closed exhaustion latch, the `Absent`-is-authoritative receipt
path, `panel_cell_capacity` (no per-axis cap — a panel may legitimately be
wider than a PTY — plus the daemon's virtual status row), the stable
normal-face probe for column count, and the divider strip whose paint rect
IS its hit rect.

`TerminalPaintPlan::build_grid` factors the shared cell planner so a panel
and a terminal cannot disagree about a wide-continuation pair; terminal
selection spans stay outside it rather than being faked as empty inside.

`PANEL_MIN_VERSION` moves into `pmacs-protocol` so the GPU frontend aliases
one definition instead of restating 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:30:57 -04:00
Levi Neuwirth aa813ec3b6 test(stage2a): unit pins for the URI teardown and the bus order
`src/lsp.rs` gains nine: the fourteen-family store inventory with a
17-entry precondition so it cannot pass vacuously, the route purge with
`workspace/symbol` and another server's route both surviving, the
awaiter drain joined on the rid, the error contract's two arms, the
late-publish drop with its does-not-over-reach companion, the
`mark_document_stale` gate across all three stale stores, exact-pair
tombstone identity, and reclamation under both `start_generation` and
terminal `forget`.

`src/diag.rs` pins that `forget` drops the epoch while `clear`
deliberately bumps it — the leak a `clear`-based forget would leave in
the one map nothing prunes.

`src/async_runtime.rs` injects two resource replies onto the private bus
in each order and asserts `TickOutcome.resources` reports arrival order,
not allocation order; plus that a failed or cancelled mutation is not
harvested at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:23:41 -04:00