From 1805705a3aa22fcda4efabdff61afa331c20a464 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:46:18 -0700 Subject: [PATCH 01/14] docs: add implementation plan for wall-pins-closure --- docs/plans/2026-07-29-wall-pins-closure.md | 1005 ++++++++++++++++++++ 1 file changed, 1005 insertions(+) create mode 100644 docs/plans/2026-07-29-wall-pins-closure.md diff --git a/docs/plans/2026-07-29-wall-pins-closure.md b/docs/plans/2026-07-29-wall-pins-closure.md new file mode 100644 index 000000000..cc4cc9f95 --- /dev/null +++ b/docs/plans/2026-07-29-wall-pins-closure.md @@ -0,0 +1,1005 @@ +# Wall Pins Closure Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Close the three remaining `test.fail` pins in the restart contract wall (`test/e2e-browser/specs/restore-contract-wall-rust.spec.ts`) so the wall finishes green with ZERO expected-fail pins, stably (3 consecutive full runs). + +**Architecture:** Three product fixes to the restart-resilience machinery. Pin 1 (claude never-conversed → Respawn, not DeadSession) and Pin 3 (pending-marker read → loud `fresh_by_race` verdict + DOM-visible breadcrumb) both modify `derive_verdicts` in `crates/freshell-ws/src/reconcile.rs` — adjacent match arms, sequenced in that order. Pin 2 (claude identity durable BEFORE the PTY spawn makes it observable in argv, plus a wrong e2e probe selector) is a disjoint subsystem and lands last. All three pins were root-caused by a systematic-debugging investigation; each task re-verifies its load-bearing diagnostic claim before building on it. + +**Tech Stack:** Rust (axum/tokio server in `crates/`), TypeScript/React client (`src/`), Playwright e2e (`test/e2e-browser/`), Vitest unit tests (`test/unit/`). + +## Global Constraints + +- **Worktree:** all work happens in `/home/dan/code/freshell/.worktrees/wall-pins-closure` on branch `fix/wall-pins-closure`, based on `origin/main` at `c1c67464` (already fetched and current — verify with `git log --oneline -1 origin/main` before Task 1; if origin/main moved, rebase first: `git fetch origin && git rebase origin/main`). +- **Ports:** NEVER use ports 3001 or 3002 (the user's LIVE server runs on 3002). The e2e harness picks kernel-ephemeral ports automatically (`findFreePort()` binds port 0) — never override with a fixed port, never set `PORT`. +- **NEVER restart the user's live server.** NEVER use broad kill patterns (`pkill`, `killall`, `kill` by name). Tests kill only PIDs they own. +- **Shared host:** no synthetic load, no parallel wall runs. +- **Node test coordinator gate:** before any `npm test`, run `npm run test:status`; if the gate is held, WAIT and retry (poll every 60s) — do not force. Full runs use: `FRESHELL_TEST_SUMMARY='wall pins closure' env -u FRESHELL_BIND_HOST npm test`. +- **TDD:** Red-Green-Refactor for every step. Frequent, focused, atomic commits. +- **Wall flip protocol** (spec header, `restore-contract-wall-rust.spec.ts:12-16`): Playwright turns an unexpected PASS of a `test.fail()` test into a hard failure — that is the signal to DELETE the `test.fail()` lines and let the assertion run green. Never widen a pin; never convert a pin to `test.fixme`. **If a pin cannot honestly flip, STOP and report the failing evidence — never re-pin.** +- **Contract:** the verdict `reason` field is a free-form `Option` / `z.string().optional()` — NOT enum-pinned. Adding `"fresh_by_race"` requires zero schema regeneration, but the gates still verify: `npm run contract:generate` must produce no diff, `npm run test:port` green, `cargo test -p freshell-protocol --locked` green. +- **Docs:** README.md is the only end-user markdown doc; this plan (under `docs/plans/`) is a working/agent doc. Create no other markdown files. +- **PR policy: do NOT create a PR.** Push the branch (`git push -u origin fix/wall-pins-closure`) and stop. Landing happens outside this workflow. +- **Rust gates (every task that touches Rust):** `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`. +- **E2e invocation:** `npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium [-g ""]` — `--project=rust-chromium` is mandatory (the wall is in `RUST_ONLY_SPECS`). The harness lazily builds `target/release/freshell-server`; pre-build with `cargo build --release -p freshell-server` to keep test timing honest. + +## Scope note + +The three pins are one deliverable — "the wall finishes green with zero pins" — with a load-bearing fix order (Pin 1 → Pin 3 share `derive_verdicts`; Pin 2 is disjoint). They ship as one plan on one branch; each task is independently red-green testable. + +## Background: the three pins (current state, verified against `c1c67464`) + +| Pin | Wall leg (title / `test.fail` line) | Root cause (verified by exploration) | +|---|---|---| +| 1 | `THE RULER: all pane types live, one SIGKILL, every §2 contract holds` (`:1459`, pin at `:1500-1503`) — red leg: the claude `--resume <preallocatedId>` argv poll at `:1752-1760` | `derive_verdicts` Absent arm (`reconcile.rs:316-360`) has an amplifier carve-out (`:327`) but none for claude. A claude pane created (id preallocated, ledger binding durable → `ever_observed` true via `ledger.ever_bound`) but never conversed with has no transcript file → `Absent` → `DeadSession{session_not_on_disk}` (`:349-353`). PR #565's locator fallback (`crates/freshell-server/src/existence.rs:156-170`) only rescues *exists-but-unparseable* files; a never-created file is genuinely `Absent`. | +| 3 | `SIGKILL-inside-locator-window: never silently fresh` (`:1933`, pin at `:1949-1952`) | Write side complete: `record_pending` at `terminal.rs:2449-2462` for `MARKER_MODES=["codex","opencode","amplifier"]` (`:105`), keyed by `terminal_id`, surviving SIGKILL (exit-hook delete at `:1338` doesn't run on SIGKILL). Read side MISSING: zero production readers of pending markers; the no-identity path emits `Fresh{no_recoverable_identity}` (`reconcile.rs:292-295`) and the client renders `"Started fresh (no_recoverable_identity)."` into the **xterm canvas** (`TerminalView.tsx:4326` via `writeLocalXtermNotice`) — invisible to the leg's DOM probe `getByText(/couldn't be resumed|could not be resumed|fresh session/i)`. **Correction to the task brief:** roadmap P1.10 (re-arm the opencode locator on restore-created panes) IS ALREADY LANDED — `maybe_arm` is called unconditionally at `terminal.rs:2319`, `OpencodeLocator::arm` gates only on missing identity, pinned by `#[tokio::test] restore_created_pane_without_identity_arms_and_resolves_into_the_ledger` (`crates/freshell-ws/src/opencode_association.rs:536`). Task 5 verifies this instead of rebuilding it. | +| 2 | `SIGKILL-within-5s-of-pane-creation: identity survives without client state` (`:1816`, pin at `:1825-1828`) | Boot-time recovery IS wired (candidate cause (a) is FALSE): `RecoveryOfferPanel` mounts unconditionally (`App.tsx:1926`), fetches `GET /api/recovery/inventory` on mount when `!hadPersistedLayoutAtBoot || pendingOffer` (`RecoveryOfferPanel.tsx:83`) — proven green by `recover-my-panes-rust.spec.ts` scenarios 1–3. Two real gaps: **Gap A (race, candidate cause (b))** — claude is excluded from `MARKER_MODES` and its binding row is written 7 `.await` points (~230 lines) AFTER the PTY spawn (`spawn .await :2211` vs `record_binding .await :2445`) while the fake CLI's argv line appears synchronously at spawn — the leg kills right after argv, potentially before durability. **Gap B (test)** — the leg's probe `getByText(/recover .*pane/i)` (`:1920`) can never match the panel's actual heading `Restore N panes from server memory?` (`RecoveryOfferPanel.tsx:217`); the stable handle is `data-testid="recovery-offer-panel"`. Task 6 instruments to confirm Gap A empirically, then fixes both. | + +Full investigation reports (background only; the plan is self-contained): `/home/dan/code/freshell/.worktrees/.the-usual-logs/wall-pins-closure/reports/{pin1-claude-verdict,pin3-pending-markers,pin2-recovery-offer,wall-and-gates,snippets-for-plan}.md`. + +--- + +### Task 1: Probe API split — `ever_observed_on_disk` + +Pin 1 needs to distinguish two facts that today's `ever_observed` conflates: "the transcript was actually SEEN on disk" (in-process observed set, fed by index snapshots and the PR #565 locator fallback) vs "the identity was ever ledger-bound" (durable, survives restarts, but proves only that the id was minted — a never-conversed claude session is ledger-bound yet has never had a transcript). This task adds `ever_observed_on_disk` to the probe trait without changing any existing behavior. + +**Files:** +- Modify: `crates/freshell-ws/src/existence.rs` (the `SessionExistenceProbe` trait — currently exactly two methods, `exists` and `ever_observed`, no defaults) +- Modify: `crates/freshell-server/src/existence.rs` (the `IndexExistenceProbe` impl at lines 110–189; tests in `mod tests` from line 191) +- Test: `crates/freshell-server/src/existence.rs` (`mod tests`) + +**Interfaces:** +- Consumes: existing `SessionExistenceProbe` trait; `IndexExistenceProbe { observed: Mutex<HashSet<String>>, ledger: Option<Arc<PaneLedger>>, ... }`; `PaneLedger::ever_bound(&self, provider: &str, session_id: &str) -> bool`. +- Produces: `fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool` on the `SessionExistenceProbe` trait, with a default implementation delegating to `ever_observed` (so test fakes whose observed set already means "seen on disk" — like `FakeProbe` in `reconcile.rs` tests — need no override), and an override on `IndexExistenceProbe` that consults ONLY the in-process observed set (never the ledger). Task 2 consumes this. + +- [ ] **Step 1: Write the failing test** + +In `crates/freshell-server/src/existence.rs` `mod tests`, next to `ever_observed_survives_a_restart_via_the_ledger` (line ~376 — reuse its exact probe+ledger construction; it builds a probe with a ledger installed and a session that was bound but is not in any index snapshot): + +```rust + /// PIN 1 (claude never-conversed carve-out): "seen on disk" is a strictly + /// stronger fact than "ever bound". A ledger binding proves the identity + /// was minted at create — NOT that a transcript ever existed. The + /// carve-out keys on disk observation, so ever_bound alone must not + /// count. + #[test] + fn ever_observed_on_disk_excludes_ledger_only_bindings() { + // Construct probe + ledger EXACTLY as + // ever_observed_survives_a_restart_via_the_ledger does (same fixture + // helpers, same bound-but-never-on-disk session id), then: + // assert!(probe.ever_observed("claude", session_id)); // via ledger — unchanged + // assert!(!probe.ever_observed_on_disk("claude", session_id)); // NEW: ledger does not count + } +``` + +Copy the sibling test's body verbatim up to its assertion, then replace/extend the assertions as shown in the comments (the two `assert!` lines are the required assertions; the construction lines come from the sibling). Also add a second test asserting the positive path via the observed set: + +```rust + /// A genuine on-disk observation (index snapshot or locator-fallback hit) + /// counts for BOTH ever_observed and ever_observed_on_disk. + #[test] + fn ever_observed_on_disk_true_after_disk_observation() { + // Construct probe as zero_turn_claude_transcript_on_disk_is_present_not_absent + // does (temp claude home + locator + warm index + zero-turn file), call + // probe.exists("claude", session_id) once (fallback hit feeds the + // observed set), then: + // assert!(probe.ever_observed_on_disk("claude", session_id)); + } +``` + +(That sibling is `#[tokio::test]` at line ~296 — mirror its async-ness and cleanup.) + +- [ ] **Step 2: Run tests to verify they fail to compile (RED)** + +Run: `cd /home/dan/code/freshell/.worktrees/wall-pins-closure && cargo test -p freshell-server --lib existence` +Expected: compile error — `no method named ever_observed_on_disk`. + +- [ ] **Step 3: Add the trait method with a default** + +In `crates/freshell-ws/src/existence.rs`, inside `trait SessionExistenceProbe`, after `ever_observed`: + +```rust + /// "Seen on disk" strictly: true only if this process actually observed + /// the session artifact on disk (index snapshots, or the claude + /// locator-fallback hit). Unlike `ever_observed`, durable ledger bindings + /// do NOT count — a binding proves the identity was minted, not that a + /// transcript ever existed (PIN 1: the claude never-conversed carve-out + /// keys on this distinction). Default: delegate to `ever_observed`, which + /// is already disk-only for fakes without a ledger. + fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool { + self.ever_observed(provider, session_id) + } +``` + +- [ ] **Step 4: Override on `IndexExistenceProbe`** + +In `crates/freshell-server/src/existence.rs`, inside `impl SessionExistenceProbe for IndexExistenceProbe` (after the existing `ever_observed` at lines 176–188): + +```rust + fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool { + self.observed + .lock() + .expect("observed set lock") + .contains(&format!("{provider}:{session_id}")) + } +``` + +- [ ] **Step 5: Run tests to verify they pass (GREEN)** + +Run: `cargo test -p freshell-server --lib existence` +Expected: PASS, including all pre-existing existence tests (`ever_observed_survives_a_restart_via_the_ledger`, the four kata-09v1 fallback tests) — `ever_observed` itself is untouched. + +- [ ] **Step 6: Rust gates** + +Run: `cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. (If `fmt` complains, run `cargo fmt --all` and re-check.) + +- [ ] **Step 7: Environment sanity + commit** + +Run: `[ -d node_modules ] || npm ci` (the later tasks need the JS toolchain; `ls node_modules/.bin/tsx` must resolve — if missing, `npm ci`). + +```bash +git add crates/freshell-ws/src/existence.rs crates/freshell-server/src/existence.rs +git commit -m "feat(existence): split ever_observed_on_disk from ever_observed (disk-only, ledger excluded)" +``` + +--- + +### Task 2: Pin 1 — claude never-conversed carve-out in `derive_verdicts` + flip the ruler pin + +**Files:** +- Modify: `crates/freshell-ws/src/reconcile.rs` (the `SessionExistence::Absent` arm, lines ~316–360; `mod tests` from line 375) +- Modify: `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts:1500-1503` (delete the ruler `test.fail`) +- Test: `crates/freshell-ws/src/reconcile.rs` `mod tests` + +**Interfaces:** +- Consumes: `SessionExistenceProbe::ever_observed_on_disk(provider, session_id) -> bool` (Task 1); `ReconcileDeps.pane_ledger: &PaneLedger` with `ever_bound(&self, provider: &str, session_id: &str) -> bool`; existing helpers `base(pane, verdict)`, `corrected_flag(claim, resolved)`, `deps.registry.respawn_exhausted(&key)`. +- Produces: claude Absent-arm behavior — `Respawn` (carrying `session_ref`) for ledger-bound, never-disk-observed claude identities; unchanged `DeadSession{session_not_on_disk}` for disk-observed-then-deleted; unchanged everything else. The composed-ruler wall leg depends on this. + +**Design decision (record in the code comment, verbatim rationale):** the carve-out mirrors the amplifier arm but is narrower — it requires (a) `sref.provider == "claude"`, (b) a durable ledger binding (`ever_bound`), and (c) the transcript never having been SEEN on disk (`!ever_observed_on_disk`). Condition (c) preserves the hazard guard within a boot (a deleted transcript that WAS observed stays an immediate `DeadSession{session_not_on_disk}` — existing test `row4_absent_but_ever_observed_yields_dead_session` keeps passing unchanged). Across a restart, a conversed-then-deleted transcript is indistinguishable from never-conversed by these signals; that case takes the same escape the amplifier arm already accepts — §7.5's `respawn_exhausted` convergence ends a respawn↔instant-exit loop in an actionable `DeadSession{respawn_exhausted}`, never thrash and never silent. Coherence with PR #565: the locator fallback fires only when a transcript FILE EXISTS (converting false-Absent to Present ⇒ Respawn); this arm fires only in the true-Absent branch (file never created). Zero overlap, two halves of one rule: "a claude identity the disk has no memory of is respawnable; one the disk remembers and lost is dead." + +- [ ] **Step 1: Write the failing tests** + +In `crates/freshell-ws/src/reconcile.rs` `mod tests`, after `amplifier_absent_even_observed_yields_respawn_not_dead_session` (~line 656). The fixture (`Fixture::new()` → `registry`/`identity`/`probe: FakeProbe`/`ledger: PaneLedger::new(Some(root))`), `pane(key)` (default mode `"claude"`), and `sref(provider, id)` builders already exist in this mod: + +```rust + /// PIN 1 red test: a claude pane whose session id was preallocated at + /// create (ledger-bound, durable) but which NEVER conversed has no + /// transcript file -> Absent. That is not a dead state: claude's + /// --resume/--session-id recreates the file on first output (mirror of + /// the amplifier arm). Kata 09v1's locator fallback covers + /// exists-but-unparseable; this covers never-created. + #[test] + fn claude_never_conversed_yields_respawn_not_dead_session() { + let f = Fixture::new(); + f.ledger + .record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: "s-never", + terminal_id: "T-never", + mode: "claude", + cwd: None, + create_request_id: Some("cr-never"), + now_ms: 1_000, + }) + .expect("record binding"); + let mut p = pane("cr-never"); + p.session_ref = Some(sref("claude", "s-never")); + // Probe default: Absent, never observed on disk. + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Respawn); + assert_eq!(v.session_ref, Some(sref("claude", "s-never"))); + } + + /// PIN 1 hazard guard: a claude transcript that WAS seen on disk and is + /// now gone is a real data-loss shape — stays loud dead_session even + /// though the identity is ledger-bound (rows 4/4b unchanged). + #[test] + fn claude_deleted_after_conversation_stays_dead_session() { + let f = Fixture::new(); + f.ledger + .record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: "s-gone2", + terminal_id: "T-gone2", + mode: "claude", + cwd: None, + create_request_id: Some("cr-gone2"), + now_ms: 1_000, + }) + .expect("record binding"); + f.probe.mark_observed("claude", "s-gone2"); + let mut p = pane("cr-gone2"); + p.session_ref = Some(sref("claude", "s-gone2")); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::DeadSession); + assert_eq!(v.reason.as_deref(), Some("session_not_on_disk")); + } +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `cargo test -p freshell-ws --lib reconcile` +Expected: `claude_never_conversed_yields_respawn_not_dead_session` FAILS (today it derives `DeadSession` — `ever_observed` answers true via `ledger.ever_bound`... note: with `FakeProbe` and a real fixture ledger, the DeadSession gate reads `deps.existence.ever_observed` which for `FakeProbe` is its observed set only, so the observed verdict may be `Fresh{identity_never_observed}` instead — either way it is NOT `Respawn`, which is the assertion). `claude_deleted_after_conversation_stays_dead_session` PASSES already (it pins the guard so the fix can't break it). All pre-existing tests PASS. + +- [ ] **Step 3: Implement the carve-out** + +In `crates/freshell-ws/src/reconcile.rs`, in the `SessionExistence::Absent` arm, immediately AFTER the amplifier carve-out block (which ends at line ~341 with `return ... Respawn`) and BEFORE the `ever_observed` dead-session gate (comment at ~:342): + +```rust + // Claude carve-out (never-conversed preallocation, PIN 1): claude + // terminals get a server-preallocated --session-id at create and + // the binding row is durable before the answer — but the + // transcript file only appears on first output. Ledger-bound + + // Absent + never SEEN on disk therefore means "created, never + // conversed", not dead: claude's --resume/--session-id recreates + // the file on first output. Kata 09v1's locator fallback + // (freshell-server existence.rs) already converts + // exists-but-unparseable into Present => Respawn; this arm covers + // never-created — two halves of one rule, no overlap. A + // transcript that WAS observed on disk and is now gone falls + // through to the loud dead_session below (rows 4/4b hazard + // guard). Cross-restart deleted-with-prior-conversation is + // indistinguishable from never-conversed by these signals and + // takes the same escape the amplifier arm accepts: §7.5's + // respawn_exhausted convergence ends a respawn <-> instant-exit + // loop in an actionable dead_session, never thrash. + if sref.provider == "claude" + && deps.pane_ledger.ever_bound(&sref.provider, &sref.session_id) + && !deps + .existence + .ever_observed_on_disk(&sref.provider, &sref.session_id) + { + if deps.registry.respawn_exhausted(&key) { + return PaneVerdict { + session_ref: Some(sref), + reason: Some("respawn_exhausted".to_string()), + ..base(pane, ReconcileVerdict::DeadSession) + }; + } + let corrected = corrected_flag(pane.session_ref.as_ref(), Some(&sref)); + return PaneVerdict { + session_ref: Some(sref), + corrected, + ..base(pane, ReconcileVerdict::Respawn) + }; + } +``` + +- [ ] **Step 4: Run to verify GREEN** + +Run: `cargo test -p freshell-ws --lib reconcile && cargo test -p freshell-server --lib existence` +Expected: ALL PASS — the two new tests plus every pre-existing decision-table test (`row4_absent_but_ever_observed_yields_dead_session` still passes: it has no ledger binding AND marks disk observation; `row4b_never_observed_identity_yields_fresh_not_dead_session` still passes: no ledger binding → carve-out doesn't fire; `amplifier_absent_even_observed_yields_respawn_not_dead_session` untouched — its arm precedes ours; `respawn_exhausted_key_yields_dead_session_not_another_respawn` at `:816` untouched). + +- [ ] **Step 5: Rust gates + commit the fix** + +Run: `cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace` +Expected: clean / all pass. + +```bash +git add crates/freshell-ws/src/reconcile.rs +git commit -m "fix(reconcile): claude never-conversed sessions respawn instead of dead_session (PIN 1)" +``` + +- [ ] **Step 6: Prove the ruler leg flips (RED signal = unexpected pass)** + +Build and run the still-pinned ruler leg: + +```bash +cargo build --release -p freshell-server +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "THE RULER" +``` + +Expected: the run FAILS with Playwright's "passed unexpectedly" error for the `test.fail`-annotated ruler — that is the flip signal. (The ruler has `test.setTimeout(600_000)`; expect up to 10 minutes.) + +**If instead the ruler still fails expectedly** (a leg OTHER than the claude `--resume` argv poll is red), do NOT delete the pin here — record the failing leg's error verbatim in the commit-message body of a `wip` note commit, continue to Task 3, and re-attempt this flip in Task 8 Step 2. If it still cannot flip there, HALT the workflow and report the evidence (never re-pin). + +- [ ] **Step 7: Flip the pin** + +In `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts`, delete exactly the four lines at 1500–1503: + +```typescript + test.fail( + e2eServerKind === 'rust', + 'P0.1: composed all-pane ruler; red until remaining P1.x land -- current observed red: the claude terminal §2.2 --resume argv leg under composition (the former P0.2 freshclaude identity gap closed in #562)', + ) +``` + +(Keep the explanatory comment above it only if it still reads true; otherwise trim it to a short note that the ruler is now a live assertion.) + +- [ ] **Step 8: Run the flipped ruler leg green, twice** + +Run (twice, sequentially): +`npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "THE RULER"` +Expected: PASS both times. (The third stability run happens with the full wall in Task 8.) + +- [ ] **Step 9: Commit** + +```bash +git add test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +git commit -m "test(wall): flip the composed-ruler pin — claude never-conversed leg now green (PIN 1)" +``` + +--- + +### Task 3: Pin 3 server — pending-marker read → `fresh_by_race`, one-shot consumption + +**Files:** +- Modify: `crates/freshell-ws/src/reconcile.rs` (the no-identity Fresh path at lines ~286–296; new pub helper; `mod tests`) +- Modify: `crates/freshell-ws/src/terminal.rs` (`handle_pane_reconcile`, ~line 3282; verdicts computed at `:3330-3333`, response sent at `:3382-3388`) +- Test: `crates/freshell-ws/src/reconcile.rs` `mod tests` + +**Interfaces:** +- Consumes: `PaneLedger::pending_for_terminal(&self, terminal_id: &str) -> Option<PendingMarker>` (reader-rule: `None` if a binding row covers the terminal — `pane_ledger.rs:787`); `PaneLedger::record_pending(&self, terminal_id: &str, mode: &str, cwd: Option<&str>, now_ms: i64) -> io::Result<()>`; `PaneLedger::delete_pending(&self, terminal_id: &str) -> io::Result<()>`; `ReconcilePane.terminal_id: Option<String>` (the client's stale pre-kill terminal id — the marker's key). +- Produces: new verdict reason string `"fresh_by_race"` on `Fresh` verdicts (free-form field — no contract regeneration); `pub fn fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String>` in `reconcile.rs` (verdicts are 1:1 with request order — documented on `PaneVerdict.pane_key`); marker consumption in `handle_pane_reconcile`. Task 4's client breadcrumb keys on the literal reason string `fresh_by_race`. + +**Load-bearing verification folded in (Step 1):** the design claims the client re-presents the dead epoch's `terminalId` on post-restart reconcile (that is the marker join key — markers are keyed by `terminal_id`, and `createRequestId` is not stored on markers). The pinned leg itself proves the client retains it (its settle-poll at `:1988-1995` waits for `content.terminalId` to CHANGE from the pre-kill value, so the pre-kill value was present). Confirm the reconcile REQUEST carries it: `grep -n "terminalId" src/lib/pane-reconcile.ts | head -30` — the request builder must include the pane's current `terminalId`. If (and only if) it demonstrably does not, STOP this task and report: the fallback design (extending `PendingMarker` with an optional `create_request_id` field and joining on that) is a schema change that needs plan-review, not an inline improvisation. + +- [ ] **Step 1: Verify the join key** (command above; record the matching line in the commit body). + +- [ ] **Step 2: Write the failing tests** + +In `crates/freshell-ws/src/reconcile.rs` `mod tests`: + +```rust + /// PIN 3 red test (§4.2 pending-marker read): identity establishment was + /// in flight when the server died (durable pending marker, keyed by the + /// dead epoch's terminal id) -> the verdict is fresh, but LOUD: + /// fresh_by_race, never a silent no_recoverable_identity. + #[test] + fn pending_marker_yields_fresh_by_race_not_silent_fresh() { + let f = Fixture::new(); + f.ledger + .record_pending("T-race", "opencode", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-race"); + p.mode = Some("opencode".to_string()); + p.terminal_id = Some("T-race".to_string()); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason.as_deref(), Some("fresh_by_race")); + } + + /// One-shot semantics (§6 decision 5): once the marker is consumed, a + /// later reconcile derives the plain labeled fresh — the breadcrumb + /// fires exactly once. + #[test] + fn consumed_marker_reverts_to_no_recoverable_identity() { + let f = Fixture::new(); + f.ledger + .record_pending("T-race2", "opencode", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-race2"); + p.mode = Some("opencode".to_string()); + p.terminal_id = Some("T-race2".to_string()); + let first = f.one(p.clone()); + assert_eq!(first.reason.as_deref(), Some("fresh_by_race")); + // Consume, as handle_pane_reconcile does for fresh_by_race verdicts. + let tids = fresh_by_race_marker_tids(&[p.clone()], &[first]); + assert_eq!(tids, vec!["T-race2".to_string()]); + for tid in &tids { + f.ledger.delete_pending(tid).expect("delete pending"); + } + let second = f.one(p); + assert_eq!(second.verdict, ReconcileVerdict::Fresh); + assert_eq!(second.reason.as_deref(), Some("no_recoverable_identity")); + } + + /// Shell panes stay bare fresh even with a stray marker — the marker + /// read sits behind the shell early-return. + #[test] + fn shell_pane_ignores_pending_markers() { + let f = Fixture::new(); + f.ledger + .record_pending("T-sh", "shell", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-sh"); + p.mode = Some("shell".to_string()); + p.terminal_id = Some("T-sh".to_string()); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason, None); + } +``` + +(If `ReconcilePane` does not derive `Clone`, build `p` twice from the same literal instead of `p.clone()`.) + +- [ ] **Step 3: Run to verify RED** + +Run: `cargo test -p freshell-ws --lib reconcile` +Expected: `pending_marker_yields_fresh_by_race_not_silent_fresh` FAILS with reason `no_recoverable_identity`; `consumed_marker_reverts_...` fails to compile (`fresh_by_race_marker_tids` undefined); `shell_pane_ignores_pending_markers` PASSES (pins existing behavior). + +- [ ] **Step 4: Implement the read + the helper** + +In `crates/freshell-ws/src/reconcile.rs`, replace the no-identity else-branch (lines ~286–296) body: + +```rust + // No live terminal for this key — recover a retired identity if one exists. + let Some(sref) = resolve_authoritative_ref(deps, pane, &key) else { + // Row 8: shells are stateless by design; row 9: CLI with nothing to + // resume becomes an explicit, labeled fresh — never a surprise. + if pane.mode.as_deref() == Some("shell") { + return base(pane, ReconcileVerdict::Fresh); + } + // §4.2 pending-marker read (PIN 3): a durable pending marker keyed by + // the client's stale terminal id means identity establishment was in + // flight when the server died — fresh by RACE, not by intent. The + // reason is distinct and surfaced (client breadcrumb); the caller + // consumes the marker so the breadcrumb fires exactly once + // (§6 decision 5: markers are never promoted — resolution or + // consumption deletes them). + if let Some(tid) = pane.terminal_id.as_deref() { + if deps.pane_ledger.pending_for_terminal(tid).is_some() { + return PaneVerdict { + reason: Some("fresh_by_race".to_string()), + ..base(pane, ReconcileVerdict::Fresh) + }; + } + } + return PaneVerdict { + reason: Some("no_recoverable_identity".to_string()), + ..base(pane, ReconcileVerdict::Fresh) + }; + }; +``` + +And add the pub helper (near `derive_verdicts`, outside `mod tests`): + +```rust +/// PIN 3 / §6 decision 5 one-shot: the terminal ids whose verdicts were +/// labeled fresh_by_race — the caller deletes their pending markers so the +/// breadcrumb fires exactly once. Verdicts are 1:1 with request order +/// (see `PaneVerdict::pane_key`). +pub fn fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String> { + panes + .iter() + .zip(verdicts) + .filter(|(_, v)| v.reason.as_deref() == Some("fresh_by_race")) + .filter_map(|(p, _)| p.terminal_id.clone()) + .collect() +} +``` + +- [ ] **Step 5: Wire consumption into `handle_pane_reconcile`** + +In `crates/freshell-ws/src/terminal.rs`, inside `async fn handle_pane_reconcile` (~:3282), after `let mut verdicts` is fully computed (`:3333`, after the `catch_unwind` derive closure resolves) and BEFORE the `PaneReconcileResult` send (`:3382`): + +```rust + // PIN 3 one-shot: fresh_by_race verdicts consume their pending markers — + // the next reconcile for the same dead terminal derives a plain + // no_recoverable_identity. Blocking-pool, same budget reasoning as every + // other ledger write site (V1.md). + let race_tids = crate::reconcile::fresh_by_race_marker_tids(&request.panes, &verdicts); + if !race_tids.is_empty() { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + tokio::task::spawn_blocking(move || { + for tid in race_tids { + if let Err(err) = ledger.delete_pending(&tid) { + tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_marker_delete_failed_on_fresh_by_race"); + } + } + }) + .await + .ok(); + } +``` + +(Match the module-path style already used at `:3330` for `derive_verdicts` — if it's imported unqualified, call `fresh_by_race_marker_tids` unqualified too. If `verdicts` at that point is inside a different scope/shape than a plain `Vec<PaneVerdict>`, place this block wherever both `request.panes` and the final verdicts vec are in scope, still before the send.) + +- [ ] **Step 6: Run to verify GREEN** + +Run: `cargo test -p freshell-ws --lib reconcile && cargo test -p freshell-ws` +Expected: all three new tests PASS; every pre-existing test PASSES (the new arm only fires when a marker exists for the presented terminal id, and fixture ledgers start empty). + +- [ ] **Step 7: Contract sanity (reason is not pinned)** + +Run: `npm run contract:generate && git diff --exit-code -- port/contract && npm run test:port && cargo test -p freshell-protocol --locked` +Expected: no diff, all green. + +- [ ] **Step 8: Rust gates + commit** + +Run: `cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings` + +```bash +git add crates/freshell-ws/src/reconcile.rs crates/freshell-ws/src/terminal.rs +git commit -m "feat(reconcile): pending-marker read derives loud fresh_by_race with one-shot consumption (PIN 3 server)" +``` + +--- + +### Task 4: Pin 3 client — `fresh_by_race` breadcrumb, DOM-visible + +**Files:** +- Modify: `src/store/panesSlice.ts` (notice constants/function at lines ~601–605) +- Modify: `src/components/TerminalView.tsx` (notice write site `:4326` inside the effect at `:2991`; root JSX `:5228`) +- Create: `test/unit/client/store/panesSlice.fresh-by-race.test.ts` + +**Interfaces:** +- Consumes: server reason string `"fresh_by_race"` (Task 3), delivered unchanged through the existing fold (`pane-reconcile.ts:451-460` forwards `verdict.reason` → reducer → `reconcileFreshNotice(reason)` — no fold change needed; this satisfies the brief's "extend the client fold" intent at the layer where text is produced). +- Produces: `export const RECONCILE_NOTICE_FRESH_BY_RACE` (exact text below — it must match the wall leg's regex `/couldn't be resumed|could not be resumed|fresh session/i`); a DOM element `data-testid="fresh-by-race-notice"` with `role="status"` rendered by `TerminalView` for ~10s when the notice is the fresh-by-race one. Task 5's e2e breadcrumb assertion depends on both. + +- [ ] **Step 1: Write the failing unit test** + +Create `test/unit/client/store/panesSlice.fresh-by-race.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { reconcileFreshNotice, RECONCILE_NOTICE_FRESH_BY_RACE } from '@/store/panesSlice' + +describe('reconcileFreshNotice', () => { + it('fresh_by_race renders the loud resumable-loss breadcrumb', () => { + expect(reconcileFreshNotice('fresh_by_race')).toBe(RECONCILE_NOTICE_FRESH_BY_RACE) + // The restart-contract-wall probes /couldn't be resumed|could not be + // resumed|fresh session/i — the breadcrumb must match it. + expect(RECONCILE_NOTICE_FRESH_BY_RACE).toMatch(/couldn't be resumed/i) + expect(RECONCILE_NOTICE_FRESH_BY_RACE).toMatch(/fresh session/i) + }) + it('other reasons keep the generic machine-coded notice', () => { + expect(reconcileFreshNotice('no_recoverable_identity')).toBe( + 'Started fresh (no_recoverable_identity).', + ) + }) +}) +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `npm run test:vitest -- run test/unit/client/store/panesSlice.fresh-by-race.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `RECONCILE_NOTICE_FRESH_BY_RACE` is not exported. + +- [ ] **Step 3: Implement the notice text** + +In `src/store/panesSlice.ts`, extend the block at lines ~601–605: + +```typescript +export const RECONCILE_NOTICE_CORRECTED = 'Session identity corrected by server — this pane now points at its live session.' +export const RECONCILE_NOTICE_DUPLICATE = 'A duplicate terminal for this session was detected and ignored.' +// PIN 3 (§4.2 "fresh by race, not by intent"): the server restarted while +// this pane's session identity was still being established — loud, distinct, +// and phrased to match the restart-contract wall's breadcrumb probe. +export const RECONCILE_NOTICE_FRESH_BY_RACE = + "This pane couldn't be resumed — the server restarted before its session identity was captured. Started a fresh session." +export function reconcileFreshNotice(reason: string): string { + if (reason === 'fresh_by_race') return RECONCILE_NOTICE_FRESH_BY_RACE + return `Started fresh (${reason}).` +} +``` + +- [ ] **Step 4: Run to verify GREEN** + +Same command as Step 2. Expected: PASS. + +- [ ] **Step 5: DOM-visible strip in TerminalView** + +Today the notice is written into the xterm canvas (`writeLocalXtermNotice`) which Playwright's `getByText` cannot read. Add a DOM overlay for the fresh-by-race notice only (minimal blast radius — other notices keep their current xterm-only rendering). + +In `src/components/TerminalView.tsx`: + +(a) Add to the existing `@/store/panesSlice` import: `RECONCILE_NOTICE_FRESH_BY_RACE`. + +(b) At the component's top level, near the other `useState` hooks: + +```typescript + // PIN 3: the fresh-by-race breadcrumb must be readable by the DOM text + // layer (assistive tech + the restart-contract wall's getByText probe) — + // the xterm canvas write below is invisible to both. + const [freshByRaceNotice, setFreshByRaceNotice] = useState<string | null>(null) + useEffect(() => { + if (!freshByRaceNotice) return + const t = setTimeout(() => setFreshByRaceNotice(null), 10_000) + return () => clearTimeout(t) + }, [freshByRaceNotice]) +``` + +(c) At the `terminal.created` notice site (~line 4326, inside the effect at `:2991`), extend the existing block: + +```typescript + const createdReconcileNotice = contentRef.current?.reconcileNotice + if (createdReconcileNotice) { + writeLocalXtermNotice(term, `\r\n${createdReconcileNotice}\r\n`) + if (createdReconcileNotice === RECONCILE_NOTICE_FRESH_BY_RACE) { + setFreshByRaceNotice(createdReconcileNotice) + } + dispatch(clearPaneReconcileNotice({ tabId, paneId: paneIdRef.current })) + } +``` + +(Only the `if (createdReconcileNotice === ...)` lines are new; keep the rest byte-identical. The attach-path site at ~`:5040` is left unchanged — a fresh verdict always arrives via `terminal.created`.) + +(d) In the root JSX at ~line 5228, inside the `wrapperRef` div (which has `relative` when visible), as a sibling of the `containerRef` xterm div: + +```tsx + {freshByRaceNotice ? ( + <div + role="status" + data-testid="fresh-by-race-notice" + className="pointer-events-none absolute inset-x-0 top-0 z-10 bg-amber-100/90 px-3 py-1 text-xs text-amber-900 dark:bg-amber-900/80 dark:text-amber-100" + > + {freshByRaceNotice} + </div> + ) : null} +``` + +- [ ] **Step 6: Lint + typecheck gates** + +Run: `npm run lint && npm run test:vitest -- run test/unit/client/store/panesSlice.fresh-by-race.test.ts --config config/vitest/vitest.config.ts` +Expected: clean, PASS. (Full `npm test` runs in Task 8.) + +- [ ] **Step 7: Commit** + +```bash +git add src/store/panesSlice.ts src/components/TerminalView.tsx test/unit/client/store/panesSlice.fresh-by-race.test.ts +git commit -m "feat(client): DOM-visible fresh_by_race breadcrumb for race-lost pane identity (PIN 3 client)" +``` + +--- + +### Task 5: Pin 3 e2e — verify P1.10 is landed, flip the pin, prove re-capture + +**Files:** +- Modify: `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` (`SIGKILL-inside-locator-window` test at `:1933`; pin at `:1949-1952`) + +**Interfaces:** +- Consumes: `data-testid="fresh-by-race-notice"` / breadcrumb text (Task 4); `fresh_by_race` verdict (Task 3); the leg's existing locals `sharedRoot`, `argLogPath`, `rowGatePath` (the `FAKE_OPENCODE_TERMINAL_ROW_GATE_PATH` value, deliberately never created pre-kill), `{ server, harness }`, `tabId`, `leaf`, helpers `readArgvLog`, `findLeafById`. +- Produces: an unpinned, strengthened wall leg — breadcrumb visible AND post-restart identity re-capture via the (already-landed) re-armed locator. + +- [ ] **Step 1: Verify P1.10 is genuinely landed (load-bearing check on the brief's claim)** + +The task brief said "verify it's genuinely un-landed, then build" — exploration found it IS landed: `maybe_arm` is called unconditionally at `terminal.rs:2319` (restore creates route through the same `handle_create`), and `OpencodeLocator::arm` gates only on missing identity (`opencode_locator.rs:194-196`). Confirm the pinning test passes: + +Run: `cargo test -p freshell-ws restore_created_pane_without_identity_arms_and_resolves_into_the_ledger` +Expected: PASS (1 test). Do NOT rebuild the re-arm; the e2e assertion added in Step 4 proves it end-to-end. Record this command+result in the commit body. + +- [ ] **Step 2: Prove the leg flips (unexpected pass)** + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "SIGKILL-inside-locator-window" +``` + +Expected: FAILS with "passed unexpectedly" — the breadcrumb now matches `/couldn't be resumed|could not be resumed|fresh session/i` in the DOM. If it still fails expectedly, debug the breadcrumb chain (server verdict → reducer notice → DOM strip) before touching the pin; do not proceed on red. + +- [ ] **Step 3: Flip the pin** + +Delete exactly the four lines at 1949–1952: + +```typescript + test.fail( + e2eServerKind === 'rust', + 'P1.8 (§2.4): SIGKILL inside locator window yields silent fresh, no breadcrumb', + ) +``` + +Update the leading `EXPECTED-FAIL WALL PIN` comment block (`:1938-1948`) to describe the now-live contract (keep the DETERMINISM note about the row gate — it still holds for the pre-kill phase). + +- [ ] **Step 4: Add the re-capture assertion (re-armed locator, end to end)** + +Immediately after the existing `expect(resumed || breadcrumbVisible).toBe(true)` (`:2007`), inside the same `try` block: + +```typescript + // P1.10 end-to-end (landed; pinned unit-side by opencode_association.rs + // restore_created_pane_without_identity_arms_and_resolves_into_the_ + // ledger): the restore-created pane lacks identity, so the locator + // re-armed at restore-create. Open the fake's row gate NOW and submit — + // the re-armed locator must capture a ses_ identity post-restart. + await fs.writeFile(rowGatePath, '') + await page.locator('.xterm').last().click() + await page.keyboard.type('hello again after restart') + await page.keyboard.press('Enter') + await expect + .poll(async () => { + const l = await findLeafById(harness, tabId, leaf.id) + return l?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }) + .toMatch(/^ses_/) +``` + +(Use the leg's actual local names — verify with a read of `:1953-2010` that they are `rowGatePath`, `harness`, `tabId`, `leaf`; `fs` is the spec's existing `node:fs/promises` import. If the leaf lookup shape differs, mirror the leg's own settle-poll at `:1989-1995`, which reads `l?.content?.terminalId` the same way.) + +- [ ] **Step 5: Run the flipped leg green, twice** + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "SIGKILL-inside-locator-window" +``` + +Expected: PASS, twice sequentially. If the re-capture poll is the flaky part (locator window mechanics), investigate against the green `freshopencode` leg (`:1051`) which exercises the same fake row-gate machinery — do not delete the assertion to get green. + +- [ ] **Step 6: Commit** + +```bash +git add test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +git commit -m "test(wall): flip SIGKILL-inside-locator-window pin — fresh_by_race breadcrumb + re-armed locator re-capture (PIN 3)" +``` + +--- + +### Task 6: Pin 2 server — instrument, then make claude identity durable BEFORE spawn + +**Files:** +- Modify: `crates/freshell-ws/src/terminal.rs` (`handle_create` at `:1408`; insert pre-spawn write immediately before the PTY `spawn_blocking` at ~`:2195`; spawn-anchor comment) +- Create: `crates/freshell-ws/src/terminal_create_ordering_tests.rs` +- Test: the new ordering test + the wall leg (Task 7) + +**Interfaces:** +- Consumes: in-scope locals of `handle_create` immediately before the spawn: `terminal_id: String` (minted once at `:1572`), `mode: String` (`:1578`), `resume_session_id: Option<String>` (`:1618`; claude preallocation assigns `Some(Uuid::new_v4().to_string())` at `:1649`), `spec` (with `spec.cwd: Option<String>`, `:2109-2141`), `create.request_id`, `state: &WsState` (with `state.pane_ledger: Arc<PaneLedger>`); `PaneLedger::record_binding(&BindingWrite) -> io::Result<()>` (atomic write+fsync+rename+fsync-parent, keyed `(provider, session_id)` — a later re-write of the same key is benign); `surface_write_failure(state, terminal_id, result)`. +- Produces: durable claude binding row BEFORE the spawn that makes the id observable in argv; source-order regression pin `claude_binding_write_precedes_pty_spawn_in_handle_create`. + +- [ ] **Step 1: Instrument — confirm the leg's red cause empirically (load-bearing stage)** + +First confirm the pin is still red as claimed: + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "SIGKILL-within-5s" +``` + +Expected: green suite result with the leg reported as "expected failure". + +Then a single instrumented run to disambiguate the race (TEMPORARY, uncommitted): in the `SIGKILL-within-5s-of-pane-creation` test, immediately after `await server.restartAbrupt()` (`:1893`), insert: + +```typescript + // TEMP INSTRUMENTATION (do not commit): was the binding durable at kill? + console.log('LEDGER BINDINGS AT KILL:', + await fs.readdir(path.join(info.homeDir, '.freshell/pane-ledger/bindings')).catch(() => 'MISSING')) +``` + +(`info` is in scope in this leg; the Rust server's HOME is the fixture's isolated home dir — `applyIsolatedHomeEnvironment`, `test-server.ts:76-77` — exposed as `info.homeDir`; if the property name differs, read `test/e2e-browser/fixtures/rust-server.ts:295-310` for the accessor. `path` is already imported in the spec.) + +Re-run the leg once and record the output verbatim in this task's final commit body: +- `MISSING` or `[]` → **Gap A confirmed empirically**: the SIGKILL beat the post-spawn binding write. +- a binding file present → the race did not materialize on this run; Gap B (Task 7) was the observed blocker. The pre-spawn write below still lands: the window is real at the code level (argv observable at `:2211`, durability only at `:2445`), and the leg's contract is "kill immediately after argv" — without tightening, green would be timing-luck. + +Revert the instrumentation: `git checkout -- test/e2e-browser/specs/restore-contract-wall-rust.spec.ts`. + +- [ ] **Step 2: Write the failing ordering test** + +Create `crates/freshell-ws/src/terminal_create_ordering_tests.rs`: + +```rust +//! P1.9 (D3) source-order pin: claude's preallocated identity becomes +//! OBSERVABLE at PTY spawn (`--session-id` in argv, logged synchronously by +//! the e2e fakes), so its durable ledger write must PRECEDE the spawn. +//! Reordering these reopens the SIGKILL-within-5s recovery hole +//! (restore-contract-wall `SIGKILL-within-5s-of-pane-creation`). + +#[test] +fn claude_binding_write_precedes_pty_spawn_in_handle_create() { + let src = include_str!("terminal.rs"); + let write = src + .find("PIN2_CLAUDE_PRE_SPAWN_BINDING") + .expect("pre-spawn claude binding block (PIN2_CLAUDE_PRE_SPAWN_BINDING) missing from terminal.rs"); + let spawn = src + .find("PIN2_PTY_SPAWN_ANCHOR") + .expect("PTY spawn anchor (PIN2_PTY_SPAWN_ANCHOR) missing from terminal.rs"); + assert!( + write < spawn, + "claude durable binding write must stay BEFORE the PTY spawn: durability precedes observability" + ); +} +``` + +Wire it in `crates/freshell-ws/src/terminal.rs` (top of file, next to any existing `#[cfg(test)]` mod wiring; same pattern as `recovery_inventory.rs`'s `#[path]` test module): + +```rust +#[cfg(test)] +#[path = "terminal_create_ordering_tests.rs"] +mod terminal_create_ordering_tests; +``` + +- [ ] **Step 3: Run to verify RED** + +Run: `cargo test -p freshell-ws claude_binding_write_precedes_pty_spawn` +Expected: FAIL — `PIN2_CLAUDE_PRE_SPAWN_BINDING missing`. + +- [ ] **Step 4: Implement the pre-spawn durable write** + +In `crates/freshell-ws/src/terminal.rs`, immediately BEFORE the PTY `spawn_blocking` whose `.await` is at ~`:2211` (i.e. just above the `let spawn_...` clones at ~`:2190-2195`), insert: + +```rust + // PIN2_CLAUDE_PRE_SPAWN_BINDING — P1.9 (D3) durability-before- + // observability: a fresh claude create preallocates its --session-id + // (:1649) and the spawn below makes that id OBSERVABLE (argv, logged + // synchronously by the e2e fakes). A SIGKILL landing right after spawn + // must still find a durable ledger row, or the recovery inventory has + // nothing to offer after browser loss. The post-spawn binding write + // (:2420 arm) re-records the same (provider, session_id) key with the + // resolved cwd — a benign re-write. Failure policy identical to that + // arm: never blocks the create, surfaced LIVE. + if mode == "claude" { + if let Some(session_id) = resume_session_id.as_deref() { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let write_session_id = session_id.to_string(); + let write_terminal_id = terminal_id.clone(); + let write_mode = mode.clone(); + let write_cwd = spec.cwd.clone(); + let write_request_id = create.request_id.clone(); + let now = now_ms(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: &write_session_id, + terminal_id: &write_terminal_id, + mode: &write_mode, + cwd: write_cwd.as_deref(), + create_request_id: Some(&write_request_id), + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + crate::pane_ledger::surface_write_failure(state, &terminal_id, result); + } + } +``` + +And add the anchor comment on the line directly above the PTY `spawn_blocking` call (~`:2195`): + +```rust + // PIN2_PTY_SPAWN_ANCHOR: the spawn makes preallocated identity observable. +``` + +Notes for the implementer: (1) this fires for claude resume-creates too — their binding row already exists from a prior epoch, so the re-write is a no-op refresh; (2) `MARKER_MODES` correctly still excludes claude (claude has create-time identity and no post-spawn resolver — this write IS its durability story; record this answer to the brief's "should claude also get a marker?" question in the commit body: no — a binding row before spawn is strictly stronger than a marker); (3) the pre-spawn window for `MARKER_MODES` providers (their marker is also written post-spawn) is a known residual NOT exercised by any wall leg — do not fix it here (YAGNI; noted for a follow-up if a leg ever pins it). + +- [ ] **Step 5: Run to verify GREEN** + +Run: `cargo test -p freshell-ws claude_binding_write_precedes_pty_spawn && cargo test -p freshell-ws && cargo test --workspace` +Expected: ordering test PASSES; full workspace green. + +- [ ] **Step 6: Rust gates + commit** + +Run: `cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings` + +```bash +git add crates/freshell-ws/src/terminal.rs crates/freshell-ws/src/terminal_create_ordering_tests.rs +git commit -m "fix(ws): claude binding row durable BEFORE PTY spawn — durability precedes argv observability (PIN 2 server)" +``` + +(Include the Step 1 instrumentation output verbatim in the commit body.) + +--- + +### Task 7: Pin 2 e2e — fix the offer probe, flip the pin + +**Files:** +- Modify: `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` (`SIGKILL-within-5s-of-pane-creation` test at `:1816`; pin at `:1825-1828`; probe at `:1919-1924`) + +**Interfaces:** +- Consumes: durable-before-spawn claude binding (Task 6); the shipped recovery offer UI — `data-testid="recovery-offer-panel"` (`RecoveryOfferPanel.tsx:190`), heading `Restore N panes from server memory?` (`:217`); the leg's locals `{ server, harness, info }`, `preallocatedId`. +- Produces: an unpinned, honestly-passing wall leg. + +- [ ] **Step 1: Fix the probe (Gap B)** + +The current probe can never match the shipped UI — `getByText(/recover .*pane/i)` vs the actual heading `Restore N panes from server memory?`. This is a broken test selector written before the panel existed (the green `recover-my-panes-rust.spec.ts` uses the testid). Replace the offer arm inside the poll at `:1919-1924`: + +```typescript + const recoverOffer = await page + .getByTestId('recovery-offer-panel') + .isVisible() + .catch(() => false) + return recoverOffer +``` + +(The poll's other arm — a pane whose `sessionRef.sessionId === preallocatedId` — stays byte-identical; the contract is unchanged: auto-restored OR visibly offered.) + +- [ ] **Step 2: Prove the leg flips (unexpected pass)** + +```bash +cargo build --release -p freshell-server +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "SIGKILL-within-5s" +``` + +Expected: FAILS with "passed unexpectedly" — the ledger row now survives the kill (Task 6), the recovery inventory's `ledgerOnly` arm reports it, `recoverable: true`, the panel opens, the testid probe sees it. If it still fails expectedly, debug the chain in order: binding file present in `<homeDir>/.freshell/pane-ledger/bindings/` after kill → `GET /api/recovery/inventory` returns `recoverable: true` with a `ledgerOnly` entry → panel mounts. Do not proceed on red. + +- [ ] **Step 3: Flip the pin** + +Delete exactly the four lines at 1825–1828: + +```typescript + test.fail( + e2eServerKind === 'rust', + 'P1.8+P1.9 (D3): pane created <5s before SIGKILL is unrecoverable after browser loss', + ) +``` + +Update the leading `EXPECTED-FAIL WALL PIN` comment (`:1821-1824`) to describe the now-live contract. + +- [ ] **Step 4: Run the flipped leg green, twice** + +Same command as Step 2, twice sequentially. Expected: PASS both times. + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +git commit -m "test(wall): flip SIGKILL-within-5s pin — durable pre-spawn binding + correct recovery-offer probe (PIN 2)" +``` + +--- + +### Task 8: Full gates, wall stability ×3, push + +**Files:** +- No new source changes expected (fix-forward only if a gate fails; each fix gets its own focused commit). + +**Interfaces:** +- Consumes: everything above. +- Produces: a pushed branch `fix/wall-pins-closure` with the wall green, zero pins, three consecutive full runs. + +- [ ] **Step 1: Zero-pin audit** + +Run: `grep -n "test\.fail(" test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` +Expected: NO matches in code (prose mentions in comments are fine — verify each hit is inside a comment). Also `grep -rn "test\.fixme" test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` → no matches. + +- [ ] **Step 2: (Only if deferred from Task 2 Step 6) flip the ruler pin now** + +If the ruler pin was left in place, re-run the ruler leg; on "passed unexpectedly", delete `:1500-1503` and commit as Task 2 Step 9 describes. If it STILL fails expectedly, HALT: report the failing leg's error verbatim — the wall cannot honestly reach zero pins and the workflow must surface that rather than re-pin. + +- [ ] **Step 3: Rust gates** + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Expected: all clean/green. + +- [ ] **Step 4: Node gates (coordinator-aware)** + +```bash +npm run test:status # if held: wait 60s and re-check until free +FRESHELL_TEST_SUMMARY='wall pins closure' env -u FRESHELL_BIND_HOST npm test +npm run test:port +npm run lint +npm run contract:generate && git diff --exit-code -- port/contract +cargo test -p freshell-protocol --locked +``` + +Expected: all green; no contract diff. + +- [ ] **Step 5: Release build + THE FULL WALL ×3** + +```bash +cargo build --release -p freshell-server +for i in 1 2 3; do + npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts || { echo "WALL RUN $i FAILED"; break; } +done +``` + +Expected: three consecutive fully-green runs, zero pins, zero unexpected passes. Runs are sequential (shared host — never parallel). If any run fails: diagnose and fix forward (focused commit), then restart the 3× count from run 1. If a formerly-pinned leg cannot honestly stay green, HALT and report — never re-pin. + +- [ ] **Step 6: Sibling e2e suites** + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium hidden-pane-rebind-rust.spec.ts +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium reconcile-client-adoption-rust.spec.ts reconcile-completion-rust.spec.ts reconcile-handshake-rust.spec.ts +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium recover-my-panes-rust.spec.ts +``` + +Expected: all green (`recover-my-panes` guards Task 6/7 against regressions in the offer flow; the reconcile suites guard Tasks 2–4). + +- [ ] **Step 7: Push the branch — NO PR** + +```bash +git log --oneline origin/main..HEAD # review: focused, atomic commits only +git push -u origin fix/wall-pins-closure +``` + +Do NOT open a pull request. Landing happens outside this workflow with the final review verdict. + +--- + +## Self-Review (performed against the task spec) + +**1. Spec coverage:** +- Pin 1 claude carve-out mirroring amplifier → Task 2. PR #565 coherence (two halves, no overlap) → Task 2 design decision + comment. Named red test `claude_never_conversed_yields_respawn_not_dead_session` → Task 2 Step 1. Composed ruler leg → Task 2 Steps 6–8. True-positive hazard guard → Task 2 `claude_deleted_after_conversation_stays_dead_session` (in-boot, direct) + `respawn_exhausted` convergence for the cross-restart shape (explicit design decision, mirrored from the amplifier arm's accepted tradeoff). +- Pin 3 read side before generic Fresh, marker lookup by terminalId lineage, distinct surfaced `fresh_by_race` → Task 3. Client fold breadcrumb visible → Task 4 (reason already flows through the fold; text + DOM visibility are where the change bites — noted explicitly). Contract-pinned check → verified NOT pinned; gates still run generate/test:port/protocol (Tasks 3, 8). P1.10 "verify then build" → verified LANDED (Task 5 Step 1 evidence) + end-to-end re-capture assertion added to the leg (Task 5 Step 4). Red tests: unit marker→fresh_by_race (Task 3), e2e breadcrumb + re-capture + pin flip (Task 5). +- Pin 2 instrument FIRST → Task 6 Step 1 (empirical bindings-at-kill disambiguation, recorded). Candidate (a) boot-state unwired → disproven with evidence (wired via App.tsx:1926; green recover-my-panes e2e). Candidate (b) race → real code-level window; fixed by durability-before-spawn (Task 6). Claude marker question → answered explicitly (binding-before-spawn is strictly stronger; recorded in commit body). Red tests: wall leg (Task 7) + ordering pin `claude_binding_write_precedes_pty_spawn_in_handle_create` (Task 6, the unit-level pin on the broken link). +- Gates: fmt/clippy/cargo test, npm test with summary + coordinator wait, test:port, lint, release build, full wall ×3 with zero pins, hidden-pane-rebind + reconcile (+ recover-my-panes) suites, ephemeral ports, no-PR push → Task 8 + Global Constraints. Fix order Pin 1 → Pin 3 → Pin 2 → task order 1–7. + +**1b. No silent deferrals:** every requirement lands as production behavior proven by an unpinned wall leg (no stubs/mocks/fakes standing in — the fake CLIs are the wall's own pre-existing harness, not new test doubles). One explicitly-scoped non-goal: the pre-spawn durability window for `MARKER_MODES` providers (markers also written post-spawn) — NOT part of any spec'd pin, not exercised by any wall leg, documented in Task 6 Step 4 note (3). This is a scope observation from investigation, not a spec requirement being deferred; no UNRESOLVED COVERAGE GAP exists against the spec. + +**2. Placeholder scan:** two steps intentionally reference sibling-test fixtures by exact name instead of inlining unknown fixture internals (Task 1 Step 1 construction lines; Task 5 Step 4 leaf-lookup fallback note) — in both, the assertions and target behavior are fully specified and the referenced fixture is named with file+line. No TBDs, no "handle edge cases", no test-less "write tests" steps. + +**3. Type consistency:** `ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool` used identically in Tasks 1 and 2; `fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String>` defined (Task 3 Step 4) matches both call sites (Task 3 Steps 2 and 5); `RECONCILE_NOTICE_FRESH_BY_RACE` defined (Task 4 Step 3) matches the import (Task 4 Step 5) and the e2e regex (verified by the Task 4 unit test); `BindingWrite` field set (`provider, session_id, terminal_id, mode, cwd, create_request_id, now_ms`) is identical across Task 2, Task 3 (none), and Task 6, matching the existing write block at `terminal.rs:2434-2443`; the reason strings `fresh_by_race` / `no_recoverable_identity` / `session_not_on_disk` are used byte-identically across server, tests, and client. From 0223823ced754e6a633ce66b4c7cdbdc48110e39 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:30:07 -0700 Subject: [PATCH 02/14] docs(plan): harden wall-pins-closure plan with load-bearing validation findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation round (ledger: .the-usual-logs/wall-pins-closure/load-bearing-ledger.md): 12 assumptions — 8 verified, 4 falsified. Plan changes: - Task 2: correct real-claude semantics (verified v2.1.220: --resume of a not-found id errors+exits — never recreates; --session-id rejected as silent-fresh-hazardous for deleted ids); add Step 5b updating reconcile-client-adoption spec (pins the inverted claude verdict); note the ruler baseline aborts at :1753 so later sub-assertions are unmeasured under composition. - Task 3: replace one-shot marker consumption with idempotent read-only design (delete-at-derive loses the breadcrumb to reconnect churn — client re-sends reconcile on every ready because responses can drop); drops the terminal.rs consumption block and helper; keeps pane-ledger-restart-rust.spec.ts green. - Task 4: cite verified created-vs-attach routing; record accepted residual. - Task 5: fix wrong comparator (freshopencode uses the sidecar fake, not the row gate); cite verified restartAbrupt env continuity. - Task 6: add Step 4b — delete the pre-spawn binding row when a FRESH claude create's spawn fails (ghost ledgerOnly offer otherwise); record verified lock-safety and resume-rewrite benignity. - Task 7: never assert ledgerOnly vs device location (racy snapshot push). - Task 8: add pane-ledger-restart-rust.spec.ts to sibling gates. - Self-Review: add section 4 documenting the validation round. --- docs/plans/2026-07-29-wall-pins-closure.md | 169 +++++++++++---------- 1 file changed, 88 insertions(+), 81 deletions(-) diff --git a/docs/plans/2026-07-29-wall-pins-closure.md b/docs/plans/2026-07-29-wall-pins-closure.md index cc4cc9f95..8cd628f17 100644 --- a/docs/plans/2026-07-29-wall-pins-closure.md +++ b/docs/plans/2026-07-29-wall-pins-closure.md @@ -159,7 +159,7 @@ git commit -m "feat(existence): split ever_observed_on_disk from ever_observed ( - Consumes: `SessionExistenceProbe::ever_observed_on_disk(provider, session_id) -> bool` (Task 1); `ReconcileDeps.pane_ledger: &PaneLedger` with `ever_bound(&self, provider: &str, session_id: &str) -> bool`; existing helpers `base(pane, verdict)`, `corrected_flag(claim, resolved)`, `deps.registry.respawn_exhausted(&key)`. - Produces: claude Absent-arm behavior — `Respawn` (carrying `session_ref`) for ledger-bound, never-disk-observed claude identities; unchanged `DeadSession{session_not_on_disk}` for disk-observed-then-deleted; unchanged everything else. The composed-ruler wall leg depends on this. -**Design decision (record in the code comment, verbatim rationale):** the carve-out mirrors the amplifier arm but is narrower — it requires (a) `sref.provider == "claude"`, (b) a durable ledger binding (`ever_bound`), and (c) the transcript never having been SEEN on disk (`!ever_observed_on_disk`). Condition (c) preserves the hazard guard within a boot (a deleted transcript that WAS observed stays an immediate `DeadSession{session_not_on_disk}` — existing test `row4_absent_but_ever_observed_yields_dead_session` keeps passing unchanged). Across a restart, a conversed-then-deleted transcript is indistinguishable from never-conversed by these signals; that case takes the same escape the amplifier arm already accepts — §7.5's `respawn_exhausted` convergence ends a respawn↔instant-exit loop in an actionable `DeadSession{respawn_exhausted}`, never thrash and never silent. Coherence with PR #565: the locator fallback fires only when a transcript FILE EXISTS (converting false-Absent to Present ⇒ Respawn); this arm fires only in the true-Absent branch (file never created). Zero overlap, two halves of one rule: "a claude identity the disk has no memory of is respawnable; one the disk remembers and lost is dead." +**Design decision (record in the code comment, verbatim rationale):** the carve-out mirrors the amplifier arm but is narrower — it requires (a) `sref.provider == "claude"`, (b) a durable ledger binding (`ever_bound`), and (c) the transcript never having been SEEN on disk (`!ever_observed_on_disk`). Condition (c) preserves the hazard guard within a boot (a deleted transcript that WAS observed stays an immediate `DeadSession{session_not_on_disk}` — existing test `row4_absent_but_ever_observed_yields_dead_session` keeps passing unchanged). Across a restart, a conversed-then-deleted transcript is indistinguishable from never-conversed by these signals; that case takes the same escape the amplifier arm already accepts — §7.5's `respawn_exhausted` convergence ends a respawn↔instant-exit loop in an actionable `DeadSession{respawn_exhausted}`, never thrash and never silent. **Verified real-CLI semantics (load-bearing validation, claude v2.1.220 binary inspection — `.the-usual-logs/wall-pins-closure/reports/V2-real-claude-contract.md`):** `--resume <not-found id>` prints an error and exits 1 in both the print and interactive entrypoints — it does NOT recreate a transcript. Against real claude this carve-out's value is therefore loud convergence (respawn → fast exit → respawn_exhausted → actionable DeadSession), while the wall's fake CLI accepts `--resume` and restores. A `--session-id` (start-intent) respawn was considered and REJECTED: real claude's in-use guard is disk-based, so it would SILENTLY start fresh under a conversed-then-deleted id — the exact silence this contract forbids. Coherence with PR #565: the locator fallback fires only when a transcript FILE EXISTS (converting false-Absent to Present ⇒ Respawn); this arm fires only in the true-Absent branch (file never created). Zero overlap, two halves of one rule: "a claude identity the disk has no memory of is respawnable; one the disk remembers and lost is dead." - [ ] **Step 1: Write the failing tests** @@ -168,9 +168,12 @@ In `crates/freshell-ws/src/reconcile.rs` `mod tests`, after `amplifier_absent_ev ```rust /// PIN 1 red test: a claude pane whose session id was preallocated at /// create (ledger-bound, durable) but which NEVER conversed has no - /// transcript file -> Absent. That is not a dead state: claude's - /// --resume/--session-id recreates the file on first output (mirror of - /// the amplifier arm). Kata 09v1's locator fallback covers + /// transcript file -> Absent. That is not an immediately-dead state: + /// respawning with --resume mirrors the amplifier arm. Against the + /// wall's fake CLI the pane restores; REAL claude (verified v2.1.220) + /// errors-and-exits on a not-found id, which §7.5 respawn_exhausted + /// converges to a loud, actionable DeadSession -- never silent. + /// Kata 09v1's locator fallback covers /// exists-but-unparseable; this covers never-created. #[test] fn claude_never_conversed_yields_respawn_not_dead_session() { @@ -235,8 +238,13 @@ In `crates/freshell-ws/src/reconcile.rs`, in the `SessionExistence::Absent` arm, // the binding row is durable before the answer — but the // transcript file only appears on first output. Ledger-bound + // Absent + never SEEN on disk therefore means "created, never - // conversed", not dead: claude's --resume/--session-id recreates - // the file on first output. Kata 09v1's locator fallback + // conversed", not dead: Respawn is the actionable verdict. The + // e2e fake accepts --resume and restores; REAL claude (verified + // v2.1.220) errors-and-exits on a not-found --resume id, which + // §7.5 respawn_exhausted converges to a loud DeadSession -- + // never silent (a --session-id start-intent respawn was + // rejected: it would silently reuse a conversed-then-deleted + // id). Kata 09v1's locator fallback // (freshell-server existence.rs) already converts // exists-but-unparseable into Present => Respawn; this arm covers // never-created — two halves of one rule, no overlap. A @@ -284,6 +292,20 @@ git add crates/freshell-ws/src/reconcile.rs git commit -m "fix(reconcile): claude never-conversed sessions respawn instead of dead_session (PIN 1)" ``` +- [ ] **Step 5b: Update the collateral spec pinned to the old claude verdict (validated blast radius)** + +Load-bearing validation found exactly one green e2e test that pins the behavior this carve-out inverts: `test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts:418-480` ("dead sessions → ONE batched panel") builds claude panes that are ledger-bound, deletes their transcripts while the server is down, restarts, and asserts the batched "Dead sessions" dialog. Under the carve-out that shape now derives `Respawn` (ever_bound + never observed on disk in the NEW epoch) — the dialog never appears and the test goes deterministically red. + +Update the test to preserve its actual contract (multiple dead sessions batch into one panel) without depending on the claude deleted-while-down shape: switch the dead-session fixture panes to a provider WITHOUT an Absent-arm carve-out (codex or opencode — their Absent still derives `DeadSession`), or use an in-boot observed-then-deleted claude shape (which stays `DeadSession` per the hazard guard). Keep the batching assertions otherwise equivalent; do not weaken the contract. + +Run: `npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium reconcile-client-adoption-rust.spec.ts` +Expected: red before the update (the batched-panel leg), green after it — record both in the commit body. + +```bash +git add test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts +git commit -m "test(e2e): dead-session batching no longer keys on claude deleted-while-down (PIN 1 carve-out derives Respawn there)" +``` + - [ ] **Step 6: Prove the ruler leg flips (RED signal = unexpected pass)** Build and run the still-pinned ruler leg: @@ -295,6 +317,8 @@ npx playwright test --config test/e2e-browser/playwright.config.ts --project=rus Expected: the run FAILS with Playwright's "passed unexpectedly" error for the `test.fail`-annotated ruler — that is the flip signal. (The ruler has `test.setTimeout(600_000)`; expect up to 10 minutes.) +(Baseline validation at `c1c67464` confirmed the ruler's only recorded error is the claude `--resume` argv poll at `:1753` — but the ruler aborts at its first failing await, so the sub-assertions AFTER `:1762` (codex/opencode resume, fresh* rehydration, etc.) have never executed under composition on this commit. A newly-surfaced red there is possible once the claude leg passes; it takes the contingency path below — re-diagnosis, never re-pin.) + **If instead the ruler still fails expectedly** (a leg OTHER than the claude `--resume` argv poll is red), do NOT delete the pin here — record the failing leg's error verbatim in the commit-message body of a `wip` note commit, continue to Task 3, and re-attempt this flip in Task 8 Step 2. If it still cannot flip there, HALT the workflow and report the evidence (never re-pin). - [ ] **Step 7: Flip the pin** @@ -325,16 +349,17 @@ git commit -m "test(wall): flip the composed-ruler pin — claude never-converse --- -### Task 3: Pin 3 server — pending-marker read → `fresh_by_race`, one-shot consumption +### Task 3: Pin 3 server — pending-marker read → `fresh_by_race`, idempotent read-only + +**Design decision (validated — supersedes the brief's one-shot consumption):** load-bearing validation FALSIFIED delete-at-derive consumption: the client re-sends `pane.reconcile` on EVERY WS ready precisely because a response can be dropped (`App.tsx:1029-1053` — "a result is not guaranteed (deferral, drop, error frame), so reconnect covers loss windows"), and the server's send is best-effort (`terminal.rs:76`). Consuming the marker before/around the send means a dropped response makes the retry derive a silent `no_recoverable_identity` — losing the breadcrumb to the exact churn Pin 3 exists to eliminate. The marker read is therefore READ-ONLY and IDEMPOTENT: every reconcile that still sees the marker derives the same loud `fresh_by_race`. Markers are still never promoted; locator resolution or the ledger's existing GC deletes them, and a recreated pane's new terminal id makes the old marker unreachable, so repeated breadcrumbs do not occur in practice. (Bonus: this keeps `pane-ledger-restart-rust.spec.ts:182,246,318` — which pin markers PRESERVED across restart — green.) Accepted residual: a pane recreated by the census fallback under a new terminal id leaves its old marker dormant until GC (no breadcrumb — same as today for that shape). **Files:** -- Modify: `crates/freshell-ws/src/reconcile.rs` (the no-identity Fresh path at lines ~286–296; new pub helper; `mod tests`) -- Modify: `crates/freshell-ws/src/terminal.rs` (`handle_pane_reconcile`, ~line 3282; verdicts computed at `:3330-3333`, response sent at `:3382-3388`) +- Modify: `crates/freshell-ws/src/reconcile.rs` (the no-identity Fresh path at lines ~286–296; `mod tests`) - Test: `crates/freshell-ws/src/reconcile.rs` `mod tests` **Interfaces:** -- Consumes: `PaneLedger::pending_for_terminal(&self, terminal_id: &str) -> Option<PendingMarker>` (reader-rule: `None` if a binding row covers the terminal — `pane_ledger.rs:787`); `PaneLedger::record_pending(&self, terminal_id: &str, mode: &str, cwd: Option<&str>, now_ms: i64) -> io::Result<()>`; `PaneLedger::delete_pending(&self, terminal_id: &str) -> io::Result<()>`; `ReconcilePane.terminal_id: Option<String>` (the client's stale pre-kill terminal id — the marker's key). -- Produces: new verdict reason string `"fresh_by_race"` on `Fresh` verdicts (free-form field — no contract regeneration); `pub fn fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String>` in `reconcile.rs` (verdicts are 1:1 with request order — documented on `PaneVerdict.pane_key`); marker consumption in `handle_pane_reconcile`. Task 4's client breadcrumb keys on the literal reason string `fresh_by_race`. +- Consumes: `PaneLedger::pending_for_terminal(&self, terminal_id: &str) -> Option<PendingMarker>` (reader-rule: `None` if a binding row covers the terminal — `pane_ledger.rs:787`); `PaneLedger::record_pending(&self, terminal_id: &str, mode: &str, cwd: Option<&str>, now_ms: i64) -> io::Result<()>` (tests only); `ReconcilePane.terminal_id: Option<String>` (the client's stale pre-kill terminal id — the marker's key). +- Produces: new verdict reason string `"fresh_by_race"` on `Fresh` verdicts (free-form field — no contract regeneration), derived idempotently from the marker's presence. Task 4's client breadcrumb keys on the literal reason string `fresh_by_race`. No `terminal.rs` change and no deletion helper. **Load-bearing verification folded in (Step 1):** the design claims the client re-presents the dead epoch's `terminalId` on post-restart reconcile (that is the marker join key — markers are keyed by `terminal_id`, and `createRequestId` is not stored on markers). The pinned leg itself proves the client retains it (its settle-poll at `:1988-1995` waits for `content.terminalId` to CHANGE from the pre-kill value, so the pre-kill value was present). Confirm the reconcile REQUEST carries it: `grep -n "terminalId" src/lib/pane-reconcile.ts | head -30` — the request builder must include the pane's current `terminalId`. If (and only if) it demonstrably does not, STOP this task and report: the fallback design (extending `PendingMarker` with an optional `create_request_id` field and joining on that) is a schema change that needs plan-review, not an inline improvisation. @@ -363,11 +388,15 @@ In `crates/freshell-ws/src/reconcile.rs` `mod tests`: assert_eq!(v.reason.as_deref(), Some("fresh_by_race")); } - /// One-shot semantics (§6 decision 5): once the marker is consumed, a - /// later reconcile derives the plain labeled fresh — the breadcrumb - /// fires exactly once. + /// Delivery-safety pin (validated design change): the marker read is + /// IDEMPOTENT and read-only. The client re-sends pane.reconcile on + /// every WS ready precisely because a response can be dropped + /// (App.tsx:1029-1053); consuming the marker at derive would turn that + /// retry into a silent no_recoverable_identity — losing the breadcrumb + /// to the exact churn PIN 3 eliminates. A retry must derive the same + /// loud verdict, and the marker must still exist afterwards. #[test] - fn consumed_marker_reverts_to_no_recoverable_identity() { + fn marker_read_is_idempotent_across_reconciles() { let f = Fixture::new(); f.ledger .record_pending("T-race2", "opencode", None, 1_000) @@ -375,17 +404,15 @@ In `crates/freshell-ws/src/reconcile.rs` `mod tests`: let mut p = pane("cr-race2"); p.mode = Some("opencode".to_string()); p.terminal_id = Some("T-race2".to_string()); - let first = f.one(p.clone()); + let first = f.one(p); assert_eq!(first.reason.as_deref(), Some("fresh_by_race")); - // Consume, as handle_pane_reconcile does for fresh_by_race verdicts. - let tids = fresh_by_race_marker_tids(&[p.clone()], &[first]); - assert_eq!(tids, vec!["T-race2".to_string()]); - for tid in &tids { - f.ledger.delete_pending(tid).expect("delete pending"); - } - let second = f.one(p); + let mut p2 = pane("cr-race2"); + p2.mode = Some("opencode".to_string()); + p2.terminal_id = Some("T-race2".to_string()); + let second = f.one(p2); assert_eq!(second.verdict, ReconcileVerdict::Fresh); - assert_eq!(second.reason.as_deref(), Some("no_recoverable_identity")); + assert_eq!(second.reason.as_deref(), Some("fresh_by_race")); + assert!(f.ledger.pending_for_terminal("T-race2").is_some()); } /// Shell panes stay bare fresh even with a stray marker — the marker @@ -405,14 +432,12 @@ In `crates/freshell-ws/src/reconcile.rs` `mod tests`: } ``` -(If `ReconcilePane` does not derive `Clone`, build `p` twice from the same literal instead of `p.clone()`.) - - [ ] **Step 3: Run to verify RED** Run: `cargo test -p freshell-ws --lib reconcile` -Expected: `pending_marker_yields_fresh_by_race_not_silent_fresh` FAILS with reason `no_recoverable_identity`; `consumed_marker_reverts_...` fails to compile (`fresh_by_race_marker_tids` undefined); `shell_pane_ignores_pending_markers` PASSES (pins existing behavior). +Expected: `pending_marker_yields_fresh_by_race_not_silent_fresh` and `marker_read_is_idempotent_across_reconciles` FAIL with reason `no_recoverable_identity`; `shell_pane_ignores_pending_markers` PASSES (pins existing behavior). -- [ ] **Step 4: Implement the read + the helper** +- [ ] **Step 4: Implement the read** In `crates/freshell-ws/src/reconcile.rs`, replace the no-identity else-branch (lines ~286–296) body: @@ -427,10 +452,15 @@ In `crates/freshell-ws/src/reconcile.rs`, replace the no-identity else-branch (l // §4.2 pending-marker read (PIN 3): a durable pending marker keyed by // the client's stale terminal id means identity establishment was in // flight when the server died — fresh by RACE, not by intent. The - // reason is distinct and surfaced (client breadcrumb); the caller - // consumes the marker so the breadcrumb fires exactly once - // (§6 decision 5: markers are never promoted — resolution or - // consumption deletes them). + // reason is distinct and surfaced (client breadcrumb). The read is + // deliberately IDEMPOTENT and read-only (amends §6 decision 5's + // consumption clause): the client re-sends pane.reconcile on every + // WS ready because a response can be dropped (App.tsx:1029-1053) — + // consuming the marker at derive would turn that retry into a + // silent no_recoverable_identity. Markers are still never promoted; + // locator resolution or the ledger's GC deletes them, and a + // recreated pane's new terminal id makes the old marker + // unreachable. if let Some(tid) = pane.terminal_id.as_deref() { if deps.pane_ledger.pending_for_terminal(tid).is_some() { return PaneVerdict { @@ -446,48 +476,12 @@ In `crates/freshell-ws/src/reconcile.rs`, replace the no-identity else-branch (l }; ``` -And add the pub helper (near `derive_verdicts`, outside `mod tests`): - -```rust -/// PIN 3 / §6 decision 5 one-shot: the terminal ids whose verdicts were -/// labeled fresh_by_race — the caller deletes their pending markers so the -/// breadcrumb fires exactly once. Verdicts are 1:1 with request order -/// (see `PaneVerdict::pane_key`). -pub fn fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String> { - panes - .iter() - .zip(verdicts) - .filter(|(_, v)| v.reason.as_deref() == Some("fresh_by_race")) - .filter_map(|(p, _)| p.terminal_id.clone()) - .collect() -} -``` +- [ ] **Step 5: Confirm no consumption wiring exists or is needed (delivery-safety design)** -- [ ] **Step 5: Wire consumption into `handle_pane_reconcile`** +No `terminal.rs` change: the derive path in `reconcile.rs` is the READ side's only touch point (see this task's design decision — delete-at-derive was falsified by load-bearing validation). Confirm and record in the commit body: -In `crates/freshell-ws/src/terminal.rs`, inside `async fn handle_pane_reconcile` (~:3282), after `let mut verdicts` is fully computed (`:3333`, after the `catch_unwind` derive closure resolves) and BEFORE the `PaneReconcileResult` send (`:3382`): - -```rust - // PIN 3 one-shot: fresh_by_race verdicts consume their pending markers — - // the next reconcile for the same dead terminal derives a plain - // no_recoverable_identity. Blocking-pool, same budget reasoning as every - // other ledger write site (V1.md). - let race_tids = crate::reconcile::fresh_by_race_marker_tids(&request.panes, &verdicts); - if !race_tids.is_empty() { - let ledger = std::sync::Arc::clone(&state.pane_ledger); - tokio::task::spawn_blocking(move || { - for tid in race_tids { - if let Err(err) = ledger.delete_pending(&tid) { - tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_marker_delete_failed_on_fresh_by_race"); - } - } - }) - .await - .ok(); - } -``` - -(Match the module-path style already used at `:3330` for `derive_verdicts` — if it's imported unqualified, call `fresh_by_race_marker_tids` unqualified too. If `verdicts` at that point is inside a different scope/shape than a plain `Vec<PaneVerdict>`, place this block wherever both `request.panes` and the final verdicts vec are in scope, still before the send.) +Run: `grep -n "delete_pending" crates/freshell-ws/src/*.rs` +Expected: call sites only in locator-resolution/exit-hook/GC paths and `pane_ledger.rs` itself — NONE in `handle_pane_reconcile`. (For reference, the alignment fact the old design leaned on was verified anyway: `derive_verdicts` is a total, order-preserving 1:1 map — reconcile.rs:45-64 — and `handle_pane_reconcile` passes untransformed `&request.panes` at `:3330`/`:3368`.) - [ ] **Step 6: Run to verify GREEN** @@ -504,8 +498,8 @@ Expected: no diff, all green. Run: `cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings` ```bash -git add crates/freshell-ws/src/reconcile.rs crates/freshell-ws/src/terminal.rs -git commit -m "feat(reconcile): pending-marker read derives loud fresh_by_race with one-shot consumption (PIN 3 server)" +git add crates/freshell-ws/src/reconcile.rs +git commit -m "feat(reconcile): pending-marker read derives loud fresh_by_race, idempotent read-only (PIN 3 server)" ``` --- @@ -607,7 +601,7 @@ In `src/components/TerminalView.tsx`: } ``` -(Only the `if (createdReconcileNotice === ...)` lines are new; keep the rest byte-identical. The attach-path site at ~`:5040` is left unchanged — a fresh verdict always arrives via `terminal.created`.) +(Only the `if (createdReconcileNotice === ...)` lines are new; keep the rest byte-identical. The attach-path site at ~`:5040` is left unchanged — verified: a fresh verdict's fold clears `terminalId` and stores the notice (`panesSlice.ts:1948,1951,1982-1983`), so the drive effect takes the CREATE branch (`TerminalView.tsx:5009` vs `:5076`) and the notice is consumed at this site in the same handler invocation that sets the new `terminalId` (`:4258-4262`). Accepted residual: a dropped `terminal.created` that later routes via attach skips the overlay — low-probability, and Task 3's idempotent marker read re-labels the next reconcile anyway.) (d) In the root JSX at ~line 5228, inside the `wrapperRef` div (which has `relative` when visible), as a sibling of the `containerRef` xterm div: @@ -704,7 +698,7 @@ Immediately after the existing `expect(resumed || breadcrumbVisible).toBe(true)` npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust.spec.ts -g "SIGKILL-inside-locator-window" ``` -Expected: PASS, twice sequentially. If the re-capture poll is the flaky part (locator window mechanics), investigate against the green `freshopencode` leg (`:1051`) which exercises the same fake row-gate machinery — do not delete the assertion to get green. +Expected: PASS, twice sequentially. If the re-capture poll is the flaky part (locator window mechanics), investigate the row-gate machinery directly: the fake's gate-poll → db-row write (`test/e2e-browser/fixtures/fake-opencode-terminal.mjs`, writes the `ses_` row to the `XDG_DATA_HOME/opencode/opencode.db` the locator watches) and the locator sweep (`opencode_locator.rs` — 150ms sweep; a late Enter re-opens an expired window). NOTE (validated): the `freshopencode` leg (`:1051`) is NOT a comparator — it uses the SIDECAR fake, not the row gate. `restartAbrupt` relaunches with identical env (`helpers/rust-server.ts:413-447,477-492`), so the gate env survives the restart. Do not delete the assertion to get green. - [ ] **Step 6: Commit** @@ -839,7 +833,15 @@ And add the anchor comment on the line directly above the PTY `spawn_blocking` c // PIN2_PTY_SPAWN_ANCHOR: the spawn makes preallocated identity observable. ``` -Notes for the implementer: (1) this fires for claude resume-creates too — their binding row already exists from a prior epoch, so the re-write is a no-op refresh; (2) `MARKER_MODES` correctly still excludes claude (claude has create-time identity and no post-spawn resolver — this write IS its durability story; record this answer to the brief's "should claude also get a marker?" question in the commit body: no — a binding row before spawn is strictly stronger than a marker); (3) the pre-spawn window for `MARKER_MODES` providers (their marker is also written post-spawn) is a known residual NOT exercised by any wall leg — do not fix it here (YAGNI; noted for a follow-up if a leg ever pins it). +Notes for the implementer: (1) this fires for claude resume-creates too — the re-write retargets the existing `(provider, session_id)` row at the new terminal id pre-spawn. Validated benign: supersession keys on the freshly-minted tid, the two-bound-rows repair groups by tid, and every recovery path keys on `(provider, session_id)`, so the prior epoch stays recoverable even if this spawn fails or is killed mid-window; (2) `MARKER_MODES` correctly still excludes claude (claude has create-time identity and no post-spawn resolver — this write IS its durability story; record this answer to the brief's "should claude also get a marker?" question in the commit body: no — a binding row before spawn is strictly stronger than a marker); (3) the pre-spawn window for `MARKER_MODES` providers (their marker is also written post-spawn) is a known residual NOT exercised by any wall leg — do not fix it here (YAGNI; noted for a follow-up if a leg ever pins it). + +- [ ] **Step 4b: Clean up the row when the spawn FAILS (validated gap)** + +Load-bearing validation falsified "a row for a spawn-failed create is benign": the spawn-failure branch (`terminal.rs:2218-2275`) does no ledger cleanup, so a pre-spawn row for a failed FRESH claude create would surface as a ghost `ledgerOnly` recovery offer (a pane that never existed) for ~30 days. (Lock-safety at the insertion point WAS verified: only RAII guards already held across the existing spawn `.await` — no new hazard.) + +Capture at the preallocation site (`:1649`) whether this create is a fresh claude preallocation (e.g. `let claude_fresh_prealloc = true;` alongside the `Uuid::new_v4()` assignment; `false` otherwise). In the spawn-failure branch, when `claude_fresh_prealloc` holds, delete the just-written row: use the ledger's existing binding-removal API (grep `pane_ledger.rs` for the deletion used by supersession/repair; if none is exposed, add `pub fn delete_binding(&self, provider: &str, session_id: &str) -> io::Result<()>` mirroring `delete_pending`'s atomic delete), wired through the same `spawn_blocking` + `surface_write_failure` pattern as the write. Do NOT delete on resume-creates — that row belongs to the prior epoch and must stay recoverable (validated, note (1) above). + +Test: add a `pane_ledger` unit test `deleted_binding_row_is_gone_for_recovery_readers` (record binding → delete → the reader that feeds the recovery inventory no longer returns the row). The failure-branch wiring is covered by the per-task spec review plus the Step 2 ordering pin's anchors. - [ ] **Step 5: Run to verify GREEN** @@ -882,6 +884,8 @@ The current probe can never match the shipped UI — `getByText(/recover .*pane/ (The poll's other arm — a pane whose `sessionRef.sessionId === preallocatedId` — stays byte-identical; the contract is unchanged: auto-restored OR visibly offered.) +Validated note: do NOT assert WHERE the entry lives in the inventory (`ledgerOnly` vs the `device.tabs` arm) — a pre-kill tabs-snapshot push (subscribe-driven, un-debounced) may race the SIGKILL and relocate the row between the two arms across runs; either way `recoverable` stays true and the panel renders. The testid + `recoverable` are the stable contract. + - [ ] **Step 2: Prove the leg flips (unexpected pass)** ```bash @@ -975,9 +979,10 @@ Expected: three consecutive fully-green runs, zero pins, zero unexpected passes. npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium hidden-pane-rebind-rust.spec.ts npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium reconcile-client-adoption-rust.spec.ts reconcile-completion-rust.spec.ts reconcile-handshake-rust.spec.ts npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium recover-my-panes-rust.spec.ts +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium pane-ledger-restart-rust.spec.ts ``` -Expected: all green (`recover-my-panes` guards Task 6/7 against regressions in the offer flow; the reconcile suites guard Tasks 2–4). +Expected: all green (`recover-my-panes` guards Task 6/7 against regressions in the offer flow; the reconcile suites guard Tasks 2–4 — note `reconcile-client-adoption` was already updated in Task 2 Step 5b; `pane-ledger-restart` pins markers PRESERVED across restart, which Task 3's read-only design deliberately keeps true — validated blast radius). - [ ] **Step 7: Push the branch — NO PR** @@ -993,8 +998,8 @@ Do NOT open a pull request. Landing happens outside this workflow with the final ## Self-Review (performed against the task spec) **1. Spec coverage:** -- Pin 1 claude carve-out mirroring amplifier → Task 2. PR #565 coherence (two halves, no overlap) → Task 2 design decision + comment. Named red test `claude_never_conversed_yields_respawn_not_dead_session` → Task 2 Step 1. Composed ruler leg → Task 2 Steps 6–8. True-positive hazard guard → Task 2 `claude_deleted_after_conversation_stays_dead_session` (in-boot, direct) + `respawn_exhausted` convergence for the cross-restart shape (explicit design decision, mirrored from the amplifier arm's accepted tradeoff). -- Pin 3 read side before generic Fresh, marker lookup by terminalId lineage, distinct surfaced `fresh_by_race` → Task 3. Client fold breadcrumb visible → Task 4 (reason already flows through the fold; text + DOM visibility are where the change bites — noted explicitly). Contract-pinned check → verified NOT pinned; gates still run generate/test:port/protocol (Tasks 3, 8). P1.10 "verify then build" → verified LANDED (Task 5 Step 1 evidence) + end-to-end re-capture assertion added to the leg (Task 5 Step 4). Red tests: unit marker→fresh_by_race (Task 3), e2e breadcrumb + re-capture + pin flip (Task 5). +- Pin 1 claude carve-out mirroring amplifier → Task 2. PR #565 coherence (two halves, no overlap) → Task 2 design decision + comment. Named red test `claude_never_conversed_yields_respawn_not_dead_session` → Task 2 Step 1. Composed ruler leg → Task 2 Steps 6–8. True-positive hazard guard → Task 2 `claude_deleted_after_conversation_stays_dead_session` (in-boot, direct) + `respawn_exhausted` convergence for the cross-restart shape (explicit design decision, mirrored from the amplifier arm's accepted tradeoff, and now grounded in VERIFIED real-CLI semantics — see the Task 2 design decision). Collateral blast radius (validated): the one green spec pinning the inverted claude verdict (`reconcile-client-adoption-rust.spec.ts`) is updated in Task 2 Step 5b. +- Pin 3 read side before generic Fresh, marker lookup by terminalId lineage, distinct surfaced `fresh_by_race` → Task 3 (the brief's one-shot consumption was FALSIFIED by delivery-semantics validation and replaced with an idempotent read-only design — Task 3's design decision). Client fold breadcrumb visible → Task 4 (reason already flows through the fold; text + DOM visibility are where the change bites — noted explicitly). Contract-pinned check → verified NOT pinned; gates still run generate/test:port/protocol (Tasks 3, 8). P1.10 "verify then build" → verified LANDED (Task 5 Step 1 evidence) + end-to-end re-capture assertion added to the leg (Task 5 Step 4). Red tests: unit marker→fresh_by_race (Task 3), e2e breadcrumb + re-capture + pin flip (Task 5). - Pin 2 instrument FIRST → Task 6 Step 1 (empirical bindings-at-kill disambiguation, recorded). Candidate (a) boot-state unwired → disproven with evidence (wired via App.tsx:1926; green recover-my-panes e2e). Candidate (b) race → real code-level window; fixed by durability-before-spawn (Task 6). Claude marker question → answered explicitly (binding-before-spawn is strictly stronger; recorded in commit body). Red tests: wall leg (Task 7) + ordering pin `claude_binding_write_precedes_pty_spawn_in_handle_create` (Task 6, the unit-level pin on the broken link). - Gates: fmt/clippy/cargo test, npm test with summary + coordinator wait, test:port, lint, release build, full wall ×3 with zero pins, hidden-pane-rebind + reconcile (+ recover-my-panes) suites, ephemeral ports, no-PR push → Task 8 + Global Constraints. Fix order Pin 1 → Pin 3 → Pin 2 → task order 1–7. @@ -1002,4 +1007,6 @@ Do NOT open a pull request. Landing happens outside this workflow with the final **2. Placeholder scan:** two steps intentionally reference sibling-test fixtures by exact name instead of inlining unknown fixture internals (Task 1 Step 1 construction lines; Task 5 Step 4 leaf-lookup fallback note) — in both, the assertions and target behavior are fully specified and the referenced fixture is named with file+line. No TBDs, no "handle edge cases", no test-less "write tests" steps. -**3. Type consistency:** `ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool` used identically in Tasks 1 and 2; `fresh_by_race_marker_tids(panes: &[ReconcilePane], verdicts: &[PaneVerdict]) -> Vec<String>` defined (Task 3 Step 4) matches both call sites (Task 3 Steps 2 and 5); `RECONCILE_NOTICE_FRESH_BY_RACE` defined (Task 4 Step 3) matches the import (Task 4 Step 5) and the e2e regex (verified by the Task 4 unit test); `BindingWrite` field set (`provider, session_id, terminal_id, mode, cwd, create_request_id, now_ms`) is identical across Task 2, Task 3 (none), and Task 6, matching the existing write block at `terminal.rs:2434-2443`; the reason strings `fresh_by_race` / `no_recoverable_identity` / `session_not_on_disk` are used byte-identically across server, tests, and client. +**3. Type consistency:** `ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool` used identically in Tasks 1 and 2; `RECONCILE_NOTICE_FRESH_BY_RACE` defined (Task 4 Step 3) matches the import (Task 4 Step 5) and the e2e regex (verified by the Task 4 unit test); `BindingWrite` field set (`provider, session_id, terminal_id, mode, cwd, create_request_id, now_ms`) is identical across Task 2, Task 3 (none), and Task 6, matching the existing write block at `terminal.rs:2434-2443`; the reason strings `fresh_by_race` / `no_recoverable_identity` / `session_not_on_disk` are used byte-identically across server, tests, and client. + +**4. Load-bearing validation round (post-plan, pre-execution):** 12 assumptions surfaced, all resolved — full ledger at `/home/dan/code/freshell/.worktrees/.the-usual-logs/wall-pins-closure/load-bearing-ledger.md` (validator evidence in `reports/V1..V7`). Verified (8): ruler's sole red cause is the claude argv poll (baseline run, JSON reporter — 12 non-pinned legs green); Respawn→`--resume` argv has no downstream existence gate; the recovery-offer chain works with a binding row alone (referenced-row rule only relocates the entry); notice routes through the created site; verdicts are 1:1 with request panes; resume-create pre-spawn rewrite is benign; row-gate env survives `restartAbrupt`; wall baseline stable. Falsified (4), plan updated accordingly: real claude `--resume` does NOT recreate a never-created transcript (Task 2 rationale corrected; `--session-id` alternative rejected as silent-fresh-hazardous); delete-at-derive marker consumption loses breadcrumbs to reconnect churn (Task 3 redesigned read-only/idempotent); a pre-spawn row for a spawn-FAILED fresh create becomes a ghost offer (Task 6 Step 4b cleanup added); two green specs pinned inverted behaviors (Task 2 Step 5b spec update; `pane-ledger-restart` kept green by the Task 3 redesign and added to Task 8 Step 6). Accepted residuals are recorded in the ledger's "Acceptable decisions" section. From aae82351acd6d81807f5d96f6b2de2b67b49f95a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:51:17 -0700 Subject: [PATCH 03/14] feat(existence): split ever_observed_on_disk from ever_observed (disk-only, ledger excluded) --- crates/freshell-server/src/existence.rs | 66 +++++++++++++++++++++++++ crates/freshell-ws/src/existence.rs | 11 +++++ 2 files changed, 77 insertions(+) diff --git a/crates/freshell-server/src/existence.rs b/crates/freshell-server/src/existence.rs index 38ad2aabd..5a0c3e3c7 100644 --- a/crates/freshell-server/src/existence.rs +++ b/crates/freshell-server/src/existence.rs @@ -186,6 +186,13 @@ impl SessionExistenceProbe for IndexExistenceProbe { .as_ref() .is_some_and(|ledger| ledger.ever_bound(provider, session_id)) } + + fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool { + self.observed + .lock() + .expect("observed set lock") + .contains(&format!("{provider}:{session_id}")) + } } #[cfg(test)] @@ -421,6 +428,65 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// PIN 1 (claude never-conversed carve-out): "seen on disk" is a strictly + /// stronger fact than "ever bound". A ledger binding proves the identity + /// was minted at create — NOT that a transcript ever existed. The + /// carve-out keys on disk observation, so ever_bound alone must not + /// count. + #[test] + fn ever_observed_on_disk_excludes_ledger_only_bindings() { + let dir = std::env::temp_dir().join(format!( + "ledger-everobs-disk-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let ledger = + std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some(dir.clone()))); + // "Generation 1" bound this identity durably. + ledger + .record_binding(&freshell_ws::pane_ledger::BindingWrite { + provider: "claude", + session_id: "11111111-2222-3333-4444-555555555555", + terminal_id: "t1", + mode: "claude", + cwd: None, + create_request_id: None, + now_ms: 1_000, + }) + .unwrap(); + + // "Generation 2": a brand-new probe with an EMPTY observed set — + // construct it exactly as main.rs does, over an index whose + // provider home is an empty temp dir (the transcript is gone). + let probe = new_test_probe_with_ledger(Some(std::sync::Arc::clone(&ledger))); + let session_id = "11111111-2222-3333-4444-555555555555"; + assert!(probe.ever_observed("claude", session_id)); // via ledger — unchanged + assert!(!probe.ever_observed_on_disk("claude", session_id)); // NEW: ledger does not count + std::fs::remove_dir_all(&dir).ok(); + } + + /// A genuine on-disk observation (index snapshot or locator-fallback hit) + /// counts for BOTH ever_observed and ever_observed_on_disk. + #[tokio::test] + async fn ever_observed_on_disk_true_after_disk_observation() { + let home = temp_claude_home("disk-observed"); + let session_id = "3c4d5e6f-7081-4a92-8b3c-4d5e6f708192"; + write_zero_turn_session(&home, session_id); + let (probe, index) = probe_over(&home); + let probe = probe.with_claude_transcript_locator(direct_locator_over(&home)); + index.warm().await; + assert_eq!( + probe.exists("claude", session_id), + SessionExistence::Present + ); + assert!(probe.ever_observed_on_disk("claude", session_id)); + let _ = std::fs::remove_dir_all(&home); + } + #[test] fn unknown_provider_is_absent_never_unknown() { let home = temp_claude_home("unknown-provider"); diff --git a/crates/freshell-ws/src/existence.rs b/crates/freshell-ws/src/existence.rs index afd455ebc..7814a3029 100644 --- a/crates/freshell-ws/src/existence.rs +++ b/crates/freshell-ws/src/existence.rs @@ -46,6 +46,17 @@ pub trait SessionExistenceProbe: Send + Sync { /// only raised for an identity disk has some memory of — a never-observed /// (stale/typo) claim falls through to `fresh`. fn ever_observed(&self, provider: &str, session_id: &str) -> bool; + + /// "Seen on disk" strictly: true only if this process actually observed + /// the session artifact on disk (index snapshots, or the claude + /// locator-fallback hit). Unlike `ever_observed`, durable ledger bindings + /// do NOT count — a binding proves the identity was minted, not that a + /// transcript ever existed (PIN 1: the claude never-conversed carve-out + /// keys on this distinction). Default: delegate to `ever_observed`, which + /// is already disk-only for fakes without a ledger. + fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool { + self.ever_observed(provider, session_id) + } } /// The no-index fallback (mirrors `session_index: None` in From e970d9ccb7c4e6c5ea2c8fc209ffbaaf0740c4cf Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:02:32 -0700 Subject: [PATCH 04/14] fix(reconcile): claude never-conversed sessions respawn instead of dead_session (PIN 1) --- crates/freshell-ws/src/reconcile.rs | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/crates/freshell-ws/src/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index 17110efd3..877d751c3 100644 --- a/crates/freshell-ws/src/reconcile.rs +++ b/crates/freshell-ws/src/reconcile.rs @@ -339,6 +339,50 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi ..base(pane, ReconcileVerdict::Respawn) }; } + // Claude carve-out (never-conversed preallocation, PIN 1): claude + // terminals get a server-preallocated --session-id at create and + // the binding row is durable before the answer — but the + // transcript file only appears on first output. Ledger-bound + + // Absent + never SEEN on disk therefore means "created, never + // conversed", not dead: Respawn is the actionable verdict. The + // e2e fake accepts --resume and restores; REAL claude (verified + // v2.1.220) errors-and-exits on a not-found --resume id, which + // §7.5 respawn_exhausted converges to a loud DeadSession -- + // never silent (a --session-id start-intent respawn was + // rejected: it would silently reuse a conversed-then-deleted + // id). Kata 09v1's locator fallback + // (freshell-server existence.rs) already converts + // exists-but-unparseable into Present => Respawn; this arm covers + // never-created — two halves of one rule, no overlap. A + // transcript that WAS observed on disk and is now gone falls + // through to the loud dead_session below (rows 4/4b hazard + // guard). Cross-restart deleted-with-prior-conversation is + // indistinguishable from never-conversed by these signals and + // takes the same escape the amplifier arm accepts: §7.5's + // respawn_exhausted convergence ends a respawn <-> instant-exit + // loop in an actionable dead_session, never thrash. + if sref.provider == "claude" + && deps + .pane_ledger + .ever_bound(&sref.provider, &sref.session_id) + && !deps + .existence + .ever_observed_on_disk(&sref.provider, &sref.session_id) + { + if deps.registry.respawn_exhausted(&key) { + return PaneVerdict { + session_ref: Some(sref), + reason: Some("respawn_exhausted".to_string()), + ..base(pane, ReconcileVerdict::DeadSession) + }; + } + let corrected = corrected_flag(pane.session_ref.as_ref(), Some(&sref)); + return PaneVerdict { + session_ref: Some(sref), + corrected, + ..base(pane, ReconcileVerdict::Respawn) + }; + } // dead_session is gated on the identity having been SEEN on disk // at least once — never a data-loss-shaped verdict for an // identity disk has no memory of (§5.3 rows 4/4b). @@ -655,6 +699,62 @@ mod tests { assert_eq!(v.session_ref, Some(sref("amplifier", "s-gcd"))); } + /// PIN 1 red test: a claude pane whose session id was preallocated at + /// create (ledger-bound, durable) but which NEVER conversed has no + /// transcript file -> Absent. That is not an immediately-dead state: + /// respawning with --resume mirrors the amplifier arm. Against the + /// wall's fake CLI the pane restores; REAL claude (verified v2.1.220) + /// errors-and-exits on a not-found id, which §7.5 respawn_exhausted + /// converges to a loud, actionable DeadSession -- never silent. + /// Kata 09v1's locator fallback covers + /// exists-but-unparseable; this covers never-created. + #[test] + fn claude_never_conversed_yields_respawn_not_dead_session() { + let f = Fixture::new(); + f.ledger + .record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: "s-never", + terminal_id: "T-never", + mode: "claude", + cwd: None, + create_request_id: Some("cr-never"), + now_ms: 1_000, + }) + .expect("record binding"); + let mut p = pane("cr-never"); + p.session_ref = Some(sref("claude", "s-never")); + // Probe default: Absent, never observed on disk. + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Respawn); + assert_eq!(v.session_ref, Some(sref("claude", "s-never"))); + } + + /// PIN 1 hazard guard: a claude transcript that WAS seen on disk and is + /// now gone is a real data-loss shape — stays loud dead_session even + /// though the identity is ledger-bound (rows 4/4b unchanged). + #[test] + fn claude_deleted_after_conversation_stays_dead_session() { + let f = Fixture::new(); + f.ledger + .record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: "s-gone2", + terminal_id: "T-gone2", + mode: "claude", + cwd: None, + create_request_id: Some("cr-gone2"), + now_ms: 1_000, + }) + .expect("record binding"); + f.probe.mark_observed("claude", "s-gone2"); + let mut p = pane("cr-gone2"); + p.session_ref = Some(sref("claude", "s-gone2")); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::DeadSession); + assert_eq!(v.reason.as_deref(), Some("session_not_on_disk")); + } + /// Row 5 (§9.1 test 6): cold index on a known provider → honest /// error{index_warming}, never dead_session, never optimistic respawn. /// (`retry` is deleted from the wire; the handler's bounded single From eb9abdf8bfc5c20ac78fb45ad22473243fd60d0c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:13:34 -0700 Subject: [PATCH 05/14] test(e2e): dead-session batching no longer keys on claude deleted-while-down (PIN 1 carve-out derives Respawn there) The batched-adjudication scenario's dead-session fixtures switch from claude to codex panes (opened from seeded sidebar history, so the resume creates record durable pane-ledger bindings). Under the PIN 1 carve-out a ledger-bound claude id never seen on disk in the new epoch derives Respawn, so the deleted-while-down claude shape no longer produces the Dead sessions dialog. Codex has no Absent-arm carve-out: ledger-bound + Absent stays dead_session{session_not_on_disk}. Batching assertions are otherwise equivalent (ONE dialog, 2 listitems, per-row adjudication, counts preserved); the seed writes fixtures only when ~/.codex/sessions is first created so a restart cannot resurrect the deleted files. Red before this update (carve-out binary, old spec): npx playwright test --config test/e2e-browser/playwright.config.ts \ --project=rust-chromium reconcile-client-adoption-rust.spec.ts 1 failed [rust-chromium] ... dead sessions surface as ONE batched adjudication panel Error: Timeout 90000ms exceeded while waiting on the predicate 477 | await expect(dialog).toHaveCount(1) 478 | await expect(dialog.getByRole('listitem')).toHaveCount(2) 2 passed Green after this update (same command): 3 passed (27.0s) --- .../reconcile-client-adoption-rust.spec.ts | 193 +++++++++++++++--- 1 file changed, 163 insertions(+), 30 deletions(-) diff --git a/test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts b/test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts index 6e2d1f277..5dba94186 100644 --- a/test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts +++ b/test/e2e-browser/specs/reconcile-client-adoption-rust.spec.ts @@ -31,6 +31,7 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') +const FAKE_CODEX_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-codex-cli.mjs') // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- see file doc comment) @@ -156,6 +157,131 @@ async function writeClaudeTranscript( await fs.writeFile(claudeTranscriptPath(homeDir, sessionId), `${line}\n`, 'utf8') } +// --- codex dead-session fixtures (PIN 1 blast radius) --- +// +// The batched-adjudication scenario needs panes that reconcile to +// dead_session after a deleted-while-down restart. That shape no longer +// exists for claude: the never-observed-on-disk carve-out (reconcile.rs +// Absent arm) derives Respawn for ledger-bound claude ids the new server +// epoch has never seen on disk. Codex has no Absent-arm carve-out, so a +// ledger-bound codex session whose rollout file is gone stays +// dead_session{session_not_on_disk} -- exactly the batching fixture this +// contract needs. Session fixture shape donor: restore-contract-wall-rust +// .spec.ts's seedCodexHome. + +const CODEX_DEAD_SESSION_A = 'aaaaaaaa-1111-4222-8333-000000000001' +const CODEX_DEAD_SESSION_B = 'aaaaaaaa-1111-4222-8333-000000000002' +const CODEX_DEAD_TITLE_A = 'adoption dead codex A' +const CODEX_DEAD_TITLE_B = 'adoption dead codex B' + +function codexSessionPath(homeDir: string, sessionId: string): string { + return path.join(homeDir, '.codex', 'sessions', `${sessionId}.jsonl`) +} + +/** + * Codex home seed: wizard-bypass config with codex enabled PLUS + * ~/.codex/sessions fixture transcripts. setupHome re-runs on every + * boot/restart, so the fixtures are written ONLY when the sessions dir is + * first created -- a restart after the test deletes individual session + * files must NOT resurrect them (same doctrine as seedClaudeHome's + * "deliberately does NOT write transcripts" note). + */ +function seedCodexAdoptionHome( + sessions: Array<{ id: string; title: string }>, + projectDir: string, +): (homeDir: string) => Promise<void> { + return async (homeDir: string) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile( + path.join(freshellDir, 'config.json'), + JSON.stringify( + { + version: 1, + settings: { codingCli: { enabledProviders: ['codex'] } }, + }, + null, + 2, + ), + ) + const sessionsDir = path.join(homeDir, '.codex', 'sessions') + const dirExists = await fs.access(sessionsDir).then( + () => true, + () => false, + ) + if (dirExists) return + await fs.mkdir(sessionsDir, { recursive: true }) + for (const s of sessions) { + const lines = [ + JSON.stringify({ + timestamp: '2026-07-21T08:00:00.000Z', + type: 'session_meta', + payload: { id: s.id, cwd: projectDir }, + }), + JSON.stringify({ + timestamp: '2026-07-21T08:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `${s.title} request 1` }], + }, + }), + JSON.stringify({ + timestamp: '2026-07-21T08:00:02.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: `${s.title} reply 1` }], + }, + }), + ] + await fs.writeFile(path.join(sessionsDir, `${s.id}.jsonl`), `${lines.join('\n')}\n`) + } + } +} + +/** + * Open a seeded codex session from the sidebar history (opens in a NEW tab; + * the resume create records a durable pane-ledger binding, so 'ever + * observed' survives a restart). Returns the pane's tab + leaf ids once the + * pane is live with the expected sessionRef. + */ +async function openSeededCodexSession( + page: Page, + harness: TestHarness, + title: string, + sessionId: string, +): Promise<{ tabId: string; leafId: string }> { + const sessionList = page.getByTestId('sidebar-session-list') + await expect(sessionList).toBeVisible({ timeout: 15_000 }) + const sessionItem = page.getByText(title, { exact: false }).first() + await expect(sessionItem).toBeVisible({ timeout: 15_000 }) + const tabCountBefore = await harness.getTabCount() + await sessionItem.click() + await expect(async () => { + expect(await harness.getTabCount()).toBe(tabCountBefore + 1) + }).toPass({ timeout: 15_000 }) + const tabId = (await harness.getActiveTabId())! + const leafId: string = await expect + .poll(async () => { + const layout = await harness.getPaneLayout(tabId) + const leaf = findLeavesByMode(layout, 'codex').find( + (l) => l?.content?.terminalId && l?.content?.sessionRef?.sessionId === sessionId, + ) + return leaf?.id ?? null + }, { timeout: 20_000 }) + .not.toBeNull() + .then(async () => { + const layout = await harness.getPaneLayout(tabId) + return findLeavesByMode(layout, 'codex').find( + (l) => l?.content?.sessionRef?.sessionId === sessionId, + )!.id + }) + return { tabId, leafId } +} + /** Boot an owned RustServer, navigate, and wait for harness + WS. */ async function bootAdoption( page: Page, @@ -423,38 +549,43 @@ test.describe('reconcile client adoption (rust server, real SPA)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-adopt-dead-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const argLogPath = path.join(sharedRoot, 'claude-argv.jsonl') - const fakeClaudePath = await installFakeCli( - FAKE_CLAUDE_CLI_SOURCE, - 'claude', + const fakeCodexPath = await installFakeCli( + FAKE_CODEX_CLI_SOURCE, + 'codex', path.join(sharedRoot, 'bin'), ) const { server, harness, info } = await bootAdoption(page, { - env: { CLAUDE_CMD: fakeClaudePath, FAKE_CLAUDE_ARGV_LOG: argLogPath }, - setupHome: seedClaudeHome(), + env: { CODEX_CMD: fakeCodexPath }, + setupHome: seedCodexAdoptionHome( + [ + { id: CODEX_DEAD_SESSION_A, title: CODEX_DEAD_TITLE_A }, + { id: CODEX_DEAD_SESSION_B, title: CODEX_DEAD_TITLE_B }, + ], + projectDir, + ), }) try { - // 1. Two fake-CLI claude panes, each with an on-disk fixture transcript - // (the durable pane ledger records the bindings, so 'ever observed' - // survives the restart); verify both live. + // 1. Two fake-CLI codex panes opened from the seeded sidebar history + // (the resume creates record durable pane-ledger bindings, so + // 'ever observed' survives the restart); verify both live. await selectShellIfPickerShowing(page) - const tabId = (await harness.getActiveTabId())! await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) - const paneA = await openClaudePaneAndGetLeaf(page, harness, tabId, projectDir, argLogPath) - await writeClaudeTranscript(info.homeDir, paneA.sessionId, projectDir) - const paneB = await openClaudePaneAndGetLeaf(page, harness, tabId, projectDir, argLogPath) - await writeClaudeTranscript(info.homeDir, paneB.sessionId, projectDir) - expect(paneB.sessionId).not.toBe(paneA.sessionId) - for (const pane of [paneA, paneB]) { - await expect - .poll(async () => (await findLeafById(harness, tabId, pane.leaf.id))?.content?.sessionRef?.sessionId ?? null, { - timeout: 20_000, - }) - .toBe(pane.sessionId) - } + const paneA = await openSeededCodexSession( + page, + harness, + CODEX_DEAD_TITLE_A, + CODEX_DEAD_SESSION_A, + ) + const paneB = await openSeededCodexSession( + page, + harness, + CODEX_DEAD_TITLE_B, + CODEX_DEAD_SESSION_B, + ) + expect(paneB.tabId).not.toBe(paneA.tabId) await expect .poll(async () => - (await listTerminals(info)).filter((t) => t.mode === 'claude' && t.status === 'running').length, + (await listTerminals(info)).filter((t) => t.mode === 'codex' && t.status === 'running').length, { timeout: 20_000 }) .toBe(2) await flushPersistence(page) @@ -463,9 +594,11 @@ test.describe('reconcile client adoption (rust server, real SPA)', () => { // 2. Stop + delete both session files + start on the SAME home/port/ // token (RustServer.restart() -- the isolated HOME is never touched - // in between, so the deletion below is exactly "gone while down"). - await fs.rm(claudeTranscriptPath(info.homeDir, paneA.sessionId)) - await fs.rm(claudeTranscriptPath(info.homeDir, paneB.sessionId)) + // in between, so the deletion below is exactly "gone while down"; + // the seed's dir-exists guard keeps setupHome from resurrecting + // the deleted fixtures on the restart boot). + await fs.rm(codexSessionPath(info.homeDir, CODEX_DEAD_SESSION_A)) + await fs.rm(codexSessionPath(info.homeDir, CODEX_DEAD_SESSION_B)) await server.restart() await waitForWsReady(page) @@ -482,14 +615,14 @@ test.describe('reconcile client adoption (rust server, real SPA)', () => { // Click "Start fresh here" on the FIRST row -> that pane becomes a // live terminal (same createRequestId -- the reducer preserves it; - // exactly ONE running claude PTY serves it). + // exactly ONE running codex PTY serves it). await dialog.getByRole('listitem').first().getByRole('button', { name: 'Start fresh here' }).click() // Exactly one of the two panes is now a live fresh terminal... await expect .poll(async () => { const contents = await Promise.all( - [paneA, paneB].map(async (p) => (await findLeafById(harness, tabId, p.leaf.id))?.content), + [paneA, paneB].map(async (p) => (await findLeafById(harness, p.tabId, p.leafId))?.content), ) const live = contents.filter( (c) => c?.status === 'running' && c?.terminalId && !c?.restoreError, @@ -497,11 +630,11 @@ test.describe('reconcile client adoption (rust server, real SPA)', () => { return live.length }, { timeout: 60_000 }) .toBe(1) - // ...backed by EXACTLY ONE running claude PTY server-side (one + // ...backed by EXACTLY ONE running codex PTY server-side (one // create for the pane's createRequestId, no duplicates). await expect .poll(async () => - (await listTerminals(info)).filter((t) => t.mode === 'claude' && t.status === 'running').length, + (await listTerminals(info)).filter((t) => t.mode === 'codex' && t.status === 'running').length, { timeout: 30_000 }) .toBe(1) From 3f776d5791d8a1e19190da59efbb473b8dd560d8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:18:37 -0700 Subject: [PATCH 06/14] wip(wall): ruler flip deferred to Task 8 -- claude --resume leg now green, newly-surfaced red at quiet-client alert assertion (PIN 1) Step 6 contingency (task-2 brief): the ruler still fails expectedly, but NOT on the pinned leg. The claude terminal \$2.2 --resume argv poll at restore-contract-wall-rust.spec.ts:1753 (the pin's only recorded red at baseline c1c67464) now passes under composition with the PIN 1 carve-out. The run proceeds past :1762 into sub-assertions that had never executed under composition and hits a newly-surfaced red; per the brief this takes the contingency path (re-diagnosis in Task 8 Step 2, never re-pin), so the test.fail pin at :1500-1503 stays in place for now. Failing leg error, verbatim (JSON reporter, expectedStatus=failed, status=expected, duration 37552ms): Error: expect(locator).toHaveCount(expected) failed Locator: getByRole('alert') Expected: 0 Received: 2 Timeout: 10000ms Call log: - Expect "toHaveCount" with timeout 10000ms - waiting for getByRole('alert') 14 x locator resolved to 2 elements - unexpected value "2" 1803 | // from the snapshot fetch racing pane creation -- see 1804 | // createFreshclaudePane's note above. > 1805 | await expect(page.getByRole('alert')).toHaveCount(0) | ^ 1806 | } finally { 1807 | await server.stop() 1808 | await fs.rm(sharedRoot, { recursive: true, force: true }) at test/e2e-browser/specs/restore-contract-wall-rust.spec.ts:1805:45 Page snapshot (error-context.md) shows two anonymous role=alert nodes with no text content at the end of the DOM. Repro: cargo build --release -p freshell-server npx playwright test --config test/e2e-browser/playwright.config.ts \ --project=rust-chromium restore-contract-wall-rust.spec.ts -g "THE RULER" From a19ad59e965e68b38e1e782fe235f9f2561c9a3a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:31:55 -0700 Subject: [PATCH 07/14] feat(reconcile): pending-marker read derives loud fresh_by_race, idempotent read-only (PIN 3 server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 join-key verification: the client's reconcile request builder includes the pane's current terminalId — src/lib/pane-reconcile.ts:127: ...(content.terminalId ? { terminalId: content.terminalId } : {}), Step 5 no-consumption confirmation (delete-at-derive falsified by load-bearing validation; read is idempotent/read-only): grep -n "delete_pending" crates/freshell-ws/src/*.rs pane_ledger.rs:760 (definition) pane_ledger_tests.rs:436,440 (ledger tests) terminal.rs:1338 (exit-hook path) terminal.rs:3792 (locator-resolution/GC path) NONE in handle_pane_reconcile — the derive path in reconcile.rs is the READ side's only touch point. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-ws/src/reconcile.rs | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/crates/freshell-ws/src/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index 877d751c3..402874c6f 100644 --- a/crates/freshell-ws/src/reconcile.rs +++ b/crates/freshell-ws/src/reconcile.rs @@ -289,6 +289,26 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi if pane.mode.as_deref() == Some("shell") { return base(pane, ReconcileVerdict::Fresh); } + // §4.2 pending-marker read (PIN 3): a durable pending marker keyed by + // the client's stale terminal id means identity establishment was in + // flight when the server died — fresh by RACE, not by intent. The + // reason is distinct and surfaced (client breadcrumb). The read is + // deliberately IDEMPOTENT and read-only (amends §6 decision 5's + // consumption clause): the client re-sends pane.reconcile on every + // WS ready because a response can be dropped (App.tsx:1029-1053) — + // consuming the marker at derive would turn that retry into a + // silent no_recoverable_identity. Markers are still never promoted; + // locator resolution or the ledger's GC deletes them, and a + // recreated pane's new terminal id makes the old marker + // unreachable. + if let Some(tid) = pane.terminal_id.as_deref() { + if deps.pane_ledger.pending_for_terminal(tid).is_some() { + return PaneVerdict { + reason: Some("fresh_by_race".to_string()), + ..base(pane, ReconcileVerdict::Fresh) + }; + } + } return PaneVerdict { reason: Some("no_recoverable_identity".to_string()), ..base(pane, ReconcileVerdict::Fresh) @@ -822,6 +842,67 @@ mod tests { assert_eq!(v.reason.as_deref(), Some("no_recoverable_identity")); } + /// PIN 3 red test (§4.2 pending-marker read): identity establishment was + /// in flight when the server died (durable pending marker, keyed by the + /// dead epoch's terminal id) -> the verdict is fresh, but LOUD: + /// fresh_by_race, never a silent no_recoverable_identity. + #[test] + fn pending_marker_yields_fresh_by_race_not_silent_fresh() { + let f = Fixture::new(); + f.ledger + .record_pending("T-race", "opencode", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-race"); + p.mode = Some("opencode".to_string()); + p.terminal_id = Some("T-race".to_string()); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason.as_deref(), Some("fresh_by_race")); + } + + /// Delivery-safety pin (validated design change): the marker read is + /// IDEMPOTENT and read-only. The client re-sends pane.reconcile on + /// every WS ready precisely because a response can be dropped + /// (App.tsx:1029-1053); consuming the marker at derive would turn that + /// retry into a silent no_recoverable_identity — losing the breadcrumb + /// to the exact churn PIN 3 eliminates. A retry must derive the same + /// loud verdict, and the marker must still exist afterwards. + #[test] + fn marker_read_is_idempotent_across_reconciles() { + let f = Fixture::new(); + f.ledger + .record_pending("T-race2", "opencode", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-race2"); + p.mode = Some("opencode".to_string()); + p.terminal_id = Some("T-race2".to_string()); + let first = f.one(p); + assert_eq!(first.reason.as_deref(), Some("fresh_by_race")); + let mut p2 = pane("cr-race2"); + p2.mode = Some("opencode".to_string()); + p2.terminal_id = Some("T-race2".to_string()); + let second = f.one(p2); + assert_eq!(second.verdict, ReconcileVerdict::Fresh); + assert_eq!(second.reason.as_deref(), Some("fresh_by_race")); + assert!(f.ledger.pending_for_terminal("T-race2").is_some()); + } + + /// Shell panes stay bare fresh even with a stray marker — the marker + /// read sits behind the shell early-return. + #[test] + fn shell_pane_ignores_pending_markers() { + let f = Fixture::new(); + f.ledger + .record_pending("T-sh", "shell", None, 1_000) + .expect("record pending"); + let mut p = pane("cr-sh"); + p.mode = Some("shell".to_string()); + p.terminal_id = Some("T-sh".to_string()); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason, None); + } + /// Row 10: malformed entries → invalid{reason}, never omission. #[test] fn row10_malformed_entries_yield_invalid_with_reasons() { From 1d056644b861b7a585d7b04429afa1f9ab445dae Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:39:14 -0700 Subject: [PATCH 08/14] feat(client): DOM-visible fresh_by_race breadcrumb for race-lost pane identity (PIN 3 client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/components/TerminalView.tsx | 22 +++++++++++++++++++ src/store/panesSlice.ts | 6 +++++ .../store/panesSlice.fresh-by-race.test.ts | 17 ++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 test/unit/client/store/panesSlice.fresh-by-race.test.ts diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 436ef74b0..4b1863465 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -17,6 +17,7 @@ import { clearPaneReconcileNotice, clearReconcilePendingPane, consumePaneRefreshRequest, + RECONCILE_NOTICE_FRESH_BY_RACE, repairCodexIdentityMismatch, resetPaneForReconcileCreate, splitPane, @@ -633,6 +634,15 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const [searchQuery, setSearchQuery] = useState('') const keyboardInsetPx = useKeyboardInset() const [mobileCtrlActive, setMobileCtrlActive] = useState(false) + // PIN 3: the fresh-by-race breadcrumb must be readable by the DOM text + // layer (assistive tech + the restart-contract wall's getByText probe) — + // the xterm canvas write below is invisible to both. + const [freshByRaceNotice, setFreshByRaceNotice] = useState<string | null>(null) + useEffect(() => { + if (!freshByRaceNotice) return + const t = setTimeout(() => setFreshByRaceNotice(null), 10_000) + return () => clearTimeout(t) + }, [freshByRaceNotice]) const setPendingLinkUriRef = useRef(setPendingLinkUri) const mobileCtrlActiveRef = useRef(false) @@ -4326,6 +4336,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const createdReconcileNotice = contentRef.current?.reconcileNotice if (createdReconcileNotice) { writeLocalXtermNotice(term, `\r\n${createdReconcileNotice}\r\n`) + if (createdReconcileNotice === RECONCILE_NOTICE_FRESH_BY_RACE) { + setFreshByRaceNotice(createdReconcileNotice) + } dispatch(clearPaneReconcileNotice({ tabId, paneId: paneIdRef.current })) } // Ledger A15: re-write a loss notice recorded while un-anchored -- @@ -5243,6 +5256,15 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) onTouchEnd={isMobile ? handleMobileTouchEnd : undefined} onTouchCancel={isMobile ? handleMobileTouchEnd : undefined} /> + {freshByRaceNotice ? ( + <div + role="status" + data-testid="fresh-by-race-notice" + className="pointer-events-none absolute inset-x-0 top-0 z-10 bg-amber-100/90 px-3 py-1 text-xs text-amber-900 dark:bg-amber-900/80 dark:text-amber-100" + > + {freshByRaceNotice} + </div> + ) : null} {isMobile && ( <div data-testid="mobile-terminal-toolbar" diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index d64a4cdb9..45f6dc9ba 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -600,7 +600,13 @@ function clearTerminalContentForRecreate( // Reconcile notice copy — exact strings reused by later tasks/tests. export const RECONCILE_NOTICE_CORRECTED = 'Session identity corrected by server — this pane now points at its live session.' export const RECONCILE_NOTICE_DUPLICATE = 'A duplicate terminal for this session was detected and ignored.' +// PIN 3 (§4.2 "fresh by race, not by intent"): the server restarted while +// this pane's session identity was still being established — loud, distinct, +// and phrased to match the restart-contract wall's breadcrumb probe. +export const RECONCILE_NOTICE_FRESH_BY_RACE = + "This pane couldn't be resumed — the server restarted before its session identity was captured. Started a fresh session." export function reconcileFreshNotice(reason: string): string { + if (reason === 'fresh_by_race') return RECONCILE_NOTICE_FRESH_BY_RACE return `Started fresh (${reason}).` } diff --git a/test/unit/client/store/panesSlice.fresh-by-race.test.ts b/test/unit/client/store/panesSlice.fresh-by-race.test.ts new file mode 100644 index 000000000..2aabe6133 --- /dev/null +++ b/test/unit/client/store/panesSlice.fresh-by-race.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { reconcileFreshNotice, RECONCILE_NOTICE_FRESH_BY_RACE } from '@/store/panesSlice' + +describe('reconcileFreshNotice', () => { + it('fresh_by_race renders the loud resumable-loss breadcrumb', () => { + expect(reconcileFreshNotice('fresh_by_race')).toBe(RECONCILE_NOTICE_FRESH_BY_RACE) + // The restart-contract-wall probes /couldn't be resumed|could not be + // resumed|fresh session/i — the breadcrumb must match it. + expect(RECONCILE_NOTICE_FRESH_BY_RACE).toMatch(/couldn't be resumed/i) + expect(RECONCILE_NOTICE_FRESH_BY_RACE).toMatch(/fresh session/i) + }) + it('other reasons keep the generic machine-coded notice', () => { + expect(reconcileFreshNotice('no_recoverable_identity')).toBe( + 'Started fresh (no_recoverable_identity).', + ) + }) +}) From 6705b6f37d2182c34ec1c4fe7083c2aedd57204b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:49:45 -0700 Subject: [PATCH 09/14] =?UTF-8?q?test(wall):=20flip=20SIGKILL-inside-locat?= =?UTF-8?q?or-window=20pin=20=E2=80=94=20fresh=5Fby=5Frace=20breadcrumb=20?= =?UTF-8?q?+=20re-armed=20locator=20re-capture=20(PIN=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1.10 pinning unit test verified landed before the flip: cargo test -p freshell-ws restore_created_pane_without_identity_arms_and_resolves_into_the_ledger -> test opencode_association::tests::restore_created_pane_without_identity_arms_and_resolves_into_the_ledger ... ok -> test codex_association::tests::restore_created_pane_without_identity_arms_and_resolves_into_the_ledger ... ok Flip proven first: the pinned leg failed with 'Expected to fail, but passed.' (breadcrumb now matches the wall probe regex in the DOM). Pin deleted, comment updated to the live contract, and a P1.10 end-to-end re-capture assertion added: open the row gate post-restart, submit, and the re-armed locator must resolve a ses_ identity into the leaf's sessionRef. Flipped leg green twice sequentially. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../specs/restore-contract-wall-rust.spec.ts | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 545d94d05..50482fd1b 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1935,21 +1935,19 @@ test.describe('Restore Contract Wall (P0.1)', () => { e2eServerKind, }) => { expect(e2eServerKind).toBe('rust') - // EXPECTED-FAIL WALL PIN -- P1.8 (§2.4/§4.2 pending markers): killing the - // server inside the opencode locator's ~2s correlation window loses the - // minted identity permanently, and the pane restores SILENTLY FRESH -- - // no resume, no breadcrumb. FLIP when ledger pending markers land - // (fresh-by-race must be visible) or the identity is captured in time. - // DETERMINISM: the fake's session-row write is held behind - // FAKE_OPENCODE_TERMINAL_ROW_GATE_PATH and this test NEVER creates the - // gate file before the kill, so the identity provably cannot land - // pre-kill. Without the gate, the 150ms locator sweep (main.rs:1112) - // can beat the SIGKILL a few percent of runs -> unexpected PASS of this - // pin -> hard suite failure. - test.fail( - e2eServerKind === 'rust', - 'P1.8 (§2.4): SIGKILL inside locator window yields silent fresh, no breadcrumb', - ) + // P1.8 (§2.4/§4.2 pending markers) LANDED -- pin flipped: killing the + // server inside the opencode locator's ~2s correlation window is no + // longer silently fresh. The server derives a loud Fresh{fresh_by_race} + // verdict from the pending marker that survives the restart (keyed by + // the client's stale terminalId), and the client renders a DOM-visible + // breadcrumb (data-testid="fresh-by-race-notice") matching the probe + // regex below. + // DETERMINISM (pre-kill phase): the fake's session-row write is held + // behind FAKE_OPENCODE_TERMINAL_ROW_GATE_PATH and this test NEVER + // creates the gate file before the kill, so the identity provably + // cannot land pre-kill -- the race loss is guaranteed, not a few-percent + // 150ms-sweep coin flip. The gate is opened only AFTER restart, to + // prove the re-armed locator (P1.10) captures identity end to end. const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-wall-locwin-')) const argLogPath = path.join(sharedRoot, 'opencode-argv.jsonl') // Deliberately never created -- see the DETERMINISM note above. @@ -2004,6 +2002,22 @@ test.describe('Restore Contract Wall (P0.1)', () => { .isVisible() .catch(() => false) expect(resumed || breadcrumbVisible).toBe(true) + + // P1.10 end-to-end (landed; pinned unit-side by opencode_association.rs + // restore_created_pane_without_identity_arms_and_resolves_into_the_ + // ledger): the restore-created pane lacks identity, so the locator + // re-armed at restore-create. Open the fake's row gate NOW and submit — + // the re-armed locator must capture a ses_ identity post-restart. + await fs.writeFile(rowGatePath, '') + await page.locator('.xterm').last().click() + await page.keyboard.type('hello again after restart') + await page.keyboard.press('Enter') + await expect + .poll(async () => { + const l = await findLeafById(harness, tabId, leaf.id) + return l?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }) + .toMatch(/^ses_/) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }) From c12e7d71e254a96e748d172109744461b8f0deb6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:21:53 -0700 Subject: [PATCH 10/14] =?UTF-8?q?fix(ws):=20claude=20binding=20row=20durab?= =?UTF-8?q?le=20BEFORE=20PTY=20spawn=20=E2=80=94=20durability=20precedes?= =?UTF-8?q?=20argv=20observability=20(PIN=202=20server)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 pin-red confirmation: the SIGKILL-within-5s leg still fails as expected (JSON reporter: expectedStatus=failed, status=failed; the summary's '1 passed' is the test.fail accounting). Step 1 instrumentation output (single instrumented run, reverted): LEDGER BINDINGS AT KILL: [ 'claude' ] A binding file was present at kill: the write-vs-SIGKILL race did NOT materialize on this run — Gap B (no recovery surface consuming the row, Task 7) was the observed blocker. The pre-spawn write still lands: the window is real at the code level (argv observable at the PTY spawn, durability previously only ~230 lines later), and the leg's contract is 'kill immediately after argv' — without tightening, green would be timing-luck. Note (2) answer — should claude also get a pending marker (MARKER_MODES)? No. MARKER_MODES correctly still excludes claude: claude has create-time identity (the preallocation) and no post-spawn resolver, so a marker for it could never resolve. This pre-spawn binding row IS its durability story — strictly stronger than a marker. Step 4b (validated gap): a pre-spawn row for a spawn-FAILED fresh claude preallocation would surface as a ghost ledgerOnly recovery offer for ~30 days. The spawn-failure branch now deletes the just-written row (new PaneLedger::delete_binding, the atomic mirror of delete_pending) for fresh preallocations only — resume-creates keep their row (prior epoch, must stay recoverable). Covered by deleted_binding_row_is_gone_for_recovery_readers. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-ws/src/pane_ledger.rs | 26 ++++++++ crates/freshell-ws/src/pane_ledger_tests.rs | 37 +++++++++++ crates/freshell-ws/src/terminal.rs | 66 +++++++++++++++++++ .../src/terminal_create_ordering_tests.rs | 20 ++++++ 4 files changed, 149 insertions(+) create mode 100644 crates/freshell-ws/src/terminal_create_ordering_tests.rs diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 2bd15fcd8..b47191558 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -586,6 +586,32 @@ impl PaneLedger { self.write_binding(root, &mut index, &row) } + /// Hard-delete one binding row (file first, then index — the mirror of + /// [`Self::delete_pending`]'s atomic delete; missing file == already + /// gone). PIN 2 (Step 4b): the ONLY caller is the spawn-failure branch + /// of a FRESH claude preallocation — its pre-spawn row describes a pane + /// that never existed, and left in place it would surface as a ghost + /// `ledgerOnly` recovery offer for ~30 days. Never used for resume + /// creates: their row belongs to the prior epoch and must stay + /// recoverable. + pub fn delete_binding(&self, provider: &str, session_id: &str) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + let result = match std::fs::remove_file(Self::binding_path(root, provider, session_id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + }; + if result.is_ok() { + index + .bindings + .remove(&(provider.to_string(), session_id.to_string())); + } + result + } + /// Raw single-row read from the index (no chain following — that is /// `lookup_by_session`, Task 2). Memory-only (V1.md read policy). pub fn load_binding(&self, provider: &str, session_id: &str) -> Option<BindingRow> { diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 4ed315dac..6f2330eb0 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -442,6 +442,43 @@ fn delete_pending_is_a_noop_when_missing() { std::fs::remove_dir_all(&root).ok(); } +#[test] +fn deleted_binding_row_is_gone_for_recovery_readers() { + // PIN 2 (Step 4b): a pre-spawn claude binding whose spawn then FAILED is + // deleted so it can never surface as a ghost `ledgerOnly` recovery offer. + // `list_bindings` is THE reader that feeds the recovery inventory + // (`recovery_inventory.rs` build_inventory), so "gone" is judged there. + let root = temp_root("del-binding"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-failed", "t1", 1_000)) + .expect("write ok"); + assert!(ledger + .list_bindings() + .iter() + .any(|r| r.session_id == "sess-failed")); + + ledger + .delete_binding("claude", "sess-failed") + .expect("delete ok"); + + // Gone for the recovery-inventory reader, the raw read, AND on disk + // (a construction-time rescan must not resurrect it). + assert!(!ledger + .list_bindings() + .iter() + .any(|r| r.session_id == "sess-failed")); + assert!(ledger.load_binding("claude", "sess-failed").is_none()); + let gen2 = PaneLedger::new(Some(root.clone())); + assert!(gen2.load_binding("claude", "sess-failed").is_none()); + + // Idempotent: deleting a missing row is Ok (mirror of delete_pending). + ledger + .delete_binding("claude", "sess-failed") + .expect("missing row is Ok"); + std::fs::remove_dir_all(&root).ok(); +} + fn never_absent(_p: &str, _s: &str) -> bool { false } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 380083df1..73f047cf3 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -68,6 +68,10 @@ use freshell_terminal::{build_child_env_from_process, FrameSink}; use crate::WsState; +#[cfg(test)] +#[path = "terminal_create_ordering_tests.rs"] +mod terminal_create_ordering_tests; + /// The write half of a split axum WebSocket. pub(crate) type WsSink = SplitSink<WebSocket, Message>; @@ -1616,6 +1620,11 @@ pub(crate) async fn handle_create( // ALWAYS gets a server-preallocated `--session-id` (`ws:2048-2064`). let mut launch_intent = LaunchIntent::Resume; let mut resume_session_id: Option<String> = None; + // PIN 2 (Step 4b): whether THIS create minted a fresh claude identity. + // Only such a create may delete its pre-spawn binding row on spawn + // failure — a resume-create's row belongs to the prior epoch and must + // stay recoverable. + let mut claude_fresh_prealloc = false; if mode != "shell" { let requested_ref = create.session_ref.as_ref().filter(|r| r.provider == mode); let should_preallocate_fresh_claude = mode == "claude" @@ -1648,6 +1657,7 @@ pub(crate) async fn handle_create( // handler does not have. resume_session_id = Some(Uuid::new_v4().to_string()); launch_intent = LaunchIntent::Start; + claude_fresh_prealloc = true; } else if should_preallocate_fresh_amplifier { resume_session_id = Some(Uuid::new_v4().to_string()); } else { @@ -2181,6 +2191,41 @@ pub(crate) async fn handle_create( // acquire happens here: for restore it would self-deadlock against that // outer permit; for non-restore it is bypassed on purpose. + // PIN2_CLAUDE_PRE_SPAWN_BINDING — P1.9 (D3) durability-before- + // observability: a fresh claude create preallocates its --session-id + // (:1649) and the spawn below makes that id OBSERVABLE (argv, logged + // synchronously by the e2e fakes). A SIGKILL landing right after spawn + // must still find a durable ledger row, or the recovery inventory has + // nothing to offer after browser loss. The post-spawn binding write + // (:2420 arm) re-records the same (provider, session_id) key with the + // resolved cwd — a benign re-write. Failure policy identical to that + // arm: never blocks the create, surfaced LIVE. + if mode == "claude" { + if let Some(session_id) = resume_session_id.as_deref() { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let write_session_id = session_id.to_string(); + let write_terminal_id = terminal_id.clone(); + let write_mode = mode.clone(); + let write_cwd = spec.cwd.clone(); + let write_request_id = create.request_id.clone(); + let now = now_ms(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_binding(&crate::pane_ledger::BindingWrite { + provider: "claude", + session_id: &write_session_id, + terminal_id: &write_terminal_id, + mode: &write_mode, + cwd: write_cwd.as_deref(), + create_request_id: Some(&write_request_id), + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + crate::pane_ledger::surface_write_failure(state, &terminal_id, result); + } + } + // The PTY spawn is synchronous; run it on the blocking pool so hung/slow // spawns occupy a blocking thread (plus, on the gated restore path, the // caller-held permit), never an async worker (on small hosts, N inline @@ -2192,6 +2237,7 @@ pub(crate) async fn handle_create( let spawn_mode = mode.clone(); let spawn_resume_session_id = resume_session_id.clone(); let spawn_create_request_id = create.request_id.clone(); + // PIN2_PTY_SPAWN_ANCHOR: the spawn makes preallocated identity observable. let create_result = match tokio::task::spawn_blocking(move || { registry.create( &spawn_spec, @@ -2216,6 +2262,26 @@ pub(crate) async fn handle_create( ))), }; if let Err(err) = create_result { + // PIN 2 (Step 4b): the spawn FAILED, so the pre-spawn claude binding + // row (PIN2_CLAUDE_PRE_SPAWN_BINDING above) now describes a pane + // that never existed — left in place it would surface as a ghost + // `ledgerOnly` recovery offer for ~30 days. Delete it, but ONLY for + // a fresh preallocation (this create minted the id, so the row is + // exclusively ours); a resume-create's row belongs to the prior + // epoch and must stay recoverable. Same failure policy as the + // write: never blocks this (already failing) create, surfaced LIVE. + if claude_fresh_prealloc { + if let Some(session_id) = resume_session_id.as_deref() { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let delete_session_id = session_id.to_string(); + let result = tokio::task::spawn_blocking(move || { + ledger.delete_binding("claude", &delete_session_id) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + crate::pane_ledger::surface_write_failure(state, &terminal_id, result); + } + } // Task 7's race-free duplicate-live-resume enforcement inside // registry.create (F5/V7): the pre-check above is a friendly fast // path only — concurrent WS/REST creates can both pass it. Map the diff --git a/crates/freshell-ws/src/terminal_create_ordering_tests.rs b/crates/freshell-ws/src/terminal_create_ordering_tests.rs new file mode 100644 index 000000000..37ab0140a --- /dev/null +++ b/crates/freshell-ws/src/terminal_create_ordering_tests.rs @@ -0,0 +1,20 @@ +//! P1.9 (D3) source-order pin: claude's preallocated identity becomes +//! OBSERVABLE at PTY spawn (`--session-id` in argv, logged synchronously by +//! the e2e fakes), so its durable ledger write must PRECEDE the spawn. +//! Reordering these reopens the SIGKILL-within-5s recovery hole +//! (restore-contract-wall `SIGKILL-within-5s-of-pane-creation`). + +#[test] +fn claude_binding_write_precedes_pty_spawn_in_handle_create() { + let src = include_str!("terminal.rs"); + let write = src.find("PIN2_CLAUDE_PRE_SPAWN_BINDING").expect( + "pre-spawn claude binding block (PIN2_CLAUDE_PRE_SPAWN_BINDING) missing from terminal.rs", + ); + let spawn = src + .find("PIN2_PTY_SPAWN_ANCHOR") + .expect("PTY spawn anchor (PIN2_PTY_SPAWN_ANCHOR) missing from terminal.rs"); + assert!( + write < spawn, + "claude durable binding write must stay BEFORE the PTY spawn: durability precedes observability" + ); +} From 6bb0173ffd644412bc572e4712ce53228bb01aef Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:31:11 -0700 Subject: [PATCH 11/14] =?UTF-8?q?test(wall):=20flip=20SIGKILL-within-5s=20?= =?UTF-8?q?pin=20=E2=80=94=20durable=20pre-spawn=20binding=20+=20correct?= =?UTF-8?q?=20recovery-offer=20probe=20(PIN=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/restore-contract-wall-rust.spec.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 50482fd1b..610c422bb 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1818,14 +1818,13 @@ test.describe('Restore Contract Wall (P0.1)', () => { e2eServerKind, }) => { expect(e2eServerKind).toBe('rust') - // EXPECTED-FAIL WALL PIN -- P1.8+P1.9 (D3, §4.2): no server-side durable - // pane-identity record exists; with localStorage gone the binding is lost - // even though the pre-allocated claude session id was server-minted. - // FLIP when the pane-identity ledger + "recover my panes" surface land. - test.fail( - e2eServerKind === 'rust', - 'P1.8+P1.9 (D3): pane created <5s before SIGKILL is unrecoverable after browser loss', - ) + // P1.8+P1.9 (D3, §4.2) LANDED -- pin flipped: the claude binding row is + // written durably to the pane-identity ledger BEFORE the PTY spawn, so a + // SIGKILL moments after spawn (before any snapshot cadence) still leaves + // a recoverable row. After browser-state loss the recovery inventory + // reports it (recoverable: true) and the "recover my panes" offer + // (data-testid="recovery-offer-panel") surfaces it -- the poll below + // accepts either an auto-restored pane or the visible offer. const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-wall-5s-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) @@ -1917,8 +1916,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { if (hit) return true } const recoverOffer = await page - .getByText(/recover .*pane/i) - .first() + .getByTestId('recovery-offer-panel') .isVisible() .catch(() => false) return recoverOffer From 8923b7e39a3635cbb9e4bea3afacaa9bbe8adece Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:43:18 -0700 Subject: [PATCH 12/14] fix(wall): quiet-client assertion excludes monaco's structural aria alerts (PIN 1 diagnosis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8 Step 2 diagnosis of the ruler's newly-surfaced red (recorded in 3f776d57): getByRole('alert') at :1805 expected 0, got 2. The two alerts are NOT product alerts and NOT a side effect of the claude respawn path. They are monaco-editor's permanent accessibility scaffold: setARIAContainer (node_modules/monaco-editor/esm/vs/base/browser/ui/aria/aria.js) appends a monaco-aria-container with exactly TWO empty role="alert" divs (.monaco-alert / alertContainer + alertContainer2) the moment the first editor mounts. The ruler composition includes an editor pane (pane-ruler-editor, spec :1590), so a bare getByRole('alert') count is structurally >=2 regardless of restart behavior. The assertion's donor (restore-sync05.spec.ts) has no editor pane, which is why it passes there. This red was latent: the ruler previously died at the claude --resume argv poll (:1753) before :1805 ever executed under composition; the PIN 1 carve-out let the run proceed and exposed it. Fix: count [role="alert"]:not(.monaco-alert) instead. This is a false- positive correction, not a weakening: every product alert (Pane error banner, TerminalExitBanner, fresh-agent banners, ConnectionErrorOverlay, DirectoryPicker error, error-boundary) lacks .monaco-alert and is still counted; the excluded nodes are empty screen-reader announcement slots invisible to users. Evidence: error-context.md from the expected-fail run shows the two alerts as anonymous empty nodes in a trailing container outside the app tree, matching monaco-aria-container exactly. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../specs/restore-contract-wall-rust.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 610c422bb..a9c2a4053 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1802,7 +1802,17 @@ test.describe('Restore Contract Wall (P0.1)', () => { // known benign alert source is the transient history-load-error banner // from the snapshot fetch racing pane creation -- see // createFreshclaudePane's note above.) - await expect(page.getByRole('alert')).toHaveCount(0) + // Monaco's aria scaffold is excluded: setARIAContainer (monaco-editor + // esm/vs/base/browser/ui/aria/aria.js) permanently mounts exactly two + // EMPTY `role="alert"` divs (.monaco-alert) the moment the editor pane + // loads -- screen-reader announcement slots, not user-facing alerts. + // Unlike restore-sync05 (this assertion's donor), THIS composition has + // an editor pane (pane-ruler-editor above), so a bare getByRole('alert') + // count is structurally >=2 here regardless of restart behavior. Every + // product alert (Pane error banner, TerminalExitBanner, fresh-agent + // banners, ConnectionErrorOverlay, ...) lacks .monaco-alert and is + // still counted. + await expect(page.locator('[role="alert"]:not(.monaco-alert)')).toHaveCount(0) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }) From 7f05c104f50703829b64d278eb7664b0ff2b1e4f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:47:42 -0700 Subject: [PATCH 13/14] =?UTF-8?q?test(wall):=20flip=20the=20composed-ruler?= =?UTF-8?q?=20pin=20=E2=80=94=20claude=20never-conversed=20leg=20now=20gre?= =?UTF-8?q?en=20(PIN=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the P0.1 test.fail pin (spec :1500-1503) and trims the pin's explanatory comment to a short "ruler is live" note. The composed all-pane ruler now passes genuinely: - The pinned red (claude terminal §2.2 --resume argv poll under composition) closed with the PIN 1 never-conversed carve-out (e970d9cc) — verified green under composition in the deferred-flip run recorded in 3f776d57. - The newly-surfaced red past :1762 (quiet-client alert count) was diagnosed as monaco-editor's structural aria scaffold, not a product alert and not a branch side effect; corrected in 8923b7e3. Flip evidence (Task 8 Step 2): with the pin still in place the ruler reported "Expected to fail, but passed" (JSON stats unexpected:1); after deletion, two consecutive un-pinned runs green (28.1s and 28.0s, JSON stats expected:1 unexpected:0 each). Zero test.fail / test.fixme markers remain anywhere in the wall spec. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../specs/restore-contract-wall-rust.spec.ts | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index a9c2a4053..ed92a8299 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1470,37 +1470,18 @@ test.describe('Restore Contract Wall (P0.1)', () => { // tabs), and a 300 s budget recreates the same sum-of-gates > timeout // defect the f3wp double-restart fix (:2068-2076) removed at 180 s. // 600 s covers the worst case with margin, matching that sibling. - // NOTE the test.fail pin below: on a load-starved run the 300 s TEST - // timeout fired BEFORE the pin's expected in-test red was reached, and - // a test-level timeout does not satisfy the pin -- so the underfunded - // budget reds the whole run despite the expected-fail marking. + // NOTE (historical): while this test carried a test.fail pin, a + // load-starved run's 300 s TEST timeout fired BEFORE the pin's expected + // in-test red was reached, and a test-level timeout does not satisfy a + // pin -- so the underfunded budget reds the whole run. The generous + // budget stays. test.setTimeout(600_000) - // EXPECTED-FAIL WALL PIN -- P0.1: this is the composed ruler; it flips - // green only when every per-pane contract above is green un-pinned. - // HISTORY: the first observed red (run of 2026-07-24) was the freshclaude - // identity poll -- that P0.2 client identity-persistence gap has since - // CLOSED (#562: sessionRef persists + sessionRef-first reader; pinned by - // Contract G above and specs/freshclaude-identity-persistence-rust - // .spec.ts), so it is NO LONGER this pin's reason. - // CURRENT OBSERVED RED (verified pre-existing at the PR #562/#563 - // council close-out): the composed ruler fails at the CLAUDE TERMINAL - // \u00a72.2 leg -- the post-restart `--resume <claudePreallocatedId>` argv - // poll below never goes green UNDER COMPOSITION, even though the same - // contract passes in its standalone per-pane test. - // The hidden-tab legs (F8/P1.11) PASSED in this composition: the - // dead-terminal census DID reach hidden tabs' layouts -- the plan flagged - // this ordering as runtime-dependent and verdict-neutral (both candidates - // are post-restart contract assertions). The freshcodex/freshopencode - // identity polls pass VACUOUSLY from persisted state (they measure - // persistence, not restore). - // FLIP DISCIPLINE (council, PR #562/#563 close-out): leg G's identity - // assertions alone are NOT liveness proof -- its post-attach turn-send is. - // Flip only when the composed ruler GENUINELY passes 3x consecutively; - // delete this pin when the last per-pane pin is retired. - test.fail( - e2eServerKind === 'rust', - 'P0.1: composed all-pane ruler; red until remaining P1.x land -- current observed red: the claude terminal §2.2 --resume argv leg under composition (the former P0.2 freshclaude identity gap closed in #562)', - ) + // THE RULER IS LIVE (P0.1, last wall pin retired): the composed all-pane + // ruler runs un-pinned. Its final two reds closed as (1) the claude + // never-conversed carve-out (reconcile derives Respawn; the post-restart + // --resume argv leg passes under composition) and (2) the quiet-client + // alert count excluding monaco's structural aria scaffold -- see the + // assertion note at the end of this test. const CODEX_SESSION_ID = '99999999-8888-4777-8666-555555555555' const SESSION_TITLE = 'ruler codex session' From eaa25b7df7fc330e77470e4b73a8ea2d4de3509b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:50:42 -0700 Subject: [PATCH 14/14] fix(wall): scope PIN 2 pre-spawn binding write to fresh claude preallocs only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-eyes review found the pre-spawn ledger binding write (PIN 2) was gated on `mode == "claude" && resume_session_id.is_some()`, so it fired for EVERY claude create — resume/restore creates included — while the compensating spawn-failure delete was (correctly) restricted to claude_fresh_prealloc. That asymmetry let a failed or race-losing RESUME create durably rewrite a binding row it does not own (live_terminal_id -> never-spawned terminal, create_request_id -> the failing create, last_observed_at bumped). In the duplicate-live-resume race (the AlreadyExists arm), a loser's write landing after the winner's post-spawn write would leave the durable ledger pointing the live session at a dead terminal id — the D8 ghost-recovery / duplicate-writer shape. - terminal.rs: gate the PIN2_CLAUDE_PRE_SPAWN_BINDING write on claude_fresh_prealloc (the freshly minted UUID is provably exclusive); comment rewritten to document the scoping and the race-loser hazard. Failure-branch delete unchanged. Source-order pin tokens unchanged. - tests/pane_ledger_triggers.rs: new behavioral test failed_claude_resume_create_leaves_prior_binding_row_untouched — seeds a prior-epoch binding row, issues a claude resume create whose PTY spawn fails, asserts PTY_SPAWN_FAILED and the row is byte-for-byte untouched (index + fresh on-disk reader). RED verified pre-fix (row rewritten exactly as the reviewer predicted), GREEN post-fix. Verified: cargo test -p freshell-ws --lib (312 passed, incl. the create-ordering pin), --test pane_ledger_triggers (5 passed), clippy --all-targets clean, fmt clean. --- crates/freshell-ws/src/terminal.rs | 18 +++-- .../freshell-ws/tests/pane_ledger_triggers.rs | 72 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 73f047cf3..d41e24ed0 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -2196,11 +2196,19 @@ pub(crate) async fn handle_create( // (:1649) and the spawn below makes that id OBSERVABLE (argv, logged // synchronously by the e2e fakes). A SIGKILL landing right after spawn // must still find a durable ledger row, or the recovery inventory has - // nothing to offer after browser loss. The post-spawn binding write - // (:2420 arm) re-records the same (provider, session_id) key with the - // resolved cwd — a benign re-write. Failure policy identical to that - // arm: never blocks the create, surfaced LIVE. - if mode == "claude" { + // nothing to offer after browser loss. Scoped to the fresh + // preallocation ONLY (`claude_fresh_prealloc`: this create minted the + // UUID, so the row is provably exclusive) — a resume/restore create's + // row belongs to the prior epoch and is already durable; writing it + // here, BEFORE any evidence the spawn succeeds, would let a failing or + // race-losing resume create rewrite live_terminal_id/create_request_id + // to a terminal that never spawns (and the failure-branch delete below + // deliberately never touches non-fresh rows). The post-spawn binding + // write (the `create_meta_record` arm below) re-records the same + // (provider, session_id) key with the resolved cwd — a benign re-write + // — and stays the ONLY writer for resume creates. Failure policy + // identical to that arm: never blocks the create, surfaced LIVE. + if claude_fresh_prealloc { if let Some(session_id) = resume_session_id.as_deref() { let ledger = std::sync::Arc::clone(&state.pane_ledger); let write_session_id = session_id.to_string(); diff --git a/crates/freshell-ws/tests/pane_ledger_triggers.rs b/crates/freshell-ws/tests/pane_ledger_triggers.rs index 61a88a2a9..765447648 100644 --- a/crates/freshell-ws/tests/pane_ledger_triggers.rs +++ b/crates/freshell-ws/tests/pane_ledger_triggers.rs @@ -114,6 +114,78 @@ async fn claude_preallocation_writes_a_binding_row_synchronously() { std::fs::remove_dir_all(&dir).ok(); } +#[tokio::test(flavor = "multi_thread")] +async fn failed_claude_resume_create_leaves_prior_binding_row_untouched() { + // PIN 2 asymmetry guard: the pre-spawn binding write is scoped to the + // FRESH preallocation only (`claude_fresh_prealloc`). A claude RESUME + // create whose spawn fails (or loses the duplicate-live race) must NOT + // rewrite the prior epoch's binding row — pre-spawn there is no evidence + // the spawn will succeed, and a rewrite would point the durable ledger + // at a never-spawned terminal (ghost `ledgerOnly` recovery offer, + // defeating the `pending_for_terminal` reader rule). + let dir = unique_ledger_dir("claude-resume-fail"); + // A claude spec whose binary does not exist: the PTY spawn fails with + // NotFound BEFORE any fork (pty.rs resolve contract) — exactly the + // failing resume create the asymmetry is about. + let mut broken_claude = sleeper_cli_spec("claude"); + broken_claude.default_cmd = "freshell-test-no-such-claude-binary".to_string(); + let (url, _registry, server_ledger) = spawn_server_with_ledger(vec![broken_claude], &dir).await; + + // Prior epoch: session S is already bound (e.g. written by the epoch + // that originally owned it). Seed through the SERVER'S Arc so its + // write-through index sees the row. + let session_id = "11111111-2222-4333-8444-555555555555"; + let seeded_at = 1_111; + server_ledger + .record_binding(&freshell_ws::pane_ledger::BindingWrite { + provider: "claude", + session_id, + terminal_id: "term-prior-epoch", + mode: "claude", + cwd: Some("/prior/cwd"), + create_request_id: Some("req-prior-epoch"), + now_ms: seeded_at, + }) + .expect("seed prior-epoch binding row"); + let seeded = server_ledger + .load_binding("claude", session_id) + .expect("seeded row present"); + + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-resume-loser", + "mode": "claude", + "shell": "system", + "resumeSessionId": session_id, + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(WsMessage::Text(create.to_string())).await.unwrap(); + let err = next_frame_of_type(&mut ws, "error").await; + assert_eq!(err["code"], "PTY_SPAWN_FAILED"); + assert_eq!(err["requestId"], "req-resume-loser"); + + // The prior epoch's row is byte-for-byte untouched: no live_terminal_id + // / create_request_id rewrite, no last_observed_at bump. + let after = server_ledger + .load_binding("claude", session_id) + .expect("row still present after failed resume create"); + assert_eq!( + after, seeded, + "a failed claude RESUME create must not mutate the binding row" + ); + // And a fresh on-disk reader agrees (durability, not just index state). + let reread = PaneLedger::new(Some(dir.clone())); + let disk = reread + .load_binding("claude", session_id) + .expect("row on disk"); + assert_eq!(disk.live_terminal_id.as_deref(), Some("term-prior-epoch")); + assert_eq!(disk.create_request_id.as_deref(), Some("req-prior-epoch")); + assert_eq!(disk.last_observed_at, seeded_at); + + std::fs::remove_dir_all(&dir).ok(); +} + #[tokio::test(flavor = "multi_thread")] async fn fresh_identity_bearing_pane_gets_a_pending_marker_at_spawn() { // Trigger (d): identity in flight (fresh codex — no resume id) ->