From b5bd866a79dcf93d6a6bbb77f081661eeacce6bf Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:51:05 -0700 Subject: [PATCH 01/35] docs: add implementation plan for resume-validation --- docs/plans/2026-07-29-resume-validation.md | 1530 ++++++++++++++++++++ 1 file changed, 1530 insertions(+) create mode 100644 docs/plans/2026-07-29-resume-validation.md diff --git a/docs/plans/2026-07-29-resume-validation.md b/docs/plans/2026-07-29-resume-validation.md new file mode 100644 index 000000000..4653acf12 --- /dev/null +++ b/docs/plans/2026-07-29-resume-validation.md @@ -0,0 +1,1530 @@ +# Resume Validation 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:** Before the Rust server constructs a coding-CLI resume command from a cached session id, validate that the session actually exists in the provider's on-disk store; when it is definitively absent, spawn the CLI fresh in the same cwd/mode, surface an operator-visible notice naming the stale id, and retire the stale ledger binding so it is not retried forever. + +**Architecture:** A pure resume-gate policy function lives in `freshell-platform` (below both consumers). The existing tri-state disk-existence probe (`SessionExistenceProbe` / `IndexExistenceProbe`) gains by-id fallbacks for amplifier and codex so "Absent" is a trustworthy POSITIVE absence, then the gate is wired into the three spawn doors that call `resolve_coding_cli_command()`: WS `terminal.create` (restore path), headless auto-resume respawn, and the freshagent REST create pipeline. Validation only ever converts a resume into a fresh spawn on positive absence; `Unknown`/`ProviderUnavailable` always fail open (today's behavior preserved). + +**Tech Stack:** Rust (axum/tokio workspace under `crates/`), serde-based WS protocol in `crates/freshell-protocol`, React/TypeScript client in `src/`, Vitest for client tests, `cargo test` for Rust. + +## Global Constraints + +- Work only inside the worktree `/home/dan/code/freshell/.worktrees/resume-validation` on branch `feat/resume-validation` (branched from `origin/main` at `ca1a60d3`). +- Do NOT open a PR, do NOT merge, do NOT restart or deploy any server. The live Rust server on port 3002 must not be touched. +- Red-Green-Refactor TDD for every task (root `AGENTS.md`): write the failing test first, watch it fail, make it pass, refactor. Never skip the refactor. +- Structural limits (`port/AGENTS.md`): ≤1K lines per file. New logic goes in new focused files, not appended to already-huge ones. +- Fail-open invariant (the spec's rule 3): validation must NEVER turn a working resume into a non-resume. Only a POSITIVE "store is readable and the session is definitively absent" may block a resume. `Unknown` and `ProviderUnavailable` always proceed unchanged. +- Providers validated: `claude`, `codex`, `opencode`, `amplifier` (the four the existence probe knows). `gemini`, `kimi`, and any unknown provider are never blocked (they are outside the probe's `KNOWN_PROVIDERS`, whose contract maps unknown providers to `Absent` — the gate must therefore check its own provider list BEFORE consulting the probe). +- Node server (`server/`) is deliberately NOT changed: root `AGENTS.md` states the live production server is the Rust server on port 3002 and the Node server is legacy; the validation substrate (the tri-state `IndexExistenceProbe`, opencode by-id sqlite check, claude `CLAUDE_CONFIG_DIR`-aware locator) exists only in Rust, so the port is not cheap or parallel. This is an explicit scope decision, not an oversight. +- Rust tests: plain `cargo test -p ` (no coordinator gate for Rust). Client tests: `npm run test:unit` (coordinator-gated; wait for the gate, never kill a foreign holder). Raw `npx vitest` is not a coordinated workflow. +- Known pre-existing failure on main, unrelated: `terminal-font-settings.test.tsx` — note it if it appears in suite runs, do not chase it. +- `port/AGENTS.md` mandates a `port/oracle/DEVIATIONS.md` entry for deliberate behavior changes vs the Node original — this feature is one (Task 9). +- Protocol change is limited to ONE additive optional field (`notice` on `terminal.created`), mirrored in `crates/freshell-protocol` and the port contract schemas per repo convention. No new message types (the `SERVER_MESSAGE_TYPES` inventory is frozen). +- Amplifier existence is checked across ALL project slugs (`~/.amplifier/projects/*/sessions//`), not just the cwd-derived slug — a session may live under a different project slug than the current cwd if the tab moved. The index source already walks all projects; the new by-id fallback scans all project dirs too. + +## Design Notes (read before Task 1) + +**The incident:** after a Rust-server restart, freshell restored an Amplifier pane by resuming stored session id `8dab420a-f76b-407c-bcbe-dfb2a971c2e1`, which had no directory anywhere under `~/.amplifier/projects/*/sessions/`. The amplifier CLI silently created a brand-new empty session under that id. Root cause on the freshell side: the reconcile-time existence probe deliberately treats amplifier `Absent` as not-dead (`crates/freshell-ws/src/reconcile.rs:330-352`, because `ensure_session` re-stubs), and the spawn doors never consult the probe at all — the `restore:true` create re-trusts the client claim unvalidated. + +**Key existing pieces (do not rebuild):** +- `crates/freshell-ws/src/existence.rs` — `SessionExistenceProbe` trait: `exists(provider, session_id) -> SessionExistence {Present|Absent|Unknown|ProviderUnavailable}`, plus `ever_observed(...)` and `ever_observed_on_disk(...)`. Reachable in WS handlers as `state.session_existence` (see `terminal.rs:3399`). +- `crates/freshell-server/src/existence.rs` — `IndexExistenceProbe` (production impl over `SessionIndex`), with by-id fallbacks today for claude (raw transcript file) and opencode (sqlite `session_exists_by_id`); constructed in `crates/freshell-server/src/main.rs` (probe construction region, see `existence.rs:112-141, 170-178`). +- `crates/freshell-platform/src/cli_launch.rs:400` — `resolve_coding_cli_command()`, the single pure argv resolver. It must stay pure; validation happens at its callers by feeding it `resume_session_id: None` (or a fresh id). +- The three spawn doors: `handle_create` (`crates/freshell-ws/src/terminal.rs:1412`, resolver call `:2089`), `respawn_agent_terminal` (`terminal.rs:2678`, resolver `:2789`), and the freshagent REST create pipeline (`crates/freshell-freshagent/src/terminal_tabs.rs:1355`). +- `crates/freshell-ws/src/pane_ledger.rs` — durable `BindingRow` per `(provider, sessionId)` with `RowState {Bound|Retired}` and `RetiredReason {Superseded|Closed|GcExpired}`; `retire_closed` at `:563` is the pattern to mirror. +- Client notice surfaces: `paneContent.reconcileNotice` is rendered as a chip (`src/.../FreshAgentView.tsx:2413`) and written into xterm (`src/.../TerminalView.tsx:4336`/`:5053`); the REST create path can inject `"reconcileNotice"` directly into `pane_content` (`terminal_tabs.rs:1657`). `terminal.status.reason` is NEVER displayed by the client — do not build the notice on it. + +**Fresh-spawn fallback shape (mirrors how a genuinely fresh pane of each mode spawns today, per `terminal.rs:1617-1717`):** +- `claude` → mint a new UUID, `LaunchIntent::Start` (`--session-id `), `claude_fresh_prealloc = true`. +- `amplifier` → mint a new UUID, keep `LaunchIntent::Resume` (fresh amplifier panes run `amplifier resume ` against a dir stubbed by `ensure_session` — identical to `should_preallocate_fresh_amplifier`). +- `codex` / `opencode` → `resume_session_id = None` (fresh panes of these modes don't preallocate). + +**Carve-outs the gate must honor (fail-open bias):** +- claude zero-turn: a claude session created by freshell that never conversed has no `.jsonl` on disk yet. `Absent` + `!ever_observed_on_disk` → Proceed (mirrors `reconcile.rs:365-403`). This is deliberately MORE fail-open than reconcile (no ledger-bound requirement). +- Preallocated fresh ids (claude Start prealloc, amplifier fresh prealloc) are intentionally not on disk — the gate only runs on ids that came from the wire (sessionRef / legacy resumeSessionId / claude restore ladder), never on ids the server just minted. +- gemini/kimi: no `resumeArgs` at all and outside `KNOWN_PROVIDERS` — gate never consults the probe for them. + +**Notice visibility per door:** +- Door 1 (WS create): new optional `notice` field on the `terminal.created` success frame; small client change renders it into the pane's xterm exactly like the reconcile notice. +- Door 2 (headless respawn): no `out` sink exists; broadcast the existing `terminal.status{Recovering, reason}` frame (precedent `auto_resume.rs:604-628`) + `tracing::warn!`. Reason prose is not client-rendered today; this is the best available channel on a headless crash-recovery path where `Absent` is near-impossible (the session was alive moments ago). Documented, deliberate. +- Door 3 (REST create): inject `reconcileNotice` into the returned `pane_content` (existing field, existing client rendering — no client change needed). + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `crates/freshell-platform/src/resume_gate.rs` | Create | Pure gate policy: provider list, `ResumeExistence`, `evaluate_resume_gate`, shared probe-fn types for freshagent injection | +| `crates/freshell-platform/src/lib.rs` | Modify | `pub mod resume_gate;` | +| `crates/freshell-sessions/src/amplifier_stub.rs` | Modify | Read-only `session_on_disk()` existence scan across all project slugs | +| `crates/freshell-server/src/existence.rs` | Modify | Amplifier + codex by-id fallbacks on `IndexExistenceProbe` | +| `crates/freshell-server/src/main.rs` | Modify | Wire new locators into the probe; wire probe + retire callback into `FreshAgentState` | +| `crates/freshell-ws/src/pane_ledger.rs` | Modify | `RetiredReason::SessionMissing` + `retire_missing()` | +| `crates/freshell-ws/src/pane_ledger_tests.rs` | Modify | Tests for `retire_missing` | +| `crates/freshell-protocol/src/server_messages.rs` | Modify | Optional `notice` on `TerminalCreated` | +| `src/components/TerminalView.tsx` | Modify | Render `terminal.created` notice into xterm (locate the create-success handler near the `[Restore failed]` handling at `:4691`) | +| `crates/freshell-ws/src/resume_validation.rs` | Create | Wire-resume validation helper shared by doors 1 & 2 (probe → gate → fallback outcome) | +| `crates/freshell-ws/src/terminal.rs` | Modify | Doors 1 & 2 call the helper; retire + notice emission | +| `crates/freshell-ws/tests/resume_validation_gate.rs` | Create | Integration coverage for door 1 (and door 2 if the harness reaches it) | +| `crates/freshell-freshagent/src/lib.rs` | Modify | `FreshAgentState`: `with_resume_probe`, `with_on_stale_resume` builders | +| `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | Door 3 gate + `reconcileNotice` injection | +| `port/oracle/DEVIATIONS.md` | Modify | Deviation ledger entry | + +--- + +### Task 1: Pure resume-gate policy in `freshell-platform` + +**Files:** +- Create: `crates/freshell-platform/src/resume_gate.rs` +- Modify: `crates/freshell-platform/src/lib.rs` (add `pub mod resume_gate;` alongside the existing ~10 `pub mod`s) +- Test: inline `#[cfg(test)]` in `crates/freshell-platform/src/resume_gate.rs` + +**Interfaces:** +- Consumes: nothing (leaf module, pure). +- Produces (used by Tasks 6–8): + - `pub const VALIDATED_PROVIDERS: [&str; 4]` + - `pub fn provider_validated(provider: &str) -> bool` + - `pub enum ResumeExistence { Present, Absent, Unknown }` + - `pub enum ResumeGateDecision { Proceed, SpawnFresh }` + - `pub fn evaluate_resume_gate(provider: &str, existence: ResumeExistence, ever_observed_on_disk: bool) -> ResumeGateDecision` + - `pub struct ResumeProbeAnswer { pub existence: ResumeExistence, pub ever_observed_on_disk: bool }` + - `pub type ResumeProbeFn = std::sync::Arc ResumeProbeAnswer + Send + Sync>` + - `pub fn stale_resume_notice(provider: &str, stale_id: &str) -> String` + +- [ ] **Step 1: Write the failing tests** + +Create `crates/freshell-platform/src/resume_gate.rs` with ONLY the test module first (referencing the not-yet-written items), or write tests + a `todo!()`-free skeleton — the repo convention is inline tests, so create the file with the tests below and empty stubs that don't compile yet is NOT allowed; instead write the tests and run them against a minimal non-implementing body (e.g. `evaluate_resume_gate` returning `Proceed` unconditionally) so the Absent cases FAIL: + +```rust +//! Pre-spawn resume validation policy (resume-validation feature). +//! +//! Pure decision logic: given a provider and a disk-existence answer, decide +//! whether a cached resume id may be passed to the CLI or the pane must spawn +//! fresh. IO-free by design (mirrors `cli_launch`'s purity rule); callers map +//! their probe answers into [`ResumeExistence`]. +//! +//! FAIL-OPEN INVARIANT: only a POSITIVE "store readable, session definitively +//! absent" returns [`ResumeGateDecision::SpawnFresh`]. Unknown/unreadable +//! stores, unvalidated providers (gemini, kimi, third-party), and the claude +//! zero-turn carve-out all Proceed (today's behavior). + +use std::sync::Arc; + +/// Providers whose on-disk store the existence probe knows how to read. +/// MUST stay a subset of `freshell-server`'s `KNOWN_PROVIDERS`: the probe's +/// contract maps unknown providers to `Absent`, so callers must check this +/// list BEFORE consulting the probe. +pub const VALIDATED_PROVIDERS: [&str; 4] = ["claude", "codex", "opencode", "amplifier"]; + +pub fn provider_validated(provider: &str) -> bool { + VALIDATED_PROVIDERS.contains(&provider) +} + +/// Caller-mapped existence answer. `ProviderUnavailable` maps to `Unknown`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResumeExistence { + Present, + Absent, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResumeGateDecision { + /// Pass the resume id through unchanged (validated present, or fail-open). + Proceed, + /// Definitively absent: drop the resume, spawn fresh, notify, retire. + SpawnFresh, +} + +pub fn evaluate_resume_gate( + provider: &str, + existence: ResumeExistence, + ever_observed_on_disk: bool, +) -> ResumeGateDecision { + if !provider_validated(provider) { + return ResumeGateDecision::Proceed; + } + match existence { + ResumeExistence::Present | ResumeExistence::Unknown => ResumeGateDecision::Proceed, + ResumeExistence::Absent => { + // Zero-turn carve-out: a freshell-minted claude session that never + // conversed has no transcript on disk yet (mirrors + // freshell-ws/reconcile.rs claude carve-out, deliberately more + // fail-open: no ledger-bound requirement). + if provider == "claude" && !ever_observed_on_disk { + ResumeGateDecision::Proceed + } else { + ResumeGateDecision::SpawnFresh + } + } + } +} + +/// The operator-visible notice line. MUST name the stale id (spec requirement). +pub fn stale_resume_notice(provider: &str, stale_id: &str) -> String { + format!( + "Saved {provider} session {stale_id} could not be found on disk — started a fresh session instead." + ) +} + +/// Injection shape for crates that cannot depend on `freshell-ws`'s probe +/// trait (freshell-freshagent): one call answering both existence and +/// disk-history for `(provider, session_id)`. +pub struct ResumeProbeAnswer { + pub existence: ResumeExistence, + pub ever_observed_on_disk: bool, +} + +pub type ResumeProbeFn = Arc ResumeProbeAnswer + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + use ResumeExistence::*; + use ResumeGateDecision::*; + + #[test] + fn amplifier_absent_spawns_fresh() { + // THE incident case: stale amplifier id with no session dir anywhere. + assert_eq!(evaluate_resume_gate("amplifier", Absent, false), SpawnFresh); + assert_eq!(evaluate_resume_gate("amplifier", Absent, true), SpawnFresh); + } + + #[test] + fn codex_and_opencode_absent_spawn_fresh() { + assert_eq!(evaluate_resume_gate("codex", Absent, true), SpawnFresh); + assert_eq!(evaluate_resume_gate("opencode", Absent, true), SpawnFresh); + assert_eq!(evaluate_resume_gate("codex", Absent, false), SpawnFresh); + assert_eq!(evaluate_resume_gate("opencode", Absent, false), SpawnFresh); + } + + #[test] + fn claude_zero_turn_carve_out_proceeds() { + // Never observed on disk => could be a legit zero-turn session. Fail open. + assert_eq!(evaluate_resume_gate("claude", Absent, false), Proceed); + } + + #[test] + fn claude_absent_but_previously_on_disk_spawns_fresh() { + // Transcript existed once and is gone now: positive absence. + assert_eq!(evaluate_resume_gate("claude", Absent, true), SpawnFresh); + } + + #[test] + fn present_and_unknown_always_proceed() { + for p in VALIDATED_PROVIDERS { + assert_eq!(evaluate_resume_gate(p, Present, false), Proceed); + assert_eq!(evaluate_resume_gate(p, Unknown, false), Proceed); + assert_eq!(evaluate_resume_gate(p, Present, true), Proceed); + assert_eq!(evaluate_resume_gate(p, Unknown, true), Proceed); + } + } + + #[test] + fn unvalidated_providers_never_blocked() { + // gemini/kimi have no resumeArgs and are outside KNOWN_PROVIDERS; + // the probe would answer Absent for them — the gate must not care. + for p in ["gemini", "kimi", "some-third-party-ext", "shell"] { + assert_eq!(evaluate_resume_gate(p, Absent, false), Proceed); + assert!(!provider_validated(p)); + } + } + + #[test] + fn notice_names_the_stale_id() { + let n = stale_resume_notice("amplifier", "8dab420a-f76b-407c-bcbe-dfb2a971c2e1"); + assert!(n.contains("amplifier")); + assert!(n.contains("8dab420a-f76b-407c-bcbe-dfb2a971c2e1")); + assert!(n.contains("could not be found")); + assert!(n.contains("fresh session")); + } +} +``` + +For the RED phase, temporarily make `evaluate_resume_gate` return `ResumeGateDecision::Proceed` unconditionally and `stale_resume_notice` return `String::new()`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p freshell-platform resume_gate -- --nocapture` +Expected: FAIL — `amplifier_absent_spawns_fresh`, `codex_and_opencode_absent_spawn_fresh`, `claude_absent_but_previously_on_disk_spawns_fresh`, `notice_names_the_stale_id` fail; the fail-open tests pass. + +- [ ] **Step 3: Implement the real bodies** + +Replace the stub bodies with the full implementations shown in Step 1 (the `match` in `evaluate_resume_gate` and the `format!` in `stale_resume_notice`). Add `pub mod resume_gate;` to `crates/freshell-platform/src/lib.rs`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-platform resume_gate` +Expected: PASS (all 7 tests). Also run `cargo clippy -p freshell-platform -- -D warnings` and `cargo fmt` — clean. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-platform/src/resume_gate.rs crates/freshell-platform/src/lib.rs +git commit -m "feat(resume-validation): pure resume-gate policy in freshell-platform" +``` + +--- + +### Task 2: Read-only amplifier session existence scan in `freshell-sessions` + +**Files:** +- Modify: `crates/freshell-sessions/src/amplifier_stub.rs` (the existence scan already exists inside `ensure_session` at `:134-148` — extract a read-only variant beside it) +- Test: inline `#[cfg(test)]` in the same file (this crate has NO `tempfile` dep — use the hand-rolled unique-temp-dir style from `crates/freshell-sessions/src/opencode_locator.rs:393-401`) + +**Interfaces:** +- Consumes: `resolve_amplifier_home()` (existing, same file) — `$FRESHELL_AMPLIFIER_HOME` override, else `$HOME/.amplifier`. +- Produces (used by Task 3): + - `pub enum AmplifierSessionAnswer { Present, Absent, Unreadable }` + - `pub fn session_on_disk(amplifier_home: &std::path::Path, session_id: &str) -> AmplifierSessionAnswer` + +- [ ] **Step 1: Write the failing tests** + +Add to the `#[cfg(test)] mod` in `amplifier_stub.rs` (create a helper mirroring the crate's existing unique-temp-dir pattern): + +```rust + use std::sync::atomic::{AtomicU64, Ordering as TestOrdering}; + + static SCAN_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn scan_temp_home(label: &str) -> std::path::PathBuf { + let n = SCAN_COUNTER.fetch_add(1, TestOrdering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-amplifier-scan-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn session_on_disk_present_under_cwd_slug() { + let home = scan_temp_home("present"); + let sess = home.join("projects/-home-dan-proj/sessions/sid-1"); + std::fs::create_dir_all(&sess).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-1"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_present_under_divergent_slug() { + // The tab may have moved cwd; the session lives under ANOTHER project + // slug. Scanning all project dirs must still find it (plan decision: + // search all projects, documented in Global Constraints). + let home = scan_temp_home("divergent"); + std::fs::create_dir_all(home.join("projects/-some-other-project/sessions/sid-2")).unwrap(); + std::fs::create_dir_all(home.join("projects/-home-dan-proj")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-2"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_absent_when_store_readable() { + let home = scan_temp_home("absent"); + std::fs::create_dir_all(home.join("projects/-home-dan-proj/sessions/other-sid")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-3"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_absent_when_projects_dir_missing() { + // Store root exists but amplifier has never created projects/: + // readable-and-empty => definitively absent. + let home = scan_temp_home("noprojects"); + assert!(matches!( + session_on_disk(&home, "sid-4"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(unix)] + #[test] + fn session_on_disk_unreadable_projects_dir_fails_open() { + use std::os::unix::fs::PermissionsExt; + let home = scan_temp_home("unreadable"); + let projects = home.join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-5"); + // Restore perms before asserting so cleanup works even on failure. + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_matches_a_file_only_as_absent() { + // A stray FILE named like the session id is not a session dir. + let home = scan_temp_home("file-not-dir"); + let sessions = home.join("projects/-p/sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(sessions.join("sid-6"), b"junk").unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-6"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p freshell-sessions session_on_disk` +Expected: FAIL to compile — `session_on_disk` / `AmplifierSessionAnswer` not defined. + +- [ ] **Step 3: Write the implementation** + +Add beside `ensure_session` in `amplifier_stub.rs`: + +```rust +/// Read-only disk-existence answer for one amplifier session id, scanning ALL +/// project slugs under `/projects/` (a session may live under +/// a different slug than the current cwd — see `ensure_session`'s divergent- +/// slug handling). Never creates anything. +/// +/// Semantics (resume-validation feature): +/// * session dir found under any project => `Present`; +/// * `projects/` missing or scanned without a hit => `Absent` +/// (store readable, definitively absent); +/// * `projects/` unreadable (permissions, IO error) => `Unreadable` +/// (callers must fail OPEN — treat as unknown, never as absent). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmplifierSessionAnswer { + Present, + Absent, + Unreadable, +} + +pub fn session_on_disk( + amplifier_home: &std::path::Path, + session_id: &str, +) -> AmplifierSessionAnswer { + let projects = amplifier_home.join("projects"); + let entries = match std::fs::read_dir(&projects) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return AmplifierSessionAnswer::Absent; + } + Err(_) => return AmplifierSessionAnswer::Unreadable, + }; + for entry in entries.flatten() { + if entry.path().join("sessions").join(session_id).is_dir() { + return AmplifierSessionAnswer::Present; + } + } + AmplifierSessionAnswer::Absent +} +``` + +Refactor step: `ensure_session`'s internal scan loop (`:134-148`) and this function walk the same layout — if a shared private helper falls out naturally (e.g. `fn find_session_dir(projects: &Path, session_id: &str) -> Option<(String, PathBuf)>`), extract it; if the two loops need different data (ensure_session needs the found slug), leaving them separate is acceptable. Do not change `ensure_session` behavior. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-sessions session_on_disk` +Expected: PASS (6 tests; 5 on non-unix). Then `cargo test -p freshell-sessions` — full crate still green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-sessions/src/amplifier_stub.rs +git commit -m "feat(resume-validation): read-only amplifier session_on_disk scan" +``` + +--- + +### Task 3: Amplifier + codex by-id fallbacks on `IndexExistenceProbe` + +Why: the probe's warm snapshot can be STALE — a session created moments before a restart may be missing from the snapshot. The claude arm already has a raw-file fallback for exactly this; without equivalents, the gate could misjudge a brand-new amplifier/codex session as `Absent` and wrongly block a working resume (violating the fail-open invariant). After this task, `Absent` for all four validated providers means POSITIVE absence. + +**Files:** +- Modify: `crates/freshell-server/src/existence.rs` +- Modify: `crates/freshell-server/src/main.rs` (probe construction site — where `IndexExistenceProbe::new(index, Some(pane_ledger), provider_roots)` is built and `.with_claude_transcript_locator(...)` / `.with_opencode_session_locator(opencode_db_locator(...))` are chained; codex sessions root resolution mirrors the `ActivityHub` wiring near `main.rs:474`) +- Test: inline `#[cfg(test)]` in `existence.rs` (mirror the existing hand-rolled temp-dir helpers at `existence.rs:307-318` and the existing fallback tests for claude/opencode) + +**Interfaces:** +- Consumes: `freshell_sessions::amplifier_stub::{session_on_disk, AmplifierSessionAnswer}` (Task 2); `freshell_ws::codex_reconcile::locate_codex_rollout(sessions_root: &Path, session_id: &str) -> Option` (existing, `codex_reconcile.rs:154`). +- Produces (wired in `main.rs`, consumed implicitly by every `probe.exists()` caller): + - `pub enum ByIdAnswer { Present, Absent, Unreadable }` (one shared enum for both new locators; mirror `OpencodeDbAnswer`'s shape) + - `pub type AmplifierSessionLocator = Arc ByIdAnswer + Send + Sync>` + - `pub type CodexRolloutExistenceLocator = Arc ByIdAnswer + Send + Sync>` + - `impl IndexExistenceProbe { pub fn with_amplifier_session_locator(self, l: AmplifierSessionLocator) -> Self; pub fn with_codex_rollout_locator(self, l: CodexRolloutExistenceLocator) -> Self }` + - `pub fn amplifier_dir_locator(amplifier_home: PathBuf) -> AmplifierSessionLocator` + - `pub fn codex_rollout_existence_locator(sessions_root: PathBuf) -> CodexRolloutExistenceLocator` + +- [ ] **Step 1: Write the failing tests** + +Add to the existing `#[cfg(test)] mod tests` in `existence.rs`, copying the construction pattern of the existing claude raw-file-fallback and opencode by-id tests in that module (same fake-index/temp-dir scaffolding; the module is `existence.rs:301-1293` — pick the nearest analogous test and clone its setup): + +```rust + #[test] + fn amplifier_absent_snapshot_rescued_by_dir_locator() { + // Warm snapshot says Absent, but the session dir exists on disk + // (created after the snapshot). By-id fallback must answer Present. + // Setup: probe with a warm snapshot NOT containing "amp-new", + // chained .with_amplifier_session_locator(amplifier_dir_locator(home)) + // where home contains projects/-p/sessions/amp-new/. + // Assert: probe.exists("amplifier", "amp-new") == SessionExistence::Present + } + + #[test] + fn amplifier_definitively_absent_stays_absent() { + // Warm snapshot Absent + locator scans a readable store without the id. + // Assert: probe.exists("amplifier", "amp-gone") == SessionExistence::Absent + } + + #[test] + fn amplifier_unreadable_store_answers_unknown() { + // Locator returns Unreadable (e.g. permissions). + // Wire a hand-rolled locator: Arc::new(|_| ByIdAnswer::Unreadable). + // Assert: probe.exists("amplifier", "amp-x") == SessionExistence::Unknown + } + + #[test] + fn amplifier_without_locator_keeps_snapshot_answer() { + // No locator chained (default) => today's behavior byte-for-byte. + // Assert: warm snapshot Absent stays Absent. + } + + #[test] + fn codex_absent_snapshot_rescued_by_rollout_locator() { + // Same shape as the amplifier rescue test, with a temp codex sessions + // root containing sessions/2026/07/29/rollout-...-.jsonl and + // codex_rollout_existence_locator(root). + // Assert Present. + } + + #[test] + fn codex_missing_sessions_root_is_absent_and_unreadable_is_unknown() { + // sessions root NotFound => Absent (fresh install, store readable-empty); + // locator returning Unreadable => probe answers Unknown. + } +``` + +Write these as REAL tests (full setup + assertions) by cloning the neighboring claude/opencode fallback tests' scaffolding — the comments above define the required behavior of each; the scaffolding (fake index snapshot seeding, probe construction) must match what those existing tests already do in this module. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p freshell-server existence` +Expected: FAIL to compile — `with_amplifier_session_locator` etc. not defined. + +- [ ] **Step 3: Implement the locators and fallbacks** + +In `existence.rs`: + +```rust +/// By-id disk answer for the amplifier/codex fallbacks (mirrors OpencodeDbAnswer). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ByIdAnswer { + Present, + Absent, + Unreadable, +} + +pub type AmplifierSessionLocator = Arc ByIdAnswer + Send + Sync>; +pub type CodexRolloutExistenceLocator = Arc ByIdAnswer + Send + Sync>; + +pub fn amplifier_dir_locator(amplifier_home: std::path::PathBuf) -> AmplifierSessionLocator { + Arc::new(move |session_id: &str| { + match freshell_sessions::amplifier_stub::session_on_disk(&lifier_home, session_id) { + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Present => ByIdAnswer::Present, + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Absent => ByIdAnswer::Absent, + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Unreadable => ByIdAnswer::Unreadable, + } + }) +} + +pub fn codex_rollout_existence_locator( + sessions_root: std::path::PathBuf, +) -> CodexRolloutExistenceLocator { + Arc::new(move |session_id: &str| { + match std::fs::metadata(&sessions_root) { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return ByIdAnswer::Absent, + Err(_) => return ByIdAnswer::Unreadable, + Ok(meta) if !meta.is_dir() => return ByIdAnswer::Unreadable, + Ok(_) => {} + } + match freshell_ws::codex_reconcile::locate_codex_rollout(&sessions_root, session_id) { + Some(_) => ByIdAnswer::Present, + None => ByIdAnswer::Absent, + } + }) +} +``` + +Add two `Option<...>` fields to `IndexExistenceProbe` plus builder methods `with_amplifier_session_locator` / `with_codex_rollout_locator` (exactly mirroring `with_claude_transcript_locator` / `with_opencode_session_locator`). In the `exists()` body's warm-snapshot-Absent adjudication section (`existence.rs:181-277`, after the claude raw-file fallback at `:226` and the opencode by-id fallback at `:254`), add the same pattern for amplifier and codex: + +```rust + // amplifier/codex by-id fallbacks (resume-validation feature): + // a stale warm snapshot must never adjudicate a real session absent. + if provider == "amplifier" { + if let Some(locator) = &self.amplifier_session_locator { + return match locator(session_id) { + ByIdAnswer::Present => SessionExistence::Present, + ByIdAnswer::Unreadable => SessionExistence::Unknown, + ByIdAnswer::Absent => SessionExistence::Absent, + }; + } + } + if provider == "codex" { + if let Some(locator) = &self.codex_rollout_locator { + return match locator(session_id) { + ByIdAnswer::Present => SessionExistence::Present, + ByIdAnswer::Unreadable => SessionExistence::Unknown, + ByIdAnswer::Absent => SessionExistence::Absent, + }; + } + } +``` + +(Place them at the same adjudication point as the existing fallbacks — only reached when the snapshot would otherwise answer `Absent`.) + +In `main.rs`, at the probe construction site, chain the new builders: +- `with_amplifier_session_locator(amplifier_dir_locator(home))` where `home` comes from `freshell_sessions::amplifier_stub::resolve_amplifier_home()` (skip chaining when it returns `None` — probe then behaves as today for amplifier). +- `with_codex_rollout_locator(codex_rollout_existence_locator(codex_sessions_root))` where `codex_sessions_root` is resolved exactly like the `ActivityHub` wiring near `main.rs:474` (`$CODEX_HOME` else `$HOME/.codex`, joined with `sessions`). Reuse/extract that resolution rather than duplicating it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-server existence` +Expected: PASS — all new tests plus the existing ~existing existence tests stay green. +Then: `cargo test -p freshell-server` and `cargo clippy -p freshell-server -- -D warnings`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-server/src/existence.rs crates/freshell-server/src/main.rs +git commit -m "feat(resume-validation): amplifier + codex by-id existence fallbacks" +``` + +--- + +### Task 4: `RetiredReason::SessionMissing` + `PaneLedger::retire_missing` + +**Files:** +- Modify: `crates/freshell-ws/src/pane_ledger.rs` +- Test: `crates/freshell-ws/src/pane_ledger_tests.rs` (existing `#[path]` test split) + +**Interfaces:** +- Consumes: existing `BindingRow`, `RowState {Bound|Retired}`, `RetiredReason {Superseded|Closed|GcExpired}` (`pane_ledger.rs:77-88`), `retire_closed` (`:563`) as the implementation template, `load_binding` (`:617`). +- Produces (used by Tasks 6–8): + - `RetiredReason::SessionMissing` (serde `snake_case` → `"session_missing"`; enum is `#[serde(rename_all = "snake_case")]`) + - `pub fn retire_missing(&self, provider: &str, session_id: &str) -> bool` — retires a `Bound` row with `retired_reason = Some(SessionMissing)`; returns `true` iff a bound row was retired; no-op (`false`) when no row exists or the row is already `Retired`; durable atomic write like every other mutation in this module. + +- [ ] **Step 1: Write the failing tests** + +Add to `pane_ledger_tests.rs`, cloning the setup style of the existing `retire_closed` tests in that file: + +```rust +#[test] +fn retire_missing_marks_bound_row_session_missing() { + // Setup: a ledger with a Bound binding for ("amplifier", "stale-sid") + // (create it exactly the way the neighboring retire_closed test does). + // Act: + // let retired = ledger.retire_missing("amplifier", "stale-sid"); + // Assert: + // retired == true; + // let row = ledger.load_binding("amplifier", "stale-sid").unwrap(); + // row.state == RowState::Retired; + // row.retired_reason == Some(RetiredReason::SessionMissing); +} + +#[test] +fn retire_missing_is_noop_without_binding() { + // Fresh ledger, no rows. + // Assert: ledger.retire_missing("amplifier", "never-seen") == false + // and no row was created (load_binding returns None). +} + +#[test] +fn retire_missing_does_not_reretire() { + // Bound row retired once => true; second call => false; reason stays + // SessionMissing; updated_at from the first retire is not clobbered + // by the failed second call. +} + +#[test] +fn session_missing_serde_round_trips() { + // serde_json::to_string(&RetiredReason::SessionMissing) == "\"session_missing\"" + // and it deserializes back. +} +``` + +Write these as REAL tests using the exact ledger-construction and row-seeding helpers the neighboring `retire_closed` tests in this file already use. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p freshell-ws pane_ledger` +Expected: FAIL to compile — `SessionMissing` / `retire_missing` not defined. + +- [ ] **Step 3: Implement** + +- Add `SessionMissing` to `RetiredReason`. +- Implement `retire_missing` by copying `retire_closed`'s body/pattern (`pane_ledger.rs:563`) with the reason swapped and the bound-row guard as specified. Keep the module's durability discipline (atomic temp+rename via the existing write path). +- Run `cargo check -p freshell-ws -p freshell-server` — the compiler will flag any exhaustive `match` on `RetiredReason` elsewhere; extend each arm conservatively (treat `SessionMissing` like `Closed` wherever a policy choice is needed, i.e. "not resumable, not superseded"). List every touched match site in the commit message body. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-ws pane_ledger` +Expected: PASS. Then `cargo test -p freshell-ws` — whole crate green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-ws/src/pane_ledger.rs crates/freshell-ws/src/pane_ledger_tests.rs +git commit -m "feat(resume-validation): RetiredReason::SessionMissing + retire_missing" +``` + +--- + +### Task 5: `notice` field on `terminal.created` + client rendering + +**Files:** +- Modify: `crates/freshell-protocol/src/server_messages.rs` (the `TerminalCreated` struct — locate `TerminalCreated` in the file) +- Modify: port contract schema for `terminal.created` (locate via `grep -rn "terminal.created" port/ server/ --include="*.ts" --include="*.json" -l` and update whichever schema/zod/JSON-schema file mirrors the frame's fields — repo convention: protocol fields are mirrored in `crates/freshell-protocol` AND the port contract schemas) +- Modify: `src/components/TerminalView.tsx` — the `terminal.created` success handler (it lives near the create-`error` handling that renders `[Restore failed]` at `TerminalView.tsx:4691`) +- Test: Rust serde test inline in `server_messages.rs` (or the protocol crate's existing test module for server messages); client test added to the existing test file that covers `[Restore failed]` rendering (find it: `grep -rln "Restore failed" test/`) + +**Interfaces:** +- Consumes: nothing new. +- Produces (used by Task 6): `TerminalCreated { …existing fields…, notice: Option }` serialized as `"notice"` only when present (`#[serde(skip_serializing_if = "Option::is_none")]`). Client behavior: when `msg.notice` is a non-empty string, write it into the pane's xterm as its own line, styled exactly like the reconcile-notice write at `TerminalView.tsx:4336`/`:5053`. + +This is one additive optional field on an existing message type — the frozen `SERVER_MESSAGE_TYPES` inventory (57 entries) is unchanged. The Node server never sets the field; it is optional end-to-end. + +- [ ] **Step 1: Write the failing Rust serde test** + +In the protocol crate's server-message test module: + +```rust +#[test] +fn terminal_created_notice_is_optional_and_additive() { + // Absent => key omitted (wire-compatible with the frozen client). + let created = /* construct TerminalCreated exactly as the neighboring + TerminalCreated tests in this module do */; + let json = serde_json::to_value(&ServerMessage::TerminalCreated(created)).unwrap(); + assert!(json.get("notice").is_none()); + + // Present => serialized verbatim. + let mut with_notice = /* same constructor */; + with_notice.notice = Some("Saved amplifier session X could not be found on disk — started a fresh session instead.".to_string()); + let json = serde_json::to_value(&ServerMessage::TerminalCreated(with_notice)).unwrap(); + assert_eq!( + json["notice"], + "Saved amplifier session X could not be found on disk — started a fresh session instead." + ); +} +``` + +(Use the construction helper/literal style of the existing `TerminalCreated` tests in that module.) + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p freshell-protocol terminal_created_notice` +Expected: FAIL to compile — no `notice` field. + +- [ ] **Step 3: Add the field + schema mirror** + +```rust + /// Resume-validation feature: operator-visible notice set when the server + /// dropped a stale resume id and spawned fresh. The client writes it into + /// the pane's xterm. Optional/additive — Node never sets it. + #[serde(skip_serializing_if = "Option::is_none")] + pub notice: Option, +``` + +Add `notice: None` at every existing `TerminalCreated { … }` construction site (`cargo check` will enumerate them). Update the port contract schema for `terminal.created` with the optional `notice` string field. Run `cargo test -p freshell-protocol --locked` (the CI contract invocation) — green. + +- [ ] **Step 4: Write the failing client test, then render** + +RED: In the existing test file covering `[Restore failed]` rendering, add a sibling test using that file's existing harness/mocks verbatim: + +```typescript +it('writes the resume-validation notice into the terminal on terminal.created', () => { + // Arrange exactly like the sibling "[Restore failed]" test (same mount, + // same xterm mock, same WS message dispatch helper), then dispatch a + // terminal.created message for this pane that includes: + // notice: 'Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 could not be found on disk — started a fresh session instead.' + // Assert the xterm write buffer received a line containing + // 'Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1'. +}); +``` + +Run it (`npm run test:vitest -- --run`), watch it fail. GREEN: in `TerminalView.tsx`'s `terminal.created` handler, when `msg.notice` is a non-empty string, write it into the pane's xterm as its own line using the exact styling/write mechanism of the reconcile-notice write at `:4336`/`:5053`. Re-run the test — PASS. Also re-run the whole file to confirm no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-protocol/src/server_messages.rs src/ test/ +git commit -m "feat(resume-validation): optional notice on terminal.created, rendered in the pane" +``` + +--- + +### Task 6: Door 1 — gate the WS `terminal.create` restore path + +**Files:** +- Create: `crates/freshell-ws/src/resume_validation.rs` +- Modify: `crates/freshell-ws/src/lib.rs` (add `pub mod resume_validation;`) +- Modify: `crates/freshell-ws/src/terminal.rs` — `handle_create`, between the resume-id derivation block (ends `:1717`) and the amplifier pre-create block (starts `:1799`) +- Test: inline `#[cfg(test)]` in `resume_validation.rs`; integration: `crates/freshell-ws/tests/resume_validation_gate.rs` + +**Interfaces:** +- Consumes: Task 1 (`resume_gate::*`), Task 4 (`retire_missing`), Task 5 (`notice` field), existing `SessionExistenceProbe` (`state.session_existence`), `LaunchIntent`, `uuid::Uuid`. +- Produces (also used by Task 7): + +```rust +pub struct ResumeValidationOutcome { + pub resume_session_id: Option, + pub launch_intent: LaunchIntent, + /// True when the fallback minted a fresh claude Start id (caller must set + /// its claude_fresh_prealloc flag so downstream identity stamping matches + /// the genuine fresh-claude path). + pub claude_fresh_prealloc: bool, + /// Some(stale_id) iff the gate fired: caller retires the ledger row, + /// emits the notice, and must NOT stamp the stale sessionRef. + pub stale_session_id: Option, + pub notice: Option, +} + +pub fn validate_wire_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: &dyn SessionExistenceProbe, +) -> ResumeValidationOutcome +``` + +- [ ] **Step 1: Write the failing unit tests** + +`crates/freshell-ws/src/resume_validation.rs` with a stub probe: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::existence::{SessionExistence, SessionExistenceProbe}; + use freshell_platform::cli_launch::LaunchIntent; + + struct FakeProbe { + answer: SessionExistence, + ever_on_disk: bool, + } + impl SessionExistenceProbe for FakeProbe { + fn exists(&self, _p: &str, _s: &str) -> SessionExistence { + self.answer + } + fn ever_observed(&self, _p: &str, _s: &str) -> bool { + false + } + fn ever_observed_on_disk(&self, _p: &str, _s: &str) -> bool { + self.ever_on_disk + } + } + + fn absent() -> FakeProbe { + FakeProbe { answer: SessionExistence::Absent, ever_on_disk: true } + } + + #[test] + fn amplifier_absent_mints_fresh_uuid_and_reports_stale() { + let out = validate_wire_resume( + "amplifier", + Some("stale-amp".into()), + LaunchIntent::Resume, + &absent(), + ); + let fresh = out.resume_session_id.expect("fresh amplifier id preallocated"); + assert_ne!(fresh, "stale-amp"); + assert_eq!(out.launch_intent, LaunchIntent::Resume); + assert!(!out.claude_fresh_prealloc); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-amp")); + let notice = out.notice.expect("notice set"); + assert!(notice.contains("stale-amp")); + } + + #[test] + fn claude_absent_previously_on_disk_falls_back_to_start_intent() { + let out = validate_wire_resume( + "claude", + Some("stale-claude".into()), + LaunchIntent::Resume, + &absent(), + ); + assert!(out.resume_session_id.is_some()); + assert_ne!(out.resume_session_id.as_deref(), Some("stale-claude")); + assert_eq!(out.launch_intent, LaunchIntent::Start); + assert!(out.claude_fresh_prealloc); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-claude")); + } + + #[test] + fn claude_zero_turn_absent_proceeds_untouched() { + let probe = FakeProbe { answer: SessionExistence::Absent, ever_on_disk: false }; + let out = validate_wire_resume( + "claude", + Some("zero-turn".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("zero-turn")); + assert_eq!(out.launch_intent, LaunchIntent::Resume); + assert!(out.stale_session_id.is_none()); + assert!(out.notice.is_none()); + } + + #[test] + fn codex_and_opencode_absent_drop_resume_entirely() { + for mode in ["codex", "opencode"] { + let out = validate_wire_resume( + mode, + Some("stale-x".into()), + LaunchIntent::Resume, + &absent(), + ); + assert!(out.resume_session_id.is_none()); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-x")); + assert!(out.notice.is_some()); + } + } + + #[test] + fn unknown_and_provider_unavailable_fail_open() { + for answer in [SessionExistence::Unknown, SessionExistence::ProviderUnavailable] { + let probe = FakeProbe { answer, ever_on_disk: false }; + let out = validate_wire_resume( + "amplifier", + Some("maybe".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("maybe")); + assert!(out.stale_session_id.is_none()); + assert!(out.notice.is_none()); + } + } + + #[test] + fn present_passes_through_untouched() { + let probe = FakeProbe { answer: SessionExistence::Present, ever_on_disk: true }; + let out = validate_wire_resume( + "amplifier", + Some("real".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("real")); + assert!(out.stale_session_id.is_none()); + } + + #[test] + fn unvalidated_providers_and_empty_ids_never_consult_probe() { + struct PanickingProbe; + impl SessionExistenceProbe for PanickingProbe { + fn exists(&self, _: &str, _: &str) -> SessionExistence { + panic!("probe must not be consulted"); + } + fn ever_observed(&self, _: &str, _: &str) -> bool { + panic!("probe must not be consulted"); + } + fn ever_observed_on_disk(&self, _: &str, _: &str) -> bool { + panic!("probe must not be consulted"); + } + } + for mode in ["gemini", "kimi", "shell", "third-party"] { + let out = validate_wire_resume( + mode, + Some("id".into()), + LaunchIntent::Resume, + &PanickingProbe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("id")); + } + let out = validate_wire_resume("amplifier", None, LaunchIntent::Resume, &PanickingProbe); + assert!(out.resume_session_id.is_none()); + assert!(out.stale_session_id.is_none()); + } +} +``` + +(If the real `SessionExistenceProbe` trait has different required-method spellings, match the trait exactly — it is defined in `crates/freshell-ws/src/existence.rs`; `ever_observed_on_disk` has a default impl, override it in the fakes.) + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p freshell-ws resume_validation` +Expected: FAIL to compile — module doesn't exist yet. + +- [ ] **Step 3: Implement `validate_wire_resume`** + +```rust +//! Spawn-door resume validation (resume-validation feature): before a cached +//! session id is turned into resume argv, ask the disk-existence probe. On +//! POSITIVE absence, fall back to the same shape a genuinely fresh pane of +//! that mode uses. Unknown/unavailable always fail open. +//! +//! Callers (the spawn doors in `crate::terminal`) apply the outcome: retire +//! the stale ledger row, emit the notice, and never stamp the stale ref. + +use freshell_platform::cli_launch::LaunchIntent; +use freshell_platform::resume_gate::{ + evaluate_resume_gate, provider_validated, stale_resume_notice, ResumeExistence, + ResumeGateDecision, +}; + +use crate::existence::{SessionExistence, SessionExistenceProbe}; + +pub struct ResumeValidationOutcome { + pub resume_session_id: Option, + pub launch_intent: LaunchIntent, + pub claude_fresh_prealloc: bool, + pub stale_session_id: Option, + pub notice: Option, +} + +fn passthrough( + resume_session_id: Option, + launch_intent: LaunchIntent, +) -> ResumeValidationOutcome { + ResumeValidationOutcome { + resume_session_id, + launch_intent, + claude_fresh_prealloc: false, + stale_session_id: None, + notice: None, + } +} + +pub fn map_existence(e: SessionExistence) -> ResumeExistence { + match e { + SessionExistence::Present => ResumeExistence::Present, + SessionExistence::Absent => ResumeExistence::Absent, + SessionExistence::Unknown | SessionExistence::ProviderUnavailable => { + ResumeExistence::Unknown + } + } +} + +pub fn validate_wire_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: &dyn SessionExistenceProbe, +) -> ResumeValidationOutcome { + let Some(sid) = resume_session_id.clone().filter(|s| !s.is_empty()) else { + return passthrough(resume_session_id, launch_intent); + }; + if !provider_validated(mode) { + return passthrough(resume_session_id, launch_intent); + } + let existence = map_existence(probe.exists(mode, &sid)); + let ever_on_disk = probe.ever_observed_on_disk(mode, &sid); + match evaluate_resume_gate(mode, existence, ever_on_disk) { + ResumeGateDecision::Proceed => passthrough(resume_session_id, launch_intent), + ResumeGateDecision::SpawnFresh => { + let notice = stale_resume_notice(mode, &sid); + let (fresh_id, intent, claude_prealloc) = match mode { + // Mirror the genuine fresh-pane shapes in handle_create + // (should_preallocate_fresh_claude / _amplifier). + "claude" => ( + Some(uuid::Uuid::new_v4().to_string()), + LaunchIntent::Start, + true, + ), + "amplifier" => ( + Some(uuid::Uuid::new_v4().to_string()), + LaunchIntent::Resume, + false, + ), + _ => (None, LaunchIntent::Resume, false), + }; + ResumeValidationOutcome { + resume_session_id: fresh_id, + launch_intent: intent, + claude_fresh_prealloc: claude_prealloc, + stale_session_id: Some(sid), + notice: Some(notice), + } + } + } +} +``` + +Run: `cargo test -p freshell-ws resume_validation` — PASS. + +- [ ] **Step 4: Write the failing integration test (RED), then wire door 1 (GREEN)** + +RED: create `crates/freshell-ws/tests/resume_validation_gate.rs`. Reuse the harness of `crates/freshell-ws/tests/restore_spawn_gate.rs` verbatim (same `common::*` helpers, same way it builds `WsState`, injects a probe/temp stores, sends a `terminal.create` and reads response frames). Cases to cover (each is one `#[tokio::test]`): + +1. `restore_true_amplifier_absent_spawns_fresh_with_notice`: state whose existence probe answers `Absent` for `("amplifier", "stale-amp")` (inject a fake `SharedExistenceProbe` — the harness sets `state.session_existence`; use a fake, not a real index) + a pane ledger containing a `Bound` row for that ref. Send `terminal.create { mode: "amplifier", restore: true, session_ref: {provider:"amplifier", sessionId:"stale-amp"}, cwd: }` (plus `FRESHELL_AMPLIFIER_HOME` pointed at an empty temp home so `ensure_session` runs against the temp store). Assert: the create SUCCEEDS (a `terminal.created` frame arrives, not an `error`); the `terminal.created` frame's `notice` contains `"stale-amp"`; the spawned resume id is NOT `stale-amp` (assert via the ledger: `load_binding("amplifier", "stale-amp")` is `Retired` with reason `SessionMissing`, and no amplifier session dir named `stale-amp` was created under the temp amplifier home — the fresh UUID dir exists instead). +2. `restore_true_amplifier_present_resumes_unchanged`: probe answers `Present`; assert `terminal.created` has NO `notice` and the ledger row stays `Bound`. +3. `restore_true_unknown_fails_open`: probe answers `Unknown`; assert no `notice`, row stays `Bound` (today's behavior preserved). + +Run: `cargo test -p freshell-ws --test resume_validation_gate` — tests fail (no gate wired; notice absent, row stays bound in case 1). + +GREEN — wire `handle_create` (`terminal.rs`), inside the resume-id derivation region: + +a. In the derivation block (`:1617-1717`), add a tracker so the gate only sees wire-derived ids — in the `else` branch (the non-prealloc arm that reads `requested_ref` / `create.resume_session_id` / the claude restore ladder), set a new local `let mut resume_id_from_wire = true;` (declared `false` above the block). + +b. Immediately after the derivation block (before the D7 liveness guard at `:1739` and the amplifier pre-create at `:1799`), insert: + +```rust + // Resume validation (docs/plans/2026-07-29-resume-validation.md): never + // hand the CLI a resume id that is definitively absent from the + // provider's on-disk store. Fail open on Unknown/ProviderUnavailable. + let mut resume_fallback_notice: Option = None; + if resume_id_from_wire { + let outcome = crate::resume_validation::validate_wire_resume( + &mode, + resume_session_id.take(), + launch_intent, + state.session_existence.as_ref(), + ); + resume_session_id = outcome.resume_session_id; + launch_intent = outcome.launch_intent; + if outcome.claude_fresh_prealloc { + claude_fresh_prealloc = true; + } + if let Some(stale) = outcome.stale_session_id.as_deref() { + tracing::warn!( + mode = %mode, + stale_session_id = %stale, + "resume validation: cached session missing on disk; spawning fresh" + ); + // Don't retry the stale id forever. + // (Use the same state.pane_ledger access pattern the claude + // restore ladder / binding writes in this file already use.) + let _ = state.pane_ledger.retire_missing(&mode, stale); + resume_fallback_notice = outcome.notice; + } + } +``` + +(Adapt the exact `state.pane_ledger` spelling to how `handle_create` already reaches the ledger — e.g. if it is an `Option`/`Arc`, follow the existing call sites in this file.) + +c. Stale-ref stamping guard: from the gate insertion point onward, `handle_create` must not stamp the stale `create.session_ref` as the pane's identity. Grep every use of `create.session_ref` / `requested_ref` BELOW the insertion point in `handle_create`; for each one that records identity (ledger binding writes, identity registry, `terminal.created` session fields), switch it to a local `effective_session_ref` that is `None` when the gate fired (the fresh id — when one was minted — flows through `resume_session_id` exactly as the prealloc paths already do). The integration test's ledger assertions in case 1 pin this. + +d. Notice emission: where `handle_create` constructs the `TerminalCreated` success frame, set `notice: resume_fallback_notice` (field from Task 5). + +Run: `cargo test -p freshell-ws --test resume_validation_gate` — PASS. Then run the neighbors that pin this region's behavior: `cargo test -p freshell-ws --test restore_spawn_gate --test claude_restore_unavailable --test codex_session_ref_resume --test pane_reconcile` and `cargo test -p freshell-platform` (argv goldens) — all green. + +- [ ] **Step 5: Refactor + full-crate run** + +Refactor pass: keep `handle_create`'s addition to the ~25 lines above (all policy is in `resume_validation.rs` / `resume_gate.rs`). Run `cargo test -p freshell-ws` (whole crate) and `cargo clippy -p freshell-ws -- -D warnings`. + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-ws/src/resume_validation.rs crates/freshell-ws/src/lib.rs crates/freshell-ws/src/terminal.rs crates/freshell-ws/tests/resume_validation_gate.rs +git commit -m "feat(resume-validation): gate WS terminal.create restore path on disk existence" +``` + +--- + +### Task 7: Door 2 — gate the headless auto-resume respawn + +**Files:** +- Modify: `crates/freshell-ws/src/terminal.rs` — `respawn_agent_terminal` (`:2678`), specifically the hardcoded block at `:2708-2710` (`let resume_session_id = Some(req.session_id.clone());`) +- Test: extend the existing respawn integration coverage — `crates/freshell-ws/tests/auto_resume_respawn.rs` (reuse its harness/state setup verbatim) + +**Interfaces:** +- Consumes: `validate_wire_resume` (Task 6), `retire_missing` (Task 4), existing `AgentRespawnRequest {mode, provider, session_id, create_request_id, cwd}` and the `emit_recovering`-style broadcast precedent (`auto_resume.rs:604-628`). +- Produces: no new public API. Behavior contract: on positive absence the respawn proceeds WITHOUT resume args (fresh spawn, same cwd/mode), the stale ledger row is retired `SessionMissing`, a `terminal.status{Recovering, reason}` frame naming the stale id is broadcast, and a `tracing::warn!` is logged. On Present/Unknown, byte-for-byte today's behavior. + +- [ ] **Step 1: Write the failing integration test** + +Add to `crates/freshell-ws/tests/auto_resume_respawn.rs`, cloning the setup of the existing tests in that file (fake state, fake probe injection into `state.session_existence`, temp amplifier home via `FRESHELL_AMPLIFIER_HOME`): + +```rust +#[tokio::test] +async fn respawn_with_absent_session_spawns_fresh_and_retires_binding() { + // Setup: harness state exactly as the sibling respawn test builds it, + // with: probe answering Absent for ("amplifier", "stale-amp"); + // pane ledger holding a Bound row for that ref; + // FRESHELL_AMPLIFIER_HOME -> empty temp dir. + // Act: respawn_agent_terminal(state, AgentRespawnRequest { + // mode: "amplifier", provider: "amplifier", + // session_id: "stale-amp", create_request_id: , + // cwd: Some() }).await + // Assert: + // 1. respawn returns Ok (the pane survives — fresh spawn, not an error); + // 2. ledger row ("amplifier","stale-amp") is Retired/SessionMissing; + // 3. NO amplifier stub dir for "stale-amp" was created under the temp + // home (ensure_session must not run for the stale id); + // 4. a broadcast terminal.status frame with status "recovering" was sent + // whose reason contains "stale-amp" (subscribe to state.broadcast_tx + // before the call, the way sibling tests capture broadcasts). +} + +#[tokio::test] +async fn respawn_with_unknown_existence_resumes_exactly_as_today() { + // Probe answers Unknown. Assert the spawned command still carries the + // resume id (observe via whatever the sibling test asserts on — spawn + // record/ledger/binding), the row stays Bound, and no recovering-with- + // stale-id broadcast is emitted. +} +``` + +Write these as REAL tests using the file's existing helpers. Run: `cargo test -p freshell-ws --test auto_resume_respawn` — the two new tests FAIL. + +- [ ] **Step 2: Wire the gate into `respawn_agent_terminal`** + +Replace the hardcoded assignment at `terminal.rs:2708-2710`: + +```rust + // Spawn-time resume identity: intent is ALWAYS `Resume` on this path. + // Resume validation (docs/plans/2026-07-29-resume-validation.md): the + // respawn id comes from the identity registry / pane ledger — cached + // state — so it gets the same disk-existence gate as door 1. + let mut launch_intent = LaunchIntent::Resume; + let outcome = crate::resume_validation::validate_wire_resume( + &req.mode, + Some(req.session_id.clone()), + launch_intent, + state.session_existence.as_ref(), + ); + let resume_session_id = outcome.resume_session_id; + launch_intent = outcome.launch_intent; + if let Some(stale) = outcome.stale_session_id.as_deref() { + tracing::warn!( + mode = %req.mode, + stale_session_id = %stale, + "resume validation (respawn): cached session missing on disk; respawning fresh" + ); + let _ = state.pane_ledger.retire_missing(&req.provider, stale); + // Headless path: no per-create `out` sink. Broadcast the existing + // Recovering status frame (precedent: auto_resume.rs emit_recovering) + // with the notice as reason. Reason prose is presentational-only per + // the protocol doc; typed fields carry no data change here. + let msg = freshell_protocol::ServerMessage::TerminalStatus(freshell_protocol::TerminalStatus { + status: freshell_protocol::RuntimeStatus::Recovering, + terminal_id: req.create_request_id.clone(), + attempt: None, + max_attempts: None, + exit_code: None, + reason: outcome.notice.clone(), + }); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = state.broadcast_tx.send(json); + } + } +``` + +Adapt the `TerminalStatus` field spellings/terminal-id source to the exact struct at `server_messages.rs:1102-1117` and to how `respawn_agent_terminal` names the terminal it is respawning (the sibling `emit_recovering` in `auto_resume.rs:604-628` is the template — if the respawned terminal id is available in scope, prefer it over `create_request_id`). Note `claude_fresh_prealloc` is intentionally unused here — for claude the outcome's `Start` intent + fresh id are honored by the same `CliLaunchInputs`, and this headless path has no prealloc bookkeeping to update (verify: the sibling tests + `cargo check` will surface any claude-specific respawn bookkeeping; if some exists, mirror door 1's handling). + +Downstream in this function, the amplifier `ensure_session` call (`:2846`) keys off `resume_session_id` — with the gate having replaced the stale id by a fresh UUID, it stubs the FRESH id, which is exactly the fresh-amplifier-pane shape. No further change needed there. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test -p freshell-ws --test auto_resume_respawn` +Expected: PASS (new + pre-existing tests). Also: `cargo test -p freshell-ws --test auto_resume_e2e`. + +- [ ] **Step 4: Refactor + full-crate run** + +Run `cargo test -p freshell-ws` and `cargo clippy -p freshell-ws -- -D warnings` — green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-ws/src/terminal.rs crates/freshell-ws/tests/auto_resume_respawn.rs +git commit -m "feat(resume-validation): gate headless auto-resume respawn on disk existence" +``` + +--- + +### Task 8: Door 3 — gate the freshagent REST create pipeline + +**Files:** +- Modify: `crates/freshell-freshagent/src/lib.rs` — `FreshAgentState` builders (`:406-475` region, beside `with_opencode_locator` / `with_codex_locator` / `with_session_identity`) +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` — after `derive_resume_identity` (`:491-509`) resolves the resume id and before `CliLaunchInputs` is built for the resolver call at `:1355`; `reconcileNotice` injection at the `pane_content` construction (`:1657` precedent) +- Modify: `crates/freshell-server/src/main.rs` — wire the two closures when building `FreshAgentState` +- Test: the existing `#[cfg(test)]` module(s) for `terminal_tabs.rs` (where `requested_resume_session_id_for_mode` / `plausible_resume_session_id` are tested) + +**Interfaces:** +- Consumes: Task 1's `ResumeProbeFn` / `ResumeProbeAnswer` / `evaluate_resume_gate` / `provider_validated` / `stale_resume_notice` (freshell-freshagent depends on freshell-platform and must NOT depend on freshell-ws — its Cargo.toml documents the cycle). +- Produces: + - `FreshAgentState::with_resume_probe(self, probe: freshell_platform::resume_gate::ResumeProbeFn) -> Self` (field `resume_probe: Option`, default `None` = feature off = today's behavior) + - `FreshAgentState::with_on_stale_resume(self, cb: Arc) -> Self` — called with `(provider, stale_session_id)` when the gate fires; main.rs implements it as ledger `retire_missing` + `tracing::warn!` + - A module-private pure helper in `terminal_tabs.rs`: + +```rust +struct RestResumeOutcome { + resume_session_id: Option, + launch_intent: LaunchIntent, + stale_session_id: Option, + notice: Option, +} + +fn validate_rest_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: Option<&freshell_platform::resume_gate::ResumeProbeFn>, +) -> RestResumeOutcome +``` + + Behavior identical to `validate_wire_resume` (Task 6) including the per-provider fresh-fallback shapes (claude → new UUID + `Start`; amplifier → new UUID + `Resume`; codex/opencode → `None`), with one addition: `probe: None` (not wired) → passthrough. + +- [ ] **Step 1: Write the failing unit tests** + +Add to the existing `#[cfg(test)]` module in `terminal_tabs.rs` (or its test split), with a fake probe fn: + +```rust + fn probe_answering( + existence: freshell_platform::resume_gate::ResumeExistence, + ever_on_disk: bool, + ) -> freshell_platform::resume_gate::ResumeProbeFn { + std::sync::Arc::new(move |_provider: &str, _sid: &str| { + freshell_platform::resume_gate::ResumeProbeAnswer { + existence, + ever_observed_on_disk: ever_on_disk, + } + }) + } + + #[test] + fn rest_resume_amplifier_absent_mints_fresh_and_notices() { + use freshell_platform::resume_gate::ResumeExistence; + let probe = probe_answering(ResumeExistence::Absent, true); + let out = validate_rest_resume( + "amplifier", + Some("stale-amp".into()), + LaunchIntent::Resume, + Some(&probe), + ); + assert_ne!(out.resume_session_id.as_deref(), Some("stale-amp")); + assert!(out.resume_session_id.is_some()); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-amp")); + assert!(out.notice.as_deref().unwrap().contains("stale-amp")); + } + + #[test] + fn rest_resume_without_probe_is_passthrough() { + let out = validate_rest_resume( + "amplifier", + Some("anything".into()), + LaunchIntent::Resume, + None, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("anything")); + assert!(out.stale_session_id.is_none()); + assert!(out.notice.is_none()); + } + + #[test] + fn rest_resume_unknown_and_present_fail_open() { + use freshell_platform::resume_gate::ResumeExistence; + for e in [ResumeExistence::Unknown, ResumeExistence::Present] { + let probe = probe_answering(e, false); + let out = validate_rest_resume( + "opencode", + Some("ses_x".into()), + LaunchIntent::Resume, + Some(&probe), + ); + assert_eq!(out.resume_session_id.as_deref(), Some("ses_x")); + assert!(out.notice.is_none()); + } + } + + #[test] + fn rest_resume_codex_absent_drops_resume() { + use freshell_platform::resume_gate::ResumeExistence; + let probe = probe_answering(ResumeExistence::Absent, true); + let out = validate_rest_resume( + "codex", + Some("stale-cx".into()), + LaunchIntent::Resume, + Some(&probe), + ); + assert!(out.resume_session_id.is_none()); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-cx")); + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test -p freshell-freshagent rest_resume` +Expected: FAIL to compile — `validate_rest_resume` not defined. + +- [ ] **Step 3: Implement helper + wiring** + +Implement `validate_rest_resume` (same body shape as `validate_wire_resume`, using `ResumeProbeFn` instead of the trait; `None` probe → passthrough; reuse `evaluate_resume_gate`, `provider_validated`, `stale_resume_notice` from freshell-platform; mint UUIDs via the crate's existing uuid dependency — check `Cargo.toml`, add `uuid` with `v4` feature if absent, matching the workspace version). + +Wire it in the create pipeline: immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`): + +```rust + let rest_outcome = validate_rest_resume( + &mode, + resume_session_id.take(), + launch_intent, + state.resume_probe.as_ref(), + ); + let resume_session_id = rest_outcome.resume_session_id; + let launch_intent = rest_outcome.launch_intent; + if let Some(stale) = rest_outcome.stale_session_id.as_deref() { + if let Some(cb) = &state.on_stale_resume { + cb(&mode, stale); + } + } +``` + +(Adapt local variable names/mutability to the surrounding pipeline code; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1. If this pipeline sets identity/binding from the requested session ref, apply the same stale-ref guard as Task 6 step 4c: when the gate fired, the stale ref must not be stamped.) + +Notice: where the handler builds the response `pane_content` (the `reconcileNotice` injection precedent at `:1657`), when `rest_outcome.notice` is `Some`, insert it as `"reconcileNotice"` in the `pane_content` JSON so the existing client chip/xterm rendering shows it. Add/extend a unit test in the same module asserting the built `pane_content` carries the notice when the gate fires (clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly). + +Add the `FreshAgentState` fields + builders in `lib.rs` (copy the `with_opencode_locator` builder shape exactly). + +In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain: + +```rust + .with_resume_probe({ + let probe = existence_probe.clone(); // the Arc built earlier + std::sync::Arc::new(move |provider: &str, session_id: &str| { + use freshell_platform::resume_gate::{ResumeExistence, ResumeProbeAnswer}; + use freshell_ws::existence::{SessionExistence, SessionExistenceProbe}; + let existence = match probe.exists(provider, session_id) { + SessionExistence::Present => ResumeExistence::Present, + SessionExistence::Absent => ResumeExistence::Absent, + SessionExistence::Unknown | SessionExistence::ProviderUnavailable => { + ResumeExistence::Unknown + } + }; + ResumeProbeAnswer { + existence, + ever_observed_on_disk: probe.ever_observed_on_disk(provider, session_id), + } + }) + }) + .with_on_stale_resume({ + let ledger = pane_ledger.clone(); // the Arc built earlier + std::sync::Arc::new(move |provider: &str, stale_id: &str| { + tracing::warn!( + provider = %provider, + stale_session_id = %stale_id, + "resume validation (REST): cached session missing on disk; spawning fresh" + ); + let _ = ledger.retire_missing(provider, stale_id); + }) + }) +``` + +(Adapt the variable names to what `main.rs` actually calls the probe and ledger Arcs at that point.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-freshagent && cargo test -p freshell-server && cargo clippy -p freshell-freshagent -p freshell-server -- -D warnings` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-freshagent/src/lib.rs crates/freshell-freshagent/src/terminal_tabs.rs crates/freshell-server/src/main.rs +git commit -m "feat(resume-validation): gate freshagent REST create pipeline on disk existence" +``` + +--- + +### Task 9: Deviation ledger entry + full verification sweep + +**Files:** +- Modify: `port/oracle/DEVIATIONS.md` (follow the file's existing entry format) +- No other code changes — this task is documentation + verification. + +**Interfaces:** +- Consumes: everything above. +- Produces: the deviation record `port/AGENTS.md` mandates for deliberate behavior changes vs the Node original, citing the pinning tests. + +- [ ] **Step 1: Write the DEVIATIONS.md entry** + +Append an entry in the file's existing format with this content (adapt heading/ID style to the neighboring entries): + +```markdown +## Resume validation at the spawn doors (2026-07-29) + +**Deviation:** The Node reference passes cached resume session ids straight to +the coding CLI (`server/terminal-registry.ts` `resolveCodingCliCommand`; +`normalizeResumeForSpawn` is the identity function — no on-disk existence +check exists in Node). The Rust server now validates the id against the +provider's on-disk session store before constructing resume argv, at all three +spawn doors (WS `terminal.create`, headless auto-resume respawn, freshagent +REST create). On POSITIVE absence (store readable, session definitively +absent) it spawns fresh in the same cwd/mode, surfaces an operator notice +naming the stale id, and retires the pane-ledger binding +(`RetiredReason::SessionMissing`). Unknown/unreadable stores fail OPEN +(resume attempted, byte-for-byte Node behavior). Providers validated: +claude (with a zero-turn carve-out: Absent + never observed on disk still +resumes), codex, opencode, amplifier. gemini/kimi/third-party are never +blocked. Additive protocol field: optional `notice` on `terminal.created`. + +**Why:** Production incident 2026-07-29 — after a server restart, freshell +resumed stored amplifier session id 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 which +existed nowhere under ~/.amplifier/projects/*/sessions/; the amplifier CLI +silently created a new empty session under that id and the user saw a broken +restore with no explanation. + +**Pinning tests:** `freshell-platform` `resume_gate` unit tests; +`freshell-ws/tests/resume_validation_gate.rs`; +`freshell-ws/tests/auto_resume_respawn.rs` +(`respawn_with_absent_session_spawns_fresh_and_retires_binding`); +`freshell-freshagent` `rest_resume_*` unit tests; +`freshell-server` `existence.rs` amplifier/codex by-id fallback tests. +``` + +- [ ] **Step 2: Full Rust verification** + +Run (long, use a generous timeout): +```bash +cargo fmt --all -- --check +cargo clippy -p freshell-platform -p freshell-sessions -p freshell-server -p freshell-ws -p freshell-freshagent -p freshell-protocol -- -D warnings +cargo test -p freshell-platform -p freshell-sessions -p freshell-protocol +cargo test -p freshell-server -p freshell-ws -p freshell-freshagent +``` +Expected: all green. Fix anything that isn't before proceeding (no known Rust flakes in these crates). + +- [ ] **Step 3: Client verification (coordinator-gated)** + +Run: `npm run test:unit` +Expected: green EXCEPT the known pre-existing `terminal-font-settings.test.tsx` failure from main — if it appears, note it in the commit body and do not chase it. If the coordinator gate is held by another agent, wait (never kill a foreign holder); set `FRESHELL_TEST_SUMMARY="resume-validation final verification"` for holder visibility. + +- [ ] **Step 4: Commit** + +```bash +git add port/oracle/DEVIATIONS.md +git commit -m "docs(resume-validation): deviation ledger entry for spawn-door resume validation" +``` + +- [ ] **Step 5: Final state check** + +`git status` — working tree clean; branch `feat/resume-validation` contains Tasks 1–9 commits. STOP HERE: no PR, no merge, no server restart/deploy. + +--- + +## Self-Review Record + +**1. Spec coverage:** +- "Validate before constructing the resume command" → Tasks 6 (WS create/restore), 7 (auto-resume respawn), 8 (REST create) — all three call sites of `resolve_coding_cli_command` are gated. ✅ +- "If validation fails: don't pass the resume flag; spawn fresh in same cwd/mode" → fallback shapes in `validate_wire_resume`/`validate_rest_resume` mirror each mode's genuine fresh-pane spawn; cwd/mode inputs are untouched. ✅ +- "Operator-visible notice naming the stale id" → Task 5 (`notice` on `terminal.created` + xterm rendering, door 1), Task 8 (`reconcileNotice` in `pane_content`, door 3); door 2 broadcasts `terminal.status{Recovering, reason}` + warn-log — documented as the best available channel on a headless path (deliberate, stated in Design Notes). ✅ +- "Clear/mark the stale cached id so it isn't retried forever" → Task 4 `retire_missing` (`SessionMissing`), invoked at all three doors. ✅ +- "Fail open on Unknown/unreadable" → gate policy (Task 1) + probe `Unreadable→Unknown` mappings (Tasks 2–3) + explicit fail-open tests at every layer. ✅ +- "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints. ✅ +- "Cover providers freshell already reads; fail open for others" → claude/codex/opencode/amplifier validated; gemini/kimi/third-party never blocked (tested). ✅ +- "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints). ✅ +- "Non-goals respected" → no amplifier-CLI changes; no indexing subsystem changes beyond two by-id fallbacks the validation needs; protocol change limited to one additive optional field, mirrored per convention. ✅ +- "TDD, unit + e2e, coordinated test commands, worktree, no PR/deploy, known flaky test" → per-task RGR steps + Task 9 sweep + Global Constraints. ✅ + +**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose own by-id fallback behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. No requirement was moved to known-limitations/future-work. NO UNRESOLVED COVERAGE GAPS. + +**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback tests, Task 6/7 integration tests, Task 5 client test, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. No TBD/TODO/"handle edge cases" remain. + +**3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2) matches Task 3's `amplifier_dir_locator` mapping; `ByIdAnswer` (Task 3) used by both locators; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` (Task 6) matches Task 7's call; `notice: Option` field name consistent across Tasks 5 and 6. ✅ From 6d11a149c9e7aeec3663fc79f402f802745e2824 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:48:25 -0700 Subject: [PATCH 02/35] docs(resume-validation): harden plan per load-bearing validation findings Nine validators ran against the plan's load-bearing assumptions (ledger in .worktrees/.the-usual-logs/resume-validation/). Falsified assumptions fixed: locator error-accumulator contract (false-Absent IO paths), cold-index coverage via direct amplifier/claude locators, spawn_blocking probe calls, gate moved after the D7 liveness guard, mandatory accepted_session_ref guard + Start threading at door 3, notice-triggered client ref clear for codex/opencode, corrected protocol-mirror mechanics (shared/ws-protocol.ts + regenerated schema), and claude coverage honestly rescoped to same-boot deletions. Design decisions AD-1..AD-4 recorded. --- docs/plans/2026-07-29-resume-validation.md | 612 +++++++++++++++++---- 1 file changed, 501 insertions(+), 111 deletions(-) diff --git a/docs/plans/2026-07-29-resume-validation.md b/docs/plans/2026-07-29-resume-validation.md index 4653acf12..c411a668a 100644 --- a/docs/plans/2026-07-29-resume-validation.md +++ b/docs/plans/2026-07-29-resume-validation.md @@ -23,7 +23,8 @@ - Rust tests: plain `cargo test -p ` (no coordinator gate for Rust). Client tests: `npm run test:unit` (coordinator-gated; wait for the gate, never kill a foreign holder). Raw `npx vitest` is not a coordinated workflow. - Known pre-existing failure on main, unrelated: `terminal-font-settings.test.tsx` — note it if it appears in suite runs, do not chase it. - `port/AGENTS.md` mandates a `port/oracle/DEVIATIONS.md` entry for deliberate behavior changes vs the Node original — this feature is one (Task 9). -- Protocol change is limited to ONE additive optional field (`notice` on `terminal.created`), mirrored in `crates/freshell-protocol` and the port contract schemas per repo convention. No new message types (the `SERVER_MESSAGE_TYPES` inventory is frozen). +- Protocol change is limited to ONE additive optional field (`notice` on `terminal.created`), mirrored in `crates/freshell-protocol` AND `shared/ws-protocol.ts` (the schema's source of truth), with `port/contract/ws-server-messages.schema.json` REGENERATED via `npm run contract:generate` — never hand-edited (drift-guarded by `test/unit/port/ws-contract-freeze.test.ts`). No new message types (the `SERVER_MESSAGE_TYPES` inventory is frozen). No `server/` (Node) files change — the precedent commits for this exact pattern (`5c591843`, `a18dd4c6`) touched zero. +- Gate calls are non-blocking (A13): the disk probe + by-id locators do real filesystem walks (the codex by-id walk measured ~0.9–1.1 s warm-cache on a real store — 20x earlier estimates), so every door runs the sync validation helper inside `tokio::task::spawn_blocking`. The pure gate policy (Task 1) stays sync/pure. This shape (sync helper, door wraps in `spawn_blocking`) is used consistently across all three doors (Tasks 6–8). - Amplifier existence is checked across ALL project slugs (`~/.amplifier/projects/*/sessions//`), not just the cwd-derived slug — a session may live under a different project slug than the current cwd if the tab moved. The index source already walks all projects; the new by-id fallback scans all project dirs too. ## Design Notes (read before Task 1) @@ -44,15 +45,35 @@ - `codex` / `opencode` → `resume_session_id = None` (fresh panes of these modes don't preallocate). **Carve-outs the gate must honor (fail-open bias):** -- claude zero-turn: a claude session created by freshell that never conversed has no `.jsonl` on disk yet. `Absent` + `!ever_observed_on_disk` → Proceed (mirrors `reconcile.rs:365-403`). This is deliberately MORE fail-open than reconcile (no ledger-bound requirement). +- claude zero-turn: a claude session created by freshell that never conversed has no `.jsonl` on disk yet. `Absent` + `!ever_observed_on_disk` → Proceed (mirrors `reconcile.rs:365-403`). This is deliberately MORE fail-open than reconcile (no ledger-bound requirement). Scope (AD-2): `ever_observed_on_disk` is a per-boot in-memory set (`existence.rs:293-298`, never persisted), so claude validation covers SAME-BOOT deletions only — a transcript deleted while the server was down is indistinguishable from never-conversed and fails open forever post-restart. Deliberate: `ever_observed` is NOT a sound swap (it ORs ledger `ever_bound`, which would break the zero-turn carve-out). - Preallocated fresh ids (claude Start prealloc, amplifier fresh prealloc) are intentionally not on disk — the gate only runs on ids that came from the wire (sessionRef / legacy resumeSessionId / claude restore ladder), never on ids the server just minted. - gemini/kimi: no `resumeArgs` at all and outside `KNOWN_PROVIDERS` — gate never consults the probe for them. **Notice visibility per door:** -- Door 1 (WS create): new optional `notice` field on the `terminal.created` success frame; small client change renders it into the pane's xterm exactly like the reconcile notice. +- Door 1 (WS create): new optional `notice` field on the `terminal.created` success frame; small client change renders it into the pane's xterm exactly like the reconcile notice AND clears the pane's persisted `sessionRef`/`resumeSessionId` (required for codex/opencode, whose gate-fired spawns carry no `sessionRef` — see the "not retried forever" story below). - Door 2 (headless respawn): no `out` sink exists; broadcast the existing `terminal.status{Recovering, reason}` frame (precedent `auto_resume.rs:604-628`) + `tracing::warn!`. Reason prose is not client-rendered today; this is the best available channel on a headless crash-recovery path where `Absent` is near-impossible (the session was alive moments ago). Documented, deliberate. - Door 3 (REST create): inject `reconcileNotice` into the returned `pane_content` (existing field, existing client rendering — no client change needed). +**Validation findings incorporated (2026-07-29)** (9 evidence-gathering validators ran against this plan; ledger + reports in `.worktrees/.the-usual-logs/resume-validation/`): + +- **Non-blocking gate (A13 falsified):** the codex by-id walk measured ~0.9–1.1 s warm-cache on a real store. Validation helpers stay sync; every door runs them inside `tokio::task::spawn_blocking` (see Global Constraints). The Task 1 policy stays pure. +- **Cold-index coverage (A1 falsified):** the index snapshot starts `None` every boot and the boot sweep is a detached task never awaited before the WS listener binds — restore-time creates can hit a cold index. Gate-facing per-provider coverage matrix: + +| Provider | Index COLD (`peek() == None`) | Index WARM (Absent adjudication; also covers TTL-stale snapshots) | +|---|---|---| +| amplifier | direct by-id dir scan (`session_on_disk`, cheap) → Present/Absent/Unknown | snapshot + by-id fallback | +| claude | existing transcript locator (cheap) → Present/Absent/Unknown | snapshot + raw-file fallback (existing) | +| codex | Unknown — fail open (AD-4) | snapshot + gate-safe by-id walk | +| opencode | Unknown — fail open (AD-4) | snapshot + sqlite by-id (existing) | + + The incident provider (amplifier) is covered even before warm-up. **AD-4 (accepted residual):** codex/opencode restores in the first seconds after boot fail open — the ~1 s codex walk stays on the warm-Absent adjudication path only. The cold-path `ProviderUnavailable` answer (provider root missing pre-warm) is untouched. +- **Locator error contract (A3 falsified — six empirical false-Absent reproductions):** errors-seen accumulator. `Present` short-circuits; a scan that completes with ANY per-entry error (unreadable subdir, EACCES file, `read_dir` failure below root, first-line open/read failure) and no hit answers `Unreadable` → `Unknown` → fail open. Never adjudicate via `.is_dir()` (returns `false` on EACCES) or `fs::metadata(root)` (tests existence, not readability). +- **Missing store root (AD-1):** store-root NotFound with a readable parent ⇒ `Absent` (positive absence — matches today's warm-path steady state and prevents the incident on fresh installs/secondary devices); any read/permission ERROR at the root ⇒ `Unreadable` (fail open). +- **Claude scope (AD-2):** claude validation covers same-boot deletions only (per-boot in-memory `ever_observed_on_disk`; see the carve-out bullet above). +- **Door 1 ordering (A11 falsified):** the gate runs AFTER the D7 cross-mode liveness guard and BEFORE the amplifier pre-create re-stub, with an in-gate liveness precondition for legacy carriers (Task 6). +- **"Not retried forever" (A5 falsified-split):** four cooperating mechanisms — ledger `retire_missing` (server, all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold) + notice-triggered clear of the pane's persisted `sessionRef`/`resumeSessionId` (codex/opencode, Task 5) + the mandatory `accepted_session_ref` guard at door 3 (Task 8). +- **AD-3 (accepted):** after a gate-fired headless respawn, the auto-resume hub's `complete_claim` keeps the STALE locator's lease bound to the fresh terminal (`auto_resume.rs:529-547`) — convergent, documented with a code comment (Task 7). + ## File Structure | File | Action | Responsibility | @@ -60,14 +81,17 @@ | `crates/freshell-platform/src/resume_gate.rs` | Create | Pure gate policy: provider list, `ResumeExistence`, `evaluate_resume_gate`, shared probe-fn types for freshagent injection | | `crates/freshell-platform/src/lib.rs` | Modify | `pub mod resume_gate;` | | `crates/freshell-sessions/src/amplifier_stub.rs` | Modify | Read-only `session_on_disk()` existence scan across all project slugs | -| `crates/freshell-server/src/existence.rs` | Modify | Amplifier + codex by-id fallbacks on `IndexExistenceProbe` | +| `crates/freshell-server/src/existence.rs` | Modify | Amplifier + codex by-id fallbacks on `IndexExistenceProbe`; gate-safe tri-state codex walk (`codex_rollout_on_disk`); cold-index amplifier/claude coverage in `exists()` | | `crates/freshell-server/src/main.rs` | Modify | Wire new locators into the probe; wire probe + retire callback into `FreshAgentState` | | `crates/freshell-ws/src/pane_ledger.rs` | Modify | `RetiredReason::SessionMissing` + `retire_missing()` | | `crates/freshell-ws/src/pane_ledger_tests.rs` | Modify | Tests for `retire_missing` | | `crates/freshell-protocol/src/server_messages.rs` | Modify | Optional `notice` on `TerminalCreated` | -| `src/components/TerminalView.tsx` | Modify | Render `terminal.created` notice into xterm (locate the create-success handler near the `[Restore failed]` handling at `:4691`) | -| `crates/freshell-ws/src/resume_validation.rs` | Create | Wire-resume validation helper shared by doors 1 & 2 (probe → gate → fallback outcome) | -| `crates/freshell-ws/src/terminal.rs` | Modify | Doors 1 & 2 call the helper; retire + notice emission | +| `shared/ws-protocol.ts` | Modify | Source of truth for the contract schema: optional `notice` on the `terminal.created` type (~`:725`) | +| `port/contract/ws-server-messages.schema.json` | Regenerate | Via `npm run contract:generate` — never hand-edit (drift-guarded) | +| `crates/freshell-protocol/tests/roundtrip.rs` | Modify | Wire test including `notice` (precedent commits `5c591843`, `a18dd4c6`) | +| `src/components/TerminalView.tsx` | Modify | Render `terminal.created` notice into xterm (locate the create-success handler near the `[Restore failed]` handling at `:4691`) + clear the pane's persisted `sessionRef`/`resumeSessionId` when `notice` is present | +| `crates/freshell-ws/src/resume_validation.rs` | Create | Wire-resume validation helper shared by doors 1 & 2 (probe → gate → fallback outcome); sync — doors run it inside `spawn_blocking` | +| `crates/freshell-ws/src/terminal.rs` | Modify | Doors 1 & 2 run the helper via `spawn_blocking` (after D7, before the amplifier re-stub; in-gate liveness precondition); retire + notice emission | | `crates/freshell-ws/tests/resume_validation_gate.rs` | Create | Integration coverage for door 1 (and door 2 if the harness reaches it) | | `crates/freshell-freshagent/src/lib.rs` | Modify | `FreshAgentState`: `with_resume_probe`, `with_on_stale_resume` builders | | `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify | Door 3 gate + `reconcileNotice` injection | @@ -94,6 +118,8 @@ - `pub type ResumeProbeFn = std::sync::Arc ResumeProbeAnswer + Send + Sync>` - `pub fn stale_resume_notice(provider: &str, stale_id: &str) -> String` +The gate policy stays synchronous and PURE — it never does IO. The disk-probe IO it consumes is gathered by the doors, which run the sync validation helpers inside `tokio::task::spawn_blocking` (Tasks 6–8; see Global Constraints). + - [ ] **Step 1: Write the failing tests** Create `crates/freshell-platform/src/resume_gate.rs` with ONLY the test module first (referencing the not-yet-written items), or write tests + a `todo!()`-free skeleton — the repo convention is inline tests, so create the file with the tests below and empty stubs that don't compile yet is NOT allowed; instead write the tests and run them against a minimal non-implementing body (e.g. `evaluate_resume_gate` returning `Proceed` unconditionally) so the Absent cases FAIL: @@ -300,6 +326,16 @@ Add to the `#[cfg(test)] mod` in `amplifier_stub.rs` (create a helper mirroring dir } + /// Permission tests are meaningless as root (root ignores mode bits) — + /// e.g. sandboxed/container suites. Skip (return early) when euid == 0. + fn running_as_root() -> bool { + std::process::Command::new("id") + .arg("-u") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") + .unwrap_or(false) + } + #[test] fn session_on_disk_present_under_cwd_slug() { let home = scan_temp_home("present"); @@ -354,6 +390,9 @@ Add to the `#[cfg(test)] mod` in `amplifier_stub.rs` (create a helper mirroring #[test] fn session_on_disk_unreadable_projects_dir_fails_open() { use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; // root ignores mode bits — test is meaningless + } let home = scan_temp_home("unreadable"); let projects = home.join("projects"); std::fs::create_dir_all(&projects).unwrap(); @@ -365,6 +404,46 @@ Add to the `#[cfg(test)] mod` in `amplifier_stub.rs` (create a helper mirroring let _ = std::fs::remove_dir_all(&home); } + #[cfg(unix)] + #[test] + fn session_on_disk_unreadable_project_subdir_fails_open() { + // V3 case E2a: a chmod-000 PROJECT dir with the session inside must + // answer Unreadable, never Absent (`.is_dir()` returns false on + // EACCES — the errors-seen accumulator catches the metadata error). + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("locked-project"); + let locked = home.join("projects/-locked-proj"); + std::fs::create_dir_all(locked.join("sessions/sid-7")).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-7"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(unix)] + #[test] + fn session_on_disk_listable_not_traversable_projects_fails_open() { + // V3 case E2b: projects/ mode 444 — read_dir succeeds (needs r) but + // stat into children needs x, so every per-entry metadata call errors. + // Any-error-and-no-hit => Unreadable. + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("no-traverse"); + let projects = home.join("projects"); + std::fs::create_dir_all(projects.join("-p/sessions/sid-8")).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o444)).unwrap(); + let answer = session_on_disk(&home, "sid-8"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + #[test] fn session_on_disk_matches_a_file_only_as_absent() { // A stray FILE named like the session id is not a session dir. @@ -395,12 +474,17 @@ Add beside `ensure_session` in `amplifier_stub.rs`: /// a different slug than the current cwd — see `ensure_session`'s divergent- /// slug handling). Never creates anything. /// -/// Semantics (resume-validation feature): -/// * session dir found under any project => `Present`; -/// * `projects/` missing or scanned without a hit => `Absent` -/// (store readable, definitively absent); -/// * `projects/` unreadable (permissions, IO error) => `Unreadable` -/// (callers must fail OPEN — treat as unknown, never as absent). +/// Semantics (resume-validation feature — errors-seen accumulator, V3): +/// * session dir found under any project => `Present` (short-circuits); +/// * `projects/` missing (NotFound, parent readable) or scanned WITHOUT any +/// error and without a hit => `Absent` (store readable, definitively +/// absent — AD-1: missing root is positive absence, matching today's +/// warm-path steady state); +/// * `projects/` unreadable at the root, OR any per-entry error during the +/// scan (unreadable project subdir, EACCES stat, dropped dir entry) with +/// no hit => `Unreadable` (callers must fail OPEN — treat as unknown, +/// never as absent). NEVER adjudicate via `.is_dir()` alone: it returns +/// `false` on EACCES and would manufacture a false Absent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AmplifierSessionAnswer { Present, @@ -416,16 +500,32 @@ pub fn session_on_disk( let entries = match std::fs::read_dir(&projects) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return AmplifierSessionAnswer::Absent; + return AmplifierSessionAnswer::Absent; // AD-1: root missing, parent readable } Err(_) => return AmplifierSessionAnswer::Unreadable, }; - for entry in entries.flatten() { - if entry.path().join("sessions").join(session_id).is_dir() { - return AmplifierSessionAnswer::Present; + let mut saw_error = false; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + saw_error = true; // dropped dir entry (EIO, network fs) — cannot rule out + continue; + } + }; + let candidate = entry.path().join("sessions").join(session_id); + match std::fs::metadata(&candidate) { + Ok(meta) if meta.is_dir() => return AmplifierSessionAnswer::Present, + Ok(_) => {} // stray FILE named like the id — not a session dir + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => saw_error = true, // EACCES etc. — the session may be hiding here } } - AmplifierSessionAnswer::Absent + if saw_error { + AmplifierSessionAnswer::Unreadable + } else { + AmplifierSessionAnswer::Absent + } } ``` @@ -434,7 +534,7 @@ Refactor step: `ensure_session`'s internal scan loop (`:134-148`) and this funct - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test -p freshell-sessions session_on_disk` -Expected: PASS (6 tests; 5 on non-unix). Then `cargo test -p freshell-sessions` — full crate still green. +Expected: PASS (8 tests; 5 on non-unix — the three permission tests are `#[cfg(unix)]` and additionally self-skip under root). Then `cargo test -p freshell-sessions` — full crate still green. - [ ] **Step 5: Commit** @@ -445,9 +545,12 @@ git commit -m "feat(resume-validation): read-only amplifier session_on_disk scan --- -### Task 3: Amplifier + codex by-id fallbacks on `IndexExistenceProbe` +### Task 3: Amplifier + codex by-id fallbacks on `IndexExistenceProbe` + cold-index coverage + +Why (two problems, one probe change): -Why: the probe's warm snapshot can be STALE — a session created moments before a restart may be missing from the snapshot. The claude arm already has a raw-file fallback for exactly this; without equivalents, the gate could misjudge a brand-new amplifier/codex session as `Absent` and wrongly block a working resume (violating the fail-open invariant). After this task, `Absent` for all four validated providers means POSITIVE absence. +1. **Stale warm snapshots.** The probe's warm snapshot can be STALE — a session created moments before a restart may be missing from the snapshot, and `peek()` serves snapshots regardless of TTL. The claude arm already has a raw-file fallback for exactly this; without equivalents, the gate could misjudge a brand-new amplifier/codex session as `Absent` and wrongly block a working resume (violating the fail-open invariant). After this task, `Absent` for all four validated providers means POSITIVE absence. +2. **Cold index at boot (A1 falsified).** The snapshot starts `None` EVERY boot (`directory_index.rs:687`) and the boot sweep is a detached `tokio::spawn` (`main.rs:796`) never awaited before the listener binds (`main.rs:1122`) — so restore-time creates routinely race a cold index. Today's cold `exists()` answers `Unknown` (root exists) → the gate would silently no-op in the EXACT incident scenario. Fix: when the index is COLD, `exists()` still runs the cheap direct by-id locators for amplifier (Task 2's `session_on_disk`) and claude (the existing transcript locator) — both are cheap directory checks — mapping Present/Absent/Unreadable → Present/Absent/Unknown. codex and opencode answer `Unknown` when cold (fail open; accepted residual AD-4 — the ~1 s codex walk must not run on every early-boot create). The incident provider (amplifier) is therefore covered even before warm-up. The cold-path `ProviderUnavailable` answer (provider root missing) stays untouched. **Files:** - Modify: `crates/freshell-server/src/existence.rs` @@ -455,14 +558,16 @@ Why: the probe's warm snapshot can be STALE — a session created moments before - Test: inline `#[cfg(test)]` in `existence.rs` (mirror the existing hand-rolled temp-dir helpers at `existence.rs:307-318` and the existing fallback tests for claude/opencode) **Interfaces:** -- Consumes: `freshell_sessions::amplifier_stub::{session_on_disk, AmplifierSessionAnswer}` (Task 2); `freshell_ws::codex_reconcile::locate_codex_rollout(sessions_root: &Path, session_id: &str) -> Option` (existing, `codex_reconcile.rs:154`). +- Consumes: `freshell_sessions::amplifier_stub::{session_on_disk, AmplifierSessionAnswer}` (Task 2). The codex rollout layout + ownership convention are taken from `freshell_ws::codex_reconcile::locate_codex_rollout`/`first_line_owns` (`codex_reconcile.rs:154-207`) as REFERENCE ONLY — that walk is fail-soft (silent `None` on per-entry errors, six false-Absent reproductions in V3) and MUST NOT be reused for the gate; reconcile's existing callers keep it unchanged. - Produces (wired in `main.rs`, consumed implicitly by every `probe.exists()` caller): - `pub enum ByIdAnswer { Present, Absent, Unreadable }` (one shared enum for both new locators; mirror `OpencodeDbAnswer`'s shape) - `pub type AmplifierSessionLocator = Arc ByIdAnswer + Send + Sync>` - `pub type CodexRolloutExistenceLocator = Arc ByIdAnswer + Send + Sync>` - `impl IndexExistenceProbe { pub fn with_amplifier_session_locator(self, l: AmplifierSessionLocator) -> Self; pub fn with_codex_rollout_locator(self, l: CodexRolloutExistenceLocator) -> Self }` - `pub fn amplifier_dir_locator(amplifier_home: PathBuf) -> AmplifierSessionLocator` - - `pub fn codex_rollout_existence_locator(sessions_root: PathBuf) -> CodexRolloutExistenceLocator` + - `pub fn codex_rollout_on_disk(sessions_root: &Path, session_id: &str) -> ByIdAnswer` — NEW gate-safe tri-state walk (errors-seen accumulator), lives in `freshell-server/src/existence.rs` + - `pub fn codex_rollout_existence_locator(sessions_root: PathBuf) -> CodexRolloutExistenceLocator` (wraps `codex_rollout_on_disk`) + - Cold-index behavior change in `exists()`: when `peek() == None` (after the untouched root-missing → `ProviderUnavailable` check), amplifier and claude consult their cheap by-id locators instead of answering `Unknown`; codex/opencode stay `Unknown` (AD-4) - [ ] **Step 1: Write the failing tests** @@ -508,8 +613,63 @@ Add to the existing `#[cfg(test)] mod tests` in `existence.rs`, copying the cons #[test] fn codex_missing_sessions_root_is_absent_and_unreadable_is_unknown() { - // sessions root NotFound => Absent (fresh install, store readable-empty); - // locator returning Unreadable => probe answers Unknown. + // sessions root NotFound => Absent (AD-1: fresh install, parent + // readable); locator returning Unreadable => probe answers Unknown. + // Root readability MUST be established via read_dir, not + // fs::metadata — metadata tests existence, not readability (V3 E3). + } + + #[test] + fn codex_zst_rollout_is_candidate_but_undecodable_answers_unreadable() { + // Future codex rollout compression (V2): a file named + // rollout-...-.jsonl.zst whose name contains the id MUST pass the + // filename prefilter; its first line is not plain JSONL, so the + // ownership read fails => counts as an error => Unreadable (probe + // answers Unknown — fail open, never Absent for a CLI-resumable file). + // Setup: temp root with only the .zst candidate (zstd-magic bytes or + // arbitrary binary). Assert codex_rollout_on_disk(...) == Unreadable. + } + + #[cfg(unix)] + #[test] + fn codex_unreadable_date_subdir_answers_unreadable() { + // V3 case E4: rollout lives under sessions/2026/07/29/ chmod 000. + // Skip when running as root (euid==0 — mode bits ignored); use the + // same running_as_root() helper pattern as Task 2's tests. + // Assert codex_rollout_on_disk(...) == Unreadable (per-entry error + // accumulated, no silent skip), and probe.exists(...) == Unknown. + } + + #[cfg(unix)] + #[test] + fn codex_unreadable_candidate_file_answers_unreadable() { + // V3 case E7: the owning rollout file itself is chmod 000 — the + // first-line ownership read fails => error => Unreadable, never + // Absent. Skip when euid==0. + } + + #[test] + fn cold_index_amplifier_uses_dir_locator() { + // Probe with NO warm snapshot (peek() == None, the every-boot state) + // but amplifier root present and .with_amplifier_session_locator(...) + // chained. Assert: session dir on disk => Present; readable-empty + // store => Absent; locator Unreadable => Unknown. (This is the + // incident scenario: restore-time create racing the boot sweep.) + } + + #[test] + fn cold_index_claude_uses_transcript_locator() { + // Same shape via the EXISTING claude transcript locator: cold index + + // transcript file on disk => Present; readable store without it => + // Absent (gate still Proceeds unless ever_observed_on_disk — Task 1). + } + + #[test] + fn cold_index_codex_and_opencode_answer_unknown() { + // Cold index + locators chained: codex/opencode must NOT run their + // by-id lookups when cold (AD-4 — the codex walk is ~1 s on a real + // store). Assert Unknown for both. The root-missing => + // ProviderUnavailable pre-check stays byte-for-byte today's behavior. } ``` @@ -546,21 +706,86 @@ pub fn amplifier_dir_locator(amplifier_home: std::path::PathBuf) -> AmplifierSes }) } +/// Gate-safe tri-state codex rollout walk (resume-validation feature). +/// Deliberately a NEW walk, NOT a reuse of the fail-soft +/// `freshell_ws::codex_reconcile::locate_codex_rollout` — that helper +/// silently converts per-entry IO errors into `None`, which the gate would +/// read as positive absence (six false-Absent reproductions in V3). +/// Errors-seen accumulator: `Present` short-circuits; a walk that completes +/// having seen ANY per-entry error and no hit answers `Unreadable`. +pub fn codex_rollout_on_disk( + sessions_root: &std::path::Path, + session_id: &str, +) -> ByIdAnswer { + // Root readability is established by read_dir itself: NotFound (parent + // readable) => Absent (AD-1); any other error => Unreadable. NOTE: + // `fs::metadata(root)` is NOT sufficient — it tests existence, not + // readability (a mode-111 root passes metadata but fails read_dir; V3 E3). + let mut stack = vec![sessions_root.to_path_buf()]; + let mut saw_error = false; + let mut first_level = true; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) if first_level && err.kind() == std::io::ErrorKind::NotFound => { + return ByIdAnswer::Absent; + } + Err(_) if first_level => return ByIdAnswer::Unreadable, + Err(_) => { + saw_error = true; // unreadable subtree below the root — may hide the rollout + continue; + } + }; + first_level = false; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + saw_error = true; + continue; + } + }; + let path = entry.path(); + // Never `.is_dir()` — false on EACCES (V3 E6). + match std::fs::metadata(&path) { + Ok(meta) if meta.is_dir() => stack.push(path), + Ok(_) => { + let name = entry.file_name(); + let name = name.to_string_lossy(); + // Filename prefilter: id-in-name (verified convention, + // V2: 4459/4459 real rollouts + codex source constructs + // the name from the id) — accept both `.jsonl` and + // `.jsonl.zst` (future codex rollout compression, V2). + if name.contains(session_id) + && (name.ends_with(".jsonl") || name.ends_with(".jsonl.zst")) + { + // Ownership check: first-line session_meta id == + // session_id => Present (short-circuit). Mirror + // `first_line_owns` (`codex_reconcile.rs:188-207`) + // but tri-state: open/read/decode failure on a + // candidate (incl. an undecodable `.jsonl.zst`) + // counts as an ERROR (saw_error = true), never as + // "not the owner"; only a VALID read whose id + // differs keeps walking as a non-owner. + // (Implement as a private fn returning + // Result and fold Err into saw_error.) + } + } + Err(_) => saw_error = true, + } + } + } + if saw_error { + ByIdAnswer::Unreadable + } else { + ByIdAnswer::Absent + } +} + pub fn codex_rollout_existence_locator( sessions_root: std::path::PathBuf, ) -> CodexRolloutExistenceLocator { - Arc::new(move |session_id: &str| { - match std::fs::metadata(&sessions_root) { - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return ByIdAnswer::Absent, - Err(_) => return ByIdAnswer::Unreadable, - Ok(meta) if !meta.is_dir() => return ByIdAnswer::Unreadable, - Ok(_) => {} - } - match freshell_ws::codex_reconcile::locate_codex_rollout(&sessions_root, session_id) { - Some(_) => ByIdAnswer::Present, - None => ByIdAnswer::Absent, - } - }) + Arc::new(move |session_id: &str| codex_rollout_on_disk(&sessions_root, session_id)) } ``` @@ -589,7 +814,40 @@ Add two `Option<...>` fields to `IndexExistenceProbe` plus builder methods `with } ``` -(Place them at the same adjudication point as the existing fallbacks — only reached when the snapshot would otherwise answer `Absent`.) +(Place them at the same adjudication point as the existing fallbacks — only reached when the snapshot would otherwise answer `Absent`. This warm-Absent adjudication also covers TTL-stale snapshots — `peek()` serves snapshots regardless of TTL, so a session created after the last publish reads a stale Absent and is rescued here.) + +Cold-index coverage (`exists()`, the `peek() == None` branch at `existence.rs:182-205`): keep the existing provider-root-missing → `ProviderUnavailable` pre-check byte-for-byte. Then, instead of returning `Unknown` unconditionally: + +```rust + None => { + if self.provider_roots.get(provider).is_some_and(|root| !root.exists()) { + return SessionExistence::ProviderUnavailable; // untouched cold-path answer + } + // Cold-index coverage (resume-validation, A1): the snapshot is None + // every boot and the warm sweep is detached — restore-time creates + // race it. amplifier + claude have CHEAP direct by-id locators; run + // them so the gate still fires in the incident scenario. codex/ + // opencode stay Unknown when cold (AD-4: the codex walk is ~1 s on + // a real store — warm-Absent adjudication only). + if provider == "amplifier" { + if let Some(locator) = &self.amplifier_session_locator { + return match locator(session_id) { + ByIdAnswer::Present => SessionExistence::Present, + ByIdAnswer::Absent => SessionExistence::Absent, + ByIdAnswer::Unreadable => SessionExistence::Unknown, + }; + } + } + if provider == "claude" { + // Reuse the EXISTING claude transcript locator (raw-file + // check — cheap), mapping its answer the same way the warm + // fallback at :226 does. + } + SessionExistence::Unknown + } +``` + +(Adapt the claude arm to the existing transcript-locator field/return shape at `existence.rs:226` — same mapping, hit ⇒ `Present`, clean miss ⇒ `Absent`, error ⇒ `Unknown`.) In `main.rs`, at the probe construction site, chain the new builders: - `with_amplifier_session_locator(amplifier_dir_locator(home))` where `home` comes from `freshell_sessions::amplifier_stub::resolve_amplifier_home()` (skip chaining when it returns `None` — probe then behaves as today for amplifier). @@ -691,16 +949,21 @@ git commit -m "feat(resume-validation): RetiredReason::SessionMissing + retire_m ### Task 5: `notice` field on `terminal.created` + client rendering **Files:** -- Modify: `crates/freshell-protocol/src/server_messages.rs` (the `TerminalCreated` struct — locate `TerminalCreated` in the file) -- Modify: port contract schema for `terminal.created` (locate via `grep -rn "terminal.created" port/ server/ --include="*.ts" --include="*.json" -l` and update whichever schema/zod/JSON-schema file mirrors the frame's fields — repo convention: protocol fields are mirrored in `crates/freshell-protocol` AND the port contract schemas) +- Modify: `crates/freshell-protocol/src/server_messages.rs` (the `TerminalCreated` struct, `~:956` region) +- Modify: `shared/ws-protocol.ts` (`~:725`, the `terminal.created` type) — this is the SOURCE OF TRUTH the contract schema is generated from +- Regenerate: `port/contract/ws-server-messages.schema.json` via `npm run contract:generate` — NEVER hand-edit the generated JSON (drift-guarded by `test/unit/port/ws-contract-freeze.test.ts`, which byte-compares the committed file against a fresh regeneration) +- Modify: `crates/freshell-protocol/tests/roundtrip.rs` — wire test including `notice`, per the precedent of commits `5c591843` (previousSessionId) and `a18dd4c6` (persisted/persistReason) - Modify: `src/components/TerminalView.tsx` — the `terminal.created` success handler (it lives near the create-`error` handling that renders `[Restore failed]` at `TerminalView.tsx:4691`) - Test: Rust serde test inline in `server_messages.rs` (or the protocol crate's existing test module for server messages); client test added to the existing test file that covers `[Restore failed]` rendering (find it: `grep -rln "Restore failed" test/`) +- NO `server/` (Node) files change — both precedent commits touched zero; `shared/ws-protocol.ts` is shared client/server TYPING, not the Node server. **Interfaces:** - Consumes: nothing new. -- Produces (used by Task 6): `TerminalCreated { …existing fields…, notice: Option }` serialized as `"notice"` only when present (`#[serde(skip_serializing_if = "Option::is_none")]`). Client behavior: when `msg.notice` is a non-empty string, write it into the pane's xterm as its own line, styled exactly like the reconcile-notice write at `TerminalView.tsx:4336`/`:5053`. +- Produces (used by Task 6): `TerminalCreated { …existing fields…, notice: Option }` serialized as `"notice"` only when present (`#[serde(skip_serializing_if = "Option::is_none")]`). Client behavior when `msg.notice` is a non-empty string: + 1. write it into the pane's xterm as its own line, styled exactly like the reconcile-notice write at `TerminalView.tsx:4336`/`:5053`; + 2. ALSO clear the pane's persisted `sessionRef`/`resumeSessionId`. Why (A5, V5): claude/amplifier gate-fired spawns self-heal — `terminal.created.sessionRef` carries the fresh id and the client unconditionally overwrites the pane ref + clears `resumeSessionId` (`TerminalView.tsx:260-281`/`:4261-4288`, verified). codex/opencode gate-fired spawns carry NO `sessionRef` (fallback is `None`), so without this clear the stale persisted ref would re-fire the gate on EVERY restart; clearing on `notice` breaks the loop, and subsequent live identity capture can then associate the fresh session unimpeded. -This is one additive optional field on an existing message type — the frozen `SERVER_MESSAGE_TYPES` inventory (57 entries) is unchanged. The Node server never sets the field; it is optional end-to-end. +This is one additive optional field on an existing message type — the frozen `SERVER_MESSAGE_TYPES` inventory (57 entries) is unchanged. The Node server never sets the field; it is optional end-to-end (the SPA ingests frames via plain `JSON.parse` cast — no zod on server→client). - [ ] **Step 1: Write the failing Rust serde test** @@ -743,7 +1006,14 @@ Expected: FAIL to compile — no `notice` field. pub notice: Option, ``` -Add `notice: None` at every existing `TerminalCreated { … }` construction site (`cargo check` will enumerate them). Update the port contract schema for `terminal.created` with the optional `notice` string field. Run `cargo test -p freshell-protocol --locked` (the CI contract invocation) — green. +Mirror sequence (order matters — the schema is GENERATED, and `roundtrip.rs` reads the committed schema from disk): + +1. Add the field to the Rust `TerminalCreated` (`crates/freshell-protocol/src/server_messages.rs`, `~:956` region) and `notice: None` at every existing `TerminalCreated { … }` construction site (`cargo check` enumerates them; expect `crates/freshell-terminal/src/registry.rs` plus ws/freshagent call sites). +2. Add `notice?: string` to the `terminal.created` type in `shared/ws-protocol.ts` (`~:725`). +3. REGENERATE `port/contract/ws-server-messages.schema.json` via `npm run contract:generate` and commit the regenerated file. NEVER hand-edit the JSON — `test/unit/port/ws-contract-freeze.test.ts` byte-compares it against a fresh regeneration and a hand-edit fails the drift guard. Verify the command runs cleanly in the worktree (it is repo tooling, not a Node-server change). +4. Extend `crates/freshell-protocol/tests/roundtrip.rs` with a wire test including `notice`, per the precedent of commits `5c591843` (previousSessionId on `terminal.session.associated`) and `a18dd4c6` (persisted/persistReason on `tabs.sync.ack`). + +Run `cargo test -p freshell-protocol --locked` (the CI contract invocation) — green. - [ ] **Step 4: Write the failing client test, then render** @@ -758,14 +1028,25 @@ it('writes the resume-validation notice into the terminal on terminal.created', // Assert the xterm write buffer received a line containing // 'Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1'. }); + +it('clears the persisted sessionRef/resumeSessionId when terminal.created carries a notice', () => { + // Same arrangement, pane pre-seeded with a persisted stale + // sessionRef/resumeSessionId (codex/opencode shape: the created frame + // carries a notice but NO sessionRef — the gate's fallback for these + // modes is None, so no sessionRef overwrite will heal the pane). + // Dispatch terminal.created with notice set and no sessionRef. + // Assert the pane content update cleared sessionRef and resumeSessionId + // (breaking the every-restart gate re-fire loop; a later live identity + // capture can then associate the fresh session unimpeded). +}); ``` -Run it (`npm run test:vitest -- --run`), watch it fail. GREEN: in `TerminalView.tsx`'s `terminal.created` handler, when `msg.notice` is a non-empty string, write it into the pane's xterm as its own line using the exact styling/write mechanism of the reconcile-notice write at `:4336`/`:5053`. Re-run the test — PASS. Also re-run the whole file to confirm no regressions. +Run them (`npm run test:vitest -- --run`), watch them fail. GREEN: in `TerminalView.tsx`'s `terminal.created` handler, when `msg.notice` is a non-empty string, (1) write it into the pane's xterm as its own line using the exact styling/write mechanism of the reconcile-notice write at `:4336`/`:5053`, and (2) clear the pane's persisted `sessionRef`/`resumeSessionId` in the same `updateContent` pass the handler already performs (`:4261-4288` region — when the frame ALSO carries a `sessionRef`, the existing overwrite fold wins; the clear only matters for the codex/opencode no-sessionRef case). Re-run the tests — PASS. Also re-run the whole file to confirm no regressions. - [ ] **Step 5: Commit** ```bash -git add crates/freshell-protocol/src/server_messages.rs src/ test/ +git add crates/freshell-protocol/src/server_messages.rs crates/freshell-protocol/tests/roundtrip.rs shared/ws-protocol.ts port/contract/ws-server-messages.schema.json src/ test/ git commit -m "feat(resume-validation): optional notice on terminal.created, rendered in the pane" ``` @@ -776,11 +1057,12 @@ git commit -m "feat(resume-validation): optional notice on terminal.created, ren **Files:** - Create: `crates/freshell-ws/src/resume_validation.rs` - Modify: `crates/freshell-ws/src/lib.rs` (add `pub mod resume_validation;`) -- Modify: `crates/freshell-ws/src/terminal.rs` — `handle_create`, between the resume-id derivation block (ends `:1717`) and the amplifier pre-create block (starts `:1799`) +- Modify: `crates/freshell-ws/src/terminal.rs` — `handle_create`, in the `:1787→:1789` slot: AFTER the D7 cross-mode liveness guard (`:1739-1787`, a pure predicate) and BEFORE the amplifier pre-create block (starts `:1798`) - Test: inline `#[cfg(test)]` in `resume_validation.rs`; integration: `crates/freshell-ws/tests/resume_validation_gate.rs` **Interfaces:** - Consumes: Task 1 (`resume_gate::*`), Task 4 (`retire_missing`), Task 5 (`notice` field), existing `SessionExistenceProbe` (`state.session_existence`), `LaunchIntent`, `uuid::Uuid`. +- `validate_wire_resume` stays SYNC (unit tests stay synchronous); the doors run it inside `tokio::task::spawn_blocking` because the probe's by-id locators do real filesystem walks (A13 — the codex walk is ~1 s on a real store). - Produces (also used by Task 7): ```rust @@ -1068,48 +1350,80 @@ RED: create `crates/freshell-ws/tests/resume_validation_gate.rs`. Reuse the harn 1. `restore_true_amplifier_absent_spawns_fresh_with_notice`: state whose existence probe answers `Absent` for `("amplifier", "stale-amp")` (inject a fake `SharedExistenceProbe` — the harness sets `state.session_existence`; use a fake, not a real index) + a pane ledger containing a `Bound` row for that ref. Send `terminal.create { mode: "amplifier", restore: true, session_ref: {provider:"amplifier", sessionId:"stale-amp"}, cwd: }` (plus `FRESHELL_AMPLIFIER_HOME` pointed at an empty temp home so `ensure_session` runs against the temp store). Assert: the create SUCCEEDS (a `terminal.created` frame arrives, not an `error`); the `terminal.created` frame's `notice` contains `"stale-amp"`; the spawned resume id is NOT `stale-amp` (assert via the ledger: `load_binding("amplifier", "stale-amp")` is `Retired` with reason `SessionMissing`, and no amplifier session dir named `stale-amp` was created under the temp amplifier home — the fresh UUID dir exists instead). 2. `restore_true_amplifier_present_resumes_unchanged`: probe answers `Present`; assert `terminal.created` has NO `notice` and the ledger row stays `Bound`. 3. `restore_true_unknown_fails_open`: probe answers `Unknown`; assert no `notice`, row stays `Bound` (today's behavior preserved). +4. `restore_true_live_absent_sessionref_hits_d7_not_the_gate`: registry/identity hold a RUNNING owner for `("amplifier", "stale-amp")` (set up the liveness state the way D7's own sibling tests do), probe answers `Absent`, ledger holds a `Bound` row. Send the same restore create as case 1. Assert: (a) the response is D7's `RestoreUnavailable` error frame ("still running"), NOT a `terminal.created` fresh spawn; (b) the `Bound` ledger row of the running session SURVIVES (`load_binding` still `Bound` — the gate never saw the create). This pins the after-D7 ordering (V8 §A11). +5. `restore_true_live_absent_legacy_resume_id_fails_open`: same live state, but the create carries the id ONLY in legacy `resumeSessionId` (no `session_ref`) — this carrier bypasses D7 in every ordering. Assert: no `notice`, the row stays `Bound`, and the resume proceeds unchanged. This pins the in-gate liveness precondition. -Run: `cargo test -p freshell-ws --test resume_validation_gate` — tests fail (no gate wired; notice absent, row stays bound in case 1). +Run: `cargo test -p freshell-ws --test resume_validation_gate` — tests fail (no gate wired; notice absent, row stays bound in case 1; cases 4/5 fail once the gate exists if ordering/liveness are wrong — write them RED now so the GREEN wiring must satisfy them). -GREEN — wire `handle_create` (`terminal.rs`), inside the resume-id derivation region: +GREEN — wire `handle_create` (`terminal.rs`): a tracker in the resume-id derivation region (a), the gate itself in the `:1787→:1789` slot (b), then the stamping guard (c) and notice emission (d): a. In the derivation block (`:1617-1717`), add a tracker so the gate only sees wire-derived ids — in the `else` branch (the non-prealloc arm that reads `requested_ref` / `create.resume_session_id` / the claude restore ladder), set a new local `let mut resume_id_from_wire = true;` (declared `false` above the block). -b. Immediately after the derivation block (before the D7 liveness guard at `:1739` and the amplifier pre-create at `:1799`), insert: +b. Insert the gate in the `:1787→:1789` slot — AFTER the D7 cross-mode liveness guard (`:1739-1787`) and BEFORE the amplifier pre-create block (`:1798+`). Both bounds are load-bearing (V8 §A11): + +- **After D7:** gate-before-D7 would let a gate fire replace the resume id, which FALSIFIES D7's applicability filter (`resume_session_id == wire sid`) — its loud "still running" reject is silently bypassed and `retire_missing` destroys the `Bound` ledger row of a RUNNING session (breaking `pre_respawn_guard`). D7 is a pure predicate with zero state mutation, so the gate sees byte-identical inputs in the later slot. +- **Before the amplifier pre-create:** the `ensure_session` re-stub (`:1800-1802`) would resurrect the stale dir under the very id the gate exists to catch. ```rust // Resume validation (docs/plans/2026-07-29-resume-validation.md): never // hand the CLI a resume id that is definitively absent from the // provider's on-disk store. Fail open on Unknown/ProviderUnavailable. + // Placed AFTER the D7 liveness guard (a gate fire would falsify D7's + // applicability filter and retire a running session's Bound row) and + // BEFORE the amplifier ensure_session re-stub (which would resurrect + // the stale dir). let mut resume_fallback_notice: Option = None; if resume_id_from_wire { - let outcome = crate::resume_validation::validate_wire_resume( - &mode, - resume_session_id.take(), - launch_intent, - state.session_existence.as_ref(), - ); - resume_session_id = outcome.resume_session_id; - launch_intent = outcome.launch_intent; - if outcome.claude_fresh_prealloc { - claude_fresh_prealloc = true; - } - if let Some(stale) = outcome.stale_session_id.as_deref() { - tracing::warn!( - mode = %mode, - stale_session_id = %stale, - "resume validation: cached session missing on disk; spawning fresh" - ); - // Don't retry the stale id forever. - // (Use the same state.pane_ledger access pattern the claude - // restore ladder / binding writes in this file already use.) - let _ = state.pane_ledger.retire_missing(&mode, stale); - resume_fallback_notice = outcome.notice; + // In-gate liveness precondition: legacy resumeSessionId-only + // carriers bypass D7 in every ordering — a LIVE session must never + // gate. Same join D7 uses: registry.live_session_owner + the + // fresh-agent sidecar liveness consult (:1747-1758). + let candidate_is_live = resume_session_id.as_deref().is_some_and(|sid| { + /* registry.live_session_owner(Some(&state.identity), &mode, sid) + || , copied from D7's join */ + }); + if !candidate_is_live { + // The probe's by-id locators do real filesystem walks (~1 s for + // codex on a real store) — never inline on the async runtime + // (A13). Run the sync helper in spawn_blocking. + let outcome = { + let probe = state.session_existence.clone(); + let mode_for_gate = mode.clone(); + let rid = resume_session_id.take(); + let intent = launch_intent; + tokio::task::spawn_blocking(move || { + crate::resume_validation::validate_wire_resume( + &mode_for_gate, + rid, + intent, + probe.as_ref(), + ) + }) + .await + .expect("resume validation task panicked") + }; + resume_session_id = outcome.resume_session_id; + launch_intent = outcome.launch_intent; + if outcome.claude_fresh_prealloc { + claude_fresh_prealloc = true; + } + if let Some(stale) = outcome.stale_session_id.as_deref() { + tracing::warn!( + mode = %mode, + stale_session_id = %stale, + "resume validation: cached session missing on disk; spawning fresh" + ); + // Don't retry the stale id forever. + // (Use the same state.pane_ledger access pattern the claude + // restore ladder / binding writes in this file already use.) + let _ = state.pane_ledger.retire_missing(&mode, stale); + resume_fallback_notice = outcome.notice; + } } } ``` -(Adapt the exact `state.pane_ledger` spelling to how `handle_create` already reaches the ledger — e.g. if it is an `Option`/`Arc`, follow the existing call sites in this file.) +(Adapt the exact `state.pane_ledger` spelling to how `handle_create` already reaches the ledger — e.g. if it is an `Option`/`Arc`, follow the existing call sites in this file. Adapt the liveness join to D7's exact call shape at `:1747-1758` — do not reimplement it. `state.session_existence` is the shared `Arc`; clone the Arc into the closure.) c. Stale-ref stamping guard: from the gate insertion point onward, `handle_create` must not stamp the stale `create.session_ref` as the pane's identity. Grep every use of `create.session_ref` / `requested_ref` BELOW the insertion point in `handle_create`; for each one that records identity (ledger binding writes, identity registry, `terminal.created` session fields), switch it to a local `effective_session_ref` that is `None` when the gate fired (the fresh id — when one was minted — flows through `resume_session_id` exactly as the prealloc paths already do). The integration test's ledger assertions in case 1 pin this. @@ -1119,7 +1433,7 @@ Run: `cargo test -p freshell-ws --test resume_validation_gate` — PASS. Then ru - [ ] **Step 5: Refactor + full-crate run** -Refactor pass: keep `handle_create`'s addition to the ~25 lines above (all policy is in `resume_validation.rs` / `resume_gate.rs`). Run `cargo test -p freshell-ws` (whole crate) and `cargo clippy -p freshell-ws -- -D warnings`. +Refactor pass: keep `handle_create`'s addition to roughly the block shown above — liveness precondition, `spawn_blocking` gate call, retire + notice bookkeeping (all POLICY stays in `resume_validation.rs` / `resume_gate.rs`; the door only orchestrates). Run `cargo test -p freshell-ws` (whole crate) and `cargo clippy -p freshell-ws -- -D warnings`. - [ ] **Step 6: Commit** @@ -1137,8 +1451,8 @@ git commit -m "feat(resume-validation): gate WS terminal.create restore path on - Test: extend the existing respawn integration coverage — `crates/freshell-ws/tests/auto_resume_respawn.rs` (reuse its harness/state setup verbatim) **Interfaces:** -- Consumes: `validate_wire_resume` (Task 6), `retire_missing` (Task 4), existing `AgentRespawnRequest {mode, provider, session_id, create_request_id, cwd}` and the `emit_recovering`-style broadcast precedent (`auto_resume.rs:604-628`). -- Produces: no new public API. Behavior contract: on positive absence the respawn proceeds WITHOUT resume args (fresh spawn, same cwd/mode), the stale ledger row is retired `SessionMissing`, a `terminal.status{Recovering, reason}` frame naming the stale id is broadcast, and a `tracing::warn!` is logged. On Present/Unknown, byte-for-byte today's behavior. +- Consumes: `validate_wire_resume` (Task 6, sync — this door too runs it inside `tokio::task::spawn_blocking`, A13), `retire_missing` (Task 4), existing `AgentRespawnRequest {mode, provider, session_id, create_request_id, cwd}` and the `emit_recovering`-style broadcast precedent (`auto_resume.rs:604-628`). +- Produces: no new public API. Behavior contract: on positive absence the respawn proceeds WITHOUT resume args (fresh spawn, same cwd/mode), the stale ledger row is retired `SessionMissing`, a `terminal.status{Recovering, reason}` frame naming the stale id is broadcast, and a `tracing::warn!` is logged. On Present/Unknown, byte-for-byte today's behavior. CRITICAL (V8 §A9): the gate replaces the `resume_session_id` LOCAL, not merely the launch args — the post-spawn bookkeeping (registry row `~:2951`, identity upsert `:3041`, ledger `record_binding` `:3060`) all read that local and must record the FRESH id, otherwise `record_binding` re-mints a Bound row for the stale id right after `retire_missing` and the respawn loop is real. - [ ] **Step 1: Write the failing integration test** @@ -1162,7 +1476,12 @@ async fn respawn_with_absent_session_spawns_fresh_and_retires_binding() { // home (ensure_session must not run for the stale id); // 4. a broadcast terminal.status frame with status "recovering" was sent // whose reason contains "stale-amp" (subscribe to state.broadcast_tx - // before the call, the way sibling tests capture broadcasts). + // before the call, the way sibling tests capture broadcasts); + // 5. the NEW generation's bookkeeping carries the FRESH id, not the stale + // one (V8 §A9 pin): the identity registry entry for the new terminal + // id names the fresh uuid, ledger load_binding("amplifier", ) + // is Bound, and NO new Bound row exists for "stale-amp" (it stays + // Retired/SessionMissing — record_binding must not resurrect it). } #[tokio::test] @@ -1185,13 +1504,29 @@ Replace the hardcoded assignment at `terminal.rs:2708-2710`: // Resume validation (docs/plans/2026-07-29-resume-validation.md): the // respawn id comes from the identity registry / pane ledger — cached // state — so it gets the same disk-existence gate as door 1. + // CRITICAL: the gate replaces this LOCAL, not merely the launch args — + // the post-spawn bookkeeping (registry row ~:2951, identity upsert + // :3041, ledger record_binding :3060) all read it and must record the + // FRESH id, or the stale id is re-minted as a Bound row right after + // retire_missing and the respawn loop is real (V8 §A9). let mut launch_intent = LaunchIntent::Resume; - let outcome = crate::resume_validation::validate_wire_resume( - &req.mode, - Some(req.session_id.clone()), - launch_intent, - state.session_existence.as_ref(), - ); + let outcome = { + // Probe + by-id locators do real filesystem walks — never inline on + // the async runtime (A13); run the sync helper in spawn_blocking. + let probe = state.session_existence.clone(); + let mode = req.mode.clone(); + let sid = Some(req.session_id.clone()); + tokio::task::spawn_blocking(move || { + crate::resume_validation::validate_wire_resume( + &mode, + sid, + LaunchIntent::Resume, + probe.as_ref(), + ) + }) + .await + .expect("resume validation task panicked") + }; let resume_session_id = outcome.resume_session_id; launch_intent = outcome.launch_intent; if let Some(stale) = outcome.stale_session_id.as_deref() { @@ -1223,6 +1558,8 @@ Adapt the `TerminalStatus` field spellings/terminal-id source to the exact struc Downstream in this function, the amplifier `ensure_session` call (`:2846`) keys off `resume_session_id` — with the gate having replaced the stale id by a fresh UUID, it stubs the FRESH id, which is exactly the fresh-amplifier-pane shape. No further change needed there. +Deliberate decision AD-3 (hub lease under the stale locator — document with a code comment at the wiring site, do NOT change behavior): after a gate-fired respawn, the auto-resume hub's `complete_claim` continues to bind the STALE locator's lease to the fresh terminal (`auto_resume.rs:529-547` → `registry.complete_session_ref_claim`). A later `terminal.create` carrying the stale ref then adopts the fresh terminal via `BoundElsewhere` WITHOUT door 1's notice. This is convergent (no loop, no duplicate pane, the user reaches the fresh terminal); the only cost is a missing notice on a rare second-order path. Accepted — releasing/rebinding the lease would mean new registry API surface for no user-visible gain. + - [ ] **Step 3: Run tests to verify they pass** Run: `cargo test -p freshell-ws --test auto_resume_respawn` @@ -1272,7 +1609,7 @@ fn validate_rest_resume( ) -> RestResumeOutcome ``` - Behavior identical to `validate_wire_resume` (Task 6) including the per-provider fresh-fallback shapes (claude → new UUID + `Start`; amplifier → new UUID + `Resume`; codex/opencode → `None`), with one addition: `probe: None` (not wired) → passthrough. + Behavior identical to `validate_wire_resume` (Task 6) including the per-provider fresh-fallback shapes (claude → new UUID + `Start`; amplifier → new UUID + `Resume`; codex/opencode → `None`), with one addition: `probe: None` (not wired) → passthrough. The helper stays SYNC; the wiring site runs it inside `tokio::task::spawn_blocking` (A13). Minted UUIDs MUST be RFC-4122 v4 (`Uuid::new_v4()`, the crate's existing mint convention at `terminal_tabs.rs:831`) — `is_canonical_claude_session_id` enforces version `1..=5` + RFC-4122 variant, so a v7 or nil UUID would fail `plausible_resume_session_id` and break the healed identity stamping. - [ ] **Step 1: Write the failing unit tests** @@ -1349,6 +1686,25 @@ Add to the existing `#[cfg(test)]` module in `terminal_tabs.rs` (or its test spl assert!(out.resume_session_id.is_none()); assert_eq!(out.stale_session_id.as_deref(), Some("stale-cx")); } + + #[test] + fn rest_resume_minted_claude_id_is_v4_and_plausible() { + // Pins the Uuid::new_v4() requirement (V9): is_canonical_claude_ + // session_id enforces version 1..=5 + RFC-4122 variant — v7/nil + // would fail and the healed pane_content stamping would silently + // fall through. + use freshell_platform::resume_gate::ResumeExistence; + let probe = probe_answering(ResumeExistence::Absent, true); + let out = validate_rest_resume( + "claude", + Some("stale-cl".into()), + LaunchIntent::Resume, + Some(&probe), + ); + assert_eq!(out.launch_intent, LaunchIntent::Start); + let minted = out.resume_session_id.expect("fresh claude id minted"); + assert!(plausible_resume_session_id("claude", &minted)); + } ``` - [ ] **Step 2: Run to verify failure** @@ -1360,27 +1716,49 @@ Expected: FAIL to compile — `validate_rest_resume` not defined. Implement `validate_rest_resume` (same body shape as `validate_wire_resume`, using `ResumeProbeFn` instead of the trait; `None` probe → passthrough; reuse `evaluate_resume_gate`, `provider_validated`, `stale_resume_notice` from freshell-platform; mint UUIDs via the crate's existing uuid dependency — check `Cargo.toml`, add `uuid` with `v4` feature if absent, matching the workspace version). -Wire it in the create pipeline: immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`): +Wire it in the create pipeline — numbered, all steps MANDATORY: + +1. **Gate call** (inside `spawn_blocking` — A13): immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`): ```rust - let rest_outcome = validate_rest_resume( - &mode, - resume_session_id.take(), - launch_intent, - state.resume_probe.as_ref(), - ); + // Probe does real filesystem walks — never inline on the async runtime + // (A13); run the sync helper in spawn_blocking. + let rest_outcome = { + let probe = state.resume_probe.clone(); + let mode_for_gate = mode.clone(); + let rid = resume_session_id.take(); + let intent = launch_intent; + tokio::task::spawn_blocking(move || { + validate_rest_resume(&mode_for_gate, rid, intent, probe.as_ref()) + }) + .await + .expect("resume validation task panicked") + }; let resume_session_id = rest_outcome.resume_session_id; let launch_intent = rest_outcome.launch_intent; if let Some(stale) = rest_outcome.stale_session_id.as_deref() { + // MANDATORY stale-ref guard (V7 row 10): the pane_content identity + // stamping PREFERS accepted_session_ref (terminal_tabs.rs:1683-1685) + // — left in place, the STALE wire ref would be stamped into the new + // tab's pane_content, poisoning client persistence + tabs-sync + // replay and re-firing the gate every restart. Clearing it makes + // stamping fall through to the minted-ref branch (:1686-1692), so + // gate-fired claude/amplifier panes are born with the HEALED ref and + // codex/opencode panes with no ref. + accepted_session_ref = None; if let Some(cb) = &state.on_stale_resume { cb(&mode, stale); } } ``` -(Adapt local variable names/mutability to the surrounding pipeline code; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1. If this pipeline sets identity/binding from the requested session ref, apply the same stale-ref guard as Task 6 step 4c: when the gate fired, the stale ref must not be stamped.) +(Adapt local variable names/mutability to the surrounding pipeline code; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1.) -Notice: where the handler builds the response `pane_content` (the `reconcileNotice` injection precedent at `:1657`), when `rest_outcome.notice` is `Some`, insert it as `"reconcileNotice"` in the `pane_content` JSON so the existing client chip/xterm rendering shows it. Add/extend a unit test in the same module asserting the built `pane_content` carries the notice when the gate fires (clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly). +2. **Thread the intent**: the pipeline today HARDCODES `launch_intent: LaunchIntent::Resume` in the `CliLaunchInputs` construction (`~:1335`) — Task 8 must thread `rest_outcome.launch_intent` into the inputs explicitly so the claude fallback's `Start` reaches the resolver. Verified safe (V9): the REST pipeline consumes the SAME Arc'd `cli_commands` the WS door uses (`main.rs:363/:409/:632`), the claude spec has `createSessionArgs` (`extensions/claude-code/freshell.json:11`), so `Start` + minted id resolves — no `StartIntentUnsupported`. + +3. **Notice injection**: where the handler builds the response `pane_content` (the `reconcileNotice` injection precedent at `:1657`), when `rest_outcome.notice` is `Some`, insert it as `"reconcileNotice"` in the `pane_content` JSON so the existing client chip/xterm rendering shows it. (Caveat, accepted: a hidden/background tab defers the render until a later visible attach pass — the notice is preserved in pane content, never dropped.) + +4. **Tests for 1+3**: add/extend unit tests in the same module asserting that when the gate fires, the built `pane_content` (a) carries the notice, and (b) carries the HEALED ref — `pane_content.sessionRef.sessionId` equals the minted fresh id, NOT the stale wire ref (pin the fall-through to the `:1686-1692` minted-ref branch). Clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly. Add the `FreshAgentState` fields + builders in `lib.rs` (copy the `with_opencode_locator` builder shape exactly). @@ -1462,9 +1840,13 @@ absent) it spawns fresh in the same cwd/mode, surfaces an operator notice naming the stale id, and retires the pane-ledger binding (`RetiredReason::SessionMissing`). Unknown/unreadable stores fail OPEN (resume attempted, byte-for-byte Node behavior). Providers validated: -claude (with a zero-turn carve-out: Absent + never observed on disk still -resumes), codex, opencode, amplifier. gemini/kimi/third-party are never -blocked. Additive protocol field: optional `notice` on `terminal.created`. +claude — for SAME-BOOT deletions only (a zero-turn carve-out keeps +Absent + never-observed-on-disk resuming, and the disk-observation signal +is a per-boot in-memory set, so a transcript deleted while the server was +DOWN is indistinguishable from never-conversed and fails OPEN post-restart; +deliberate, fail-open) — codex, opencode, amplifier. gemini/kimi/third-party +are never blocked. Additive protocol field: optional `notice` on +`terminal.created`. **Why:** Production incident 2026-07-29 — after a server restart, freshell resumed stored amplifier session id 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 which @@ -1473,11 +1855,16 @@ silently created a new empty session under that id and the user saw a broken restore with no explanation. **Pinning tests:** `freshell-platform` `resume_gate` unit tests; -`freshell-ws/tests/resume_validation_gate.rs`; +`freshell-ws/tests/resume_validation_gate.rs` (incl. the live-session/D7 +ordering and legacy-carrier liveness cases); `freshell-ws/tests/auto_resume_respawn.rs` -(`respawn_with_absent_session_spawns_fresh_and_retires_binding`); -`freshell-freshagent` `rest_resume_*` unit tests; -`freshell-server` `existence.rs` amplifier/codex by-id fallback tests. +(`respawn_with_absent_session_spawns_fresh_and_retires_binding`, incl. the +fresh-id bookkeeping assertions); +`freshell-freshagent` `rest_resume_*` unit tests (incl. minted-v4 +plausibility and healed pane_content stamping); +`freshell-server` `existence.rs` amplifier/codex by-id fallback, +cold-index, and sub-root permission tests; +`freshell-protocol/tests/roundtrip.rs` `notice` wire test. ``` - [ ] **Step 2: Full Rust verification** @@ -1511,20 +1898,23 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re ## Self-Review Record +*(Re-run 2026-07-29 after incorporating the load-bearing-assumption validation findings — 9 validators, ledger in `.worktrees/.the-usual-logs/resume-validation/`.)* + **1. Spec coverage:** -- "Validate before constructing the resume command" → Tasks 6 (WS create/restore), 7 (auto-resume respawn), 8 (REST create) — all three call sites of `resolve_coding_cli_command` are gated. ✅ -- "If validation fails: don't pass the resume flag; spawn fresh in same cwd/mode" → fallback shapes in `validate_wire_resume`/`validate_rest_resume` mirror each mode's genuine fresh-pane spawn; cwd/mode inputs are untouched. ✅ -- "Operator-visible notice naming the stale id" → Task 5 (`notice` on `terminal.created` + xterm rendering, door 1), Task 8 (`reconcileNotice` in `pane_content`, door 3); door 2 broadcasts `terminal.status{Recovering, reason}` + warn-log — documented as the best available channel on a headless path (deliberate, stated in Design Notes). ✅ -- "Clear/mark the stale cached id so it isn't retried forever" → Task 4 `retire_missing` (`SessionMissing`), invoked at all three doors. ✅ -- "Fail open on Unknown/unreadable" → gate policy (Task 1) + probe `Unreadable→Unknown` mappings (Tasks 2–3) + explicit fail-open tests at every layer. ✅ -- "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints. ✅ -- "Cover providers freshell already reads; fail open for others" → claude/codex/opencode/amplifier validated; gemini/kimi/third-party never blocked (tested). ✅ -- "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints). ✅ -- "Non-goals respected" → no amplifier-CLI changes; no indexing subsystem changes beyond two by-id fallbacks the validation needs; protocol change limited to one additive optional field, mirrored per convention. ✅ +- "Validate before constructing the resume command" → Tasks 6 (WS create/restore), 7 (auto-resume respawn), 8 (REST create) — all three call sites of `resolve_coding_cli_command` are gated, and the gate now fires even against a COLD index for amplifier/claude (Task 3 cold coverage — the incident scenario is a cold-index race). ✅ +- "If validation fails: don't pass the resume flag; spawn fresh in same cwd/mode" → fallback shapes in `validate_wire_resume`/`validate_rest_resume` mirror each mode's genuine fresh-pane spawn; cwd/mode inputs are untouched; door 3 threads the claude `Start` intent explicitly past the `~:1335` hardcode (Task 8 step 2, V9-verified). ✅ +- "Operator-visible notice naming the stale id" → Task 5 (`notice` on `terminal.created` + xterm rendering, door 1), Task 8 (`reconcileNotice` in `pane_content`, door 3); door 2 broadcasts `terminal.status{Recovering, reason}` + warn-log — documented as the best available channel on a headless path (deliberate, stated in Design Notes). AD-3 residual (missing notice on the rare stale-ref-adopts-fresh-terminal path) is recorded in Task 7. ✅ +- "Clear/mark the stale cached id so it isn't retried forever" → four cooperating mechanisms, all required with tests: Task 4 `retire_missing` (all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold, V5-verified) + Task 5 notice-triggered clear of persisted `sessionRef`/`resumeSessionId` (codex/opencode) + Task 8's mandatory `accepted_session_ref` guard (door 3). ✅ +- "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1 (a LIVE session can never be gated or have its Bound row retired) + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ +- "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints; covered even before index warm-up (cold-coverage matrix in Design Notes). ✅ +- "Cover providers freshell already reads; fail open for others" → claude (same-boot deletions only — AD-2, honestly scoped in Design Notes + DEVIATIONS)/codex/opencode/amplifier validated; codex/opencode additionally fail open in the first seconds after boot (AD-4, accepted residual); gemini/kimi/third-party never blocked (tested). ✅ +- "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints); Task 5's mirror provably touches zero `server/` files (precedent commits `5c591843`/`a18dd4c6`). ✅ +- "Non-goals respected" → no amplifier-CLI changes; no indexing subsystem changes beyond the by-id fallbacks + the cold-branch consult the validation needs; protocol change limited to one additive optional field, mirrored via `shared/ws-protocol.ts` + regenerated contract schema (never hand-edited). ✅ - "TDD, unit + e2e, coordinated test commands, worktree, no PR/deploy, known flaky test" → per-task RGR steps + Task 9 sweep + Global Constraints. ✅ +- Runtime discipline: no door blocks the async runtime — all probe/locator IO runs in `tokio::task::spawn_blocking` (one consistent shape: sync helpers, doors wrap; Tasks 6/7/8), and the expensive codex walk is confined to warm-Absent adjudication (A13/AD-4). ✅ -**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose own by-id fallback behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. No requirement was moved to known-limitations/future-work. NO UNRESOLVED COVERAGE GAPS. +**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness (Task 6 cases 4–5), and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. -**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback tests, Task 6/7 integration tests, Task 5 client test, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. No TBD/TODO/"handle edge cases" remain. +**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) rather than leaving it open. No TBD/TODO/"handle edge cases" remain. -**3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2) matches Task 3's `amplifier_dir_locator` mapping; `ByIdAnswer` (Task 3) used by both locators; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` (Task 6) matches Task 7's call; `notice: Option` field name consistent across Tasks 5 and 6. ✅ +**3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8 — mapping unchanged by the validation edits; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2, errors-seen accumulator) matches Task 3's `amplifier_dir_locator` mapping AND the cold-branch consult; `ByIdAnswer{Present,Absent,Unreadable}` (Task 3) is the single answer contract for both locators, with identical Unreadable⇒Unknown mapping at the warm-Absent adjudication and the cold branch; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` stays SYNC and matches Task 7's call — both doors wrap it in `spawn_blocking` with an `Arc` clone, and Task 8's sync `validate_rest_resume` is wrapped identically at its wiring site (one consistent async shape across all three doors; unit tests for all helpers stay synchronous); `notice: Option` field name consistent across Tasks 5 and 6, and Task 5's client clear keys on the same field. ✅ From fa0ca9abf628f0ed07dc33a00b2c9e9f2953f117 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:08:43 -0700 Subject: [PATCH 03/35] docs(resume-validation): fix fresheyes round-1 blocking plan defects Independent cross-model plan review found two blocking executable-plan defects; both fixed: 1. Door 3 (Task 8) gate ordering: the REST create pipeline puts the amplifier ensure (:895) BEFORE the REST D7 live-session guard (:968-988) and D8 lease, so the gate cannot be protected by after-D7 placement like door 1 - a gate fire would falsify D7-REST's applicability filter, bypass its loud CONFLICT reject, and retire the Bound ledger row of a RUNNING session (live zero-turn codex has no rollout on disk). Added a MANDATORY in-gate liveness precondition reusing the D7-REST guard's own consult, a passthrough branch in the wiring snippet, and a live-session test pinning reject preservation and never-invoked on_stale_resume. 2. Task 6 in-gate liveness snippet was a sync is_some_and closure that cannot host the async half of D7's join (fresh-agent sidecar has_live_session(sid).await) - the sidecar arm was silently droppable while all planned tests passed. Reshaped to an async-capable let (both arms load-bearing, copy D7's join verbatim) and added integration case 6 pinning the sidecar arm (sidecar-only liveness, registry empty, probe Absent, legacy carrier - fail open). Self-Review record, placeholder scan, and DEVIATIONS pinning-tests text updated to match (cases 4-6, door-3 liveness precondition). --- docs/plans/2026-07-29-resume-validation.md | 68 ++++++++++++++++------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-07-29-resume-validation.md b/docs/plans/2026-07-29-resume-validation.md index c411a668a..e836e5273 100644 --- a/docs/plans/2026-07-29-resume-validation.md +++ b/docs/plans/2026-07-29-resume-validation.md @@ -1351,9 +1351,10 @@ RED: create `crates/freshell-ws/tests/resume_validation_gate.rs`. Reuse the harn 2. `restore_true_amplifier_present_resumes_unchanged`: probe answers `Present`; assert `terminal.created` has NO `notice` and the ledger row stays `Bound`. 3. `restore_true_unknown_fails_open`: probe answers `Unknown`; assert no `notice`, row stays `Bound` (today's behavior preserved). 4. `restore_true_live_absent_sessionref_hits_d7_not_the_gate`: registry/identity hold a RUNNING owner for `("amplifier", "stale-amp")` (set up the liveness state the way D7's own sibling tests do), probe answers `Absent`, ledger holds a `Bound` row. Send the same restore create as case 1. Assert: (a) the response is D7's `RestoreUnavailable` error frame ("still running"), NOT a `terminal.created` fresh spawn; (b) the `Bound` ledger row of the running session SURVIVES (`load_binding` still `Bound` — the gate never saw the create). This pins the after-D7 ordering (V8 §A11). -5. `restore_true_live_absent_legacy_resume_id_fails_open`: same live state, but the create carries the id ONLY in legacy `resumeSessionId` (no `session_ref`) — this carrier bypasses D7 in every ordering. Assert: no `notice`, the row stays `Bound`, and the resume proceeds unchanged. This pins the in-gate liveness precondition. +5. `restore_true_live_absent_legacy_resume_id_fails_open`: same live state, but the create carries the id ONLY in legacy `resumeSessionId` (no `session_ref`) — this carrier bypasses D7 in every ordering. Assert: no `notice`, the row stays `Bound`, and the resume proceeds unchanged. This pins the REGISTRY arm of the in-gate liveness precondition (case 6 pins the sidecar arm). +6. `restore_true_sidecar_live_absent_legacy_resume_id_fails_open`: liveness held ONLY by the fresh-agent sidecar — registry/identity hold NO running owner for the ref, but the state's fresh-agent sidecar (`state.fresh_codex` for a `("codex", "stale-cx")` ref — set up the fake sidecar the way D7's own sidecar-liveness sibling tests do) answers `has_live_session("stale-cx") == true`; probe answers `Absent`; ledger holds a `Bound` row; the create carries the id only in legacy `resumeSessionId`. Assert: no `notice`, the row stays `Bound`, and the resume proceeds unchanged. This pins the ASYNC sidecar arm of the in-gate liveness join — the arm protecting live zero-turn sessions that have no rollout on disk yet (`crates/freshell-server/src/existence.rs:224-227`) — so it cannot be silently dropped while cases 4–5 still pass. -Run: `cargo test -p freshell-ws --test resume_validation_gate` — tests fail (no gate wired; notice absent, row stays bound in case 1; cases 4/5 fail once the gate exists if ordering/liveness are wrong — write them RED now so the GREEN wiring must satisfy them). +Run: `cargo test -p freshell-ws --test resume_validation_gate` — tests fail (no gate wired; notice absent, row stays bound in case 1; cases 4–6 fail once the gate exists if ordering/liveness are wrong — write them RED now so the GREEN wiring must satisfy them). GREEN — wire `handle_create` (`terminal.rs`): a tracker in the resume-id derivation region (a), the gate itself in the `:1787→:1789` slot (b), then the stamping guard (c) and notice emission (d): @@ -1377,11 +1378,26 @@ b. Insert the gate in the `:1787→:1789` slot — AFTER the D7 cross-mode liven // In-gate liveness precondition: legacy resumeSessionId-only // carriers bypass D7 in every ordering — a LIVE session must never // gate. Same join D7 uses: registry.live_session_owner + the - // fresh-agent sidecar liveness consult (:1747-1758). - let candidate_is_live = resume_session_id.as_deref().is_some_and(|sid| { - /* registry.live_session_owner(Some(&state.identity), &mode, sid) - || , copied from D7's join */ - }); + // fresh-agent sidecar liveness consult (:1747-1758). SHAPE NOTE + // (load-bearing): D7's join is partly ASYNC — the sidecar arm is + // `state.fresh_claude/fresh_codex/fresh_opencode + // .has_live_session(sid).await` — so it CANNOT live inside a sync + // `is_some_and` closure. Compute it as a plain `let` with `.await` + // in scope, exactly the shape D7 itself uses. BOTH arms are + // load-bearing: dropping the async sidecar arm silently un-protects + // live zero-turn sessions that have no rollout on disk yet + // (integration case 6 pins that arm). + let candidate_is_live = match resume_session_id.as_deref() { + None => false, + Some(sid) => { + /* D7's exact two-arm join, copied from :1747-1758: + registry.live_session_owner(Some(&state.identity), &mode, sid) + || + — copy it, do not reimplement it, and do not reduce it + to the sync registry arm alone. */ + } + }; if !candidate_is_live { // The probe's by-id locators do real filesystem walks (~1 s for // codex on a real store) — never inline on the async runtime @@ -1718,12 +1734,29 @@ Implement `validate_rest_resume` (same body shape as `validate_wire_resume`, usi Wire it in the create pipeline — numbered, all steps MANDATORY: -1. **Gate call** (inside `spawn_blocking` — A13): immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`): +1. **Gate call** (inside `spawn_blocking` — A13): immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`), and guarded by a MANDATORY in-gate liveness precondition. Ordering note (load-bearing — the A11 door-1 hazard with the constraint INVERTED): in THIS pipeline the amplifier ensure (`:895`) comes BEFORE the REST D7 live-session guard (`:968-988`) and the D8 session-ref lease (`:990-1040`), so door 3 cannot get liveness protection by after-D7 placement the way door 1 did — the gate must sit before `:895` (or the `ensure_session` re-stub resurrects the stale dir), yet placed there a gate fire would clear `accepted_session_ref` / replace `resume_session_id` and FALSIFY the D7-REST applicability filter (`:968-971`): its loud `RESTORE_UNAVAILABLE`/CONFLICT reject and the D8 lease would be silently bypassed, and `on_stale_resume` → `retire_missing` would destroy the Bound ledger row of a RUNNING session. This is reachable — a live zero-turn codex session genuinely has no rollout on disk (`crates/freshell-server/src/existence.rs:224-227`). Therefore: when the candidate session is LIVE, skip the gate entirely (byte-for-byte today's behavior), letting the unchanged create flow into the D7-REST guard, which issues its loud reject exactly as today. Reuse the SAME liveness consult the REST D7 guard at `:968-988` performs for this `(mode, resume id)` — hoist it into a shared helper or call it ahead of the gate; do NOT reimplement it, and keep any async consults `.await`ed in the pipeline body (never inside the `spawn_blocking` closure or a sync closure): ```rust + // In-gate liveness precondition (MANDATORY — see ordering note above): + // a LIVE session must never be gated; the downstream REST D7 guard + // (:968-988) owns the loud reject for live sessions. + let candidate_is_live = match resume_session_id.as_deref() { + None => false, + Some(sid) => { + /* the SAME (mode, sid) liveness consult the REST D7 guard at + :968-988 performs — hoisted into a shared helper or called + here ahead of the gate; not reimplemented; async consults + `.await`ed here in the pipeline body */ + } + }; // Probe does real filesystem walks — never inline on the async runtime - // (A13); run the sync helper in spawn_blocking. - let rest_outcome = { + // (A13); run the sync helper in spawn_blocking. A LIVE candidate skips + // the gate entirely (passthrough — same shape validate_rest_resume + // returns for Proceed), so the unchanged create flows into the D7-REST + // guard and D8 lease exactly as today. + let rest_outcome = if candidate_is_live { + passthrough(resume_session_id.take(), launch_intent) + } else { let probe = state.resume_probe.clone(); let mode_for_gate = mode.clone(); let rid = resume_session_id.take(); @@ -1752,13 +1785,13 @@ Wire it in the create pipeline — numbered, all steps MANDATORY: } ``` -(Adapt local variable names/mutability to the surrounding pipeline code; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1.) +(Adapt local variable names/mutability to the surrounding pipeline code — in particular the `resume_session_id`/`launch_intent` rebinding must flow out of the `if candidate_is_live { .. } else { .. }` expression, not shadow inside a block; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1. `passthrough(..)` is the same Proceed-shape constructor `validate_rest_resume` itself uses — expose/reuse it rather than hand-building the outcome.) 2. **Thread the intent**: the pipeline today HARDCODES `launch_intent: LaunchIntent::Resume` in the `CliLaunchInputs` construction (`~:1335`) — Task 8 must thread `rest_outcome.launch_intent` into the inputs explicitly so the claude fallback's `Start` reaches the resolver. Verified safe (V9): the REST pipeline consumes the SAME Arc'd `cli_commands` the WS door uses (`main.rs:363/:409/:632`), the claude spec has `createSessionArgs` (`extensions/claude-code/freshell.json:11`), so `Start` + minted id resolves — no `StartIntentUnsupported`. 3. **Notice injection**: where the handler builds the response `pane_content` (the `reconcileNotice` injection precedent at `:1657`), when `rest_outcome.notice` is `Some`, insert it as `"reconcileNotice"` in the `pane_content` JSON so the existing client chip/xterm rendering shows it. (Caveat, accepted: a hidden/background tab defers the render until a later visible attach pass — the notice is preserved in pane content, never dropped.) -4. **Tests for 1+3**: add/extend unit tests in the same module asserting that when the gate fires, the built `pane_content` (a) carries the notice, and (b) carries the HEALED ref — `pane_content.sessionRef.sessionId` equals the minted fresh id, NOT the stale wire ref (pin the fall-through to the `:1686-1692` minted-ref branch). Clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly. +4. **Tests for 1+3**: add/extend unit tests in the same module asserting that when the gate fires, the built `pane_content` (a) carries the notice, and (b) carries the HEALED ref — `pane_content.sessionRef.sessionId` equals the minted fresh id, NOT the stale wire ref (pin the fall-through to the `:1686-1692` minted-ref branch). Clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly. ADDITIONALLY (pins the MANDATORY liveness precondition of step 1): a live-session case — clone the setup of the existing tests that pin the REST D7 guard's `RESTORE_UNAVAILABLE`/CONFLICT reject at `:968-988` (same fake liveness state), inject a probe answering `Absent` for that same live ref, and drive the create pipeline. Assert: (a) the response is the D7-REST loud reject, NOT a gate-fired fresh spawn; (b) `state.on_stale_resume` was NEVER invoked (install a counting fake — the Bound ledger row of the running session must survive); (c) no notice was injected. This test fails RED if the gate runs before the liveness check or the precondition is dropped. Add the `FreshAgentState` fields + builders in `lib.rs` (copy the `with_opencode_locator` builder shape exactly). @@ -1856,12 +1889,13 @@ restore with no explanation. **Pinning tests:** `freshell-platform` `resume_gate` unit tests; `freshell-ws/tests/resume_validation_gate.rs` (incl. the live-session/D7 -ordering and legacy-carrier liveness cases); +ordering and legacy-carrier liveness cases — registry AND sidecar arms); `freshell-ws/tests/auto_resume_respawn.rs` (`respawn_with_absent_session_spawns_fresh_and_retires_binding`, incl. the fresh-id bookkeeping assertions); `freshell-freshagent` `rest_resume_*` unit tests (incl. minted-v4 -plausibility and healed pane_content stamping); +plausibility, healed pane_content stamping, and the live-session +precondition/D7-REST-reject preservation case); `freshell-server` `existence.rs` amplifier/codex by-id fallback, cold-index, and sub-root permission tests; `freshell-protocol/tests/roundtrip.rs` `notice` wire test. @@ -1905,7 +1939,7 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re - "If validation fails: don't pass the resume flag; spawn fresh in same cwd/mode" → fallback shapes in `validate_wire_resume`/`validate_rest_resume` mirror each mode's genuine fresh-pane spawn; cwd/mode inputs are untouched; door 3 threads the claude `Start` intent explicitly past the `~:1335` hardcode (Task 8 step 2, V9-verified). ✅ - "Operator-visible notice naming the stale id" → Task 5 (`notice` on `terminal.created` + xterm rendering, door 1), Task 8 (`reconcileNotice` in `pane_content`, door 3); door 2 broadcasts `terminal.status{Recovering, reason}` + warn-log — documented as the best available channel on a headless path (deliberate, stated in Design Notes). AD-3 residual (missing notice on the rare stale-ref-adopts-fresh-terminal path) is recorded in Task 7. ✅ - "Clear/mark the stale cached id so it isn't retried forever" → four cooperating mechanisms, all required with tests: Task 4 `retire_missing` (all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold, V5-verified) + Task 5 notice-triggered clear of persisted `sessionRef`/`resumeSessionId` (codex/opencode) + Task 8's mandatory `accepted_session_ref` guard (door 3). ✅ -- "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1 (a LIVE session can never be gated or have its Bound row retired) + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ +- "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1, and the MANDATORY in-gate liveness precondition at door 3 (whose pipeline puts the amplifier ensure `:895` BEFORE the REST D7 guard `:968-988`, so ordering alone cannot protect it — Task 8 step 1 ordering note) — a LIVE session can never be gated or have its Bound row retired at any door + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ - "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints; covered even before index warm-up (cold-coverage matrix in Design Notes). ✅ - "Cover providers freshell already reads; fail open for others" → claude (same-boot deletions only — AD-2, honestly scoped in Design Notes + DEVIATIONS)/codex/opencode/amplifier validated; codex/opencode additionally fail open in the first seconds after boot (AD-4, accepted residual); gemini/kimi/third-party never blocked (tested). ✅ - "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints); Task 5's mirror provably touches zero `server/` files (precedent commits `5c591843`/`a18dd4c6`). ✅ @@ -1913,8 +1947,8 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re - "TDD, unit + e2e, coordinated test commands, worktree, no PR/deploy, known flaky test" → per-task RGR steps + Task 9 sweep + Global Constraints. ✅ - Runtime discipline: no door blocks the async runtime — all probe/locator IO runs in `tokio::task::spawn_blocking` (one consistent shape: sync helpers, doors wrap; Tasks 6/7/8), and the expensive codex walk is confined to warm-Absent adjudication (A13/AD-4). ✅ -**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness (Task 6 cases 4–5), and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. +**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness incl. its async sidecar arm (Task 6 cases 4–6), the door-3 liveness precondition + D7-REST-reject preservation (Task 8 step 4 live-session test), and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. -**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) rather than leaving it open. No TBD/TODO/"handle edge cases" remain. +**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) — in an async-capable `let` shape, both arms — rather than leaving it open; Task 8's liveness precondition likewise reuses the REST D7 guard's own consult (`:968-988`) rather than leaving it open. No TBD/TODO/"handle edge cases" remain. **3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8 — mapping unchanged by the validation edits; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2, errors-seen accumulator) matches Task 3's `amplifier_dir_locator` mapping AND the cold-branch consult; `ByIdAnswer{Present,Absent,Unreadable}` (Task 3) is the single answer contract for both locators, with identical Unreadable⇒Unknown mapping at the warm-Absent adjudication and the cold branch; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` stays SYNC and matches Task 7's call — both doors wrap it in `spawn_blocking` with an `Arc` clone, and Task 8's sync `validate_rest_resume` is wrapped identically at its wiring site (one consistent async shape across all three doors; unit tests for all helpers stay synchronous); `notice: Option` field name consistent across Tasks 5 and 6, and Task 5's client clear keys on the same field. ✅ From d025f4b9dc97aa5f0ff7a7f0c674ff878a876d77 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:34:59 -0700 Subject: [PATCH 04/35] docs(resume-validation): wire sidecar liveness arm into door-3 gate per fresheyes round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresheyes iteration 2 found the door-3 in-gate liveness precondition reused the REST D7 guard's registry-only consult, which is PTY-scoped and blind to sessions live inside the fresh-agent sidecars — the exact live-zero-turn-codex-with-no-rollout hazard the plan cites. Task 8 now mandates a two-arm join: the D7-REST registry consult PLUS a new injected async SidecarLivenessProbe (FreshAgentState::with_sidecar_liveness, built in main.rs from the same sidecar instances the WS door consults, with_* builder to avoid the FreshOpencodeState Arc cycle), a step-4 sidecar-live pinning test that fails RED if the arm is dropped, and matching updates to the Interfaces, main.rs wiring snippet, Self-Review attestations, and the DEVIATIONS pinning-test list. --- docs/plans/2026-07-29-resume-validation.md | 80 +++++++++++++++++----- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-07-29-resume-validation.md b/docs/plans/2026-07-29-resume-validation.md index e836e5273..71c6d55bb 100644 --- a/docs/plans/2026-07-29-resume-validation.md +++ b/docs/plans/2026-07-29-resume-validation.md @@ -1599,7 +1599,7 @@ git commit -m "feat(resume-validation): gate headless auto-resume respawn on dis **Files:** - Modify: `crates/freshell-freshagent/src/lib.rs` — `FreshAgentState` builders (`:406-475` region, beside `with_opencode_locator` / `with_codex_locator` / `with_session_identity`) - Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` — after `derive_resume_identity` (`:491-509`) resolves the resume id and before `CliLaunchInputs` is built for the resolver call at `:1355`; `reconcileNotice` injection at the `pane_content` construction (`:1657` precedent) -- Modify: `crates/freshell-server/src/main.rs` — wire the two closures when building `FreshAgentState` +- Modify: `crates/freshell-server/src/main.rs` — wire the three closures when building `FreshAgentState` - Test: the existing `#[cfg(test)]` module(s) for `terminal_tabs.rs` (where `requested_resume_session_id_for_mode` / `plausible_resume_session_id` are tested) **Interfaces:** @@ -1607,6 +1607,7 @@ git commit -m "feat(resume-validation): gate headless auto-resume respawn on dis - Produces: - `FreshAgentState::with_resume_probe(self, probe: freshell_platform::resume_gate::ResumeProbeFn) -> Self` (field `resume_probe: Option`, default `None` = feature off = today's behavior) - `FreshAgentState::with_on_stale_resume(self, cb: Arc) -> Self` — called with `(provider, stale_session_id)` when the gate fires; main.rs implements it as ledger `retire_missing` + `tracing::warn!` + - `FreshAgentState::with_sidecar_liveness(self, probe: SidecarLivenessProbe) -> Self` (field `sidecar_liveness: Option`, default `None` = arm contributes false), where `pub type SidecarLivenessProbe = std::sync::Arc std::pin::Pin + Send>> + Send + Sync>` — `(mode, session_id) -> is that session live inside a fresh-agent sidecar`. Precedent: the existing `TerminalLivenessProbe` (`lib.rs:69`, built as a closure in `main.rs:310-329`, injected at `:330-332`) solves the SAME cross-crate liveness problem in the opposite direction — copy that shape, made async. MUST be a consuming `with_*` builder like the siblings, NOT a `set_*`/`OnceLock` late-bind: `FreshOpencodeState` holds a `FreshAgentState` clone (`opencode_ws.rs:94`, `main.rs:260`), so a late-bound handle back to the sidecars would create a real Arc cycle. The closure lives in `freshell-server/src/main.rs`, so no new crate edge (deps stay freshagent ← ws ← server). - A module-private pure helper in `terminal_tabs.rs`: ```rust @@ -1734,19 +1735,37 @@ Implement `validate_rest_resume` (same body shape as `validate_wire_resume`, usi Wire it in the create pipeline — numbered, all steps MANDATORY: -1. **Gate call** (inside `spawn_blocking` — A13): immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`), and guarded by a MANDATORY in-gate liveness precondition. Ordering note (load-bearing — the A11 door-1 hazard with the constraint INVERTED): in THIS pipeline the amplifier ensure (`:895`) comes BEFORE the REST D7 live-session guard (`:968-988`) and the D8 session-ref lease (`:990-1040`), so door 3 cannot get liveness protection by after-D7 placement the way door 1 did — the gate must sit before `:895` (or the `ensure_session` re-stub resurrects the stale dir), yet placed there a gate fire would clear `accepted_session_ref` / replace `resume_session_id` and FALSIFY the D7-REST applicability filter (`:968-971`): its loud `RESTORE_UNAVAILABLE`/CONFLICT reject and the D8 lease would be silently bypassed, and `on_stale_resume` → `retire_missing` would destroy the Bound ledger row of a RUNNING session. This is reachable — a live zero-turn codex session genuinely has no rollout on disk (`crates/freshell-server/src/existence.rs:224-227`). Therefore: when the candidate session is LIVE, skip the gate entirely (byte-for-byte today's behavior), letting the unchanged create flow into the D7-REST guard, which issues its loud reject exactly as today. Reuse the SAME liveness consult the REST D7 guard at `:968-988` performs for this `(mode, resume id)` — hoist it into a shared helper or call it ahead of the gate; do NOT reimplement it, and keep any async consults `.await`ed in the pipeline body (never inside the `spawn_blocking` closure or a sync closure): +1. **Gate call** (inside `spawn_blocking` — A13): immediately after the resume id is derived (`derive_resume_identity`, before the amplifier ensure at `:895` and the `CliLaunchInputs` construction feeding `:1355`), and guarded by a MANDATORY in-gate liveness precondition. Ordering note (load-bearing — the A11 door-1 hazard with the constraint INVERTED): in THIS pipeline the amplifier ensure (`:895`) comes BEFORE the REST D7 live-session guard (`:968-988`) and the D8 session-ref lease (`:990-1040`), so door 3 cannot get liveness protection by after-D7 placement the way door 1 did — the gate must sit before `:895` (or the `ensure_session` re-stub resurrects the stale dir), yet placed there a gate fire would clear `accepted_session_ref` / replace `resume_session_id` and FALSIFY the D7-REST applicability filter (`:968-971`): its loud `RESTORE_UNAVAILABLE`/CONFLICT reject and the D8 lease would be silently bypassed, and `on_stale_resume` → `retire_missing` would destroy the Bound ledger row of a RUNNING session. This is reachable — a live zero-turn codex session genuinely has no rollout on disk (`crates/freshell-server/src/existence.rs:224-227`). Therefore: when the candidate session is LIVE, skip the gate entirely (byte-for-byte today's behavior), letting the unchanged create flow onward — for registry-live sessions the D7-REST guard then issues its loud reject exactly as today; for sidecar-live sessions the create proceeds unchanged exactly as today. LIVENESS IS A TWO-ARM JOIN, BOTH ARMS MANDATORY — the REST D7 guard's own consult alone is NOT sufficient: (arm 1, registry) the SAME consult the guard at `:968-988` performs — `registry.live_session_owner(state.session_identity.as_deref(), &mode, sid).is_some()` — hoist it into a shared helper or call it ahead of the gate; do NOT reimplement it. (arm 2, sidecar) that consult is `TerminalRegistry`/PTY-scoped and structurally BLIND to sessions live inside the fresh-agent sidecars (sidecars never get PTY rows; `register_headless` is test-only) — yet the reachable hazard above is exactly a sidecar-live case: a codex session live in the `FreshCodexState` sidecar (e.g. after "Reopen as freshcodex" of a previously terminal-bound session) still has a Bound ledger row and, at zero turns, no rollout on disk. So the precondition MUST also consult the new injected async `state.sidecar_liveness` probe (Interfaces above; main.rs wiring below), `.await`ed in the pipeline body — `spawn_terminal_pane` is already async — never inside the `spawn_blocking` closure or a sync closure: ```rust // In-gate liveness precondition (MANDATORY — see ordering note above): - // a LIVE session must never be gated; the downstream REST D7 guard - // (:968-988) owns the loud reject for live sessions. + // a LIVE session must never be gated. TWO ARMS, both load-bearing: + // the registry arm reuses the REST D7 guard's own consult (:968-988, + // which owns the loud reject for registry-live sessions downstream); + // the sidecar arm exists because that consult is PTY-scoped and blind + // to sessions live inside the fresh-agent sidecars — the very + // live-zero-turn-with-no-rollout-on-disk case. Dropping either arm + // silently un-protects a class of live sessions (step 4's live-session + // tests pin BOTH arms). let candidate_is_live = match resume_session_id.as_deref() { None => false, Some(sid) => { - /* the SAME (mode, sid) liveness consult the REST D7 guard at - :968-988 performs — hoisted into a shared helper or called - here ahead of the gate; not reimplemented; async consults - `.await`ed here in the pipeline body */ + // Arm 1 (registry): the SAME consult the REST D7 guard at + // :968-988 performs — shared/hoisted, not reimplemented. + let registry_live = registry + .live_session_owner(state.session_identity.as_deref(), &mode, sid) + .is_some(); + // Arm 2 (sidecar): the injected async probe. None (not wired, + // e.g. bare unit-test states) => arm contributes false. + let sidecar_live = if registry_live { + true // short-circuit: already live + } else { + match &state.sidecar_liveness { + Some(probe) => probe(&mode, sid).await, + None => false, + } + }; + registry_live || sidecar_live } }; // Probe does real filesystem walks — never inline on the async runtime @@ -1785,15 +1804,15 @@ Wire it in the create pipeline — numbered, all steps MANDATORY: } ``` -(Adapt local variable names/mutability to the surrounding pipeline code — in particular the `resume_session_id`/`launch_intent` rebinding must flow out of the `if candidate_is_live { .. } else { .. }` expression, not shadow inside a block; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1. `passthrough(..)` is the same Proceed-shape constructor `validate_rest_resume` itself uses — expose/reuse it rather than hand-building the outcome.) +(Adapt local variable names/mutability to the surrounding pipeline code — in particular the `resume_session_id`/`launch_intent` rebinding must flow out of the `if candidate_is_live { .. } else { .. }` expression, not shadow inside a block; the amplifier ensure at `:895` then keys off the fresh id, mirroring door 1. `registry` is the same `state.terminal_registry` handle the REST D7 guard itself uses (`:729`). `passthrough(..)` is the same Proceed-shape constructor `validate_rest_resume` itself uses — expose/reuse it rather than hand-building the outcome.) 2. **Thread the intent**: the pipeline today HARDCODES `launch_intent: LaunchIntent::Resume` in the `CliLaunchInputs` construction (`~:1335`) — Task 8 must thread `rest_outcome.launch_intent` into the inputs explicitly so the claude fallback's `Start` reaches the resolver. Verified safe (V9): the REST pipeline consumes the SAME Arc'd `cli_commands` the WS door uses (`main.rs:363/:409/:632`), the claude spec has `createSessionArgs` (`extensions/claude-code/freshell.json:11`), so `Start` + minted id resolves — no `StartIntentUnsupported`. 3. **Notice injection**: where the handler builds the response `pane_content` (the `reconcileNotice` injection precedent at `:1657`), when `rest_outcome.notice` is `Some`, insert it as `"reconcileNotice"` in the `pane_content` JSON so the existing client chip/xterm rendering shows it. (Caveat, accepted: a hidden/background tab defers the render until a later visible attach pass — the notice is preserved in pane content, never dropped.) -4. **Tests for 1+3**: add/extend unit tests in the same module asserting that when the gate fires, the built `pane_content` (a) carries the notice, and (b) carries the HEALED ref — `pane_content.sessionRef.sessionId` equals the minted fresh id, NOT the stale wire ref (pin the fall-through to the `:1686-1692` minted-ref branch). Clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly. ADDITIONALLY (pins the MANDATORY liveness precondition of step 1): a live-session case — clone the setup of the existing tests that pin the REST D7 guard's `RESTORE_UNAVAILABLE`/CONFLICT reject at `:968-988` (same fake liveness state), inject a probe answering `Absent` for that same live ref, and drive the create pipeline. Assert: (a) the response is the D7-REST loud reject, NOT a gate-fired fresh spawn; (b) `state.on_stale_resume` was NEVER invoked (install a counting fake — the Bound ledger row of the running session must survive); (c) no notice was injected. This test fails RED if the gate runs before the liveness check or the precondition is dropped. +4. **Tests for 1+3**: add/extend unit tests in the same module asserting that when the gate fires, the built `pane_content` (a) carries the notice, and (b) carries the HEALED ref — `pane_content.sessionRef.sessionId` equals the minted fresh id, NOT the stale wire ref (pin the fall-through to the `:1686-1692` minted-ref branch). Clone the existing `:1657`-area test if one exists, otherwise assert on the handler's pane-content builder function directly. ADDITIONALLY (pins the MANDATORY liveness precondition of step 1): a live-session case — clone the setup of the existing tests that pin the REST D7 guard's `RESTORE_UNAVAILABLE`/CONFLICT reject at `:968-988` (same fake liveness state), inject a probe answering `Absent` for that same live ref, and drive the create pipeline. Assert: (a) the response is the D7-REST loud reject, NOT a gate-fired fresh spawn; (b) `state.on_stale_resume` was NEVER invoked (install a counting fake — the Bound ledger row of the running session must survive); (c) no notice was injected. This test fails RED if the gate runs before the liveness check or the precondition is dropped. AND a sidecar-live case (pins the ASYNC sidecar arm — mirrors Task 6 case 6; the registry-live case above stays GREEN if the sidecar arm is dropped, so it cannot pin this): registry/identity hold NO running owner for `("codex", "stale-cx")`; inject a fake `sidecar_liveness` probe via `with_sidecar_liveness` (a closure returning `Box::pin(async move { ... })` answering `true` only for `("codex", "stale-cx")` — a fake, not a real sidecar); existence probe answers `Absent` for the same ref; ledger holds a `Bound` row; drive the create pipeline with that resume id. Assert: (a) the create proceeds UNCHANGED — the resume id reaching `CliLaunchInputs` is still `stale-cx` (NOT a gate-fired fresh spawn; and NOT a D7-REST reject, since the registry has no row — today's behavior for a sidecar-live resume); (b) `state.on_stale_resume` was NEVER invoked (counting fake — the Bound row of the sidecar-live session must survive); (c) no notice was injected. This test fails RED if the sidecar arm is dropped or the join is reduced to the registry consult alone. -Add the `FreshAgentState` fields + builders in `lib.rs` (copy the `with_opencode_locator` builder shape exactly). +Add the `FreshAgentState` fields + builders in `lib.rs` — `resume_probe`, `on_stale_resume`, AND `sidecar_liveness` (type + Arc-cycle warning per Interfaces above; copy the `with_opencode_locator` builder shape exactly). In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain: @@ -1827,9 +1846,35 @@ In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain let _ = ledger.retire_missing(provider, stale_id); }) }) + .with_sidecar_liveness({ + // MANDATORY (arm 2 of the door-3 liveness precondition): the + // SAME sidecar instances the WS door's D7 join consults — built + // at :221/:231/:259, frozen at :333-335, shared with WsState at + // :624-626. This builder chain (:408-411) runs after all three + // bindings exist, so clones are in scope here. + let claude = fresh_claude_state.clone(); + let codex = fresh_codex_state.clone(); + let opencode = fresh_opencode_state.clone(); + std::sync::Arc::new(move |mode: &str, session_id: &str| { + let claude = claude.clone(); + let codex = codex.clone(); + let opencode = opencode.clone(); + let mode = mode.to_string(); + let sid = session_id.to_string(); + Box::pin(async move { + match mode.as_str() { + "claude" => claude.has_live_session(&sid).await, + "codex" => codex.has_live_session(&sid).await, + "opencode" => opencode.has_live_session(&sid).await, + _ => false, + } + }) + as std::pin::Pin + Send>> + }) + }) ``` -(Adapt the variable names to what `main.rs` actually calls the probe and ledger Arcs at that point.) +(Adapt the variable names to what `main.rs` actually calls the probe, ledger, and sidecar-state Arcs at that point, and adapt the mode→sidecar mapping to mirror the WS door's D7 sidecar arm (`crates/freshell-ws/src/terminal.rs:1748-1752`) — the same modes must consult the same sidecars, unknown modes contribute false.) - [ ] **Step 4: Run tests to verify they pass** @@ -1895,7 +1940,8 @@ ordering and legacy-carrier liveness cases — registry AND sidecar arms); fresh-id bookkeeping assertions); `freshell-freshagent` `rest_resume_*` unit tests (incl. minted-v4 plausibility, healed pane_content stamping, and the live-session -precondition/D7-REST-reject preservation case); +precondition cases — registry arm with D7-REST-reject preservation AND +the sidecar-liveness arm); `freshell-server` `existence.rs` amplifier/codex by-id fallback, cold-index, and sub-root permission tests; `freshell-protocol/tests/roundtrip.rs` `notice` wire test. @@ -1939,7 +1985,7 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re - "If validation fails: don't pass the resume flag; spawn fresh in same cwd/mode" → fallback shapes in `validate_wire_resume`/`validate_rest_resume` mirror each mode's genuine fresh-pane spawn; cwd/mode inputs are untouched; door 3 threads the claude `Start` intent explicitly past the `~:1335` hardcode (Task 8 step 2, V9-verified). ✅ - "Operator-visible notice naming the stale id" → Task 5 (`notice` on `terminal.created` + xterm rendering, door 1), Task 8 (`reconcileNotice` in `pane_content`, door 3); door 2 broadcasts `terminal.status{Recovering, reason}` + warn-log — documented as the best available channel on a headless path (deliberate, stated in Design Notes). AD-3 residual (missing notice on the rare stale-ref-adopts-fresh-terminal path) is recorded in Task 7. ✅ - "Clear/mark the stale cached id so it isn't retried forever" → four cooperating mechanisms, all required with tests: Task 4 `retire_missing` (all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold, V5-verified) + Task 5 notice-triggered clear of persisted `sessionRef`/`resumeSessionId` (codex/opencode) + Task 8's mandatory `accepted_session_ref` guard (door 3). ✅ -- "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1, and the MANDATORY in-gate liveness precondition at door 3 (whose pipeline puts the amplifier ensure `:895` BEFORE the REST D7 guard `:968-988`, so ordering alone cannot protect it — Task 8 step 1 ordering note) — a LIVE session can never be gated or have its Bound row retired at any door + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ +- "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1, and the MANDATORY two-arm in-gate liveness precondition at door 3 (whose pipeline puts the amplifier ensure `:895` BEFORE the REST D7 guard `:968-988`, so ordering alone cannot protect it — Task 8 step 1 ordering note; and whose registry consult is PTY-scoped, so a NEW injected `sidecar_liveness` probe covers sidecar-live sessions the REST D7 guard is blind to) — a LIVE session (registry- OR sidecar-live) can never be gated or have its Bound row retired at any door + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ - "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints; covered even before index warm-up (cold-coverage matrix in Design Notes). ✅ - "Cover providers freshell already reads; fail open for others" → claude (same-boot deletions only — AD-2, honestly scoped in Design Notes + DEVIATIONS)/codex/opencode/amplifier validated; codex/opencode additionally fail open in the first seconds after boot (AD-4, accepted residual); gemini/kimi/third-party never blocked (tested). ✅ - "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints); Task 5's mirror provably touches zero `server/` files (precedent commits `5c591843`/`a18dd4c6`). ✅ @@ -1947,8 +1993,8 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re - "TDD, unit + e2e, coordinated test commands, worktree, no PR/deploy, known flaky test" → per-task RGR steps + Task 9 sweep + Global Constraints. ✅ - Runtime discipline: no door blocks the async runtime — all probe/locator IO runs in `tokio::task::spawn_blocking` (one consistent shape: sync helpers, doors wrap; Tasks 6/7/8), and the expensive codex walk is confined to warm-Absent adjudication (A13/AD-4). ✅ -**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness incl. its async sidecar arm (Task 6 cases 4–6), the door-3 liveness precondition + D7-REST-reject preservation (Task 8 step 4 live-session test), and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. +**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness incl. its async sidecar arm (Task 6 cases 4–6), the door-3 two-arm liveness precondition — registry arm with D7-REST-reject preservation AND the sidecar arm via the injected `sidecar_liveness` probe (Task 8 step 4 live-session tests) — and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. -**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) — in an async-capable `let` shape, both arms — rather than leaving it open; Task 8's liveness precondition likewise reuses the REST D7 guard's own consult (`:968-988`) rather than leaving it open. No TBD/TODO/"handle edge cases" remain. +**2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) — in an async-capable `let` shape, both arms — rather than leaving it open; Task 8's liveness precondition likewise spells out both arms rather than leaving either open — the registry arm reuses the REST D7 guard's own consult (`:968-988`, shared/hoisted, not reimplemented), and the sidecar arm is a fully-typed injected probe (`SidecarLivenessProbe` + `with_sidecar_liveness`, with its `main.rs` closure spelled inline) because the REST guard's consult is PTY-scoped and blind to sidecar-live sessions. No TBD/TODO/"handle edge cases" remain. -**3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8 — mapping unchanged by the validation edits; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2, errors-seen accumulator) matches Task 3's `amplifier_dir_locator` mapping AND the cold-branch consult; `ByIdAnswer{Present,Absent,Unreadable}` (Task 3) is the single answer contract for both locators, with identical Unreadable⇒Unknown mapping at the warm-Absent adjudication and the cold branch; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` stays SYNC and matches Task 7's call — both doors wrap it in `spawn_blocking` with an `Arc` clone, and Task 8's sync `validate_rest_resume` is wrapped identically at its wiring site (one consistent async shape across all three doors; unit tests for all helpers stay synchronous); `notice: Option` field name consistent across Tasks 5 and 6, and Task 5's client clear keys on the same field. ✅ +**3. Type consistency check:** `ResumeExistence{Present,Absent,Unknown}` and `ResumeGateDecision{Proceed,SpawnFresh}` (Task 1) used identically in Tasks 6–8 — mapping unchanged by the validation edits; `ResumeProbeAnswer{existence, ever_observed_on_disk}`/`ResumeProbeFn` (Task 1) match Task 8's fake and main.rs closure; `AmplifierSessionAnswer{Present,Absent,Unreadable}` (Task 2, errors-seen accumulator) matches Task 3's `amplifier_dir_locator` mapping AND the cold-branch consult; `ByIdAnswer{Present,Absent,Unreadable}` (Task 3) is the single answer contract for both locators, with identical Unreadable⇒Unknown mapping at the warm-Absent adjudication and the cold branch; `retire_missing(&self, provider: &str, session_id: &str) -> bool` (Task 4) matches every call site in Tasks 6–8; `validate_wire_resume(mode, Option, LaunchIntent, &dyn SessionExistenceProbe) -> ResumeValidationOutcome` stays SYNC and matches Task 7's call — both doors wrap it in `spawn_blocking` with an `Arc` clone, and Task 8's sync `validate_rest_resume` is wrapped identically at its wiring site (one consistent async shape across all three doors; unit tests for all helpers stay synchronous); `notice: Option` field name consistent across Tasks 5 and 6, and Task 5's client clear keys on the same field; `SidecarLivenessProbe` (`Arc Pin + Send>> + Send + Sync>`, Task 8) matches its `main.rs` closure and the step-4 fake, and is `.await`ed only in the async pipeline body (never inside `spawn_blocking`). ✅ From bad363d050d6c5ee2682866ca40493511944f2f4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:07:07 -0700 Subject: [PATCH 05/35] docs(resume-validation): fix fresheyes round-3 blocking plan defects Round-3 fresheyes review found two major blocking issues in the plan; both are resolved: 1. Task 8 main.rs wiring was written against a code shape that does not exist (cloned a never-named 'existence_probe' binding; pinned the builder chain to :408-411, before pane_ledger exists at :527). The wiring now specifies a pure hoist of the inline WsState session_existence initializer into a named binding plus ONE new final consuming rebinding after pane_ledger and the hoisted probe exist, with an inline soundness argument (all door-3 consumers clone after that site; the :260 FreshOpencodeState capture predates every door-3-relevant builder by existing design; OnceLock setters at :494/:542 unaffected). 2. The plan never reconciled the gate with the designed amplifier never-used-stub GC / ensure-after-GC restore contract. Now recorded as an explicit accepted deviation AD-5 (Design Notes) with rationale: a GC'd never-used stub is indistinguishable on disk from the incident's stale id, the outcome for a never-typed pane is an equivalent empty session either way, and reconcile's amplifier Absent carve-out (parking decision) is untouched. Cross-referenced in the Global Constraints fail-open invariant, the Door 2 'Absent is near-impossible' framing (now scoped to non-amplifier modes), Task 1's policy comment + amplifier test, Task 6 integration case 1, the Task 9 DEVIATIONS entry, and the Self-Review Record. --- docs/plans/2026-07-29-resume-validation.md | 61 +++++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-07-29-resume-validation.md b/docs/plans/2026-07-29-resume-validation.md index 71c6d55bb..487049be6 100644 --- a/docs/plans/2026-07-29-resume-validation.md +++ b/docs/plans/2026-07-29-resume-validation.md @@ -17,7 +17,7 @@ - Do NOT open a PR, do NOT merge, do NOT restart or deploy any server. The live Rust server on port 3002 must not be touched. - Red-Green-Refactor TDD for every task (root `AGENTS.md`): write the failing test first, watch it fail, make it pass, refactor. Never skip the refactor. - Structural limits (`port/AGENTS.md`): ≤1K lines per file. New logic goes in new focused files, not appended to already-huge ones. -- Fail-open invariant (the spec's rule 3): validation must NEVER turn a working resume into a non-resume. Only a POSITIVE "store is readable and the session is definitively absent" may block a resume. `Unknown` and `ProviderUnavailable` always proceed unchanged. +- Fail-open invariant (the spec's rule 3): validation must NEVER turn a working resume into a non-resume. Only a POSITIVE "store is readable and the session is definitively absent" may block a resume. `Unknown` and `ProviderUnavailable` always proceed unchanged. ONE decided exception, recorded as AD-5 (Design Notes): an amplifier never-used-stub resume — where "working" means `ensure_session` would fabricate an equivalent EMPTY session under the same id — is deliberately gated, because on disk it is indistinguishable from the incident. - Providers validated: `claude`, `codex`, `opencode`, `amplifier` (the four the existence probe knows). `gemini`, `kimi`, and any unknown provider are never blocked (they are outside the probe's `KNOWN_PROVIDERS`, whose contract maps unknown providers to `Absent` — the gate must therefore check its own provider list BEFORE consulting the probe). - Node server (`server/`) is deliberately NOT changed: root `AGENTS.md` states the live production server is the Rust server on port 3002 and the Node server is legacy; the validation substrate (the tri-state `IndexExistenceProbe`, opencode by-id sqlite check, claude `CLAUDE_CONFIG_DIR`-aware locator) exists only in Rust, so the port is not cheap or parallel. This is an explicit scope decision, not an oversight. - Rust tests: plain `cargo test -p ` (no coordinator gate for Rust). Client tests: `npm run test:unit` (coordinator-gated; wait for the gate, never kill a foreign holder). Raw `npx vitest` is not a coordinated workflow. @@ -51,7 +51,7 @@ **Notice visibility per door:** - Door 1 (WS create): new optional `notice` field on the `terminal.created` success frame; small client change renders it into the pane's xterm exactly like the reconcile notice AND clears the pane's persisted `sessionRef`/`resumeSessionId` (required for codex/opencode, whose gate-fired spawns carry no `sessionRef` — see the "not retried forever" story below). -- Door 2 (headless respawn): no `out` sink exists; broadcast the existing `terminal.status{Recovering, reason}` frame (precedent `auto_resume.rs:604-628`) + `tracing::warn!`. Reason prose is not client-rendered today; this is the best available channel on a headless crash-recovery path where `Absent` is near-impossible (the session was alive moments ago). Documented, deliberate. +- Door 2 (headless respawn): no `out` sink exists; broadcast the existing `terminal.status{Recovering, reason}` frame (precedent `auto_resume.rs:604-628`) + `tracing::warn!`. Reason prose is not client-rendered today; this is the best available channel on a headless crash-recovery path where `Absent` is near-impossible for claude/codex/opencode (the session was alive moments ago). EXCEPTION, deliberate (AD-5): for amplifier, the exit-hook never-used-stub GC (`terminal.rs:1381` → `gc_stub_if_unused`) deletes a never-typed terminal's stub at the very exit that triggers the respawn, so `Absent` is the EXPECTED respawn state for that class — the gate fires and the respawn proceeds under a minted id, an equivalent empty session. Documented, deliberate. - Door 3 (REST create): inject `reconcileNotice` into the returned `pane_content` (existing field, existing client rendering — no client change needed). **Validation findings incorporated (2026-07-29)** (9 evidence-gathering validators ran against this plan; ledger + reports in `.worktrees/.the-usual-logs/resume-validation/`): @@ -70,9 +70,10 @@ - **Locator error contract (A3 falsified — six empirical false-Absent reproductions):** errors-seen accumulator. `Present` short-circuits; a scan that completes with ANY per-entry error (unreadable subdir, EACCES file, `read_dir` failure below root, first-line open/read failure) and no hit answers `Unreadable` → `Unknown` → fail open. Never adjudicate via `.is_dir()` (returns `false` on EACCES) or `fs::metadata(root)` (tests existence, not readability). - **Missing store root (AD-1):** store-root NotFound with a readable parent ⇒ `Absent` (positive absence — matches today's warm-path steady state and prevents the incident on fresh installs/secondary devices); any read/permission ERROR at the root ⇒ `Unreadable` (fail open). - **Claude scope (AD-2):** claude validation covers same-boot deletions only (per-boot in-memory `ever_observed_on_disk`; see the carve-out bullet above). -- **Door 1 ordering (A11 falsified):** the gate runs AFTER the D7 cross-mode liveness guard and BEFORE the amplifier pre-create re-stub, with an in-gate liveness precondition for legacy carriers (Task 6). +- **Door 1 ordering (A11 falsified):** the gate runs AFTER the D7 cross-mode liveness guard and BEFORE the amplifier pre-create re-stub, with an in-gate liveness precondition for legacy carriers (Task 6). (Consequence for amplifier's designed stub GC: AD-5 below.) - **"Not retried forever" (A5 falsified-split):** four cooperating mechanisms — ledger `retire_missing` (server, all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold) + notice-triggered clear of the pane's persisted `sessionRef`/`resumeSessionId` (codex/opencode, Task 5) + the mandatory `accepted_session_ref` guard at door 3 (Task 8). - **AD-3 (accepted):** after a gate-fired headless respawn, the auto-resume hub's `complete_claim` keeps the STALE locator's lease bound to the fresh terminal (`auto_resume.rs:529-547`) — convergent, documented with a code comment (Task 7). +- **AD-5 (accepted, decided — the amplifier never-used-stub GC collision; fresheyes round 3):** freshell's DESIGNED amplifier lifecycle GCs a never-typed pane's session stub at terminal exit (`terminal.rs:1381` → `amplifier_stub.rs` `gc_stub_if_unused`; a stub is "unused" when metadata has no `turn_count`, the transcript is empty, and no `prompt:submit` event exists), and both spawn doors compensate by running `amplifier_stub::ensure_session` (ensure-after-GC) before spawning, re-stubbing the SAME id (`reconcile.rs:337-347`; door-1 pre-create comment `terminal.rs:1798-1815`). The gate deliberately sits BEFORE that re-stub (A11 — otherwise `ensure_session` resurrects the dir and the probe answers Present, hiding the incident forever), so after a restart EVERY never-typed open amplifier pane probes `Absent` and the gate fires: minted id + operator notice + `SessionMissing` retirement, where today the restore silently re-stubs the same id. DECIDED: ACCEPTED, for three reasons. (a) On disk a GC'd never-used stub is INDISTINGUISHABLE from the incident's stale id — both are "no session dir anywhere under any project slug", and `ever_observed_on_disk` is per-boot in-memory so post-restart both answer `false` — any carve-out that lets the stub through (e.g. mirroring claude's zero-turn carve-out for amplifier) would let the INCIDENT through too, and the incident is this feature's reason to exist ("amplifier MUST be covered"). (b) The user-visible outcome for a never-typed pane is an equivalent EMPTY amplifier session either way — same cwd, same mode, zero history to lose; the only costs are the notice line and an id change, and the minted id is healed into client persistence by the existing `terminal.created.sessionRef` overwrite fold. (c) This does NOT conflict with reconcile's amplifier Absent carve-out (`reconcile.rs:330-352`, pinned by `reconcile.rs:703-720` — both untouched): reconcile decides whether a pane gets PARKED in the dead-sessions dialog (destructive — the pane is lost), while the gate merely swaps WHICH id an already-restoring pane spawns with (the pane survives in place). Consequences recorded: Task 1's `amplifier_absent_spawns_fresh` test and Task 6's integration case 1 deliberately enshrine this exact shape, the door-2 "Absent is near-impossible" framing is scoped to non-amplifier modes (see the Door 2 bullet above), and the Task 9 DEVIATIONS entry names this consequence explicitly. ## File Structure @@ -180,6 +181,11 @@ pub fn evaluate_resume_gate( // conversed has no transcript on disk yet (mirrors // freshell-ws/reconcile.rs claude carve-out, deliberately more // fail-open: no ledger-bound requirement). + // + // Amplifier deliberately gets NO such carve-out (plan AD-5): a + // never-used stub GC'd at terminal exit is indistinguishable on + // disk from the incident's stale id, and the gate-fired fresh + // spawn is an equivalent empty session for a never-typed pane. if provider == "claude" && !ever_observed_on_disk { ResumeGateDecision::Proceed } else { @@ -215,6 +221,8 @@ mod tests { #[test] fn amplifier_absent_spawns_fresh() { // THE incident case: stale amplifier id with no session dir anywhere. + // Deliberately ALSO covers the never-used-stub-GC'd-at-exit shape — + // indistinguishable on disk from the incident (plan AD-5). assert_eq!(evaluate_resume_gate("amplifier", Absent, false), SpawnFresh); assert_eq!(evaluate_resume_gate("amplifier", Absent, true), SpawnFresh); } @@ -1347,7 +1355,7 @@ Run: `cargo test -p freshell-ws resume_validation` — PASS. RED: create `crates/freshell-ws/tests/resume_validation_gate.rs`. Reuse the harness of `crates/freshell-ws/tests/restore_spawn_gate.rs` verbatim (same `common::*` helpers, same way it builds `WsState`, injects a probe/temp stores, sends a `terminal.create` and reads response frames). Cases to cover (each is one `#[tokio::test]`): -1. `restore_true_amplifier_absent_spawns_fresh_with_notice`: state whose existence probe answers `Absent` for `("amplifier", "stale-amp")` (inject a fake `SharedExistenceProbe` — the harness sets `state.session_existence`; use a fake, not a real index) + a pane ledger containing a `Bound` row for that ref. Send `terminal.create { mode: "amplifier", restore: true, session_ref: {provider:"amplifier", sessionId:"stale-amp"}, cwd: }` (plus `FRESHELL_AMPLIFIER_HOME` pointed at an empty temp home so `ensure_session` runs against the temp store). Assert: the create SUCCEEDS (a `terminal.created` frame arrives, not an `error`); the `terminal.created` frame's `notice` contains `"stale-amp"`; the spawned resume id is NOT `stale-amp` (assert via the ledger: `load_binding("amplifier", "stale-amp")` is `Retired` with reason `SessionMissing`, and no amplifier session dir named `stale-amp` was created under the temp amplifier home — the fresh UUID dir exists instead). +1. `restore_true_amplifier_absent_spawns_fresh_with_notice`: state whose existence probe answers `Absent` for `("amplifier", "stale-amp")` (inject a fake `SharedExistenceProbe` — the harness sets `state.session_existence`; use a fake, not a real index) + a pane ledger containing a `Bound` row for that ref. Send `terminal.create { mode: "amplifier", restore: true, session_ref: {provider:"amplifier", sessionId:"stale-amp"}, cwd: }` (plus `FRESHELL_AMPLIFIER_HOME` pointed at an empty temp home so `ensure_session` runs against the temp store). Assert: the create SUCCEEDS (a `terminal.created` frame arrives, not an `error`); the `terminal.created` frame's `notice` contains `"stale-amp"`; the spawned resume id is NOT `stale-amp` (assert via the ledger: `load_binding("amplifier", "stale-amp")` is `Retired` with reason `SessionMissing`, and no amplifier session dir named `stale-amp` was created under the temp amplifier home — the fresh UUID dir exists instead). NOTE (AD-5): this empty-home + stale-id shape is byte-identical on disk to a never-used stub GC'd at terminal exit — gating it is the DECIDED behavior, not an accident (see Design Notes AD-5). 2. `restore_true_amplifier_present_resumes_unchanged`: probe answers `Present`; assert `terminal.created` has NO `notice` and the ledger row stays `Bound`. 3. `restore_true_unknown_fails_open`: probe answers `Unknown`; assert no `notice`, row stays `Bound` (today's behavior preserved). 4. `restore_true_live_absent_sessionref_hits_d7_not_the_gate`: registry/identity hold a RUNNING owner for `("amplifier", "stale-amp")` (set up the liveness state the way D7's own sibling tests do), probe answers `Absent`, ledger holds a `Bound` row. Send the same restore create as case 1. Assert: (a) the response is D7's `RestoreUnavailable` error frame ("still running"), NOT a `terminal.created` fresh spawn; (b) the `Bound` ledger row of the running session SURVIVES (`load_binding` still `Bound` — the gate never saw the create). This pins the after-D7 ordering (V8 §A11). @@ -1814,11 +1822,28 @@ Wire it in the create pipeline — numbered, all steps MANDATORY: Add the `FreshAgentState` fields + builders in `lib.rs` — `resume_probe`, `on_stale_resume`, AND `sidecar_liveness` (type + Arc-cycle warning per Interfaces above; copy the `with_opencode_locator` builder shape exactly). -In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain: +In `crates/freshell-server/src/main.rs`, the probe and the ledger do NOT exist where `FreshAgentState` is first built — the last builder chain today ends at `:408-411`, but `pane_ledger` is only constructed at `:527`, and the existence probe is built INLINE and UNNAMED inside the `WsState` struct literal's `session_existence:` field initializer (`:559-610`, a `match &session_index` with an `IndexExistenceProbe` arm and a no-index fallback arm). So the wiring takes two mechanical moves plus ONE new (final) builder rebinding at a later site: + +1. **Hoist the probe into a named binding (pure hoist, both arms byte-identical).** Cut the entire `match &session_index { ... }` initializer out of the `WsState` literal's `session_existence:` field and bind it immediately ABOVE the literal as `let session_existence: = match &session_index { ... };`, then put `session_existence.clone()` back in the struct literal field. + +2. **Add one more consuming rebinding** between `pane_ledger`'s construction (`:527`) / the hoisted `session_existence` binding and the `WsState` literal that consumes them — this becomes the LAST `with_*` rebinding, so update the "last builder" comment at `:486-489` to point here: ```rust + // Resume-validation wiring. Deliberately the LAST rebinding: it needs + // pane_ledger (:527) and the hoisted session_existence probe, which do + // not exist at the earlier builder chains. Sound because every door-3 + // consumer clones fresh_agent_state AFTER this point (the freshagent + // REST router at :1017, SnapshotState::new at :956); the one EARLIER + // capture — FreshOpencodeState::new(fresh_agent_state.clone()) at :260, + // held by value (opencode_ws.rs:94) — already predates every + // door-3-relevant builder (with_cli_commands lands at :408-411) by + // existing design and never runs the REST create pipeline. The + // set_spawn_gate/set_identity_sink calls at :494/:542 are unaffected: + // Arc cells initialized in new(), shared by every clone + // including this rebound value. + let fresh_agent_state = fresh_agent_state .with_resume_probe({ - let probe = existence_probe.clone(); // the Arc built earlier + let probe = session_existence.clone(); // the hoisted Arc'd probe from step 1 std::sync::Arc::new(move |provider: &str, session_id: &str| { use freshell_platform::resume_gate::{ResumeExistence, ResumeProbeAnswer}; use freshell_ws::existence::{SessionExistence, SessionExistenceProbe}; @@ -1850,8 +1875,8 @@ In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain // MANDATORY (arm 2 of the door-3 liveness precondition): the // SAME sidecar instances the WS door's D7 join consults — built // at :221/:231/:259, frozen at :333-335, shared with WsState at - // :624-626. This builder chain (:408-411) runs after all three - // bindings exist, so clones are in scope here. + // :624-626. All three bindings long predate this late wiring + // site, so clones are in scope here. let claude = fresh_claude_state.clone(); let codex = fresh_codex_state.clone(); let opencode = fresh_opencode_state.clone(); @@ -1871,10 +1896,10 @@ In `crates/freshell-server/src/main.rs`, where `FreshAgentState` is built, chain }) as std::pin::Pin + Send>> }) - }) + }); ``` -(Adapt the variable names to what `main.rs` actually calls the probe, ledger, and sidecar-state Arcs at that point, and adapt the mode→sidecar mapping to mirror the WS door's D7 sidecar arm (`crates/freshell-ws/src/terminal.rs:1748-1752`) — the same modes must consult the same sidecars, unknown modes contribute false.) +(All `main.rs` line anchors above are PRE-CHANGE positions — re-locate them in the file as found (the hoist in step 1 shifts everything below it). Adapt the sidecar-state binding names to what `main.rs` actually calls the three Arcs, and adapt the mode→sidecar mapping to mirror the WS door's D7 sidecar arm (`crates/freshell-ws/src/terminal.rs:1748-1752`) — the same modes must consult the same sidecars, unknown modes contribute false.) - [ ] **Step 4: Run tests to verify they pass** @@ -1923,8 +1948,16 @@ Absent + never-observed-on-disk resuming, and the disk-observation signal is a per-boot in-memory set, so a transcript deleted while the server was DOWN is indistinguishable from never-conversed and fails OPEN post-restart; deliberate, fail-open) — codex, opencode, amplifier. gemini/kimi/third-party -are never blocked. Additive protocol field: optional `notice` on -`terminal.created`. +are never blocked. Known accepted consequence for amplifier (AD-5 in the +plan): freshell's designed never-used-stub GC deletes a never-typed pane's +session stub at terminal exit and the spawn doors re-stub the SAME id via +`ensure_session` on the next resume — after a restart such a pane now spawns +fresh under a minted id WITH a notice instead of silently re-stubbing the +same id. Decided and accepted: on disk the GC'd stub is indistinguishable +from the incident's stale id, and for a never-typed pane the outcome is an +equivalent empty session either way (reconcile's amplifier Absent carve-out, +which prevents PARKING such panes in the dead-sessions dialog, is untouched). +Additive protocol field: optional `notice` on `terminal.created`. **Why:** Production incident 2026-07-29 — after a server restart, freshell resumed stored amplifier session id 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 which @@ -1987,13 +2020,13 @@ git commit -m "docs(resume-validation): deviation ledger entry for spawn-door re - "Clear/mark the stale cached id so it isn't retried forever" → four cooperating mechanisms, all required with tests: Task 4 `retire_missing` (all doors) + `terminal.created.sessionRef` overwrite (claude/amplifier, existing client fold, V5-verified) + Task 5 notice-triggered clear of persisted `sessionRef`/`resumeSessionId` (codex/opencode) + Task 8's mandatory `accepted_session_ref` guard (door 3). ✅ - "Fail open on Unknown/unreadable" — invariant now STRONGER than the original plan: gate policy (Task 1) + errors-seen accumulator locator contracts (Tasks 2–3: ANY per-entry error with no hit ⇒ `Unreadable` ⇒ `Unknown` ⇒ Proceed; never `.is_dir()`/`fs::metadata(root)` adjudication) + AD-1 root semantics (NotFound-with-readable-parent ⇒ Absent, root ERROR ⇒ Unreadable) + in-gate liveness precondition and after-D7 ordering at door 1, and the MANDATORY two-arm in-gate liveness precondition at door 3 (whose pipeline puts the amplifier ensure `:895` BEFORE the REST D7 guard `:968-988`, so ordering alone cannot protect it — Task 8 step 1 ordering note; and whose registry consult is PTY-scoped, so a NEW injected `sidecar_liveness` probe covers sidecar-live sessions the REST D7 guard is blind to) — a LIVE session (registry- OR sidecar-live) can never be gated or have its Bound row retired at any door + explicit fail-open tests at every layer incl. sub-root permission fixtures (euid-guarded). ✅ - "Amplifier MUST be covered; store = ~/.amplifier/projects//sessions//; search all projects (documented)" → Tasks 2–3 + Global Constraints; covered even before index warm-up (cold-coverage matrix in Design Notes). ✅ -- "Cover providers freshell already reads; fail open for others" → claude (same-boot deletions only — AD-2, honestly scoped in Design Notes + DEVIATIONS)/codex/opencode/amplifier validated; codex/opencode additionally fail open in the first seconds after boot (AD-4, accepted residual); gemini/kimi/third-party never blocked (tested). ✅ +- "Cover providers freshell already reads; fail open for others" → claude (same-boot deletions only — AD-2, honestly scoped in Design Notes + DEVIATIONS)/codex/opencode/amplifier validated; codex/opencode additionally fail open in the first seconds after boot (AD-4, accepted residual); amplifier's never-used-stub GC collision is DECIDED and documented, not defaulted into (AD-5 — gate deliberately fires on the GC'd-stub shape; pinned by Task 1's amplifier test + Task 6 case 1; DEVIATIONS names the consequence); gemini/kimi/third-party never blocked (tested). ✅ - "Rust server REQUIRED; Node only if cheap — if skipped, say so" → Node explicitly skipped with reasons (Global Constraints); Task 5's mirror provably touches zero `server/` files (precedent commits `5c591843`/`a18dd4c6`). ✅ - "Non-goals respected" → no amplifier-CLI changes; no indexing subsystem changes beyond the by-id fallbacks + the cold-branch consult the validation needs; protocol change limited to one additive optional field, mirrored via `shared/ws-protocol.ts` + regenerated contract schema (never hand-edited). ✅ - "TDD, unit + e2e, coordinated test commands, worktree, no PR/deploy, known flaky test" → per-task RGR steps + Task 9 sweep + Global Constraints. ✅ - Runtime discipline: no door blocks the async runtime — all probe/locator IO runs in `tokio::task::spawn_blocking` (one consistent shape: sync helpers, doors wrap; Tasks 6/7/8), and the expensive codex walk is confined to warm-Absent adjudication (A13/AD-4). ✅ -**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness incl. its async sidecar arm (Task 6 cases 4–6), the door-3 two-arm liveness precondition — registry arm with D7-REST-reject preservation AND the sidecar arm via the injected `sidecar_liveness` probe (Task 8 step 4 live-session tests) — and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-4), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. +**1b. No silent deferrals:** The production outcome that proves the feature: `resume_validation_gate.rs` case 1 drives the REAL `handle_create` restore path with a stale amplifier ref against a real (temp) amplifier store and asserts fresh spawn + notice + retired binding — no stub stands in for required behavior in production code paths. Behaviors the validation pass surfaced are now REQUIRED with pinning tests, not deferred: the codex/opencode notice-triggered client clear (Task 5 test), the door-3 `accepted_session_ref` guard + healed-ref stamping (Task 8 step 4 tests), the door-2 fresh-id bookkeeping (Task 7 assertion 5), the after-D7 ordering + in-gate liveness incl. its async sidecar arm (Task 6 cases 4–6), the door-3 two-arm liveness precondition — registry arm with D7-REST-reject preservation AND the sidecar arm via the injected `sidecar_liveness` probe (Task 8 step 4 live-session tests) — and the minted-v4 plausibility pin (Task 8). The only accepted residuals are recorded decisions with rationale (AD-1…AD-5), not silent gaps. Test doubles used (fake `SessionExistenceProbe`/`ResumeProbeFn` in unit tests) are replaced in production by `IndexExistenceProbe`, whose by-id fallback, cold-index, and error-contract behavior is tested against real temp stores in Task 3; the `main.rs` wiring is exercised by `cargo test -p freshell-server` compile + probe tests and reviewed in Tasks 3/8. NO UNRESOLVED COVERAGE GAPS. **2. Placeholder scan:** Steps that intentionally clone an existing harness (Task 3 fallback/cold/permission tests, Task 6/7 integration tests, Task 5 client tests, Task 4 ledger tests) each name the exact sibling file/test whose scaffolding to copy and spell out the complete required assertions — the behavior contract is fully specified; only mechanical harness reuse is deferred to the named files. The Task 3 `codex_rollout_on_disk` snippet leaves ONE named private helper to mechanical implementation (the tri-state first-line ownership read) with its full contract spelled inline (valid-read-mismatch ⇒ non-owner; open/read/decode failure incl. `.jsonl.zst` ⇒ error). Task 6's liveness precondition explicitly copies D7's existing join (`:1747-1758`) — in an async-capable `let` shape, both arms — rather than leaving it open; Task 8's liveness precondition likewise spells out both arms rather than leaving either open — the registry arm reuses the REST D7 guard's own consult (`:968-988`, shared/hoisted, not reimplemented), and the sidecar arm is a fully-typed injected probe (`SidecarLivenessProbe` + `with_sidecar_liveness`, with its `main.rs` closure spelled inline) because the REST guard's consult is PTY-scoped and blind to sidecar-live sessions. No TBD/TODO/"handle edge cases" remain. From 944c528a3010487b943d61b68ce74440d44cf5a3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:17:24 -0700 Subject: [PATCH 06/35] feat(resume-validation): pure resume-gate policy in freshell-platform --- crates/freshell-platform/src/lib.rs | 1 + crates/freshell-platform/src/resume_gate.rs | 150 ++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 crates/freshell-platform/src/resume_gate.rs diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 98bfc113c..5d2054cc9 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -56,6 +56,7 @@ pub mod detect; pub mod mcp_inject; pub mod opencode_plugin; pub mod path; +pub mod resume_gate; pub mod spawn; // ---- Live-process modules (Phase 3, step 14) — builders + injected CommandRunner ---- diff --git a/crates/freshell-platform/src/resume_gate.rs b/crates/freshell-platform/src/resume_gate.rs new file mode 100644 index 000000000..ace5a442d --- /dev/null +++ b/crates/freshell-platform/src/resume_gate.rs @@ -0,0 +1,150 @@ +//! Pre-spawn resume validation policy (resume-validation feature). +//! +//! Pure decision logic: given a provider and a disk-existence answer, decide +//! whether a cached resume id may be passed to the CLI or the pane must spawn +//! fresh. IO-free by design (mirrors `cli_launch`'s purity rule); callers map +//! their probe answers into [`ResumeExistence`]. +//! +//! FAIL-OPEN INVARIANT: only a POSITIVE "store readable, session definitively +//! absent" returns [`ResumeGateDecision::SpawnFresh`]. Unknown/unreadable +//! stores, unvalidated providers (gemini, kimi, third-party), and the claude +//! zero-turn carve-out all Proceed (today's behavior). + +use std::sync::Arc; + +/// Providers whose on-disk store the existence probe knows how to read. +/// MUST stay a subset of `freshell-server`'s `KNOWN_PROVIDERS`: the probe's +/// contract maps unknown providers to `Absent`, so callers must check this +/// list BEFORE consulting the probe. +pub const VALIDATED_PROVIDERS: [&str; 4] = ["claude", "codex", "opencode", "amplifier"]; + +pub fn provider_validated(provider: &str) -> bool { + VALIDATED_PROVIDERS.contains(&provider) +} + +/// Caller-mapped existence answer. `ProviderUnavailable` maps to `Unknown`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResumeExistence { + Present, + Absent, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResumeGateDecision { + /// Pass the resume id through unchanged (validated present, or fail-open). + Proceed, + /// Definitively absent: drop the resume, spawn fresh, notify, retire. + SpawnFresh, +} + +pub fn evaluate_resume_gate( + provider: &str, + existence: ResumeExistence, + ever_observed_on_disk: bool, +) -> ResumeGateDecision { + if !provider_validated(provider) { + return ResumeGateDecision::Proceed; + } + match existence { + ResumeExistence::Present | ResumeExistence::Unknown => ResumeGateDecision::Proceed, + ResumeExistence::Absent => { + // Zero-turn carve-out: a freshell-minted claude session that never + // conversed has no transcript on disk yet (mirrors + // freshell-ws/reconcile.rs claude carve-out, deliberately more + // fail-open: no ledger-bound requirement). + // + // Amplifier deliberately gets NO such carve-out (plan AD-5): a + // never-used stub GC'd at terminal exit is indistinguishable on + // disk from the incident's stale id, and the gate-fired fresh + // spawn is an equivalent empty session for a never-typed pane. + if provider == "claude" && !ever_observed_on_disk { + ResumeGateDecision::Proceed + } else { + ResumeGateDecision::SpawnFresh + } + } + } +} + +/// The operator-visible notice line. MUST name the stale id (spec requirement). +pub fn stale_resume_notice(provider: &str, stale_id: &str) -> String { + format!( + "Saved {provider} session {stale_id} could not be found on disk — started a fresh session instead." + ) +} + +/// Injection shape for crates that cannot depend on `freshell-ws`'s probe +/// trait (freshell-freshagent): one call answering both existence and +/// disk-history for `(provider, session_id)`. +pub struct ResumeProbeAnswer { + pub existence: ResumeExistence, + pub ever_observed_on_disk: bool, +} + +pub type ResumeProbeFn = Arc ResumeProbeAnswer + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + use ResumeExistence::*; + use ResumeGateDecision::*; + + #[test] + fn amplifier_absent_spawns_fresh() { + // THE incident case: stale amplifier id with no session dir anywhere. + // Deliberately ALSO covers the never-used-stub-GC'd-at-exit shape — + // indistinguishable on disk from the incident (plan AD-5). + assert_eq!(evaluate_resume_gate("amplifier", Absent, false), SpawnFresh); + assert_eq!(evaluate_resume_gate("amplifier", Absent, true), SpawnFresh); + } + + #[test] + fn codex_and_opencode_absent_spawn_fresh() { + assert_eq!(evaluate_resume_gate("codex", Absent, true), SpawnFresh); + assert_eq!(evaluate_resume_gate("opencode", Absent, true), SpawnFresh); + assert_eq!(evaluate_resume_gate("codex", Absent, false), SpawnFresh); + assert_eq!(evaluate_resume_gate("opencode", Absent, false), SpawnFresh); + } + + #[test] + fn claude_zero_turn_carve_out_proceeds() { + // Never observed on disk => could be a legit zero-turn session. Fail open. + assert_eq!(evaluate_resume_gate("claude", Absent, false), Proceed); + } + + #[test] + fn claude_absent_but_previously_on_disk_spawns_fresh() { + // Transcript existed once and is gone now: positive absence. + assert_eq!(evaluate_resume_gate("claude", Absent, true), SpawnFresh); + } + + #[test] + fn present_and_unknown_always_proceed() { + for p in VALIDATED_PROVIDERS { + assert_eq!(evaluate_resume_gate(p, Present, false), Proceed); + assert_eq!(evaluate_resume_gate(p, Unknown, false), Proceed); + assert_eq!(evaluate_resume_gate(p, Present, true), Proceed); + assert_eq!(evaluate_resume_gate(p, Unknown, true), Proceed); + } + } + + #[test] + fn unvalidated_providers_never_blocked() { + // gemini/kimi have no resumeArgs and are outside KNOWN_PROVIDERS; + // the probe would answer Absent for them — the gate must not care. + for p in ["gemini", "kimi", "some-third-party-ext", "shell"] { + assert_eq!(evaluate_resume_gate(p, Absent, false), Proceed); + assert!(!provider_validated(p)); + } + } + + #[test] + fn notice_names_the_stale_id() { + let n = stale_resume_notice("amplifier", "8dab420a-f76b-407c-bcbe-dfb2a971c2e1"); + assert!(n.contains("amplifier")); + assert!(n.contains("8dab420a-f76b-407c-bcbe-dfb2a971c2e1")); + assert!(n.contains("could not be found")); + assert!(n.contains("fresh session")); + } +} From 3e6c1898b8b32686f5351a444a996ea0e11a66ae Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:24:32 -0700 Subject: [PATCH 07/35] feat(resume-validation): read-only amplifier session_on_disk scan - Add AmplifierSessionAnswer enum with three states: Present, Absent, Unreadable - Implement session_on_disk() function for read-only session existence scanning - Scan ALL project slugs under amplifier_home/projects/ (not just cwd-derived slug) - Tri-state semantics with errors-seen accumulator: - Present: session dir found anywhere (short-circuits) - Absent: projects/ missing OR scanned without errors/hits - Unreadable: projects/ unreadable OR per-entry errors with no hit (fail-open) - 8 comprehensive tests covering present, absent, divergent slug, and 5 permission/access scenarios - All tests passing, clippy clean --- .../freshell-sessions/src/amplifier_stub.rs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/crates/freshell-sessions/src/amplifier_stub.rs b/crates/freshell-sessions/src/amplifier_stub.rs index bff79d6cd..7f5869fbd 100644 --- a/crates/freshell-sessions/src/amplifier_stub.rs +++ b/crates/freshell-sessions/src/amplifier_stub.rs @@ -290,6 +290,65 @@ pub fn gc_stub_if_unused(session_dir: &Path) -> bool { std::fs::remove_dir_all(session_dir).is_ok() } +/// Read-only disk-existence answer for one amplifier session id, scanning ALL +/// project slugs under `/projects/` (a session may live under +/// a different slug than the current cwd — see `ensure_session`'s divergent- +/// slug handling). Never creates anything. +/// +/// Semantics (resume-validation feature — errors-seen accumulator, V3): +/// * session dir found under any project => `Present` (short-circuits); +/// * `projects/` missing (NotFound, parent readable) or scanned WITHOUT any +/// error and without a hit => `Absent` (store readable, definitively +/// absent — AD-1: missing root is positive absence, matching today's +/// warm-path steady state); +/// * `projects/` unreadable at the root, OR any per-entry error during the +/// scan (unreadable project subdir, EACCES stat, dropped dir entry) with +/// no hit => `Unreadable` (callers must fail OPEN — treat as unknown, +/// never as absent). NEVER adjudicate via `.is_dir()` alone: it returns +/// `false` on EACCES and would manufacture a false Absent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmplifierSessionAnswer { + Present, + Absent, + Unreadable, +} + +pub fn session_on_disk( + amplifier_home: &std::path::Path, + session_id: &str, +) -> AmplifierSessionAnswer { + let projects = amplifier_home.join("projects"); + let entries = match std::fs::read_dir(&projects) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return AmplifierSessionAnswer::Absent; // AD-1: root missing, parent readable + } + Err(_) => return AmplifierSessionAnswer::Unreadable, + }; + let mut saw_error = false; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + saw_error = true; // dropped dir entry (EIO, network fs) — cannot rule out + continue; + } + }; + let candidate = entry.path().join("sessions").join(session_id); + match std::fs::metadata(&candidate) { + Ok(meta) if meta.is_dir() => return AmplifierSessionAnswer::Present, + Ok(_) => {} // stray FILE named like the id — not a session dir + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => saw_error = true, // EACCES etc. — the session may be hiding here + } + } + if saw_error { + AmplifierSessionAnswer::Unreadable + } else { + AmplifierSessionAnswer::Absent + } +} + /// Outcome of the boot-time layout canary ([`verify_amplifier_layout_contract`]). #[derive(Debug, Clone, PartialEq, Eq)] pub enum CanaryOutcome { @@ -840,4 +899,151 @@ mod tests { } ); } + + // ============ session_on_disk tests ============ + use std::sync::atomic::{AtomicU64, Ordering as TestOrdering}; + + static SCAN_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn scan_temp_home(label: &str) -> std::path::PathBuf { + let n = SCAN_COUNTER.fetch_add(1, TestOrdering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-amplifier-scan-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Permission tests are meaningless as root (root ignores mode bits) — + /// e.g. sandboxed/container suites. Skip (return early) when euid == 0. + fn running_as_root() -> bool { + std::process::Command::new("id") + .arg("-u") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") + .unwrap_or(false) + } + + #[test] + fn session_on_disk_present_under_cwd_slug() { + let home = scan_temp_home("present"); + let sess = home.join("projects/-home-dan-proj/sessions/sid-1"); + std::fs::create_dir_all(&sess).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-1"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_present_under_divergent_slug() { + // The tab may have moved cwd; the session lives under ANOTHER project + // slug. Scanning all project dirs must still find it (plan decision: + // search all projects, documented in Global Constraints). + let home = scan_temp_home("divergent"); + std::fs::create_dir_all(home.join("projects/-some-other-project/sessions/sid-2")).unwrap(); + std::fs::create_dir_all(home.join("projects/-home-dan-proj")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-2"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_absent_when_store_readable() { + let home = scan_temp_home("absent"); + std::fs::create_dir_all(home.join("projects/-home-dan-proj/sessions/other-sid")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-3"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_absent_when_projects_dir_missing() { + // Store root exists but amplifier has never created projects/: + // readable-and-empty => definitively absent. + let home = scan_temp_home("noprojects"); + assert!(matches!( + session_on_disk(&home, "sid-4"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(unix)] + #[test] + fn session_on_disk_unreadable_projects_dir_fails_open() { + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; // root ignores mode bits — test is meaningless + } + let home = scan_temp_home("unreadable"); + let projects = home.join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-5"); + // Restore perms before asserting so cleanup works even on failure. + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(unix)] + #[test] + fn session_on_disk_unreadable_project_subdir_fails_open() { + // V3 case E2a: a chmod-000 PROJECT dir with the session inside must + // answer Unreadable, never Absent (`.is_dir()` returns false on + // EACCES — the errors-seen accumulator catches the metadata error). + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("locked-project"); + let locked = home.join("projects/-locked-proj"); + std::fs::create_dir_all(locked.join("sessions/sid-7")).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-7"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(unix)] + #[test] + fn session_on_disk_listable_not_traversable_projects_fails_open() { + // V3 case E2b: projects/ mode 444 — read_dir succeeds (needs r) but + // stat into children needs x, so every per-entry metadata call errors. + // Any-error-and-no-hit => Unreadable. + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("no-traverse"); + let projects = home.join("projects"); + std::fs::create_dir_all(projects.join("-p/sessions/sid-8")).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o444)).unwrap(); + let answer = session_on_disk(&home, "sid-8"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn session_on_disk_matches_a_file_only_as_absent() { + // A stray FILE named like the session id is not a session dir. + let home = scan_temp_home("file-not-dir"); + let sessions = home.join("projects/-p/sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(sessions.join("sid-6"), b"junk").unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-6"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); + } } From 40f30503a9ae540c77902c53b3577a188220023a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:31:21 -0700 Subject: [PATCH 08/35] refactor(resume-validation): split session_on_disk tests into sibling file to honor 1K-line cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved the eight session_on_disk test functions (and their helpers SCAN_COUNTER, scan_temp_home, running_as_root) from amplifier_stub.rs (1049 → 906 lines) into a new sibling module amplifier_stub_scan_tests.rs (148 lines), wired up via #[path] attribute per the pane_ledger_tests.rs pattern. All 8 tests verified running and passing: - session_on_disk_present_under_cwd_slug - session_on_disk_present_under_divergent_slug - session_on_disk_absent_when_store_readable - session_on_disk_absent_when_projects_dir_missing - session_on_disk_unreadable_projects_dir_fails_open - session_on_disk_unreadable_project_subdir_fails_open - session_on_disk_listable_not_traversable_projects_fails_open - session_on_disk_matches_a_file_only_as_absent Clippy: clean (no warnings). Generated with Amplifier (https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../freshell-sessions/src/amplifier_stub.rs | 151 +----------------- .../src/amplifier_stub_scan_tests.rs | 148 +++++++++++++++++ 2 files changed, 152 insertions(+), 147 deletions(-) create mode 100644 crates/freshell-sessions/src/amplifier_stub_scan_tests.rs diff --git a/crates/freshell-sessions/src/amplifier_stub.rs b/crates/freshell-sessions/src/amplifier_stub.rs index 7f5869fbd..9f09bf997 100644 --- a/crates/freshell-sessions/src/amplifier_stub.rs +++ b/crates/freshell-sessions/src/amplifier_stub.rs @@ -899,151 +899,8 @@ mod tests { } ); } - - // ============ session_on_disk tests ============ - use std::sync::atomic::{AtomicU64, Ordering as TestOrdering}; - - static SCAN_COUNTER: AtomicU64 = AtomicU64::new(0); - - fn scan_temp_home(label: &str) -> std::path::PathBuf { - let n = SCAN_COUNTER.fetch_add(1, TestOrdering::SeqCst); - let dir = std::env::temp_dir().join(format!( - "freshell-amplifier-scan-{label}-{}-{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - /// Permission tests are meaningless as root (root ignores mode bits) — - /// e.g. sandboxed/container suites. Skip (return early) when euid == 0. - fn running_as_root() -> bool { - std::process::Command::new("id") - .arg("-u") - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") - .unwrap_or(false) - } - - #[test] - fn session_on_disk_present_under_cwd_slug() { - let home = scan_temp_home("present"); - let sess = home.join("projects/-home-dan-proj/sessions/sid-1"); - std::fs::create_dir_all(&sess).unwrap(); - assert!(matches!( - session_on_disk(&home, "sid-1"), - AmplifierSessionAnswer::Present - )); - let _ = std::fs::remove_dir_all(&home); - } - - #[test] - fn session_on_disk_present_under_divergent_slug() { - // The tab may have moved cwd; the session lives under ANOTHER project - // slug. Scanning all project dirs must still find it (plan decision: - // search all projects, documented in Global Constraints). - let home = scan_temp_home("divergent"); - std::fs::create_dir_all(home.join("projects/-some-other-project/sessions/sid-2")).unwrap(); - std::fs::create_dir_all(home.join("projects/-home-dan-proj")).unwrap(); - assert!(matches!( - session_on_disk(&home, "sid-2"), - AmplifierSessionAnswer::Present - )); - let _ = std::fs::remove_dir_all(&home); - } - - #[test] - fn session_on_disk_absent_when_store_readable() { - let home = scan_temp_home("absent"); - std::fs::create_dir_all(home.join("projects/-home-dan-proj/sessions/other-sid")).unwrap(); - assert!(matches!( - session_on_disk(&home, "sid-3"), - AmplifierSessionAnswer::Absent - )); - let _ = std::fs::remove_dir_all(&home); - } - - #[test] - fn session_on_disk_absent_when_projects_dir_missing() { - // Store root exists but amplifier has never created projects/: - // readable-and-empty => definitively absent. - let home = scan_temp_home("noprojects"); - assert!(matches!( - session_on_disk(&home, "sid-4"), - AmplifierSessionAnswer::Absent - )); - let _ = std::fs::remove_dir_all(&home); - } - - #[cfg(unix)] - #[test] - fn session_on_disk_unreadable_projects_dir_fails_open() { - use std::os::unix::fs::PermissionsExt; - if running_as_root() { - return; // root ignores mode bits — test is meaningless - } - let home = scan_temp_home("unreadable"); - let projects = home.join("projects"); - std::fs::create_dir_all(&projects).unwrap(); - std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); - let answer = session_on_disk(&home, "sid-5"); - // Restore perms before asserting so cleanup works even on failure. - std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); - let _ = std::fs::remove_dir_all(&home); - } - - #[cfg(unix)] - #[test] - fn session_on_disk_unreadable_project_subdir_fails_open() { - // V3 case E2a: a chmod-000 PROJECT dir with the session inside must - // answer Unreadable, never Absent (`.is_dir()` returns false on - // EACCES — the errors-seen accumulator catches the metadata error). - use std::os::unix::fs::PermissionsExt; - if running_as_root() { - return; - } - let home = scan_temp_home("locked-project"); - let locked = home.join("projects/-locked-proj"); - std::fs::create_dir_all(locked.join("sessions/sid-7")).unwrap(); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); - let answer = session_on_disk(&home, "sid-7"); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); - let _ = std::fs::remove_dir_all(&home); - } - - #[cfg(unix)] - #[test] - fn session_on_disk_listable_not_traversable_projects_fails_open() { - // V3 case E2b: projects/ mode 444 — read_dir succeeds (needs r) but - // stat into children needs x, so every per-entry metadata call errors. - // Any-error-and-no-hit => Unreadable. - use std::os::unix::fs::PermissionsExt; - if running_as_root() { - return; - } - let home = scan_temp_home("no-traverse"); - let projects = home.join("projects"); - std::fs::create_dir_all(projects.join("-p/sessions/sid-8")).unwrap(); - std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o444)).unwrap(); - let answer = session_on_disk(&home, "sid-8"); - std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); - let _ = std::fs::remove_dir_all(&home); - } - - #[test] - fn session_on_disk_matches_a_file_only_as_absent() { - // A stray FILE named like the session id is not a session dir. - let home = scan_temp_home("file-not-dir"); - let sessions = home.join("projects/-p/sessions"); - std::fs::create_dir_all(&sessions).unwrap(); - std::fs::write(sessions.join("sid-6"), b"junk").unwrap(); - assert!(matches!( - session_on_disk(&home, "sid-6"), - AmplifierSessionAnswer::Absent - )); - let _ = std::fs::remove_dir_all(&home); - } } + +#[cfg(test)] +#[path = "amplifier_stub_scan_tests.rs"] +mod scan_tests; diff --git a/crates/freshell-sessions/src/amplifier_stub_scan_tests.rs b/crates/freshell-sessions/src/amplifier_stub_scan_tests.rs new file mode 100644 index 000000000..255cdc8df --- /dev/null +++ b/crates/freshell-sessions/src/amplifier_stub_scan_tests.rs @@ -0,0 +1,148 @@ +/// Tests for `session_on_disk` scanning logic. +use super::*; + +use std::sync::atomic::{AtomicU64, Ordering as TestOrdering}; + +static SCAN_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn scan_temp_home(label: &str) -> std::path::PathBuf { + let n = SCAN_COUNTER.fetch_add(1, TestOrdering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-amplifier-scan-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Permission tests are meaningless as root (root ignores mode bits) — +/// e.g. sandboxed/container suites. Skip (return early) when euid == 0. +fn running_as_root() -> bool { + std::process::Command::new("id") + .arg("-u") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") + .unwrap_or(false) +} + +#[test] +fn session_on_disk_present_under_cwd_slug() { + let home = scan_temp_home("present"); + let sess = home.join("projects/-home-dan-proj/sessions/sid-1"); + std::fs::create_dir_all(&sess).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-1"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn session_on_disk_present_under_divergent_slug() { + // The tab may have moved cwd; the session lives under ANOTHER project + // slug. Scanning all project dirs must still find it (plan decision: + // search all projects, documented in Global Constraints). + let home = scan_temp_home("divergent"); + std::fs::create_dir_all(home.join("projects/-some-other-project/sessions/sid-2")).unwrap(); + std::fs::create_dir_all(home.join("projects/-home-dan-proj")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-2"), + AmplifierSessionAnswer::Present + )); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn session_on_disk_absent_when_store_readable() { + let home = scan_temp_home("absent"); + std::fs::create_dir_all(home.join("projects/-home-dan-proj/sessions/other-sid")).unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-3"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn session_on_disk_absent_when_projects_dir_missing() { + // Store root exists but amplifier has never created projects/: + // readable-and-empty => definitively absent. + let home = scan_temp_home("noprojects"); + assert!(matches!( + session_on_disk(&home, "sid-4"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); +} + +#[cfg(unix)] +#[test] +fn session_on_disk_unreadable_projects_dir_fails_open() { + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; // root ignores mode bits — test is meaningless + } + let home = scan_temp_home("unreadable"); + let projects = home.join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-5"); + // Restore perms before asserting so cleanup works even on failure. + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); +} + +#[cfg(unix)] +#[test] +fn session_on_disk_unreadable_project_subdir_fails_open() { + // V3 case E2a: a chmod-000 PROJECT dir with the session inside must + // answer Unreadable, never Absent (`.is_dir()` returns false on + // EACCES — the errors-seen accumulator catches the metadata error). + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("locked-project"); + let locked = home.join("projects/-locked-proj"); + std::fs::create_dir_all(locked.join("sessions/sid-7")).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = session_on_disk(&home, "sid-7"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); +} + +#[cfg(unix)] +#[test] +fn session_on_disk_listable_not_traversable_projects_fails_open() { + // V3 case E2b: projects/ mode 444 — read_dir succeeds (needs r) but + // stat into children needs x, so every per-entry metadata call errors. + // Any-error-and-no-hit => Unreadable. + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; + } + let home = scan_temp_home("no-traverse"); + let projects = home.join("projects"); + std::fs::create_dir_all(projects.join("-p/sessions/sid-8")).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o444)).unwrap(); + let answer = session_on_disk(&home, "sid-8"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(answer, AmplifierSessionAnswer::Unreadable)); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn session_on_disk_matches_a_file_only_as_absent() { + // A stray FILE named like the session id is not a session dir. + let home = scan_temp_home("file-not-dir"); + let sessions = home.join("projects/-p/sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(sessions.join("sid-6"), b"junk").unwrap(); + assert!(matches!( + session_on_disk(&home, "sid-6"), + AmplifierSessionAnswer::Absent + )); + let _ = std::fs::remove_dir_all(&home); +} From bb788a4bbb9b6f3fd8afb8541fd02008f848112e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:51:25 -0700 Subject: [PATCH 09/35] feat(resume-validation): amplifier + codex by-id existence fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one probe change (plan Task 3): 1. Stale warm snapshots: a session created moments before a restart can be missing from the snapshot, and peek() serves snapshots regardless of TTL. The claude/opencode arms already had by-id fallbacks; amplifier and codex now get them too, so Absent for all four validated providers is POSITIVE absence. The codex fallback is a NEW gate-safe tri-state walk (errors-seen accumulator), deliberately NOT the fail-soft locate_codex_rollout (silent None on per-entry errors => false Absent). 2. Cold index at boot (A1): the snapshot is None every boot and the warm sweep is a detached spawn, so restore-time creates race it. exists() now runs the cheap amplifier/claude by-id locators when cold; codex/opencode stay Unknown (AD-4: the ~1s codex walk is warm-Absent adjudication only). The root-missing => ProviderUnavailable pre-check is untouched. New locator logic lives in a focused existence_by_id.rs (existence.rs is already large) and is re-exported as existence::*; tests live in a sibling existence_resume_validation_tests.rs included from existence.rs's test module (amplifier_stub scan_tests precedent). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-server/src/existence.rs | 137 +++++- crates/freshell-server/src/existence_by_id.rs | 143 ++++++ .../src/existence_resume_validation_tests.rs | 413 ++++++++++++++++++ crates/freshell-server/src/main.rs | 35 +- 4 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 crates/freshell-server/src/existence_by_id.rs create mode 100644 crates/freshell-server/src/existence_resume_validation_tests.rs diff --git a/crates/freshell-server/src/existence.rs b/crates/freshell-server/src/existence.rs index 5475e9e96..3915f9212 100644 --- a/crates/freshell-server/src/existence.rs +++ b/crates/freshell-server/src/existence.rs @@ -73,6 +73,14 @@ pub enum OpencodeDbAnswer { /// `claude_transcript_locator` above. pub type OpencodeSessionLocator = Arc OpencodeDbAnswer + Send + Sync>; +// Resume-validation (plan Task 3): the amplifier/codex by-id locators live in +// a focused sibling module; re-exported so the probe's whole locator surface +// stays addressable as `existence::*` (main.rs wiring, tests). +pub use crate::existence_by_id::{ + amplifier_dir_locator, codex_rollout_existence_locator, AmplifierSessionLocator, ByIdAnswer, + CodexRolloutExistenceLocator, +}; + pub struct IndexExistenceProbe { index: Arc, /// `provider:sessionId` keys ever seen in ANY snapshot this boot. @@ -107,6 +115,19 @@ pub struct IndexExistenceProbe { /// finalized. `None` (tests, callers that never set it) keeps the pure /// index answer. opencode_session_locator: Option, + /// Amplifier by-id fallback (resume-validation): the warm snapshot can be + /// STALE — a session created moments before a restart may be missing — + /// and it is `None` every boot while restore-time creates race the + /// detached sweep. When set, a warm-index Absent for provider + /// "amplifier" is re-checked by id on disk, and the COLD-index answer + /// runs the same cheap check (the incident scenario). `None` keeps the + /// pure index answer. + amplifier_session_locator: Option, + /// Codex by-id fallback (resume-validation): warm-Absent adjudication + /// ONLY — the rollout walk is ~1s on a real store, so the cold path + /// never consults it (AD-4: cold codex stays `Unknown`). `None` keeps + /// the pure index answer. + codex_rollout_locator: Option, } impl IndexExistenceProbe { @@ -122,6 +143,8 @@ impl IndexExistenceProbe { provider_roots, claude_transcript_locator: None, opencode_session_locator: None, + amplifier_session_locator: None, + codex_rollout_locator: None, } } @@ -140,6 +163,22 @@ impl IndexExistenceProbe { self } + /// Builder-style: install the by-id disk fallback for amplifier (see the + /// field doc). Chained at the single production construction site in + /// main.rs. + pub fn with_amplifier_session_locator(mut self, locator: AmplifierSessionLocator) -> Self { + self.amplifier_session_locator = Some(locator); + self + } + + /// Builder-style: install the by-id rollout fallback for codex (see the + /// field doc). Chained at the single production construction site in + /// main.rs. + pub fn with_codex_rollout_locator(mut self, locator: CodexRolloutExistenceLocator) -> Self { + self.codex_rollout_locator = Some(locator); + self + } + /// Kick a detached background refresh (never blocks the caller). No-op /// outside a tokio runtime — the WS handler always runs inside one. fn kick_refresh(&self) { @@ -200,6 +239,47 @@ impl SessionExistenceProbe for IndexExistenceProbe { { return SessionExistence::ProviderUnavailable; } + // Cold-index coverage (resume-validation, A1): the snapshot + // is None every boot and the warm sweep is a detached spawn — + // restore-time creates race it. amplifier + claude have CHEAP + // direct by-id locators; run them so the gate still fires in + // the incident scenario. codex/opencode stay Unknown when + // cold (AD-4: the codex walk is ~1 s on a real store — + // warm-Absent adjudication only). + if provider == "amplifier" { + if let Some(locator) = &self.amplifier_session_locator { + return match locator(session_id) { + ByIdAnswer::Present => { + // On-disk observation — feed the monotone + // observed-set (module-doc invariant). + self.observed + .lock() + .expect("observed set lock") + .insert(format!("{provider}:{session_id}")); + SessionExistence::Present + } + ByIdAnswer::Absent => SessionExistence::Absent, + ByIdAnswer::Unreadable => SessionExistence::Unknown, + }; + } + } + if provider == "claude" { + // Reuse the EXISTING claude transcript locator (raw-file + // check — cheap), same mapping as the warm fallback + // below: hit => Present, clean miss => Absent (the + // Option shape has no error channel). + if let Some(locator) = &self.claude_transcript_locator { + return if locator(session_id).is_some() { + self.observed + .lock() + .expect("observed set lock") + .insert(format!("{provider}:{session_id}")); + SessionExistence::Present + } else { + SessionExistence::Absent + }; + } + } SessionExistence::Unknown } Some(items) => { @@ -271,6 +351,47 @@ impl SessionExistenceProbe for IndexExistenceProbe { } } } + // Amplifier/codex by-id fallbacks (resume-validation): a + // STALE warm snapshot must never adjudicate a real session + // absent — a session created moments before a restart may be + // missing from the snapshot, and `peek()` serves snapshots + // regardless of TTL. Same adjudication point as the claude/ + // opencode fallbacks above: only reached when the snapshot + // would otherwise answer Absent. After these arms, `Absent` + // for all four validated providers is POSITIVE absence. + if provider == "amplifier" { + if let Some(locator) = &self.amplifier_session_locator { + match locator(session_id) { + ByIdAnswer::Present => { + // A fallback hit is an on-disk observation: + // feed the monotone observed-set (module-doc + // invariant), same as the arms above. + self.observed + .lock() + .expect("observed set lock") + .insert(format!("{provider}:{session_id}")); + return SessionExistence::Present; + } + ByIdAnswer::Unreadable => return SessionExistence::Unknown, + ByIdAnswer::Absent => {} + } + } + } + if provider == "codex" { + if let Some(locator) = &self.codex_rollout_locator { + match locator(session_id) { + ByIdAnswer::Present => { + self.observed + .lock() + .expect("observed set lock") + .insert(format!("{provider}:{session_id}")); + return SessionExistence::Present; + } + ByIdAnswer::Unreadable => return SessionExistence::Unknown, + ByIdAnswer::Absent => {} + } + } + } SessionExistence::Absent } } @@ -304,7 +425,7 @@ mod tests { use freshell_sessions::directory_index::{ClaudeSource, OpencodeSource, SessionSource}; use std::time::Duration; - fn temp_claude_home(tag: &str) -> std::path::PathBuf { + pub(super) fn temp_claude_home(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( "freshell-existence-{tag}-{}-{}", std::process::id(), @@ -317,7 +438,7 @@ mod tests { dir } - fn write_session(claude_home: &std::path::Path, session_id: &str) { + pub(super) fn write_session(claude_home: &std::path::Path, session_id: &str) { // Minimal claude transcript that passes the R10b cwd gate: one line // carrying `cwd` + timestamps; the file stem is the session id. let line = serde_json::json!({ @@ -336,7 +457,7 @@ mod tests { .expect("write session fixture"); } - fn probe_over(home: &std::path::Path) -> (IndexExistenceProbe, Arc) { + pub(super) fn probe_over(home: &std::path::Path) -> (IndexExistenceProbe, Arc) { let index = Arc::new(SessionIndex::with_ttl_and_cache_path( vec![Arc::new(ClaudeSource::new(home.to_path_buf())) as Arc], Duration::from_millis(50), @@ -388,7 +509,7 @@ mod tests { /// Test locator with the SAME contract as claude_snapshot::locate_transcript /// (Some(path) iff the transcript file exists), scoped to the temp home so /// tests never touch process-global CLAUDE_* env vars. - fn direct_locator_over(home: &std::path::Path) -> ClaudeTranscriptLocator { + pub(super) fn direct_locator_over(home: &std::path::Path) -> ClaudeTranscriptLocator { let projects = home.join("projects/proj"); Arc::new(move |session_id: &str| { let p = projects.join(format!("{session_id}.jsonl")); @@ -1291,3 +1412,11 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } } + +// Resume-validation (plan Task 3) tests live in a sibling file — this module +// is already large; same include pattern as +// freshell-sessions/src/amplifier_stub.rs's `scan_tests`. It reuses the +// `pub(super)` scaffolding helpers from `tests` above. +#[cfg(test)] +#[path = "existence_resume_validation_tests.rs"] +mod resume_validation_tests; diff --git a/crates/freshell-server/src/existence_by_id.rs b/crates/freshell-server/src/existence_by_id.rs new file mode 100644 index 000000000..d83253c0b --- /dev/null +++ b/crates/freshell-server/src/existence_by_id.rs @@ -0,0 +1,143 @@ +//! By-id disk locators for the resume-validation feature (plan Task 3): +//! amplifier session-dir scan + gate-safe codex rollout walk, consumed by +//! [`crate::existence::IndexExistenceProbe`]'s warm-Absent adjudication and +//! cold-index coverage. New focused file (not appended to `existence.rs`, +//! which is already large); `existence.rs` re-exports everything here so the +//! probe's public surface stays `existence::*`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// By-id disk answer for the amplifier/codex fallbacks (mirrors +/// `OpencodeDbAnswer`'s shape). `Unreadable` is LOAD-BEARING: the probe maps +/// it to `Unknown`, NEVER `Absent` — only positive absence (store readable, +/// session definitively absent) may answer `Absent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ByIdAnswer { + Present, + Absent, + Unreadable, +} + +/// Injected by-id amplifier session-dir check (kata 09v1 pattern: the probe +/// must agree with the attach arm — `amplifier resume ` finds the dir +/// regardless of index state). A closure keeps the probe unit-testable; +/// precedent: `ClaudeTranscriptLocator`/`OpencodeSessionLocator`. +pub type AmplifierSessionLocator = Arc ByIdAnswer + Send + Sync>; + +/// Injected by-id codex rollout check (same pattern). Warm-Absent +/// adjudication ONLY — the walk is ~1s on a real store, so the probe never +/// runs it on the cold path (AD-4). +pub type CodexRolloutExistenceLocator = Arc ByIdAnswer + Send + Sync>; + +/// Production amplifier locator: the read-only all-slugs scan of Task 2 +/// (`freshell_sessions::amplifier_stub::session_on_disk` — errors-seen +/// accumulator, never `.is_dir()`-adjudicated). +pub fn amplifier_dir_locator(amplifier_home: PathBuf) -> AmplifierSessionLocator { + Arc::new(move |session_id: &str| { + match freshell_sessions::amplifier_stub::session_on_disk(&lifier_home, session_id) { + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Present => { + ByIdAnswer::Present + } + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Absent => ByIdAnswer::Absent, + freshell_sessions::amplifier_stub::AmplifierSessionAnswer::Unreadable => { + ByIdAnswer::Unreadable + } + } + }) +} + +/// Gate-safe tri-state codex rollout walk (resume-validation feature). +/// Deliberately a NEW walk, NOT a reuse of the fail-soft +/// `freshell_ws::codex_reconcile::locate_codex_rollout` — that helper +/// silently converts per-entry IO errors into `None`, which the gate would +/// read as positive absence (six false-Absent reproductions in V3). +/// Errors-seen accumulator: `Present` short-circuits; a walk that completes +/// having seen ANY per-entry error and no hit answers `Unreadable`. +pub fn codex_rollout_on_disk(sessions_root: &Path, session_id: &str) -> ByIdAnswer { + // Root readability is established by read_dir itself: NotFound (parent + // readable) => Absent (AD-1); any other error => Unreadable. NOTE: + // `fs::metadata(root)` is NOT sufficient — it tests existence, not + // readability (a mode-111 root passes metadata but fails read_dir; V3 E3). + let mut stack = vec![sessions_root.to_path_buf()]; + let mut saw_error = false; + let mut first_level = true; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) if first_level && err.kind() == std::io::ErrorKind::NotFound => { + return ByIdAnswer::Absent; + } + Err(_) if first_level => return ByIdAnswer::Unreadable, + Err(_) => { + saw_error = true; // unreadable subtree below the root — may hide the rollout + continue; + } + }; + first_level = false; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + saw_error = true; // dropped dir entry (EIO, network fs) — cannot rule out + continue; + } + }; + let path = entry.path(); + // Never `.is_dir()` — false on EACCES (V3 E6). + match std::fs::metadata(&path) { + Ok(meta) if meta.is_dir() => stack.push(path), + Ok(_) => { + let name = entry.file_name(); + let name = name.to_string_lossy(); + // Filename prefilter: id-in-name (verified convention, + // V2: 4459/4459 real rollouts + codex source constructs + // the name from the id) — accept both `.jsonl` and + // `.jsonl.zst` (future codex rollout compression, V2). + if name.contains(session_id) + && (name.ends_with(".jsonl") || name.ends_with(".jsonl.zst")) + { + match first_line_owns_tri(&path, session_id) { + Ok(true) => return ByIdAnswer::Present, + Ok(false) => {} // valid read, id differs — keep walking + Err(()) => saw_error = true, + } + } + } + Err(_) => saw_error = true, + } + } + } + if saw_error { + ByIdAnswer::Unreadable + } else { + ByIdAnswer::Absent + } +} + +/// Tri-state first-line ownership proof: `Ok(true)` iff the first line is a +/// `session_meta` whose `payload.id` equals `session_id` (mirror of +/// `codex_reconcile.rs`'s `first_line_owns`), `Ok(false)` for a VALID read +/// whose id differs (a genuine non-owner), `Err(())` for any open/read/ +/// decode failure on a candidate (incl. an undecodable `.jsonl.zst`) — an +/// error must count toward `saw_error`, never as "not the owner". +fn first_line_owns_tri(path: &Path, session_id: &str) -> Result { + use std::io::{BufRead, Read}; + let file = std::fs::File::open(path).map_err(|_| ())?; + let mut reader = std::io::BufReader::new(file).take(1024 * 1024); + let mut first = String::new(); + reader.read_line(&mut first).map_err(|_| ())?; + let value = serde_json::from_str::(first.trim()).map_err(|_| ())?; + Ok(value.get("type").and_then(|t| t.as_str()) == Some("session_meta") + && value + .get("payload") + .and_then(|p| p.get("id")) + .and_then(|i| i.as_str()) + == Some(session_id)) +} + +/// Production codex locator: wraps [`codex_rollout_on_disk`] over the same +/// sessions root the resume-time locator walks. +pub fn codex_rollout_existence_locator(sessions_root: PathBuf) -> CodexRolloutExistenceLocator { + Arc::new(move |session_id: &str| codex_rollout_on_disk(&sessions_root, session_id)) +} diff --git a/crates/freshell-server/src/existence_resume_validation_tests.rs b/crates/freshell-server/src/existence_resume_validation_tests.rs new file mode 100644 index 000000000..077145fb0 --- /dev/null +++ b/crates/freshell-server/src/existence_resume_validation_tests.rs @@ -0,0 +1,413 @@ +//! Resume-validation feature tests (plan Task 3): amplifier + codex by-id +//! fallbacks on the warm-Absent adjudication path, and cold-index coverage +//! for the cheap amplifier/claude locators. Included as a sibling module of +//! `existence.rs`'s `tests` (file-include pattern precedent: +//! `freshell-sessions/src/amplifier_stub_scan_tests.rs`), reusing that +//! module's `pub(super)` scaffolding helpers. +use super::tests::{direct_locator_over, probe_over, temp_claude_home, write_session}; +use super::*; +use crate::existence_by_id::codex_rollout_on_disk; + +use freshell_sessions::directory_index::{ClaudeSource, SessionSource}; +use std::sync::atomic::{AtomicU64, Ordering as TestOrdering}; +use std::time::Duration; + +static RV_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn rv_temp_dir(tag: &str) -> std::path::PathBuf { + let n = RV_COUNTER.fetch_add(1, TestOrdering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-existence-rv-{tag}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("mkdir rv temp dir"); + dir +} + +/// Amplifier home with `projects/-p/sessions//` created for each id. +fn amp_home_with_sessions(tag: &str, ids: &[&str]) -> std::path::PathBuf { + let home = rv_temp_dir(tag); + for id in ids { + std::fs::create_dir_all(home.join("projects/-p/sessions").join(id)) + .expect("mkdir amplifier session dir"); + } + home +} + +/// Write a codex rollout owned by `session_id` under the dated tree +/// `/2026/07/29/rollout-2026-07-29T10-00-00-.jsonl` (first line = +/// `session_meta` whose `payload.id` proves ownership). Returns the file path. +fn write_codex_rollout(root: &std::path::Path, session_id: &str) -> std::path::PathBuf { + let dated = root.join("2026/07/29"); + std::fs::create_dir_all(&dated).expect("mkdir codex dated dir"); + let path = dated.join(format!("rollout-2026-07-29T10-00-00-{session_id}.jsonl")); + std::fs::write( + &path, + format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{session_id}\"}}}}\n"), + ) + .expect("write codex rollout"); + path +} + +/// Permission tests are meaningless as root (root ignores mode bits) — +/// e.g. sandboxed/container suites. Skip (return early) when euid == 0. +#[cfg(unix)] +fn running_as_root() -> bool { + std::process::Command::new("id") + .arg("-u") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") + .unwrap_or(false) +} + +/// Probe over a claude-source index at `index_home` (like `probe_over`) but +/// with caller-chosen provider roots — the cold-index tests need per-provider +/// roots that exist so the ProviderUnavailable pre-check does not fire. +fn probe_with_roots( + index_home: &std::path::Path, + roots: HashMap, +) -> (IndexExistenceProbe, Arc) { + let index = Arc::new(SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(ClaudeSource::new(index_home.to_path_buf())) as Arc], + Duration::from_millis(50), + None, + )); + ( + IndexExistenceProbe::new(Arc::clone(&index), None, roots), + index, + ) +} + +/// Warm snapshot says Absent, but the session dir exists on disk (created +/// after the snapshot). By-id fallback must answer Present. +#[tokio::test] +async fn amplifier_absent_snapshot_rescued_by_dir_locator() { + let home = temp_claude_home("amp-rescue"); + let amp_home = amp_home_with_sessions("amp-rescue-home", &["amp-new"]); + let (probe, index) = probe_over(&home); + let probe = probe.with_amplifier_session_locator(amplifier_dir_locator(amp_home.clone())); + index.warm().await; + assert_eq!( + probe.exists("amplifier", "amp-new"), + SessionExistence::Present, + "the session dir exists on disk — a stale warm snapshot must never \ + adjudicate a real session absent" + ); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&_home); +} + +/// Warm snapshot Absent + locator scans a readable store without the id. +#[tokio::test] +async fn amplifier_definitively_absent_stays_absent() { + let home = temp_claude_home("amp-absent"); + let amp_home = amp_home_with_sessions("amp-absent-home", &["amp-other"]); + let (probe, index) = probe_over(&home); + let probe = probe.with_amplifier_session_locator(amplifier_dir_locator(amp_home.clone())); + index.warm().await; + assert_eq!( + probe.exists("amplifier", "amp-gone"), + SessionExistence::Absent, + "store readable, session definitively absent => positive Absent" + ); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&_home); +} + +/// Locator returns Unreadable (e.g. permissions) => honest Unknown, never +/// a false Absent. +#[tokio::test] +async fn amplifier_unreadable_store_answers_unknown() { + let home = temp_claude_home("amp-unreadable"); + let (probe, index) = probe_over(&home); + let probe = probe.with_amplifier_session_locator(Arc::new(|_sid: &str| ByIdAnswer::Unreadable)); + index.warm().await; + assert_eq!( + probe.exists("amplifier", "amp-x"), + SessionExistence::Unknown, + "read failure is honest ignorance — fail open, never Absent" + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// No locator chained (default) => today's behavior byte-for-byte. +#[tokio::test] +async fn amplifier_without_locator_keeps_snapshot_answer() { + let home = temp_claude_home("amp-nolocator"); + let (probe, index) = probe_over(&home); + index.warm().await; + assert_eq!( + probe.exists("amplifier", "amp-anything"), + SessionExistence::Absent, + "without a locator the pure warm-index answer stands" + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// Same shape as the amplifier rescue test, with a temp codex sessions root +/// containing `sessions/2026/07/29/rollout-...-.jsonl`. +#[tokio::test] +async fn codex_absent_snapshot_rescued_by_rollout_locator() { + let home = temp_claude_home("codex-rescue"); + let root = rv_temp_dir("codex-rescue-root"); + let session_id = "c0dec0de-1111-4222-8333-444455556666"; + write_codex_rollout(&root, session_id); + let (probe, index) = probe_over(&home); + let probe = probe.with_codex_rollout_locator(codex_rollout_existence_locator(root.clone())); + index.warm().await; + assert_eq!( + probe.exists("codex", session_id), + SessionExistence::Present, + "the rollout exists on disk (the resume arm would find it) — a stale \ + warm snapshot must never adjudicate it absent" + ); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&root); +} + +/// sessions root NotFound => Absent (AD-1: fresh install, parent readable); +/// locator returning Unreadable => probe answers Unknown. Root readability +/// is established via read_dir inside `codex_rollout_on_disk`, not +/// fs::metadata — metadata tests existence, not readability (V3 E3). +#[tokio::test] +async fn codex_missing_sessions_root_is_absent_and_unreadable_is_unknown() { + let home = temp_claude_home("codex-missing-root"); + let parent = rv_temp_dir("codex-missing-root-parent"); + let missing_root = parent.join("sessions"); // never created + let (probe, index) = probe_over(&home); + let probe = + probe.with_codex_rollout_locator(codex_rollout_existence_locator(missing_root.clone())); + index.warm().await; + assert_eq!( + probe.exists("codex", "thread-1"), + SessionExistence::Absent, + "missing sessions root with readable parent is positive absence (AD-1)" + ); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&parent); + + let home2 = temp_claude_home("codex-unreadable"); + let (probe2, index2) = probe_over(&home2); + let probe2 = probe2.with_codex_rollout_locator(Arc::new(|_sid: &str| ByIdAnswer::Unreadable)); + index2.warm().await; + assert_eq!( + probe2.exists("codex", "thread-2"), + SessionExistence::Unknown, + "an unreadable store is honest Unknown — fail open, never Absent" + ); + let _ = std::fs::remove_dir_all(&home2); +} + +/// Future codex rollout compression (V2): a `rollout-...-.jsonl.zst` +/// candidate MUST pass the filename prefilter; its first line is not plain +/// JSONL, so the ownership read fails => counts as an error => Unreadable +/// (the probe answers Unknown — fail open, never Absent for a +/// CLI-resumable file). +#[test] +fn codex_zst_rollout_is_candidate_but_undecodable_answers_unreadable() { + let root = rv_temp_dir("codex-zst"); + let session_id = "c0dec0de-2222-4222-8333-444455556666"; + let dated = root.join("2026/07/29"); + std::fs::create_dir_all(&dated).expect("mkdir codex dated dir"); + // zstd magic bytes + arbitrary binary — not decodable as JSONL. + let mut bytes = vec![0x28u8, 0xB5, 0x2F, 0xFD]; + bytes.extend_from_slice(&[0x00, 0xFF, 0x13, 0x37]); + std::fs::write( + dated.join(format!("rollout-2026-07-29T10-00-00-{session_id}.jsonl.zst")), + bytes, + ) + .expect("write zst candidate"); + assert_eq!( + codex_rollout_on_disk(&root, session_id), + ByIdAnswer::Unreadable, + "an undecodable candidate is an ERROR, never 'not the owner'" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// V3 case E4: the rollout lives under `sessions/2026/07/29/` chmod 000 — +/// the per-entry read_dir error must be accumulated (no silent skip). +#[cfg(unix)] +#[test] +fn codex_unreadable_date_subdir_answers_unreadable() { + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; // root ignores mode bits — test is meaningless + } + let root = rv_temp_dir("codex-locked-subdir"); + let session_id = "c0dec0de-3333-4222-8333-444455556666"; + write_codex_rollout(&root, session_id); + let locked = root.join("2026/07/29"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = codex_rollout_on_disk(&root, session_id); + // Restore perms before asserting so cleanup works even on failure. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + answer, + ByIdAnswer::Unreadable, + "an unreadable subtree may hide the rollout — never Absent" + ); + + // And through the probe: warm-Absent + Unreadable walk => Unknown. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let home = temp_claude_home("codex-locked-probe"); + let (probe, index) = probe_over(&home); + let probe = probe.with_codex_rollout_locator(codex_rollout_existence_locator(root.clone())); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(index.warm()); + let probe_answer = probe.exists("codex", session_id); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(probe_answer, SessionExistence::Unknown); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&root); +} + +/// V3 case E7: the owning rollout file itself is chmod 000 — the first-line +/// ownership read fails => error => Unreadable, never Absent. +#[cfg(unix)] +#[test] +fn codex_unreadable_candidate_file_answers_unreadable() { + use std::os::unix::fs::PermissionsExt; + if running_as_root() { + return; // root ignores mode bits — test is meaningless + } + let root = rv_temp_dir("codex-locked-file"); + let session_id = "c0dec0de-4444-4222-8333-444455556666"; + let file = write_codex_rollout(&root, session_id); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap(); + let answer = codex_rollout_on_disk(&root, session_id); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!( + answer, + ByIdAnswer::Unreadable, + "an unreadable candidate file is an ERROR, never 'not the owner'" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// Cold index (peek() == None, the every-boot state) + amplifier root +/// present + locator chained: the cheap by-id locator answers instead of +/// Unknown. This is the incident scenario: a restore-time create racing the +/// detached boot sweep. Plain #[test] (no tokio runtime) so kick_refresh +/// no-ops and the index deterministically stays cold. +#[test] +fn cold_index_amplifier_uses_dir_locator() { + // (a) session dir on disk => Present. + let index_home = temp_claude_home("cold-amp-present"); + let amp_home = amp_home_with_sessions("cold-amp-present-home", &["amp-cold"]); + let (probe, _index) = probe_with_roots( + &index_home, + HashMap::from([("amplifier".to_string(), amp_home.clone())]), + ); + let probe = probe.with_amplifier_session_locator(amplifier_dir_locator(amp_home.clone())); + assert_eq!( + probe.exists("amplifier", "amp-cold"), + SessionExistence::Present, + "cold index + dir on disk => Present (the incident scenario)" + ); + let _ = std::fs::remove_dir_all(&index_home); + let _ = std::fs::remove_dir_all(&_home); + + // (b) readable-empty store => Absent. + let index_home = temp_claude_home("cold-amp-absent"); + let amp_home = rv_temp_dir("cold-amp-absent-home"); + let (probe, _index) = probe_with_roots( + &index_home, + HashMap::from([("amplifier".to_string(), amp_home.clone())]), + ); + let probe = probe.with_amplifier_session_locator(amplifier_dir_locator(amp_home.clone())); + assert_eq!( + probe.exists("amplifier", "amp-cold"), + SessionExistence::Absent, + "cold index + readable-empty store => positive Absent" + ); + let _ = std::fs::remove_dir_all(&index_home); + let _ = std::fs::remove_dir_all(&_home); + + // (c) locator Unreadable => Unknown. + let index_home = temp_claude_home("cold-amp-unreadable"); + let amp_home = rv_temp_dir("cold-amp-unreadable-home"); + let (probe, _index) = probe_with_roots( + &index_home, + HashMap::from([("amplifier".to_string(), amp_home.clone())]), + ); + let probe = probe.with_amplifier_session_locator(Arc::new(|_sid: &str| ByIdAnswer::Unreadable)); + assert_eq!( + probe.exists("amplifier", "amp-cold"), + SessionExistence::Unknown + ); + let _ = std::fs::remove_dir_all(&index_home); + let _ = std::fs::remove_dir_all(&_home); +} + +/// Same shape via the EXISTING claude transcript locator: cold index + +/// transcript file on disk => Present; readable store without it => Absent +/// (the gate still Proceeds unless ever_observed_on_disk — Task 1). +#[test] +fn cold_index_claude_uses_transcript_locator() { + let home = temp_claude_home("cold-claude"); + let session_id = "4d5e6f70-8192-4aa3-8b4c-5d6e7f809101"; + write_session(&home, session_id); + let (probe, _index) = probe_over(&home); + let probe = probe.with_claude_transcript_locator(direct_locator_over(&home)); + assert_eq!( + probe.exists("claude", session_id), + SessionExistence::Present, + "cold index + transcript on disk => Present via the cheap raw-file check" + ); + assert_eq!( + probe.exists("claude", "5e6f7081-92a3-4bb4-8c5d-6e7f80910212"), + SessionExistence::Absent, + "cold index + readable store without the transcript => positive Absent" + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// Cold index + locators chained: codex/opencode must NOT run their by-id +/// lookups when cold (AD-4 — the codex walk is ~1 s on a real store). +/// The root-missing => ProviderUnavailable pre-check stays byte-for-byte +/// today's behavior. +#[test] +fn cold_index_codex_and_opencode_answer_unknown() { + let index_home = temp_claude_home("cold-codex-opencode"); + let codex_root = rv_temp_dir("cold-codex-root"); + let opencode_root = rv_temp_dir("cold-opencode-root"); + let (probe, _index) = probe_with_roots( + &index_home, + HashMap::from([ + ("codex".to_string(), codex_root.clone()), + ("opencode".to_string(), opencode_root.clone()), + ]), + ); + // Both locators would answer Present if (wrongly) consulted. + let probe = probe + .with_codex_rollout_locator(Arc::new(|_sid: &str| ByIdAnswer::Present)) + .with_opencode_session_locator(Arc::new(|_sid: &str| OpencodeDbAnswer::Present)); + assert_eq!( + probe.exists("codex", "thread-cold"), + SessionExistence::Unknown, + "cold codex stays Unknown — the ~1s walk must not run on early-boot creates (AD-4)" + ); + assert_eq!( + probe.exists("opencode", "ses_cold00000000000000000000000"), + SessionExistence::Unknown, + "cold opencode stays Unknown (AD-4)" + ); + + // Root-missing pre-check untouched: even with a locator chained, a + // missing provider root still answers ProviderUnavailable. + let gone = codex_root.join("never-created"); + let (probe2, _index2) = + probe_with_roots(&index_home, HashMap::from([("codex".to_string(), gone)])); + let probe2 = probe2.with_codex_rollout_locator(Arc::new(|_sid: &str| ByIdAnswer::Present)); + assert_eq!( + probe2.exists("codex", "thread-cold"), + SessionExistence::ProviderUnavailable + ); + let _ = std::fs::remove_dir_all(&index_home); + let _ = std::fs::remove_dir_all(&codex_root); + let _ = std::fs::remove_dir_all(&opencode_root); +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 5eb04b37e..8ce9ca129 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -20,6 +20,7 @@ mod boot; mod checkpoints; mod diag; mod existence; +mod existence_by_id; mod extensions; mod files; mod identity_sink; @@ -583,8 +584,8 @@ async fn main() -> ExitCode { // provider home resolves — mirrors `session_index`'s own `Option` // convention. session_existence: match &session_index { - Some(index) => std::sync::Arc::new( - existence::IndexExistenceProbe::new( + Some(index) => { + let probe = existence::IndexExistenceProbe::new( std::sync::Arc::clone(index), // P1.8 read 2: the durable ledger backs `ever_observed`, so a // transcript deleted while the server was DOWN still derives @@ -630,8 +631,34 @@ async fn main() -> ExitCode { // (bounded deferral), never a false dead_session. .with_opencode_session_locator(existence::opencode_db_locator( freshell_sessions::parse::default_opencode_data_home(), - )), - ), + )); + // Amplifier by-id fallback (resume-validation): the SAME + // all-slugs disk scan the stub writer/attach arm trusts + // (amplifier_stub::session_on_disk), over the SAME home the + // stub writer resolves. Covers both a stale warm snapshot + // AND the cold index at boot (restore-time creates race the + // detached sweep). No resolvable home => probe behaves as + // today for amplifier. + let probe = match freshell_sessions::amplifier_stub::resolve_amplifier_home() { + Some(amplifier_home) => probe.with_amplifier_session_locator( + existence::amplifier_dir_locator(amplifier_home), + ), + None => probe, + }; + // Codex by-id fallback (resume-validation): the gate-safe + // tri-state rollout walk over the SAME sessions root the + // ActivityHub's resume-time locator (above) walks — + // warm-Absent adjudication only (AD-4: ~1s on a real + // store, never on the cold path). No resolvable root => + // probe behaves as today for codex. + let probe = match freshell_ws::codex_sessions_root() { + Some(codex_sessions_root) => probe.with_codex_rollout_locator( + existence::codex_rollout_existence_locator(codex_sessions_root), + ), + None => probe, + }; + std::sync::Arc::new(probe) + } None => std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), }, // §5.3 row 5: the ONE bounded index-warming deferral's budget From f8d6ce4770f33bce2b1e67bc82d0123eadc2166e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:12:12 -0700 Subject: [PATCH 10/35] feat(resume-validation): RetiredReason::SessionMissing + retire_missing --- crates/freshell-ws/src/pane_ledger.rs | 26 +++++++ crates/freshell-ws/src/pane_ledger_tests.rs | 79 +++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index b47191558..48b7c1ec3 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -85,6 +85,7 @@ pub enum RetiredReason { Superseded, Closed, GcExpired, + SessionMissing, } /// A durable identity fact — see the module doc for the schema contract. @@ -586,6 +587,31 @@ impl PaneLedger { self.write_binding(root, &mut index, &row) } + /// Retire a Bound row as SessionMissing (session file not found on disk). + /// Returns true iff a Bound row was successfully retired; false if the row + /// does not exist or is already retired (idempotent). + pub fn retire_missing(&self, provider: &str, session_id: &str) -> bool { + let Some(root) = &self.root else { + return false; + }; + let now_ms = crate::terminal::now_ms(); + let mut index = self.guard(); + let Some(mut row) = index + .bindings + .get(&(provider.to_string(), session_id.to_string())) + .cloned() + else { + return false; + }; + if row.state != RowState::Bound { + return false; + } + row.state = RowState::Retired; + row.retired_reason = Some(RetiredReason::SessionMissing); + row.updated_at = now_ms; + self.write_binding(root, &mut index, &row).is_ok() + } + /// 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 diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 6f2330eb0..6b68666e7 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -1022,3 +1022,82 @@ fn rebind_to_the_same_identity_is_not_a_supersession() { assert_eq!(row.retired_reason, None); std::fs::remove_dir_all(&root).ok(); } + +#[test] +fn retire_missing_marks_bound_row_session_missing() { + // Setup: a ledger with a Bound binding for ("amplifier", "stale-sid") + let root = temp_root("retire-missing"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("amplifier", "stale-sid", "t1", 1_000)) + .unwrap(); + + // Act: retire the binding as missing + let retired = ledger.retire_missing("amplifier", "stale-sid"); + + // Assert: retirement succeeded + assert!(retired); + let row = ledger.load_binding("amplifier", "stale-sid").unwrap(); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::SessionMissing)); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn retire_missing_is_noop_without_binding() { + // Fresh ledger, no rows. + let root = temp_root("retire-missing-noop"); + let ledger = PaneLedger::new(Some(root.clone())); + + // Act: try to retire a non-existent binding + let retired = ledger.retire_missing("amplifier", "never-seen"); + + // Assert: no-op returns false + assert!(!retired); + // Verify no row was created + assert_eq!(ledger.load_binding("amplifier", "never-seen"), None); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn retire_missing_does_not_reretire() { + // Bound row retired once => true; second call => false; reason stays + // SessionMissing; updated_at from the first retire is not clobbered + // by the failed second call. + let root = temp_root("retire-missing-reretire"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("amplifier", "sid", "t1", 1_000)) + .unwrap(); + + // First retire should succeed + let first = ledger.retire_missing("amplifier", "sid"); + assert!(first); + let row_after_first = ledger.load_binding("amplifier", "sid").unwrap(); + let first_updated_at = row_after_first.updated_at; + assert_eq!(row_after_first.state, RowState::Retired); + assert_eq!(row_after_first.retired_reason, Some(RetiredReason::SessionMissing)); + + // Second retire should fail (already retired) + let second = ledger.retire_missing("amplifier", "sid"); + assert!(!second); + let row_after_second = ledger.load_binding("amplifier", "sid").unwrap(); + assert_eq!(row_after_second.state, RowState::Retired); + assert_eq!(row_after_second.retired_reason, Some(RetiredReason::SessionMissing)); + // Verify updated_at was not clobbered + assert_eq!(row_after_second.updated_at, first_updated_at); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn session_missing_serde_round_trips() { + // Test serialization + let reason = RetiredReason::SessionMissing; + let json = serde_json::to_string(&reason).expect("serialize"); + assert_eq!(json, r#""session_missing""#); + + // Test deserialization + let deserialized: RetiredReason = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(deserialized, RetiredReason::SessionMissing); +} From 31ea092623578a44cd9bc5ac41a45f685f6a9c5f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:08 -0700 Subject: [PATCH 11/35] feat(resume-validation): optional notice on terminal.created, rendered in the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive optional field mirrored Rust protocol -> shared/ws-protocol.ts -> regenerated contract schema (57-entry inventory unchanged, Node never sets it). Client writes the notice into the pane's xterm like the reconcile notice and clears the persisted sessionRef/resumeSessionId so codex/opencode gate-fired spawns (no sessionRef on the frame) don't re-fire the gate on every restart; a frame that also carries a sessionRef keeps the existing overwrite fold. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../freshell-protocol/src/server_messages.rs | 5 + crates/freshell-protocol/tests/roundtrip.rs | 53 ++++++ crates/freshell-ws/src/terminal.rs | 3 + port/contract/ws-server-messages.schema.json | 3 + shared/ws-protocol.ts | 2 + src/components/TerminalView.tsx | 15 ++ .../TerminalView.lifecycle.test.tsx | 153 ++++++++++++++++++ 7 files changed, 234 insertions(+) diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index f1b50ae75..758f279e1 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -967,6 +967,11 @@ pub struct TerminalCreated { pub clear_codex_durability: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, + /// Resume-validation feature: operator-visible notice set when the server + /// dropped a stale resume id and spawned fresh. The client writes it into + /// the pane's xterm. Optional/additive — Node never sets it. + #[serde(skip_serializing_if = "Option::is_none")] + pub notice: Option, #[serde(skip_serializing_if = "Option::is_none")] pub restore_error: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index 004b34d9c..2188393b6 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -522,3 +522,56 @@ fn terminal_auto_resume_cancel_roundtrips() { } assert_eq!(serde_json::to_value(&msg).unwrap(), json); } + +#[test] +fn terminal_created_notice_is_optional_and_additive() { + // Absent => key omitted (wire-compatible with the frozen client). + let created = TerminalCreated { + created_at: 1_700_000_000_000, + request_id: "req-1".into(), + terminal_id: "t1".into(), + clear_codex_durability: None, + cwd: None, + restore_error: None, + session_ref: None, + notice: None, + }; + let json = serde_json::to_value(ServerMessage::TerminalCreated(created.clone())).unwrap(); + assert!(json.get("notice").is_none()); + + // Present => serialized verbatim. + let mut with_notice = created; + with_notice.notice = Some( + "Saved amplifier session X could not be found on disk — started a fresh session instead." + .to_string(), + ); + let json = serde_json::to_value(ServerMessage::TerminalCreated(with_notice)).unwrap(); + assert_eq!( + json["notice"], + "Saved amplifier session X could not be found on disk — started a fresh session instead." + ); +} + +#[test] +fn terminal_created_roundtrips_with_and_without_notice() { + // Base shape: notice omitted — byte-identical to today's frame on the wire. + let base = r#"{"type":"terminal.created","createdAt":1700000000000,"requestId":"req-1","terminalId":"t1"}"#; + match server_roundtrip(base, "terminal.created") { + ServerMessage::TerminalCreated(created) => { + assert_eq!(created.notice, None); + } + other => panic!("expected TerminalCreated, got {other:?}"), + } + // Resume-validation shape: the fresh-spawn notice must conform to the + // frozen contract too. + let with_notice = r#"{"type":"terminal.created","createdAt":1700000000000,"requestId":"req-1","terminalId":"t1","notice":"Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 could not be found on disk — started a fresh session instead."}"#; + match server_roundtrip(with_notice, "terminal.created") { + ServerMessage::TerminalCreated(created) => { + assert_eq!( + created.notice.as_deref(), + Some("Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 could not be found on disk — started a fresh session instead.") + ); + } + other => panic!("expected TerminalCreated, got {other:?}"), + } +} diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 87c3b825c..05826a128 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1704,6 +1704,7 @@ pub(crate) async fn handle_create( terminal_id: existing.clone(), clear_codex_durability: None, cwd: state.registry.probe(&existing).and_then(|row| row.cwd), + notice: None, restore_error: None, session_ref: state.identity.session_ref_for(&existing), }); @@ -1768,6 +1769,7 @@ pub(crate) async fn handle_create( terminal_id: terminal_id.clone(), clear_codex_durability: None, cwd: state.registry.probe(&terminal_id).and_then(|row| row.cwd), + notice: None, restore_error: None, session_ref: state .identity @@ -2831,6 +2833,7 @@ pub(crate) async fn handle_create( clear_codex_durability: None, // Echo the resolved cwd (`record.cwd`) when the shell spec carries one. cwd: spec.cwd.clone(), + notice: None, restore_error: None, // The canonical create-time identity, from the SAME registry every other // identity-stamped frame reads (shell creates have no entry -> `None`). diff --git a/port/contract/ws-server-messages.schema.json b/port/contract/ws-server-messages.schema.json index d69456d3d..f30088fa0 100644 --- a/port/contract/ws-server-messages.schema.json +++ b/port/contract/ws-server-messages.schema.json @@ -2214,6 +2214,9 @@ "cwd": { "type": "string" }, + "notice": { + "type": "string" + }, "requestId": { "type": "string" }, diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 86072889c..71a31fad2 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -738,6 +738,8 @@ export type TerminalCreatedMessage = { sessionRef?: SessionLocator clearCodexDurability?: boolean restoreError?: RestoreError + /** Resume-validation: operator-visible notice set when the server dropped a stale resume id and spawned fresh. The client writes it into the pane's xterm. Additive; Node never sets it. */ + notice?: string } export type TerminalAttachReadyMessage = { diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index e3298c858..100e55627 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -4260,6 +4260,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const createdSessionRef = (msg as { sessionRef?: TerminalPaneContent['sessionRef'] }).sessionRef const createdCwd = typeof msg.cwd === 'string' && msg.cwd.trim() ? msg.cwd : undefined const createdSessionUpdates = buildSessionAssociationContentUpdates(contentRef.current, createdSessionRef) + // Resume-validation: a notice means the server dropped a stale + // resume id and spawned fresh (never built on terminal.status.reason). + const createdNotice = typeof msg.notice === 'string' && msg.notice ? msg.notice : undefined terminalIdRef.current = newId // Ledger A2: remember the acquired tid so the terminal.input.blocked // backstop still matches after a recovery path clears terminalIdRef. @@ -4270,6 +4273,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) streamId: undefined, status: 'running', ...(createdCwd && !contentRef.current?.initialCwd ? { initialCwd: createdCwd } : {}), + // Resume-validation: the notice names a stale resume id the server + // dropped — clear the persisted refs so codex/opencode gate-fired + // spawns (whose created frame carries NO sessionRef) don't re-fire + // the gate on every restart. When the frame also carries a + // sessionRef, the association fold below wins. + ...(createdNotice ? { sessionRef: undefined, resumeSessionId: undefined } : {}), ...(createdSessionUpdates ?? {}), ...(msg.clearCodexDurability ? { codexDurability: undefined } : {}), ...(msg.restoreError ? { restoreError: msg.restoreError } : {}), @@ -4330,6 +4339,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } else { attachTerminal(newId, 'viewport_hydrate', { clearViewportFirst: true }) } + // Resume-validation notice: the server dropped a stale resume id and + // spawned fresh — surface it in the pane, same write mechanism as + // the reconcile notice below. + if (createdNotice) { + writeLocalXtermNotice(term, `\r\n${createdNotice}\r\n`) + } // One-shot reconcile notice (respawn/fresh verdicts): render it now // that the pane anchored, then clear it from the store. const createdReconcileNotice = contentRef.current?.reconcileNotice diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 2d9010e6b..bc75d4d0e 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -2676,6 +2676,159 @@ describe('TerminalView lifecycle updates', () => { expectTerminalWriteContaining(term, 'execvp(3) failed.: No such file or directory') }) + it('writes the resume-validation notice into the terminal on terminal.created', async () => { + const tabId = 'tab-resume-validation-notice' + const paneId = 'pane-resume-validation-notice' + const notice = 'Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 could not be found on disk — started a fresh session instead.' + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-resume-validation-notice', + status: 'creating', + mode: 'shell', + shell: 'system', + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode: 'shell', + status: 'creating', + title: 'Shell', + titleSetByUser: false, + createRequestId: 'req-resume-validation-notice', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: createSettingsState(), + connection: { status: 'connected', error: null }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(messageHandler).not.toBeNull() + }) + + const term = terminalInstances[0] + + act(() => { + messageHandler!({ + type: 'terminal.created', + requestId: 'req-resume-validation-notice', + terminalId: 'term-resume-validation-notice', + createdAt: Date.now(), + notice, + }) + }) + + await waitFor(() => { + expectTerminalWriteContaining(term, 'Saved amplifier session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1') + }) + }) + + it('clears the persisted sessionRef/resumeSessionId when terminal.created carries a notice', async () => { + const tabId = 'tab-resume-validation-clear' + const paneId = 'pane-resume-validation-clear' + const staleSessionRef = { + provider: 'codex', + sessionId: '8dab420a-f76b-407c-bcbe-dfb2a971c2e1', + } + + // codex/opencode shape: the created frame carries a notice but NO + // sessionRef — the gate's fallback for these modes is None, so no + // sessionRef overwrite will heal the pane. + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: 'req-resume-validation-clear', + status: 'creating', + mode: 'codex', + shell: 'system', + sessionRef: staleSessionRef, + resumeSessionId: staleSessionRef.sessionId, + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode: 'codex', + status: 'creating', + title: 'Codex', + titleSetByUser: false, + createRequestId: 'req-resume-validation-clear', + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: createSettingsState(), + connection: { status: 'connected', error: null }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(messageHandler).not.toBeNull() + }) + + act(() => { + messageHandler!({ + type: 'terminal.created', + requestId: 'req-resume-validation-clear', + terminalId: 'term-resume-validation-clear', + createdAt: Date.now(), + notice: 'Saved codex session 8dab420a-f76b-407c-bcbe-dfb2a971c2e1 could not be found on disk — started a fresh session instead.', + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts[tabId] + if (layout?.type !== 'leaf') throw new Error('unexpected layout') + if (layout.content.kind !== 'terminal') throw new Error('unexpected content') + expect(layout.content.terminalId).toBe('term-resume-validation-clear') + expect(layout.content.sessionRef).toBeUndefined() + expect(layout.content.resumeSessionId).toBeUndefined() + }) + }) + it('settles a clean restored attach startup exit as exited', async () => { const tabId = 'tab-clean-restore-attach-exit' const paneId = 'pane-clean-restore-attach-exit' From 67367abb95f2e983e8d95b60fe58a2a643e61d98 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:07:47 -0700 Subject: [PATCH 12/35] feat(resume-validation): gate WS terminal.create restore path on disk existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Door 1: handle_create consults the disk-existence probe (via the pure resume_gate policy) before turning a WIRE-derived resume id into resume argv. Positive absence => provider-shaped fresh-spawn fallback (claude: fresh UUID + Start + prealloc; amplifier: fresh UUID keep Resume; codex/opencode: drop resume), operator notice on terminal.created, and retire_missing on the pane ledger. Unknown/ProviderUnavailable and live sessions (registry + async fresh-agent sidecar arms) always fail open. Placed AFTER the D7 cross-mode liveness guard and BEFORE the amplifier ensure_session re-stub; the sync helper runs in spawn_blocking. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-ws/src/lib.rs | 1 + crates/freshell-ws/src/resume_validation.rs | 258 ++++++++ crates/freshell-ws/src/terminal.rs | 113 +++- .../tests/resume_validation_gate.rs | 571 ++++++++++++++++++ 4 files changed, 940 insertions(+), 3 deletions(-) create mode 100644 crates/freshell-ws/src/resume_validation.rs create mode 100644 crates/freshell-ws/tests/resume_validation_gate.rs diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index e95ae0bf6..60a3acd2c 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -41,6 +41,7 @@ pub mod pane_identity_binder; pub mod pane_ledger; pub mod reconcile; pub mod reconcile_freshagent; +pub mod resume_validation; pub mod screenshot; pub mod spawn_gate; pub mod tabs; diff --git a/crates/freshell-ws/src/resume_validation.rs b/crates/freshell-ws/src/resume_validation.rs new file mode 100644 index 000000000..43e3f971a --- /dev/null +++ b/crates/freshell-ws/src/resume_validation.rs @@ -0,0 +1,258 @@ +//! Spawn-door resume validation (resume-validation feature): before a cached +//! session id is turned into resume argv, ask the disk-existence probe. On +//! POSITIVE absence, fall back to the same shape a genuinely fresh pane of +//! that mode uses. Unknown/unavailable always fail open. +//! +//! Callers (the spawn doors in `crate::terminal`) apply the outcome: retire +//! the stale ledger row, emit the notice, and never stamp the stale ref. + +use freshell_platform::cli_launch::LaunchIntent; +use freshell_platform::resume_gate::{ + evaluate_resume_gate, provider_validated, stale_resume_notice, ResumeExistence, + ResumeGateDecision, +}; + +use crate::existence::{SessionExistence, SessionExistenceProbe}; + +pub struct ResumeValidationOutcome { + pub resume_session_id: Option, + pub launch_intent: LaunchIntent, + /// True when the fallback minted a fresh claude Start id (caller must set + /// its claude_fresh_prealloc flag so downstream identity stamping matches + /// the genuine fresh-claude path). + pub claude_fresh_prealloc: bool, + /// Some(stale_id) iff the gate fired: caller retires the ledger row, + /// emits the notice, and must NOT stamp the stale sessionRef. + pub stale_session_id: Option, + pub notice: Option, +} + +fn passthrough( + resume_session_id: Option, + launch_intent: LaunchIntent, +) -> ResumeValidationOutcome { + ResumeValidationOutcome { + resume_session_id, + launch_intent, + claude_fresh_prealloc: false, + stale_session_id: None, + notice: None, + } +} + +pub fn map_existence(e: SessionExistence) -> ResumeExistence { + match e { + SessionExistence::Present => ResumeExistence::Present, + SessionExistence::Absent => ResumeExistence::Absent, + SessionExistence::Unknown | SessionExistence::ProviderUnavailable => { + ResumeExistence::Unknown + } + } +} + +pub fn validate_wire_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: &dyn SessionExistenceProbe, +) -> ResumeValidationOutcome { + let Some(sid) = resume_session_id.clone().filter(|s| !s.is_empty()) else { + return passthrough(resume_session_id, launch_intent); + }; + if !provider_validated(mode) { + return passthrough(resume_session_id, launch_intent); + } + let existence = map_existence(probe.exists(mode, &sid)); + let ever_on_disk = probe.ever_observed_on_disk(mode, &sid); + match evaluate_resume_gate(mode, existence, ever_on_disk) { + ResumeGateDecision::Proceed => passthrough(resume_session_id, launch_intent), + ResumeGateDecision::SpawnFresh => { + let notice = stale_resume_notice(mode, &sid); + let (fresh_id, intent, claude_prealloc) = match mode { + // Mirror the genuine fresh-pane shapes in handle_create + // (should_preallocate_fresh_claude / _amplifier). + "claude" => ( + Some(uuid::Uuid::new_v4().to_string()), + LaunchIntent::Start, + true, + ), + "amplifier" => ( + Some(uuid::Uuid::new_v4().to_string()), + LaunchIntent::Resume, + false, + ), + _ => (None, LaunchIntent::Resume, false), + }; + ResumeValidationOutcome { + resume_session_id: fresh_id, + launch_intent: intent, + claude_fresh_prealloc: claude_prealloc, + stale_session_id: Some(sid), + notice: Some(notice), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::existence::{SessionExistence, SessionExistenceProbe}; + use freshell_platform::cli_launch::LaunchIntent; + + struct FakeProbe { + answer: SessionExistence, + ever_on_disk: bool, + } + impl SessionExistenceProbe for FakeProbe { + fn exists(&self, _p: &str, _s: &str) -> SessionExistence { + self.answer + } + fn ever_observed(&self, _p: &str, _s: &str) -> bool { + false + } + fn ever_observed_on_disk(&self, _p: &str, _s: &str) -> bool { + self.ever_on_disk + } + } + + fn absent() -> FakeProbe { + FakeProbe { + answer: SessionExistence::Absent, + ever_on_disk: true, + } + } + + #[test] + fn amplifier_absent_mints_fresh_uuid_and_reports_stale() { + let out = validate_wire_resume( + "amplifier", + Some("stale-amp".into()), + LaunchIntent::Resume, + &absent(), + ); + let fresh = out + .resume_session_id + .expect("fresh amplifier id preallocated"); + assert_ne!(fresh, "stale-amp"); + assert_eq!(out.launch_intent, LaunchIntent::Resume); + assert!(!out.claude_fresh_prealloc); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-amp")); + let notice = out.notice.expect("notice set"); + assert!(notice.contains("stale-amp")); + } + + #[test] + fn claude_absent_previously_on_disk_falls_back_to_start_intent() { + let out = validate_wire_resume( + "claude", + Some("stale-claude".into()), + LaunchIntent::Resume, + &absent(), + ); + assert!(out.resume_session_id.is_some()); + assert_ne!(out.resume_session_id.as_deref(), Some("stale-claude")); + assert_eq!(out.launch_intent, LaunchIntent::Start); + assert!(out.claude_fresh_prealloc); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-claude")); + } + + #[test] + fn claude_zero_turn_absent_proceeds_untouched() { + let probe = FakeProbe { + answer: SessionExistence::Absent, + ever_on_disk: false, + }; + let out = validate_wire_resume( + "claude", + Some("zero-turn".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("zero-turn")); + assert_eq!(out.launch_intent, LaunchIntent::Resume); + assert!(out.stale_session_id.is_none()); + assert!(out.notice.is_none()); + } + + #[test] + fn codex_and_opencode_absent_drop_resume_entirely() { + for mode in ["codex", "opencode"] { + let out = validate_wire_resume( + mode, + Some("stale-x".into()), + LaunchIntent::Resume, + &absent(), + ); + assert!(out.resume_session_id.is_none()); + assert_eq!(out.stale_session_id.as_deref(), Some("stale-x")); + assert!(out.notice.is_some()); + } + } + + #[test] + fn unknown_and_provider_unavailable_fail_open() { + for answer in [ + SessionExistence::Unknown, + SessionExistence::ProviderUnavailable, + ] { + let probe = FakeProbe { + answer, + ever_on_disk: false, + }; + let out = validate_wire_resume( + "amplifier", + Some("maybe".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("maybe")); + assert!(out.stale_session_id.is_none()); + assert!(out.notice.is_none()); + } + } + + #[test] + fn present_passes_through_untouched() { + let probe = FakeProbe { + answer: SessionExistence::Present, + ever_on_disk: true, + }; + let out = validate_wire_resume( + "amplifier", + Some("real".into()), + LaunchIntent::Resume, + &probe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("real")); + assert!(out.stale_session_id.is_none()); + } + + #[test] + fn unvalidated_providers_and_empty_ids_never_consult_probe() { + struct PanickingProbe; + impl SessionExistenceProbe for PanickingProbe { + fn exists(&self, _: &str, _: &str) -> SessionExistence { + panic!("probe must not be consulted"); + } + fn ever_observed(&self, _: &str, _: &str) -> bool { + panic!("probe must not be consulted"); + } + fn ever_observed_on_disk(&self, _: &str, _: &str) -> bool { + panic!("probe must not be consulted"); + } + } + for mode in ["gemini", "kimi", "shell", "third-party"] { + let out = validate_wire_resume( + mode, + Some("id".into()), + LaunchIntent::Resume, + &PanickingProbe, + ); + assert_eq!(out.resume_session_id.as_deref(), Some("id")); + } + let out = validate_wire_resume("amplifier", None, LaunchIntent::Resume, &PanickingProbe); + assert!(out.resume_session_id.is_none()); + assert!(out.stale_session_id.is_none()); + } +} diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 05826a128..5caaa68ae 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1465,6 +1465,12 @@ pub(crate) struct LaunchPrep { pub launch_intent: LaunchIntent, pub resume_session_id: Option, pub claude_fresh_prealloc: bool, + /// Resume-validation tracker (door 1): true only when the resume id above + /// was derived from the WIRE (sessionRef / legacy resumeSessionId / the + /// claude restore ladder, which resolves persisted client state) — + /// server-minted prealloc ids are never gated. Set in exactly ONE place + /// (the wire `else` arm of derive_launch_prep); never re-computed. + pub resume_id_from_wire: bool, } /// Extraction of handle_create's PURE derivation rungs @@ -1483,6 +1489,10 @@ pub(crate) fn derive_launch_prep(create: &TerminalCreate, mode: &str) -> LaunchP // failure — a resume-create's row belongs to the prior epoch and must // stay recoverable. let mut claude_fresh_prealloc = false; + // Resume-validation tracker (door 1): true only when the resume id below + // was derived from the WIRE (sessionRef / legacy resumeSessionId / the + // claude restore ladder) — server-minted prealloc ids are never gated. + let mut resume_id_from_wire = false; if mode != "shell" { let requested_ref = create.session_ref.as_ref().filter(|r| r.provider == mode); // Shared with the REST spawn pipeline (kata hbsa) — one predicate, @@ -1534,12 +1544,18 @@ pub(crate) fn derive_launch_prep(create: &TerminalCreate, mode: &str) -> LaunchP .map(|r| r.session_id.clone()) .or_else(|| create.resume_session_id.clone()) .filter(|s| !s.is_empty()); + // Everything this arm can produce came from the wire (or the + // claude ladder in handle_create, which only runs when this arm + // ran and resolves persisted client state) — eligible for + // resume validation. + resume_id_from_wire = true; } } LaunchPrep { launch_intent, resume_session_id, claude_fresh_prealloc, + resume_id_from_wire, } } @@ -1870,9 +1886,10 @@ pub(crate) async fn handle_create( ); let LaunchPrep { - launch_intent, + mut launch_intent, mut resume_session_id, - claude_fresh_prealloc, + mut claude_fresh_prealloc, + resume_id_from_wire, } = match prep { Some(prep) => prep, None => derive_launch_prep(&create, &mode), @@ -1994,6 +2011,94 @@ pub(crate) async fn handle_create( } } + // Resume validation (docs/plans/2026-07-29-resume-validation.md): never + // hand the CLI a resume id that is definitively absent from the + // provider's on-disk store. Fail open on Unknown/ProviderUnavailable. + // Placed AFTER the D7 liveness guard (a gate fire would falsify D7's + // applicability filter and retire a running session's Bound row) and + // BEFORE the amplifier ensure_session re-stub (which would resurrect + // the stale dir). + let mut resume_fallback_notice: Option = None; + if resume_id_from_wire { + // In-gate liveness precondition: legacy resumeSessionId-only + // carriers bypass D7 in every ordering — a LIVE session must never + // gate. Same join D7 uses: registry.live_session_owner + the + // fresh-agent sidecar liveness consult above. SHAPE NOTE + // (load-bearing): the sidecar arm is ASYNC + // (`has_live_session(sid).await`), so the join is computed as a + // plain `let` with `.await` in scope — exactly the shape D7 itself + // uses. BOTH arms are load-bearing: dropping the async sidecar arm + // silently un-protects live zero-turn sessions that have no rollout + // on disk yet. + let candidate_is_live = match resume_session_id.as_deref() { + None => false, + Some(sid) => { + let registry_row_live = state + .registry + .live_session_owner(Some(&state.identity), &mode, sid) + .is_some(); + let fresh_agent_live = !registry_row_live + && match mode.as_str() { + "claude" => state.fresh_claude.has_live_session(sid).await, + "codex" => state.fresh_codex.has_live_session(sid).await, + "opencode" => state.fresh_opencode.has_live_session(sid).await, + _ => false, + }; + registry_row_live || fresh_agent_live + } + }; + if !candidate_is_live { + // The probe's by-id locators do real filesystem walks (~1 s for + // codex on a real store) — never inline on the async runtime + // (A13). Run the sync helper in spawn_blocking. + let outcome = { + let probe = state.session_existence.clone(); + let mode_for_gate = mode.clone(); + let rid = resume_session_id.take(); + let intent = launch_intent; + tokio::task::spawn_blocking(move || { + crate::resume_validation::validate_wire_resume( + &mode_for_gate, + rid, + intent, + probe.as_ref(), + ) + }) + .await + .expect("resume validation task panicked") + }; + resume_session_id = outcome.resume_session_id; + launch_intent = outcome.launch_intent; + if outcome.claude_fresh_prealloc { + claude_fresh_prealloc = true; + } + if let Some(stale) = outcome.stale_session_id.as_deref() { + tracing::warn!( + mode = %mode, + stale_session_id = %stale, + "resume validation: cached session missing on disk; spawning fresh" + ); + // Stale-ref stamping guard: this create no longer creates the + // wire sessionRef's session, so a D8 lease claimed for the + // STALE ref must be RELEASED, never completed (completing it + // would bind stale-ref→terminal in the registry binding map). + // Dropping the armed guard runs fail_session_ref_claim. + session_ref_lease = None; + // Don't retry the stale id forever. Same blocking-pool + // discipline as every other pane-ledger write in this file + // (~15ms p50 fsyncs — past the async-worker budget). + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let retire_mode = mode.clone(); + let stale_id = stale.to_string(); + let _ = tokio::task::spawn_blocking(move || { + ledger.retire_missing(&retire_mode, &stale_id) + }) + .await; + resume_fallback_notice = outcome.notice; + } + } + } + // Branch selection mirrors `buildSpawnSpec` (`tr:1127-1137`): the Windows-shell // branches apply on native Windows AND on WSL with an explicit cmd/powershell // pane shell (`isWindowsLike() && !inWslWithLinuxShell`). Hoisted ABOVE the @@ -2833,7 +2938,9 @@ pub(crate) async fn handle_create( clear_codex_durability: None, // Echo the resolved cwd (`record.cwd`) when the shell spec carries one. cwd: spec.cwd.clone(), - notice: None, + // Resume-validation door 1: the operator-visible stale-resume notice + // (Some only when the gate fired above). + notice: resume_fallback_notice, restore_error: None, // The canonical create-time identity, from the SAME registry every other // identity-stamped frame reads (shell creates have no entry -> `None`). diff --git a/crates/freshell-ws/tests/resume_validation_gate.rs b/crates/freshell-ws/tests/resume_validation_gate.rs new file mode 100644 index 000000000..94f917b2a --- /dev/null +++ b/crates/freshell-ws/tests/resume_validation_gate.rs @@ -0,0 +1,571 @@ +//! Door 1 (resume-validation feature): the WS `terminal.create` restore path +//! consults the disk-existence probe before turning a WIRE-derived resume id +//! into resume argv. POSITIVE absence => fresh-spawn fallback + `notice` on +//! `terminal.created` + `retire_missing` on the pane ledger. Everything else +//! (Present/Unknown/live sessions) proceeds byte-identically to today. +//! +//! Harness: the `restore_spawn_gate.rs` convention — REAL axum server + REAL +//! tokio-tungstenite client on an ephemeral loopback port — with an injected +//! fake `SharedExistenceProbe` and a REAL pane ledger in a temp dir. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use freshell_ws::existence::{SessionExistence, SessionExistenceProbe}; +use freshell_ws::pane_ledger::{BindingWrite, PaneLedger, RetiredReason, RowState}; +use freshell_ws::WsState; + +// ── scripted disk-truth probe (pane_reconcile_freshagent.rs convention) ────── + +#[derive(Default)] +struct StubProbe { + answers: std::sync::Mutex>, +} + +impl StubProbe { + fn answering(provider: &str, session_id: &str, answer: SessionExistence) -> Arc { + let probe = Self::default(); + probe + .answers + .lock() + .unwrap() + .insert((provider.to_string(), session_id.to_string()), answer); + Arc::new(probe) + } +} + +impl SessionExistenceProbe for StubProbe { + fn exists(&self, provider: &str, session_id: &str) -> SessionExistence { + self.answers + .lock() + .unwrap() + .get(&(provider.into(), session_id.into())) + .copied() + .unwrap_or(SessionExistence::Unknown) + } + fn ever_observed(&self, _provider: &str, _session_id: &str) -> bool { + // Amplifier/codex gating never consults the on-disk-history flag + // (only the claude zero-turn carve-out does), so the fakes keep it + // trivially false via the trait default. + false + } +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis() as i64 +} + +/// Seed a Bound ledger row for `(provider, session_id)` through the SERVER'S +/// ledger Arc (write-through index — V1.md read policy). +fn seed_bound_row(ledger: &PaneLedger, provider: &str, session_id: &str) { + ledger + .record_binding(&BindingWrite { + provider, + session_id, + terminal_id: "t-prior-epoch", + mode: provider, + cwd: None, + create_request_id: None, + now_ms: now_ms(), + }) + .expect("seed bound ledger row"); + assert_eq!( + ledger + .load_binding(provider, session_id) + .expect("seeded row present") + .state, + RowState::Bound + ); +} + +/// Real server with an injected existence probe + a REAL pane ledger rooted +/// in a fresh temp dir. Returns `(ws_url, registry, ledger, state)` — the +/// state clone lets tests seed liveness (identity upsert) the way +/// `spawn_server_with_specs_and_state` consumers do. +async fn spawn_server_with_probe( + probe: Arc, + fresh_agent_enabled: bool, +) -> ( + String, + freshell_terminal::TerminalRegistry, + Arc, + WsState, +) { + // F7/V9 choke point: amplifier creates write stub dirs — never the real home. + let _ = common::isolate_amplifier_home(); + let ledger_dir = std::env::temp_dir().join(format!( + "freshell-resume-gate-ledger-{}-{}", + std::process::id(), + uuid_like_suffix() + )); + std::fs::create_dir_all(&ledger_dir).expect("create ledger temp dir"); + let pane_ledger = Arc::new(PaneLedger::new(Some(ledger_dir))); + + let auth_token = Arc::new(common::AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let mut settings_value = common::test_settings_value(); + settings_value["freshAgent"]["enabled"] = json!(fresh_agent_enabled); + let settings = + Arc::new(serde_json::from_value(settings_value).expect("valid settings fixture")); + let registry = freshell_terminal::TerminalRegistry::new(); + + let state = WsState { + pane_ledger: Arc::clone(&pane_ledger), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + json!({ "freshAgent": { "enabled": fresh_agent_enabled } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(vec![ + common::sleeper_cli_spec("amplifier"), + common::sleeper_cli_spec("claude"), + common::sleeper_cli_spec("codex"), + ]), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: probe, + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + let router = freshell_ws::router(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + (format!("ws://{addr}/ws"), registry, pane_ledger, state) +} + +fn uuid_like_suffix() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{nanos}-{:?}", std::thread::current().id()) +} + +async fn send_json(ws: &mut common::TestWs, value: &Value) { + ws.send(WsMessage::Text(value.to_string())) + .await + .expect("send frame"); +} + +/// Read frames until either a `terminal.created` or an `error` correlated to +/// `request_id` arrives (bounded). +async fn next_created_or_error(ws: &mut common::TestWs, request_id: &str) -> Value { + for _ in 0..40u8 { + let msg = tokio::time::timeout(Duration::from_secs(10), ws.next()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for a reply to {request_id}")) + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: Value = serde_json::from_str(text).expect("json frame"); + let frame_type = value["type"].as_str().unwrap_or(""); + if (frame_type == "terminal.created" || frame_type == "error") + && value["requestId"] == json!(request_id) + { + return value; + } + } + } + panic!("no terminal.created/error for {request_id} within 40 messages"); +} + +fn notice_of(frame: &Value) -> Option { + match frame.get("notice") { + Some(v) if !v.is_null() => v.as_str().map(str::to_string), + _ => None, + } +} + +/// Does `/projects/*/sessions/` exist? +fn amplifier_session_dir_exists(amp_home: &std::path::Path, session_id: &str) -> bool { + let Ok(entries) = std::fs::read_dir(amp_home.join("projects")) else { + return false; + }; + entries + .flatten() + .any(|e| e.path().join("sessions").join(session_id).is_dir()) +} + +fn restore_create_with_session_ref(request_id: &str, mode: &str, session_id: &str) -> Value { + json!({ + "type": "terminal.create", + "requestId": request_id, + "mode": mode, + "shell": "system", + "restore": true, + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": { "provider": mode, "sessionId": session_id }, + }) +} + +fn restore_create_with_legacy_resume_id(request_id: &str, mode: &str, session_id: &str) -> Value { + json!({ + "type": "terminal.create", + "requestId": request_id, + "mode": mode, + "shell": "system", + "restore": true, + "cwd": std::env::temp_dir().to_string_lossy(), + "resumeSessionId": session_id, + }) +} + +// ── the six pinned behaviors ───────────────────────────────────────────────── + +/// Case 1 — THE incident shape: a restore of an amplifier id that is +/// definitively absent from the store must SUCCEED as a fresh spawn (never an +/// error), carry the operator notice, retire the stale Bound row as +/// SessionMissing, and never resurrect the stale dir. NOTE (AD-5): an empty +/// home + stale id is byte-identical on disk to a never-used stub GC'd at +/// terminal exit — gating it is the DECIDED behavior, not an accident. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_amplifier_absent_spawns_fresh_with_notice() { + let probe = StubProbe::answering("amplifier", "stale-amp", SessionExistence::Absent); + let (url, registry, ledger, _state) = spawn_server_with_probe(probe, false).await; + seed_bound_row(&ledger, "amplifier", "stale-amp"); + let amp_home = common::isolate_amplifier_home(); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + send_json( + &mut ws, + &restore_create_with_session_ref("req-gate-1", "amplifier", "stale-amp"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-1").await; + + assert_eq!( + frame["type"], "terminal.created", + "the gate-fired create must SUCCEED as a fresh spawn, got {frame}" + ); + let notice = notice_of(&frame).expect("gate fire must carry the operator notice"); + assert!( + notice.contains("stale-amp"), + "notice must name the stale id: {notice}" + ); + + // The stale row is retired as SessionMissing — never retried forever. + let stale_row = ledger + .load_binding("amplifier", "stale-amp") + .expect("stale row still exists (retired, not deleted)"); + assert_eq!(stale_row.state, RowState::Retired); + assert_eq!( + stale_row.retired_reason, + Some(RetiredReason::SessionMissing) + ); + + // The spawned resume id is a FRESH mint, not the stale ref. + let fresh_id = frame["sessionRef"]["sessionId"] + .as_str() + .expect("created frame carries the fresh sessionRef") + .to_string(); + assert_ne!(fresh_id, "stale-amp"); + + // Disk truth: the stale dir was NOT resurrected; the fresh stub exists. + assert!( + !amplifier_session_dir_exists(&_home, "stale-amp"), + "the amplifier pre-create must never re-stub the stale id" + ); + assert!( + amplifier_session_dir_exists(&_home, &fresh_id), + "the fresh UUID stub must exist under the temp amplifier home" + ); + + registry.kill_all(); +} + +/// Case 2 — Present passes through unchanged: no notice, row stays Bound. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_amplifier_present_resumes_unchanged() { + let probe = StubProbe::answering("amplifier", "present-amp", SessionExistence::Present); + let (url, registry, ledger, _state) = spawn_server_with_probe(probe, false).await; + seed_bound_row(&ledger, "amplifier", "present-amp"); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + send_json( + &mut ws, + &restore_create_with_session_ref("req-gate-2", "amplifier", "present-amp"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-2").await; + + assert_eq!(frame["type"], "terminal.created", "got {frame}"); + assert!( + notice_of(&frame).is_none(), + "a Present resume must carry NO notice: {frame}" + ); + assert_eq!( + frame["sessionRef"]["sessionId"], "present-amp", + "the resume id must pass through unchanged" + ); + assert_eq!( + ledger + .load_binding("amplifier", "present-amp") + .expect("row present") + .state, + RowState::Bound + ); + + registry.kill_all(); +} + +/// Case 3 — Unknown fails OPEN (today's behavior preserved): no notice, row +/// stays Bound. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_unknown_fails_open() { + let probe = StubProbe::answering("amplifier", "unknown-amp", SessionExistence::Unknown); + let (url, registry, ledger, _state) = spawn_server_with_probe(probe, false).await; + seed_bound_row(&ledger, "amplifier", "unknown-amp"); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + send_json( + &mut ws, + &restore_create_with_session_ref("req-gate-3", "amplifier", "unknown-amp"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-3").await; + + assert_eq!(frame["type"], "terminal.created", "got {frame}"); + assert!( + notice_of(&frame).is_none(), + "Unknown must fail open with NO notice: {frame}" + ); + assert_eq!(frame["sessionRef"]["sessionId"], "unknown-amp"); + assert_eq!( + ledger + .load_binding("amplifier", "unknown-amp") + .expect("row present") + .state, + RowState::Bound + ); + + registry.kill_all(); +} + +/// Case 4 — ordering pin (V8 §A11): a LIVE session whose ref rides the wire +/// `sessionRef` hits D7's loud "still running" reject, NOT the gate — the +/// Bound ledger row of the running session SURVIVES. Gate-before-D7 would +/// replace the resume id, falsify D7's applicability filter, and retire a +/// RUNNING session's row. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_live_absent_sessionref_hits_d7_not_the_gate() { + let probe = StubProbe::answering("amplifier", "live-amp", SessionExistence::Absent); + let (url, registry, ledger, state) = spawn_server_with_probe(probe, false).await; + seed_bound_row(&ledger, "amplifier", "live-amp"); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + // A RUNNING owner for ("amplifier", "live-amp"): a live PTY whose + // identity-registry entry names the session (the same identity-owner arm + // D7's live_session_owner join consults). + let owner_tid = common::create_shell_terminal(&mut ws, "req-live-owner-4").await; + state.identity.upsert( + &owner_tid, + Some("amplifier"), + Some("live-amp"), + None, + now_ms(), + ); + + send_json( + &mut ws, + &restore_create_with_session_ref("req-gate-4", "amplifier", "live-amp"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-4").await; + + assert_eq!( + frame["type"], "error", + "a live session must hit D7's reject, never a gate-fired fresh spawn: {frame}" + ); + assert_eq!(frame["code"], "RESTORE_UNAVAILABLE"); + assert!( + frame["message"] + .as_str() + .is_some_and(|m| m.contains("still running")), + "D7's own rejection message must answer: {frame}" + ); + // The running session's Bound row SURVIVES — the gate never saw the create. + assert_eq!( + ledger + .load_binding("amplifier", "live-amp") + .expect("row present") + .state, + RowState::Bound + ); + + registry.kill_all(); +} + +/// Case 5 — the REGISTRY arm of the in-gate liveness precondition: a legacy +/// `resumeSessionId`-only carrier bypasses D7 in every ordering, so the gate +/// itself must refuse to fire on a LIVE session. Resume proceeds unchanged. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_live_absent_legacy_resume_id_fails_open() { + let probe = StubProbe::answering("amplifier", "live-amp-legacy", SessionExistence::Absent); + let (url, registry, ledger, state) = spawn_server_with_probe(probe, false).await; + seed_bound_row(&ledger, "amplifier", "live-amp-legacy"); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + let owner_tid = common::create_shell_terminal(&mut ws, "req-live-owner-5").await; + state.identity.upsert( + &owner_tid, + Some("amplifier"), + Some("live-amp-legacy"), + None, + now_ms(), + ); + + send_json( + &mut ws, + &restore_create_with_legacy_resume_id("req-gate-5", "amplifier", "live-amp-legacy"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-5").await; + + assert_eq!( + frame["type"], "terminal.created", + "the legacy carrier must proceed (fail open on liveness): {frame}" + ); + assert!( + notice_of(&frame).is_none(), + "a LIVE session must never gate: {frame}" + ); + assert_eq!( + ledger + .load_binding("amplifier", "live-amp-legacy") + .expect("row present") + .state, + RowState::Bound, + "the live session's Bound row must survive" + ); + + registry.kill_all(); +} + +/// Case 6 — the ASYNC sidecar arm of the in-gate liveness join: liveness held +/// ONLY by the fresh-agent sidecar (no registry/identity owner). This is the +/// arm protecting live zero-turn sessions with no rollout on disk yet +/// (`freshell-server/src/existence.rs:224-227`) — it must not be silently +/// dropped while cases 4–5 still pass. +#[tokio::test(flavor = "multi_thread")] +async fn restore_true_sidecar_live_absent_legacy_resume_id_fails_open() { + // The codex fake app-server fixture (the codex.rs crash-recovery tests' + // scripted peer), pinned to mint the exact thread id this test keys on. + // Env mutation is process-global; this is the ONLY test in this binary + // that spawns a codex sidecar, so nothing else reads these vars. + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + r#"{"threadStartThreadId":"stale-cx"}"#, + ); + + let probe = StubProbe::answering("codex", "stale-cx", SessionExistence::Absent); + let (url, registry, ledger, state) = spawn_server_with_probe(probe, true).await; + seed_bound_row(&ledger, "codex", "stale-cx"); + + let (mut ws, _inv) = common::connect_and_capture_inventory(&url).await; + // A live freshcodex sidecar owning ("codex", "stale-cx"). + send_json( + &mut ws, + &json!({ + "type": "freshAgent.create", + "requestId": "req-fa-owner-6", + "sessionType": "freshcodex", + "provider": "codex", + "cwd": "/tmp", + }), + ) + .await; + let created = common::next_frame_of_type(&mut ws, "freshAgent.created").await; + assert_eq!( + created["sessionId"], "stale-cx", + "the fake app-server mints the pinned thread id" + ); + assert!( + state.fresh_codex.has_live_session("stale-cx").await, + "precondition: the sidecar owns the session" + ); + + send_json( + &mut ws, + &restore_create_with_legacy_resume_id("req-gate-6", "codex", "stale-cx"), + ) + .await; + let frame = next_created_or_error(&mut ws, "req-gate-6").await; + + assert_eq!( + frame["type"], "terminal.created", + "sidecar-live legacy carrier must proceed (fail open): {frame}" + ); + assert!( + notice_of(&frame).is_none(), + "a sidecar-LIVE session must never gate: {frame}" + ); + assert_eq!( + ledger + .load_binding("codex", "stale-cx") + .expect("row present") + .state, + RowState::Bound, + "the live session's Bound row must survive" + ); + + registry.kill_all(); + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); +} From 5452827d86b815e1da1a3d8d8355414b94c540f2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:29:54 -0700 Subject: [PATCH 13/35] feat(resume-validation): gate headless auto-resume respawn on disk existence Door 2 of the resume-validation feature: respawn_agent_terminal now runs the shared validate_wire_resume helper (spawn_blocking, A13) before turning the cached session id into resume argv. On positive absence the respawn proceeds fresh under a minted id, the stale ledger row is retired SessionMissing, a terminal.status{recovering, reason} frame is broadcast (headless path has no out sink), and a tracing::warn is logged. The gate replaces the resume_session_id LOCAL (V8 \u00a7A9) so all post-spawn bookkeeping (registry row, identity upsert, record_binding, amplifier ensure_session) records the FRESH id \u2014 no respawn loop. Present/Unknown proceed byte-for-byte as today (fail-open invariant). AD-3 (stale locator lease stays bound to the fresh terminal) accepted and documented at the wiring site. \U0001f916 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-ws/src/terminal.rs | 93 ++++- .../freshell-ws/tests/auto_resume_respawn.rs | 321 ++++++++++++++++++ 2 files changed, 405 insertions(+), 9 deletions(-) diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 5caaa68ae..bff2f0ede 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -3047,8 +3047,79 @@ pub async fn respawn_agent_terminal( ); // Spawn-time resume identity: intent is ALWAYS `Resume` on this path. - let launch_intent = LaunchIntent::Resume; - let resume_session_id = Some(req.session_id.clone()); + // Resume validation (docs/plans/2026-07-29-resume-validation.md): the + // respawn id comes from the identity registry / pane ledger — cached + // state — so it gets the same disk-existence gate as door 1. + // CRITICAL (V8 §A9): the gate replaces these LOCALS, not merely the + // launch args — the post-spawn bookkeeping (registry row, identity + // upsert, ledger record_binding below) all read `resume_session_id` and + // must record the FRESH id, or the stale id is re-minted as a Bound row + // right after retire_missing and the respawn loop is real. + let outcome = { + // The probe's by-id locators do real filesystem walks — never inline + // on the async runtime (A13). Run the sync helper in spawn_blocking, + // the same shape as door 1. + let probe = state.session_existence.clone(); + let gate_mode = mode.clone(); + let sid = Some(req.session_id.clone()); + tokio::task::spawn_blocking(move || { + crate::resume_validation::validate_wire_resume( + &gate_mode, + sid, + LaunchIntent::Resume, + probe.as_ref(), + ) + }) + .await + .expect("resume validation task panicked") + }; + let launch_intent = outcome.launch_intent; + let resume_session_id = outcome.resume_session_id; + if let Some(stale) = outcome.stale_session_id.as_deref() { + tracing::warn!( + mode = %req.mode, + stale_session_id = %stale, + "resume validation (respawn): cached session missing on disk; respawning fresh" + ); + // Don't retry the stale id forever. Same blocking-pool discipline as + // door 1 (~15ms p50 fsyncs — past the async-worker budget). + { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let retire_provider = req.provider.clone(); + let stale_id = stale.to_string(); + let _ = tokio::task::spawn_blocking(move || { + ledger.retire_missing(&retire_provider, &stale_id) + }) + .await; + } + // Headless path: no per-create `out` sink. Broadcast the existing + // Recovering status frame (precedent: auto_resume.rs emit_recovering) + // with the notice as reason. Reason prose is presentational-only per + // the protocol doc; the typed fields carry no data change here. + // + // Deliberate decision AD-3 (accepted, documented — no behavior + // change): after this gate-fired respawn, the auto-resume hub's + // `complete_claim` keeps the STALE locator's lease bound to the FRESH + // terminal (auto_resume.rs → registry.complete_session_ref_claim). A + // later `terminal.create` carrying the stale ref then adopts the + // fresh terminal via `BoundElsewhere` WITHOUT door 1's notice. This + // is convergent (no loop, no duplicate pane, the user reaches the + // fresh terminal); the only cost is a missing notice on a rare + // second-order path. Releasing/rebinding the lease would mean new + // registry API surface for no user-visible gain. + let msg = + freshell_protocol::ServerMessage::TerminalStatus(freshell_protocol::TerminalStatus { + status: freshell_protocol::RuntimeStatus::Recovering, + terminal_id: terminal_id.clone(), + attempt: None, + max_attempts: None, + exit_code: None, + reason: outcome.notice.clone(), + }); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = state.broadcast_tx.send(json); + } + } // Launch params from state.settings EXACTLY as handle_create derives them // (BindingRow launch fields are hardcoded None for terminal panes — @@ -3172,14 +3243,18 @@ pub async fn respawn_agent_terminal( }; let child_env = build_child_env_from_process(&spec); - // Launcher-assigned amplifier identity: a respawn resumes req.session_id - // unconditionally — make that resume guaranteed-resumable first - // (ensure-after-GC; existing/used sessions are found and left - // untouched). Best-effort: a failure here must not veto the respawn — - // the CLI itself will fail loudly in-terminal if the dir is truly gone. + // Launcher-assigned amplifier identity: a respawn resumes the + // gate-validated `resume_session_id` (== req.session_id unless the gate + // above minted a fresh uuid) — make that resume guaranteed-resumable + // first (ensure-after-GC; existing/used sessions are found and left + // untouched). Keying off the LOCAL, not req.session_id, is load-bearing: + // a gate-fired respawn must stub the FRESH id, never resurrect the stale + // dir. Best-effort: a failure here must not veto the respawn — the CLI + // itself will fail loudly in-terminal if the dir is truly gone. let mut respawn_amplifier_stub_gc: Option = None; if req.mode == "amplifier" { - if let (Some(amp_home), Some(cwd)) = ( + if let (Some(sid), Some(amp_home), Some(cwd)) = ( + resume_session_id.as_deref(), freshell_sessions::amplifier_stub::resolve_amplifier_home(), req.cwd .as_deref() @@ -3187,7 +3262,7 @@ pub async fn respawn_agent_terminal( ) { match freshell_sessions::amplifier_stub::ensure_session( &_home, - &req.session_id, + sid, cwd, &terminal_id, ) { diff --git a/crates/freshell-ws/tests/auto_resume_respawn.rs b/crates/freshell-ws/tests/auto_resume_respawn.rs index 0a7e2bd23..ea971949d 100644 --- a/crates/freshell-ws/tests/auto_resume_respawn.rs +++ b/crates/freshell-ws/tests/auto_resume_respawn.rs @@ -10,12 +10,18 @@ mod common; +use std::sync::Arc; use std::time::Duration; use common::{connect_and_capture_inventory, next_frame_of_type}; use futures_util::SinkExt; +use serde_json::Value; use tokio_tungstenite::tungstenite::Message as WsMessage; +use freshell_ws::existence::{SessionExistence, SessionExistenceProbe}; +use freshell_ws::pane_ledger::{BindingWrite, PaneLedger, RetiredReason, RowState}; +use freshell_ws::WsState; + /// A claude-shaped CLI spec whose command records its argv (one token per /// line, atomically via tmp+mv) to `capture`, then exits 1 — so the first /// generation "crashes" immediately and a respawn's argv overwrites the @@ -253,3 +259,318 @@ async fn respawn_is_rejected_loud_when_spawn_gate_queue_is_full() { let _ = w.await; } } + +// ── Door 2 (resume-validation): gate the headless respawn ────────────────── + +/// Scripted disk-truth probe (the `resume_validation_gate.rs` convention). +#[derive(Default)] +struct StubProbe { + answers: std::sync::Mutex>, +} + +impl StubProbe { + fn answering(provider: &str, session_id: &str, answer: SessionExistence) -> Arc { + let probe = Self::default(); + probe + .answers + .lock() + .unwrap() + .insert((provider.to_string(), session_id.to_string()), answer); + Arc::new(probe) + } +} + +impl SessionExistenceProbe for StubProbe { + fn exists(&self, provider: &str, session_id: &str) -> SessionExistence { + self.answers + .lock() + .unwrap() + .get(&(provider.into(), session_id.into())) + .copied() + .unwrap_or(SessionExistence::Unknown) + } + fn ever_observed(&self, _provider: &str, _session_id: &str) -> bool { + // Amplifier gating never consults the on-disk-history flag (only the + // claude zero-turn carve-out does). + false + } +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis() as i64 +} + +/// Seed a Bound ledger row for `(provider, session_id)` through the STATE'S +/// ledger Arc (write-through index — V1.md read policy). +fn seed_bound_row(ledger: &PaneLedger, provider: &str, session_id: &str) { + ledger + .record_binding(&BindingWrite { + provider, + session_id, + terminal_id: "t-prior-epoch", + mode: provider, + cwd: None, + create_request_id: None, + now_ms: now_ms(), + }) + .expect("seed bound ledger row"); + assert_eq!( + ledger + .load_binding(provider, session_id) + .expect("seeded row present") + .state, + RowState::Bound + ); +} + +/// Does `/projects/*/sessions/` exist? +fn amplifier_session_dir_exists(amp_home: &std::path::Path, session_id: &str) -> bool { + let Ok(entries) = std::fs::read_dir(amp_home.join("projects")) else { + return false; + }; + entries + .flatten() + .any(|e| e.path().join("sessions").join(session_id).is_dir()) +} + +/// A directly-constructed `WsState` for driving `respawn_agent_terminal` +/// (no WS connection needed on the headless door): injected existence probe + +/// REAL pane ledger in a fresh temp dir, amplifier sleeper CLI spec so the +/// respawn genuinely spawns. Field-for-field the +/// `resume_validation_gate.rs::spawn_server_with_probe` state. +fn respawn_state_with_probe( + probe: Arc, +) -> ( + freshell_terminal::TerminalRegistry, + Arc, + WsState, +) { + // F7/V9 choke point: amplifier respawns write stub dirs — never the real home. + let _ = common::isolate_amplifier_home(); + let ledger_dir = std::env::temp_dir().join(format!( + "freshell-respawn-gate-ledger-{}-{}", + std::process::id(), + now_ms() + )); + std::fs::create_dir_all(&ledger_dir).expect("create ledger temp dir"); + let pane_ledger = Arc::new(PaneLedger::new(Some(ledger_dir))); + + let auth_token = Arc::new(common::AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = Arc::new( + serde_json::from_value(common::test_settings_value()).expect("valid settings fixture"), + ); + let registry = freshell_terminal::TerminalRegistry::new(); + + let state = WsState { + pane_ledger: Arc::clone(&pane_ledger), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(vec![common::sleeper_cli_spec("amplifier")]), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: probe, + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + (registry, pane_ledger, state) +} + +/// Drain every frame buffered on the broadcast receiver into parsed JSON. +fn drain_broadcast_frames(rx: &mut tokio::sync::broadcast::Receiver) -> Vec { + let mut frames = Vec::new(); + while let Ok(json) = rx.try_recv() { + if let Ok(v) = serde_json::from_str::(&json) { + frames.push(v); + } + } + frames +} + +/// Door 2 gate fire: a respawn of an amplifier id that is definitively absent +/// from the store must PROCEED as a fresh spawn (the pane survives), retire +/// the stale Bound row `SessionMissing`, never re-stub the stale dir, +/// broadcast a `terminal.status{recovering, reason}` frame naming the stale +/// id, and carry the FRESH id through ALL post-spawn bookkeeping (V8 §A9: +/// registry row, identity upsert, ledger record_binding — a Bound row for the +/// stale id must NOT be re-minted right after retire_missing). +#[tokio::test(flavor = "multi_thread")] +#[cfg(unix)] +async fn respawn_with_absent_session_spawns_fresh_and_retires_binding() { + let amp_home = common::isolate_amplifier_home(); + let probe = StubProbe::answering("amplifier", "stale-amp", SessionExistence::Absent); + let (registry, ledger, state) = respawn_state_with_probe(probe); + seed_bound_row(&ledger, "amplifier", "stale-amp"); + let mut rx = state.broadcast_tx.subscribe(); + + let new_tid = freshell_ws::terminal::respawn_agent_terminal( + &state, + &freshell_ws::terminal::AgentRespawnRequest { + mode: "amplifier".into(), + provider: "amplifier".into(), + session_id: "stale-amp".into(), + create_request_id: "req-respawn-gate-1".into(), + cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + }, + ) + .await + .expect("a gate-fired respawn must proceed as a fresh spawn, never an error"); + + // 2. The stale row is retired SessionMissing — never retried forever. + let stale_row = ledger + .load_binding("amplifier", "stale-amp") + .expect("stale row still exists (retired, not deleted)"); + assert_eq!(stale_row.state, RowState::Retired); + assert_eq!( + stale_row.retired_reason, + Some(RetiredReason::SessionMissing) + ); + + // 5. V8 §A9 pin: the NEW generation's bookkeeping carries the FRESH id. + let sref = state + .identity + .session_ref_for(&new_tid) + .expect("identity registry entry for the new terminal"); + assert_eq!(sref.provider, "amplifier"); + let fresh_id = sref.session_id.clone(); + assert_ne!(fresh_id, "stale-amp", "identity must name the fresh uuid"); + assert_eq!( + registry + .probe(&new_tid) + .expect("registry row") + .resume_session_id + .as_deref(), + Some(fresh_id.as_str()), + "the registry row must record the fresh id, not the stale one" + ); + assert_eq!( + ledger + .load_binding("amplifier", &fresh_id) + .expect("fresh Bound row") + .state, + RowState::Bound + ); + // record_binding must NOT have resurrected the stale id as Bound. + assert_eq!( + ledger + .load_binding("amplifier", "stale-amp") + .expect("stale row present") + .state, + RowState::Retired, + "record_binding re-minted a Bound row for the stale id (respawn loop)" + ); + + // 3. ensure_session must not run for the stale id. + assert!( + !amplifier_session_dir_exists(&_home, "stale-amp"), + "the amplifier pre-spawn stub must never resurrect the stale dir" + ); + + // 4. A broadcast terminal.status{recovering} frame names the stale id. + let frames = drain_broadcast_frames(&mut rx); + assert!( + frames.iter().any(|v| v["type"] == "terminal.status" + && v["status"] == "recovering" + && v["reason"] + .as_str() + .is_some_and(|r| r.contains("stale-amp"))), + "no recovering broadcast naming the stale id; frames: {frames:?}" + ); + + registry.kill_all(); +} + +/// Fail-open pin: Unknown existence resumes EXACTLY as today — the spawned +/// generation carries the resume id, the row stays Bound, and no +/// recovering-with-stale-id broadcast is emitted. +#[tokio::test(flavor = "multi_thread")] +#[cfg(unix)] +async fn respawn_with_unknown_existence_resumes_exactly_as_today() { + let probe = StubProbe::answering("amplifier", "maybe-amp", SessionExistence::Unknown); + let (registry, ledger, state) = respawn_state_with_probe(probe); + seed_bound_row(&ledger, "amplifier", "maybe-amp"); + let mut rx = state.broadcast_tx.subscribe(); + + let new_tid = freshell_ws::terminal::respawn_agent_terminal( + &state, + &freshell_ws::terminal::AgentRespawnRequest { + mode: "amplifier".into(), + provider: "amplifier".into(), + session_id: "maybe-amp".into(), + create_request_id: "req-respawn-gate-2".into(), + cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + }, + ) + .await + .expect("Unknown fails open: the respawn proceeds as a resume"); + + // The spawned generation still carries the resume id (the sibling test's + // registry-row observation point) and the row stays Bound. + assert_eq!( + registry + .probe(&new_tid) + .expect("registry row") + .resume_session_id + .as_deref(), + Some("maybe-amp") + ); + assert_eq!( + ledger + .load_binding("amplifier", "maybe-amp") + .expect("row present") + .state, + RowState::Bound + ); + + // No recovering-with-stale-id broadcast on the fail-open path. + let frames = drain_broadcast_frames(&mut rx); + assert!( + !frames.iter().any(|v| v["type"] == "terminal.status" + && v["status"] == "recovering" + && v["reason"] + .as_str() + .is_some_and(|r| r.contains("maybe-amp"))), + "Unknown must never emit the stale-id recovering frame; frames: {frames:?}" + ); + + registry.kill_all(); +} From 644a9b40a42e0324d472f8090ae81e0cea5f45dc Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:05:42 -0700 Subject: [PATCH 14/35] feat(resume-validation): gate freshagent REST create pipeline on disk existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Door 3: before the REST create pipeline (POST /api/tabs, /api/panes/:id/split, /api/panes/:id/respawn) turns a cached resume id into resume argv, consult the disk-existence probe; on POSITIVE absence, spawn fresh (claude -> minted v4 + Start, amplifier -> minted v4 + Resume, codex/opencode -> no resume), clear the stale wire ref, retire the ledger row via on_stale_resume, and inject the stale-resume notice as paneContent.reconcileNotice. - validate_rest_resume in terminal_tabs.rs (sync; run in spawn_blocking, A13), gated by the MANDATORY two-arm in-gate liveness precondition: the D7-REST guard's own registry consult (shared, not reimplemented) + the injected async sidecar_liveness probe (PTY-scoped consult is blind to sidecar-live sessions). A LIVE candidate skips the gate byte-for-byte. - FreshAgentState: resume_probe / on_stale_resume / sidecar_liveness fields + consuming with_* builders (with_opencode_locator shape; None = today's behavior). - main.rs: hoist the session_existence probe out of the WsState literal into a named binding (arms byte-identical) and add the door-3 wiring as the LAST fresh_agent_state rebinding (needs pane_ledger + the hoisted probe). Fail-open invariant preserved: Unknown/ProviderUnavailable and unvalidated providers proceed unchanged; probe unwired = passthrough. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-freshagent/src/lib.rs | 70 +++ .../freshell-freshagent/src/terminal_tabs.rs | 516 +++++++++++++++++- crates/freshell-server/src/existence_by_id.rs | 14 +- .../src/existence_resume_validation_tests.rs | 4 +- crates/freshell-server/src/main.rs | 246 ++++++--- crates/freshell-ws/src/pane_ledger_tests.rs | 13 +- 6 files changed, 764 insertions(+), 99 deletions(-) diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 8714f7486..720eb209e 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -68,6 +68,28 @@ pub use spawn_gate::{SpawnGate, SpawnGateError}; /// Defaults to "always false" (behavior-preserving for constructors that don't wire it). pub type TerminalLivenessProbe = std::sync::Arc bool + Send + Sync>; +/// Door 3 (resume-validation): the gate-fired callback shape -- +/// `(provider, stale_session_id)`. In production, `freshell-server`'s main.rs +/// implements it as pane-ledger `retire_missing` + `tracing::warn!`. +pub type OnStaleResume = std::sync::Arc; + +/// Door 3 (resume-validation): the injected ASYNC sidecar-liveness probe -- +/// `(mode, session_id) -> is that session live inside a fresh-agent sidecar`. +/// The REST create gate's registry consult is `TerminalRegistry`/PTY-scoped +/// and structurally BLIND to sessions live inside the fresh-agent sidecars +/// (sidecars never get PTY rows), so the in-gate liveness precondition needs +/// this second arm. Precedent: [`TerminalLivenessProbe`] solves the SAME +/// cross-crate liveness problem in the opposite direction -- same shape, made +/// async. Constructed by `freshell-server`'s `main.rs` over the SAME sidecar +/// instances the WS door's D7 join consults, so this crate never imports +/// `freshell-ws`. `None` (not wired, e.g. bare unit-test states) => the arm +/// contributes false. +pub type SidecarLivenessProbe = std::sync::Arc< + dyn Fn(&str, &str) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + /// Handle pairing the shared gate with the permit-wait timeout /// (`CreateProtectConfig.spawn_timeout_ms`, resolved once in main.rs so both /// doors share one env snapshot). @@ -224,6 +246,20 @@ pub struct FreshAgentState { /// `identity_sink`). `None` = ungated (unwired unit tests keep legacy /// behavior; production always wires it). spawn_gate: Arc>, + /// Door 3 (resume-validation): the disk-existence probe consulted before + /// a REST create turns a cached resume id into resume argv. Injected as + /// [`freshell_platform::resume_gate::ResumeProbeFn`] because this crate + /// must NOT depend on `freshell-ws` (whose probe trait would be circular + /// -- see this Cargo.toml's freshell-sessions comment). `None` = feature + /// off = today's behavior (every pre-existing test). + pub(crate) resume_probe: Option, + /// Door 3: called with `(provider, stale_session_id)` when the gate + /// fires. `freshell-server`'s main.rs implements it as pane-ledger + /// `retire_missing` + `tracing::warn!`. `None` = no-op. + pub(crate) on_stale_resume: Option, + /// Door 3: arm 2 of the in-gate liveness precondition (see + /// [`SidecarLivenessProbe`]). `None` = arm contributes false. + pub(crate) sidecar_liveness: Option, } /// A fresh-agent pane (the `paneContent` subset the opencode T2 path needs). @@ -292,6 +328,9 @@ impl FreshAgentState { codex_locator: None, identity_sink: Arc::new(std::sync::OnceLock::new()), spawn_gate: Arc::new(std::sync::OnceLock::new()), + resume_probe: None, + on_stale_resume: None, + sidecar_liveness: None, } } @@ -480,6 +519,37 @@ impl FreshAgentState { self } + /// Door 3 (resume-validation): wire the disk-existence probe the REST + /// create pipeline consults before turning a cached resume id into + /// resume argv. `freshell-server`'s main.rs builds it over the SAME + /// `SessionExistenceProbe` the WS doors use. Unwired = feature off = + /// today's behavior. + pub fn with_resume_probe( + mut self, + probe: freshell_platform::resume_gate::ResumeProbeFn, + ) -> Self { + self.resume_probe = Some(probe); + self + } + + /// Door 3: wire the gate-fired callback (`(provider, stale_session_id)`), + /// implemented by `freshell-server`'s main.rs as pane-ledger + /// `retire_missing` + `tracing::warn!`. + pub fn with_on_stale_resume(mut self, cb: OnStaleResume) -> Self { + self.on_stale_resume = Some(cb); + self + } + + /// Door 3: wire arm 2 of the in-gate liveness precondition (see + /// [`SidecarLivenessProbe`]). MUST stay a consuming `with_*` builder like + /// its siblings, NOT a `set_*`/`OnceLock` late-bind: `FreshOpencodeState` + /// holds a `FreshAgentState` clone by value, so a late-bound handle back + /// to the sidecars would create a real Arc cycle. + pub fn with_sidecar_liveness(mut self, probe: SidecarLivenessProbe) -> Self { + self.sidecar_liveness = Some(probe); + self + } + /// SESSION-09 fix-forward: replace this state's own `sessions_revision` /// counter with a SHARED one -- in production, `freshell-server` wires /// this to the SAME `Arc` as `freshell_ws::WsState::sessions_revision` diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 8593f7408..cdb35a58e 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -529,6 +529,90 @@ pub(crate) fn derive_resume_identity( )) } +/// Door 3 (resume-validation): what [`validate_rest_resume`] decided for a +/// REST create's cached resume id. Mirrors `freshell-ws`'s +/// `ResumeValidationOutcome` (Task 6) minus the claude-prealloc flag (the +/// REST pipeline has no fresh-claude preallocation path; the healed +/// pane_content stamping falls out of `plausible_resume_session_id` on the +/// minted id instead). +struct RestResumeOutcome { + resume_session_id: Option, + launch_intent: LaunchIntent, + /// Some(stale_id) iff the gate fired: caller clears the accepted wire + /// ref (never stamp the stale sessionRef), invokes `on_stale_resume`, + /// and injects the notice into the returned `paneContent`. + stale_session_id: Option, + notice: Option, +} + +/// The Proceed shape — shared by [`validate_rest_resume`] and the wiring +/// site's live-candidate skip (a LIVE session must never be gated). +fn rest_resume_passthrough( + resume_session_id: Option, + launch_intent: LaunchIntent, +) -> RestResumeOutcome { + RestResumeOutcome { + resume_session_id, + launch_intent, + stale_session_id: None, + notice: None, + } +} + +/// Door 3 gate (resume-validation): before the REST create pipeline turns a +/// cached session id into resume argv, ask the disk-existence probe. On +/// POSITIVE absence, fall back to the same shape a genuinely fresh pane of +/// that mode uses (claude → new UUID + `Start`; amplifier → new UUID + +/// `Resume`; codex/opencode → `None`). Unknown/unavailable, unvalidated +/// providers, and `probe: None` (feature not wired — bare unit-test states) +/// all fail open. Body mirrors `freshell_ws::resume_validation:: +/// validate_wire_resume`, using the `ResumeProbeFn` injection shape because +/// this crate must not depend on `freshell-ws`. SYNC by design: the wiring +/// site runs it inside `tokio::task::spawn_blocking` (A13 — the probe does +/// real filesystem walks). Minted UUIDs MUST be RFC-4122 v4 (`Uuid::new_v4()`, +/// the crate's existing mint convention) — `is_canonical_claude_session_id` +/// enforces version 1..=5 + RFC-4122 variant, so a v7 or nil UUID would fail +/// [`plausible_resume_session_id`] and break the healed identity stamping. +fn validate_rest_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: Option<&freshell_platform::resume_gate::ResumeProbeFn>, +) -> RestResumeOutcome { + use freshell_platform::resume_gate::{ + evaluate_resume_gate, provider_validated, stale_resume_notice, ResumeGateDecision, + }; + let Some(probe) = probe else { + return rest_resume_passthrough(resume_session_id, launch_intent); + }; + let Some(sid) = resume_session_id.clone().filter(|s| !s.is_empty()) else { + return rest_resume_passthrough(resume_session_id, launch_intent); + }; + if !provider_validated(mode) { + return rest_resume_passthrough(resume_session_id, launch_intent); + } + let answer = probe(mode, &sid); + match evaluate_resume_gate(mode, answer.existence, answer.ever_observed_on_disk) { + ResumeGateDecision::Proceed => rest_resume_passthrough(resume_session_id, launch_intent), + ResumeGateDecision::SpawnFresh => { + let notice = stale_resume_notice(mode, &sid); + let (fresh_id, intent) = match mode { + // Mirror the genuine fresh-pane shapes (same per-provider + // fallbacks as the WS door's validate_wire_resume). + "claude" => (Some(Uuid::new_v4().to_string()), LaunchIntent::Start), + "amplifier" => (Some(Uuid::new_v4().to_string()), LaunchIntent::Resume), + _ => (None, LaunchIntent::Resume), + }; + RestResumeOutcome { + resume_session_id: fresh_id, + launch_intent: intent, + stale_session_id: Some(sid), + notice: Some(notice), + } + } + } +} + /// The successful result of [`spawn_terminal_pane`]: the `paneContent` JSON + the /// resolved `mode`/`shell`/`cwd`/`terminal_id`, everything a caller (tab-create or /// pane-split) needs to build its own `ui.command` payload and success envelope @@ -792,9 +876,91 @@ pub(crate) async fn spawn_terminal_pane( } } - let (mut resume_session_id, accepted_session_ref, session_ref_locator_present) = + let (mut resume_session_id, mut accepted_session_ref, session_ref_locator_present) = derive_resume_identity(body, &mode)?; + // Door 3 (resume-validation): gate the cached resume id on disk existence + // BEFORE the amplifier ensure_session below (or the re-stub would + // resurrect the stale dir) and before CliLaunchInputs is built. + // + // In-gate liveness precondition (MANDATORY — ordering hazard, the A11 + // door-1 hazard with the constraint INVERTED: in THIS pipeline the + // amplifier ensure comes BEFORE the REST D7 live-session guard and the D8 + // lease, so the gate cannot get liveness protection by after-D7 placement; + // placed here, a gate fire on a LIVE candidate would clear + // accepted_session_ref / replace resume_session_id and FALSIFY the D7-REST + // applicability filter — its loud RESTORE_UNAVAILABLE/CONFLICT reject and + // the D8 lease silently bypassed, and on_stale_resume → retire_missing + // would destroy the Bound ledger row of a RUNNING session. Reachable: a + // live zero-turn codex session genuinely has no rollout on disk.): + // a LIVE session must never be gated. TWO ARMS, both load-bearing: + // the registry arm reuses the REST D7 guard's own consult (below, which + // owns the loud reject for registry-live sessions downstream); the + // sidecar arm exists because that consult is PTY-scoped and blind to + // sessions live inside the fresh-agent sidecars — the very + // live-zero-turn-with-no-rollout-on-disk case. Dropping either arm + // silently un-protects a class of live sessions (the live-session tests + // pin BOTH arms). + let candidate_is_live = match resume_session_id.as_deref() { + None => false, + Some(sid) => { + // Arm 1 (registry): the SAME consult the REST D7 guard below + // performs — shared, not reimplemented. + let registry_live = registry + .live_session_owner(state.session_identity.as_deref(), &mode, sid) + .is_some(); + // Arm 2 (sidecar): the injected async probe. None (not wired, + // e.g. bare unit-test states) => arm contributes false. + let sidecar_live = if registry_live { + true // short-circuit: already live + } else { + match &state.sidecar_liveness { + Some(probe) => probe(&mode, sid).await, + None => false, + } + }; + registry_live || sidecar_live + } + }; + // Today's hardcoded intent for this pipeline (see the CliLaunchInputs + // comment in settle_gated_create) — the gate's claude fallback is the + // one path that rewrites it to Start. + let launch_intent = LaunchIntent::Resume; + // Probe does real filesystem walks — never inline on the async runtime + // (A13); run the sync helper in spawn_blocking. A LIVE candidate skips + // the gate entirely (passthrough — same shape validate_rest_resume + // returns for Proceed), so the unchanged create flows into the D7-REST + // guard and D8 lease exactly as today. + let rest_outcome = if candidate_is_live { + rest_resume_passthrough(resume_session_id.take(), launch_intent) + } else { + let probe = state.resume_probe.clone(); + let mode_for_gate = mode.clone(); + let rid = resume_session_id.take(); + let intent = launch_intent; + tokio::task::spawn_blocking(move || { + validate_rest_resume(&mode_for_gate, rid, intent, probe.as_ref()) + }) + .await + .expect("resume validation task panicked") + }; + let mut resume_session_id = rest_outcome.resume_session_id; + let launch_intent = rest_outcome.launch_intent; + if let Some(stale) = rest_outcome.stale_session_id.as_deref() { + // MANDATORY stale-ref guard (V7 row 10): the pane_content identity + // stamping PREFERS accepted_session_ref — left in place, the STALE + // wire ref would be stamped into the new tab's pane_content, + // poisoning client persistence + tabs-sync replay and re-firing the + // gate every restart. Clearing it makes stamping fall through to the + // minted-ref branch, so gate-fired claude/amplifier panes are born + // with the HEALED ref and codex/opencode panes with no ref. + accepted_session_ref = None; + if let Some(cb) = &state.on_stale_resume { + cb(&mode, stale); + } + } + let resume_notice = rest_outcome.notice; + // Fresh-claude preallocation (kata hbsa): WS parity. The WS door's // fresh-claude special case (freshell-ws/src/terminal.rs, LIVE-PATH LAW // spec §2.1(3)) mints a server-preallocated --session-id for every fresh @@ -1109,6 +1275,8 @@ pub(crate) async fn spawn_terminal_pane( shell_str, cwd, resume_session_id, + launch_intent, + resume_notice, accepted_session_ref, claude_fresh_prealloc, pane_identity: state.pane_identity.clone(), @@ -1144,6 +1312,13 @@ struct GatedSettleInputs { shell_str: Option, cwd: Option, resume_session_id: Option, + /// Door 3 (resume-validation): the gate's outcome intent — today's + /// hardcoded `Resume` everywhere EXCEPT the gate-fired claude fallback, + /// whose minted fresh id launches with `Start`. + launch_intent: LaunchIntent, + /// Door 3: the operator-visible stale-resume notice when the gate fired, + /// injected into the returned `paneContent` as `reconcileNotice`. + resume_notice: Option, accepted_session_ref: Option, /// Fresh-claude preallocation (kata hbsa): `true` iff THIS create minted /// its own `--session-id` (the [`freshell_platform::should_preallocate_fresh_claude`] @@ -1194,6 +1369,8 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result Result