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 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 { 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/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index 17110efd3..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) @@ -339,6 +359,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 +719,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 @@ -722,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() { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 380083df1..d41e24ed0 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; @@ -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 = 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,49 @@ 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. 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(); + 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 +2245,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 +2270,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" + ); +} 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) -> 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..8cd628f17 --- /dev/null +++ b/docs/plans/2026-07-29-wall-pins-closure.md @@ -0,0 +1,1012 @@ +# 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. **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** + +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 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")); + } +``` + +- [ ] **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: 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) + }; + } +``` + +- [ ] **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 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: + +```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.) + +(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** + +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`, 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; `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<()>` (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. + +- [ ] **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")); + } + + /// 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); + } +``` + +- [ ] **Step 3: Run to verify RED** + +Run: `cargo test -p freshell-ws --lib reconcile` +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** + +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 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) + }; + }; +``` + +- [ ] **Step 5: Confirm no consumption wiring exists or is needed (delivery-safety design)** + +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: + +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** + +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 +git commit -m "feat(reconcile): pending-marker read derives loud fresh_by_race, idempotent read-only (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 — 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: + +```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 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** + +```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 — 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** + +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.) + +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 +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 +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 — 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** + +```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, 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. + +**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; `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. diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 353cb5f4a..b2a6f2a16 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/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) 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..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' @@ -1802,7 +1783,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 }) @@ -1818,14 +1809,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 +1907,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 @@ -1935,21 +1924,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 +1991,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 }) 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).', + ) + }) +})