Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1805705
docs: add implementation plan for wall-pins-closure
danshapiro Jul 29, 2026
0223823
docs(plan): harden wall-pins-closure plan with load-bearing validatio…
danshapiro Jul 29, 2026
aae8235
feat(existence): split ever_observed_on_disk from ever_observed (disk…
danshapiro Jul 29, 2026
e970d9c
fix(reconcile): claude never-conversed sessions respawn instead of de…
danshapiro Jul 29, 2026
eb9abdf
test(e2e): dead-session batching no longer keys on claude deleted-whi…
danshapiro Jul 29, 2026
3f776d5
wip(wall): ruler flip deferred to Task 8 -- claude --resume leg now g…
danshapiro Jul 29, 2026
a19ad59
feat(reconcile): pending-marker read derives loud fresh_by_race, idem…
danshapiro Jul 29, 2026
1d05664
feat(client): DOM-visible fresh_by_race breadcrumb for race-lost pane…
danshapiro Jul 29, 2026
6705b6f
test(wall): flip SIGKILL-inside-locator-window pin — fresh_by_race br…
danshapiro Jul 29, 2026
c12e7d7
fix(ws): claude binding row durable BEFORE PTY spawn — durability pre…
danshapiro Jul 29, 2026
6bb0173
test(wall): flip SIGKILL-within-5s pin — durable pre-spawn binding + …
danshapiro Jul 29, 2026
8923b7e
fix(wall): quiet-client assertion excludes monaco's structural aria a…
danshapiro Jul 29, 2026
7f05c10
test(wall): flip the composed-ruler pin — claude never-conversed leg …
danshapiro Jul 29, 2026
eaa25b7
fix(wall): scope PIN 2 pre-spawn binding write to fresh claude preall…
danshapiro Jul 29, 2026
72a37a4
Merge remote-tracking branch 'origin/main' into fix/wall-pins-closure
danshapiro Jul 29, 2026
fded45c
Merge remote-tracking branch 'origin/main' into fix/wall-pins-closure
danshapiro Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions crates/freshell-server/src/existence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ impl SessionExistenceProbe for IndexExistenceProbe {
.as_ref()
.is_some_and(|ledger| ledger.ever_bound(provider, session_id))
}

fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool {
self.observed
.lock()
.expect("observed set lock")
.contains(&format!("{provider}:{session_id}"))
}
}

#[cfg(test)]
Expand Down Expand Up @@ -421,6 +428,65 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}

/// PIN 1 (claude never-conversed carve-out): "seen on disk" is a strictly
/// stronger fact than "ever bound". A ledger binding proves the identity
/// was minted at create — NOT that a transcript ever existed. The
/// carve-out keys on disk observation, so ever_bound alone must not
/// count.
#[test]
fn ever_observed_on_disk_excludes_ledger_only_bindings() {
let dir = std::env::temp_dir().join(format!(
"ledger-everobs-disk-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let ledger =
std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some(dir.clone())));
// "Generation 1" bound this identity durably.
ledger
.record_binding(&freshell_ws::pane_ledger::BindingWrite {
provider: "claude",
session_id: "11111111-2222-3333-4444-555555555555",
terminal_id: "t1",
mode: "claude",
cwd: None,
create_request_id: None,
now_ms: 1_000,
})
.unwrap();

// "Generation 2": a brand-new probe with an EMPTY observed set —
// construct it exactly as main.rs does, over an index whose
// provider home is an empty temp dir (the transcript is gone).
let probe = new_test_probe_with_ledger(Some(std::sync::Arc::clone(&ledger)));
let session_id = "11111111-2222-3333-4444-555555555555";
assert!(probe.ever_observed("claude", session_id)); // via ledger — unchanged
assert!(!probe.ever_observed_on_disk("claude", session_id)); // NEW: ledger does not count
std::fs::remove_dir_all(&dir).ok();
}

/// A genuine on-disk observation (index snapshot or locator-fallback hit)
/// counts for BOTH ever_observed and ever_observed_on_disk.
#[tokio::test]
async fn ever_observed_on_disk_true_after_disk_observation() {
let home = temp_claude_home("disk-observed");
let session_id = "3c4d5e6f-7081-4a92-8b3c-4d5e6f708192";
write_zero_turn_session(&home, session_id);
let (probe, index) = probe_over(&home);
let probe = probe.with_claude_transcript_locator(direct_locator_over(&home));
index.warm().await;
assert_eq!(
probe.exists("claude", session_id),
SessionExistence::Present
);
assert!(probe.ever_observed_on_disk("claude", session_id));
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn unknown_provider_is_absent_never_unknown() {
let home = temp_claude_home("unknown-provider");
Expand Down
11 changes: 11 additions & 0 deletions crates/freshell-ws/src/existence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ pub trait SessionExistenceProbe: Send + Sync {
/// only raised for an identity disk has some memory of — a never-observed
/// (stale/typo) claim falls through to `fresh`.
fn ever_observed(&self, provider: &str, session_id: &str) -> bool;

/// "Seen on disk" strictly: true only if this process actually observed
/// the session artifact on disk (index snapshots, or the claude
/// locator-fallback hit). Unlike `ever_observed`, durable ledger bindings
/// do NOT count — a binding proves the identity was minted, not that a
/// transcript ever existed (PIN 1: the claude never-conversed carve-out
/// keys on this distinction). Default: delegate to `ever_observed`, which
/// is already disk-only for fakes without a ledger.
fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool {
self.ever_observed(provider, session_id)
}
}

/// The no-index fallback (mirrors `session_index: None` in
Expand Down
26 changes: 26 additions & 0 deletions crates/freshell-ws/src/pane_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,32 @@ impl PaneLedger {
self.write_binding(root, &mut index, &row)
}

/// Hard-delete one binding row (file first, then index — the mirror of
/// [`Self::delete_pending`]'s atomic delete; missing file == already
/// gone). PIN 2 (Step 4b): the ONLY caller is the spawn-failure branch
/// of a FRESH claude preallocation — its pre-spawn row describes a pane
/// that never existed, and left in place it would surface as a ghost
/// `ledgerOnly` recovery offer for ~30 days. Never used for resume
/// creates: their row belongs to the prior epoch and must stay
/// recoverable.
pub fn delete_binding(&self, provider: &str, session_id: &str) -> std::io::Result<()> {
let Some(root) = &self.root else {
return Ok(());
};
let mut index = self.guard();
let result = match std::fs::remove_file(Self::binding_path(root, provider, session_id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
};
if result.is_ok() {
index
.bindings
.remove(&(provider.to_string(), session_id.to_string()));
}
result
}

/// Raw single-row read from the index (no chain following — that is
/// `lookup_by_session`, Task 2). Memory-only (V1.md read policy).
pub fn load_binding(&self, provider: &str, session_id: &str) -> Option<BindingRow> {
Expand Down
37 changes: 37 additions & 0 deletions crates/freshell-ws/src/pane_ledger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,43 @@ fn delete_pending_is_a_noop_when_missing() {
std::fs::remove_dir_all(&root).ok();
}

#[test]
fn deleted_binding_row_is_gone_for_recovery_readers() {
// PIN 2 (Step 4b): a pre-spawn claude binding whose spawn then FAILED is
// deleted so it can never surface as a ghost `ledgerOnly` recovery offer.
// `list_bindings` is THE reader that feeds the recovery inventory
// (`recovery_inventory.rs` build_inventory), so "gone" is judged there.
let root = temp_root("del-binding");
let ledger = PaneLedger::new(Some(root.clone()));
ledger
.record_binding(&write("claude", "sess-failed", "t1", 1_000))
.expect("write ok");
assert!(ledger
.list_bindings()
.iter()
.any(|r| r.session_id == "sess-failed"));

ledger
.delete_binding("claude", "sess-failed")
.expect("delete ok");

// Gone for the recovery-inventory reader, the raw read, AND on disk
// (a construction-time rescan must not resurrect it).
assert!(!ledger
.list_bindings()
.iter()
.any(|r| r.session_id == "sess-failed"));
assert!(ledger.load_binding("claude", "sess-failed").is_none());
let gen2 = PaneLedger::new(Some(root.clone()));
assert!(gen2.load_binding("claude", "sess-failed").is_none());

// Idempotent: deleting a missing row is Ok (mirror of delete_pending).
ledger
.delete_binding("claude", "sess-failed")
.expect("missing row is Ok");
std::fs::remove_dir_all(&root).ok();
}

fn never_absent(_p: &str, _s: &str) -> bool {
false
}
Expand Down
181 changes: 181 additions & 0 deletions crates/freshell-ws/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,26 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi
if pane.mode.as_deref() == Some("shell") {
return base(pane, ReconcileVerdict::Fresh);
}
// §4.2 pending-marker read (PIN 3): a durable pending marker keyed by
// the client's stale terminal id means identity establishment was in
// flight when the server died — fresh by RACE, not by intent. The
// reason is distinct and surfaced (client breadcrumb). The read is
// deliberately IDEMPOTENT and read-only (amends §6 decision 5's
// consumption clause): the client re-sends pane.reconcile on every
// WS ready because a response can be dropped (App.tsx:1029-1053) —
// consuming the marker at derive would turn that retry into a
// silent no_recoverable_identity. Markers are still never promoted;
// locator resolution or the ledger's GC deletes them, and a
// recreated pane's new terminal id makes the old marker
// unreachable.
if let Some(tid) = pane.terminal_id.as_deref() {
if deps.pane_ledger.pending_for_terminal(tid).is_some() {
return PaneVerdict {
reason: Some("fresh_by_race".to_string()),
..base(pane, ReconcileVerdict::Fresh)
};
}
}
return PaneVerdict {
reason: Some("no_recoverable_identity".to_string()),
..base(pane, ReconcileVerdict::Fresh)
Expand Down Expand Up @@ -339,6 +359,50 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi
..base(pane, ReconcileVerdict::Respawn)
};
}
// Claude carve-out (never-conversed preallocation, PIN 1): claude
// terminals get a server-preallocated --session-id at create and
// the binding row is durable before the answer — but the
// transcript file only appears on first output. Ledger-bound +
// Absent + never SEEN on disk therefore means "created, never
// conversed", not dead: Respawn is the actionable verdict. The
// e2e fake accepts --resume and restores; REAL claude (verified
// v2.1.220) errors-and-exits on a not-found --resume id, which
// §7.5 respawn_exhausted converges to a loud DeadSession --
// never silent (a --session-id start-intent respawn was
// rejected: it would silently reuse a conversed-then-deleted
// id). Kata 09v1's locator fallback
// (freshell-server existence.rs) already converts
// exists-but-unparseable into Present => Respawn; this arm covers
// never-created — two halves of one rule, no overlap. A
// transcript that WAS observed on disk and is now gone falls
// through to the loud dead_session below (rows 4/4b hazard
// guard). Cross-restart deleted-with-prior-conversation is
// indistinguishable from never-conversed by these signals and
// takes the same escape the amplifier arm accepts: §7.5's
// respawn_exhausted convergence ends a respawn <-> instant-exit
// loop in an actionable dead_session, never thrash.
if sref.provider == "claude"
&& deps
.pane_ledger
.ever_bound(&sref.provider, &sref.session_id)
&& !deps
.existence
.ever_observed_on_disk(&sref.provider, &sref.session_id)
{
if deps.registry.respawn_exhausted(&key) {
return PaneVerdict {
session_ref: Some(sref),
reason: Some("respawn_exhausted".to_string()),
..base(pane, ReconcileVerdict::DeadSession)
};
}
let corrected = corrected_flag(pane.session_ref.as_ref(), Some(&sref));
return PaneVerdict {
session_ref: Some(sref),
corrected,
..base(pane, ReconcileVerdict::Respawn)
};
}
// dead_session is gated on the identity having been SEEN on disk
// at least once — never a data-loss-shaped verdict for an
// identity disk has no memory of (§5.3 rows 4/4b).
Expand Down Expand Up @@ -655,6 +719,62 @@ mod tests {
assert_eq!(v.session_ref, Some(sref("amplifier", "s-gcd")));
}

/// PIN 1 red test: a claude pane whose session id was preallocated at
/// create (ledger-bound, durable) but which NEVER conversed has no
/// transcript file -> Absent. That is not an immediately-dead state:
/// respawning with --resume mirrors the amplifier arm. Against the
/// wall's fake CLI the pane restores; REAL claude (verified v2.1.220)
/// errors-and-exits on a not-found id, which §7.5 respawn_exhausted
/// converges to a loud, actionable DeadSession -- never silent.
/// Kata 09v1's locator fallback covers
/// exists-but-unparseable; this covers never-created.
#[test]
fn claude_never_conversed_yields_respawn_not_dead_session() {
let f = Fixture::new();
f.ledger
.record_binding(&crate::pane_ledger::BindingWrite {
provider: "claude",
session_id: "s-never",
terminal_id: "T-never",
mode: "claude",
cwd: None,
create_request_id: Some("cr-never"),
now_ms: 1_000,
})
.expect("record binding");
let mut p = pane("cr-never");
p.session_ref = Some(sref("claude", "s-never"));
// Probe default: Absent, never observed on disk.
let v = f.one(p);
assert_eq!(v.verdict, ReconcileVerdict::Respawn);
assert_eq!(v.session_ref, Some(sref("claude", "s-never")));
}

/// PIN 1 hazard guard: a claude transcript that WAS seen on disk and is
/// now gone is a real data-loss shape — stays loud dead_session even
/// though the identity is ledger-bound (rows 4/4b unchanged).
#[test]
fn claude_deleted_after_conversation_stays_dead_session() {
let f = Fixture::new();
f.ledger
.record_binding(&crate::pane_ledger::BindingWrite {
provider: "claude",
session_id: "s-gone2",
terminal_id: "T-gone2",
mode: "claude",
cwd: None,
create_request_id: Some("cr-gone2"),
now_ms: 1_000,
})
.expect("record binding");
f.probe.mark_observed("claude", "s-gone2");
let mut p = pane("cr-gone2");
p.session_ref = Some(sref("claude", "s-gone2"));
let v = f.one(p);
assert_eq!(v.verdict, ReconcileVerdict::DeadSession);
assert_eq!(v.reason.as_deref(), Some("session_not_on_disk"));
}

/// Row 5 (§9.1 test 6): cold index on a known provider → honest
/// error{index_warming}, never dead_session, never optimistic respawn.
/// (`retry` is deleted from the wire; the handler's bounded single
Expand Down Expand Up @@ -722,6 +842,67 @@ mod tests {
assert_eq!(v.reason.as_deref(), Some("no_recoverable_identity"));
}

/// PIN 3 red test (§4.2 pending-marker read): identity establishment was
/// in flight when the server died (durable pending marker, keyed by the
/// dead epoch's terminal id) -> the verdict is fresh, but LOUD:
/// fresh_by_race, never a silent no_recoverable_identity.
#[test]
fn pending_marker_yields_fresh_by_race_not_silent_fresh() {
let f = Fixture::new();
f.ledger
.record_pending("T-race", "opencode", None, 1_000)
.expect("record pending");
let mut p = pane("cr-race");
p.mode = Some("opencode".to_string());
p.terminal_id = Some("T-race".to_string());
let v = f.one(p);
assert_eq!(v.verdict, ReconcileVerdict::Fresh);
assert_eq!(v.reason.as_deref(), Some("fresh_by_race"));
}

/// Delivery-safety pin (validated design change): the marker read is
/// IDEMPOTENT and read-only. The client re-sends pane.reconcile on
/// every WS ready precisely because a response can be dropped
/// (App.tsx:1029-1053); consuming the marker at derive would turn that
/// retry into a silent no_recoverable_identity — losing the breadcrumb
/// to the exact churn PIN 3 eliminates. A retry must derive the same
/// loud verdict, and the marker must still exist afterwards.
#[test]
fn marker_read_is_idempotent_across_reconciles() {
let f = Fixture::new();
f.ledger
.record_pending("T-race2", "opencode", None, 1_000)
.expect("record pending");
let mut p = pane("cr-race2");
p.mode = Some("opencode".to_string());
p.terminal_id = Some("T-race2".to_string());
let first = f.one(p);
assert_eq!(first.reason.as_deref(), Some("fresh_by_race"));
let mut p2 = pane("cr-race2");
p2.mode = Some("opencode".to_string());
p2.terminal_id = Some("T-race2".to_string());
let second = f.one(p2);
assert_eq!(second.verdict, ReconcileVerdict::Fresh);
assert_eq!(second.reason.as_deref(), Some("fresh_by_race"));
assert!(f.ledger.pending_for_terminal("T-race2").is_some());
}

/// Shell panes stay bare fresh even with a stray marker — the marker
/// read sits behind the shell early-return.
#[test]
fn shell_pane_ignores_pending_markers() {
let f = Fixture::new();
f.ledger
.record_pending("T-sh", "shell", None, 1_000)
.expect("record pending");
let mut p = pane("cr-sh");
p.mode = Some("shell".to_string());
p.terminal_id = Some("T-sh".to_string());
let v = f.one(p);
assert_eq!(v.verdict, ReconcileVerdict::Fresh);
assert_eq!(v.reason, None);
}

/// Row 10: malformed entries → invalid{reason}, never omission.
#[test]
fn row10_malformed_entries_yield_invalid_with_reasons() {
Expand Down
Loading
Loading