diff --git a/Cargo.lock b/Cargo.lock index 20eb279dc..613f6ff49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1362,6 +1362,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2", "tempfile", "tokio", "tokio-tungstenite 0.24.0", diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index b4700bfe2..d87e9bfdf 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -60,7 +60,7 @@ use freshell_protocol::{ FreshAgentSend, FreshAgentSendAccepted, ServerMessage, SessionType, }; -use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome}; +use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). const PROVIDER: &str = "claude"; @@ -97,6 +97,12 @@ pub struct FreshClaudeState { /// `resuming` analog, simplified: contenders return immediately instead of /// waiting -- the winner's frames broadcast to every client anyway). resuming: Arc>>, + /// P1.13 identity-event sink (the pane-ledger bridge, + /// [`crate::identity_sink`]). Clone-shared + set-once: the state is cloned + /// into consumer tasks, so the `OnceLock` sits behind an `Arc`. Wired + /// post-construction by `freshell-server` (precedent: + /// `TerminalRegistry::set_activity_observer`). + identity_sink: Arc>, } /// The cached result of a completed claude/kilroy `freshAgent.create`, keyed by @@ -142,9 +148,46 @@ impl FreshClaudeState { cli_index: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), resuming: Arc::new(TokioMutex::new(std::collections::HashSet::new())), + identity_sink: Arc::new(std::sync::OnceLock::new()), } } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). + pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { + let _ = self.identity_sink.set(sink); + } + + /// The wired identity sink, if any. + fn identity_sink(&self) -> Option { + self.identity_sink.get().cloned() + } + + /// Broadcast a `freshAgent.error` alarm/degradation frame (P1.13; Task 10 + /// consumes this too). Same envelope contract as codex's helper (`codex.rs`): + /// top-level `sessionType`/`provider` are REQUIRED (locator resolution) and + /// `message` is user-facing (the banner shows the message, never the code) — + /// but unlike codex this one cannot hardcode the session type: provider + /// `claude` covers BOTH `freshclaude` and `kilroy`, so the flavour is a param. + fn emit_fresh_agent_error( + &self, + session_id: &str, + session_type: &str, + code: &str, + message: &str, + ) { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.error", + "sessionId": session_id, + "code": code, + "message": message, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + })); + } + /// Reap every owned claude sidecar: SIGTERM the Node process (so it cleanly kills its /// own `claude` CLI via the SDK abort), SIGKILL any straggler, abort the consumer, and /// run the `/proc` ownership sweep for the grandchild. Called on server shutdown. @@ -213,6 +256,17 @@ impl FreshClaudeState { } }; + // P1.13: FULL settings snapshot for the binding row the consumer writes at + // `sdk.session.init` (claude has no sandbox concept — always `None`). Built + // from the SAME values the create request below actually sends. + let settings = crate::identity_sink::FreshAgentSettings { + model: msg.model.clone(), + sandbox: None, + permission_mode: msg.permission_mode.clone(), + effort: msg.effort.clone(), + cwd: msg.cwd.clone(), + }; + // Send the create request (faithful to createClaudeSdkOptions inputs). let create_req = json!({ "type": "create", @@ -244,11 +298,13 @@ impl FreshClaudeState { }; // Start the stdout consumer (the completion edge normalization lives here). + // `Some(settings)` => the consumer records a binding row at `sdk.session.init`. let consumer = self.spawn_consumer( reader, created.clone(), session_type.to_string(), created.clone(), + Some(settings), ); self.sessions.lock().await.insert( @@ -421,6 +477,17 @@ impl FreshClaudeState { )); } + /// Reconcile liveness probe (campaign §4.3, Task 13): resolve the DURABLE + /// claude UUID through [`Self::cli_index`] to its sessions-map key, then + /// check the map. The sessions map is read UNFILTERED — no session_type + /// filter, so kilroy sessions count for free (V2 N-A17-1). + pub async fn has_live_session(&self, session_id: &str) -> bool { + let Some(key) = self.cli_index.lock().await.get(session_id).cloned() else { + return false; + }; + self.sessions.lock().await.contains_key(&key) + } + // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): @@ -484,6 +551,30 @@ impl FreshClaudeState { let Some(transcript) = crate::claude_snapshot::locate_transcript(durable) else { return Err(ResumeClaudeError::NotFound); }; + // P1.13 (Task 10): recover the pane's recorded settings from the ledger so a + // resume-in-place does not silently revert model/permissionMode/effort. V7/A10 + // alarm gate: alarm ONLY when the ledger PROVES prior recording yet no snapshot + // is recoverable. Never-recorded transcripts (the entire pre-existing + // ~/.claude/projects population — resume_for_attach exists FOR them) stay silent. + let sink = self.identity_sink(); + let recovered = sink + .as_ref() + .and_then(|s| s.load_settings("claude", durable)); + if recovered.is_none() + && sink + .as_ref() + .is_some_and(|s| s.was_recorded("claude", durable)) + { + // Recorded before, unrecoverable now — the genuine anomaly. + tracing::warn!(session = %durable, "freshagent.claude.settings_record_unrecoverable"); + self.emit_fresh_agent_error( + durable, + session_type_str(msg.session_type), // preserves freshclaude vs kilroy in the frame + "SETTINGS_RESET", + "Session settings could not be recovered after restart - the agent is running with default model and permissions. Reconfirm your settings.", + ); + } + let rec = recovered.clone().unwrap_or_default(); // The CLI's resume lookup is scoped to the ORIGINAL cwd's project slug // (ledger A15) -- resume with the cwd recorded in the transcript itself. // If that directory is gone, fall back to path-based resume @@ -493,7 +584,12 @@ impl FreshClaudeState { .filter(|c| std::path::Path::new(c).is_dir()); let (resume_value, resume_cwd) = match original_cwd { Some(cwd) => (json!(durable), json!(cwd)), - None => (json!(transcript.to_string_lossy()), json!(msg.cwd)), + // Attach cwd stays primary; the ledger record's cwd is a FINAL + // fallback only when both existing sources are absent (Task 10). + None => ( + json!(transcript.to_string_lossy()), + json!(msg.cwd.clone().or_else(|| rec.cwd.clone())), + ), }; let (mut child, mut stdin, stdout, ownership_id) = spawn_sidecar() @@ -504,9 +600,11 @@ impl FreshClaudeState { "type": "create", "requestId": request_id, "cwd": resume_cwd, - "model": Value::Null, - "permissionMode": Value::Null, - "effort": Value::Null, + // Recovered from the ledger record (Task 10); `json!` serializes `None` + // as `null`, preserving today's fallback wire shape exactly on a miss. + "model": rec.model, + "permissionMode": rec.permission_mode, + "effort": rec.effort, "resumeSessionId": resume_value, }); if let Err(err) = write_line(&mut stdin, &create_req).await { @@ -528,13 +626,17 @@ impl FreshClaudeState { // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. - // 4-arg spawn_consumer: the sidecar's created id is the eviction identity - // guard for this consumer. + // The sidecar's created id is the eviction identity guard for this consumer. + // `settings` (P1.13, Task 10): `Some(rec)` re-records the row under any new + // cliSessionId (the old durable's row keeps serving `load_settings`, so later + // attaches with the old id still resolve — no repeat-fire, V7 §4); `None` + // records nothing (no laundered blank row under the new id, V7/A10). let consumer = self.spawn_consumer( reader, msg.session_id.clone(), session_type.clone(), sidecar_session_id.clone(), + recovered, ); self.sessions.lock().await.insert( msg.session_id.clone(), @@ -569,6 +671,7 @@ impl FreshClaudeState { actual_session_ref: None, expected_session_ref: None, request_id: request_id.clone(), + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, })); @@ -583,16 +686,25 @@ impl FreshClaudeState { /// `sidecar_session_id` is the id THIS consumer's sidecar is keyed by; it guards the /// eviction so a stale consumer can never evict a newer session re-registered under /// the same map key (attach-resume, Task 6). + /// + /// `settings` (P1.13): `Some` = record a fresh-agent binding row at + /// `sdk.session.init` (create path / resume-with-record); `None` = do NOT record + /// (resume of a never-recorded session — writing would launder a blank row under + /// the new cliSessionId, V7/A10). The row's `mode` is the `session_type` param + /// (the `"freshclaude"` vs `"kilroy"` flavour; provider is always [`PROVIDER`]). fn spawn_consumer( &self, mut reader: tokio::io::Lines>, session_id: String, session_type: String, sidecar_session_id: String, + settings: Option, ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); let sessions = self.sessions.clone(); let cli_index = self.cli_index.clone(); + let identity_sink = self.identity_sink(); + let state = self.clone(); tokio::spawn(async move { while let Ok(Some(line)) = reader.next_line().await { let trimmed = line.trim(); @@ -614,6 +726,43 @@ impl FreshClaudeState { if let Some(session) = sessions.lock().await.get_mut(&session_id) { session.cli_session_id = Some(cli_id.to_string()); } + // P1.13: binding row keyed by the DURABLE cliSessionId, with + // the FULL create-settings snapshot — AWAITED here (this arm + // runs on the async consumer task) so the row is durable + // BEFORE the init-driven broadcast below proceeds (V8/A11). + // A failed write is surfaced user-visibly, never + // warn-and-drop, then the identity event proceeds. + // No-laundering guard (V7/A10, parity with codex's + // `record_codex_binding`): never persist an all-blank + // snapshot — it would make `was_recorded` true while + // `load_settings` returns None (the server sink's + // blank-snapshot guard), arming a FALSE SETTINGS_RESET + // for a legitimately-default create on a later resume. + let recordable = settings + .as_ref() + .filter(|s| **s != crate::identity_sink::FreshAgentSettings::default()); + if let (Some(sink), Some(settings)) = (identity_sink.clone(), recordable) { + if let Err(e) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: cli_id.to_string(), + mode: session_type.clone(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + settings: settings.clone(), + }) + .await + { + tracing::warn!(error = %e, session = %cli_id, "freshagent.claude.binding_write_failed"); + state.emit_fresh_agent_error( + cli_id, + &session_type, + "LEDGER_WRITE_FAILED", + "Failed to persist this session's resume record - settings may not survive a server restart.", + ); + } + } } } if let Some(frame) = sdk_line_to_frame(&value, &session_id, &session_type) { @@ -1842,6 +1991,245 @@ rl.on('line', (line) => { drop(env); } + /// P1.13 (Task 9): the `sdk.session.init` arm must record ONE fresh-agent binding + /// row through the identity sink — keyed by the DURABLE cliSessionId, carrying the + /// FULL create-settings snapshot and the sessionType flavour — AWAITED before the + /// init-driven broadcast proceeds (durable-before-answer, V8/A11). + #[tokio::test(flavor = "multi_thread")] + async fn session_init_records_binding_with_create_settings() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + // Drive freshAgent.create with sessionType kilroy + explicit settings. + let mut msg = dedup_create_msg("req-binding-init"); + msg.session_type = SessionType::Kilroy; + msg.model = Some("opus-x".to_string()); + msg.permission_mode = Some("plan".to_string()); + msg.effort = Some("high".to_string()); + msg.cwd = Some(env.dir.to_string_lossy().to_string()); + state.handle_create(msg).await; + await_claude_created(&mut rx, "req-binding-init").await; + + // Wait for sdk.session.init to be consumed: the binding write is AWAITED + // before the init frame broadcasts, so seeing the freshAgent.session.init + // envelope proves the row already landed. + tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.session.init" { + break; + } + } + }) + .await + .expect("freshAgent.session.init consumed within budget"); + + let bindings = fake.bindings.lock().unwrap(); + let b = bindings.last().expect("binding at sdk.session.init"); + assert_eq!(b.provider, "claude"); + assert_eq!(b.mode, "kilroy", "sessionType flavour preserved in the row"); + assert_eq!( + b.session_id, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "keyed by cliSessionId" + ); + assert_eq!(b.settings.model.as_deref(), Some("opus-x")); + assert_eq!(b.settings.permission_mode.as_deref(), Some("plan")); + assert_eq!(b.settings.effort.as_deref(), Some("high")); + assert!(b.settings.cwd.is_some()); + } + + /// No-laundering guard (V7/A10, parity with codex's `record_codex_binding`): + /// a create carrying NO optional settings (model/permissionMode/effort/cwd all + /// None) must NOT persist an all-blank binding row at `sdk.session.init`. A blank + /// row makes `was_recorded` true while `load_settings` returns None (the server + /// sink's blank-snapshot guard) — the exact SETTINGS_RESET alarm condition — so a + /// legitimately-default session would false-alarm on a later resume. The init + /// frame itself still broadcasts; only the ledger write is skipped. + #[tokio::test(flavor = "multi_thread")] + async fn session_init_with_all_blank_settings_records_no_binding() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + // dedup_create_msg carries no model/permissionMode/effort/cwd — the + // all-blank snapshot shape. + state + .handle_create(dedup_create_msg("req-binding-blank")) + .await; + await_claude_created(&mut rx, "req-binding-blank").await; + + // The init frame still broadcasts (the skip affects ONLY the ledger write). + tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.session.init" { + break; + } + } + }) + .await + .expect("freshAgent.session.init consumed within budget"); + + assert!( + fake.bindings.lock().unwrap().is_empty(), + "an all-blank settings snapshot must not be persisted \ + (it would arm a false SETTINGS_RESET on resume)" + ); + drop(env); + } + + // ── resume settings-from-ledger (P1.13, Task 10) ───────────────────────────── + + /// P1.13 (Task 10): resume-in-place must reapply the pane's recorded + /// model/permissionMode/effort from the ledger record instead of sending Nulls — + /// the known defect where a restarted freshclaude/kilroy pane silently reverted + /// its settings. The fake sidecar logs the WHOLE create-request JSON per spawn, + /// so the wire itself is the assertion surface. + #[tokio::test(flavor = "multi_thread")] + async fn resume_for_attach_reapplies_settings_from_ledger() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + const DURABLE: &str = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (state, _rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed( + "claude", + DURABLE, + crate::identity_sink::FreshAgentSettings { + model: Some("opus-x".into()), + sandbox: None, + permission_mode: Some("plan".into()), + effort: Some("high".into()), + cwd: None, + }, + ); + state.set_identity_sink(fake); + + // Drive the donor resume flow (attach to DURABLE with a transcript on disk). + state + .handle_attach(attach_msg_with_resume("client-nanoid-settings", DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); + let create_req: serde_json::Value = + serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) + .unwrap(); + assert_eq!(create_req["model"], "opus-x"); + assert_eq!(create_req["permissionMode"], "plan"); + assert_eq!(create_req["effort"], "high"); + drop(env); + } + + /// V7/A10: record misses are ROUTINE — `resume_for_attach` exists precisely to + /// serve never-tracked transcripts (every claude-CLI-created and pre-ship session + /// in the shared `~/.claude/projects` store). They resume silently with nulls + /// exactly as today (the preserved fallback), and record NOTHING under the new + /// cliSessionId (settings: None ⇒ no laundered blank row — Task 9). + #[tokio::test(flavor = "multi_thread")] + async fn resume_without_record_is_silent_and_sends_nulls() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + const DURABLE: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (state, mut rx) = state_with_bus(); + // Deliberately empty: no record, no snapshot. + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + state + .handle_attach(attach_msg_with_resume("client-nanoid-norec", DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + // Today's behavior preserved as the silent fallback: nulls on the wire. + let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); + let create_req: serde_json::Value = + serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) + .unwrap(); + assert_eq!(create_req["model"], Value::Null); + assert_eq!(create_req["permissionMode"], Value::Null); + assert_eq!(create_req["effort"], Value::Null); + + // Bounded bus drain (pattern from Task 5): NO SETTINGS_RESET frame may appear. + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await + { + let Ok(text) = frame else { break }; + assert!( + !text.contains("SETTINGS_RESET"), + "never-recorded resume must stay silent" + ); + } + // No defaults laundering: no binding row was written under the new cliSessionId. + assert!( + fake.bindings.lock().unwrap().is_empty(), + "a load_settings miss must not write a blank row" + ); + drop(env); + } + + /// The genuine anomaly (V7/A10): the ledger PROVES prior fresh-agent recording, + /// yet no snapshot is recoverable — the only resume case that alarms. + #[tokio::test(flavor = "multi_thread")] + async fn resume_with_prior_record_but_unrecoverable_settings_alarms() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + const DURABLE: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + // was_recorded=true, load_settings=None — the SETTINGS_RESET-positive fixture. + fake.seed_recorded_only("claude", DURABLE); + state.set_identity_sink(fake); + + state + .handle_attach(attach_msg_with_resume("client-nanoid-alarm", DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + let mut found = false; + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await + { + let Ok(text) = frame else { break }; + let frame: Value = serde_json::from_str(&text).unwrap(); + if frame["event"]["code"] == "SETTINGS_RESET" { + // Top-level sessionType/provider (locator resolution) + a + // user-facing message (the banner shows the message, never the code). + assert_eq!(frame["event"]["type"], "freshAgent.error"); + assert_eq!(frame["sessionType"], "freshclaude"); + assert_eq!(frame["provider"], "claude"); + assert!(frame["event"]["message"] + .as_str() + .unwrap() + .contains("Reconfirm your settings")); + found = true; + break; + } + } + assert!( + found, + "recorded-but-unrecoverable resume must broadcast SETTINGS_RESET" + ); + drop(env); + } + /// Ledger A9: consumer exit (== sidecar death) must evict the dead session AND its /// `cli_index` entries — kill/shutdown are NOT the only eviction paths. Without this, /// a dead-but-tracked session makes the tracked⇒no-op attach row strand panes forever. diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 2e469fc4e..077356cdb 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -68,7 +68,7 @@ use freshell_protocol::{ FreshAgentSend, FreshAgentSessionMaterialized, ServerMessage, SessionLocator, }; -use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome}; +use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The codex fresh-agent `sessionType` (`AGENT_SESSION_TYPES.codex`). const SESSION_TYPE: &str = "freshcodex"; @@ -146,6 +146,12 @@ pub struct FreshCodexState { /// entries only on an explicit `freshAgent.kill` ([`Self::handle_kill`]); an /// unrequested sidecar exit does NOT evict (mirrors legacy, see the type doc). create_dedup: Arc>, + /// P1.13 identity-event sink (the pane-ledger bridge, + /// [`crate::identity_sink`]). Clone-shared + set-once: the state is cloned + /// into consumer tasks, so the `OnceLock` sits behind an `Arc`. Wired + /// post-construction by `freshell-server` (precedent: + /// `TerminalRegistry::set_activity_observer`). + identity_sink: Arc>, } /// The cached result of a completed codex `freshAgent.create`, keyed by `requestId` in @@ -272,9 +278,101 @@ impl FreshCodexState { dead_threads: Arc::new(TokioMutex::new(HashMap::new())), dead_thread_ttl: DEAD_THREAD_CACHE_TTL, create_dedup: Arc::new(FreshAgentCreateDedup::new()), + identity_sink: Arc::new(std::sync::OnceLock::new()), } } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). + pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { + let _ = self.identity_sink.set(sink); + } + + /// The wired identity sink, if any. + fn identity_sink(&self) -> Option { + self.identity_sink.get().cloned() + } + + /// P1.13: write one fresh-agent binding row (FULL settings snapshot) through the + /// identity sink. AWAITED at every identity site (the wave-A durable-before-answer + /// policy) BEFORE that site's reply/broadcast goes out; a failed write is surfaced + /// user-visibly ([`Self::emit_fresh_agent_error`], never warn-and-drop) and then the + /// identity event proceeds -- a write failure never blocks it. + #[allow(clippy::too_many_arguments)] + async fn record_codex_binding( + &self, + session_id: &str, + create_request_id: Option<&str>, + model: &str, + sandbox: Option<&str>, + permission_mode: Option<&str>, + effort: Option<&str>, + cwd: Option<&str>, + supersedes: Option<&str>, + ) { + let Some(sink) = self.identity_sink() else { + return; + }; + let settings = crate::identity_sink::FreshAgentSettings { + model: if model.is_empty() { + None + } else { + Some(model.into()) + }, + sandbox: sandbox.map(Into::into), + permission_mode: permission_mode.map(Into::into), + effort: effort.map(Into::into), + cwd: cwd.map(Into::into), + }; + // No-laundering guard (V7/A10): never persist an all-blank snapshot -- + // it would mask a genuine record miss forever. Real creates always carry + // at least cwd; a supersession write always goes through (G3 linkage). + if settings == crate::identity_sink::FreshAgentSettings::default() && supersedes.is_none() { + return; + } + if let Err(e) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: "codex".into(), + session_id: session_id.into(), + mode: "freshcodex".into(), + create_request_id: create_request_id.map(Into::into), + resolves_pending: None, + supersedes: supersedes.map(Into::into), + settings, + }) + .await + { + tracing::warn!(error = %e, session = %session_id, "freshagent.codex.ledger_write_failed"); + self.emit_fresh_agent_error( + session_id, + "LEDGER_WRITE_FAILED", + "Failed to persist this session's resume record - settings may not survive a server restart.", + ); + } + } + + /// Broadcast a `freshAgent.error` alarm/degradation frame (Tasks 5/6 consume this + /// too). Wire shape (V1/A2, verified against `fresh-agent-ws.ts:182-193`): + /// `{ "type": "freshAgent.event", "sessionId", "sessionType", "provider", + /// "event": { "type": "freshAgent.error", "code", "message" } }` -- built on the + /// SAME [`ServerMessage::FreshAgentEvent`] envelope [`lost_session_frame`] uses (the + /// existing `freshAgent.error` forwarding path), so it is byte-compatible with the + /// frozen client's banner path: top-level `sessionType`/`provider` are REQUIRED + /// (locator resolution) and `message` is user-facing (the banner shows the message, + /// never the code). + fn emit_fresh_agent_error(&self, session_id: &str, code: &str, message: &str) { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.error", + "sessionId": session_id, + "code": code, + "message": message, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: SESSION_TYPE.to_string(), + })); + } + /// The `PATCH /api/settings` sub-router (the fresh-clients enable toggle). pub fn settings_router(&self) -> Router { Router::new() @@ -353,11 +451,23 @@ impl FreshCodexState { FreshAgentCreateOutcome::Proceed(guard) => guard, }; - let cwd = msg.cwd.clone(); - let model = normalize_freshcodex_model(msg.model.as_deref()); - let effort = normalize_freshcodex_effort(Some(&model), msg.effort.as_deref()); - let sandbox = msg.sandbox.map(sandbox_wire_value); - let permission_mode = msg.permission_mode.clone(); + // P1.13 (Task 5, R1): for a resume-create the client's explicit params win and + // the ledger record fills the gaps -- merged BEFORE normalization, so a missing + // model recovers the recorded one instead of being rewritten to the default. + let rec = match msg.resume_session_id.as_deref() { + Some(resume_id) => self + .identity_sink() + .and_then(|s| s.load_settings("codex", resume_id)) + .unwrap_or_default(), + None => crate::identity_sink::FreshAgentSettings::default(), + }; + let cwd = msg.cwd.clone().or(rec.cwd); + let raw_model = msg.model.clone().or(rec.model); + let model = normalize_freshcodex_model(raw_model.as_deref()); + let raw_effort = msg.effort.clone().or(rec.effort); + let effort = normalize_freshcodex_effort(Some(&model), raw_effort.as_deref()); + let sandbox = msg.sandbox.map(sandbox_wire_value).or(rec.sandbox); + let permission_mode = msg.permission_mode.clone().or(rec.permission_mode); // Validate the effort maps to the codex wire vocabulary (adapter create calls // toCodexReasoningEffort purely to reject unsupported efforts before spawning). @@ -618,11 +728,11 @@ impl FreshCodexState { thread_id.clone(), CodexSession { client, - model, - effort, + model: model.clone(), + effort: effort.clone(), cwd: cwd.clone(), - sandbox, - permission_mode, + sandbox: sandbox.clone(), + permission_mode: permission_mode.clone(), active_turn, consumer, kill_tx: Some(kill_tx), @@ -631,6 +741,22 @@ impl FreshCodexState { }, ); + // P1.13 identity event (Task 4): the ledger binding row for this create, + // AWAITED before the `freshAgent.created` reply below goes out + // (durable-before-answer). Covers both the healthy create and the + // `handle_create_resume` (R1) path -- both funnel through this shared tail. + self.record_codex_binding( + &thread_id, + Some(&request_id), + &model, + sandbox.as_deref(), + permission_mode.as_deref(), + effort.as_deref(), + cwd.as_deref(), + None, + ) + .await; + // DIAG-01: fresh-agent session lifecycle -- provider/session_id/cwd, // never the turn text/prompt content. tracing::info!( @@ -817,6 +943,7 @@ impl FreshCodexState { actual_session_ref: None, expected_session_ref: None, request_id: request_id.clone(), + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, })); @@ -912,6 +1039,17 @@ impl FreshCodexState { // ── freshAgent.attach (reload-rehydrate, PR-4) ────────────────────────── + /// Reconcile liveness probe (campaign §4.3, Task 13): is this thread id + /// tracked in the sessions map with a sidecar that has NOT exited? The + /// exited check matters — a crashed sidecar stays mapped for lazy respawn + /// ([`Self::ensure_session_alive`]), but it is not attach-ably live. + pub async fn has_live_session(&self, session_id: &str) -> bool { + let guard = self.sessions.lock().await; + guard + .get(session_id) + .is_some_and(|s| !s.exited.load(Ordering::SeqCst)) + } + /// Handle a `freshAgent.attach` for codex (reload-rehydrate). Decision table: /// /// | State | Action | @@ -1065,7 +1203,7 @@ impl FreshCodexState { &self, session_id: &str, ) -> Result { - let (cwd, model, effort, sandbox, permission_mode) = { + let (cwd, session_model, session_effort, session_sandbox, session_permission_mode) = { let guard = self.sessions.lock().await; let session = guard.get(session_id).ok_or(EnsureAliveError::NotFound)?; if !session.exited.load(Ordering::SeqCst) { @@ -1080,6 +1218,29 @@ impl FreshCodexState { ) }; + // P1.13 (Task 5, R2): a blank stored model means this registration came from a + // settings-less resume (a record miss at R3 time) -- fall back to the ledger + // record so crash recovery doesn't silently respawn onto defaults. + let (model, sandbox, permission_mode, effort) = if session_model.is_empty() { + let rec = self + .identity_sink() + .and_then(|s| s.load_settings("codex", session_id)) + .unwrap_or_default(); + ( + rec.model.unwrap_or_default(), + rec.sandbox, + rec.permission_mode, + rec.effort, + ) + } else { + ( + session_model, + session_sandbox, + session_permission_mode, + session_effort, + ) + }; + // Single-flight: acquire (and possibly create) this session id's per-thread lock, // shared with `ensure_session_resumable`'s map so a concurrent historical-thread // resume and a crash recovery for the SAME id can't race either. @@ -1246,11 +1407,11 @@ impl FreshCodexState { session_id.to_string(), CodexSession { client, - model, - effort, - cwd, - sandbox, - permission_mode, + model: model.clone(), + effort: effort.clone(), + cwd: cwd.clone(), + sandbox: sandbox.clone(), + permission_mode: permission_mode.clone(), active_turn, consumer, kill_tx: Some(kill_tx), @@ -1260,6 +1421,22 @@ impl FreshCodexState { ); } + // P1.13 identity event (Task 4, R2): refresh write under the SAME id -- + // snapshots the LIVE in-session values (which originate from a real + // create/user change); the helper's no-laundering guard skips it if they + // are all blank. AWAITED before this fn returns (durable-before-answer). + self.record_codex_binding( + session_id, + None, + &model, + sandbox.as_deref(), + permission_mode.as_deref(), + effort.as_deref(), + cwd.as_deref(), + None, + ) + .await; + // FIX (CODEX-FIRST triage Finding 2): the app-server just proved this id alive again // -- clear any stale "recently gone" marking so it doesn't linger. self.clear_dead_thread(session_id).await; @@ -1333,11 +1510,11 @@ impl FreshCodexState { new_thread_id.clone(), CodexSession { client, - model, - effort, - cwd, - sandbox, - permission_mode, + model: model.clone(), + effort: effort.clone(), + cwd: cwd.clone(), + sandbox: sandbox.clone(), + permission_mode: permission_mode.clone(), active_turn, consumer, kill_tx: Some(kill_tx), @@ -1347,6 +1524,23 @@ impl FreshCodexState { ); } + // P1.13 identity event (Task 4): NEW ledger row under the new thread id with + // `supersedes: Some(old_thread_id)` -- the G3 supersession linkage (V8/A14): + // the ledger retires the old row and links it to the new one. This is the + // ONLY site that ever knows both ids; the edge is unrecoverable if not + // written here. AWAITED before the materialized broadcast below goes out. + self.record_codex_binding( + &new_thread_id, + None, + &model, + sandbox.as_deref(), + permission_mode.as_deref(), + effort.as_deref(), + cwd.as_deref(), + Some(old_session_id), + ) + .await; + // DIAG-01: crash recovery had to mint a fresh thread -- the durable // identity MOVED (old_session_id -> new_thread_id); conversation // memory for the old thread is lost. `warn`, unlike the resume-first @@ -1370,6 +1564,18 @@ impl FreshCodexState { }, )); + // P1.13 §2.6b (Task 6): memory loss must be user-visible, not server-log-only. + // Emitted AFTER the materialized broadcast above -- the frozen client re-keys + // its session state on `materialized` (`fresh-agent-ws.ts:143-160`); emitting + // first would target a session id the client no longer tracks. The non-`RESTORE_` + // error branch also clears streaming and forces `running -> idle` -- safe here, + // the turn is already dead. + self.emit_fresh_agent_error( + &new_thread_id, + "THREAD_MEMORY_LOST", + "Codex crashed and this pane was restarted as a new thread. The agent no longer has memory of the earlier conversation in this pane.", + ); + Ok(EnsureAliveOutcome::Respawned { new_session_id: new_thread_id, }) @@ -1715,14 +1921,41 @@ impl FreshCodexState { .await .map_err(ResumeSessionError::Transient)?; + // P1.13 (Task 5, R3): recover this thread's recorded settings snapshot BEFORE + // issuing `thread/resume`, gated per V7/A10. + let sink = self.identity_sink(); + let recovered = sink + .as_ref() + .and_then(|s| s.load_settings("codex", thread_id)); + // A record miss with NO prior recording (pre-ship / historical / sidebar-opened + // -- the populations this resume path exists to serve, see `snapshot_runtime_for`) + // is ROUTINE: resume silently with defaults exactly as before. NO alarm, NO + // defaults write (V7). + if recovered.is_none() + && sink + .as_ref() + .is_some_and(|s| s.was_recorded("codex", thread_id)) + { + // The ledger PROVES prior fresh-agent recording, yet nothing is + // recoverable -- the genuine "never-happens" anomaly. Alarm. + tracing::warn!(session = %thread_id, "freshagent.codex.settings_record_unrecoverable"); + self.emit_fresh_agent_error( + thread_id, + "SETTINGS_RESET", + "Session settings could not be recovered after restart - the agent is \ + running with default model and permissions. Reconfirm your settings.", + ); + } + let rec = recovered.clone().unwrap_or_default(); + let resume_result = client .resume_thread( thread_id, StartThreadParams { cwd: cwd.map(str::to_string), - model: None, - sandbox: None, - approval_policy: None, + model: rec.model.clone(), + sandbox: rec.sandbox.clone(), + approval_policy: rec.permission_mode.clone(), }, ) .await; @@ -1788,15 +2021,14 @@ impl FreshCodexState { thread_id.to_string(), CodexSession { client: client.clone(), - // Unknown until a `freshAgent.send` supplies one (matches the - // reference's `settingsByThread` being unpopulated for a thread this - // process has never created/sent to); `handle_send` overwrites these - // via its own `rememberThreadSettings`-equivalent path when it runs. - model: String::new(), - effort: None, - cwd: cwd.map(str::to_string), - sandbox: None, - permission_mode: None, + // P1.13 (Task 5, R3): the ledger record's settings snapshot -- blank + // only when no record was recoverable (never-recorded historical + // sessions resume on defaults, exactly as before this fix). + model: rec.model.clone().unwrap_or_default(), + effort: rec.effort.clone(), + cwd: cwd.map(str::to_string).or_else(|| rec.cwd.clone()), + sandbox: rec.sandbox.clone(), + permission_mode: rec.permission_mode.clone(), active_turn: active_turn.clone(), consumer, kill_tx: Some(kill_tx), @@ -1805,6 +2037,26 @@ impl FreshCodexState { }, ); } + + // P1.13 identity event (Task 5, R3): refresh write re-persisting the RECOVERED + // live values, `supersedes: None` -- GATED on an actual recovery. On a miss it + // must NOT run: writing would launder blank defaults into the ledger and + // permanently mask the miss (V7 §2's laundering finding). AWAITED before this + // fn returns (durable-before-answer). + if recovered.is_some() { + self.record_codex_binding( + thread_id, + None, + rec.model.as_deref().unwrap_or(""), + rec.sandbox.as_deref(), + rec.permission_mode.as_deref(), + rec.effort.as_deref(), + cwd.or(rec.cwd.as_deref()), + None, + ) + .await; + } + Ok(ResumedCodexSession { client, active_turn, @@ -3979,6 +4231,141 @@ pub(crate) mod tests { } } + /// Task 5 (R3): a restart/reload resume of a thread the ledger holds a full settings + /// record for must reapply model/sandbox/permission/effort -- on the registered + /// session AND on the `thread/resume` RPC itself (the wire is the contract). + #[tokio::test(flavor = "multi_thread")] + async fn resume_after_restart_reapplies_settings_from_ledger() { + let _guard = ENV_LOCK.lock().await; + let op_log = tempfile::NamedTempFile::new().unwrap(); + configure_fake_codex_cmd( + &json!({ "appendThreadOperationLogPath": op_log.path().to_str().unwrap() }).to_string(), + ); + let (state, _bus) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed( + "codex", + "thread-9", + crate::identity_sink::FreshAgentSettings { + model: Some("gpt-5.3-codex-spark".into()), + sandbox: Some("workspace-write".into()), + permission_mode: Some("on-request".into()), + effort: Some("high".into()), + cwd: Some("/w".into()), + }, + ); + state.set_identity_sink(fake.clone()); + + // Simulate the restart path exactly as the existing R3 tests above do + // (`handle_attach_unknown_session_*`): attach to "thread-9", which is NOT in + // the in-memory map. + state.handle_attach(attach_msg("thread-9")).await; + + // (1) The registered session carries the recovered settings: + let sessions = state.sessions.lock().await; + let s = sessions.get("thread-9").expect("session registered"); + assert_eq!(s.model, "gpt-5.3-codex-spark"); + assert_eq!(s.sandbox.as_deref(), Some("workspace-write")); + assert_eq!(s.permission_mode.as_deref(), Some("on-request")); + assert_eq!(s.effort.as_deref(), Some("high")); + drop(sessions); + + // The fake app-server's log write lands from a SEPARATE OS process (see the + // single-flight test above) -- poll briefly for it before the one real read. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !std::fs::read_to_string(op_log.path()) + .unwrap_or_default() + .contains("\"thread/resume\"") + { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("thread/resume reaches the fake app-server's op log"); + + // (2) The thread/resume RPC itself carried them (the wire is the contract): + let log = std::fs::read_to_string(op_log.path()).unwrap(); + let resume_line = log + .lines() + .find(|l| l.contains("\"thread/resume\"")) + .expect("thread/resume logged"); + let entry: serde_json::Value = serde_json::from_str(resume_line).unwrap(); + assert_eq!(entry["params"]["model"], "gpt-5.3-codex-spark"); + assert_eq!(entry["params"]["sandbox"], "workspace-write"); + } + + /// V7/A10: record misses are ROUTINE (pre-ship sessions, sidebar-opened historical + /// threads -- R3 exists FOR them, see `snapshot_runtime_for`'s doc). They must + /// resume silently with defaults exactly as today, and must NOT launder a defaults + /// row into the ledger. Permanent regression guard for V7's no-spam rule. + #[tokio::test(flavor = "multi_thread")] + async fn resume_of_a_never_recorded_session_is_silent_and_writes_no_defaults_row() { + let _guard = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + let (state, mut bus_rx) = state_with_bus(); + // Deliberately empty: no record, no snapshot. + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + // Same R3 entry point as above for an untracked "thread-x". + state.handle_attach(attach_msg("thread-x")).await; + + // Bounded drain: NO SETTINGS_RESET frame may appear. + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(1), bus_rx.recv()).await + { + let Ok(text) = frame else { break }; + assert!( + !text.contains("SETTINGS_RESET"), + "never-recorded resume must stay silent" + ); + } + // No defaults laundering (V7 §2): no binding row was written for thread-x. + assert!( + !fake + .bindings + .lock() + .unwrap() + .iter() + .any(|b| b.session_id == "thread-x"), + "a load_settings miss must not write a defaults row" + ); + } + + /// The genuine anomaly: the ledger PROVES prior fresh-agent recording, yet no + /// snapshot is recoverable -- the only case that alarms (V7/A10). + #[tokio::test(flavor = "multi_thread")] + async fn resume_with_a_prior_record_but_unrecoverable_settings_alarms_settings_reset() { + let _guard = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + let (state, mut bus_rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed_recorded_only("codex", "thread-y"); // was_recorded=true, load_settings=None + state.set_identity_sink(fake); + + // Same R3 entry point, attaching untracked "thread-y". + state.handle_attach(attach_msg("thread-y")).await; + + let mut found = false; + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), bus_rx.recv()).await + { + let Ok(text) = frame else { break }; + if text.contains("SETTINGS_RESET") { + // A2 qualification (V1): the frame must carry top-level + // sessionType/provider and a user-facing message. + assert!(text.contains("freshcodex") && text.contains("codex")); + assert!(text.contains("Reconfirm your settings")); + found = true; + break; + } + } + assert!( + found, + "recorded-but-unrecoverable resume must broadcast SETTINGS_RESET" + ); + } + /// REVIEW FIX (Minor, item 3): `fail_create`'s `freshAgent.create.failed` frame must /// carry `retryable: true`, matching legacy's hardcoded `retryable: true` on every /// create-failed path this port's `fail_create` corresponds to (`ws-handler.ts:3334` @@ -5066,6 +5453,144 @@ pub(crate) mod tests { assert_eq!(exited_frame["sessionId"], session_id); } + /// Task 4 (P1.13): a healthy create writes a fresh-agent binding row (provider + /// `codex`, mode `freshcodex`, FULL settings snapshot) through the identity sink + /// at thread/start -- the ledger row a restarted server later resumes from. + #[tokio::test(flavor = "multi_thread")] + async fn create_records_fresh_agent_binding_with_settings() { + let _guard = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + // Drive a real create through the fake app server, requesting explicit + // settings -- `create_real_fake_session` doesn't accept settings, so this + // inlines the same `freshAgent.create` the existing create tests send. + let tmp_cwd = std::env::temp_dir().to_string_lossy().to_string(); + state + .handle_create(FreshAgentCreate { + request_id: "req-bind-1".to_string(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: Some(freshell_protocol::AgentProvider::Codex), + cwd: Some(tmp_cwd), + legacy_restore_context: None, + resume_session_id: None, + session_ref: None, + model: Some("gpt-5.3-codex-spark".to_string()), + model_selection: None, + permission_mode: Some("on-request".to_string()), + sandbox: Some(freshell_protocol::Sandbox::WorkspaceWrite), + effort: Some("high".to_string()), + plugins: None, + }) + .await; + let created: Value = tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.created" + || frame["type"] == "freshAgent.create.failed" + { + return frame; + } + } + }) + .await + .expect("the fake app-server responds within the budget"); + assert_eq!( + created["type"], "freshAgent.created", + "fixture create failed: {created}" + ); + let thread_id = created["sessionId"].as_str().unwrap().to_string(); + + let bindings = fake.bindings.lock().unwrap(); + let b = bindings + .iter() + .find(|b| b.session_id == thread_id) + .expect("binding row written at thread/start"); + assert_eq!(b.provider, "codex"); + assert_eq!(b.mode, "freshcodex"); + assert_eq!(b.settings.model.as_deref(), Some("gpt-5.3-codex-spark")); + assert_eq!(b.settings.sandbox.as_deref(), Some("workspace-write")); + assert_eq!(b.settings.permission_mode.as_deref(), Some("on-request")); + assert_eq!(b.settings.effort.as_deref(), Some("high")); + } + + /// Task 4 (P1.13, awaited-writes policy): a failed ledger write is surfaced as a + /// live `freshAgent.error{code:'LEDGER_WRITE_FAILED'}` frame (never a silent + /// warn-and-drop) AND the create still succeeds -- a write failure never blocks + /// the identity event. + #[tokio::test(flavor = "multi_thread")] + async fn ledger_write_failure_is_surfaced_as_a_live_frame() { + let _guard = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.fail_writes + .store(true, std::sync::atomic::Ordering::SeqCst); + state.set_identity_sink(fake.clone()); + + state + .handle_create(FreshAgentCreate { + request_id: "req-ledger-fail".to_string(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: Some(freshell_protocol::AgentProvider::Codex), + cwd: None, + legacy_restore_context: None, + resume_session_id: None, + session_ref: None, + model: Some("gpt-5.3-codex-spark".to_string()), + model_selection: None, + permission_mode: None, + sandbox: None, + effort: None, + plugins: None, + }) + .await; + + // Drain the bus (bounded, as in the alarm tests): both the alarm frame and + // the created frame must arrive -- in either order. + let mut failure_frame: Option = None; + let mut created_frame: Option = None; + tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.event" + && frame["event"]["code"] == "LEDGER_WRITE_FAILED" + { + failure_frame = Some(frame); + } else if frame["type"] == "freshAgent.created" { + created_frame = Some(frame); + } + if failure_frame.is_some() && created_frame.is_some() { + return; + } + } + }) + .await + .expect("a LEDGER_WRITE_FAILED frame AND freshAgent.created arrive within the budget"); + + let failure = failure_frame.expect("a LEDGER_WRITE_FAILED frame was broadcast"); + assert_eq!(failure["sessionType"], "freshcodex"); + assert_eq!(failure["provider"], "codex"); + assert_eq!(failure["event"]["type"], "freshAgent.error"); + assert!( + failure["event"]["message"] + .as_str() + .is_some_and(|m| !m.is_empty()), + "the alarm carries a user-facing message: {failure}" + ); + + // The create still succeeded: the session exists under the created id. + let created = created_frame.expect("the create still succeeded"); + let thread_id = created["sessionId"].as_str().unwrap().to_string(); + let guard = state.sessions.lock().await; + assert!( + guard.contains_key(&thread_id), + "a write failure never blocks the identity event" + ); + } + /// FIX-2 (codex-first triage): crash recovery is resume-first now. The crashed /// sidecar respawns, `thread/resume`s the ORIGINAL thread id, and the turn completes /// under that SAME id -- no `freshAgent.session.materialized` broadcast (the durable @@ -5216,6 +5741,80 @@ pub(crate) mod tests { assert!(guard.contains_key(&new_thread_id)); } + /// P1.13 §2.6b (Task 6): crash respawn discards conversation memory -- that loss must be + /// user-visible. After the mint-new-thread fallback, a `THREAD_MEMORY_LOST` degradation + /// frame is broadcast under the NEW thread id, AFTER the + /// `freshAgent.session.materialized` frame (the frozen client re-keys its session state + /// on materialized; an error frame emitted first would target an id the client no + /// longer tracks). + #[tokio::test(flavor = "multi_thread")] + async fn send_after_crash_mint_new_thread_broadcasts_thread_memory_lost_after_materialized() { + let _guard = ENV_LOCK.lock().await; + let (st, mut rx) = state_with_bus(); + + configure_fake_codex_cmd( + r#"{"threadStartThreadId":"thread-original","exitProcessAfterMethodsOnce":["thread/start"]}"#, + ); + let thread_id = create_real_fake_session(&st, &mut rx).await; + wait_for_self_heal(&st, &mut rx, &thread_id).await; + + // The respawned sidecar's `thread/resume` reports the thread as genuinely gone, + // forcing the mint-new-thread crash-respawn fallback. + configure_fake_codex_cmd( + &json!({ + "threadStartThreadId": "thread-respawned", + "overrides": { + "thread/resume": { + "error": { "code": -32001, "message": "Thread not found" } + } + } + }) + .to_string(), + ); + + st.handle_send(FreshAgentSend { + request_id: Some("req-2".to_string()), + provider: freshell_protocol::AgentProvider::Codex, + session_id: thread_id.clone(), + session_type: freshell_protocol::SessionType::Freshcodex, + text: "hello again".to_string(), + images: None, + cwd: None, + settings: None, + }) + .await; + + let mut saw_materialized = false; + let mut new_thread_id = String::new(); + let mut degradation_after_materialized = false; + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await + { + let Ok(text) = frame else { break }; + if text.contains("freshAgent.session.materialized") { + saw_materialized = true; + let materialized: Value = serde_json::from_str(&text).unwrap(); + new_thread_id = materialized["sessionId"].as_str().unwrap().to_string(); + } + if text.contains("THREAD_MEMORY_LOST") { + assert!( + saw_materialized, + "degradation frame must follow materialized (client re-keys on it)" + ); + assert!( + text.contains(&new_thread_id), + "frame must target the NEW thread id" + ); + degradation_after_materialized = true; + break; + } + } + assert!( + degradation_after_materialized, + "crash respawn must broadcast a user-visible degradation frame" + ); + } + /// FIX-2: a resume failure that is NOT "thread not found" (a transient RPC error) must /// NOT silently mint a new thread -- it reports `CODEX_RESPAWN_FAILED` and leaves the /// session mapped under its OLD id, still marked exited, for a future retry (mirroring diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs new file mode 100644 index 000000000..db8640673 --- /dev/null +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -0,0 +1,197 @@ +//! P1.13 crate-boundary bridge: fresh-agent identity events flow OUT of this +//! crate through this trait; `freshell-server` implements it over the pane +//! ledger (this crate must not depend on `freshell-ws`, where the ledger +//! lives — the dependency edge runs the other way). + +use std::sync::Arc; + +/// Resume-invocation record (campaign plan §4.2): exactly what the +/// provider-native resume command needs. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FreshAgentSettings { + pub model: Option, + pub sandbox: Option, + pub permission_mode: Option, + pub effort: Option, + pub cwd: Option, +} + +/// One fresh-agent identity event. Settings are a FULL snapshot (replace, +/// not merge). `resolves_pending` names a pending marker (placeholder id) +/// this binding supersedes. +#[derive(Debug, Clone, PartialEq)] +pub struct FreshAgentBindingUpsert { + pub provider: String, + pub session_id: String, + pub mode: String, + pub create_request_id: Option, + pub resolves_pending: Option, + /// G3 supersession (V8/A14): OLD session id this binding replaces + /// (codex crash-respawn passes the old thread id; everyone else None). + pub supersedes: Option, + pub settings: FreshAgentSettings, +} + +/// Write-completion future (see Interfaces block for the style citation: +/// BoxFuture aliases at freshell-opencode/src/serve.rs:44 / +/// freshell-codex/src/app_server.rs:62; no async-trait dep in the workspace). +pub type SinkWrite = + std::pin::Pin> + Send + 'static>>; + +/// AWAITED writes (wave-A durable-before-answer policy, V8/A11): callers +/// `.await` the returned future before replying/broadcasting/proceeding. +/// Implementations run fsync work on `spawn_blocking` and propagate failures +/// as `Err` — call sites surface them user-visibly, then proceed (a write +/// failure never blocks the identity event). Reads are memory-fast + sync. +pub trait PaneIdentitySink: Send + Sync { + fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite; + fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite; + fn load_settings(&self, provider: &str, session_id: &str) -> Option; + /// True iff a fresh-agent binding row was EVER recorded for this key — + /// the SETTINGS_RESET alarm gate (V7/A10): alarm only when the ledger + /// proves prior recording; never for never-recorded sessions. + fn was_recorded(&self, provider: &str, session_id: &str) -> bool; +} + +pub type SharedPaneIdentitySink = Arc; + +/// In-memory sink for tests, crate-wide. Mutations happen synchronously +/// before the (already-completed) future is returned, so tests can assert +/// immediately after `.await`. +#[cfg(test)] +#[derive(Default)] +pub(crate) struct FakeIdentitySink { + pub pendings: std::sync::Mutex)>>, + pub bindings: std::sync::Mutex>, + pub settings: std::sync::Mutex>, + /// Keys ever recorded (or seeded) — backs `was_recorded`. + pub recorded: std::sync::Mutex>, + /// When true, write futures resolve to Err — for failure-surfacing tests. + pub fail_writes: std::sync::atomic::AtomicBool, +} + +#[cfg(test)] +impl FakeIdentitySink { + #[allow(dead_code)] // used by identity-event tasks (Tasks 4-10 tests) + pub fn seed(&self, provider: &str, session_id: &str, s: FreshAgentSettings) { + self.recorded + .lock() + .unwrap() + .insert((provider.into(), session_id.into())); + self.settings + .lock() + .unwrap() + .insert((provider.into(), session_id.into()), s); + } + /// Mark a key as previously recorded WITHOUT a recoverable snapshot — + /// the SETTINGS_RESET-alarm-positive fixture (V7/A10 gating). + #[allow(dead_code)] // used by identity-event tasks (Tasks 4-10 tests) + pub fn seed_recorded_only(&self, provider: &str, session_id: &str) { + self.recorded + .lock() + .unwrap() + .insert((provider.into(), session_id.into())); + } + fn write_result(&self) -> SinkWrite { + if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + Box::pin(std::future::ready(Err(std::io::Error::other( + "fake write failure", + )))) + } else { + Box::pin(std::future::ready(Ok(()))) + } + } +} + +#[cfg(test)] +impl PaneIdentitySink for FakeIdentitySink { + fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { + if !self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + self.pendings.lock().unwrap().push(( + placeholder_id.into(), + mode.into(), + cwd.map(Into::into), + )); + } + self.write_result() + } + fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { + if !self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + self.recorded + .lock() + .unwrap() + .insert((upsert.provider.clone(), upsert.session_id.clone())); + self.settings.lock().unwrap().insert( + (upsert.provider.clone(), upsert.session_id.clone()), + upsert.settings.clone(), + ); + self.bindings.lock().unwrap().push(upsert); + } + self.write_result() + } + fn load_settings(&self, provider: &str, session_id: &str) -> Option { + self.settings + .lock() + .unwrap() + .get(&(provider.into(), session_id.into())) + .cloned() + } + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { + self.recorded + .lock() + .unwrap() + .contains(&(provider.into(), session_id.into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn fake_sink_records_and_serves_settings() { + let fake = Arc::new(FakeIdentitySink::default()); + fake.record_pending("freshopencode-r1", "freshopencode", Some("/w")) + .await + .expect("pending write ok"); + fake.record_binding(FreshAgentBindingUpsert { + provider: "opencode".into(), + session_id: "ses_1".into(), + mode: "freshopencode".into(), + create_request_id: Some("r1".into()), + resolves_pending: Some("freshopencode-r1".into()), + supersedes: None, + settings: FreshAgentSettings { + model: Some("m".into()), + sandbox: None, + permission_mode: None, + effort: Some("low".into()), + cwd: Some("/w".into()), + }, + }) + .await + .expect("binding write ok"); + let s = fake.load_settings("opencode", "ses_1").expect("settings"); + assert_eq!(s.model.as_deref(), Some("m")); + assert_eq!(s.effort.as_deref(), Some("low")); + assert_eq!(fake.pendings.lock().unwrap().len(), 1); + assert_eq!(fake.bindings.lock().unwrap().len(), 1); + assert!(fake.load_settings("opencode", "nope").is_none()); + assert!(fake.was_recorded("opencode", "ses_1")); + assert!(!fake.was_recorded("opencode", "nope")); + } + + #[tokio::test] + async fn fake_sink_failure_knob_returns_err() { + let fake = Arc::new(FakeIdentitySink::default()); + fake.fail_writes + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!( + fake.record_pending("p", "freshopencode", None) + .await + .is_err(), + "failure must surface as Err, never be swallowed" + ); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index a0c1a406a..f9c9a9d51 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -39,6 +39,7 @@ pub mod claude; pub(crate) mod claude_snapshot; pub mod codex; +pub mod identity_sink; pub mod opencode_ws; pub mod pane_ops; pub mod snapshot; @@ -46,6 +47,10 @@ pub mod terminal_tabs; pub use claude::FreshClaudeState; pub use codex::FreshCodexState; +pub use identity_sink::{ + FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, SharedPaneIdentitySink, + SinkWrite, +}; pub use opencode_ws::FreshOpencodeState; pub use snapshot::SnapshotState; @@ -72,7 +77,8 @@ use freshell_opencode::{ ServeDeps, ServeError, }; use freshell_protocol::{ - FreshAgentSessionMaterialized, ServerMessage, SessionLocator, SessionsChanged, UiCommand, + FreshAgentEvent, FreshAgentSessionMaterialized, ServerMessage, SessionLocator, SessionsChanged, + UiCommand, }; /// The opencode fresh-agent `sessionType` (`AGENT_SESSION_TYPES.opencode`, `router.ts:541`). @@ -171,6 +177,11 @@ pub struct FreshAgentState { /// restore fix -- the SAME shared instance /// `freshell_ws::opencode_association::maybe_arm` arms. pub(crate) opencode_locator: Option>, + /// P1.13 identity-event sink (the pane-ledger bridge, [`identity_sink`]). + /// Clone-shared + set-once: the state is cloned into consumer tasks, so + /// the `OnceLock` sits behind an `Arc`. Wired post-construction by + /// `freshell-server` (precedent: `TerminalRegistry::set_activity_observer`). + identity_sink: Arc>, } /// A fresh-agent pane (the `paneContent` subset the opencode T2 path needs). @@ -235,9 +246,21 @@ impl FreshAgentState { cli_commands: Arc::new(Vec::new()), amplifier_locator: None, opencode_locator: None, + identity_sink: Arc::new(std::sync::OnceLock::new()), } } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). + pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { + let _ = self.identity_sink.set(sink); + } + + /// The wired identity sink, if any. Used by the REST send-keys + /// materialization site ([`send_keys`]'s cold-start block). + fn identity_sink(&self) -> Option { + self.identity_sink.get().cloned() + } + /// Record what a `restoreKey`-tagged create produced (continuity trio, /// `tabs_snapshots.rs:632`). Called by `terminal_tabs`'s create paths /// immediately after the tab/pane maps are populated, so a restore retry @@ -1496,6 +1519,58 @@ async fn send_keys( entry.durable_id = Some(durable_id.clone()); } + // P1.13: binding row at REST materialization (AWAITED BEFORE the materialized + // broadcast -- durable-before-answer), resolving the pane's placeholder id. + // Without this site, REST-created sessions (the e2e seeding surface) never get + // a resume record (V10 A13-N1). Opencode has no sandbox/permission concepts. + // No-laundering guard (V7/A10, parity with `record_codex_binding` and + // claude's consumer arm): never persist an all-blank settings snapshot -- + // it would make `was_recorded` true while `load_settings` returns None, + // arming a FALSE SETTINGS_RESET for a legitimately-default create. + let has_recordable_settings = + pane.model.is_some() || pane.effort.is_some() || pane.cwd.is_some(); + if !has_recordable_settings { + tracing::debug!( + session = %durable_id, + "freshagent.opencode.rest_binding_skipped: all-blank settings snapshot" + ); + } + if let (Some(sink), true) = (state.identity_sink(), has_recordable_settings) { + if let Err(e) = sink + .record_binding(identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: durable_id.clone(), + mode: SESSION_TYPE.into(), + create_request_id: None, + resolves_pending: Some(pane.placeholder_id.clone()), + supersedes: None, + settings: identity_sink::FreshAgentSettings { + model: pane.model.clone(), + sandbox: None, + permission_mode: None, + effort: pane.effort.clone(), + cwd: pane.cwd.clone(), + }, + }) + .await + { + tracing::warn!(error = %e, session = %durable_id, "freshagent.opencode.rest_binding_write_failed"); + // Same LEDGER_WRITE_FAILED frame shape the WS sites emit (top-level + // sessionType/provider + user-facing message), via state.broadcast. + state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.error", + "sessionId": durable_id, + "code": "LEDGER_WRITE_FAILED", + "message": "Failed to persist this session's resume record - settings may not survive a server restart.", + }), + provider: PROVIDER.to_string(), + session_id: durable_id.clone(), + session_type: SESSION_TYPE.to_string(), + })); + } + } + let session_ref = SessionLocator { provider: PROVIDER.to_string(), session_id: durable_id.clone(), @@ -2365,6 +2440,143 @@ mod tests { assert!(matches!(err, OpencodeSnapshotError::NotFound)); } + // -- P1.13 Task 7: REST send-keys materialization writes a binding row -- + + /// Like `opencode_ws::tests::FakeHttp`: `POST /session` mints `ses_1`; everything + /// else (health, prompt, status) answers a benign `{}`. + struct CreateCapableHttp; + impl ServeHttp for CreateCapableHttp { + fn request<'a>( + &'a self, + req: ServeHttpRequest, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + let is_create = matches!(req.method, freshell_opencode::serve::HttpMethod::Post) + && (req.url.ends_with("/session") || req.url.contains("/session?")); + let body = if is_create { + serde_json::to_vec(&json!({ "id": "ses_1", "directory": null })).unwrap() + } else { + b"{}".to_vec() + }; + Box::pin(async move { Ok(ServeHttpResponse::new(200, body)) }) + } + } + + /// The REST half of the Task 7 identity-event coverage (V10 A13-N1): a REST-created + /// pane (POST /api/tabs seeds model/effort) whose first `send-keys` cold-start + /// materializes the durable `ses_*` id must record a binding through the pane + /// ledger sink -- without this site, REST-created sessions never get a resume record. + #[tokio::test] + async fn rest_send_keys_materialization_records_binding() { + let st = state(); + let deps = ServeDeps { + spawner: Arc::new(NoopSpawner), + http: Arc::new(CreateCapableHttp), + ports: Arc::new(FakeAllocator), + events: Arc::new(NoopEventSource), + }; + let manager = OpencodeServeManager::new(deps, ServeConfig::default()); + manager + .ensure_started() + .await + .expect("healthy fake serve starts"); + st.set_manager_for_test(manager).await; + + let fake = Arc::new(identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + + // A REST-created pane carrying model/effort (what POST /api/tabs records). + st.panes.lock().expect("panes mutex").insert( + "pane-1".to_string(), + PaneEntry { + placeholder_id: "freshopencode-r1".to_string(), + cwd: Some("/w".to_string()), + model: Some("big-model".to_string()), + effort: Some("high".to_string()), + durable_id: None, + }, + ); + + // Drive the REST send-keys cold-start materialization. `timeout: 0` bounds the + // inline `run_turn` idle-wait (the fake never reports activity); the + // materialization + binding write happen BEFORE the turn is driven. + let mut headers = HeaderMap::new(); + headers.insert("x-auth-token", "tok".parse().unwrap()); + let _resp = send_keys( + State(st.clone()), + Path("pane-1".to_string()), + headers, + Json(json!({ "text": "hello", "timeout": 0 })), + ) + .await; + + let bindings = fake.bindings.lock().unwrap(); + let b = bindings + .iter() + .find(|b| b.session_id == "ses_1") + .expect("binding recorded at REST send-keys materialization"); + assert_eq!(b.provider, "opencode"); + assert_eq!(b.mode, "freshopencode"); + assert_eq!(b.resolves_pending.as_deref(), Some("freshopencode-r1")); + assert_eq!(b.settings.model.as_deref(), Some("big-model")); + assert_eq!(b.settings.effort.as_deref(), Some("high")); + assert_eq!(b.settings.cwd.as_deref(), Some("/w")); + } + + /// WAVE-B fast-follow (B4 lane review): the REST send-keys materialization + /// site gets the SAME no-laundering guard as `record_codex_binding` / + /// claude's consumer arm (V7/A10): never persist an all-blank settings + /// snapshot -- it would make `was_recorded` true while `load_settings` + /// returns None, arming a FALSE SETTINGS_RESET for a + /// legitimately-default create on a later resume. + #[tokio::test] + async fn rest_send_keys_materialization_skips_all_blank_settings() { + let st = state(); + let deps = ServeDeps { + spawner: Arc::new(NoopSpawner), + http: Arc::new(CreateCapableHttp), + ports: Arc::new(FakeAllocator), + events: Arc::new(NoopEventSource), + }; + let manager = OpencodeServeManager::new(deps, ServeConfig::default()); + manager + .ensure_started() + .await + .expect("healthy fake serve starts"); + st.set_manager_for_test(manager).await; + + let fake = Arc::new(identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + + // A REST-created pane with NO model/effort/cwd -- the all-blank shape. + st.panes.lock().expect("panes mutex").insert( + "pane-blank".to_string(), + PaneEntry { + placeholder_id: "freshopencode-b1".to_string(), + cwd: None, + model: None, + effort: None, + durable_id: None, + }, + ); + + let mut headers = HeaderMap::new(); + headers.insert("x-auth-token", "tok".parse().unwrap()); + let _resp = send_keys( + State(st.clone()), + Path("pane-blank".to_string()), + headers, + Json(json!({ "text": "hello", "timeout": 0 })), + ) + .await; + + assert!( + fake.bindings.lock().unwrap().is_empty(), + "an all-blank settings snapshot must never be persisted (V7/A10 no-laundering guard)" + ); + } + // -- Batch D PR-6: rich transcript items for the opencode snapshot endpoint -- #[test] diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index d3042eacb..490d5de04 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -69,12 +69,15 @@ use freshell_opencode::{ SdkProviderEvent, SessionSignal, SnapshotStatus, }; use freshell_protocol::{ - ErrorCode, ErrorMsg, FreshAgentAttach, FreshAgentCreate, FreshAgentCreated, FreshAgentEvent, - FreshAgentInterrupt, FreshAgentKill, FreshAgentKilled, FreshAgentSend, FreshAgentSendAccepted, - FreshAgentSessionMaterialized, ServerMessage, SessionLocator, + ErrorCode, ErrorMsg, FreshAgentAttach, FreshAgentCreate, FreshAgentCreateFailed, + FreshAgentCreated, FreshAgentEvent, FreshAgentInterrupt, FreshAgentKill, FreshAgentKilled, + FreshAgentSend, FreshAgentSendAccepted, FreshAgentSessionMaterialized, ServerMessage, + SessionLocator, }; -use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, FreshAgentState}; +use crate::{ + FreshAgentCreateDedup, FreshAgentCreateOutcome, FreshAgentState, SharedPaneIdentitySink, +}; /// The opencode fresh-agent `sessionType` (`AGENT_SESSION_TYPES.opencode`). const SESSION_TYPE: &str = "freshopencode"; @@ -102,6 +105,12 @@ pub struct FreshOpencodeState { /// [`OpencodeSession`] object. Cleared for a session's entries only on an explicit /// `freshAgent.kill` ([`Self::handle_kill`]). create_dedup: Arc>, + /// P1.13 identity-event sink (the pane-ledger bridge, + /// [`crate::identity_sink`]). Clone-shared + set-once: the state is cloned + /// into consumer tasks, so the `OnceLock` sits behind an `Arc`. Wired + /// post-construction by `freshell-server` (precedent: + /// `TerminalRegistry::set_activity_observer`). + identity_sink: Arc>, } /// The cached result of a completed opencode `freshAgent.create`, keyed by `requestId` in @@ -188,13 +197,59 @@ impl FreshOpencodeState { fresh_agent, sessions: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), + identity_sink: Arc::new(std::sync::OnceLock::new()), } } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). + pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { + let _ = self.identity_sink.set(sink); + } + + /// The wired identity sink, if any. + fn identity_sink(&self) -> Option { + self.identity_sink.get().cloned() + } + + /// Broadcast a `freshAgent.error` alarm/degradation frame (Task 8 consumes this + /// too). Same envelope contract as codex.rs's helper (verified against + /// `fresh-agent-ws.ts:182-193`): `{ "type": "freshAgent.event", "sessionId", + /// "sessionType", "provider", "event": { "type": "freshAgent.error", "code", + /// "message" } }` -- built on the SAME [`ServerMessage::FreshAgentEvent`] envelope + /// [`lost_session_frame`] uses, so it is byte-compatible with the frozen client's + /// banner path: top-level `sessionType`/`provider` are REQUIRED (locator + /// resolution) and `message` is user-facing (the banner shows the message, never + /// the code). + fn emit_fresh_agent_error(&self, session_id: &str, code: &str, message: &str) { + self.broadcast(&event_frame( + session_id, + json!({ + "type": "freshAgent.error", + "sessionId": session_id, + "code": code, + "message": message, + }), + )); + } + fn broadcast(&self, msg: &ServerMessage) { self.fresh_agent.broadcast(msg); } + /// Broadcast a `freshAgent.create.failed` frame (mirrors codex.rs's `fail_create`; + /// `ws-handler.ts:3388-3405`'s generic catch -- always `retryable: true`, + /// `ws-handler.ts:3403`). + fn fail_create(&self, request_id: &str, code: &str, message: &str) { + self.broadcast(&ServerMessage::FreshAgentCreateFailed( + FreshAgentCreateFailed { + code: code.to_string(), + message: message.to_string(), + request_id: request_id.to_string(), + retryable: Some(true), + }, + )); + } + fn send_error(&self, request_id: &Option, code: &str, message: &str) { self.broadcast(&ServerMessage::Error(ErrorMsg { code: ErrorCode::InternalError, @@ -203,6 +258,7 @@ impl FreshOpencodeState { actual_session_ref: None, expected_session_ref: None, request_id: request_id.clone(), + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, })); @@ -240,6 +296,23 @@ impl FreshOpencodeState { FreshAgentCreateOutcome::Proceed(guard) => guard, }; + // P1.13 (Task 8, V2/A4 -- THE P1.13 wall-pin mechanism): after a page reload + // the frozen client never sends `freshAgent.attach` -- its ONLY resume vehicle + // is `freshAgent.create{resumeSessionId: ses_*}` (persistMiddleware strips + // `sessionId`, gating both attach effects off). A create naming a durable + // `ses_*` id must REBIND that surviving session (mirroring codex/claude's + // resume-in-create), never mint a fresh `freshopencode-*` placeholder. + let resume_target = msg + .resume_session_id + .clone() + .or_else(|| msg.session_ref.as_ref().map(|r| r.session_id.clone())) + .filter(|id| id.starts_with("ses_")); + if let Some(durable_id) = resume_target { + self.handle_create_resume(request_id, durable_id, &msg) + .await; + return; + } + let model = normalize_opencode_model(msg.model.as_deref()); let effort = normalize_opencode_effort(model.as_deref(), msg.effort.as_deref()); let placeholder = format!("freshopencode-{request_id}"); @@ -262,6 +335,23 @@ impl FreshOpencodeState { ) .await; + // P1.13: pending marker (AWAITED before the created broadcast -- + // durable-before-answer). A failed write is surfaced user-visibly, never + // silently dropped, and never blocks the create. + if let Some(sink) = self.identity_sink() { + if let Err(e) = sink + .record_pending(&placeholder, SESSION_TYPE, msg.cwd.as_deref()) + .await + { + tracing::warn!(error = %e, placeholder = %placeholder, "freshagent.opencode.pending_write_failed"); + self.emit_fresh_agent_error( + &placeholder, + "LEDGER_WRITE_FAILED", + "Failed to persist this pane's identity marker - identity may not survive a crash.", + ); + } + } + self.broadcast(&ServerMessage::FreshAgentCreated(FreshAgentCreated { provider: PROVIDER.to_string(), request_id, @@ -277,6 +367,91 @@ impl FreshOpencodeState { // ── freshAgent.send (WS) — materialize-or-send ───────────────────────── + /// The resume branch of `handle_create` (P1.13 Task 8, V2/A4): rebind the + /// surviving durable `ses_*` session instead of minting a `freshopencode-*` + /// placeholder. Routes through the SAME resume machinery `freshAgent.attach` + /// uses ([`Self::resume_durable_session`], which applies settings-from-ledger + /// and the V7/A10 `SETTINGS_RESET` gate), then answers `freshAgent.created` + /// with the durable id so the frozen client ends up re-keyed to the `ses_*` + /// identity. Mirrors codex's `handle_create_resume`: a resume target that is + /// genuinely gone (or an unreachable sidecar) fails the create loudly + /// (`freshAgent.create.failed`) -- never a silently-minted fresh session, + /// never a `lost_session_frame` (that shape is exclusive to `freshAgent.attach`). + async fn handle_create_resume( + &self, + request_id: String, + durable_id: String, + msg: &FreshAgentCreate, + ) { + // Already tracked locally (a live pane, or an earlier attach/create already + // rebound it)? Reuse it -- mirrors handle_attach's local-map-first lookup. + let existing = { + let guard = self.sessions.lock().await; + guard.get(&durable_id).cloned() + }; + let session_arc = match existing { + Some(session_arc) => session_arc, + None => match self + .resume_durable_session(&durable_id, msg.cwd.as_deref()) + .await + { + Ok(session_arc) => session_arc, + Err(ResumeOpencodeError::NotFound) => { + self.fail_create( + &request_id, + "FRESH_AGENT_CREATE_FAILED", + &format!("opencode session {durable_id} not found"), + ); + return; + } + Err(ResumeOpencodeError::Manager(err)) => { + self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); + return; + } + }, + }; + + // Explicit client params on the create win over the ledger record (Task 5(d) + // precedence): merge msg over the resumed session's values BEFORE + // normalization, so an omitted param recovers the recorded value instead of + // being rewritten to the default. + { + let mut session = session_arc.lock().await; + let raw_model = msg.model.clone().or_else(|| session.model.clone()); + let model = normalize_opencode_model(raw_model.as_deref()); + let raw_effort = msg.effort.clone().or_else(|| session.effort.clone()); + let effort = normalize_opencode_effort(model.as_deref(), raw_effort.as_deref()); + session.model = model; + session.effort = effort; + if msg.cwd.is_some() { + session.cwd = msg.cwd.clone(); + } + } + + // requestId dedup cache: a duplicate create replays the DURABLE id (never a + // placeholder), keeping the reconnect-resend behavior intact. + self.create_dedup + .record_success( + &request_id, + OpencodeCreateRecord { + placeholder_id: durable_id.clone(), + }, + ) + .await; + + self.broadcast(&ServerMessage::FreshAgentCreated(FreshAgentCreated { + provider: PROVIDER.to_string(), + request_id, + runtime_provider: PROVIDER.to_string(), + session_id: durable_id.clone(), + session_type: SESSION_TYPE.to_string(), + session_ref: Some(SessionLocator { + provider: PROVIDER.to_string(), + session_id: durable_id, + }), + })); + } + /// Handle a `freshAgent.send` for opencode: `materializeOrSend` (`adapter.ts:324-361`). /// Creates the durable `ses_*` session ONLY if this session has not materialized yet /// (the continuity fix), broadcasts `freshAgent.session.materialized` exactly once, @@ -368,6 +543,37 @@ impl FreshOpencodeState { .await .insert(durable_id.clone(), session_arc.clone()); + // P1.13: binding row at materialization (AWAITED BEFORE the materialized + // broadcast -- durable-before-answer), resolving the create's pending + // marker. Opencode has no sandbox/permission concepts -- always `None`. + if let Some(sink) = self.identity_sink() { + if let Err(e) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: durable_id.clone(), + mode: SESSION_TYPE.into(), + create_request_id: request_id.clone(), + resolves_pending: Some(session.placeholder_id.clone()), + supersedes: None, + settings: crate::identity_sink::FreshAgentSettings { + model: session.model.clone(), + sandbox: None, + permission_mode: None, + effort: session.effort.clone(), + cwd: session.cwd.clone(), + }, + }) + .await + { + tracing::warn!(error = %e, session = %durable_id, "freshagent.opencode.binding_write_failed"); + self.emit_fresh_agent_error( + &durable_id, + "LEDGER_WRITE_FAILED", + "Failed to persist this session's resume record - settings may not survive a server restart.", + ); + } + } + // `freshAgent.session.materialized` (ws-handler.ts:3477-3484): placeholder -> // durable, emitted EXACTLY ONCE (a later send never re-enters this branch). self.broadcast(&ServerMessage::FreshAgentSessionMaterialized( @@ -396,6 +602,40 @@ impl FreshOpencodeState { session.model = model.clone(); session.effort = effort.clone(); + + // P1.13: settings-change refresh -- once durable, every send's committed + // model/effort re-snapshot the binding row (AWAITED BEFORE send.accepted -- + // durable-before-answer). No pending resolution or supersession here. + if acked_session_id.starts_with("ses_") { + if let Some(sink) = self.identity_sink() { + if let Err(e) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: acked_session_id.clone(), + mode: SESSION_TYPE.into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + settings: crate::identity_sink::FreshAgentSettings { + model: session.model.clone(), + sandbox: None, + permission_mode: None, + effort: session.effort.clone(), + cwd: session.cwd.clone(), + }, + }) + .await + { + tracing::warn!(error = %e, session = %acked_session_id, "freshagent.opencode.binding_write_failed"); + self.emit_fresh_agent_error( + &acked_session_id, + "LEDGER_WRITE_FAILED", + "Failed to persist this session's resume record - settings may not survive a server restart.", + ); + } + } + } + let real_id = acked_session_id.clone(); let route = session.cwd.clone(); let text = msg.text.clone(); @@ -460,6 +700,14 @@ impl FreshOpencodeState { session.turn_task = Some(turn_task); } + /// Reconcile liveness probe (campaign §4.3, Task 13): is this id tracked + /// in the sessions map? The map is keyed by BOTH the placeholder AND the + /// durable `ses_*` id (the `remember()` mirror), so a durable-id lookup + /// resolves the same record. + pub async fn has_live_session(&self, session_id: &str) -> bool { + self.sessions.lock().await.contains_key(session_id) + } + // ── freshAgent.kill (WS) ──────────────────────────────────────────────── /// Handle a `freshAgent.kill` for opencode: remove the session's bookkeeping (both @@ -675,10 +923,49 @@ impl FreshOpencodeState { } Err(err) => return Err(ResumeOpencodeError::Manager(err)), }; - let _ = info; + // P1.13 (Task 8): recover this session's recorded settings snapshot, gated + // per V7/A10 (same vocabulary + gating as codex.rs's Task 5 site). + let sink = self.identity_sink(); + let recovered = sink + .as_ref() + .and_then(|s| s.load_settings(PROVIDER, session_id)); + if recovered.is_none() + && sink + .as_ref() + .is_some_and(|s| s.was_recorded(PROVIDER, session_id)) + { + // Recorded before, unrecoverable now -- the genuine anomaly. Never-recorded + // sessions (pre-ship / serve-known-but-ledger-unknown, the ROUTINE attach + // population per this handler's own doc above) resume silently with defaults. + tracing::warn!(session = %session_id, "freshagent.opencode.settings_record_unrecoverable"); + self.emit_fresh_agent_error( + session_id, + "SETTINGS_RESET", + "Session settings could not be recovered after restart - the agent is running with default model and effort. Reconfirm your settings.", + ); + } + let rec = recovered.clone().unwrap_or_default(); + + // Stop discarding the serve body (the old `let _ = info;`): its `directory` + // is the session's REAL working directory -- a better cwd than the attach + // message's, though the ledger record's still wins. + let serve_dir = info + .get("directory") + .and_then(Value::as_str) + .filter(|d| !d.is_empty()) + .map(str::to_string); + let cwd = rec + .cwd + .clone() + .or(serve_dir) + .or_else(|| cwd.map(str::to_string)); - let mut session = - OpencodeSession::new(session_id.to_string(), cwd.map(str::to_string), None, None); + let mut session = OpencodeSession::new( + session_id.to_string(), + cwd.clone(), + rec.model.clone(), + rec.effort.clone(), + ); session.real_session_id = Some(session_id.to_string()); session.serve_bridge = Some(self.spawn_serve_bridge( manager, @@ -692,6 +979,39 @@ impl FreshOpencodeState { .await .insert(session_id.to_string(), session_arc.clone()); + // P1.13 (Task 8): refresh the binding row after a successful resume -- AWAITED + // (durable-before-answer), and ONLY when a record was actually recovered: never + // launder a defaults row for a never-recorded session (V7). + if recovered.is_some() { + if let Some(sink) = sink { + if let Err(e) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.to_string(), + mode: SESSION_TYPE.into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + settings: crate::identity_sink::FreshAgentSettings { + model: rec.model.clone(), + sandbox: None, + permission_mode: None, + effort: rec.effort.clone(), + cwd, + }, + }) + .await + { + tracing::warn!(error = %e, session = %session_id, "freshagent.opencode.binding_write_failed"); + self.emit_fresh_agent_error( + session_id, + "LEDGER_WRITE_FAILED", + "Failed to persist this session's resume record - settings may not survive a server restart.", + ); + } + } + } + Ok(session_arc) } @@ -1066,8 +1386,11 @@ mod tests { ])) .unwrap() } else { + // Like the real serve, `GET /session/:id` carries the session's own + // `directory` -- Task 8's resume path consumes it instead of + // discarding the body (`let _ = info;`). serde_json::to_vec( - &json!({ "id": id, "title": "materialized session", "time": { "updated": 5 } }), + &json!({ "id": id, "title": "materialized session", "time": { "updated": 5 }, "directory": "/serve/dir" }), ) .unwrap() }; @@ -1787,6 +2110,323 @@ mod tests { .contains("SESSION_NOT_FOUND")); } + // ── P1.13: identity-sink writes (pending at create, binding at materialization, + // refresh on settings change) ────────────────────────────────────────── + + #[tokio::test] + async fn materialization_resolves_pending_into_binding_with_settings() { + // Harness: same FakeHttp setup the existing materialization test uses. + let (state, _killed) = state().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + // Create with settings, then first send (materializes ses_*): + // freshAgent.create { requestId: "r1", sessionType: "freshopencode", + // cwd: "/w", model: "big-model", effort: "high" } + let mut create = create_msg("r1"); + create.cwd = Some("/w".to_string()); + create.model = Some("big-model".to_string()); + create.effort = Some("high".to_string()); + state.handle_create(create).await; + state + .handle_send(send_msg("freshopencode-r1", "hello")) + .await; + + // Pending was recorded at create under the placeholder: + let pendings = fake.pendings.lock().unwrap(); + assert!(pendings + .iter() + .any(|(id, mode, _)| id.starts_with("freshopencode-") && mode == "freshopencode")); + drop(pendings); + + // Binding recorded at materialization, resolving the pending: + let bindings = fake.bindings.lock().unwrap(); + let b = bindings + .iter() + .find(|b| b.session_id.starts_with("ses_")) + .expect("binding at materialization"); + assert_eq!(b.provider, "opencode"); + assert_eq!(b.settings.model.as_deref(), Some("big-model")); + assert_eq!(b.settings.effort.as_deref(), Some("high")); + assert!( + b.settings.cwd.is_some(), + "cwd captured (upgraded from created.directory)" + ); + assert!(b + .resolves_pending + .as_deref() + .unwrap_or("") + .starts_with("freshopencode-")); + } + + #[tokio::test] + async fn send_with_changed_settings_refreshes_the_binding() { + // Same harness; after materialization, send again with + // settings: { model: "small-model", effort: "low" } (FreshAgentSendSettings, + // consumed per-turn by handle_send's normalize block). + let (state, _killed) = state().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + let mut create = create_msg("r2"); + create.cwd = Some("/w".to_string()); + create.model = Some("big-model".to_string()); + create.effort = Some("high".to_string()); + state.handle_create(create).await; + let placeholder = "freshopencode-r2"; + state.handle_send(send_msg(placeholder, "first")).await; + + let mut second = send_msg(placeholder, "second"); + second.settings = Some(freshell_protocol::FreshAgentSendSettings { + cwd: None, + effort: Some("low".to_string()), + model: Some("small-model".to_string()), + permission_mode: None, + sandbox: None, + }); + state.handle_send(second).await; + + // Assert the LAST recorded binding for the ses_* id carries the new values: + let bindings = fake.bindings.lock().unwrap(); + let b = bindings + .iter() + .rev() + .find(|b| b.session_id.starts_with("ses_")) + .unwrap(); + assert_eq!(b.settings.model.as_deref(), Some("small-model")); + assert_eq!(b.settings.effort.as_deref(), Some("low")); + } + + // ── P1.13 Task 8: settings-from-ledger resume (attach + create-with-resume) ── + + /// The durable `ses_*` id [`RealisticServeHttp`] mints for its FIRST + /// `POST /session` -- the one durable serve session the Task 8 harness + /// pre-creates. + const DURABLE_ID: &str = "ses_1"; + + /// Task 8 harness: a [`RealisticServeHttp`]-backed state (same fakes as the + /// donor test `attach_unknown_session_resumes_a_durable_serve_session_not_in_the_local_map`) + /// with ONE durable serve session pre-created ([`DURABLE_ID`]) that the local WS + /// session map has never heard of, plus a bus receiver subscribed BEFORE any + /// handler runs. Each test wires its own identity-sink fixture. + async fn state_with_durable_serve_session( + ) -> (FreshOpencodeState, tokio::sync::broadcast::Receiver) { + let (tx, rx) = tokio::sync::broadcast::channel::(64); + let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + let deps = ServeDeps { + spawner: Arc::new(TrackedSpawner { + killed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }), + http: Arc::new(RealisticServeHttp::new()), + ports: Arc::new(FakeAllocator), + events: Arc::new(NoopEventSource), + }; + let manager = OpencodeServeManager::new(deps, ServeConfig::default()); + manager + .ensure_started() + .await + .expect("healthy fake serve starts"); + let created = manager + .create_session(None, None, None) + .await + .expect("create_session"); + assert_eq!( + created.id, DURABLE_ID, + "sanity: RealisticServeHttp mints ses_1 for its first create" + ); + fresh_agent.set_manager_for_test(manager).await; + let st = FreshOpencodeState::new(fresh_agent); + assert!( + !st.sessions.lock().await.contains_key(DURABLE_ID), + "not tracked locally yet" + ); + (st, rx) + } + + #[tokio::test] + async fn resume_durable_session_reapplies_settings_from_ledger() { + // Same RealisticServeHttp harness as the donor test. + let (state, _rx) = state_with_durable_serve_session().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed( + "opencode", + DURABLE_ID, + crate::identity_sink::FreshAgentSettings { + model: Some("big-model".into()), + sandbox: None, + permission_mode: None, + effort: Some("high".into()), + cwd: Some("/real/project".into()), + }, + ); + state.set_identity_sink(fake); + + // Drive the same attach the donor test drives -- with a COMPETING cwd on + // the attach message, so the cwd assertion below proves precedence rather + // than absence. + let mut attach = attach_msg(DURABLE_ID); + attach.cwd = Some("/attach/cwd".to_string()); + state.handle_attach(attach).await; + + let sessions = state.sessions.lock().await; + let s = sessions.get(DURABLE_ID).expect("resumed").lock().await; + assert_eq!(s.model.as_deref(), Some("big-model")); + assert_eq!(s.effort.as_deref(), Some("high")); + assert_eq!( + s.cwd.as_deref(), + Some("/real/project"), + "cwd from the record, not the attach message" + ); + } + + #[tokio::test] + async fn resume_without_record_is_silent_and_uses_serve_directory() { + // Same harness; NO seed (never-recorded session -- the ROUTINE case, V7: + // handle_attach's own doc describes this attach population). + let (state, mut rx) = state_with_durable_serve_session().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + state.handle_attach(attach_msg(DURABLE_ID)).await; + + // RealisticServeHttp's GET /session/:id directory is now used instead of + // being discarded. + { + let sessions = state.sessions.lock().await; + let s = sessions.get(DURABLE_ID).expect("resumed").lock().await; + assert_eq!( + s.cwd.as_deref(), + Some("/serve/dir"), + "the serve GET /session/:id body's directory must be used, not discarded" + ); + } + + // NO SETTINGS_RESET frame was broadcast (bounded bus drain -- Task 5 pattern). + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await + { + let Ok(text) = frame else { break }; + assert!( + !text.contains("SETTINGS_RESET"), + "never-recorded resume must stay silent" + ); + } + + // NO refresh binding was written for the session (no defaults laundering). + assert!( + !fake + .bindings + .lock() + .unwrap() + .iter() + .any(|b| b.session_id == DURABLE_ID), + "a load_settings miss must not write a defaults row" + ); + } + + #[tokio::test] + async fn resume_with_prior_record_but_unrecoverable_settings_alarms() { + // fake.seed_recorded_only("opencode", DURABLE_ID) -- was_recorded=true, + // load_settings=None. The genuine anomaly: the only case that alarms (V7/A10). + let (state, mut rx) = state_with_durable_serve_session().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed_recorded_only("opencode", DURABLE_ID); + state.set_identity_sink(fake); + + state.handle_attach(attach_msg(DURABLE_ID)).await; + + let mut found = false; + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await + { + let Ok(text) = frame else { break }; + if text.contains("SETTINGS_RESET") { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + // Top-level sessionType/provider (locator resolution) + a + // user-facing message (the banner shows the message, not the code). + assert_eq!(frame["sessionType"], "freshopencode"); + assert_eq!(frame["provider"], "opencode"); + assert_eq!(frame["event"]["code"], "SETTINGS_RESET"); + assert!(frame["event"]["message"] + .as_str() + .unwrap() + .contains("Reconfirm your settings")); + found = true; + break; + } + } + assert!( + found, + "recorded-but-unrecoverable resume must broadcast SETTINGS_RESET" + ); + } + + #[tokio::test] + async fn create_with_resume_session_id_rebinds_the_durable_session() { + // V2/A4: the frozen client's ONLY post-reload resume vehicle is + // freshAgent.create{resumeSessionId: ses_*} -- donor shape: codex's + // handle_create_with_resume_session_id_resumes_the_same_thread. + let (state, mut rx) = state_with_durable_serve_session().await; + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + fake.seed( + "opencode", + DURABLE_ID, + crate::identity_sink::FreshAgentSettings { + model: Some("big-model".into()), + sandbox: None, + permission_mode: None, + effort: Some("high".into()), + cwd: Some("/real/project".into()), + }, + ); + state.set_identity_sink(fake); + + let mut create = create_msg("req-resume-oc"); + create.resume_session_id = Some(DURABLE_ID.to_string()); + state.handle_create(create).await; + + let sessions = state.sessions.lock().await; + assert!( + sessions.contains_key(DURABLE_ID), + "rebound to the surviving ses_*" + ); + let s = sessions.get(DURABLE_ID).unwrap().lock().await; + assert_eq!( + s.model.as_deref(), + Some("big-model"), + "settings-from-ledger applied on the create path" + ); + drop(s); + drop(sessions); + + // And the FreshAgentCreated broadcast answered with the ses_* id (not a + // freshopencode-* placeholder) -- capture it via the bus receiver. + let mut created_frame: Option = None; + while let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await + { + let Ok(text) = frame else { break }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame["type"] == "freshAgent.created" { + created_frame = Some(frame); + break; + } + } + let created = created_frame.expect("a freshAgent.created frame was broadcast"); + assert_eq!( + created["sessionId"], DURABLE_ID, + "created must answer with the durable ses_* id, not a placeholder" + ); + assert_eq!(created["sessionRef"]["sessionId"], DURABLE_ID); + assert!( + !created["sessionId"] + .as_str() + .unwrap() + .starts_with("freshopencode-"), + "never a freshopencode-* placeholder on a resume-create" + ); + } + // ── PR-3: serve-stream bridge (status / turn.complete gating) ───────── /// Build a [`FreshOpencodeState`] on top of [`state_with_status_poll`], returning it diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 7f411e9b1..c6e5ae314 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -359,7 +359,9 @@ pub struct ReconcilePane { /// client omitted it (the entry is then `invalid`). #[serde(default)] pub pane_key: String, - /// v1: `"terminal"` only (fresh-agent extension deferred, §12). + /// v1: `"terminal"`; `"fresh-agent"` is answered on connections that + /// negotiated `paneReconcileFreshAgentV1` (campaign §4.3) — otherwise it + /// keeps the frozen-client `invalid{unsupported_kind}` contract. #[serde(default, skip_serializing_if = "Option::is_none")] pub kind: Option, /// `TerminalMode` string as persisted (`"shell"`, `"claude"`, …). diff --git a/crates/freshell-protocol/src/common.rs b/crates/freshell-protocol/src/common.rs index 5bfeb584a..58a6bb199 100644 --- a/crates/freshell-protocol/src/common.rs +++ b/crates/freshell-protocol/src/common.rs @@ -94,6 +94,11 @@ pub enum ErrorCode { /// failed (poisoned lock, index handle error). The client keeps its state /// and may re-send. ReconcileUnavailable, + /// D8 per-sessionRef single-flight (council rules 6/7/8): another create + /// for the same sessionRef is in flight on a `paneReconcileV1`-negotiated + /// connection. The loser retries after the frame's `retryAfterMs` hint — + /// by then it either adopts the winner's terminal or wins the next claim. + SessionReserved, } /// The coding-agent providers (`claude | codex | opencode | amplifier`). diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index e16b8776c..bcc3dde30 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -512,6 +512,11 @@ pub struct ErrorMsg { pub expected_session_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, + /// D8 (`SESSION_RESERVED` only): how long the loser should wait before + /// re-sending its create. Additive and omitted everywhere else, so every + /// other error frame stays byte-identical on the wire. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub terminal_exit_code: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -688,8 +693,10 @@ pub enum ReconcileVerdict { Respawn, Fresh, DeadSession, - Retry, Invalid, + /// Terminal per-pane error state (replaces the deleted `retry`): + /// reason is one of "index_warming" | "provider_unavailable". + Error, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -708,12 +715,9 @@ pub struct PaneVerdict { /// Present iff the server overrode a differing client claim. #[serde(skip_serializing_if = "Option::is_none")] pub corrected: Option, - /// fresh / dead_session / retry / invalid: machine-readable code. + /// fresh / dead_session / error / invalid: machine-readable code. #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - /// retry only. - #[serde(skip_serializing_if = "Option::is_none")] - pub retry_after_ms: Option, /// Row 2b (invariant I6): a newer duplicate generation exists for the same /// `createRequestId`; the client stays on its live attachment and this /// merely flags the duplicate `terminalId`. @@ -757,6 +761,11 @@ pub struct Pong { pub struct ReadyCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub pane_reconcile_v1: Option, + /// Fresh-agent restart resilience: `Some(true)` iff the connection's + /// `hello` opted in via `capabilities.paneReconcileFreshAgentV1` — + /// omitted from the wire entirely otherwise (frozen-client inertness). + #[serde(skip_serializing_if = "Option::is_none")] + pub pane_reconcile_fresh_agent_v1: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/tests/pane_reconcile.rs b/crates/freshell-protocol/tests/pane_reconcile.rs index d2d9701a9..84c05cf85 100644 --- a/crates/freshell-protocol/tests/pane_reconcile.rs +++ b/crates/freshell-protocol/tests/pane_reconcile.rs @@ -74,6 +74,7 @@ fn ready_capabilities_advertise_pane_reconcile_v1_when_negotiated() { server_instance_id: Some("srv-1".to_string()), capabilities: Some(ReadyCapabilities { pane_reconcile_v1: Some(true), + pane_reconcile_fresh_agent_v1: None, }), }; let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes"); @@ -164,7 +165,6 @@ fn reconcile_result_serializes_verdicts_with_optional_fields_omitted() { }), corrected: Some(true), reason: None, - retry_after_ms: None, duplicate: None, }, PaneVerdict { @@ -174,7 +174,6 @@ fn reconcile_result_serializes_verdicts_with_optional_fields_omitted() { session_ref: None, corrected: None, reason: Some("no_recoverable_identity".to_string()), - retry_after_ms: None, duplicate: None, }, ], @@ -201,7 +200,7 @@ fn reconcile_verdict_wire_names_are_snake_case() { (ReconcileVerdict::Respawn, "respawn"), (ReconcileVerdict::Fresh, "fresh"), (ReconcileVerdict::DeadSession, "dead_session"), - (ReconcileVerdict::Retry, "retry"), + (ReconcileVerdict::Error, "error"), (ReconcileVerdict::Invalid, "invalid"), ] { assert_eq!(serde_json::to_value(verdict).unwrap(), json!(name)); @@ -209,20 +208,23 @@ fn reconcile_verdict_wire_names_are_snake_case() { } #[test] -fn retry_verdict_carries_retry_after_ms() { +fn error_verdict_carries_reason_and_no_retry_after_ms() { + // `retry` is deleted from the wire: the terminal per-pane error state + // replaces it and carries only a machine-readable reason — never a + // `retryAfterMs` cadence hint. let verdict = PaneVerdict { pane_key: "p".to_string(), - verdict: ReconcileVerdict::Retry, + verdict: ReconcileVerdict::Error, terminal_id: None, session_ref: None, corrected: None, reason: Some("index_warming".to_string()), - retry_after_ms: Some(2000), duplicate: None, }; let wire = serde_json::to_value(&verdict).expect("serializes"); - assert_eq!(wire["retryAfterMs"], 2000); + assert_eq!(wire["verdict"], "error"); assert_eq!(wire["reason"], "index_warming"); + assert!(wire.get("retryAfterMs").is_none()); } // --- error codes -------------------------------------------------------------- diff --git a/crates/freshell-server/Cargo.toml b/crates/freshell-server/Cargo.toml index 9f3458602..679d428c8 100644 --- a/crates/freshell-server/Cargo.toml +++ b/crates/freshell-server/Cargo.toml @@ -66,6 +66,10 @@ dotenvy = "0.15.7" # `createHash('sha1')` (`fresh-agent-extras-router.ts:76`) -- not a security # use of SHA-1, just a deterministic per-cwd bucket name. sha1 = "0.10" +# B3/P1.9 `recovery_inventory.rs`: the 16-hex-char `contentId` digest over the +# timestamp-free inventory substance (same sha2 the tabs-persist digest +# convention uses, `crates/freshell-ws/src/tabs_persist.rs:82-87`). +sha2 = "0.10" # DIAG-01/DIAG-03: structured JSONL logging. `tracing` is the event/span API; # `tracing-subscriber`'s `registry` (default feature) gives the `Layer` + # `LookupSpan` machinery `logging.rs`'s custom formatter builds on, and diff --git a/crates/freshell-server/src/existence.rs b/crates/freshell-server/src/existence.rs index 4494657eb..ed15366ac 100644 --- a/crates/freshell-server/src/existence.rs +++ b/crates/freshell-server/src/existence.rs @@ -15,7 +15,8 @@ //! feeds a monotone observed-set, so "disk has seen this identity at least //! once (this boot)" survives the session later disappearing from disk. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use freshell_sessions::directory_index::SessionIndex; @@ -33,17 +34,25 @@ pub struct IndexExistenceProbe { /// memory — survives restarts, so a transcript deleted while the server /// was down yields loud dead_session, not silent fresh. ledger: Option>, + /// Each known provider's session root on THIS machine (the same paths + /// `main.rs` hands the index sources). A known provider whose root does + /// not exist will never warm up — the cold-index answer for it is + /// `ProviderUnavailable`, not the deferrable `Unknown`. A provider with + /// no entry keeps the plain `Unknown` cold answer. + provider_roots: HashMap, } impl IndexExistenceProbe { pub fn new( index: Arc, ledger: Option>, + provider_roots: HashMap, ) -> Self { Self { index, observed: Mutex::new(HashSet::new()), ledger, + provider_roots, } } @@ -78,7 +87,19 @@ impl SessionExistenceProbe for IndexExistenceProbe { self.kick_refresh(); } match self.index.peek() { - None => SessionExistence::Unknown, + None => { + // Cold index: a known provider whose session root does not + // exist on this machine will NEVER warm up — that's an + // immediate, honest provider_unavailable, not index_warming. + if self + .provider_roots + .get(provider) + .is_some_and(|root| !root.exists()) + { + return SessionExistence::ProviderUnavailable; + } + SessionExistence::Unknown + } Some(items) => { self.record_observed(&items); let hit = items @@ -152,7 +173,14 @@ mod tests { Duration::from_millis(50), None, // no persistent parse-cache — fully isolated temp home )); - (IndexExistenceProbe::new(Arc::clone(&index), None), index) + ( + IndexExistenceProbe::new( + Arc::clone(&index), + None, + HashMap::from([("claude".to_string(), home.to_path_buf())]), + ), + index, + ) } /// Construct a probe exactly as `main.rs` does — over an index whose @@ -164,11 +192,11 @@ mod tests { ) -> IndexExistenceProbe { let home = temp_claude_home("with-ledger"); let index = Arc::new(SessionIndex::with_ttl_and_cache_path( - vec![Arc::new(ClaudeSource::new(home)) as Arc], + vec![Arc::new(ClaudeSource::new(home.clone())) as Arc], Duration::from_millis(50), None, )); - IndexExistenceProbe::new(index, ledger) + IndexExistenceProbe::new(index, ledger, HashMap::from([("claude".to_string(), home)])) } #[test] @@ -240,6 +268,40 @@ mod tests { let _ = std::fs::remove_dir_all(&home); } + /// A known provider whose session root does NOT exist on this machine + /// will never warm up — the probe answers `ProviderUnavailable`, not the + /// deferrable `Unknown`. + #[tokio::test] + async fn missing_provider_root_is_provider_unavailable_not_unknown() { + let home = temp_claude_home("root-missing"); + let gone = home.join("never-created-claude-root"); + let index = Arc::new(SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(ClaudeSource::new(gone.clone())) as Arc], + Duration::from_millis(50), + None, + )); + let probe = IndexExistenceProbe::new( + index, + None, + std::collections::HashMap::from([("claude".to_string(), gone)]), + ); + assert_eq!( + probe.exists("claude", "s-any"), + SessionExistence::ProviderUnavailable + ); + let _ = std::fs::remove_dir_all(&home); + } + + /// The counterpart boundary: the root EXISTS but the index is still cold + /// → `Unknown` (warming), unchanged by the ProviderUnavailable check. + #[tokio::test] + async fn existing_but_cold_provider_root_stays_unknown() { + let home = temp_claude_home("root-cold"); + let (probe, _index) = probe_over(&home); + assert_eq!(probe.exists("claude", "s-cold"), SessionExistence::Unknown); + let _ = std::fs::remove_dir_all(&home); + } + /// §9.1 test 13 — real-index staleness: a `provider:sessionId` written to /// disk AFTER a cold read must resolve `Present` on re-query; a stale /// `Absent` must never latch. diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs new file mode 100644 index 000000000..5ce4d46a6 --- /dev/null +++ b/crates/freshell-server/src/identity_sink.rs @@ -0,0 +1,157 @@ +//! Server-side implementation of the fresh-agent identity bridge (P1.13): +//! freshell-freshagent cannot see the ledger (crate cycle), so main.rs +//! injects this adapter at wiring time. + +use freshell_freshagent::{ + FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, SinkWrite, +}; +use freshell_ws::pane_ledger::{FreshAgentBindingWrite, PaneLedger}; +use std::sync::Arc; + +pub struct LedgerIdentitySink { + ledger: Arc, +} + +impl LedgerIdentitySink { + pub fn new(ledger: Arc) -> Self { + Self { ledger } + } +} + +fn now_ms() -> i64 { + // Match the timestamp convention main.rs already uses for ledger writes + // (see the boot-scan / record_binding call sites in main.rs). + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +impl PaneIdentitySink for LedgerIdentitySink { + // Writes are AWAITED spawn_blocking (durable-before-answer, V8/A11) — + // exactly the shape terminal.rs:1589 already ships. Timestamps are taken + // at EVENT time (before the hop), so `updated_at` reflects event order + // (V8's out-of-order aggravator). + fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, m, c) = ( + placeholder_id.to_string(), + mode.to_string(), + cwd.map(str::to_string), + ); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || ledger.record_pending(&p, &m, c.as_deref(), now)) + .await + .map_err(std::io::Error::other)? // JoinError (incl. closure panic) surfaces as Err + }) + } + + fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { + let ledger = self.ledger.clone(); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let w = FreshAgentBindingWrite { + provider: &upsert.provider, + session_id: &upsert.session_id, + mode: &upsert.mode, + cwd: upsert.settings.cwd.as_deref(), + create_request_id: upsert.create_request_id.as_deref(), + model: upsert.settings.model.as_deref(), + sandbox: upsert.settings.sandbox.as_deref(), + permission_mode: upsert.settings.permission_mode.as_deref(), + effort: upsert.settings.effort.as_deref(), + supersedes: upsert.supersedes.as_deref(), + now_ms: now, + }; + ledger.record_fresh_agent_binding(&w)?; // binding-write failure propagates + if let Some(p) = upsert.resolves_pending.as_deref() { + // Cosmetic on failure: an orphaned marker is TTL-swept at 30d + // (V6/A15) — warn, do not fail the identity event over it. + if let Err(e) = ledger.delete_pending(p) { + tracing::warn!(error = %e, placeholder = %p, "pane_ledger.fresh_agent.pending_delete_failed"); + } + } + Ok(()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + + fn load_settings(&self, provider: &str, session_id: &str) -> Option { + // Reads are memory-only against the write-through index — safe inline. + let row = self.ledger.load_binding(provider, session_id)?; + // Terminal-lineage rows (wave-A codex_candidate etc.) are NOT resume + // records — only fresh-agent rows serve settings (V7/A10 gating). + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + let s = FreshAgentSettings { + model: row.model, + sandbox: row.sandbox, + permission_mode: row.permission_mode, + effort: row.effort, + cwd: row.cwd, + }; + // A fully blank snapshot is "nothing recoverable" (real creates always + // carry at least cwd): None here + was_recorded()==true is exactly the + // SETTINGS_RESET alarm condition (V7/A10). + if s == FreshAgentSettings::default() { + return None; + } + Some(s) + } + + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { + // State-agnostic (load_binding serves Retired/GcExpired rows too, V6/A9). + self.ledger + .load_binding(provider, session_id) + .map(|r| r.pane_kind.as_deref() == Some("fresh-agent")) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use freshell_freshagent::{FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink}; + use std::sync::Arc; + + #[tokio::test(flavor = "multi_thread")] + async fn writes_through_to_ledger_and_reads_back() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))); + let sink = LedgerIdentitySink::new(ledger.clone()); + sink.record_binding(FreshAgentBindingUpsert { + provider: "codex".into(), + session_id: "t1".into(), + mode: "freshcodex".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + settings: FreshAgentSettings { + model: Some("gpt-5.3-codex-spark".into()), + sandbox: Some("workspace-write".into()), + permission_mode: Some("on-request".into()), + effort: None, + cwd: Some("/w".into()), + }, + }) + .await + .expect("awaited write succeeds"); + // Awaited write => durable and readable IMMEDIATELY, no polling. + let s = sink + .load_settings("codex", "t1") + .expect("binding visible after await"); + assert_eq!(s.model.as_deref(), Some("gpt-5.3-codex-spark")); + assert_eq!(s.sandbox.as_deref(), Some("workspace-write")); + assert!(sink.was_recorded("codex", "t1")); + assert!(!sink.was_recorded("codex", "nope")); + let row = ledger.load_binding("codex", "t1").unwrap(); + assert_eq!(row.pane_kind.as_deref(), Some("fresh-agent")); + } +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 3457cffc7..b2607c799 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -22,11 +22,13 @@ mod diag; mod existence; mod extensions; mod files; +mod identity_sink; mod instance_id; mod logging; mod network; mod proxy; mod rate_limit; +mod recovery_inventory; mod screenshots; mod serve_client; mod session_directory; @@ -340,6 +342,12 @@ async fn main() -> ExitCode { freshell_sessions::parse::default_opencode_data_home(), ), )); + // Lane B2 (campaign §2.3.2): server-side codex identity locator. Same + // sessions root the resume-time rollout locator below walks. `None` + // when HOME/CODEX_HOME are unresolvable — every codex_association + // entry point no-ops in that case. + let codex_locator = freshell_ws::codex_sessions_root() + .map(|root| std::sync::Arc::new(freshell_sessions::codex_locator::CodexLocator::new(root))); // Slice 3a (docs/plans/2026-07-18-agent-api-mcp-parity-spec.md): wire the // SAME locators + coding-CLI command specs `ws_state` (below) gets into // `fresh_agent_state` too, so `POST /api/tabs` terminal-mode creates (a) @@ -427,11 +435,24 @@ async fn main() -> ExitCode { home.as_ref() .map(|h| h.join(".freshell").join("pane-ledger")), )); + // P1.13: inject the ledger-backed identity sink into the fresh-agent + // states (constructed earlier, before the ledger exists — the + // post-construction setter exists precisely for this ordering). All + // clones of each state share the `Arc` field, so this covers + // every route's clone. + let fresh_agent_identity_sink: freshell_freshagent::SharedPaneIdentitySink = + std::sync::Arc::new(identity_sink::LedgerIdentitySink::new(pane_ledger.clone())); + fresh_codex_state.set_identity_sink(fresh_agent_identity_sink.clone()); + fresh_claude_state.set_identity_sink(fresh_agent_identity_sink.clone()); + fresh_opencode_state.set_identity_sink(fresh_agent_identity_sink.clone()); + // opencode REST surface (Task 7's materialization site; V10 A13-N1) + fresh_agent_state.set_identity_sink(fresh_agent_identity_sink.clone()); let ws_state = WsState { activity: Some(activity_hub.clone()), identity: terminal_identity.clone(), amplifier_locator: amplifier_locator.clone(), opencode_locator: opencode_locator.clone(), + codex_locator: codex_locator.clone(), // Reconciliation handshake disk-truth probe (design §5.1): backed by // the SAME shared session index the History surfaces read; the // no-index fallback (honest `Unknown` on known providers) when no @@ -444,9 +465,35 @@ async fn main() -> ExitCode { // transcript deleted while the server was DOWN still derives // loud dead_session (per-boot observed set is empty then). Some(std::sync::Arc::clone(&pane_ledger)), + // Provider session roots resolved with the SAME helpers the + // `session_index` sources above use — a known provider whose + // root does not exist on this machine derives an immediate + // `error{provider_unavailable}`, never `index_warming`. + session_directory::provider_home() + .map(|h| { + std::collections::HashMap::from([ + ("claude".to_string(), session_directory::claude_home(&h)), + ("codex".to_string(), session_directory::codex_home(&h)), + ( + "opencode".to_string(), + freshell_sessions::parse::default_opencode_data_home(), + ), + ( + "amplifier".to_string(), + freshell_sessions::amplifier::amplifier_home(&h), + ), + ]) + }) + .unwrap_or_default(), )), None => std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), }, + // §5.3 row 5: the ONE bounded index-warming deferral's budget + // (council-pinned single deferral, default 2000ms). + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + // Per-boot fresh-agent respawn-answer counter (campaign §4.3, V2/A7): + // in-memory by design — a restart intentionally resets it. + fresh_agent_respawn_counts: Default::default(), auth_token: Arc::clone(&auth_token), // Shared (not moved) so `GET /api/health` reports the SAME `instanceId`. server_instance_id: Arc::clone(&server_instance_id), @@ -638,6 +685,13 @@ async fn main() -> ExitCode { AMPLIFIER_LOCATOR_SWEEP_INTERVAL, ); } + // Lane B2: codex locator sweep — same cadence as the sibling sweeps. + if codex_locator.is_some() { + freshell_ws::codex_association::spawn_codex_locator_sweep( + ws_state.clone(), + AMPLIFIER_LOCATOR_SWEEP_INTERVAL, + ); + } // DIAG-05: the diag router's `sessionsProjects` reads the SAME session // index (clone before the move below into `session_directory_state`). let diag_session_index = session_index.clone(); @@ -787,12 +841,22 @@ async fn main() -> ExitCode { snapshots_dir: home .as_ref() .map(|h| h.join(".freshell").join("tabs-snapshots")), - fresh_agent: fresh_agent_state.clone(), - screenshots: screenshots.clone(), - terminals: registry.clone(), // the SAME TerminalRegistry from main.rs:246 - restore_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), - restore_ack_timeout: std::time::Duration::from_secs(5), })) + // B3/P1.9 Task 2: the recovery-inventory read surface. Joins the SAME + // tabs-snapshots store as `tabs_snapshots` above (read-only), the + // pane-identity ledger (`:427`), and the shared terminal registry + // (`:249`, the D7 liveness join). + .merge(recovery_inventory::router( + recovery_inventory::RecoveryInventoryState { + auth_token: auth_token.as_ref().clone(), + snapshots_dir: home + .as_ref() + .map(|h| h.join(".freshell").join("tabs-snapshots")), + ledger: std::sync::Arc::clone(&pane_ledger), + registry: registry.clone(), + identity: terminal_identity.clone(), + }, + )) .merge(network::router(network_state)) .merge(session_directory::router(session_directory_state)) .merge(sessions::router(sessions::SessionsState { diff --git a/crates/freshell-server/src/recovery_inventory.rs b/crates/freshell-server/src/recovery_inventory.rs new file mode 100644 index 000000000..d24f0c9bb --- /dev/null +++ b/crates/freshell-server/src/recovery_inventory.rs @@ -0,0 +1,507 @@ +//! B3/P1.9 Task 1 — the PURE recovery-inventory builder: joins tabs-snapshot +//! device unions with pane-ledger binding rows into the `/api/recovery` +//! inventory shape. No I/O here — Task 2 (the HTTP route) feeds it from the +//! snapshot store, the ledger, and the terminal registry, and consumes +//! `select_foreign_recent_generation_ids` when composing each device's union. + +use freshell_ws::pane_ledger::{BindingRow, RetiredReason, RowState}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +pub struct DeviceUnion { + pub device_id: String, + pub union_doc: Value, +} + +const STALE_CLIENT_MS: u64 = 15 * 60 * 1000; // heartbeat cadence is 5 min (tabRegistrySync.ts:21, 475-477) + +/// A15 staleness + A16 concurrent-client rules (D2): drop the requester's own +/// generations; drop clients ALL of whose retained generations postdate +/// `boot_cutoff_ms` (a client born after this browser session booted is a +/// concurrently-opened fresh window, never lost data — a lost client's pushes +/// all predate the fresh boot, so retention depth cannot misclassify it); then +/// drop clients whose newest generation is >15 min older than the device max +/// over the REMAINING clients (junk must never stale-out real recovery data). +pub fn select_foreign_recent_generation_ids( + generations: &[Value], + exclude_client: &str, + boot_cutoff_ms: u64, +) -> Vec { + let foreign: Vec<&Value> = generations + .iter() + .filter(|g| g["clientInstanceId"].as_str() != Some(exclude_client)) + .collect(); + let mut oldest_by_client: HashMap<&str, u64> = HashMap::new(); + let mut newest_by_client: HashMap<&str, u64> = HashMap::new(); + for g in &foreign { + let c = g["clientInstanceId"].as_str().unwrap_or(""); + let t = g["capturedAt"].as_u64().unwrap_or(0); + let o = oldest_by_client.entry(c).or_insert(u64::MAX); + if t < *o { + *o = t; + } + let e = newest_by_client.entry(c).or_insert(0); + if t > *e { + *e = t; + } + } + let pre_boot = |c: &str| oldest_by_client.get(c).copied().unwrap_or(u64::MAX) < boot_cutoff_ms; + let device_max = newest_by_client + .iter() + .filter(|(c, _)| pre_boot(c)) + .map(|(_, t)| *t) + .max() + .unwrap_or(0); + foreign + .iter() + .filter(|g| { + let c = g["clientInstanceId"].as_str().unwrap_or(""); + pre_boot(c) + && newest_by_client.get(c).copied().unwrap_or(0) + STALE_CLIENT_MS >= device_max + }) + .filter_map(|g| g["generationId"].as_str().map(String::from)) + .collect() +} + +fn ref_key(provider: &str, session_id: &str) -> String { + format!("{provider}\u{1}{session_id}") +} + +enum Verdict { + Bound(String, String), + Closed, + GcExpired, + Unknown, +} + +/// Resolve a snapshot's sessionRef claim to its EFFECTIVE identity per D4 by +/// walking the ledger's superseded chain (bounded — a cycle degrades to +/// `GcExpired`, never loops). +fn resolve(provider: &str, session_id: &str, by_key: &HashMap) -> Verdict { + let (mut p, mut s) = (provider.to_string(), session_id.to_string()); + for _ in 0..10 { + match by_key.get(&ref_key(&p, &s)) { + None => { + return if (p.as_str(), s.as_str()) == (provider, session_id) { + Verdict::Unknown + } else { + Verdict::GcExpired + } + } + Some(row) if row_is_bound(row) => { + return Verdict::Bound(row_provider(row), row_session_id(row)) + } + Some(row) => match row_successor(row) { + Some((np, ns)) => { + p = np; + s = ns; + } + None => { + return if row_reason_is_closed(row) { + Verdict::Closed + } else { + Verdict::GcExpired + } + } + }, + } + } + Verdict::GcExpired +} + +pub fn build_inventory( + device_unions: Vec, + bindings: Vec, + live_session_keys: HashSet<(String, String)>, +) -> Value { + let by_key: HashMap = bindings + .iter() + .map(|r| (ref_key(&row_provider(r), &row_session_id(r)), r)) + .collect(); + let is_live = |p: &str, s: &str| live_session_keys.contains(&(p.to_string(), s.to_string())); + + // sort newest-first; primary device = greatest capturedAt with >=1 record + let mut unions = device_unions; + unions.sort_by_key(|d| std::cmp::Reverse(d.union_doc["capturedAt"].as_u64().unwrap_or(0))); + + // Pass 1 - resolve EVERY pane in EVERY union (not just the primary): effective refs + // feed the cross-device ledgerOnly rule (A4) and the contentId substance (A5/A6); + // the primary union's tabs feed `device`. + let mut referenced: HashSet = HashSet::new(); + let mut substance: Vec = Vec::new(); + let mut tabs_per_union: Vec> = Vec::new(); + for d in &unions { + let doc = &d.union_doc; + let device_id = d.device_id.clone(); + let tabs: Vec = doc["records"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|rec| { + let panes: Vec = rec["panes"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|pane| { + let payload = &pane["payload"]; + let snap_ref = payload.get("sessionRef").filter(|v| !v.is_null()).cloned(); + let (ledger_state, eff_ref) = match &snap_ref { + None => ("unknown", None), + Some(r) => { + let (p, s) = ( + r["provider"].as_str().unwrap_or(""), + r["sessionId"].as_str().unwrap_or(""), + ); + match resolve(p, s, &by_key) { + Verdict::Bound(bp, bs) => { + ("bound", Some(json!({"provider": bp, "sessionId": bs}))) + } + Verdict::Closed => ("closed", None), + Verdict::GcExpired => ("gc_expired", Some(r.clone())), + Verdict::Unknown => ("unknown", Some(r.clone())), + } + } + }; + let eff_str = eff_ref + .as_ref() + .map(|r| { + format!( + "{}:{}", + r["provider"].as_str().unwrap_or(""), + r["sessionId"].as_str().unwrap_or("") + ) + }) + .unwrap_or_else(|| "-".into()); + let live = eff_ref + .as_ref() + .map(|r| { + is_live( + r["provider"].as_str().unwrap_or(""), + r["sessionId"].as_str().unwrap_or(""), + ) + }) + .unwrap_or(false); + if let Some(er) = &eff_ref { + referenced.insert(ref_key( + er["provider"].as_str().unwrap_or(""), + er["sessionId"].as_str().unwrap_or(""), + )); + } + // TIMESTAMP-FREE substance line: capturedAt/updatedAt deliberately absent (D3) + substance.push(format!( + "{}\u{1}{}\u{1}{}\u{1}{}\u{1}{}", + device_id, + rec["tabKey"].as_str().unwrap_or(""), + pane["paneId"].as_str().unwrap_or(""), + pane["kind"].as_str().unwrap_or(""), + eff_str + )); + json!({ + "paneId": pane["paneId"], "kind": pane["kind"], + "mode": payload.get("mode").cloned().unwrap_or(Value::Null), + "shell": payload.get("shell").cloned().unwrap_or(Value::Null), + "cwd": payload.get("initialCwd").cloned().unwrap_or(Value::Null), + "payload": payload.clone(), + "sessionRef": eff_ref.unwrap_or(Value::Null), + "ledgerState": ledger_state, + "live": live, + }) + }) + .collect(); + json!({"tabKey": rec["tabKey"], "tabName": rec["tabName"], "panes": panes}) + }) + .collect(); + tabs_per_union.push(tabs); + } + + let primary_idx = unions.iter().position(|d| { + d.union_doc["records"] + .as_array() + .map(|r| !r.is_empty()) + .unwrap_or(false) + }); + + let device = primary_idx.map(|i| { + let doc = &unions[i].union_doc; + json!({"deviceId": doc["deviceId"], "deviceLabel": doc["deviceLabel"], + "capturedAt": doc["capturedAt"], "tabs": tabs_per_union[i].clone()}) + }); + + let other_devices: Vec = unions + .iter() + .enumerate() + .filter(|(i, _)| Some(*i) != primary_idx) + .filter(|(_, d)| { + d.union_doc["records"] + .as_array() + .map(|r| !r.is_empty()) + .unwrap_or(false) + }) + .map(|(_, d)| { + let pane_count: u64 = d.union_doc["records"] + .as_array() + .unwrap() + .iter() + .map(|r| r["panes"].as_array().map(|p| p.len() as u64).unwrap_or(0)) + .sum(); + json!({"deviceId": d.union_doc["deviceId"], "deviceLabel": d.union_doc["deviceLabel"], + "capturedAt": d.union_doc["capturedAt"], "paneCount": pane_count}) + }) + .collect(); + + let ledger_only: Vec = bindings + .iter() + .filter(|r| row_is_bound(r)) + // vs effective refs across ALL unions (A4), not just the primary device + .filter(|r| !referenced.contains(&ref_key(&row_provider(r), &row_session_id(r)))) + // live rows are excluded: sessions still running are never offered for resume (D7) + .filter(|r| !is_live(&row_provider(r), &row_session_id(r))) + .map(|r| { + json!({"provider": row_provider(r), "sessionId": row_session_id(r), + "mode": row_mode(r), "cwd": row_cwd(r)}) + }) + .collect(); + + // contentId: sha256 over the sorted TIMESTAMP-FREE substance (A5/A6, D3) + substance.extend(ledger_only.iter().map(|e| { + format!( + "{}:{}", + e["provider"].as_str().unwrap_or(""), + e["sessionId"].as_str().unwrap_or("") + ) + })); + substance.sort(); + let content_id = digest16(&substance); + + let recoverable = device.is_some() || !ledger_only.is_empty(); + json!({"recoverable": recoverable, "contentId": content_id, + "device": device.unwrap_or(Value::Null), + "otherDevices": other_devices, "ledgerOnly": ledger_only}) +} + +// Thin accessors over the real `BindingRow` fields/enums +// (`crates/freshell-ws/src/pane_ledger.rs:93`) — single field accesses, no logic. + +fn row_provider(r: &BindingRow) -> String { + r.provider.clone() +} + +fn row_session_id(r: &BindingRow) -> String { + r.session_id.clone() +} + +fn row_is_bound(r: &BindingRow) -> bool { + r.state == RowState::Bound +} + +fn row_reason_is_closed(r: &BindingRow) -> bool { + r.retired_reason == Some(RetiredReason::Closed) +} + +fn row_successor(r: &BindingRow) -> Option<(String, String)> { + r.superseded_by + .as_ref() + .map(|l| (l.provider.clone(), l.session_id.clone())) +} + +fn row_mode(r: &BindingRow) -> String { + r.mode.clone() +} + +fn row_cwd(r: &BindingRow) -> Option { + r.cwd.clone() +} + +/// The `contentId` digest: sha256 over the parts joined with `\u{1}`, +/// hex-encoded, truncated to 16 chars (the tabs-persist digest convention, +/// `crates/freshell-ws/src/tabs_persist.rs:82-87`, at half width). +fn digest16(parts: &[String]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(parts.join("\u{1}").as_bytes()); + digest[..8].iter().map(|b| format!("{b:02x}")).collect() +} + +// ── Task 2: the `GET /api/recovery/inventory` route ─────────────────────────── + +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; + +use crate::boot::{is_authed, unauthorized}; + +/// State for the recovery-inventory read surface. `registry` is the SAME +/// shared `TerminalRegistry` the WS server state receives (`main.rs:249`) — +/// read-only here (the D7 liveness join). +#[derive(Clone)] +pub struct RecoveryInventoryState { + pub auth_token: String, + pub snapshots_dir: Option, + pub ledger: std::sync::Arc, + pub registry: freshell_terminal::TerminalRegistry, + /// The SAME shared identity registry the WS state receives — read-only + /// here (the wave-B widened D7 liveness join: locator-adopted terminals + /// hold their session identity here, not on the registry row). + pub identity: freshell_ws::identity::TerminalIdentityRegistry, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct InventoryQuery { + client_instance_id: Option, + boot_ago_ms: Option, +} + +pub fn router(state: RecoveryInventoryState) -> Router { + Router::new() + .route("/api/recovery/inventory", get(inventory_handler)) + .with_state(state) +} + +/// Epoch millis — the same convention the tabs-persist/tabs stores use +/// (`tabs.rs:549`), as `u64` because the A15/A16 cutoffs are unsigned. +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Snapshot store present but unreadable, or the blocking read task failed: +/// fail LOUD (500) — never a silent empty inventory (the +/// `tabs_snapshots.rs:61` precedent). +fn internal_error() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "recovery inventory unavailable" })), + ) + .into_response() +} + +async fn inventory_handler( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let exclude = q.client_instance_id.unwrap_or_default(); + // D2/A16: anchor the concurrent-client filter to the requester's boot. + // Missing param => 0 => boot_cutoff = now, so nothing that predates the + // request is dropped. + let boot_cutoff = now_ms().saturating_sub(q.boot_ago_ms.unwrap_or(0)); + let unions = match state.snapshots_dir.clone() { + None => vec![], + Some(dir) => { + let job = tokio::task::spawn_blocking(move || { + read_foreign_unions(&dir, &exclude, boot_cutoff) + }); + match job.await { + Ok(Ok(u)) => u, + Ok(Err(e)) => { + tracing::error!(target: "freshell_server::recovery_inventory", + error = %e, "recovery inventory snapshot read failed"); + return internal_error(); + } + Err(e) => { + tracing::error!(target: "freshell_server::recovery_inventory", + error = %e, "recovery inventory join failed"); + return internal_error(); + } + } + } + }; + let live = live_session_keys(&state.registry, &state.identity); + Json(build_inventory(unions, state.ledger.list_bindings(), live)).into_response() +} + +/// Read-only liveness join (D7): `(provider = mode, sessionId)` for every +/// currently-Running terminal row — the same row fields the ladder's A13 guard +/// reads (`terminal.rs:1690-1745`: mode + resume session id, status == +/// `TerminalRunStatus::Running`). +/// +/// WAVE-B widening (B3 lane review): the D7 create-rung server guard checks +/// BOTH stores — the identity-registry owner (probed Running) AND the +/// registry-row scan. A locator-adopted terminal (codex/opencode/amplifier) +/// holds its session in the identity registry while the row's +/// `resume_session_id` stays unset, so the registry-row scan alone under-counts +/// live sessions: the inventory would offer them for resume and the accept +/// would die on the server guard. Join both stores here so the offer and the +/// guard agree. +fn live_session_keys( + registry: &freshell_terminal::TerminalRegistry, + identity: &freshell_ws::identity::TerminalIdentityRegistry, +) -> HashSet<(String, String)> { + let mut keys: HashSet<(String, String)> = registry + .directory() + .into_iter() + .filter(|row| row.status == freshell_protocol::TerminalRunStatus::Running) + .filter_map(|row| { + row.resume_session_id + .filter(|s| !s.is_empty()) + .map(|sid| (row.mode, sid)) + }) + .collect(); + // Identity-registry side of the join: live (non-retired) entries whose + // owning terminal probes Running — mirrors the guard's + // `identity_owner_live` arm. + for entry in identity.list() { + let (Some(provider), Some(session_id)) = (entry.provider, entry.session_id) else { + continue; + }; + if session_id.is_empty() { + continue; + } + let owner_running = registry + .probe(&entry.terminal_id) + .is_some_and(|r| r.status == freshell_protocol::TerminalRunStatus::Running); + if owner_running { + keys.insert((provider, session_id)); + } + } + keys +} + +fn read_foreign_unions( + dir: &std::path::Path, + exclude_client: &str, + boot_cutoff: u64, +) -> std::io::Result> { + use freshell_ws::tabs_persist::{ + list_snapshot_devices, read_device_overview, read_generations_union_by_ids, ComponentsUnion, + }; + let mut out = vec![]; + if !dir.is_dir() { + return Ok(out); + } + for device in list_snapshot_devices(dir)? { + let Some((_, generations)) = read_device_overview(dir, &device)? else { + continue; + }; + // Task 1 helper: drops the requester's own generations, concurrent + // post-boot clients (A16), AND stale clients (A15). + let foreign = + select_foreign_recent_generation_ids(&generations, exclude_client, boot_cutoff); + if foreign.is_empty() { + continue; + } + match read_generations_union_by_ids(dir, &device, &foreign)? { + ComponentsUnion::Found(union_doc) => out.push(DeviceUnion { + device_id: device, + union_doc, + }), + // A component pruned between the overview scan and the union read: + // zero surviving generations for this device — skip it. + ComponentsUnion::Missing(_) => continue, + } + } + Ok(out) +} + +#[cfg(test)] +#[path = "recovery_inventory_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs new file mode 100644 index 000000000..d6a5e6231 --- /dev/null +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -0,0 +1,670 @@ +//! Tests for the pure recovery-inventory builder (B3/P1.9 Task 1). + +use super::*; +use freshell_protocol::SessionLocator; +use freshell_ws::pane_ledger::{BindingRow, RetiredReason, RowState, LEDGER_VERSION}; +use serde_json::json; +use std::collections::HashSet; + +fn no_live() -> HashSet<(String, String)> { + HashSet::new() +} + +fn live(pairs: &[(&str, &str)]) -> HashSet<(String, String)> { + pairs + .iter() + .map(|(p, s)| (p.to_string(), s.to_string())) + .collect() +} + +fn union_doc(device: &str, captured_at: u64, panes: serde_json::Value) -> serde_json::Value { + json!({ + "deviceId": device, "deviceLabel": format!("label-{device}"), "capturedAt": captured_at, + "records": [{ "tabKey": "k1", "tabId": "t1", "tabName": "work", "revision": 1, + "updatedAt": captured_at, "paneCount": 1, "panes": panes }] + }) +} + +/// (state, retired_reason, superseded_by) parts for constructing a `BindingRow`. +type StateParts = (RowState, Option, Option); + +fn bound() -> StateParts { + (RowState::Bound, None, None) +} + +fn retired_closed() -> StateParts { + (RowState::Retired, Some(RetiredReason::Closed), None) +} + +fn retired_gc_expired() -> StateParts { + (RowState::Retired, Some(RetiredReason::GcExpired), None) +} + +fn retired_superseded_by(provider: &str, session_id: &str) -> StateParts { + ( + RowState::Retired, + Some(RetiredReason::Superseded), + Some(SessionLocator { + provider: provider.to_string(), + session_id: session_id.to_string(), + }), + ) +} + +fn binding_row_at( + provider: &str, + session_id: &str, + state_parts: StateParts, + updated_at: i64, +) -> BindingRow { + let (state, retired_reason, superseded_by) = state_parts; + BindingRow { + ledger_version: LEDGER_VERSION, + provider: provider.to_string(), + session_id: session_id.to_string(), + mode: provider.to_string(), + cwd: Some("/x".to_string()), + live_terminal_id: None, + create_request_id: None, + created_at: 1000, + updated_at, + last_observed_at: updated_at, + state, + retired_reason, + superseded_by, + pane_kind: None, + model: None, + sandbox: None, + permission_mode: None, + effort: None, + } +} + +fn binding_row(provider: &str, session_id: &str, state_parts: StateParts) -> BindingRow { + binding_row_at(provider, session_id, state_parts, 1000) +} + +/// WAVE-B fast-follow (B3 lane review): the inventory's D7 liveness join must +/// match the server guard's width (terminal.rs D7 live-guard: identity-registry +/// owner check PLUS the registry-row scan). A locator-adopted terminal holds +/// its session in the IDENTITY registry while the registry row's +/// resume_session_id stays unset (fresh pane, never resumed) -- the inventory +/// must still report that session live, or it gets offered for resume and the +/// accept dies on the server guard instead of never being offered. +#[test] +fn live_session_keys_includes_identity_registry_bound_sessions() { + let registry = freshell_terminal::TerminalRegistry::new(); + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "t-live".into(), + stream_id: "s1".into(), + mode: "codex".into(), + resume_session_id: None, // fresh pane: row carries no resume id + create_request_id: None, + created_at: None, + }); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + identity.upsert("t-live", Some("codex"), Some("sess-live-1"), None, 0); + + let keys = live_session_keys(®istry, &identity); + assert!( + keys.contains(&("codex".to_string(), "sess-live-1".to_string())), + "identity-registry-bound session of a Running terminal must be live" + ); +} + +/// Retired identity entries and identity entries whose terminal is not +/// Running never widen the live set. +#[test] +fn live_session_keys_ignores_retired_and_dead_identity_entries() { + let registry = freshell_terminal::TerminalRegistry::new(); + // No registry row at all for "t-gone" -- its identity entry must not count. + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + identity.upsert("t-gone", Some("codex"), Some("sess-gone"), None, 0); + // A retired entry on a live terminal must not count either. + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "t-retired".into(), + stream_id: "s2".into(), + mode: "claude".into(), + resume_session_id: None, + create_request_id: None, + created_at: None, + }); + identity.upsert("t-retired", Some("claude"), Some("sess-retired"), None, 0); + assert!(identity.retire("t-retired")); + + let keys = live_session_keys(®istry, &identity); + assert!(!keys.contains(&("codex".to_string(), "sess-gone".to_string()))); + assert!(!keys.contains(&("claude".to_string(), "sess-retired".to_string()))); +} + +#[test] +fn empty_inputs_not_recoverable() { + let out = build_inventory(vec![], vec![], no_live()); + assert_eq!(out["recoverable"], false); + assert!(out["device"].is_null()); + assert_eq!(out["ledgerOnly"].as_array().unwrap().len(), 0); +} + +#[test] +fn newest_device_wins_others_summarized() { + let old = DeviceUnion { + device_id: "dev0".into(), + union_doc: union_doc( + "dev0", + 500, + json!([{ "paneId": "p0", "kind": "terminal", "payload": {"mode": "shell"} }]), + ), + }; + let new = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc( + "dev1", + 1000, + json!([{ "paneId": "p1", "kind": "terminal", "payload": {"mode": "shell", "initialCwd": "/w"} }]), + ), + }; + let out = build_inventory(vec![old, new], vec![], no_live()); + assert_eq!(out["recoverable"], true); + assert_eq!(out["device"]["deviceId"], "dev1"); + assert_eq!(out["device"]["tabs"][0]["panes"][0]["cwd"], "/w"); + assert_eq!(out["device"]["tabs"][0]["panes"][0]["live"], false); + assert_eq!(out["otherDevices"][0]["deviceId"], "dev0"); + assert_eq!(out["otherDevices"][0]["paneCount"], 1); +} + +#[test] +fn ledger_bound_row_overrides_snapshot_claim_via_superseded_chain() { + // snapshot says S1; ledger: S1 retired(superseded -> S2), S2 bound + let d = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc( + "dev1", + 1000, + json!([{ "paneId": "p1", "kind": "terminal", + "payload": { "mode": "claude", "sessionRef": { "provider": "claude", "sessionId": "S1" } } }]), + ), + }; + let bindings = vec![ + binding_row("claude", "S1", retired_superseded_by("claude", "S2")), + binding_row("claude", "S2", bound()), + ]; + let out = build_inventory(vec![d], bindings, no_live()); + let pane = &out["device"]["tabs"][0]["panes"][0]; + assert_eq!(pane["ledgerState"], "bound"); + assert_eq!(pane["sessionRef"]["sessionId"], "S2"); // ledger identity beat the snapshot claim + assert_eq!(out["ledgerOnly"].as_array().unwrap().len(), 0); // S2 is referenced, not ledger-only +} + +#[test] +fn closed_row_strips_resume_gc_expired_keeps_snapshot_ref_unknown_passes_through() { + let d = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc( + "dev1", + 1000, + json!([ + { "paneId": "p1", "kind": "terminal", "payload": { "mode": "claude", "sessionRef": { "provider": "claude", "sessionId": "CLOSED" } } }, + { "paneId": "p2", "kind": "terminal", "payload": { "mode": "codex", "sessionRef": { "provider": "codex", "sessionId": "EXPIRED" } } }, + { "paneId": "p3", "kind": "fresh-agent", "payload": { "sessionRef": { "provider": "freshclaude", "sessionId": "NOROW" } } } + ]), + ), + }; + let bindings = vec![ + binding_row("claude", "CLOSED", retired_closed()), + binding_row("codex", "EXPIRED", retired_gc_expired()), + ]; + let out = build_inventory(vec![d], bindings, no_live()); + let panes = out["device"]["tabs"][0]["panes"].as_array().unwrap(); + assert_eq!(panes[0]["ledgerState"], "closed"); + assert!(panes[0]["sessionRef"].is_null()); + assert_eq!(panes[1]["ledgerState"], "gc_expired"); + assert_eq!(panes[1]["sessionRef"]["sessionId"], "EXPIRED"); + assert_eq!(panes[2]["ledgerState"], "unknown"); + assert_eq!(panes[2]["sessionRef"]["sessionId"], "NOROW"); +} + +#[test] +fn unreferenced_bound_rows_become_ledger_only() { + let out = build_inventory(vec![], vec![binding_row("codex", "C9", bound())], no_live()); + assert_eq!(out["recoverable"], true); + assert_eq!(out["ledgerOnly"][0]["sessionId"], "C9"); +} + +#[test] +fn bound_row_referenced_by_non_primary_device_is_not_ledger_only() { + // A4: a two-device steady state must not report the OTHER device's sessions as orphaned. + let newer = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc( + "dev1", + 1000, + json!([{ "paneId": "p1", "kind": "terminal", "payload": {"mode": "shell"} }]), + ), + }; + let older = DeviceUnion { + device_id: "dev0".into(), + union_doc: union_doc( + "dev0", + 500, + json!([{ "paneId": "p0", "kind": "terminal", + "payload": { "mode": "codex", "sessionRef": { "provider": "codex", "sessionId": "C9" } } }]), + ), + }; + let out = build_inventory( + vec![newer, older], + vec![binding_row("codex", "C9", bound())], + no_live(), + ); + assert_eq!(out["device"]["deviceId"], "dev1"); // dev0 is NON-primary + assert_eq!( + out["ledgerOnly"].as_array().unwrap().len(), + 0, + "C9 is referenced by dev0's union - not orphaned" + ); +} + +#[test] +fn live_effective_ref_marks_pane_live_and_live_rows_never_ledger_only() { + // D7: pane resolves (via ledger chain) to S2, which a Running terminal owns; + // a second live bound row C9 is referenced by no pane. + let d = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc( + "dev1", + 1000, + json!([{ "paneId": "p1", "kind": "terminal", + "payload": { "mode": "claude", "sessionRef": { "provider": "claude", "sessionId": "S1" } } }]), + ), + }; + let bindings = vec![ + binding_row("claude", "S1", retired_superseded_by("claude", "S2")), + binding_row("claude", "S2", bound()), + binding_row("codex", "C9", bound()), + ]; + let out = build_inventory( + vec![d], + bindings, + live(&[("claude", "S2"), ("codex", "C9")]), + ); + let pane = &out["device"]["tabs"][0]["panes"][0]; + assert_eq!(pane["live"], true); + assert_eq!(pane["sessionRef"]["sessionId"], "S2"); // ref still reported; the CLIENT strips it (Task 4, D7) + assert_eq!( + out["ledgerOnly"].as_array().unwrap().len(), + 0, + "live bound rows are excluded from ledgerOnly" + ); +} + +#[test] +fn content_id_is_stable_and_input_sensitive() { + let a = build_inventory(vec![], vec![binding_row("codex", "C9", bound())], no_live()); + let b = build_inventory(vec![], vec![binding_row("codex", "C9", bound())], no_live()); + let c = build_inventory(vec![], vec![binding_row("codex", "C8", bound())], no_live()); + assert_eq!(a["contentId"], b["contentId"]); + assert_ne!(a["contentId"], c["contentId"]); +} + +#[test] +fn content_id_ignores_timestamp_churn() { + // A5/A6: heartbeat re-pushes bump capturedAt/updatedAt every <=5 min - dismissal must survive. + let doc = |captured_at| { + union_doc( + "dev1", + captured_at, + json!([{ "paneId": "p1", "kind": "terminal", "payload": {"mode": "shell"} }]), + ) + }; + let a = build_inventory( + vec![DeviceUnion { + device_id: "dev1".into(), + union_doc: doc(1000), + }], + vec![binding_row_at("codex", "C9", bound(), 1000)], + no_live(), + ); + let b = build_inventory( + vec![DeviceUnion { + device_id: "dev1".into(), + union_doc: doc(2000), + }], + vec![binding_row_at("codex", "C9", bound(), 2000)], + no_live(), + ); + assert_eq!( + a["contentId"], b["contentId"], + "bumping only capturedAt/updatedAt must not change contentId" + ); +} + +#[test] +fn stale_clients_generations_are_dropped() { + // A15: any client silent >15 min (heartbeat is 5 min) is closed or rotated - drop it. + let t_max: u64 = 100_000_000; + let gens = vec![ + json!({"generationId": "gA", "clientInstanceId": "fresh", "capturedAt": t_max}), + json!({"generationId": "gB", "clientInstanceId": "fresh", "capturedAt": t_max - 60_000}), + json!({"generationId": "gC", "clientInstanceId": "stale", "capturedAt": t_max - 16 * 60 * 1000}), + json!({"generationId": "gD", "clientInstanceId": "me", "capturedAt": t_max}), + ]; + // boot cutoff AFTER every push: the A16 concurrent-client rule drops nothing here. + let ids = select_foreign_recent_generation_ids(&gens, "me", t_max + 1); + assert!(ids.contains(&"gA".to_string()) && ids.contains(&"gB".to_string())); + assert!( + !ids.contains(&"gC".to_string()), + "stale rotated client must not resurrect closed tabs" + ); + assert!( + !ids.contains(&"gD".to_string()), + "requester's own generations are excluded" + ); +} + +// ── Task 2: `GET /api/recovery/inventory` route tests ───────────────────────── + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +/// Snapshot fixture written directly with the store's REAL layout — +/// `//--r.json` (alphanumeric +/// device/client ids need no escaping). +fn write_snapshot( + dir: &std::path::Path, + device: &str, + client: &str, + captured_at: u64, + rev: u64, + records: serde_json::Value, +) { + let doc = json!({ + "deviceId": device, "deviceLabel": format!("label-{device}"), "clientInstanceId": client, + "serverInstanceId": "srv-test", "snapshotRevision": rev, "capturedAt": captured_at, + "records": records + }); + let d = dir.join(device); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( + d.join(format!("{client}-{captured_at:020}-r{rev:012}.json")), + serde_json::to_vec(&doc).unwrap(), + ) + .unwrap(); +} + +// Fresh EMPTY terminal registry — constructed exactly the way main.rs:249 does; +// no running terminals => every pane comes back `live: false`. +fn test_registry() -> freshell_terminal::TerminalRegistry { + freshell_terminal::TerminalRegistry::new() +} + +fn test_state( + dir: Option, + ledger_root: Option, +) -> RecoveryInventoryState { + RecoveryInventoryState { + auth_token: "tok".into(), + snapshots_dir: dir, + ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked( + ledger_root, + )), + registry: test_registry(), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + } +} + +async fn get( + router: axum::Router, + uri: &str, + auth: Option<&str>, +) -> (StatusCode, serde_json::Value) { + let mut req = Request::builder().method("GET").uri(uri); + if let Some(token) = auth { + req = req.header("x-auth-token", token); + } + let resp = router + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) +} + +#[tokio::test] +async fn route_requires_auth_and_serves_inventory() { + let tmp = tempfile::tempdir().unwrap(); + write_snapshot( + tmp.path(), + "dev1", + "clientA", + 1000, + 1, + json!([ + {"tabKey":"k1","tabId":"t1","tabName":"work","status":"open","revision":1,"updatedAt":1000, + "paneCount":1,"panes":[{"paneId":"p1","kind":"terminal","payload":{"mode":"shell","initialCwd":"/w"}}]} + ]), + ); + let router = router(test_state(Some(tmp.path().to_path_buf()), None)); + // house convention: 401 case asserted alongside the happy path + let (code, _) = get( + router.clone(), + "/api/recovery/inventory?clientInstanceId=me", + None, + ) + .await; + assert_eq!(code, axum::http::StatusCode::UNAUTHORIZED); + let (code, body) = get( + router, + "/api/recovery/inventory?clientInstanceId=me", + Some("tok"), + ) + .await; + assert_eq!(code, axum::http::StatusCode::OK); + assert_eq!(body["recoverable"], true); + assert_eq!(body["device"]["deviceId"], "dev1"); + assert_eq!(body["device"]["tabs"][0]["panes"][0]["cwd"], "/w"); +} + +#[tokio::test] +async fn route_excludes_requesting_clients_own_generations() { + let tmp = tempfile::tempdir().unwrap(); + write_snapshot( + tmp.path(), + "dev1", + "oldclient", + 1000, + 1, + json!([ + {"tabKey":"k1","tabId":"t1","tabName":"work","status":"open","revision":1,"updatedAt":1000, + "paneCount":1,"panes":[{"paneId":"p1","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + write_snapshot( + tmp.path(), + "dev1", + "me", + 2000, + 1, + json!([ + {"tabKey":"junk","tabId":"tj","tabName":"junk","status":"open","revision":1,"updatedAt":2000, + "paneCount":1,"panes":[{"paneId":"pj","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + let router = router(test_state(Some(tmp.path().to_path_buf()), None)); + let (_, body) = get( + router, + "/api/recovery/inventory?clientInstanceId=me", + Some("tok"), + ) + .await; + let tabs = body["device"]["tabs"].as_array().unwrap(); + assert!( + tabs.iter().all(|t| t["tabKey"] != "junk"), + "requester's own push must be filtered out" + ); + assert!(tabs.iter().any(|t| t["tabKey"] == "k1")); +} + +#[tokio::test] +async fn route_serves_ledger_only_recovery_without_snapshots() { + // Seed a binding file the ledger boot-scan will load (BindingRow camelCase JSON). + let home = tempfile::tempdir().unwrap(); + let broot = home.path().join("pane-ledger"); + std::fs::create_dir_all(broot.join("bindings").join("claude")).unwrap(); + std::fs::write( + broot.join("bindings").join("claude").join("S1.json"), + serde_json::to_vec(&json!({ + "ledgerVersion": 1, "provider": "claude", "sessionId": "S1", "mode": "claude", + "cwd": "/w", "createdAt": 1, "updatedAt": 1, "lastObservedAt": 1, "state": "bound" + })) + .unwrap(), + ) + .unwrap(); + let router = router(test_state(None, Some(broot))); + let (_, body) = get( + router, + "/api/recovery/inventory?clientInstanceId=me", + Some("tok"), + ) + .await; + assert_eq!(body["recoverable"], true); + assert_eq!(body["ledgerOnly"][0]["sessionId"], "S1"); +} + +#[tokio::test] +async fn route_drops_stale_rotated_clients() { + // A15: a client silent >15 min (heartbeat is 5 min) is closed or rotated - its + // resurrected tab must not enter the inventory union. + let tmp = tempfile::tempdir().unwrap(); + let t_max: u64 = 100_000_000; + write_snapshot( + tmp.path(), + "dev1", + "fresh", + t_max, + 1, + json!([ + {"tabKey":"k1","tabId":"t1","tabName":"work","status":"open","revision":1,"updatedAt":t_max, + "paneCount":1,"panes":[{"paneId":"p1","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + write_snapshot( + tmp.path(), + "dev1", + "stale", + t_max - 16 * 60 * 1000, + 1, + json!([ + {"tabKey":"zombie","tabId":"tz","tabName":"zombie","status":"open","revision":1,"updatedAt":t_max - 16 * 60 * 1000, + "paneCount":1,"panes":[{"paneId":"pz","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + let router = router(test_state(Some(tmp.path().to_path_buf()), None)); + let (_, body) = get( + router, + "/api/recovery/inventory?clientInstanceId=me", + Some("tok"), + ) + .await; + let tabs = body["device"]["tabs"].as_array().unwrap(); + assert!( + tabs.iter().all(|t| t["tabKey"] != "zombie"), + "stale client's tab must be dropped" + ); + assert!(tabs.iter().any(|t| t["tabKey"] == "k1")); +} + +#[tokio::test] +async fn route_bootagoms_drops_concurrent_post_boot_clients() { + // A16/D2 at the ROUTE level: this test forces the bootAgoMs -> boot_cutoff -> + // read_foreign_unions(_, _, boot_cutoff) wiring to actually exist. It uses REAL + // wall-clock capturedAt values because boot_cutoff is computed from now_ms(). + let tmp = tempfile::tempdir().unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + // The genuinely lost client: its only push predates the requester's boot by 60s. + write_snapshot( + tmp.path(), + "dev1", + "lost", + now - 60_000, + 1, + json!([ + {"tabKey":"k1","tabId":"t1","tabName":"work","status":"open","revision":1,"updatedAt":now - 60_000, + "paneCount":1,"panes":[{"paneId":"p1","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + // A concurrently-born fresh window: ALL of its generations postdate the boot. + write_snapshot( + tmp.path(), + "dev1", + "concurrent", + now, + 1, + json!([ + {"tabKey":"junk","tabId":"tj","tabName":"junk","status":"open","revision":1,"updatedAt":now, + "paneCount":1,"panes":[{"paneId":"pj","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + let router = router(test_state(Some(tmp.path().to_path_buf()), None)); + // Requester booted 30s ago => boot_cutoff = now - 30s: "concurrent" (born now) is + // post-boot junk and must be dropped; "lost" (60s ago) predates boot and survives. + let (_, body) = get( + router.clone(), + "/api/recovery/inventory?clientInstanceId=me&bootAgoMs=30000", + Some("tok"), + ) + .await; + let tabs = body["device"]["tabs"].as_array().unwrap(); + assert!( + tabs.iter().all(|t| t["tabKey"] != "junk"), + "post-boot concurrent client must be dropped (A16)" + ); + assert!( + tabs.iter().any(|t| t["tabKey"] == "k1"), + "pre-boot lost client must survive" + ); + // Without bootAgoMs (default 0 => boot_cutoff = now at handler time) BOTH clients + // predate the cutoff and BOTH tabs appear - pins the optional-default-0 contract. + let (_, body) = get( + router, + "/api/recovery/inventory?clientInstanceId=me", + Some("tok"), + ) + .await; + let tabs = body["device"]["tabs"].as_array().unwrap(); + assert!( + tabs.iter().any(|t| t["tabKey"] == "junk"), + "default cutoff must drop nothing pre-request" + ); + assert!(tabs.iter().any(|t| t["tabKey"] == "k1")); +} + +#[test] +fn concurrent_fresh_windows_generations_are_dropped() { + // A16/D2: a client whose ENTIRE retained history postdates the requester's boot is a + // concurrently-opened fresh window (junk auto shell tab) - it must never demote the + // genuinely lost device by winning primary-device selection. + let boot: u64 = 100_000_000; + let gens = vec![ + json!({"generationId": "gJ1", "clientInstanceId": "sibling-window", "capturedAt": boot + 2_000}), + json!({"generationId": "gJ2", "clientInstanceId": "sibling-window", "capturedAt": boot + 300_000}), + json!({"generationId": "gR", "clientInstanceId": "lost", "capturedAt": boot - 30_000}), + ]; + let ids = select_foreign_recent_generation_ids(&gens, "me", boot); + assert!( + ids.contains(&"gR".to_string()), + "pre-boot client is real lost data - kept" + ); + assert!( + !ids.contains(&"gJ1".to_string()) && !ids.contains(&"gJ2".to_string()), + "post-boot-only client is a concurrent fresh window - dropped" + ); +} diff --git a/crates/freshell-server/src/tabs_snapshots.rs b/crates/freshell-server/src/tabs_snapshots.rs index 8556284f8..f28be92a2 100644 --- a/crates/freshell-server/src/tabs_snapshots.rs +++ b/crates/freshell-server/src/tabs_snapshots.rs @@ -1,10 +1,12 @@ //! Tabs-sync snapshot REST surface (continuity trio, //! docs/plans/2026-07-22-continuity-safety-trio.md). PURELY ADDITIVE: the legacy -//! server has no on-disk snapshot generations and no snapshot/restore routes, so -//! this diverges from no ported behavior and gets no DEVIATIONS ledger entry. -//! Read endpoints serve the generations `freshell_ws::tabs_persist` persists; -//! POST /api/tabs-sync/restore (Task 3) rebuilds tabs by driving the SAME -//! `POST /api/tabs` create pipeline. +//! server has no on-disk snapshot generations, so this diverges from no ported +//! behavior and gets no DEVIATIONS ledger entry. +//! Read endpoints serve the generations `freshell_ws::tabs_persist` persists. +//! The store's consumers are these GET endpoints (operator tooling, e.g. +//! `scripts/deploy-tab-diff.sh`) and `GET /api/recovery/inventory` + the +//! client recover-my-panes flow (the operator server-push restore endpoint +//! was deleted in the kata h9vt cleanup). use std::path::PathBuf; use std::sync::Arc; @@ -22,27 +24,12 @@ use crate::boot::is_authed; // pub(crate) in boot.rs:686 — same crate, no copy pub struct TabsSnapshotsState { pub auth_token: Arc, pub snapshots_dir: Option, - pub fresh_agent: freshell_freshagent::FreshAgentState, - pub screenshots: freshell_ws::screenshot::ScreenshotBroker, - /// The SAME `TerminalRegistry` (`main.rs:246`) the WS handler + `fresh_agent` - /// use. Restore reconciles write-ahead marker entries against it by - /// `is_running(terminalId)` so a crash between create and marker-promotion - /// can't cause a duplicate on retry (Task 3, `:1532`). - pub terminals: freshell_terminal::TerminalRegistry, - /// Serializes restores so two concurrent requests can't both read an empty - /// marker and duplicate tabs (Task 3). One process-wide lock is sufficient — - /// restores are rare, operator-triggered recovery actions. - pub restore_lock: Arc>, - /// Bounds each per-pane delivery-ack round-trip (Task 3, `:1460`). Production - /// ~5s; tests set it short so the connection-drop path is fast. - pub restore_ack_timeout: std::time::Duration, } pub fn router(state: TabsSnapshotsState) -> Router { Router::new() .route("/api/tabs-sync/snapshots", get(list_snapshots)) .route("/api/tabs-sync/snapshots/{device_id}", get(get_snapshot)) - .route("/api/tabs-sync/restore", axum::routing::post(restore_tabs)) .with_state(state) } @@ -68,11 +55,11 @@ fn snapshots_read_error(dir: &std::path::Path, err: &dyn std::fmt::Display) -> R .into_response() } -// Fail-closed selector parsing (GET query params AND the restore body share -// ONE code path) — see `tabs_snapshots_selectors.rs`. +// Fail-closed selector parsing for the GET query params — see +// `tabs_snapshots_selectors.rs`. #[path = "tabs_snapshots_selectors.rs"] mod selectors_mod; -use selectors_mod::{parse_restore_selection, parse_selector, Selector}; +use selectors_mod::{parse_selector, Selector}; async fn list_snapshots(State(state): State, headers: HeaderMap) -> Response { if !is_authed(&headers, &state.auth_token) { @@ -155,777 +142,6 @@ async fn get_snapshot( } } -// ── POST /api/tabs-sync/restore ───────────────────────────────────────────── -// Rebuilds open panes through the normal create pipeline. A per-source -// write-ahead ledger makes each `(device, source, tabKey, paneId)` idempotent; -// targeted delivery plus a screenshot fence proves the selected connection -// received each replayable tab.create. Selectors and marker corruption fail -// closed, while unsupported pane kinds are reported as skips. - -#[path = "tabs_snapshots_marker.rs"] -mod marker_mod; -use marker_mod::{ - prune_marker_doc, read_marker_doc, validate_restore_projection, write_marker_doc, Marker, - MarkerDoc, PaneMark, RESTORE_MARKER, -}; -#[cfg(test)] -use marker_mod::{ - validate_marker_pane_count, MAX_RESTORE_MARKER_BYTES, MAX_RESTORE_MARKER_PANES_PER_SOURCE, - MAX_RESTORE_MARKER_PANE_KEY_BYTES, MAX_RESTORE_MARKER_SOURCES, - MAX_RESTORE_MARKER_SOURCE_ID_BYTES, MAX_RESTORE_MARKER_TERMINAL_ID_BYTES, RESTORE_MARKER_TMP, -}; - -#[path = "tabs_snapshots_create_body.rs"] -mod tabs_snapshots_create_body; -use tabs_snapshots_create_body::pane_to_create_body; - -/// Stable per-pane identity key (content-derived, NOT a positional index). -fn pane_key(tab_key: &str, pane_id: &str) -> String { - format!("{tab_key}#{pane_id}") -} - -/// Deterministic key for reconciling a create whose marker promotion did not land. -fn restore_key_for(device_id: &str, source_id: &str, pane_key: &str) -> String { - format!("restore:{device_id}:{source_id}:{pane_key}") -} - -/// Read the whole marker ledger off-runtime under the persistence lock. -async fn read_marker_doc_async(device_dir: &std::path::Path) -> std::io::Result { - let dd = device_dir.to_path_buf(); - match tokio::task::spawn_blocking(move || { - freshell_ws::tabs_persist::with_persist_lock(|| read_marker_doc(&dd)) - }) - .await - { - Ok(r) => r, - Err(join) => Err(std::io::Error::other(join.to_string())), - } -} - -/// Persist the updated source ledger under the shared persistence lock. -async fn persist_marker( - device_dir: Option<&std::path::Path>, - doc: &mut MarkerDoc, - source_id: &str, - panes: &Marker, - at: i64, -) -> std::io::Result<()> { - let Some(dd) = device_dir else { return Ok(()) }; - doc.insert(source_id.to_string(), (at, panes.clone())); - prune_marker_doc(doc, Some(source_id)); - let (dd, doc) = (dd.to_path_buf(), doc.clone()); - match tokio::task::spawn_blocking(move || { - freshell_ws::tabs_persist::with_persist_lock(|| write_marker_doc(&dd, &doc)) - }) - .await - { - Ok(r) => r, - Err(join) => Err(std::io::Error::other(join.to_string())), - } -} - -/// Fence tab.create with a screenshot reply from the same selected connection. -/// A per-attempt nonce prevents a stale response from confirming a retry. -async fn confirm_delivery( - state: &TabsSnapshotsState, - target_client_id: u64, - nonce: &str, - pane_key: &str, -) -> bool { - let request_id = format!("restore-ack:{nonce}:{pane_key}"); - let rx = state - .screenshots - .register_for_client(request_id.clone(), target_client_id); - if !state - .screenshots - .send_capture_to(target_client_id, &request_id, "view", None, None) - { - state.screenshots.cancel(&request_id); - return false; - } - match tokio::time::timeout(state.restore_ack_timeout, rx).await { - Ok(Ok(_)) => true, // ANY resolved result (ok OR error) == received - // A dropped sender (pending entry cancelled/purged before the client - // answered) is NOT an ack; its removal already dropped the entry. - Ok(Err(_)) => false, - Err(_) => { - state.screenshots.cancel(&request_id); - false - } - } -} - -#[allow(clippy::result_large_err)] -fn optional_bool(body: &Value, field: &str) -> Result { - match body.get(field) { - None => Ok(false), - Some(Value::Bool(value)) => Ok(*value), - Some(_) => Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("{field} must be a boolean") })), - ) - .into_response()), - } -} - -async fn restore_tabs( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Response { - if !is_authed(&headers, &state.auth_token) { - return unauthorized(); - } - let device_id = match body.get("deviceId").and_then(Value::as_str) { - Some(d) if !d.is_empty() => d.to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "deviceId is required" })), - ) - .into_response() - } - }; - let dry_run = match optional_bool(&body, "dryRun") { - Ok(value) => value, - Err(response) => return response, - }; - let force = match optional_bool(&body, "force") { - Ok(value) => value, - Err(response) => return response, - }; - - let Some(dir) = state.snapshots_dir.clone() else { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Snapshot not found" })), - ) - .into_response(); - }; - // Malformed selectors fail closed; components have highest priority. - let selection = match parse_restore_selection(&body) { - Ok(s) => s, - Err(resp) => return resp, - }; - // Protect the source generation and its write-ahead marker for the WHOLE - // restore, including async create and delivery waits between locked IO. - let _snapshot_lease = freshell_ws::tabs_persist::protect_snapshot_device(&dir, &device_id); - let (generation_n, generation_id_req) = match &selection.selector { - Selector::Index(n) => (Some(*n), None), - Selector::Id(id) => (None, Some(id.clone())), - Selector::Union => (None, None), - }; - // Snapshot selection off the runtime (fail-loud). Errors -> 500. - enum Read { - Snap(Option), - MissingComponents(Vec), - } - let sel = ( - dir.clone(), - device_id.clone(), - selection.components.clone(), - generation_id_req.clone(), - generation_n, - ); - let read = tokio::task::spawn_blocking(move || -> std::io::Result { - let (dir, device_id, comps, gid, gn) = sel; - if !comps.is_empty() { - // Every requested component must resolve; partial unions fail. - return Ok( - match freshell_ws::tabs_persist::read_generations_union_by_ids( - &dir, &device_id, &comps, - )? { - freshell_ws::tabs_persist::ComponentsUnion::Found(v) => Read::Snap(Some(v)), - freshell_ws::tabs_persist::ComponentsUnion::Missing(m) => { - Read::MissingComponents(m) - } - }, - ); - } - Ok(Read::Snap(if let Some(id) = gid { - freshell_ws::tabs_persist::read_generation_by_id(&dir, &device_id, &id)? - } else if let Some(g) = gn { - freshell_ws::tabs_persist::read_generation(&dir, &device_id, g)? - } else { - freshell_ws::tabs_persist::read_device_union(&dir, &device_id)? - })) - }) - .await; - let snap = match read { - Ok(Ok(Read::Snap(Some(snap)))) => snap, - Ok(Ok(Read::Snap(None))) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Snapshot not found" })), - ) - .into_response() - } - Ok(Ok(Read::MissingComponents(missing))) => { - return ( - StatusCode::CONFLICT, - Json(json!({ - "error": format!( - "restore refused: requested component generation(s) not found for device {device_id}: {} (pruned or never captured). Re-capture, or pick available generations via GET /api/tabs-sync/snapshots.", - missing.join(", ") - ), - "missingComponents": missing, - })), - ) - .into_response() - } - Ok(Err(err)) => return snapshots_read_error(&dir, &err), - Err(join) => return snapshots_read_error(&dir, &join), - }; - // Validate every targeted pane key before any side effect. - if let Some(filter) = &selection.panes { - let mut known: std::collections::HashSet = std::collections::HashSet::new(); - for record in snap - .get("records") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - if record.get("status").and_then(Value::as_str) != Some("open") { - continue; - } - let tk = record.get("tabKey").and_then(Value::as_str).unwrap_or(""); - for pane in record - .get("panes") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let pid = pane.get("paneId").and_then(Value::as_str).unwrap_or(""); - known.insert(pane_key(tk, pid)); - } - } - let mut unknown: Vec = filter - .iter() - .filter(|k| !known.contains(*k)) - .cloned() - .collect(); - unknown.sort(); - if !unknown.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": format!( - "restore refused: requested pane(s) not present in the selected snapshot: {}", - unknown.join(", ") - ), - "unknownPanes": unknown, - })), - ) - .into_response(); - } - } - let source_id = freshell_ws::tabs_persist::snapshot_content_id(&snap); - let captured_at = snap.get("capturedAt").and_then(Value::as_i64).unwrap_or(0); - // Distinguish delivery fences from replies to earlier attempts. - let nonce = uuid::Uuid::new_v4().to_string(); - - let _guard = state.restore_lock.lock().await; - - // Bind all delivery and acknowledgements to this exact connection. - let (connected, target_client_id) = state.screenshots.client_snapshot(); - if !dry_run && !force && target_client_id.is_none() { - return (StatusCode::CONFLICT, Json(json!({ - "error": format!("restore requires exactly one connected browser (found {connected}); connect the target device only, or pass force"), - "connectedClients": connected, - }))).into_response(); - } - - let device_dir = freshell_ws::tabs_persist::encode_device_id(&device_id).map(|e| dir.join(e)); - // Preserve per-source history, including under force. Corrupt idempotency - // state fails closed unless force explicitly rebuilds it. - let mut doc: MarkerDoc = match &device_dir { - Some(dd) => match read_marker_doc_async(dd).await { - Ok(doc) => doc, - Err(err) if force => { - tracing::warn!(target: "freshell_server::tabs_snapshots", device_id = %device_id, - error = %err, "restore_marker_unreadable_force_override: rebuilding ledger"); - MarkerDoc::new() - } - Err(err) => { - tracing::error!(target: "freshell_server::tabs_snapshots", device_id = %device_id, - error = %err, "restore_marker_unreadable"); - return (StatusCode::CONFLICT, Json(json!({ - "error": format!( - "restore refused: the restore marker for device {device_id} is unreadable or corrupt ({err}). It is the sole idempotency record, so proceeding could duplicate previously-restored tabs. Inspect/repair or delete the marker file, or rerun with force:true to explicitly discard it." - ), - "markerError": true, - }))).into_response(); - } - }, - None => MarkerDoc::new(), - }; - let prior: Marker = doc - .get(&source_id) - .map(|(_, panes)| panes.clone()) - .unwrap_or_default(); - let mut marker: Marker = prior.clone(); // the union we will persist - let records = snap - .get("records") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - // Preflight the projected ledger, including UUID-sized terminal ids. - if !dry_run { - if let Some(device_dir) = &device_dir { - if let Err(error) = validate_restore_projection( - &device_dir.join(RESTORE_MARKER), - &doc, - &source_id, - captured_at, - force, - selection.panes.as_ref(), - &records, - ) { - return marker_preflight_error(&device_id, &error); - } - } - } - - let mut restored = Vec::new(); - let mut skipped = Vec::new(); - let mut failed = Vec::new(); - let mut delivery_confirmed = true; - let mut connection_dropped = false; // once true, remaining panes are FAILED - for record in &records { - if record.get("status").and_then(Value::as_str) != Some("open") { - continue; - } - let tab_key = record.get("tabKey").cloned().unwrap_or(Value::Null); - let tab_key_str = tab_key.as_str().unwrap_or("").to_string(); - let tab_name = record.get("tabName"); - for pane in record - .get("panes") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .iter() - { - let pane_id = pane.get("paneId").cloned().unwrap_or(Value::Null); - let pane_id_str = pane_id.as_str().unwrap_or("").to_string(); - let kind = pane.get("kind").cloned().unwrap_or(Value::Null); - let kind_str = kind.as_str().unwrap_or("").to_string(); - let pk = pane_key(&tab_key_str, &pane_id_str); - - // Report unselected panes without touching state or the connection. - if selection - .panes - .as_ref() - .is_some_and(|filter| !filter.contains(&pk)) - { - skipped.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "not-selected" })); - continue; - } - - // Classify before touching the marker or connection. - let create_body = match pane_to_create_body(tab_name, pane) { - Err("unsupported-kind") => { - skipped.push(json!({ "tabKey": tab_key, "paneId": pane_id, - "kind": kind, "reason": "unsupported-kind" })); - continue; - } - Err(reason) => { - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, - "kind": kind, "reason": reason })); - continue; - } - Ok(b) => b, - }; - let prior_mark = prior.get(&pk).cloned(); - // Ordinary reruns report restored panes as no-ops. Force bypasses - // that skip and either replaces the UI identity or recreates. - if !force { - if let Some(pm) = &prior_mark { - if pm.state == "restored" { - skipped.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "already-restored" })); - continue; - } - } - } - if connection_dropped { - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "connection-dropped" })); - continue; - } - - // Reconcile a prior live process/tab through its replayable command. - // A new target receives tab.create before its fence. Force replaces - // no-process content tabs instead. - let restore_key = restore_key_for(&device_id, &source_id, &pk); - let ledger = state.fresh_agent.lookup_restore_key(&restore_key); - let ledger_live_terminal = ledger - .as_ref() - .and_then(|entry| entry.terminal_id.clone()) - .filter(|terminal_id| state.terminals.is_running(terminal_id)); - if force && !dry_run && kind_str == "terminal" && ledger_live_terminal.is_some() { - let (old_tab_id, replacement) = state - .fresh_agent - .reissue_restore_key_terminal(&restore_key) - .expect("live restore-key terminal has tab bookkeeping"); - let close = - freshell_protocol::ServerMessage::UiCommand(freshell_protocol::UiCommand { - command: "tab.close".to_string(), - payload: Some(json!({ "id": old_tab_id })), - }); - let delivered = target_client_id.is_some_and(|target| { - state.screenshots.send_to_client(target, close) - && state - .screenshots - .send_to_client(target, replacement.ui_command.clone()) - }); - if delivered { - state - .fresh_agent - .mark_restore_key_delivered(&restore_key, target_client_id.unwrap()); - } - let confirmed = delivered - && confirm_delivery( - &state, - target_client_id.expect("delivered requires a target"), - &nonce, - &pk, - ) - .await; - if confirmed { - marker.insert( - pk.clone(), - PaneMark { - state: "restored".into(), - terminal_id: replacement.terminal_id.clone(), - }, - ); - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "terminalId": replacement.terminal_id, "tabId": replacement.tab_id, - "forcedReplacement": true })); - } else if target_client_id.is_some() { - delivery_confirmed = false; - connection_dropped = true; - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "delivery-unconfirmed", - "terminalId": replacement.terminal_id })); - } else { - delivery_confirmed = false; - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "terminalId": replacement.terminal_id, "tabId": replacement.tab_id, - "forcedReplacement": true })); - } - continue; - } - if force && !dry_run && kind_str != "terminal" { - if let Some(entry) = ledger.as_ref().filter(|e| e.terminal_id.is_none()) { - let close = - freshell_protocol::ServerMessage::UiCommand(freshell_protocol::UiCommand { - command: "tab.close".to_string(), - payload: Some(json!({ "id": entry.tab_id })), - }); - if target_client_id - .is_some_and(|target| !state.screenshots.send_to_client(target, close)) - { - delivery_confirmed = false; - connection_dropped = true; - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "delivery-unconfirmed" })); - continue; - } - state.fresh_agent.retire_restore_key_content(&restore_key); - } - } else if let Some(pm) = &prior_mark { - let live_terminal_id = pm - .terminal_id - .clone() - .filter(|t| state.terminals.is_running(t)) - .or_else(|| { - ledger - .as_ref() - .and_then(|e| e.terminal_id.clone()) - .filter(|t| state.terminals.is_running(t)) - }); - let ledger_content_tab = ledger - .as_ref() - .filter(|e| e.terminal_id.is_none()) - .map(|e| e.tab_id.clone()); - if live_terminal_id.is_some() || ledger_content_tab.is_some() { - if dry_run { - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "terminalId": live_terminal_id, - "tabId": ledger_content_tab.map(Value::from).unwrap_or(Value::Null), - "reconciled": true, "dryRun": true })); - continue; - } - let ready_for_fence = target_client_id.is_some_and(|target| { - if ledger - .as_ref() - .is_some_and(|entry| entry.delivered_to.contains(&target)) - { - return true; - } - let sent = ledger.as_ref().is_some_and(|entry| { - state - .screenshots - .send_to_client(target, entry.ui_command.clone()) - }); - if sent { - state - .fresh_agent - .mark_restore_key_delivered(&restore_key, target); - } - sent - }); - let acked = ready_for_fence - && confirm_delivery( - &state, - target_client_id.expect("checked Some"), - &nonce, - &pk, - ) - .await; - if !acked { - delivery_confirmed = false; - connection_dropped = target_client_id.is_some(); - marker.insert(pk.clone(), pm.clone()); - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "delivery-unconfirmed", - "terminalId": live_terminal_id })); - } else { - marker.insert( - pk.clone(), - PaneMark { - state: "restored".into(), - terminal_id: live_terminal_id.clone(), - }, - ); - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "terminalId": live_terminal_id, - "tabId": ledger_content_tab.map(Value::from).unwrap_or(Value::Null), - "reconciled": true })); - } - continue; - } - if pm.state == "in-progress" && kind_str != "terminal" && !force { - marker.insert(pk.clone(), pm.clone()); - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "in-progress-unconfirmed", - "hint": "an earlier attempt's tab.create may have reached the client; verify the pane in the target browser, then rerun with force:true to recreate it" })); - continue; - } - } - if dry_run { - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "request": create_body, "tabId": Value::Null, "dryRun": true })); - continue; - } - - // Write ahead before create; restoreKey reconciles the crash window. - marker.insert( - pk.clone(), - PaneMark { - state: "in-progress".into(), - terminal_id: None, - }, - ); - if let Err(err) = persist_marker( - device_dir.as_deref(), - &mut doc, - &source_id, - &marker, - captured_at, - ) - .await - { - return marker_error( - &device_id, - &snap, - connected, - delivery_confirmed, - restored, - skipped, - failed, - &err, - ); - } - - let mut tagged_body = create_body.clone(); - tagged_body["restoreKey"] = json!(restore_key); - let resp = freshell_freshagent::terminal_tabs::create_terminal_or_content_tab_deferred( - state.fresh_agent.clone(), - tagged_body, - ) - .await; - let status = resp.status(); - let rbytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap_or_default(); - let resp_body: Value = serde_json::from_slice(&rbytes).unwrap_or(Value::Null); - if !(status.is_success() - && resp_body.get("status").and_then(Value::as_str) == Some("ok")) - { - // A failed create restores any prior marker entry. - match &prior_mark { - Some(pm) => { - marker.insert(pk.clone(), pm.clone()); - } - None => { - marker.remove(&pk); - } - } - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "status": status.as_u16(), "error": resp_body })); - continue; - } - let data = resp_body.get("data").cloned().unwrap_or(Value::Null); - let terminal_id = data - .get("terminalId") - .and_then(Value::as_str) - .map(str::to_string); - let ui_command = data.get("uiCommand").cloned().and_then(|value| { - serde_json::from_value::(value).ok() - }); - // The durable pre-create record preserves write-ahead safety. Batch - // this terminal id into the next write-ahead or final marker write. - marker.insert( - pk.clone(), - PaneMark { - state: "in-progress".into(), - terminal_id: terminal_id.clone(), - }, - ); - // Deliver and fence on the exact selected target. - let delivered = target_client_id - .zip(ui_command) - .is_some_and(|(target, command)| { - let sent = state.screenshots.send_to_client(target, command); - if sent { - state - .fresh_agent - .mark_restore_key_delivered(&restore_key, target); - } - sent - }); - let confirmed = if delivered { - confirm_delivery( - &state, - target_client_id.expect("delivered requires a target"), - &nonce, - &pk, - ) - .await - } else { - false - }; - if confirmed { - marker.insert( - pk.clone(), - PaneMark { - state: "restored".into(), - terminal_id: terminal_id.clone(), - }, - ); - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "tabId": data.get("tabId").cloned().unwrap_or(Value::Null), "terminalId": terminal_id })); - } else if target_client_id.is_some() { - // Keep the created terminal in-progress for a safe retry. - delivery_confirmed = false; - connection_dropped = true; - failed.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "reason": "delivery-unconfirmed", "terminalId": terminal_id })); - } else { - // force + 0 clients: created but delivery cannot be confirmed. - delivery_confirmed = false; - restored.push(json!({ "tabKey": tab_key, "paneId": pane_id, "kind": kind, - "tabId": data.get("tabId").cloned().unwrap_or(Value::Null), "terminalId": terminal_id })); - } - } - } - - if !dry_run { - if let Err(err) = persist_marker( - device_dir.as_deref(), - &mut doc, - &source_id, - &marker, - captured_at, - ) - .await - { - return marker_error( - &device_id, - &snap, - connected, - delivery_confirmed, - restored, - skipped, - failed, - &err, - ); - } - } - - Json(json!({ - "deviceId": device_id, - "generation": generation_n, - "generationId": generation_id_req, - "sourceId": source_id, - "sourceCapturedAt": snap.get("capturedAt").cloned().unwrap_or(Value::Null), - "broadcastScope": if target_client_id.is_some() { "target-client" } else { "none" }, - "connectedClients": connected, - "deliveryConfirmed": dry_run || delivery_confirmed, - "restored": restored, - "skipped": skipped, - "failed": failed, - })) - .into_response() -} - -fn marker_preflight_error(device_id: &str, error: &dyn std::fmt::Display) -> Response { - tracing::warn!(target: "freshell_server::tabs_snapshots", device_id = %device_id, - error = %error, "restore_marker_limit_rejected"); - ( - StatusCode::CONFLICT, - Json(json!({ - "error": format!("restore refused: restore marker limit exceeded ({error})"), - "markerError": true, - })), - ) - .into_response() -} - -/// A marker write failed AFTER side-effects: 500, fail LOUDLY, echoing what was -/// done so a lost marker can never read as a clean success. -#[allow(clippy::too_many_arguments)] -fn marker_error( - device_id: &str, - snap: &Value, - connected: i64, - delivery_confirmed: bool, - restored: Vec, - skipped: Vec, - failed: Vec, - err: &dyn std::fmt::Display, -) -> Response { - tracing::error!(target: "freshell_server::tabs_snapshots", device_id = %device_id, - error = %err, "restore_marker_write_failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "deviceId": device_id, - "sourceCapturedAt": snap.get("capturedAt").cloned().unwrap_or(Value::Null), - "connectedClients": connected, - "deliveryConfirmed": delivery_confirmed, - "error": format!("restore marker write failed: {err}"), - "markerError": true, - "restored": restored, "skipped": skipped, "failed": failed, - })), - ) - .into_response() -} - #[cfg(test)] #[path = "tabs_snapshots_tests.rs"] mod tests; diff --git a/crates/freshell-server/src/tabs_snapshots_create_body.rs b/crates/freshell-server/src/tabs_snapshots_create_body.rs deleted file mode 100644 index 6c6ca8563..000000000 --- a/crates/freshell-server/src/tabs_snapshots_create_body.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! `pane_to_create_body` — extracted from `tabs_snapshots.rs` to honor the -//! 1,000-line file cap (port/AGENTS.md) when the createRequestId passthrough -//! was added. Included via `#[path]` from `tabs_snapshots.rs`, matching the -//! sibling pattern used by `tabs_snapshots_marker.rs` / `tabs_snapshots_tests.rs`. - -use serde_json::{json, Value}; - -/// Map a snapshot pane to its `POST /api/tabs` body. Invalid session identity -/// fails before spawn; unsupported kinds are skips. Captured terminal, browser, -/// and editor options pass through to the restored pane. -pub(crate) fn pane_to_create_body( - tab_name: Option<&Value>, - pane: &Value, -) -> Result { - let payload = pane.get("payload").cloned().unwrap_or_else(|| json!({})); - let kind = pane.get("kind").and_then(Value::as_str).unwrap_or(""); - let name = tab_name.cloned().unwrap_or(Value::Null); - match kind { - "terminal" => { - let mode = payload - .get("mode") - .and_then(Value::as_str) - .unwrap_or("shell"); - let mut b = json!({ "mode": mode, "name": name }); - if let Some(cwd) = payload.get("initialCwd").filter(|v| v.is_string()) { - b["cwd"] = cwd.clone(); - } - if let Some(shell) = payload.get("shell").filter(|v| v.is_string()) { - b["shell"] = shell.clone(); - } - // Stable pane identity key (reconciliation design §5.5): restore - // re-creates the pane under its CAPTURED key so server-side state - // keyed on it survives; absent on legacy snapshots (the REST - // ingress mints one in that case). - if let Some(crid) = payload.get("createRequestId").filter(|v| v.is_string()) { - b["createRequestId"] = crid.clone(); - } - if let Some(cd) = payload.get("codexDurability").filter(|v| v.is_object()) { - if mode == "codex" { - b["codexDurability"] = cd.clone(); - } - } - // Present identity must be nonempty and match the terminal mode. - if let Some(sref) = payload.get("sessionRef").filter(|v| !v.is_null()) { - let ok = sref.is_object() - && sref.get("provider").and_then(Value::as_str) == Some(mode) - && sref - .get("sessionId") - .and_then(Value::as_str) - .is_some_and(|s| !s.is_empty()); - if !ok { - return Err("session-identity-mismatch"); - } - b["sessionRef"] = sref.clone(); - } - Ok(b) - } - "browser" => match payload.get("url").and_then(Value::as_str) { - Some(url) => { - let mut b = json!({ "browser": url, "name": name }); - if let Some(dt) = payload.get("devToolsOpen").filter(|v| v.is_boolean()) { - b["devToolsOpen"] = dt.clone(); - } - Ok(b) - } - None => Err("missing-url"), - }, - "editor" => match payload.get("filePath") { - Some(file_path) if file_path.is_string() || file_path.is_null() => { - let mut b = json!({ "editor": file_path, "name": name }); - if let Some(lang) = payload.get("language").filter(|v| v.is_string()) { - b["language"] = lang.clone(); - } - if let Some(ro) = payload.get("readOnly").filter(|v| v.is_boolean()) { - b["readOnly"] = ro.clone(); - } - if let Some(vm) = payload.get("viewMode").filter(|v| v.is_string()) { - b["viewMode"] = vm.clone(); - } - if let Some(ww) = payload.get("wordWrap").filter(|v| v.is_boolean()) { - b["wordWrap"] = ww.clone(); - } - Ok(b) - } - _ => Err("missing-filePath"), - }, - _ => Err("unsupported-kind"), - } -} diff --git a/crates/freshell-server/src/tabs_snapshots_marker.rs b/crates/freshell-server/src/tabs_snapshots_marker.rs deleted file mode 100644 index e0f32701f..000000000 --- a/crates/freshell-server/src/tabs_snapshots_marker.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! Write-ahead restore marker: the on-disk idempotency ledger for -//! `POST /api/tabs-sync/restore` (`tabs_snapshots.rs`). Split into its own -//! `#[path]`-included module to keep the handler file under the repo's -//! 1,000-line-per-file limit. -//! -//! FORMAT (v2, multi-source): one `last-restore.marker` per device dir holding -//! EVERY source's pane history: -//! -//! ```json -//! { "version": 2, -//! "sources": { "": { "at": 123, "panes": { -//! "#": { "state": "in-progress|restored", "terminalId": null } } } } } -//! ``` -//! -//! Keeping history per `(deviceId, sourceId)` (NOT a single last-source entry) -//! is what makes restore idempotent across source switches: restoring source -//! A, then B, then A again must still see A's `restored` panes and skip them -//! (`tabs_snapshots.rs:304`). A v1 single-source marker (top-level -//! `sourceId`/`panes`) is migrated on read. -//! -//! READS FAIL LOUD (`tabs_snapshots.rs:306`): this file is the SOLE -//! idempotency record, so an unreadable or unparsable marker is an `Err` the -//! handler surfaces as HTTP 409 with recovery instructions — NEVER an empty -//! marker (which would read as "nothing restored yet" and duplicate every -//! previously-restored tab on an ordinary rerun). - -use std::collections::HashMap; -use std::io::Write; -use std::path::Path; - -use serde::ser::{SerializeMap, SerializeStruct}; -use serde::Serialize; -use serde_json::Value; - -pub(super) const RESTORE_MARKER: &str = "last-restore.marker"; // .marker ext -> invisible to *.json listing - -/// In-flight temp filename for the atomic marker write. Deliberately `.new`, -/// NOT `.tmp`: freshell-ws's `sweep_orphan_tmp` reaps every `*.tmp` in this -/// SAME device dir. Marker IO now runs under the SHARED -/// `freshell_ws::tabs_persist::with_persist_lock`, so the sweep can no longer -/// run mid-write — the `.new` name stays as defense-in-depth (pinned by -/// `marker_in_flight_temp_is_invisible_to_the_ws_orphan_tmp_sweep` and the -/// freshell-ws sweep test). -pub(super) const RESTORE_MARKER_TMP: &str = ".last-restore.marker.new"; -/// Bound the per-device restore history independently of source churn. This -/// matches the maximum number of retained generation files for one device, so -/// marker IO and disk usage cannot grow without limit. -pub(super) const MAX_RESTORE_MARKER_SOURCES: usize = - freshell_ws::tabs_persist::MAX_SNAPSHOT_FILES_PER_DEVICE; -/// One device retains at most 40 MiB of snapshot-generation payload. Allow a -/// 64 MiB marker so ordinary ledger overhead fits while marker disk/memory use -/// remains explicitly bounded. -pub(super) const MAX_RESTORE_MARKER_BYTES: usize = - 64 * freshell_ws::tabs_persist::MAX_SNAPSHOT_BYTES; -/// A valid pane occupies more than 32 serialized bytes in its source snapshot, -/// so this accommodates every pane that could fit in all 40 retained files. -pub(super) const MAX_RESTORE_MARKER_PANES_PER_SOURCE: usize = - freshell_ws::tabs_persist::MAX_SNAPSHOT_FILES_PER_DEVICE - * (freshell_ws::tabs_persist::MAX_SNAPSHOT_BYTES / 32); -/// Content source ids are 32 hex bytes today; leave room for future digests. -pub(super) const MAX_RESTORE_MARKER_SOURCE_ID_BYTES: usize = 128; -/// Browser-generated tab/pane ids are short. 64 KiB is deliberately generous -/// while preventing repeated tab keys from amplifying one marker without bound. -pub(super) const MAX_RESTORE_MARKER_PANE_KEY_BYTES: usize = 64 * 1024; -/// Terminal ids are UUID-shaped today; leave room for future formats. -pub(super) const MAX_RESTORE_MARKER_TERMINAL_ID_BYTES: usize = 128; - -/// One pane's marker state (`in-progress` = write-ahead, side-effect may exist, -/// delivery NOT yet confirmed; `restored` = delivery-acked). `terminal_id` is -/// recorded once the create returns, for crash reconciliation. -#[derive(Clone)] -pub(super) struct PaneMark { - pub state: String, - pub terminal_id: Option, -} -/// paneKey -> mark, for ONE source. -pub(super) type Marker = HashMap; -/// sourceId -> (at, panes): the whole on-disk ledger. -pub(super) type MarkerDoc = HashMap; - -fn marker_limit(path: &Path, why: impl std::fmt::Display) -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("restore marker {} limit exceeded: {why}", path.display()), - ) -} - -fn validate_identifier( - path: &Path, - name: &str, - value: &str, - max_bytes: usize, -) -> std::io::Result<()> { - if value.is_empty() { - return Err(marker_limit(path, format!("{name} must be non-empty"))); - } - if value.len() > max_bytes { - return Err(marker_limit( - path, - format!( - "{name} identifier length limit is {max_bytes} bytes (got {})", - value.len() - ), - )); - } - Ok(()) -} - -pub(super) fn validate_marker_pane_count( - path: &Path, - source_id: &str, - pane_count: usize, -) -> std::io::Result<()> { - if pane_count > MAX_RESTORE_MARKER_PANES_PER_SOURCE { - return Err(marker_limit( - path, - format!( - "source `{source_id}` pane-count limit is {MAX_RESTORE_MARKER_PANES_PER_SOURCE} (got {pane_count})" - ), - )); - } - Ok(()) -} - -fn parse_panes(path: &Path, source_id: &str, value: &Value) -> std::io::Result { - let map = value.as_object().ok_or_else(|| { - corrupt( - path, - format!("source `{source_id}` `panes` is not an object"), - ) - })?; - validate_marker_pane_count(path, source_id, map.len())?; - let mut out = Marker::new(); - for (key, pane) in map { - validate_identifier( - path, - &format!("source `{source_id}` pane key"), - key, - MAX_RESTORE_MARKER_PANE_KEY_BYTES, - )?; - let pane = pane.as_object().ok_or_else(|| { - corrupt( - path, - format!("source `{source_id}` pane `{key}` is not an object"), - ) - })?; - let state = pane - .get("state") - .and_then(Value::as_str) - .filter(|state| matches!(*state, "in-progress" | "restored")) - .ok_or_else(|| { - corrupt( - path, - format!("source `{source_id}` pane `{key}` has an invalid or missing `state`"), - ) - })? - .to_string(); - let terminal_id = match pane.get("terminalId") { - None | Some(Value::Null) => None, - Some(Value::String(id)) => { - validate_identifier( - path, - &format!("source `{source_id}` pane `{key}` terminal id"), - id, - MAX_RESTORE_MARKER_TERMINAL_ID_BYTES, - )?; - Some(id.clone()) - } - Some(_) => { - return Err(corrupt( - path, - format!( - "source `{source_id}` pane `{key}` `terminalId` must be null or a non-empty string" - ), - )) - } - }; - out.insert(key.clone(), PaneMark { state, terminal_id }); - } - Ok(out) -} - -fn corrupt(path: &Path, why: impl std::fmt::Display) -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("corrupt restore marker {}: {why}", path.display()), - ) -} - -/// Read the WHOLE marker ledger. A MISSING file is genuine absence -/// (`Ok(empty)`); an unreadable file or unparsable/unrecognized content is an -/// `Err` (fail LOUD — see module doc). Blocking fs — call via `spawn_blocking` -/// under `freshell_ws::tabs_persist::with_persist_lock`. -pub(super) fn read_marker_doc(device_dir: &Path) -> std::io::Result { - let path = device_dir.join(RESTORE_MARKER); - match std::fs::metadata(&path) { - Ok(metadata) if metadata.len() > MAX_RESTORE_MARKER_BYTES as u64 => { - return Err(marker_limit( - &path, - format!( - "serialized byte limit is {MAX_RESTORE_MARKER_BYTES} (got {})", - metadata.len() - ), - )) - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(MarkerDoc::new()), - Err(error) => { - return Err(std::io::Error::new( - error.kind(), - format!("unreadable restore marker {}: {error}", path.display()), - )) - } - } - let text = match std::fs::read_to_string(&path) { - Ok(t) => t, - Err(e) => { - return Err(std::io::Error::new( - e.kind(), - format!("unreadable restore marker {}: {e}", path.display()), - )) - } - }; - if text.len() > MAX_RESTORE_MARKER_BYTES { - return Err(marker_limit( - &path, - "serialized byte limit changed during read", - )); - } - let v: Value = serde_json::from_str(&text).map_err(|e| corrupt(&path, e))?; - let mut doc = MarkerDoc::new(); - if v.get("version").is_some() || v.get("sources").is_some() { - if v.get("version").and_then(Value::as_i64) != Some(2) { - return Err(corrupt(&path, "`version` must be exactly 2")); - } - let map = v - .get("sources") - .and_then(Value::as_object) - .ok_or_else(|| corrupt(&path, "required `sources` is not an object"))?; - for (sid, s) in map { - validate_identifier(&path, "source id", sid, MAX_RESTORE_MARKER_SOURCE_ID_BYTES)?; - let source = s - .as_object() - .ok_or_else(|| corrupt(&path, format!("source `{sid}` is not an object")))?; - let at = source - .get("at") - .and_then(Value::as_i64) - .ok_or_else(|| corrupt(&path, format!("source `{sid}` is missing integer `at`")))?; - let panes = source - .get("panes") - .ok_or_else(|| corrupt(&path, format!("source `{sid}` is missing `panes`")))?; - doc.insert(sid.clone(), (at, parse_panes(&path, sid, panes)?)); - } - prune_marker_doc(&mut doc, None); - validate_marker_doc(&path, &doc)?; - return Ok(doc); - } - // v1 migration: a single top-level {sourceId, at, panes}. - if let Some(sid) = v - .get("sourceId") - .and_then(Value::as_str) - .filter(|sid| !sid.is_empty()) - { - validate_identifier(&path, "source id", sid, MAX_RESTORE_MARKER_SOURCE_ID_BYTES)?; - let at = v - .get("at") - .and_then(Value::as_i64) - .ok_or_else(|| corrupt(&path, "v1 marker is missing integer `at`"))?; - let panes = v - .get("panes") - .ok_or_else(|| corrupt(&path, "v1 marker is missing `panes`"))?; - doc.insert(sid.to_string(), (at, parse_panes(&path, sid, panes)?)); - validate_marker_doc(&path, &doc)?; - return Ok(doc); - } - Err(corrupt( - &path, - "unrecognized marker shape (no sources/sourceId)", - )) -} - -struct MarkerFile<'a>(&'a MarkerDoc); -struct MarkerSources<'a>(&'a MarkerDoc); -struct MarkerSource<'a> { - at: i64, - panes: &'a Marker, -} -struct MarkerPanes<'a> { - at: i64, - panes: &'a Marker, -} -struct MarkerPane<'a> { - at: i64, - mark: &'a PaneMark, -} - -impl Serialize for MarkerFile<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut root = serializer.serialize_struct("RestoreMarker", 2)?; - root.serialize_field("version", &2)?; - root.serialize_field("sources", &MarkerSources(self.0))?; - root.end() - } -} - -impl Serialize for MarkerSources<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map = serializer.serialize_map(Some(self.0.len()))?; - for (source_id, (at, panes)) in self.0 { - map.serialize_entry(source_id, &MarkerSource { at: *at, panes })?; - } - map.end() - } -} - -impl Serialize for MarkerSource<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut source = serializer.serialize_struct("RestoreMarkerSource", 2)?; - source.serialize_field("at", &self.at)?; - source.serialize_field( - "panes", - &MarkerPanes { - at: self.at, - panes: self.panes, - }, - )?; - source.end() - } -} - -impl Serialize for MarkerPanes<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map = serializer.serialize_map(Some(self.panes.len()))?; - for (pane_key, mark) in self.panes { - map.serialize_entry(pane_key, &MarkerPane { at: self.at, mark })?; - } - map.end() - } -} - -impl Serialize for MarkerPane<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut pane = serializer.serialize_struct("RestoreMarkerPane", 3)?; - pane.serialize_field("state", &self.mark.state)?; - pane.serialize_field("terminalId", &self.mark.terminal_id)?; - pane.serialize_field("at", &self.at)?; - pane.end() - } -} - -struct MarkerSizeWriter<'a> { - path: &'a Path, - bytes: usize, -} - -impl Write for MarkerSizeWriter<'_> { - fn write(&mut self, buffer: &[u8]) -> std::io::Result { - let next = self - .bytes - .checked_add(buffer.len()) - .ok_or_else(|| marker_limit(self.path, "serialized byte count overflow"))?; - if next > MAX_RESTORE_MARKER_BYTES { - return Err(marker_limit( - self.path, - format!( - "serialized byte limit is {MAX_RESTORE_MARKER_BYTES} (more than {MAX_RESTORE_MARKER_BYTES})" - ), - )); - } - self.bytes = next; - Ok(buffer.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -pub(super) fn validate_marker_doc(path: &Path, doc: &MarkerDoc) -> std::io::Result<()> { - for (source_id, (_, panes)) in doc { - validate_identifier( - path, - "source id", - source_id, - MAX_RESTORE_MARKER_SOURCE_ID_BYTES, - )?; - validate_marker_pane_count(path, source_id, panes.len())?; - for (pane_key, mark) in panes { - validate_identifier( - path, - &format!("source `{source_id}` pane key"), - pane_key, - MAX_RESTORE_MARKER_PANE_KEY_BYTES, - )?; - if !matches!(mark.state.as_str(), "in-progress" | "restored") { - return Err(marker_limit( - path, - format!("source `{source_id}` pane `{pane_key}` has invalid state"), - )); - } - if let Some(terminal_id) = &mark.terminal_id { - validate_identifier( - path, - &format!("source `{source_id}` pane `{pane_key}` terminal id"), - terminal_id, - MAX_RESTORE_MARKER_TERMINAL_ID_BYTES, - )?; - } - } - } - let mut writer = MarkerSizeWriter { path, bytes: 0 }; - serde_json::to_writer(&mut writer, &MarkerFile(doc)).map_err(|error| { - marker_limit( - path, - format!("serialized byte limit validation failed: {error}"), - ) - }) -} - -pub(super) fn validate_restore_projection( - path: &Path, - doc: &MarkerDoc, - source_id: &str, - captured_at: i64, - force: bool, - selected_panes: Option<&std::collections::HashSet>, - records: &[Value], -) -> std::io::Result<()> { - let current = doc.get(source_id).map(|(_, panes)| panes); - let mut projected = current.cloned().unwrap_or_default(); - for record in records { - if record.get("status").and_then(Value::as_str) != Some("open") { - continue; - } - let tab_key = record.get("tabKey").and_then(Value::as_str).unwrap_or(""); - for pane in record - .get("panes") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let pane_id = pane.get("paneId").and_then(Value::as_str).unwrap_or(""); - let key = super::pane_key(tab_key, pane_id); - if selected_panes.is_some_and(|filter| !filter.contains(&key)) - || super::pane_to_create_body(record.get("tabName"), pane).is_err() - { - continue; - } - if !force - && current - .and_then(|panes| panes.get(&key)) - .is_some_and(|mark| mark.state == "restored") - { - continue; - } - let is_terminal = pane.get("kind").and_then(Value::as_str) == Some("terminal"); - projected - .entry(key) - .and_modify(|mark| { - mark.state = "in-progress".into(); - if is_terminal - && mark - .terminal_id - .as_ref() - .is_none_or(|terminal_id| terminal_id.len() < 36) - { - mark.terminal_id = Some("00000000-0000-0000-0000-000000000000".to_string()); - } - }) - .or_insert_with(|| PaneMark { - state: "in-progress".into(), - terminal_id: is_terminal - .then(|| "00000000-0000-0000-0000-000000000000".to_string()), - }); - } - } - let mut projected_doc = doc.clone(); - projected_doc.insert(source_id.to_string(), (captured_at, projected)); - prune_marker_doc(&mut projected_doc, Some(source_id)); - validate_marker_doc(path, &projected_doc) -} - -/// Retain a deterministic newest-first source window. `keep_source_id` is the -/// source currently being restored; it is never evicted by older capturedAt -/// metadata while its write-ahead state is being updated. -pub(super) fn prune_marker_doc(doc: &mut MarkerDoc, keep_source_id: Option<&str>) { - if doc.len() <= MAX_RESTORE_MARKER_SOURCES { - return; - } - let mut ranked: Vec<(i64, String)> = doc - .iter() - .filter(|(source_id, _)| keep_source_id != Some(source_id.as_str())) - .map(|(source_id, (at, _))| (*at, source_id.clone())) - .collect(); - ranked.sort_by(|a, b| b.cmp(a)); - let other_limit = MAX_RESTORE_MARKER_SOURCES - - usize::from(keep_source_id.is_some_and(|id| doc.contains_key(id))); - let retained: std::collections::HashSet = ranked - .into_iter() - .take(other_limit) - .map(|(_, source_id)| source_id) - .collect(); - doc.retain(|source_id, _| { - keep_source_id == Some(source_id.as_str()) || retained.contains(source_id) - }); -} - -/// Durable atomic write of the WHOLE ledger: the temporary file is synced, -/// renamed, and the parent directory is synced before success is reported. -/// Returns Err so the handler fails LOUDLY. Blocking fs — call via -/// `spawn_blocking` under `freshell_ws::tabs_persist::with_persist_lock`. -pub(super) fn write_marker_doc(device_dir: &Path, doc: &MarkerDoc) -> std::io::Result<()> { - std::fs::create_dir_all(device_dir)?; - let mut bounded = doc.clone(); - prune_marker_doc(&mut bounded, None); - let destination = device_dir.join(RESTORE_MARKER); - validate_marker_doc(&destination, &bounded)?; - let bytes = serde_json::to_vec(&MarkerFile(&bounded)) - .map_err(|error| std::io::Error::other(format!("restore marker serialization: {error}")))?; - debug_assert!(bytes.len() <= MAX_RESTORE_MARKER_BYTES); - let tmp = device_dir.join(RESTORE_MARKER_TMP); - freshell_ws::tabs_persist::atomic_write_durable(&destination, &tmp, &bytes) -} diff --git a/crates/freshell-server/src/tabs_snapshots_restore_tests.rs b/crates/freshell-server/src/tabs_snapshots_restore_tests.rs deleted file mode 100644 index 3dbaa6414..000000000 --- a/crates/freshell-server/src/tabs_snapshots_restore_tests.rs +++ /dev/null @@ -1,1348 +0,0 @@ -//! Restore-hardening tests (cross-model review delta r1): marker ledger -//! idempotency across source switches, fail-loud marker reads, fail-closed -//! body selectors, loud missing-component bundles, full pane-state -//! round-trips, the write-ahead crash window, invocation-unique delivery-ack -//! ids, and the targeted `panes` filter. Nested `#[path]`-included child of -//! `tabs_snapshots_tests.rs` so both files stay under the repo's -//! 1,000-line-per-file limit; helpers (`rig`/`connected`/`post`/`rec`/...) -//! come from the parent test module. - -use super::*; - -/// Drain every frame currently buffered on a broadcast receiver. -fn drain(rx: &mut tokio::sync::broadcast::Receiver) -> Vec { - let mut out = Vec::new(); - while let Ok(frame) = rx.try_recv() { - if let Ok(v) = serde_json::from_str::(&frame) { - out.push(v); - } - } - out -} -fn tab_creates(frames: &[Value]) -> Vec { - frames - .iter() - .filter(|v| v["type"] == "ui.command" && v["command"] == "tab.create") - .map(|v| v["payload"].clone()) - .collect() -} -fn ack_request_ids(frames: &[Value]) -> Vec { - frames - .iter() - .filter(|v| v["type"] == "ui.command" && v["command"] == "screenshot.capture") - .filter_map(|v| v["payload"]["requestId"].as_str().map(str::to_string)) - .collect() -} -fn device_dir(dir: &std::path::Path, device: &str) -> std::path::PathBuf { - dir.join(freshell_ws::tabs_persist::encode_device_id(device).unwrap()) -} -fn union_source_id(dir: &std::path::Path, device: &str) -> String { - let snap = freshell_ws::tabs_persist::read_device_union(dir, device) - .unwrap() - .expect("seeded union"); - freshell_ws::tabs_persist::snapshot_content_id(&snap) -} -fn gen_id(dir: &std::path::Path, device: &str, n: usize) -> String { - let snap = freshell_ws::tabs_persist::read_generation(dir, device, n) - .unwrap() - .expect("generation present"); - freshell_ws::tabs_persist::snapshot_generation_id(&snap) -} - -#[tokio::test] -async fn restore_history_survives_source_switch_a_b_a() { - // MARKER LEDGER (`:304`): restore A, then B, then A again — A's panes must - // be skipped `already-restored`, never duplicated, even though B was - // restored in between (a single last-source marker would have wiped A). - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "tA", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let id_a = gen_id(dir.path(), "dev-1", 0); - seed_records( - dir.path(), - "dev-1", - "c1", - 2, - vec![rec( - "tB", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let id_b = gen_id(dir.path(), "dev-1", 0); - assert_ne!(id_a, id_b); - let (r, _on, _h) = connected(dir.path()); - let go = |gid: String, r: &Rig| { - let router = router(r.state.clone()); - async move { - post( - router, - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "generationId": gid }), - true, - ) - .await - .1 - } - }; - let first_a = go(id_a.clone(), &r).await; - assert_eq!( - first_a["restored"].as_array().unwrap().len(), - 1, - "{first_a}" - ); - let first_b = go(id_b.clone(), &r).await; - assert_eq!( - first_b["restored"].as_array().unwrap().len(), - 1, - "{first_b}" - ); - // A again: NOTHING recreated (its history survived B's restore). - let second_a = go(id_a.clone(), &r).await; - assert_eq!( - second_a["restored"].as_array().unwrap().len(), - 0, - "A's panes must not be duplicated after restoring B: {second_a}" - ); - assert_eq!(second_a["skipped"][0]["reason"], "already-restored"); -} - -#[tokio::test] -async fn corrupt_marker_fails_loud_409_and_force_overrides() { - // FAIL-LOUD MARKER READS (`:306`): a present-but-unparsable marker is a - // 409 telling the operator how to proceed — never "nothing restored yet". - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let dd = device_dir(dir.path(), "dev-1"); - std::fs::write(dd.join(RESTORE_MARKER), b"{ not json").unwrap(); - let (r, _on, _h) = connected(dir.path()); - let (status, body) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{body}"); - assert_eq!(body["markerError"], true); - assert!( - body["error"].as_str().unwrap().contains("force"), - "must tell the operator how to proceed: {body}" - ); - // force:true is the explicit override: discard the unreadable ledger. - let (status, body) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["restored"].as_array().unwrap().len(), 1); -} - -#[tokio::test] -async fn unreadable_marker_fails_loud_409() { - // An UNREADABLE (not merely corrupt) marker — here the path is a - // directory, so read fails with EISDIR — must also refuse loudly. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - std::fs::create_dir(device_dir(dir.path(), "dev-1").join(RESTORE_MARKER)).unwrap(); - let (r, _on, _h) = connected(dir.path()); - let (status, body) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{body}"); - assert_eq!(body["markerError"], true); -} - -#[test] -fn semantically_corrupt_marker_documents_are_rejected() { - let dir = tempfile::tempdir().unwrap(); - let dd = device_dir(dir.path(), "dev-1"); - std::fs::create_dir_all(&dd).unwrap(); - for bad in [ - json!({ "version": 99, "sources": {} }), - json!({ "version": 2, "sources": { "source": {} } }), - json!({ "version": 2, "sources": { "source": { "at": 1 } } }), - json!({ "version": 2, "sources": { "source": { "panes": {} } } }), - json!({ "version": 2, "sources": { "source": { - "at": 1, "panes": { "tab#pane": { "state": "garbage", "terminalId": null } } - } } }), - ] { - std::fs::write( - dd.join(RESTORE_MARKER), - serde_json::to_vec_pretty(&bad).unwrap(), - ) - .unwrap(); - assert!( - read_marker_doc(&dd).is_err(), - "semantically corrupt marker was accepted: {bad}" - ); - } -} - -#[test] -fn marker_ledger_retention_bounds_sources_and_keeps_the_newest() { - let dir = tempfile::tempdir().unwrap(); - let dd = device_dir(dir.path(), "dev-1"); - let mut doc = MarkerDoc::new(); - for n in 0..(MAX_RESTORE_MARKER_SOURCES + 7) { - let mut panes = Marker::new(); - panes.insert( - format!("tab-{n}#pane"), - PaneMark { - state: "restored".into(), - terminal_id: Some(format!("terminal-{n}")), - }, - ); - doc.insert(format!("source-{n:03}"), (n as i64, panes)); - } - write_marker_doc(&dd, &doc).unwrap(); - let retained = read_marker_doc(&dd).unwrap(); - assert_eq!(retained.len(), MAX_RESTORE_MARKER_SOURCES); - assert!(retained.contains_key(&format!("source-{:03}", MAX_RESTORE_MARKER_SOURCES + 6))); - assert!(!retained.contains_key("source-000")); - - // The source currently being updated remains even when it is older than - // every other entry; pruning may evict the next-oldest source instead. - let mut with_active = doc; - prune_marker_doc(&mut with_active, Some("source-000")); - assert_eq!(with_active.len(), MAX_RESTORE_MARKER_SOURCES); - assert!(with_active.contains_key("source-000")); -} - -#[test] -fn marker_serialized_byte_cap_accepts_boundary_and_rejects_over() { - let dir = tempfile::tempdir().unwrap(); - let dd = device_dir(dir.path(), "dev-1"); - std::fs::create_dir_all(&dd).unwrap(); - let mut bytes = br#"{"version":2,"sources":{}}"#.to_vec(); - bytes.resize(MAX_RESTORE_MARKER_BYTES, b' '); - std::fs::write(dd.join(RESTORE_MARKER), &bytes).unwrap(); - assert!( - read_marker_doc(&dd).is_ok(), - "a marker exactly at the byte cap must remain readable" - ); - - bytes.push(b' '); - std::fs::write(dd.join(RESTORE_MARKER), &bytes).unwrap(); - let error = match read_marker_doc(&dd) { - Ok(_) => panic!("over-cap marker must fail loudly"), - Err(error) => error, - }; - assert!( - error.to_string().contains("byte limit"), - "clear byte-cap error: {error}" - ); -} - -#[test] -fn marker_pane_count_cap_accepts_boundary_and_rejects_over() { - let path = std::path::Path::new(""); - assert!( - validate_marker_pane_count(path, "source", MAX_RESTORE_MARKER_PANES_PER_SOURCE).is_ok() - ); - let error = validate_marker_pane_count(path, "source", MAX_RESTORE_MARKER_PANES_PER_SOURCE + 1) - .expect_err("over-cap pane count must fail loudly"); - assert!( - error.to_string().contains("pane-count limit"), - "clear pane-count error: {error}" - ); -} - -#[test] -fn marker_identifier_caps_accept_boundaries_and_reject_over() { - fn doc(source: String, pane_key: String, terminal_id: String) -> MarkerDoc { - let mut panes = Marker::new(); - panes.insert( - pane_key, - PaneMark { - state: "restored".into(), - terminal_id: Some(terminal_id), - }, - ); - MarkerDoc::from([(source, (1, panes))]) - } - - let dir = tempfile::tempdir().unwrap(); - let dd = device_dir(dir.path(), "dev-1"); - write_marker_doc( - &dd, - &doc( - "s".repeat(MAX_RESTORE_MARKER_SOURCE_ID_BYTES), - "p".repeat(MAX_RESTORE_MARKER_PANE_KEY_BYTES), - "t".repeat(MAX_RESTORE_MARKER_TERMINAL_ID_BYTES), - ), - ) - .expect("identifier lengths exactly at their caps are accepted"); - - for (name, over) in [ - ( - "source id", - doc( - "s".repeat(MAX_RESTORE_MARKER_SOURCE_ID_BYTES + 1), - "pane".into(), - "terminal".into(), - ), - ), - ( - "pane key", - doc( - "source".into(), - "p".repeat(MAX_RESTORE_MARKER_PANE_KEY_BYTES + 1), - "terminal".into(), - ), - ), - ( - "terminal id", - doc( - "source".into(), - "pane".into(), - "t".repeat(MAX_RESTORE_MARKER_TERMINAL_ID_BYTES + 1), - ), - ), - ] { - let error = match write_marker_doc(&dd, &over) { - Ok(()) => panic!("over-cap {name} must fail loudly"), - Err(error) => error, - }; - assert!( - error.to_string().contains("identifier length limit"), - "clear {name} error: {error}" - ); - } -} - -#[tokio::test] -async fn restore_rejects_over_limit_marker_plan_before_side_effects() { - let dir = tempfile::tempdir().unwrap(); - let mut record = rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - ); - record["tabKey"] = json!("k".repeat(MAX_RESTORE_MARKER_PANE_KEY_BYTES + 1)); - seed_records(dir.path(), "dev-1", "c1", 1, vec![record]); - let (r, _on, _client) = connected(dir.path()); - - let (status, body) = post( - router(r.state), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{body}"); - assert_eq!(body["markerError"], true, "{body}"); - assert!( - body["error"] - .as_str() - .is_some_and(|error| error.contains("limit")), - "{body}" - ); - assert!( - !device_dir(dir.path(), "dev-1") - .join(RESTORE_MARKER) - .exists(), - "preflight rejection must not write a marker or create a pane" - ); -} - -#[tokio::test] -async fn write_ahead_marker_is_durable_before_tab_create_is_sent() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - let r = rig(dir.path()); - let broker = r.state.screenshots.clone(); - let dd = device_dir(dir.path(), "dev-1"); - let saw_write_ahead = std::sync::Arc::new(AtomicBool::new(false)); - let saw_write_ahead_in_sink = saw_write_ahead.clone(); - let client_id = 9_001; - r.state.screenshots.add_capable_client( - client_id, - std::sync::Arc::new(move |message| { - let value = serde_json::to_value(message).unwrap(); - if value["type"] == "ui.command" && value["command"] == "tab.create" { - let marker = read_marker_doc(&dd).expect("write-ahead marker is durable"); - let panes = &marker.values().next().expect("source exists").1; - assert_eq!( - panes.get("dev-1:t1#p-t1").map(|mark| mark.state.as_str()), - Some("in-progress"), - "tab.create must not be sent before its durable write-ahead entry" - ); - saw_write_ahead_in_sink.store(true, Ordering::SeqCst); - } - if value["type"] == "ui.command" && value["command"] == "screenshot.capture" { - broker.resolve_from( - client_id, - value["payload"]["requestId"].as_str().unwrap(), - freshell_ws::screenshot::ScreenshotResult { - ok: true, - ..Default::default() - }, - ); - } - true - }), - ); - - let (status, body) = post( - router(r.state), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK, "{body}"); - assert!(saw_write_ahead.load(Ordering::SeqCst)); -} - -#[tokio::test] -async fn dropped_ack_sender_is_not_delivery_confirmation() { - // A dropped oneshot sender (the pending ack entry was cancelled/purged - // before the client answered) resolves the receiver with `Err(RecvError)`. - // That is NOT a delivery ack: the pane must be reported failed - // (`delivery-unconfirmed`) and stay `in-progress` for a safe retry. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - let r = rig(dir.path()); - let broker = r.state.screenshots.clone(); - let client_id = 9_102; - r.state.screenshots.add_capable_client( - client_id, - std::sync::Arc::new(move |message| { - let value = serde_json::to_value(message).unwrap(); - if value["type"] == "ui.command" && value["command"] == "screenshot.capture" { - // Simulate the pending ack being purged (sender dropped) - // instead of answered by the client. - broker.cancel(value["payload"]["requestId"].as_str().unwrap()); - } - true - }), - ); - - let (status, body) = post( - router(r.state), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!( - body["deliveryConfirmed"], false, - "a dropped ack sender must not read as confirmation: {body}" - ); - let failed = body["failed"].as_array().unwrap(); - assert_eq!(failed.len(), 1, "{body}"); - assert_eq!(failed[0]["reason"], "delivery-unconfirmed", "{body}"); - assert_eq!(body["restored"].as_array().unwrap().len(), 0, "{body}"); - assert_eq!( - marker_panes(dir.path(), "dev-1")["dev-1:t1#p-t1"]["state"], - "in-progress", - "unconfirmed delivery must keep the write-ahead entry for retry" - ); -} - -#[tokio::test] -async fn restore_rejects_malformed_body_selectors_with_400_never_broader_union() { - // FAIL-CLOSED BODY SELECTORS (`:459`): every malformed shape is a 400 — - // never a silent fall-through to the broader coherent union. dryRun keeps - // the client gate out of the way (parsing runs regardless). - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - for bad in [ - json!({ "deviceId": "dev-1", "dryRun": true, "generation": -1 }), - json!({ "deviceId": "dev-1", "dryRun": true, "generation": "3" }), // wrong type - json!({ "deviceId": "dev-1", "dryRun": true, "generation": 1.5 }), - json!({ "deviceId": "dev-1", "dryRun": true, "generation": true }), - json!({ "deviceId": "dev-1", "dryRun": true, "generationId": 5 }), // wrong type - json!({ "deviceId": "dev-1", "dryRun": true, "generationId": "" }), - json!({ "deviceId": "dev-1", "dryRun": true, "components": "abc" }), // non-array - json!({ "deviceId": "dev-1", "dryRun": true, "components": [1] }), // non-string entry - json!({ "deviceId": "dev-1", "dryRun": true, "components": [""] }), - json!({ "deviceId": "dev-1", "dryRun": true, "components": [] }), - json!({ "deviceId": "dev-1", "dryRun": true, "components": ["a"], "generationId": "b" }), - json!({ "deviceId": "dev-1", "dryRun": true, "panes": "x" }), // non-array - json!({ "deviceId": "dev-1", "dryRun": true, "panes": [1] }), - json!({ "deviceId": "dev-1", "dryRun": true, "panes": [] }), - ] { - let (status, body) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - bad.clone(), - true, - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "must 400: {bad} -> {body}"); - } -} - -#[tokio::test] -async fn restore_components_fails_loud_when_any_component_is_pruned() { - // MISSING COMPONENT BUNDLES (`tabs_persist.rs:232`): one present + one - // pruned component must refuse loudly NAMING the missing id — a partial - // union restored with `failed=0` would silently convert an incomplete - // recovery into success. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let present = gen_id(dir.path(), "dev-1", 0); - let pruned = "deadbeefdeadbeefdeadbeefdeadbeef".to_string(); - let (status, body) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true, - "components": [present, pruned] }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{body}"); - assert_eq!( - body["missingComponents"], - json!(["deadbeefdeadbeefdeadbeefdeadbeef"]) - ); - assert!(body["error"] - .as_str() - .unwrap() - .contains("deadbeefdeadbeefdeadbeefdeadbeef")); -} - -#[test] -fn create_body_carries_full_terminal_state_including_codex_durability() { - // `:245` unit slice: the codex pane's captured shell + codexDurability - // flow into the create body (the codex CLI itself can't spawn in tests, - // so the freshagent leg for codexDurability is proven at body level here - // and end-to-end for shell/browser/editor below). - let pane = json!({ "paneId": "p1", "kind": "terminal", "payload": { - "mode": "codex", "shell": "wsl", "initialCwd": "/tmp", - "codexDurability": { "schemaVersion": 1, "state": "durable" }, - "sessionRef": { "provider": "codex", "sessionId": "s-1" } - }}); - let body = pane_to_create_body(None, &pane).unwrap(); - assert_eq!(body["shell"], "wsl"); - assert_eq!(body["codexDurability"]["state"], "durable"); - assert_eq!(body["sessionRef"]["sessionId"], "s-1"); -} - -#[test] -fn create_body_carries_create_request_id_and_omits_when_absent() { - // Captured key passes through (P1.6: snapshot-restored panes keep identity). - let pane = json!({ "paneId": "p1", "kind": "terminal", "payload": { - "mode": "shell", "shell": "system", - "createRequestId": "crid-from-snapshot" - }}); - let body = pane_to_create_body(None, &pane).unwrap(); - assert_eq!(body["createRequestId"], "crid-from-snapshot"); - - // Legacy snapshot without the field: body omits it entirely (the REST - // ingress mints a fresh key in that case — never emit null/empty). - let legacy = json!({ "paneId": "p2", "kind": "terminal", "payload": { - "mode": "shell", "shell": "system" - }}); - let legacy_body = pane_to_create_body(None, &legacy).unwrap(); - assert!(legacy_body.get("createRequestId").is_none()); - - // Wrong-typed field is dropped, not an error (same tolerance as shell/cwd). - let wrong = json!({ "paneId": "p3", "kind": "terminal", "payload": { - "mode": "shell", "createRequestId": 42 - }}); - let wrong_body = pane_to_create_body(None, &wrong).unwrap(); - assert!(wrong_body.get("createRequestId").is_none()); -} - -#[tokio::test] -async fn restore_round_trips_non_default_pane_state_to_the_client() { - // FULL PANE STATE (`:245`): NON-default captured values must reach the - // frozen client's `tab.create` paneContent — terminal `shell`, browser - // `devToolsOpen`, editor `language`/`readOnly`/`viewMode`/`wordWrap`. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "shell", "shell": "wsl", "initialCwd": "/tmp" }), - ), - rec( - "t2", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": true }), - ), - rec( - "t3", - "editor", - json!({ "filePath": "/tmp/x.md", "language": "markdown", - "readOnly": true, "viewMode": "preview", "wordWrap": false }), - ), - ], - ); - let (r, _on, _h) = connected(dir.path()); - let mut rx = r.bus.subscribe(); - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - assert_eq!(body["restored"].as_array().unwrap().len(), 3, "{body}"); - let creates = tab_creates(&drain(&mut rx)); - let by_kind = |k: &str| { - creates - .iter() - .find(|p| p["paneContent"]["kind"] == k) - .unwrap_or_else(|| panic!("no {k} tab.create broadcast")) - .clone() - }; - assert_eq!(by_kind("terminal")["paneContent"]["shell"], "wsl"); - assert_eq!(by_kind("browser")["paneContent"]["devToolsOpen"], true); - let ed = by_kind("editor"); - assert_eq!(ed["paneContent"]["language"], "markdown"); - assert_eq!(ed["paneContent"]["readOnly"], true); - assert_eq!(ed["paneContent"]["viewMode"], "preview"); - assert_eq!(ed["paneContent"]["wordWrap"], false); -} - -#[tokio::test] -async fn restore_round_trips_scratch_editor_with_null_file_path() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "scratch", - "editor", - json!({ - "filePath": null, - "language": null, - "readOnly": false, - "viewMode": "source", - "wordWrap": true, - }), - )], - ); - let (r, _on, _client) = connected(dir.path()); - let mut frames = r.bus.subscribe(); - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - - assert_eq!(body["failed"], json!([]), "{body}"); - assert_eq!(body["restored"].as_array().unwrap().len(), 1, "{body}"); - let creates = tab_creates(&drain(&mut frames)); - assert_eq!(creates.len(), 1); - assert_eq!(creates[0]["paneContent"]["kind"], "editor"); - assert_eq!(creates[0]["paneContent"]["filePath"], Value::Null); -} - -#[tokio::test] -async fn crash_window_terminal_reconciles_via_restore_key_ledger() { - // CRASH WINDOW (`:632`), terminal leg: the create succeeded but NEITHER - // marker write after it landed (in-progress entry with NO terminalId on - // disk). The create was tagged with the deterministic restoreKey, so a - // same-process retry finds the live terminal and its DEFERRED tab.create - // in the fresh-agent ledger. The retry must deliver that command before - // accepting the screenshot fence, without spawning a duplicate. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let (r, _on, _h) = connected(dir.path()); - let source_id = union_source_id(dir.path(), "dev-1"); - let pk = pane_key("dev-1:t1", "p-t1"); - let key = restore_key_for("dev-1", &source_id, &pk); - let mut rx = r.bus.subscribe(); - // Exercise the production restore create path: the process was created, - // but the deferred tab.create was never sent. - let resp = freshell_freshagent::terminal_tabs::create_terminal_or_content_tab_deferred( - r.state.fresh_agent.clone(), - json!({ "mode": "shell", "cwd": "/tmp", "restoreKey": key }), - ) - .await; - let rbytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let created: Value = serde_json::from_slice(&rbytes).unwrap(); - let tid = created["data"]["terminalId"].as_str().unwrap().to_string(); - assert!(r.terminals.is_running(&tid)); - // ...but the on-disk marker never got past the write-ahead entry. - let mut doc = MarkerDoc::new(); - let mut panes = Marker::new(); - panes.insert( - pk.clone(), - PaneMark { - state: "in-progress".into(), - terminal_id: None, - }, - ); - doc.insert(source_id.clone(), (0, panes)); - write_marker_doc(&device_dir(dir.path(), "dev-1"), &doc).unwrap(); - // Retry: reconciled to the SAME terminal — no duplicate create. - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let rec0 = &body["restored"][0]; - assert_eq!(rec0["reconciled"], true, "{body}"); - assert_eq!(rec0["terminalId"], tid.as_str(), "no duplicate: {body}"); - assert_eq!(body["failed"].as_array().unwrap().len(), 0); - let creates = tab_creates(&drain(&mut rx)); - assert_eq!( - creates.len(), - 1, - "retry must deliver deferred create: {body}" - ); - assert_eq!(creates[0]["terminalId"], tid); -} - -#[tokio::test] -async fn reconciled_delivery_drop_fails_and_stops_before_later_panes() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - rec( - "t2", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - ], - ); - let r = rig(dir.path()); - let source_id = union_source_id(dir.path(), "dev-1"); - let first_pk = pane_key("dev-1:t1", "p-t1"); - let key = restore_key_for("dev-1", &source_id, &first_pk); - let resp = freshell_freshagent::terminal_tabs::create_terminal_or_content_tab_deferred( - r.state.fresh_agent.clone(), - json!({ "mode": "shell", "cwd": "/tmp", "restoreKey": key }), - ) - .await; - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let created: Value = serde_json::from_slice(&bytes).unwrap(); - let first_tid = created["data"]["terminalId"].as_str().unwrap().to_string(); - let mut panes = Marker::new(); - panes.insert( - first_pk, - PaneMark { - state: "in-progress".into(), - terminal_id: Some(first_tid.clone()), - }, - ); - let mut doc = MarkerDoc::new(); - doc.insert(source_id, (0, panes)); - write_marker_doc(&device_dir(dir.path(), "dev-1"), &doc).unwrap(); - - let frames = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let frames_for_sink = frames.clone(); - r.state.screenshots.add_capable_client( - 811, - std::sync::Arc::new(move |message| { - frames_for_sink - .lock() - .unwrap() - .push(serde_json::to_value(message).unwrap()); - true - }), - ); - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - - assert_eq!(body["restored"].as_array().unwrap().len(), 0, "{body}"); - assert_eq!(body["skipped"].as_array().unwrap().len(), 0, "{body}"); - assert_eq!( - body["failed"][0]["reason"], "delivery-unconfirmed", - "{body}" - ); - assert_eq!(body["failed"][0]["terminalId"], first_tid, "{body}"); - assert_eq!(body["failed"][1]["reason"], "connection-dropped", "{body}"); - let creates = tab_creates(&frames.lock().unwrap()); - assert_eq!(creates.len(), 1, "later pane must not be created: {body}"); - assert_eq!( - r.terminals.inventory().len(), - 1, - "only reconciled terminal exists" - ); -} - -#[tokio::test] -async fn retry_replays_create_when_target_connection_changes() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let r = rig(dir.path()); - let first_on = std::sync::Arc::new(AtomicBool::new(false)); - let first_client = spawn_browser(&r, first_on); - let first = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let terminal_id = first["failed"][0]["terminalId"] - .as_str() - .unwrap() - .to_string(); - r.state.screenshots.remove_capable_client(first_client); - - let second_on = std::sync::Arc::new(AtomicBool::new(true)); - let _second_client = spawn_browser(&r, second_on); - let mut rx = r.bus.subscribe(); - let second = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - - assert_eq!(second["restored"][0]["terminalId"], terminal_id, "{second}"); - let creates = tab_creates(&drain(&mut rx)); - assert_eq!( - creates.len(), - 1, - "new target must receive the existing terminal's create: {second}" - ); - assert_eq!(creates[0]["terminalId"], terminal_id); -} - -#[tokio::test] -async fn force_recreates_content_pane_in_same_server_process() { - for (kind, payload) in [ - ( - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - ), - ( - "editor", - json!({ "filePath": "/tmp/x.md", "language": "markdown", "readOnly": false, - "viewMode": "preview", "wordWrap": true }), - ), - ] { - let dir = tempfile::tempdir().unwrap(); - seed_records(dir.path(), "dev-1", "c1", 1, vec![rec("t1", kind, payload)]); - let (r, _on, _client) = connected(dir.path()); - let first = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let original_tab_id = first["restored"][0]["tabId"].as_str().unwrap().to_string(); - let mut rx = r.bus.subscribe(); - let forced = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - - assert_eq!(forced["restored"].as_array().unwrap().len(), 1, "{forced}"); - assert_ne!(forced["restored"][0]["tabId"], original_tab_id, "{forced}"); - assert_ne!(forced["restored"][0]["reconciled"], true, "{forced}"); - let frames = drain(&mut rx); - let closes: Vec<_> = frames - .iter() - .filter(|v| v["command"] == "tab.close") - .collect(); - assert_eq!(closes.len(), 1, "{kind}: force should retire stale tab"); - assert_eq!(closes[0]["payload"]["id"], original_tab_id); - assert_eq!(tab_creates(&frames).len(), 1, "{kind}: missing create"); - } -} - -#[tokio::test] -async fn forced_terminal_retry_closes_and_replays_one_stable_identity() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let (r, _on, first_client) = connected(dir.path()); - let first = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let original_tab_id = first["restored"][0]["tabId"].as_str().unwrap().to_string(); - let terminal_id = first["restored"][0]["terminalId"] - .as_str() - .unwrap() - .to_string(); - r.state.screenshots.remove_capable_client(first_client); - - let first_attempt_frames = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let first_attempt_sink = first_attempt_frames.clone(); - r.state.screenshots.add_capable_client( - 850, - std::sync::Arc::new(move |message| { - first_attempt_sink - .lock() - .unwrap() - .push(serde_json::to_value(message).unwrap()); - true - }), - ); - let unconfirmed = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - assert_eq!( - unconfirmed["failed"][0]["reason"], "delivery-unconfirmed", - "{unconfirmed}" - ); - let first_attempt_frames = first_attempt_frames.lock().unwrap().clone(); - let first_close = first_attempt_frames - .iter() - .find(|frame| frame["command"] == "tab.close") - .unwrap(); - let first_create = tab_creates(&first_attempt_frames); - assert_eq!(first_close["payload"]["id"], original_tab_id); - assert_eq!(first_create.len(), 1); - assert_eq!(first_create[0]["id"], original_tab_id); - - r.state.screenshots.remove_capable_client(850); - let responsive = std::sync::Arc::new(AtomicBool::new(true)); - let _retry_client = spawn_browser(&r, responsive); - let mut retry_frames = r.bus.subscribe(); - let retry = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - assert_eq!(retry["failed"], json!([]), "{retry}"); - assert_eq!(retry["restored"][0]["tabId"], original_tab_id, "{retry}"); - assert_eq!(retry["restored"][0]["terminalId"], terminal_id, "{retry}"); - let retry_frames = drain(&mut retry_frames); - let retry_close = retry_frames - .iter() - .find(|frame| frame["command"] == "tab.close") - .unwrap(); - let retry_create = tab_creates(&retry_frames); - assert_eq!(retry_close["payload"]["id"], original_tab_id); - assert_eq!(retry_create.len(), 1); - assert_eq!(retry_create[0]["id"], original_tab_id); - assert_eq!( - r.terminals.inventory().len(), - 1, - "unconfirmed delivery and retry must not spawn or orphan another PTY" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn active_restore_survives_production_device_eviction_interleaving() { - let dir = tempfile::tempdir().unwrap(); - for n in 0..freshell_ws::tabs_persist::MAX_SNAPSHOT_DEVICES { - let device = format!("dev-{n:03}"); - seed_records( - dir.path(), - &device, - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let device_dir = device_dir(dir.path(), &device); - let generation = std::fs::read_dir(&device_dir) - .unwrap() - .flatten() - .map(|entry| entry.path()) - .find(|path| path.extension().is_some_and(|ext| ext == "json")) - .unwrap(); - let mut value: Value = - serde_json::from_slice(&std::fs::read(&generation).unwrap()).unwrap(); - value["capturedAt"] = json!(n as i64 + 1); - std::fs::write(generation, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); - } - let source_dir = device_dir(dir.path(), "dev-000"); - let r = rig(dir.path()); - let (fence_tx, fence_rx) = tokio::sync::oneshot::channel(); - let fence_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(fence_tx))); - let fence_for_sink = fence_tx.clone(); - r.state.screenshots.add_capable_client( - 912, - std::sync::Arc::new(move |message| { - let value = serde_json::to_value(message).unwrap(); - if value["command"] == "screenshot.capture" { - if let Some(tx) = fence_for_sink.lock().unwrap().take() { - let _ = tx.send(()); - } - } - true - }), - ); - let app = router(r.state.clone()); - let restore = tokio::spawn(async move { - post( - app, - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-000" }), - true, - ) - .await - .1 - }); - fence_rx.await.expect("restore reached delivery fence"); - - seed_records( - dir.path(), - "dev-new-1", - "c1", - 1, - vec![rec( - "new1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - assert!(source_dir.exists(), "active restore source was evicted"); - assert!( - source_dir.join(RESTORE_MARKER).exists(), - "active restore marker was evicted" - ); - let body = restore.await.unwrap(); - assert_eq!( - body["failed"][0]["reason"], "delivery-unconfirmed", - "{body}" - ); - - seed_records( - dir.path(), - "dev-new-2", - "c1", - 1, - vec![rec( - "new2", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - assert!( - !source_dir.exists(), - "source should be evictable after restore lease release" - ); -} - -#[tokio::test] -async fn crash_window_content_pane_fails_needs_operator_not_silent_recreate() { - // CRASH WINDOW (`:632`), non-terminal leg: browser/editor creates return - // no terminal id and the client persists tabs locally, so an in-progress - // entry with no ledger record (process restarted) is UNRECONCILABLE — it - // must FAIL for the operator, never silently recreate. force recreates. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t2", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - let source_id = union_source_id(dir.path(), "dev-1"); - let pk = pane_key("dev-1:t2", "p-t2"); - let mut doc = MarkerDoc::new(); - let mut panes = Marker::new(); - panes.insert( - pk.clone(), - PaneMark { - state: "in-progress".into(), - terminal_id: None, - }, - ); - doc.insert(source_id.clone(), (0, panes)); - write_marker_doc(&device_dir(dir.path(), "dev-1"), &doc).unwrap(); - // Fresh rig == fresh process (empty restore-key ledger). - let (r, _on, _h) = connected(dir.path()); - let mut rx = r.bus.subscribe(); - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - assert_eq!(body["restored"].as_array().unwrap().len(), 0, "{body}"); - assert_eq!(body["failed"][0]["reason"], "in-progress-unconfirmed"); - assert!( - body["failed"][0]["hint"] - .as_str() - .unwrap() - .contains("force"), - "hint must tell the operator how to proceed: {body}" - ); - assert!( - tab_creates(&drain(&mut rx)).is_empty(), - "nothing may be broadcast for an unconfirmed non-terminal pane" - ); - // force is the explicit operator override: recreate. - let forced = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - assert_eq!(forced["restored"].as_array().unwrap().len(), 1, "{forced}"); -} - -#[tokio::test] -async fn delivery_ack_request_ids_are_invocation_unique() { - // ACK NONCE (`:403`): two restore attempts over the SAME pane must use - // DIFFERENT ack request ids, so a stale reply from a timed-out attempt - // can never resolve a later attempt's registration (the broker resolves - // by exact id). - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let (r, _on, _h) = connected(dir.path()); - let pk = pane_key("dev-1:t1", "p-t1"); - let mut rx = r.bus.subscribe(); - let _ = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - let ids1 = ack_request_ids(&drain(&mut rx)); - // force re-acks the (live, already-restored) pane on a SECOND attempt. - let _ = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await; - let ids2 = ack_request_ids(&drain(&mut rx)); - assert_eq!(ids1.len(), 1, "{ids1:?}"); - assert_eq!(ids2.len(), 1, "{ids2:?}"); - for id in ids1.iter().chain(ids2.iter()) { - assert!(id.starts_with("restore-ack:"), "{id}"); - assert!(id.ends_with(&pk), "id embeds the paneKey: {id}"); - } - assert_ne!( - ids1[0], ids2[0], - "attempts must not share an ack request id" - ); -} - -#[tokio::test] -async fn restore_panes_filter_restores_only_requested_and_rejects_unknown() { - // TARGETED REMEDIATION (`deploy-tab-diff.sh:175`, server leg): `panes` - // restores ONLY the named panes (everything else reported skipped - // `not-selected`), and an unknown pane key is a fail-closed 400 naming it - // BEFORE any side effect. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - rec( - "t2", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - ), - ], - ); - let (r, _on, _h) = connected(dir.path()); - let browser_pk = pane_key("dev-1:t2", "p-t2"); - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "panes": [browser_pk] }), - true, - ) - .await - .1; - let restored = body["restored"].as_array().unwrap(); - assert_eq!(restored.len(), 1, "{body}"); - assert_eq!(restored[0]["kind"], "browser"); - assert!( - body["skipped"] - .as_array() - .unwrap() - .iter() - .any(|s| s["reason"] == "not-selected" && s["tabKey"] == "dev-1:t1"), - "unselected panes are reported, never silently dropped: {body}" - ); - // Unknown pane key -> 400 naming it (fail-closed, nothing created). - let (status, err) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "panes": ["dev-1:ghost#p-ghost"] }), - true, - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{err}"); - assert_eq!(err["unknownPanes"], json!(["dev-1:ghost#p-ghost"])); -} diff --git a/crates/freshell-server/src/tabs_snapshots_selectors.rs b/crates/freshell-server/src/tabs_snapshots_selectors.rs index bfc31a56e..88635bb28 100644 --- a/crates/freshell-server/src/tabs_snapshots_selectors.rs +++ b/crates/freshell-server/src/tabs_snapshots_selectors.rs @@ -1,9 +1,7 @@ //! Fail-closed snapshot selection parsing for the tabs-sync REST surface //! (`tabs_snapshots.rs`). Split into its own `#[path]`-included module to keep -//! the handler file under the repo's 1,000-line-per-file limit. ONE code path -//! validates selectors for BOTH the GET read endpoints (query params) and the -//! POST restore endpoint (JSON body) — see [`parse_restore_selection`]'s doc -//! for how the body shares [`parse_selector`]. +//! the handler file under the repo's 1,000-line-per-file limit. +//! [`parse_selector`] validates the GET read endpoints' query params. // The Err variant is a ready-to-send axum `Response` (the same pattern every // handler in `tabs_snapshots.rs` uses); its size is irrelevant on this @@ -12,7 +10,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; -use serde_json::{json, Value}; +use serde_json::json; /// The parsed generation selector, or a 400 response. FAIL-CLOSED (`:1101`): an /// invalid, negative, duplicated, or conflicting selector is a 400, never a @@ -59,96 +57,3 @@ pub(super) fn parse_selector(params: &[(String, String)]) -> Result `"\"3\""`, -/// `1.5` -> `"1.5"`, `-1` -> `"-1"`, `true` -> `"true"`), all of which that -/// parser rejects, so both endpoints share ONE validation code path. -pub(super) struct RestoreSelection { - pub components: Vec, - pub selector: Selector, - pub panes: Option>, -} - -pub(super) fn parse_restore_selection(body: &Value) -> Result { - let bad = |msg: &str| (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response(); - let components: Vec = match body.get("components") { - None | Some(Value::Null) => Vec::new(), - Some(Value::Array(a)) => { - let mut out = Vec::new(); - for x in a { - match x.as_str().filter(|s| !s.is_empty()) { - Some(s) => out.push(s.to_string()), - None => { - return Err(bad( - "`components` must be an array of non-empty generation-id strings", - )) - } - } - } - if out.is_empty() { - return Err(bad( - "`components` must be a non-empty array of generation-id strings", - )); - } - out - } - Some(_) => { - return Err(bad( - "`components` must be an array of generation-id strings", - )) - } - }; - let mut params: Vec<(String, String)> = Vec::new(); - match body.get("generation") { - None | Some(Value::Null) => {} - // A valid u64 serializes to the digits parse_selector accepts; EVERY - // other JSON type serializes to something it rejects ("\"3\"", "1.5", - // "-1", "true") — fail-closed by construction. - Some(v) => params.push(("generation".to_string(), v.to_string())), - } - match body.get("generationId") { - None | Some(Value::Null) => {} - Some(Value::String(s)) => params.push(("generationId".to_string(), s.clone())), - Some(_) => return Err(bad("`generationId` must be a string")), - } - let selector = parse_selector(¶ms)?; - if !components.is_empty() && !matches!(selector, Selector::Union) { - return Err(bad( - "provide `components` OR `generation`/`generationId`, not both", - )); - } - let panes: Option> = match body.get("panes") { - None | Some(Value::Null) => None, - Some(Value::Array(a)) => { - let mut set = std::collections::HashSet::new(); - for x in a { - match x.as_str().filter(|s| !s.is_empty()) { - Some(s) => { - set.insert(s.to_string()); - } - None => { - return Err(bad( - "`panes` must be an array of non-empty \"tabKey#paneId\" strings", - )) - } - } - } - if set.is_empty() { - return Err(bad( - "`panes` must be a non-empty array of \"tabKey#paneId\" strings", - )); - } - Some(set) - } - Some(_) => return Err(bad("`panes` must be an array of \"tabKey#paneId\" strings")), - }; - Ok(RestoreSelection { - components, - selector, - panes, - }) -} diff --git a/crates/freshell-server/src/tabs_snapshots_tests.rs b/crates/freshell-server/src/tabs_snapshots_tests.rs index 076e46dc9..98fa454d3 100644 --- a/crates/freshell-server/src/tabs_snapshots_tests.rs +++ b/crates/freshell-server/src/tabs_snapshots_tests.rs @@ -6,7 +6,6 @@ use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; use serde_json::json; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use tower::ServiceExt; const TOKEN: &str = "test-token"; @@ -38,43 +37,12 @@ fn seed(dir: &std::path::Path, device: &str, client: &str, rev: i64, session_id: .unwrap(); } -// A test rig wiring the screenshot broker + the fresh-agent create pipeline to -// ONE shared broadcast bus (exactly as `main.rs:196,202,232` do), plus the ONE -// shared TerminalRegistry restore uses for marker reconciliation. `bus` is kept -// so a test can subscribe an in-process "browser" that answers the delivery-ack -// screenshot round-trip. Ack timeout is short so the connection-drop test is fast. -struct Rig { - state: TabsSnapshotsState, - bus: std::sync::Arc>, - terminals: freshell_terminal::TerminalRegistry, -} -fn rig(dir: &std::path::Path) -> Rig { - let bus = std::sync::Arc::new(tokio::sync::broadcast::channel::(256).0); - let terminals = freshell_terminal::TerminalRegistry::new(); - let fresh_agent = freshell_freshagent::FreshAgentState::new( - std::sync::Arc::new(TOKEN.to_string()), - bus.clone(), - ) - .with_terminal_registry(terminals.clone()); - let state = TabsSnapshotsState { +fn test_state(dir: &std::path::Path) -> TabsSnapshotsState { + TabsSnapshotsState { auth_token: std::sync::Arc::new(TOKEN.to_string()), snapshots_dir: Some(dir.to_path_buf()), - fresh_agent, - screenshots: freshell_ws::screenshot::ScreenshotBroker::new(bus.clone()), - terminals: terminals.clone(), - restore_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), - restore_ack_timeout: std::time::Duration::from_millis(300), - }; - Rig { - state, - bus, - terminals, } } -// Back-compat helper for the read-endpoint tests (no delivery needed). -fn test_state(dir: &std::path::Path) -> TabsSnapshotsState { - rig(dir).state -} async fn get(router: axum::Router, uri: &str, auth: bool) -> (StatusCode, serde_json::Value) { let mut req = Request::builder().method("GET").uri(uri); @@ -238,751 +206,3 @@ async fn corrupt_generation_file_returns_500_not_404() { "list must also 500 on a corrupt store" ); } - -// ── POST /api/tabs-sync/restore (Task 3) ──────────────────────────────────── - -async fn post( - router: axum::Router, - uri: &str, - body: serde_json::Value, - auth: bool, -) -> (StatusCode, serde_json::Value) { - let mut req = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json"); - if auth { - req = req.header("x-auth-token", TOKEN); - } - let resp = router - .oneshot(req.body(Body::from(body.to_string())).unwrap()) - .await - .unwrap(); - let status = resp.status(); - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - ( - status, - serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), - ) -} - -// Seed a real generation (encoded layout) via the registry. -fn seed_records(dir: &std::path::Path, device: &str, client: &str, rev: i64, records: Vec) { - let reg = freshell_ws::tabs::TabsRegistry::with_persist_dir(dir.to_path_buf()); - reg.replace_client_snapshot("srv", device, "Dev One", client, rev, records) - .unwrap(); -} - -// NOTE: every shell payload below carries an explicit `initialCwd: "/tmp"`. -// Sibling tests in this binary (`files.rs`) set `HOME=/home/tester` (nonexistent) -// process-wide and never restore it, and portable-pty falls back to the child -// env's HOME as the spawn cwd when none is given — a cwd-less shell spawn then -// fails ENOENT under the full suite. An explicit, always-present cwd keeps these -// tests deterministic regardless of sibling env pollution. -fn rec(tab: &str, kind: &str, payload: Value) -> Value { - json!({ "tabKey": format!("dev-1:{tab}"), "tabId": tab, "tabName": tab, "status": "open", - "revision": 1, "updatedAt": 2000, "paneCount": 1, - "panes": [{ "paneId": format!("p-{tab}"), "kind": kind, "payload": payload }] }) -} - -fn mixed_records() -> Vec { - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - rec( - "t2", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - ), - rec( - "t3", - "fresh-agent", - json!({ "provider": "claude", "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": "x" } }), - ), - ] -} - -// The in-process "browser" registers the same per-connection sink a real WS -// connection does and answers every targeted `screenshot.capture` while `on` -// is true. The connection id is carried into `resolve_from`, so another -// browser cannot acknowledge this target's restore. -fn spawn_browser(r: &Rig, on: std::sync::Arc) -> u64 { - static NEXT_CLIENT_ID: AtomicU64 = AtomicU64::new(1); - let client_id = NEXT_CLIENT_ID.fetch_add(1, Ordering::SeqCst); - let broker = r.state.screenshots.clone(); - let observed = r.bus.clone(); - r.state.screenshots.add_capable_client( - client_id, - std::sync::Arc::new(move |message| { - if !on.load(Ordering::SeqCst) { - return true; - } - let v = serde_json::to_value(message).expect("server message serializes"); - let _ = observed.send(v.to_string()); - if v["type"] == "ui.command" && v["command"] == "screenshot.capture" { - if let Some(request_id) = v["payload"]["requestId"].as_str() { - broker.resolve_from( - client_id, - request_id, - freshell_ws::screenshot::ScreenshotResult { - ok: true, - ..Default::default() - }, - ); - } - } - true - }), - ); - client_id -} -// A rig with exactly one connected, RESPONSIVE browser. -fn connected(dir: &std::path::Path) -> (Rig, std::sync::Arc, u64) { - let r = rig(dir); - let on = std::sync::Arc::new(AtomicBool::new(true)); - let h = spawn_browser(&r, on.clone()); - (r, on, h) -} -fn marker_json(dir: &std::path::Path, device: &str) -> Value { - let enc = freshell_ws::tabs_persist::encode_device_id(device).unwrap(); - serde_json::from_slice(&std::fs::read(dir.join(enc).join(RESTORE_MARKER)).unwrap()).unwrap() -} -// The v2 marker is a per-source LEDGER (`tabs_snapshots_marker.rs`); these -// tests restore exactly one source, so return that single source's panes map. -fn marker_panes(dir: &std::path::Path, device: &str) -> Value { - let doc = marker_json(dir, device); - let sources = doc["sources"].as_object().expect("v2 marker sources"); - assert_eq!(sources.len(), 1, "single-source test marker: {doc}"); - sources.values().next().unwrap()["panes"].clone() -} - -#[tokio::test] -async fn restore_rebuilds_supported_panes_and_reports_skips() { - let dir = tempfile::tempdir().unwrap(); - seed_records(dir.path(), "dev-1", "c1", 3, mixed_records()); - let (r, _on, _h) = connected(dir.path()); - let (status, body) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["broadcastScope"], "target-client"); - assert_eq!( - body["deliveryConfirmed"], true, - "single responsive browser acked every tab.create" - ); - assert!(body["sourceId"].is_string()); - let restored = body["restored"].as_array().unwrap(); - assert_eq!( - restored.len(), - 2, - "shell terminal + browser restored: {body}" - ); - assert!(restored - .iter() - .any(|r| r["kind"] == "terminal" && r["terminalId"].is_string())); - assert!(restored - .iter() - .any(|r| r["kind"] == "browser" && r["tabId"].is_string())); - let skipped = body["skipped"].as_array().unwrap(); - assert_eq!(skipped.len(), 1); - assert_eq!(skipped[0]["reason"], "unsupported-kind"); - assert_eq!(body["failed"].as_array().unwrap().len(), 0); -} - -#[tokio::test] -async fn restore_rejects_non_boolean_control_flags() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - for (field, value) in [ - ("dryRun", json!("true")), - ("dryRun", json!(1)), - ("dryRun", Value::Null), - ("force", json!("true")), - ("force", json!(1)), - ("force", Value::Null), - ] { - let mut request = json!({ "deviceId": "dev-1", "dryRun": true }); - request[field] = value; - let (status, body) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - request, - true, - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{field}: {body}"); - assert_eq!( - body["error"], - format!("{field} must be a boolean"), - "{field}: {body}" - ); - } -} - -#[tokio::test] -async fn dry_run_reads_and_classifies_the_restore_marker() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - let (connected_rig, _on, _client) = connected(dir.path()); - let (_, first) = post( - router(connected_rig.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(first["restored"].as_array().unwrap().len(), 1, "{first}"); - - let (_, preview) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true }), - true, - ) - .await; - assert_eq!( - preview["restored"].as_array().unwrap().len(), - 0, - "{preview}" - ); - assert_eq!(preview["skipped"][0]["reason"], "already-restored"); - - let marker_path = dir - .path() - .join(freshell_ws::tabs_persist::encode_device_id("dev-1").unwrap()) - .join(RESTORE_MARKER); - let mut marker = marker_json(dir.path(), "dev-1"); - let pane = marker["sources"] - .as_object_mut() - .unwrap() - .values_mut() - .next() - .unwrap()["panes"] - .as_object_mut() - .unwrap() - .values_mut() - .next() - .unwrap(); - pane["state"] = json!("in-progress"); - std::fs::write(&marker_path, serde_json::to_vec(&marker).unwrap()).unwrap(); - - let (_, reconciled) = post( - router(connected_rig.state), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true }), - true, - ) - .await; - assert_eq!( - reconciled["restored"][0]["reconciled"], true, - "{reconciled}" - ); - - let (_, unconfirmed) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true }), - true, - ) - .await; - assert_eq!( - unconfirmed["failed"][0]["reason"], "in-progress-unconfirmed", - "{unconfirmed}" - ); - - std::fs::write(&marker_path, b"{corrupt").unwrap(); - let (status, corrupt) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{corrupt}"); - assert_eq!(corrupt["markerError"], true); -} - -#[tokio::test] -async fn connection_churn_cannot_redirect_delivery_or_acknowledgement() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "browser", - json!({ "url": "https://example.com", "devToolsOpen": false }), - )], - ); - let r = rig(dir.path()); - let target_id = 71; - let late_id = 72; - let broker = r.state.screenshots.clone(); - let late_frames = std::sync::Arc::new(AtomicUsize::new(0)); - let late_frames_for_sink = late_frames.clone(); - r.state.screenshots.add_capable_client( - target_id, - std::sync::Arc::new(move |message| { - let value = serde_json::to_value(message).unwrap(); - if value["command"] == "tab.create" { - let late_frames = late_frames_for_sink.clone(); - broker.add_capable_client( - late_id, - std::sync::Arc::new(move |_| { - late_frames.fetch_add(1, Ordering::SeqCst); - true - }), - ); - } - if value["command"] == "screenshot.capture" { - let request_id = value["payload"]["requestId"].as_str().unwrap(); - // A late client guesses the request id, but cannot satisfy the - // target-bound pending request. - broker.resolve_from( - late_id, - request_id, - freshell_ws::screenshot::ScreenshotResult { - ok: true, - ..Default::default() - }, - ); - } - true - }), - ); - - let (status, body) = post( - router(r.state), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["restored"].as_array().unwrap().len(), 0, "{body}"); - assert_eq!(body["failed"][0]["reason"], "delivery-unconfirmed"); - assert_eq!(late_frames.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn restore_rejects_mismatched_or_malformed_session_ref_before_spawning() { - let dir = tempfile::tempdir().unwrap(); - // (a) codex mode + CLAUDE sessionRef; (b) codex mode + sessionRef object - // MISSING sessionId -> both must FAIL (never silently spawn fresh). - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "codex", - "sessionRef": { "provider": "claude", "sessionId": "z" } }), - ), - rec( - "t2", - "terminal", - json!({ "mode": "codex", - "sessionRef": { "provider": "codex" } }), - ), - ], - ); // no sessionId - let (r, _on, _h) = connected(dir.path()); - let (status, body) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}"); - assert_eq!(body["error"], "snapshot store unreadable"); - assert!( - r.terminals.inventory().is_empty(), - "semantic validation must fail before spawning" - ); -} - -#[tokio::test] -async fn restore_is_idempotent_and_force_bypasses_skip() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - // Run 1: restore (marker persisted on disk). - let (r1, _on1, _h1) = connected(dir.path()); - let first = post( - router(r1.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - assert_eq!(first["restored"].as_array().unwrap().len(), 1); - // Run 2: a DIFFERENT server instance (fresh TerminalRegistry, SAME on-disk - // marker dir). The restored terminal isn't in THIS registry, so live-reconcile - // can't fire -> the pane is skipped `already-restored` (idempotent), nothing - // recreated. Deterministic (no reliance on kill-exit timing). - let (r2, _on2, _h2) = connected(dir.path()); - let second = post( - router(r2.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - assert_eq!(second["restored"].as_array().unwrap().len(), 0); - assert_eq!(second["skipped"][0]["reason"], "already-restored"); - // force bypasses the skip -> re-creates (the terminal is not live in r2). - let forced = post( - router(r2.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - assert_eq!(forced["restored"].as_array().unwrap().len(), 1); -} - -#[tokio::test] -async fn restore_refuses_unless_exactly_one_browser_connected() { - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - // ZERO clients -> 409. - let (status, body) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "zero clients must be refused"); - assert_eq!(body["connectedClients"], 0); - // TWO clients -> 409 (would duplicate onto the bystander). - let two = rig(dir.path()); - let on2 = std::sync::Arc::new(AtomicBool::new(true)); - let _h2 = spawn_browser(&two, on2); // count -> 1 (responsive) - two.state - .screenshots - .add_capable_client(9_999_999, std::sync::Arc::new(|_| true)); // count -> 2 - let (status, body) = post( - router(two.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!(body["connectedClients"], 2); - // force overrides the gate even at 2 (the responsive browser still acks). - let (status, _) = post( - router(two.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK); - // EXACTLY ONE -> OK. - let (r, _on, _h) = connected(dir.path()); - let (status, _) = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK); -} - -#[tokio::test] -async fn restore_dry_run_creates_nothing_and_404_on_missing_snapshot() { - let dir = tempfile::tempdir().unwrap(); - seed_records(dir.path(), "dev-1", "c1", 3, mixed_records()); - // dryRun is allowed regardless of client count (creates nothing). - let (status, body) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "dryRun": true }), - true, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!( - body["deliveryConfirmed"], true, - "dryRun is trivially confirmed" - ); - assert_eq!(body["restored"].as_array().unwrap().len(), 2); - assert!(body["restored"][0]["tabId"].is_null()); - // dryRun writes no marker. - assert!(!dir - .path() - .join(freshell_ws::tabs_persist::encode_device_id("dev-1").unwrap()) - .join(RESTORE_MARKER) - .exists()); - // Missing snapshot -> 404 even under dryRun (gate bypassed, lookup fails). - let (status, _) = post( - router(test_state(dir.path())), - "/api/tabs-sync/restore", - json!({ "deviceId": "ghost", "dryRun": true }), - true, - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn delivery_drop_fails_remaining_panes_and_writes_in_progress_marker() { - // VERIFIED DELIVERY (:1460): one connected but UNRESPONSIVE browser. The - // first pane's create succeeds but its delivery ack times out -> that pane - // AND every remaining pane are FAILED (not restored), deliveryConfirmed is - // false, and the created terminal is recorded IN-PROGRESS (with its id) for - // reconciliation -- NOT restored. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![ - rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - rec( - "t2", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - ), - ], - ); - let r = rig(dir.path()); - let off = std::sync::Arc::new(AtomicBool::new(false)); // never answers - let _h = spawn_browser(&r, off); // count 1, unresponsive - let body = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - assert_eq!(body["restored"].as_array().unwrap().len(), 0); - assert_eq!(body["deliveryConfirmed"], false); - let failed = body["failed"].as_array().unwrap(); - assert!(failed.iter().any(|f| f["reason"] == "delivery-unconfirmed")); - assert!( - failed.iter().any(|f| f["reason"] == "connection-dropped"), - "the pane after the drop must be FAILED, never restored" - ); - // The created terminal is recorded IN-PROGRESS in the marker. - let m = marker_panes(dir.path(), "dev-1"); - let states: Vec<&str> = m - .as_object() - .unwrap() - .values() - .filter_map(|p| p["state"].as_str()) - .collect(); - assert!( - states.iter().all(|s| *s == "in-progress"), - "nothing marked restored: {m}" - ); -} - -#[tokio::test] -async fn write_ahead_reconciles_live_terminal_no_duplicate_on_retry() { - // MARKER-BEFORE-SIDE-EFFECTS (:1532): run 1 drops delivery after creating - // the terminal (write-ahead in-progress). Run 2 (now responsive) must - // RECONCILE that still-live terminal -- promote to restored WITHOUT creating - // a duplicate. The same terminalId proves no second create happened. - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let r = rig(dir.path()); - let on = std::sync::Arc::new(AtomicBool::new(false)); - let _h = spawn_browser(&r, on.clone()); - let first = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let tid = first["failed"][0]["terminalId"] - .as_str() - .unwrap() - .to_string(); - assert!( - r.terminals.is_running(&tid), - "terminal created despite delivery drop" - ); - on.store(true, Ordering::SeqCst); // browser comes back - let second = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let rec0 = &second["restored"][0]; - assert_eq!( - rec0["reconciled"], true, - "in-progress live pane reconciled, not recreated" - ); - assert_eq!( - rec0["terminalId"], tid, - "SAME terminal -> no duplicate create" - ); - assert_eq!(second["failed"].as_array().unwrap().len(), 0); - assert_eq!( - marker_panes(dir.path(), "dev-1")[pane_key("dev-1:t1", "p-t1").as_str()]["state"], - "restored" - ); -} - -#[tokio::test] -async fn force_preserves_prior_marker_and_never_duplicates_live_terminal() { - // FORCE PRESERVES HISTORY (:1497): a normal restore records t1 restored. - // A subsequent FORCE restore must LOAD + preserve that record and must NOT - // duplicate the still-live terminal (reconciled), so a later ordinary - // restore still sees t1 restored (no re-create). - let dir = tempfile::tempdir().unwrap(); - seed_records( - dir.path(), - "dev-1", - "c1", - 1, - vec![rec( - "t1", - "terminal", - json!({ "mode": "shell", "initialCwd": "/tmp" }), - )], - ); - let (r, _on, _h) = connected(dir.path()); - let first = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1" }), - true, - ) - .await - .1; - let tid = first["restored"][0]["terminalId"] - .as_str() - .unwrap() - .to_string(); - let first_tab_id = first["restored"][0]["tabId"].as_str().unwrap().to_string(); - let forced = post( - router(r.state.clone()), - "/api/tabs-sync/restore", - json!({ "deviceId": "dev-1", "force": true }), - true, - ) - .await - .1; - assert_eq!(forced["restored"][0]["forcedReplacement"], true); - assert_eq!( - forced["restored"][0]["terminalId"], tid, - "no duplicate under force" - ); - assert_eq!( - forced["restored"][0]["tabId"], first_tab_id, - "the reused PTY must keep the ids injected into its environment" - ); - // The marker STILL records t1 restored -- force did not discard prior history. - assert_eq!( - marker_panes(dir.path(), "dev-1")[pane_key("dev-1:t1", "p-t1").as_str()]["state"], - "restored" - ); -} - -#[test] -fn marker_in_flight_temp_is_invisible_to_the_ws_orphan_tmp_sweep() { - // freshell-ws's `sweep_orphan_tmp` reaps every `*.tmp` in the device dir - // without any lock shared with the restore path, so the marker's in-flight - // temp must never carry the `tmp` extension (see RESTORE_MARKER_TMP docs - // and the freshell-ws test - // `restore_marker_in_flight_temp_survives_the_sweep_while_stray_tmp_is_reaped`). - assert!( - std::path::Path::new(RESTORE_MARKER_TMP) - .extension() - .is_none_or(|e| e != "tmp"), - "RESTORE_MARKER_TMP must not use the .tmp extension: freshell-ws's \ - orphan-tmp sweep would reap it mid-write" - ); - // And it must still be a hidden dotfile so the *.json generation listing - // never sees it either. - assert!(RESTORE_MARKER_TMP.starts_with('.')); -} - -// Restore-hardening tests (cross-model review delta r1) live in a sibling file -// so both test files stay under the repo's 1,000-line-per-file limit. -#[path = "tabs_snapshots_restore_tests.rs"] -mod restore_tests; diff --git a/crates/freshell-sessions/src/codex_locator.rs b/crates/freshell-sessions/src/codex_locator.rs new file mode 100644 index 000000000..f51283eb1 --- /dev/null +++ b/crates/freshell-sessions/src/codex_locator.rs @@ -0,0 +1,971 @@ +//! Server-side codex terminal-pane session locator (Lane B2, campaign §2.3.2). +//! +//! Third sibling of `amplifier_locator` / `opencode_locator` (a +//! provider-parameterized locator was explicitly rejected — the substrates +//! share zero code). Substrate: codex persists ONE JSONL rollout file per +//! session under a process-global sessions root +//! (`/sessions/YYYY/MM/DD/rollout--.jsonl`, +//! flat `.jsonl` in tests). A new session is a NEW FILE — so the locator +//! does a snapshot-diff of the file set, not a row-diff. +//! +//! Codex-behavior facts below are validated against codex source @ +//! rust-v0.145.0 and a 3,858-rollout corpus; a codex upgrade re-opens them. +//! +//! Deliberate deviations from the opencode locator, with rationale: +//! - Windows are ENTER-ANCHORED ONLY — no spawn window. Real codex defers +//! rollout file creation until the first user prompt is recorded +//! (`RolloutRecorder` defers to `persist()`, materialized via +//! `ensure_rollout_materialized()`), so before the pane's first Enter every +//! new same-cwd rollout is by construction FOREIGN. `arm()` takes the +//! known-files snapshot immediately but schedules NO deadline until +//! `note_submit`. +//! - NO `pre_epsilon_ms` and NO created-at time bound: filesystems have no +//! reliable cross-platform creation time (mtime moves on every append). +//! The `known_files` snapshot is the primary safety — a file +//! already present in the snapshot can never bind to this terminal. The FILENAME +//! timestamp and the dated `YYYY/MM/DD` dir are never used as filters +//! either: both are precomputed at codex session construction and can +//! predate on-disk creation by the entire user idle gap (the dir can even +//! be "yesterday" across midnight). The full-tree snapshot-diff sidesteps +//! both. +//! - FIRST-SUBMIT re-snapshot (A4 hardening): the first `note_submit` +//! replaces `known_files` with a fresh scan — strictly safe because the +//! pane's own rollout cannot exist before its first Enter, so everything +//! that appeared between arm and the first Enter is foreign by +//! construction. SOUNDNESS PRECONDITION: the caller completes the first +//! `note_submit` BEFORE the Enter byte is written to the PTY (codex +//! materializes the rollout in response to that very Enter) — the +//! `codex_association` submit seam encodes this ordering. Later window +//! re-opens NEVER re-snapshot: a >2 s Enter→creation latency is recovered +//! by a later Enter only if the pane's own late file stays a candidate. +//! - Attribution disambiguator: the rollout's own first-line +//! `session_meta.payload.cwd` is REQUIRED and must match the armed +//! terminal's cwd (`SessionMeta.cwd` is non-optional at 0.145.0; +//! 3,858/3,858 real rollouts carry it — accepting a no-cwd line would be +//! pure foreign attack surface). `payload.cwd` is the codex process's +//! physical `getcwd` path recorded verbatim; equality holds because +//! `normalize_cwd` opportunistically canonicalizes the pane side — that +//! canonicalize is load-bearing for symlinked spawn dirs. +//! - Pending first-line grace: codex CREATES the file, then awaits git-info +//! collection (subprocesses, 5 s timeout each, worst ~10 s) BEFORE writing +//! the `session_meta` line. A NEW file whose first line is empty/incomplete +//! is a PENDING candidate: re-probed each sweep up to +//! `PENDING_FIRST_LINE_GRACE_MS`, and while ANY pending candidate exists +//! this terminal binds NOTHING (bind-blocking — a readable foreign file +//! must not win while the pane's own file sits in its git-info gap). +//! Enter→creation latency beyond the 2 s window is mitigated by this grace +//! plus window re-open on a later Enter. +//! - Contested-cwd refusal is CROSS-TICK: while ≥2 armed terminals share a +//! normalized cwd, no candidate with that cwd binds for any of them. +//! - Ownership is proven ONLY by `payload.id` on line 1 — NEVER the filename +//! (prefilter-grade at best), NEVER `payload.session_id` (fork/resume +//! LINEAGE: matches a FOREIGN session in 54/144 sampled real rollouts) — +//! same predicate as `freshell-ws`'s `first_line_owns`. +//! - Resumed codex sessions append to their EXISTING rollout file (no new +//! file) — consistent with the arm gate refusing resume panes. Compressed +//! artifacts (`.jsonl.zst`, present in 0.145.0 source) fail the `.jsonl` +//! suffix filter; fresh sessions always write plain `.jsonl`. +//! +//! Zero cost when idle: scans happen only at arm, at the FIRST `note_submit` +//! (the re-snapshot), and at due Enter-anchored +//! evaluations (a pending candidate keeps its evaluation due, so re-probes +//! ride the same gate), proven by `fs_scan_count`. Callers run `arm()`, +//! `note_submit()`, and `tick()` inside `tokio::task::spawn_blocking` (cold +//! dentry cache is the one unmeasured tail — A6). + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use crate::opencode_locator::normalize_cwd; + +/// Correlation window after a submit (Enter-anchored deadline). There is NO +/// spawn-anchored window: real codex (0.145.0) creates the rollout file only +/// when the first user prompt is recorded, so before the pane's first Enter +/// every new same-cwd rollout is by construction foreign. +pub const CODEX_WINDOW_MS: i64 = 2_000; + +/// Bounded re-probe grace for a NEW file whose first line is not yet +/// readable: codex creates the file, then awaits git-info collection +/// (subprocesses, 5 s timeout each, worst ~10 s) before writing the +/// `session_meta` line. Matches codex's worst case and the magnitude of the +/// existing `IDENTITY_RESOLUTION_GRACE_MS`. +pub const PENDING_FIRST_LINE_GRACE_MS: i64 = 10_000; + +/// Bounded first-line read cap — real rollouts reach 152 MB; observed real +/// first lines are ≤ 22.4 KB. Mirrors `codex_reconcile.rs`. +const MAX_FIRST_LINE_BYTES: u64 = 1024 * 1024; + +/// Bounded walk depth — `sessions/YYYY/MM/DD/` is depth 3; 5 mirrors +/// `locate_codex_rollout`. +const MAX_WALK_DEPTH: u8 = 5; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Located { + pub terminal_id: String, + pub thread_id: String, + pub rollout_path: PathBuf, + pub cwd: String, +} + +#[derive(Debug, Clone)] +struct Armed { + cwd_normalized: String, + known_files: HashSet, + enter_ms: Option, + resolved: bool, + /// NEW files whose first line was empty/incomplete when probed, keyed to + /// first-seen ms. While any un-expired entry exists, this terminal binds + /// NOTHING (bind-blocking); entries older than + /// `PENDING_FIRST_LINE_GRACE_MS` are merged into `known_files` + /// (permanently excluded — fail toward refusal). + pending_first_line: HashMap, +} + +#[derive(Default)] +struct Inner { + armed: HashMap, +} + +pub struct CodexLocator { + sessions_root: PathBuf, + window_ms: i64, + inner: Mutex, + fs_scan_count: AtomicU64, +} + +impl CodexLocator { + pub fn new(sessions_root: PathBuf) -> Self { + Self::with_config(sessions_root, CODEX_WINDOW_MS) + } + + pub fn with_config(sessions_root: PathBuf, window_ms: i64) -> Self { + Self { + sessions_root, + window_ms, + inner: Mutex::new(Inner::default()), + fs_scan_count: AtomicU64::new(0), + } + } + + pub fn armed_count(&self) -> usize { + self.inner.lock().unwrap().armed.len() + } + + pub fn fs_scan_count(&self) -> u64 { + self.fs_scan_count.load(Ordering::SeqCst) + } + + /// Admission rules (mirrors `OpencodeLocator::arm`): codex mode, running, + /// NO resume id (the only already-bound gate — never a restore flag, so + /// restore-created identity-less panes re-arm for free), non-empty cwd, + /// not already armed. On success takes the arm-time known-files snapshot. + /// Arming schedules NO deadline — windows open only on `note_submit` + /// (Enter-anchored; see module doc). + pub fn arm( + &self, + terminal_id: &str, + mode: &str, + status_running: bool, + resume_session_id: Option<&str>, + cwd: Option<&str>, + ) -> bool { + if mode != "codex" || !status_running || resume_session_id.is_some() { + return false; + } + let Some(cwd) = cwd.filter(|c| !c.is_empty()) else { + return false; + }; + let mut inner = self.inner.lock().unwrap(); + if inner.armed.contains_key(terminal_id) { + return false; + } + let known_files = self.scan_rollout_files(); + inner.armed.insert( + terminal_id.to_string(), + Armed { + cwd_normalized: normalize_cwd(cwd), + known_files, + enter_ms: None, + resolved: false, + pending_first_line: HashMap::new(), + }, + ); + true + } + + pub fn disarm(&self, terminal_id: &str) { + self.inner.lock().unwrap().armed.remove(terminal_id); + } + + /// The FIRST submit is what opens a window at all (windows are + /// Enter-anchored — arm schedules no deadline), and it RE-SNAPSHOTS + /// `known_files` (see module doc: strictly safe because the pane's own + /// rollout cannot exist before its first Enter; the caller must complete + /// this call BEFORE the Enter byte reaches the PTY, and must run it on + /// the blocking pool — the re-snapshot walks the sessions tree). + /// Re-open semantics mirror + /// opencode: a mid-turn Enter never re-opens a still-pending evaluation; + /// a resolved (zero-candidate / ambiguous / contested) terminal gets a + /// fresh Enter-anchored deadline. Re-opens NEVER re-snapshot. + pub fn note_submit(&self, terminal_id: &str, at_ms: i64) -> bool { + let mut inner = self.inner.lock().unwrap(); + let Some(armed) = inner.armed.get_mut(terminal_id) else { + return false; + }; + if !armed.resolved && armed.enter_ms.is_some() { + return false; + } + if armed.enter_ms.is_none() { + // FIRST submit: everything that appeared between arm and this + // Enter is foreign by construction (A1/A4) — replace the + // snapshot. Holding the lock across the scan is deliberate and + // bounded (warm walks are 7-9 ms — A6; callers are on the + // blocking pool); it also keeps the re-snapshot atomic with the + // window open. + armed.known_files = self.scan_rollout_files(); + } + armed.enter_ms = Some(at_ms); + armed.resolved = false; + true + } + + /// A terminal is due only when an Enter-anchored deadline exists and has + /// passed. No submit -> no window -> never evaluated (see module doc). + fn due(&self, armed: &Armed, now_ms: i64) -> bool { + matches!(armed.enter_ms, Some(enter_ms) if !armed.resolved && now_ms >= enter_ms + self.window_ms) + } + + /// Evaluation at (or after) an Enter-anchored deadline. Outcomes: + /// - any NEW file with an empty/incomplete first line (codex's + /// create→session_meta git-info gap) → PENDING: bind NOTHING for this + /// terminal, stay unresolved, re-probe each sweep up to + /// `PENDING_FIRST_LINE_GRACE_MS` (grace-expired files are permanently + /// excluded); + /// - 0 candidates → keep watching (stays armed, `resolved = true`); + /// - 2+ candidates for one terminal → WARN + refuse (never guess); + /// - exactly one candidate but ≥2 ARMED terminals share this cwd → WARN + + /// refuse (contested cwd — cross-tick, so staggered deadlines can't + /// grab a sibling's rollout uncontested); + /// - one candidate claimed by ≥2 terminals in the same tick → WARN + + /// refuse ALL claimants (defense-in-depth behind the cwd census); + /// - exactly one clean match → emit `Located` and disarm. `tick()` drains. + pub fn tick(&self, now_ms: i64) -> Vec { + { + let inner = self.inner.lock().unwrap(); + if inner.armed.is_empty() { + return Vec::new(); + } + if !inner.armed.values().any(|a| self.due(a, now_ms)) { + return Vec::new(); + } + } + let current = self.scan_rollout_files(); + let mut inner = self.inner.lock().unwrap(); + + // Cross-tick contested-cwd census over ALL armed terminals (not just + // the due ones): two armed same-cwd panes are indistinguishable on + // this substrate, whatever their deadlines. + let mut cwd_counts: HashMap = HashMap::new(); + for a in inner.armed.values() { + *cwd_counts.entry(a.cwd_normalized.clone()).or_insert(0) += 1; + } + + // Pass 1: per-terminal candidate evaluation. + let mut claims: Vec<(String, Located)> = Vec::new(); + for (terminal_id, armed) in inner.armed.iter_mut() { + if !matches!(armed.enter_ms, Some(e) if !armed.resolved && now_ms >= e + self.window_ms) + { + continue; + } + let new_paths: Vec = current.difference(&armed.known_files).cloned().collect(); + let mut matches: Vec<(PathBuf, RolloutMeta)> = Vec::new(); + let mut pending_blocking = false; + for path in new_paths { + match probe_rollout(&path) { + Probe::Candidate(meta) => { + armed.pending_first_line.remove(&path); + if normalize_cwd(&meta.cwd) == armed.cwd_normalized { + matches.push((path, meta)); + } + } + Probe::NotYet => { + let first_seen = *armed + .pending_first_line + .entry(path.clone()) + .or_insert(now_ms); + if now_ms - first_seen >= PENDING_FIRST_LINE_GRACE_MS { + // Grace exhausted: permanently excluded (fail + // toward refusal — A4 hardening 1). + armed.pending_first_line.remove(&path); + armed.known_files.insert(path); + } else { + pending_blocking = true; + } + } + Probe::Never => {} + } + } + if pending_blocking { + // A new file is still inside codex's create→session_meta gap + // (git-info collection, worst ~10 s). It may be THIS pane's + // rollout — binding any other candidate now could hand the + // window to a foreign file while the true owner is unreadable. + // Bind nothing, stay unresolved, re-probe next sweep. + tracing::debug!( + terminal_id = %terminal_id, + "codex_locator_pending: new rollout first line not yet readable; deferring evaluation" + ); + continue; + } + armed.resolved = true; + match matches.len() { + 0 => {} // keep watching + 1 => { + if cwd_counts.get(&armed.cwd_normalized).copied().unwrap_or(0) >= 2 { + tracing::warn!( + terminal_id = %terminal_id, + "codex_locator_contested_cwd: >=2 armed terminals share this cwd; refusing to bind" + ); + } else { + let (path, meta) = matches.remove(0); + claims.push(( + terminal_id.clone(), + Located { + terminal_id: terminal_id.clone(), + thread_id: meta.thread_id, + rollout_path: path, + cwd: armed.cwd_normalized.clone(), + }, + )); + } + } + n => { + tracing::warn!( + terminal_id = %terminal_id, + candidates = n, + "codex_locator_ambiguous: multiple new rollouts in one window; refusing to bind" + ); + } + } + } + + // Pass 2: same-tick cross-terminal conflict — the same rollout (or + // thread id) claimed by two armed terminals in one tick is + // unattributable. (Defense-in-depth: the contested-cwd census above + // already refuses same-cwd claimants across ticks.) + let mut located = Vec::new(); + for (terminal_id, candidate) in &claims { + let contested = claims.iter().any(|(other_tid, other)| { + other_tid != terminal_id + && (other.rollout_path == candidate.rollout_path + || other.thread_id == candidate.thread_id) + }); + if contested { + tracing::warn!( + terminal_id = %terminal_id, + thread_id = %candidate.thread_id, + "codex_locator_contested: rollout claimed by multiple armed terminals; refusing to bind" + ); + continue; + } + located.push(candidate.clone()); + } + for l in &located { + inner.armed.remove(&l.terminal_id); + } + located + } + + fn scan_rollout_files(&self) -> HashSet { + self.fs_scan_count.fetch_add(1, Ordering::SeqCst); + fn walk(dir: &Path, depth: u8, out: &mut HashSet) { + if depth > MAX_WALK_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; // missing/corrupt root tolerated, never a panic + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, depth + 1, out); + } else if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".jsonl")) + .unwrap_or(false) + { + out.insert(path); + } + } + } + let mut out = HashSet::new(); + walk(&self.sessions_root, 0, &mut out); + out + } +} + +struct RolloutMeta { + thread_id: String, + /// REQUIRED — `SessionMeta.cwd` is non-optional at codex 0.145.0 + /// (3,858/3,858 real rollouts carry it); a no-cwd first line is a + /// foreign shape, never a candidate (A4 hardening). + cwd: String, +} + +/// Tri-state probe result. The distinction between `NotYet` and `Never` is +/// load-bearing: codex writes the whole session_meta line + '\n' in one +/// write-then-flush, so a COMPLETE (newline-terminated) line that fails the +/// candidate shape will never become one, while an empty file or a line +/// without its trailing newline is codex's create→meta gap (or a raced +/// write) — "not yet", not "never" (A3, validated). +enum Probe { + /// Parseable `session_meta` with a bare-UUID `payload.id` AND a + /// `payload.cwd` — a real candidate shape. + Candidate(RolloutMeta), + /// Empty file, transient open/read failure, or first line still missing + /// its trailing newline — re-probe within the pending grace. + NotYet, + /// Complete first line that is not a codex session_meta candidate + /// (non-JSON, wrong type, non-UUID id, missing cwd, oversized) — never + /// a candidate; the locator stays silent on foreign files. + Never, +} + +/// Identity probe: bounded first-line read (see `Probe` for the tri-state +/// semantics). +fn probe_rollout(path: &Path) -> Probe { + use std::io::{BufRead, Read}; + let Ok(file) = std::fs::File::open(path) else { + return Probe::NotYet; + }; + let mut reader = std::io::BufReader::new(file).take(MAX_FIRST_LINE_BYTES); + let mut first_line = Vec::new(); + if reader.read_until(b'\n', &mut first_line).is_err() { + return Probe::NotYet; + } + if first_line.len() as u64 >= MAX_FIRST_LINE_BYTES && !first_line.ends_with(b"\n") { + return Probe::Never; // oversized: will never fit the cap + } + if first_line.is_empty() || !first_line.ends_with(b"\n") { + return Probe::NotYet; // create→meta gap, or a raced partial write + } + let Ok(record) = serde_json::from_slice::(&first_line) else { + return Probe::Never; + }; + if record.get("type").and_then(|v| v.as_str()) != Some("session_meta") { + return Probe::Never; + } + let Some(thread_id) = record.pointer("/payload/id").and_then(|v| v.as_str()) else { + return Probe::Never; + }; + if !is_uuid_shaped(thread_id) { + return Probe::Never; + } + let Some(cwd) = record.pointer("/payload/cwd").and_then(|v| v.as_str()) else { + return Probe::Never; // cwd REQUIRED (A4 hardening) + }; + Probe::Candidate(RolloutMeta { + thread_id: thread_id.to_string(), + cwd: cwd.to_string(), + }) +} + +/// Bare hyphenated 36-char UUID shape gate (deliberate small duplicate of +/// `freshell-ws`'s predicate — this crate sits below it in the dep graph). +fn is_uuid_shaped(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (i, b) in bytes.iter().enumerate() { + let is_hyphen_pos = matches!(i, 8 | 13 | 18 | 23); + if is_hyphen_pos { + if *b != b'-' { + return false; + } + } else if !b.is_ascii_hexdigit() { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Same convention as opencode_locator.rs tests: no tempfile crate. + fn unique_temp_dir(label: &str) -> PathBuf { + let n = TEST_DIR_COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-codex-locator-test-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create test dir"); + dir + } + + /// Write a rollout file whose FIRST line is the session_meta identity + /// record, exactly the shape the real codex CLI writes + /// (payload.id = identity; payload.cwd = the session's working dir). + fn write_rollout(root: &Path, rel_dir: &str, thread_id: &str, cwd: Option<&str>) -> PathBuf { + let dir = root.join(rel_dir); + std::fs::create_dir_all(&dir).expect("create rollout dir"); + let file = dir.join(format!("rollout-2026-07-26T08-00-00-{thread_id}.jsonl")); + let payload = match cwd { + Some(c) => format!(r#"{{"id":"{thread_id}","cwd":"{c}"}}"#), + None => format!(r#"{{"id":"{thread_id}"}}"#), + }; + let line = format!( + r#"{{"timestamp":"2026-07-26T08:00:00.000Z","type":"session_meta","payload":{payload}}}"# + ); + std::fs::write(&file, format!("{line}\n")).expect("write rollout"); + file + } + + const TID: &str = "11111111-2222-3333-4444-555555555555"; + + #[test] + fn fresh_rollout_after_first_enter_resolves_via_enter_window() { + let root = unique_temp_dir("enter-happy"); + let cwd = root.join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let cwd_s = cwd.to_string_lossy().to_string(); + let locator = CodexLocator::new(root.clone()); + + assert!(locator.arm("t1", "codex", true, None, Some(&cwd_s))); + // No submit yet -> no deadline exists; nothing to evaluate. + assert!(locator.tick(10_000).is_empty()); + // Enter at 20_000; the rollout appears AFTER the submit (real codex + // materializes the file only when the first user prompt is recorded). + assert!(locator.note_submit("t1", 20_000)); + let path = write_rollout(&root, "2026/07/26", TID, Some(&cwd_s)); + + // Before the Enter-anchored deadline: nothing yet. + assert!(locator.tick(20_000 + CODEX_WINDOW_MS - 1).is_empty()); + let located = locator.tick(20_000 + CODEX_WINDOW_MS); + assert_eq!( + located, + vec![Located { + terminal_id: "t1".into(), + thread_id: TID.into(), + rollout_path: path, + cwd: crate::opencode_locator::normalize_cwd(&cwd_s), + }] + ); + // Success fully resolves and disarms; tick() drains. + assert_eq!(locator.armed_count(), 0); + assert!(locator.tick(20_000 + CODEX_WINDOW_MS + 1).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_after_arm_without_submit_is_never_bound_and_never_scanned() { + // A1 (validated): real codex creates the rollout ONLY at the first + // user prompt, so before Enter every new same-cwd rollout is by + // construction FOREIGN. With no submit there is NO window: the file + // must never bind and no deadline scans may run. + let root = unique_temp_dir("no-submit"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.fs_scan_count(), 1); // the arm snapshot + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert!(locator.tick(100 * CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + assert_eq!(locator.fs_scan_count(), 1); // still only the arm snapshot + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_created_between_arm_and_first_enter_never_binds() { + // A4 hardening (first-submit re-snapshot): Premise 7 guarantees the + // pane's own rollout cannot exist before its first Enter, so EVERY + // file that appears between arm and the first submit is foreign by + // construction (freshagent sidecar, `codex exec`, codex outside + // freshell in the same cwd). The FIRST note_submit re-snapshots + // known_files, so a bare Enter (empty composer, trust dialog) can + // never hand the window to that foreign file as a sole candidate. + let root = unique_temp_dir("resnapshot"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + // Foreign rollout lands AFTER arm but BEFORE the first Enter. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.note_submit("t1", 1_000)); // first submit re-snapshots + assert_eq!(locator.fs_scan_count(), 2); // arm + first-submit scans + assert!(locator.tick(1_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); // zero candidates → keep watching + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn arm_admission_gates() { + let root = unique_temp_dir("gates"); + let locator = CodexLocator::new(root.clone()); + // wrong mode + assert!(!locator.arm("t1", "opencode", true, None, Some("/tmp"))); + // not running + assert!(!locator.arm("t1", "codex", false, None, Some("/tmp"))); + // resume id present — the ONLY already-bound gate (no restore flag) + assert!(!locator.arm("t1", "codex", true, Some(TID), Some("/tmp"))); + // missing / empty cwd + assert!(!locator.arm("t1", "codex", true, None, None)); + assert!(!locator.arm("t1", "codex", true, None, Some(""))); + // happy arm, then idempotent re-arm returns false + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(!locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn disarmed_terminal_never_resolves() { + let root = unique_temp_dir("disarm"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + locator.disarm("t1"); + assert!(locator.tick(CODEX_WINDOW_MS + 1).is_empty()); + assert_eq!(locator.armed_count(), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn tick_while_unarmed_performs_zero_fs_scans() { + let root = unique_temp_dir("idle"); + let locator = CodexLocator::new(root.clone()); + // Construction must not scan eagerly either. + assert_eq!(locator.fs_scan_count(), 0); + assert!(locator.tick(10_000).is_empty()); + assert_eq!(locator.fs_scan_count(), 0); + // Arming scans once (the known-files snapshot)… + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.fs_scan_count(), 1); + // …and a tick BEFORE any Enter-anchored deadline is due (here: no + // submit at all, so no deadline exists) still scans nothing. + let before = locator.fs_scan_count(); + assert!(locator.tick(1).is_empty()); + assert_eq!(locator.fs_scan_count(), before); + let _ = std::fs::remove_dir_all(&root); + } + + const TID2: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + #[test] + fn rollout_present_at_arm_is_never_a_candidate() { + let root = unique_temp_dir("snapshot"); + // File exists BEFORE arm — the known-files snapshot must exclude it + // forever, regardless of any timing. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 1_000)); + assert!(locator.tick(1_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); // zero candidates → keep watching + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn foreign_cwd_rollout_is_never_a_candidate() { + let root = unique_temp_dir("cwd"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/home/me/project-a"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/home/me/project-b")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_without_cwd_field_never_binds() { + // cwd is REQUIRED (A4 hardening): `SessionMeta.cwd` is non-optional + // at codex 0.145.0 and 3,858/3,858 + 500/500 real rollouts carry it. + // A no-cwd first line is a foreign shape — accepting it would be + // pure attack surface (a location-blind universal candidate). + let root = unique_temp_dir("no-cwd"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, None); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn two_new_rollouts_in_one_window_refuse_to_bind() { + let root = unique_temp_dir("ambiguous"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // Refusal marks the evaluation resolved but stays armed… + assert_eq!(locator.armed_count(), 1); + // …and a later Enter re-opens a fresh window (both files are now + // still absent from known_files, so still ambiguous — proves the + // refusal is repeatable, never a guess). + assert!(locator.note_submit("t1", CODEX_WINDOW_MS + 100)); + assert!(locator + .tick(CODEX_WINDOW_MS + 100 + CODEX_WINDOW_MS) + .is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn same_rollout_claimed_by_two_armed_terminals_refuses_both() { + let root = unique_temp_dir("contested"); + let locator = CodexLocator::new(root.clone()); + // Two panes, SAME cwd, armed concurrently, submitting in the same + // tick; ONE new rollout. The contested-cwd census refuses both + // (Pass 2's same-tick claim check remains as defense-in-depth). + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.arm("t2", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.note_submit("t2", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 2); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn staggered_same_cwd_armed_terminals_never_bind_uncontested() { + // Cross-tick contested guard (A4 hardening): ambiguity refusal must + // not be same-tick-only. While ≥2 ARMED terminals share a normalized + // cwd, NO candidate with that cwd binds for any of them — staggered + // deadlines must not let pane A grab pane B's rollout uncontested. + let root = unique_temp_dir("staggered"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.arm("t2", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.note_submit("t2", 5_000)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + // t1's deadline fires alone: contested cwd → refuse. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // t2's deadline: still contested → refuse. + assert!(locator.tick(5_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 2); + // One pane exits (disarm); the survivor re-opens with a fresh Enter + // and may now bind — re-evaluation is legitimate once uncontested. + locator.disarm("t2"); + assert!(locator.note_submit("t1", 10_000)); + let located = locator.tick(10_000 + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].terminal_id, "t1"); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn zero_candidate_window_keeps_watching_and_later_enter_reopens() { + let root = unique_temp_dir("reopen"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + // Window closes with zero candidates → keep watching (stays armed). + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // A later Enter re-opens; the rollout appears; resolves via the new + // Enter-anchored window. + let enter_at = 10 * CODEX_WINDOW_MS; + assert!(locator.note_submit("t1", enter_at)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(enter_at + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn later_enter_reopen_keeps_the_first_submit_snapshot() { + // Slow materialization (>2 s Enter→creation) is recovered by a later + // Enter ONLY if re-opens never re-snapshot: the pane's own late + // rollout appears between the first window's close and the second + // Enter, and must STAY a candidate. Only the FIRST submit + // re-snapshots (pinned via fs_scan_count). + let root = unique_temp_dir("reopen-snapshot"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); // first submit: re-snapshot + // First window closes empty. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // The pane's own rollout lands LATE — after the window, before the + // next Enter. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let scans_before = locator.fs_scan_count(); + assert!(locator.note_submit("t1", 10_000)); // re-open: NO re-snapshot + assert_eq!(locator.fs_scan_count(), scans_before); + let located = locator.tick(10_000 + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn mid_turn_enter_never_reopens_a_pending_evaluation() { + let root = unique_temp_dir("midturn"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 100)); + // Second Enter while the first evaluation is still pending: no-op. + assert!(!locator.note_submit("t1", 200)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn non_session_meta_or_malformed_first_line_is_never_a_candidate() { + // COMPLETE (newline-terminated) garbage lines are `Probe::Never` — + // not pending: codex writes the whole meta line + '\n' in one + // write-then-flush, so a complete non-candidate line never becomes + // one. (Empty/torn lines are the pending case — see the tests below.) + let root = unique_temp_dir("badmeta"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")), + format!( + "{{\"type\":\"event_msg\",\"payload\":{{\"id\":\"{TID}\"}}}} +" + ), + ) + .unwrap(); + std::fs::write( + dir.join(format!("rollout-2026-07-26T08-00-01-{TID2}.jsonl")), + "not json at all\n", + ) + .unwrap(); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn empty_first_line_file_is_pending_and_binds_once_meta_lands() { + // A3 (validated): codex CREATES the rollout file, then awaits + // git-info collection (subprocesses, 5 s timeout each, worst ~10 s) + // BEFORE writing the session_meta first line. A deadline scan can + // observe the empty file — it must be a re-probed PENDING candidate, + // never dropped by a one-shot read. + let root = unique_temp_dir("pending"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&file, "").unwrap(); // created, meta not yet written + // Deadline scan: pending candidate → bind NOTHING, stay unresolved. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // Meta line lands (well within grace); the next sweep binds it. + // (write_rollout reuses the same filename — same ts, same TID.) + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(CODEX_WINDOW_MS + 300); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + assert_eq!(locator.armed_count(), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn readable_candidate_never_binds_while_another_new_file_is_pending() { + // A4 (validated, CRITICAL): the pane's OWN rollout can sit + // unreadable in the git-info gap while a FOREIGN same-cwd rollout is + // already readable. Pending candidates are BIND-BLOCKING — the + // readable file must not win the window. + let root = unique_temp_dir("pending-block"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + // Pane's own file: created, first line not yet written. + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&own, "").unwrap(); + // Foreign file: fully readable, same cwd. + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + // Deadline: NOTHING binds while the pending file exists. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // Own meta line lands → TWO candidates → ambiguity refusal (fail + // toward refusal, never a guess). + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS + 300).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn pending_file_that_never_parses_expires_after_grace() { + // Grace is bounded (A4 hardening 1): once PENDING_FIRST_LINE_GRACE_MS + // elapses without a readable first line, the file is permanently + // excluded and stops blocking; a surviving sole candidate may then + // bind. + let root = unique_temp_dir("pending-expiry"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&own, "").unwrap(); // never gains a first line + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + // First due scan sees the pending file (grace clock starts here). + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // Still blocked just before grace expiry… + assert!(locator + .tick(CODEX_WINDOW_MS + PENDING_FIRST_LINE_GRACE_MS - 1) + .is_empty()); + // …then the never-parsed file expires and the sole survivor binds. + let located = locator.tick(CODEX_WINDOW_MS + PENDING_FIRST_LINE_GRACE_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID2); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn missing_sessions_root_is_tolerated_and_resolves_once_it_appears() { + let base = unique_temp_dir("missing-root"); + let root = base.join("does-not-exist-yet"); + let locator = CodexLocator::new(root.clone()); + // arm() scans the missing root — tolerated, never a panic. + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); // no panic, keep watching + assert_eq!(locator.armed_count(), 1); + assert!(locator.note_submit("t1", 2 * CODEX_WINDOW_MS)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(3 * CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn flat_test_shape_rollout_resolves() { + // locate_codex_rollout supports flat `.jsonl`; the locator's walk + // must too (integration fixtures seed this shape). + let root = unique_temp_dir("flat"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, ".", TID, Some("/tmp")); + let located = locator.tick(CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/freshell-sessions/src/lib.rs b/crates/freshell-sessions/src/lib.rs index ac5636ecb..ced48df3a 100644 --- a/crates/freshell-sessions/src/lib.rs +++ b/crates/freshell-sessions/src/lib.rs @@ -16,6 +16,7 @@ pub mod amplifier; pub mod amplifier_locator; +pub mod codex_locator; pub mod directory_index; pub mod indexer; pub mod meta; diff --git a/crates/freshell-sessions/src/opencode_locator.rs b/crates/freshell-sessions/src/opencode_locator.rs index c31aef0f9..ec30bd32b 100644 --- a/crates/freshell-sessions/src/opencode_locator.rs +++ b/crates/freshell-sessions/src/opencode_locator.rs @@ -366,7 +366,7 @@ impl OpencodeLocator { /// Lexical cwd normalization (mirrors `amplifier_locator::normalize_cwd`): /// trailing-slash / separator only — no realpath; `std::fs::canonicalize` is /// used opportunistically where the path exists. -fn normalize_cwd(input: &str) -> String { +pub(crate) fn normalize_cwd(input: &str) -> String { if let Ok(real) = std::fs::canonicalize(input) { return real.to_string_lossy().into_owned(); } diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index b3e7a5152..6a8d0c80a 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -405,6 +405,105 @@ pub type ActivityObserver = Arc; const DEFAULT_RESPAWN_LIVENESS_WINDOW_MS: i64 = 30_000; const DEFAULT_RESPAWN_GENERATION_CAP: i64 = 3; +/// Explicit wall-clock backstop for a hung holder. NOT derived from any +/// "spawn budget" — the 10s constant at create_limit.rs:49 (spawn_timeout_ms, +/// env FRESHELL_SPAWN_GATE_TIMEOUT_MS) bounds the spawn-GATE PERMIT wait, +/// not spawn duration; spawns run unbounded in spawn_blocking. Task 6 makes +/// this env-tunable and adds spawn-duration instrumentation to tune it on +/// evidence. +pub const SESSION_REF_LEASE_TTL_MS: u64 = 20_000; +pub const SESSION_RESERVED_RETRY_AFTER_MS: u64 = 1_000; + +/// The effective lease TTL: `FRESHELL_SESSION_REF_LEASE_TTL_MS` when set to a +/// positive integer, else [`SESSION_REF_LEASE_TTL_MS`]. Same sanitizing-parse +/// shape as the spawn-gate timeout's `FRESHELL_SPAWN_GATE_TIMEOUT_MS` +/// (`freshell-ws/src/create_limit.rs`) — unset/unparseable/zero → default. +/// Kept next to the const so the client's re-drive window derivation +/// (Task 12: window > TTL + margin) stays visible in one place. +pub fn session_ref_lease_ttl_ms() -> u64 { + std::env::var("FRESHELL_SESSION_REF_LEASE_TTL_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(SESSION_REF_LEASE_TTL_MS) +} + +/// Whether `pid` is still alive (`kill(pid, 0)`): the ESRCH death-confirm +/// probe of the kill-before-release discipline (council rule 8). Only ESRCH +/// confirms death — EPERM (or any other errno) reports ALIVE, so an +/// uncertain probe can never release a lease it shouldn't. Non-unix: PTY +/// pids are never recorded there ([`crate::pty::PtyTerminal::pid`] is +/// `None`), so no pid-carrying lease can exist and this is unreachable. +pub fn pid_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: signal 0 performs existence/permission checking only; no + // signal is delivered. + if unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +/// Outcome of [`TerminalRegistry::claim_session_ref`] (council rule 7, D8: +/// one in-flight create per sessionRef, liveness-bound). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionRefClaim { + Acquired, + Held { + retry_after_ms: u64, + }, + /// TTL expired on a holder with a recorded child: caller must kill the + /// holder's spawn via the REGISTRY handle (group-kill discipline, + /// pty.rs:352-386 — never a raw single-pid SIGKILL), CONFIRM death, then + /// call force_release_after_confirmed_kill and re-claim. The pid is for + /// ESRCH-confirmation, not for raw kill(). + ExpiredNeedsKill { + pid: u32, + }, + BoundElsewhere { + terminal_id: String, + }, +} + +/// One in-flight sessionRef create reservation (value side of +/// `TerminalRegistry::session_ref_leases`, keyed `"provider\u{0}sessionId"`). +/// Released on spawn complete (bind), spawn fail (error), or holder +/// connection death; [`SESSION_REF_LEASE_TTL_MS`] is the wall-clock backstop +/// for a hung holder — expiry is KILL-BEFORE-RELEASE (a pid-carrying lease +/// stays held until the caller confirms the kill; a pid-less one is revoked +/// and held closed, never released except via holder conn death or spawn +/// failure, both of which prove no orphan child exists). +#[derive(Debug, Clone)] +struct SessionRefLease { + /// Stored alongside the string key so conn-death cleanup can hand + /// callers real [`SessionLocator`]s without parsing NUL-joined keys. + locator: SessionLocator, + holder_create_request_id: String, + holder_conn: u64, + acquired_at_ms: u64, + /// The spawned child's pid once the holder records it + /// ([`TerminalRegistry::set_session_ref_lease_pid`]) — presence decides + /// TTL expiry's shape (`ExpiredNeedsKill` vs revoke-and-hold-closed). + pid: Option, + /// Set when TTL expired on a pid-less holder: there is nothing to kill, + /// so the lease is held closed and the holder's late + /// [`TerminalRegistry::complete_session_ref_claim`] is rejected. + revoked: bool, +} + +/// A sessionRef lease's map key: `"provider\u{0}sessionId"` (NUL joint — +/// neither side can contain it, so the key is collision-free). +fn session_ref_key(locator: &SessionLocator) -> String { + format!("{}\u{0}{}", locator.provider, locator.session_id) +} + #[derive(Clone)] pub struct TerminalRegistry { inner: Arc>, @@ -450,6 +549,19 @@ pub struct TerminalRegistry { /// one key could BOTH pass the `newest_live_by_create_request_id` check /// and both spawn — the exact duplicate-writer shape the dedupe closes. keyed_create_inflight: Arc>>, + /// Council rule 7 (D8): one in-flight create per sessionRef. Keyed + /// `"provider\u{0}sessionId"` ([`session_ref_key`]); see + /// [`SessionRefLease`] for the release/TTL/kill-before-release rules. + /// Mirrors the `keyed_create_inflight` shape (claim before spawn, + /// release after bind/fail/conn-death). + session_ref_leases: Arc>>, + /// locator key ([`session_ref_key`]) → the terminalId a completed claim + /// bound the sessionRef to. Consulted by + /// [`Self::claim_session_ref`] alongside + /// [`Self::live_terminal_for_session_ref`]; a binding whose terminal is + /// KNOWN dead (registered but not Running) is pruned instead of + /// answering `BoundElsewhere`, so a dead winner never strands losers. + session_ref_bindings: Arc>>, } impl Default for TerminalRegistry { @@ -500,6 +612,8 @@ impl TerminalRegistry { )), respawn_generation_cap: Arc::new(AtomicI64::new(DEFAULT_RESPAWN_GENERATION_CAP)), keyed_create_inflight: Arc::new(Mutex::new(std::collections::HashSet::new())), + session_ref_leases: Arc::new(Mutex::new(HashMap::new())), + session_ref_bindings: Arc::new(Mutex::new(HashMap::new())), } } @@ -1143,6 +1257,18 @@ impl TerminalRegistry { let Some(mut handle) = handle else { return false; }; + // sessionRef lease fix (finding 1): the kill path REMOVES the row + // entirely, so `claim_session_ref`'s "known dead" probe (which needs + // a registered-but-not-Running row) can never fire for a killed + // winner — an UNKNOWN id would be honored as `BoundElsewhere{dead-id}` + // forever. Prune any sessionRef binding pointing at this terminal at + // row-removal time instead. This is the ONLY row-removal site + // (natural exit RETAINS the row via `finish_pty_exit`). The `inner` + // lock is already released here, so no ordering hazard. + self.session_ref_bindings + .lock() + .expect("session-ref bindings lock") + .retain(|_, bound_id| bound_id != terminal_id); let was_running = { let mut s = handle.shared.lock().expect("terminal lock"); let was_running = s.status == TerminalRunStatus::Running; @@ -1612,6 +1738,304 @@ impl TerminalRegistry { .remove(key); } + /// Council rule 7 (D8): claim the one in-flight create slot for a + /// sessionRef. Checks, in order: + /// + /// 1. A live terminal already carrying the ref + /// ([`Self::live_terminal_for_session_ref`]) or a recorded binding + /// whose terminal is not KNOWN dead → `BoundElsewhere` (attach to the + /// winner). A binding whose terminal is known dead (registered but + /// not Running) is pruned instead — a dead winner must not strand + /// losers. + /// 2. A held, unexpired lease → `Held` (retry after + /// [`SESSION_RESERVED_RETRY_AFTER_MS`]). + /// 3. A held lease past `acquired_at_ms + `[`SESSION_REF_LEASE_TTL_MS`]: + /// with a recorded pid → `ExpiredNeedsKill` (KILL-BEFORE-RELEASE: the + /// lease stays held until [`Self::force_release_after_confirmed_kill`]); + /// without one (holder hung pre-spawn, nothing to kill) → revoke the + /// lease, ERROR-log, and answer `Held` — hold closed, never release + /// what you can't kill. + /// 4. Otherwise the slot is free → record the lease, `Acquired`. + /// + /// `now_ms` is caller-supplied wall-clock so tests never sleep. + pub fn claim_session_ref( + &self, + locator: &SessionLocator, + holder_create_request_id: &str, + holder_conn: u64, + now_ms: u64, + ) -> SessionRefClaim { + let key = session_ref_key(locator); + + // 1a. Row-join liveness: a live terminal already carries this ref. + if let Some(terminal_id) = self.live_terminal_for_session_ref(locator) { + return SessionRefClaim::BoundElsewhere { terminal_id }; + } + + // 1b. Recorded binding — honored unless its terminal is KNOWN dead + // (a registered row that is no longer Running). Lock order: the + // bindings mutex is held across the liveness probes, which take only + // the registry `inner` + terminal locks; no path acquires bindings + // while holding those, so the order is acyclic. + { + let mut bindings = self + .session_ref_bindings + .lock() + .expect("session-ref bindings lock"); + if let Some(terminal_id) = bindings.get(&key).cloned() { + let known_dead = self.is_running(&terminal_id) && !self.is_live(&terminal_id); + if known_dead { + bindings.remove(&key); + } else { + return SessionRefClaim::BoundElsewhere { terminal_id }; + } + } + } + + self.claim_session_ref_lease_phase(locator, holder_create_request_id, holder_conn, now_ms) + } + + /// Lease phase of [`Self::claim_session_ref`] (steps 2-4: held / + /// expired / free). Split out so the claim-side TOCTOU re-check + /// (final review finding 1) can be pinned by a test that stages + /// "loser passed checks 1a/1b before the winner registered" without + /// threads: a full `claim_session_ref` call is always caught by step + /// 1b while a binding exists, so only a direct entry here can + /// exercise the under-lock re-check. + fn claim_session_ref_lease_phase( + &self, + locator: &SessionLocator, + holder_create_request_id: &str, + holder_conn: u64, + now_ms: u64, + ) -> SessionRefClaim { + let key = session_ref_key(locator); + let mut leases = self + .session_ref_leases + .lock() + .expect("session-ref lease lock"); + match leases.get_mut(&key) { + None => { + // Claim-side TOCTOU (final review finding 1): checks 1a/1b + // ran BEFORE this lock was taken. A loser preempted across + // the winner's register -> `complete_session_ref_claim` + // window arrives here after complete removed the winner's + // lease -- seeing no lease -- while only the bindings map + // records the winner. Re-check bindings WHILE HOLDING the + // leases lock: a recorded binding means a winner already + // completed, so answer `BoundElsewhere` instead of + // double-acquiring (a second spawn on one sessionRef is the + // duplicate-writer shape D8 exists to close). Lock order + // leases -> bindings matches `complete_session_ref_claim` + // and is acyclic: step 1b's bindings block ends before the + // leases lock is taken, and no path acquires the leases + // lock while holding bindings. A binding observed here is + // either freshly completed (live winner) or, if its winner + // has since died, handled on the loser's retry claim, whose + // step 1b prunes known-dead winners. + let bound = self + .session_ref_bindings + .lock() + .expect("session-ref bindings lock") + .get(&key) + .cloned(); + if let Some(terminal_id) = bound { + return SessionRefClaim::BoundElsewhere { terminal_id }; + } + leases.insert( + key, + SessionRefLease { + locator: locator.clone(), + holder_create_request_id: holder_create_request_id.to_string(), + holder_conn, + acquired_at_ms: now_ms, + pid: None, + revoked: false, + }, + ); + SessionRefClaim::Acquired + } + Some(lease) => { + let expired = now_ms > lease.acquired_at_ms + session_ref_lease_ttl_ms(); + if !expired { + return SessionRefClaim::Held { + retry_after_ms: SESSION_RESERVED_RETRY_AFTER_MS, + }; + } + match lease.pid { + Some(pid) => SessionRefClaim::ExpiredNeedsKill { pid }, + None => { + if !lease.revoked { + lease.revoked = true; + tracing::error!(target: "invariant", + provider = %locator.provider, + session_id = %locator.session_id, + holder_create_request_id = %lease.holder_create_request_id, + acquired_at_ms = lease.acquired_at_ms, + now_ms, + "session_ref_lease_revoked: TTL expired on a pid-less holder; held closed"); + } + SessionRefClaim::Held { + retry_after_ms: SESSION_RESERVED_RETRY_AFTER_MS, + } + } + } + } + } + } + + /// Record the holder's spawned child pid on its lease, arming the TTL + /// expiry's `ExpiredNeedsKill` path. No-op if the lease is gone or held + /// by a different `createRequestId`. + pub fn set_session_ref_lease_pid( + &self, + locator: &SessionLocator, + holder_create_request_id: &str, + pid: u32, + ) { + let key = session_ref_key(locator); + let mut leases = self + .session_ref_leases + .lock() + .expect("session-ref lease lock"); + if let Some(lease) = leases.get_mut(&key) { + if lease.holder_create_request_id == holder_create_request_id { + lease.pid = Some(pid); + } + } + } + + /// Spawn succeeded: record binding, release lease, run the duplicate alarm. + /// Returns false if the lease was revoked while spawning (caller must kill + /// its own child and fail the create loudly). A revoked lease is NOT + /// released here — kill-before-release: the caller confirms its child's + /// death, then calls [`Self::force_release_after_confirmed_kill`]. + pub fn complete_session_ref_claim( + &self, + locator: &SessionLocator, + holder_create_request_id: &str, + terminal_id: &str, + ) -> bool { + let key = session_ref_key(locator); + { + let mut leases = self + .session_ref_leases + .lock() + .expect("session-ref lease lock"); + match leases.get(&key) { + Some(lease) + if lease.holder_create_request_id == holder_create_request_id + && !lease.revoked => + { + leases.remove(&key); + // ATOMICITY (fix round 1, finding 2): the binding MUST be + // inserted while the leases lock is still held. Releasing + // the lease first and binding under a separate lock opens + // a window where a racing `claim_session_ref` sees no live + // row, no binding, and no lease -> `Acquired` -> a second + // spawn: the exact duplicate-writer race this primitive + // exists to close. Lock order leases -> bindings is + // acyclic: `claim_session_ref`'s bindings block ends + // before it takes the leases lock, and no other path + // acquires the leases lock while holding bindings. + self.session_ref_bindings + .lock() + .expect("session-ref bindings lock") + .insert(key, terminal_id.to_string()); + } + _ => return false, + } + } + self.alarm_if_duplicate_session_ref(locator); + true + } + + /// Spawn failed: release the holder's lease (no child exists — the spawn + /// itself errored — so release is safe even for a revoked lease). No-op + /// if the lease is gone or held by a different `createRequestId`. + pub fn fail_session_ref_claim(&self, locator: &SessionLocator, holder_create_request_id: &str) { + let key = session_ref_key(locator); + let mut leases = self + .session_ref_leases + .lock() + .expect("session-ref lease lock"); + if leases + .get(&key) + .is_some_and(|l| l.holder_create_request_id == holder_create_request_id) + { + leases.remove(&key); + } + } + + /// Connection death: release this conn's leases. Returns (locator_key, pid) + /// pairs whose in-flight children the caller must kill (kill-before-release + /// applies: entries WITH a pid are returned still-held; caller kills, + /// confirms, then calls force_release_after_confirmed_kill). + pub fn release_session_ref_leases_for_conn( + &self, + conn: u64, + ) -> Vec<(SessionLocator, Option)> { + let mut leases = self + .session_ref_leases + .lock() + .expect("session-ref lease lock"); + let mut out = Vec::new(); + leases.retain(|_, lease| { + if lease.holder_conn != conn { + return true; + } + out.push((lease.locator.clone(), lease.pid)); + // pid-less: nothing spawned, release now. pid-carrying: keep held + // until the caller confirms the kill (kill-before-release). + lease.pid.is_some() + }); + out + } + + /// The caller killed the holder's child via the registry PTY handle and + /// CONFIRMED death (ESRCH): release the lease so the next claim wins. + pub fn force_release_after_confirmed_kill(&self, locator: &SessionLocator) { + self.session_ref_leases + .lock() + .expect("session-ref lease lock") + .remove(&session_ref_key(locator)); + } + + /// The terminalId a completed claim bound this sessionRef to + /// ([`Self::complete_session_ref_claim`]), if any — the read side of the + /// registry binding map (Task 6 winner bind; also a test probe). + pub fn bound_terminal_for_session_ref(&self, locator: &SessionLocator) -> Option { + self.session_ref_bindings + .lock() + .expect("session-ref bindings lock") + .get(&session_ref_key(locator)) + .cloned() + } + + /// The live child pid behind `terminal_id`'s PTY handle (unix only; + /// `None` for headless/exited terminals). Task 6: the winner records this + /// on its sessionRef lease ([`Self::set_session_ref_lease_pid`]). + pub fn pid_of(&self, terminal_id: &str) -> Option { + let inner = self.inner.lock().expect("registry lock"); + inner + .terminals + .get(terminal_id) + .and_then(|h| h.pty.as_ref()) + .and_then(|p| p.pid()) + } + + /// The terminal whose PTY handle carries `pid`. The kill-before-release + /// paths (lease TTL expiry, holder conn death) must kill through the + /// REGISTRY handle ([`Self::kill`] → group-kill discipline, `pty.rs`) — + /// NEVER a raw single-pid SIGKILL — so they resolve the recorded pid back + /// to its owning terminal first. + pub fn live_terminal_for_pid(&self, pid: u32) -> Option { + let inner = self.inner.lock().expect("registry lock"); + inner.terminals.iter().find_map(|(id, h)| { + (h.pty.as_ref().and_then(|p| p.pid()) == Some(pid)).then(|| id.clone()) + }) + } + /// §5.4 backstop detector (always-on, capability-independent): if a key /// now has two or more LIVE terminals, make it loud — two live PTYs on one /// `createRequestId` means two JSONL writers on one session file. @@ -1631,6 +2055,72 @@ impl TerminalRegistry { } } + /// Live terminal ids currently carrying this sessionRef via the + /// registry-row join: `mode == locator.provider && + /// resume_session_id == Some(locator.session_id) && status Running`, + /// newest generation first (`created_at` desc, `terminal_id` desc + /// tie-break, mirroring [`Self::terminals_by_create_request_id`]). + /// + /// LOOKUP DESIGN (validator-corrected): registry rows have NO dedicated + /// `session_ref` field ([`TerminalShared::inventory`] hardcodes + /// `session_ref: None`), so row identity IS this join. Rows are one of + /// the TWO identity stores — the other is `freshell-ws`'s identity + /// registry, which this crate cannot see (dep direction) — so ws-side + /// callers consult both. + fn live_terminal_ids_for_session_ref(&self, locator: &SessionLocator) -> Vec { + let shareds: Vec>> = { + let inner = self.inner.lock().expect("registry lock"); + inner + .terminals + .values() + .map(|h| Arc::clone(&h.shared)) + .collect() + }; + let mut rows: Vec<(i64, String)> = shareds + .iter() + .filter_map(|shared| { + let s = shared.lock().expect("terminal lock"); + if s.status == TerminalRunStatus::Running + && s.mode == locator.provider + && s.resume_session_id.as_deref() == Some(locator.session_id.as_str()) + { + Some((s.created_at, s.terminal_id.clone())) + } else { + None + } + }) + .collect(); + rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1))); + rows.into_iter().map(|(_, id)| id).collect() + } + + /// Council rule 6: the live terminal (newest first) currently carrying + /// this sessionRef, if any — the reconcile derivation and the create-path + /// lease (Tasks 5/6) attach every other client's claim for the same ref + /// to this winner, regardless of `createRequestId`. + pub fn live_terminal_for_session_ref(&self, locator: &SessionLocator) -> Option { + self.live_terminal_ids_for_session_ref(locator) + .into_iter() + .next() + } + + /// Council rule 9, D8 backstop: >=2 live PTYs carrying one sessionRef is + /// the two-writers corruption shape. Alarm loudly (ERROR-level invariant + /// log); never kill silently. Returns whether the invariant is violated. + pub fn alarm_if_duplicate_session_ref(&self, locator: &SessionLocator) -> bool { + let live = self.live_terminal_ids_for_session_ref(locator); + if live.len() >= 2 { + tracing::error!(target: "invariant", + provider = %locator.provider, + session_id = %locator.session_id, + live = live.len(), + terminal_ids = ?live, + "duplicate_pty_for_session_ref: >=2 live PTYs share one sessionRef"); + return true; + } + false + } + /// The current `terminals.changed.revision` (run-monotonic, `§7.5`). pub fn revision(&self) -> i64 { self.inner.lock().expect("registry lock").revision @@ -3469,6 +3959,258 @@ mod tests { ); } + /// Council rule 6 support: the registry-row join (mode + + /// resume_session_id + Running) finds the live terminal carrying a + /// sessionRef — exited generations and other providers/sessions never + /// match. + #[test] + fn live_terminal_for_session_ref_joins_rows_on_mode_and_resume_id() { + let reg = TerminalRegistry::new(); + reg.register_headless(HeadlessTerminal { + terminal_id: "T-ref".to_string(), + stream_id: "S-ref".to_string(), + mode: "codex".to_string(), + resume_session_id: Some("sess-r".to_string()), + create_request_id: Some("cr-a".to_string()), + created_at: Some(1_000), + }); + let locator = SessionLocator { + provider: "codex".to_string(), + session_id: "sess-r".to_string(), + }; + assert_eq!( + reg.live_terminal_for_session_ref(&locator), + Some("T-ref".to_string()) + ); + // Wrong provider / wrong session never match. + assert!(reg + .live_terminal_for_session_ref(&SessionLocator { + provider: "claude".to_string(), + session_id: "sess-r".to_string(), + }) + .is_none()); + assert!(reg + .live_terminal_for_session_ref(&SessionLocator { + provider: "codex".to_string(), + session_id: "other".to_string(), + }) + .is_none()); + // An exited terminal is not a live carrier. + reg.finish_pty_exit("T-ref", 0); + assert!(reg.live_terminal_for_session_ref(&locator).is_none()); + } + + /// Council rule 9 (D8 backstop): >=2 live PTYs stamped with ONE + /// sessionRef is the two-writers corruption shape — the alarm returns + /// true and ERROR-logs `duplicate_pty_for_session_ref`; one live carrier + /// returns false and stays silent. + #[test] + fn alarm_if_duplicate_session_ref_fires_only_on_two_live_carriers() { + let (events, _guard) = tracing_capture::capture(); + let reg = TerminalRegistry::new(); + let stamped = |id: &str, created_at: i64| HeadlessTerminal { + terminal_id: id.to_string(), + stream_id: format!("S-{id}"), + mode: "claude".to_string(), + resume_session_id: Some("sess-dup".to_string()), + create_request_id: None, + created_at: Some(created_at), + }; + let locator = SessionLocator { + provider: "claude".to_string(), + session_id: "sess-dup".to_string(), + }; + + reg.register_headless(stamped("dupA", 1_000)); + assert!( + !reg.alarm_if_duplicate_session_ref(&locator), + "one live carrier must not trip the alarm" + ); + + reg.register_headless(stamped("dupB", 2_000)); + assert!(reg.alarm_if_duplicate_session_ref(&locator)); + let captured = events.lock().unwrap(); + let alarm = captured + .iter() + .find(|e| e.message.contains("duplicate_pty_for_session_ref")) + .expect(">=2 live PTYs on one sessionRef must emit the invariant alarm"); + assert_eq!( + alarm.fields.get("session_id").map(String::as_str), + Some("sess-dup") + ); + } + + // ------------------------------------------------------------------ + // Council rule 7 (D8): sessionRef liveness-bound lease — claim / + // complete / fail / conn-death release / TTL kill-before-release. + // ------------------------------------------------------------------ + + fn test_registry() -> TerminalRegistry { + TerminalRegistry::new() + } + + fn locator(provider: &str, session_id: &str) -> SessionLocator { + SessionLocator { + provider: provider.to_string(), + session_id: session_id.to_string(), + } + } + + #[test] + fn second_claim_while_held_is_reserved() { + let reg = test_registry(); + let s = locator("claude", "s1"); + assert!(matches!( + reg.claim_session_ref(&s, "cr-A", 1, 1000), + SessionRefClaim::Acquired + )); + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, 1500), + SessionRefClaim::Held { .. } + )); + } + + #[test] + fn completed_claim_yields_bound_elsewhere() { + let reg = test_registry(); + let s = locator("claude", "s1"); + reg.claim_session_ref(&s, "cr-A", 1, 1000); + assert!(reg.complete_session_ref_claim(&s, "cr-A", "term-1")); + match reg.claim_session_ref(&s, "cr-B", 2, 2000) { + SessionRefClaim::BoundElsewhere { terminal_id } => assert_eq!(terminal_id, "term-1"), + other => panic!("expected BoundElsewhere, got {other:?}"), + } + } + + /// winner-dies-mid-claim (council red test): holder conn death releases the + /// pid-less lease; the loser's next claim wins. + #[test] + fn winner_dies_mid_claim_releases_lease() { + let reg = test_registry(); + let s = locator("claude", "s1"); + reg.claim_session_ref(&s, "cr-A", 1, 1000); + let to_kill = reg.release_session_ref_leases_for_conn(1); + assert!(to_kill.iter().all(|(_, pid)| pid.is_none())); + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, 1500), + SessionRefClaim::Acquired + )); + } + + /// winner-hangs-mid-claim (council red test): TTL expiry with a recorded + /// child pid demands kill-before-release; confirmed kill releases; a pid-less + /// hung holder is revoked and HELD CLOSED, never released. + #[test] + fn winner_hangs_mid_claim_ttl_is_kill_before_release() { + let reg = test_registry(); + let s = locator("claude", "s1"); + reg.claim_session_ref(&s, "cr-A", 1, 1000); + reg.set_session_ref_lease_pid(&s, "cr-A", 4242); + let late = 1000 + SESSION_REF_LEASE_TTL_MS + 1; + match reg.claim_session_ref(&s, "cr-B", 2, late) { + SessionRefClaim::ExpiredNeedsKill { pid } => assert_eq!(pid, 4242), + other => panic!("expected ExpiredNeedsKill, got {other:?}"), + } + reg.force_release_after_confirmed_kill(&s); + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, late + 1), + SessionRefClaim::Acquired + )); + } + + #[test] + fn hung_holder_without_pid_is_revoked_and_held_closed() { + let reg = test_registry(); + let s = locator("claude", "s1"); + reg.claim_session_ref(&s, "cr-A", 1, 1000); + let late = 1000 + SESSION_REF_LEASE_TTL_MS + 1; + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, late), + SessionRefClaim::Held { .. } + )); + // The revoked holder's late completion is rejected. + assert!(!reg.complete_session_ref_claim(&s, "cr-A", "term-late")); + } + + /// Fix round 1, finding 1: a KILLED winner must not strand the + /// sessionRef. The kill path REMOVES the terminal row entirely + /// ([`TerminalRegistry::kill_internal`]), so the binding's terminal id + /// becomes UNKNOWN to the registry — the claim-time "known dead" probe + /// can never fire. The binding must instead be pruned at row-removal + /// time so the next claim wins, rather than answering + /// `BoundElsewhere{dead-id}` forever. + #[test] + fn killed_winner_binding_is_pruned_so_next_claim_acquires() { + let reg = test_registry(); + let s = locator("claude", "s1"); + assert!(matches!( + reg.claim_session_ref(&s, "cr-A", 1, 1000), + SessionRefClaim::Acquired + )); + // The winner's spawn registers a real row carrying the ref, then binds. + reg.register_headless(HeadlessTerminal { + terminal_id: "T-win".to_string(), + stream_id: "S-win".to_string(), + mode: "claude".to_string(), + resume_session_id: Some("s1".to_string()), + create_request_id: Some("cr-A".to_string()), + created_at: Some(1_000), + }); + assert!(reg.complete_session_ref_claim(&s, "cr-A", "T-win")); + // User kills the winner: the real kill path removes the row entirely. + assert!(reg.kill("T-win")); + // The dead winner must not strand losers. + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, 2000), + SessionRefClaim::Acquired + )); + } + + /// Final-review finding 1 (claim-side TOCTOU): loser B passes checks + /// 1a/1b while both maps are still empty, is preempted across the + /// winner's register -> `complete_session_ref_claim` window, then takes + /// the leases lock AFTER complete removed the lease. Pre-fix the lease + /// phase saw "no lease" and answered `Acquired` -> a second spawn for + /// the same sessionRef (the duplicate-writer shape D8 exists to close). + /// + /// Staged sequentially, no threads: the winner claims, registers its + /// row, and completes (binding recorded, lease gone); B then enters the + /// lease phase DIRECTLY -- exactly the state B is in after its early + /// (empty) 1a/1b pass. A full `claim_session_ref` call from B cannot + /// pin the fix: while the binding exists, step 1b always intercepts it, + /// so only direct entry exercises the under-lock bindings re-check. + #[test] + fn lease_phase_rechecks_bindings_under_leases_lock() { + let reg = test_registry(); + let s = locator("claude", "s1"); + assert!(matches!( + reg.claim_session_ref(&s, "cr-A", 1, 1000), + SessionRefClaim::Acquired + )); + reg.register_headless(HeadlessTerminal { + terminal_id: "T-win".to_string(), + stream_id: "S-win".to_string(), + mode: "claude".to_string(), + resume_session_id: Some("s1".to_string()), + create_request_id: Some("cr-A".to_string()), + created_at: Some(1_000), + }); + assert!(reg.complete_session_ref_claim(&s, "cr-A", "T-win")); + // B resumes at the lease phase; its 1a/1b ran before the winner + // existed, so neither check fired. It must NOT acquire. + match reg.claim_session_ref_lease_phase(&s, "cr-B", 2, 2000) { + SessionRefClaim::BoundElsewhere { terminal_id } => { + assert_eq!(terminal_id, "T-win") + } + other => panic!("expected BoundElsewhere, got {other:?}"), + } + // The full claim path agrees (step 1a/1b catch it sequentially). + assert!(matches!( + reg.claim_session_ref(&s, "cr-B", 2, 2000), + SessionRefClaim::BoundElsewhere { .. } + )); + } + /// §5.1 atomic-stamp insert-edge interleave (§9.1 test 5): the key is part /// of the row inserted under the registry lock, so an observer's /// `newest_live_by_create_request_id` sees either no row or the diff --git a/crates/freshell-ws/src/amplifier_association.rs b/crates/freshell-ws/src/amplifier_association.rs index 35c08f49c..581e36970 100644 --- a/crates/freshell-ws/src/amplifier_association.rs +++ b/crates/freshell-ws/src/amplifier_association.rs @@ -316,7 +316,10 @@ mod tests { config_fallback: None, amplifier_locator: Some(StdArc::new(AmplifierLocator::new(amplifier_home))), session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), opencode_locator: None, + codex_locator: None, activity: None, }; (state, rx) diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs new file mode 100644 index 000000000..7c5fbd5c9 --- /dev/null +++ b/crates/freshell-ws/src/codex_association.rs @@ -0,0 +1,726 @@ +//! Codex terminal-pane association controller (Lane B2): arm the +//! CodexLocator at create, feed Enter submits, and on resolution adopt the +//! identity through the shared `codex_identity::adopt_codex_identity` tail. +//! Structure mirrors `opencode_association.rs` — deliberately (spec §5-shape +//! duplication over a premature provider-generic controller). One deliberate +//! deviation: the locator's windows are ENTER-ANCHORED ONLY (no spawn +//! window) — real codex materializes its rollout only at the first user +//! prompt, so `maybe_arm` only takes the snapshot and `note_possible_submit` +//! is what opens a correlation window (async: the FIRST submit re-snapshots +//! `known_files` on the blocking pool and MUST complete before the Enter is +//! written to the PTY — see the terminal.rs seam). + +use freshell_protocol::TerminalRunStatus; + +use crate::terminal::now_ms; +use crate::WsState; + +/// Deliberate one-line duplicate of `opencode_association::is_submit_input` +/// (itself a duplicate of `amplifier_association`'s — spec §5: "a one-liner, +/// duplication acceptable"): the input is ONLY a run of CR/LF bytes — an +/// Enter keypress, possibly repeated. +pub(crate) fn is_submit_input(data: &str) -> bool { + !data.is_empty() && data.chars().all(|c| c == '\r' || c == '\n') +} + +/// Arm the locator for a freshly-created terminal, iff it's a fresh +/// (non-resuming) `codex` pane with a resolved cwd. No-ops when the locator +/// is unavailable (`WsState::codex_locator` is `None`) or the mode isn't +/// `codex`. Arming only takes the known-files snapshot — windows are +/// Enter-anchored and open in `note_possible_submit` (see module doc). The +/// snapshot walks the sessions tree, so the caller runs this on the +/// blocking pool (see the terminal.rs arm-at-create seam). +pub(crate) fn maybe_arm( + state: &WsState, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + resume_session_id: Option<&str>, +) { + if mode != "codex" { + return; + } + let Some(locator) = &state.codex_locator else { + return; + }; + locator.arm(terminal_id, mode, true, resume_session_id, cwd); +} + +/// Feed a `terminal.input` write to the locator iff it's submit-shaped +/// (Enter). Async, unlike the opencode sibling: the FIRST `note_submit` +/// re-snapshots `known_files` (a bounded sessions-tree walk), so it runs on +/// the blocking pool, and the CALLER MUST AWAIT this BEFORE writing the +/// Enter to the PTY — codex materializes the rollout in response to that +/// very Enter, and a re-snapshot racing after the write could capture +/// (permanently exclude) the pane's own file. Non-submit data returns +/// immediately; later submits are a cheap mutex hop. +pub(crate) async fn note_possible_submit(state: &WsState, terminal_id: &str, data: &str) { + if !is_submit_input(data) { + return; + } + let Some(locator) = &state.codex_locator else { + return; + }; + let locator = std::sync::Arc::clone(locator); + let terminal_id = terminal_id.to_string(); + let at_ms = now_ms(); + if let Err(join_error) = + tokio::task::spawn_blocking(move || locator.note_submit(&terminal_id, at_ms)).await + { + tracing::warn!( + error = %join_error, + "codex_note_submit_panicked: blocking submit task panicked" + ); + } +} + +/// Drive one locator polling cycle and adopt every association it resolved +/// this tick through the shared `codex_identity::adopt_codex_identity` tail. +/// The tick does bounded filesystem walks + first-line reads — never on an +/// async worker (same `spawn_blocking` discipline as the opencode sweep). +pub(crate) async fn drain_and_associate(state: &WsState) { + let Some(locator) = &state.codex_locator else { + return; + }; + let locator = std::sync::Arc::clone(locator); + let now = now_ms(); + let located = match tokio::task::spawn_blocking(move || locator.tick(now)).await { + Ok(located) => located, + Err(join_error) => { + tracing::warn!( + error = %join_error, + "codex_locator_tick_panicked: sweep tick task panicked, skipping this cycle" + ); + return; + } + }; + for hit in located { + // Defense-in-depth rejects against registry truth (mirrors + // opencode_association.rs's drain checks): a terminal could + // legitimately be killed between `Located` and this draining tick. + let Some(entry) = state + .registry + .directory() + .into_iter() + .find(|e| e.terminal_id == hit.terminal_id) + else { + tracing::warn!( + terminal_id = %hit.terminal_id, + thread_id = %hit.thread_id, + "codex_association_rejected: terminal_missing" + ); + continue; + }; + if entry.mode != "codex" || entry.status != TerminalRunStatus::Running { + tracing::warn!( + terminal_id = %hit.terminal_id, + mode = %entry.mode, + "codex_association_rejected: terminal_not_codex_or_not_running" + ); + continue; + } + if entry.resume_session_id.is_some() { + tracing::warn!( + terminal_id = %hit.terminal_id, + "codex_association_rejected: terminal_already_bound" + ); + continue; + } + // The shared adoption tail (codex_identity.rs): binds both identity + // homes, awaits the durable ledger row, broadcasts the pinned + // associated/meta pair, and feeds the activity hub (including the + // rollout attach for the reconcile lane). + crate::codex_identity::adopt_codex_identity( + state, + crate::codex_identity::CodexAdoption { + terminal_id: &hit.terminal_id, + thread_id: &hit.thread_id, + rollout_path: Some(hit.rollout_path.as_path()), + cwd: entry.cwd.as_deref(), + }, + ) + .await; + } +} + +/// The sweep-timer wiring (mirrors `spawn_opencode_locator_sweep`): +/// periodically drive the locator's polling cycle and process any resolved +/// associations, off the per-connection select loops. +pub fn spawn_codex_locator_sweep(state: WsState, interval: std::time::Duration) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + drain_and_associate(&state).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::now_ms; + use crate::WsState; + use freshell_sessions::codex_locator::CodexLocator; + use std::sync::Arc as StdArc; + + fn state_with_locator( + data_home: std::path::PathBuf, + ) -> (WsState, tokio::sync::broadcast::Receiver) { + let auth_token = StdArc::new("s3cr3t-token-abcdef".to_string()); + let broadcast_tx = StdArc::new(tokio::sync::broadcast::channel::(16).0); + let rx = broadcast_tx.subscribe(); + let state = WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), + identity: crate::identity::TerminalIdentityRegistry::new(), + auth_token: StdArc::clone(&auth_token), + server_instance_id: StdArc::new("srv-1111".to_string()), + boot_id: StdArc::new("boot-2222".to_string()), + settings: StdArc::new( + serde_json::from_value(serde_json::json!({ + "ai": {}, + "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, + "editor": { "externalEditor": "auto" }, + "extensions": { "disabled": [] }, + "freshAgent": { "defaultPlugins": [], "enabled": false, "providers": {} }, + "logging": { "debug": false }, + "network": { "configured": true, "host": "127.0.0.1" }, + "panes": { "defaultNewPane": "ask" }, + "safety": { "autoKillIdleMinutes": 15 }, + "sidebar": { + "autoGenerateTitles": true, + "excludeFirstChatMustStart": false, + "excludeFirstChatSubstrings": [] + }, + "terminal": { "scrollback": 10000 } + })) + .unwrap(), + ), + broadcast_tx: StdArc::clone(&broadcast_tx), + fresh_codex: freshell_freshagent::FreshCodexState::new( + StdArc::clone(&auth_token), + StdArc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(StdArc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new(auth_token, StdArc::clone(&broadcast_tx)), + ), + registry: freshell_terminal::TerminalRegistry::new(), + shutdown: StdArc::new(tokio::sync::Notify::new()), + tabs: crate::tabs::TabsRegistry::new(), + screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), + terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: StdArc::new(Vec::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: StdArc::new(crate::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: crate::backpressure::Term09Config::default(), + create_protect: crate::create_limit::CreateProtectConfig::default(), + spawn_gate: std::sync::Arc::new(crate::spawn_gate::SpawnGate::new(4, 64)), + config_fallback: None, + amplifier_locator: None, + opencode_locator: None, + codex_locator: Some(StdArc::new(CodexLocator::new(data_home))), + activity: None, + session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + (state, rx) + } + + /// Sibling of `state_with_locator` with a REAL (enabled) pane ledger + /// rooted at `ledger_dir` — copied from `opencode_association.rs`'s + /// harness rather than churning every existing caller of the + /// disabled-ledger fixture. + fn state_with_locator_and_ledger( + data_home: std::path::PathBuf, + ledger_dir: &std::path::Path, + ) -> (WsState, tokio::sync::broadcast::Receiver) { + let (mut state, rx) = state_with_locator(data_home); + state.pane_ledger = std::sync::Arc::new(crate::pane_ledger::PaneLedger::new(Some( + ledger_dir.to_path_buf(), + ))); + (state, rx) + } + + fn unique_temp_dir(label: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-codex-association-test-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Write a rollout file whose FIRST line is the session_meta identity + /// record, exactly the shape the real codex CLI writes (payload.id = + /// identity; payload.cwd = the session's working dir). Reused inline + /// from `codex_locator.rs`'s test helper. + fn write_rollout( + root: &std::path::Path, + rel_dir: &str, + thread_id: &str, + cwd: Option<&str>, + ) -> std::path::PathBuf { + let dir = root.join(rel_dir); + std::fs::create_dir_all(&dir).expect("create rollout dir"); + let file = dir.join(format!("rollout-2026-07-26T08-00-00-{thread_id}.jsonl")); + let payload = match cwd { + Some(c) => format!(r#"{{"id":"{thread_id}","cwd":"{c}"}}"#), + None => format!(r#"{{"id":"{thread_id}"}}"#), + }; + let line = format!( + r#"{{"timestamp":"2026-07-26T08:00:00.000Z","type":"session_meta","payload":{payload}}}"# + ); + std::fs::write(&file, format!("{line}\n")).expect("write rollout"); + file + } + + #[test] + fn is_submit_input_matches_enter_only_sequences() { + for yes in ["\r", "\n", "\r\n", "\r\r\n\n"] { + assert!(is_submit_input(yes), "{yes:?} should be a submit"); + } + for no in ["", "hello", "hello\r\n", "\u{1b}[A"] { + assert!(!is_submit_input(no), "{no:?} should not be a submit"); + } + } + + #[test] + fn maybe_arm_arms_a_fresh_codex_terminal_and_ignores_others() { + let dir = unique_temp_dir("assoc-arm"); + let (state, _rx) = state_with_locator(dir.clone()); + let locator = state.codex_locator.as_ref().unwrap().clone(); + maybe_arm(&state, "t1", "opencode", Some("/tmp"), None); // wrong mode + assert_eq!(locator.armed_count(), 0); + maybe_arm(&state, "t1", "codex", Some("/tmp"), Some("resume-id")); // resuming + assert_eq!(locator.armed_count(), 0); + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); // fresh + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn note_possible_submit_feeds_only_enter_sequences() { + let dir = unique_temp_dir("assoc-submit"); + let (state, _rx) = state_with_locator(dir.clone()); + let locator = state.codex_locator.as_ref().unwrap().clone(); + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + note_possible_submit(&state, "t1", "hello").await; + // Observable proof via the locator's own seam: "hello" must not have + // consumed the window — a direct note_submit still returns true. + assert!(locator.note_submit("t1", now_ms())); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Fresh codex pane: rollout appears after arm → sweep binds identity, + /// writes the durable binding row, and broadcasts both frames in the + /// pinned order. + #[tokio::test] + async fn drain_and_associate_binds_identity_ledger_and_broadcasts() { + const TID: &str = "11111111-2222-3333-4444-555555555555"; + let home = unique_temp_dir("drain-associate"); + let ledger_dir = unique_temp_dir("drain-associate-ledger"); + let (state, mut rx) = state_with_locator_and_ledger(home.clone(), &ledger_dir); + + // A running codex terminal the association controller can validate + // against (mode/status/resume_session_id all read from + // `state.registry`, mirroring the controller's own reject checks). + let spec = freshell_platform::build_spawn_spec( + freshell_platform::ShellType::System, + freshell_platform::detect::HostOs::Linux, + false, + Some("/tmp"), + &freshell_platform::RealEnv, + &freshell_platform::RealFileProbe, + &std::collections::BTreeMap::new(), + None, + None, + ); + state + .registry + .create( + &spec, + &std::collections::BTreeMap::new(), + "t1".to_string(), + "stream-1".to_string(), + "codex", + None, + None, + None, + None, + ) + .expect("spawn a real shell for the test PTY"); + state + .registry + .set_meta("t1", None, None, Some("codex".to_string()), None); + + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + + // OPEN THE WINDOW FIRST: windows are Enter-anchored (no spawn + // window), so resolution requires a submit, and the FIRST submit + // re-snapshots known_files — the rollout MUST be seeded AFTER this + // call (a pre-seeded file would be captured by the re-snapshot and + // never bind; that exclusion is the Task 1/2 hardening, not a bug). + note_possible_submit(&state, "t1", "\r").await; + + // THEN write the rollout the locator must find (lands well inside + // the 2 s Enter-anchored window). + write_rollout(&home, "2026/07/26", TID, Some("/tmp")); + + // Drain repeatedly until the locator's correlation window has + // definitely closed relative to wall-clock `now_ms()`. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + for _ in 0..40 { + drain_and_associate(&state).await; + if state + .identity + .get("t1") + .and_then(|i| i.session_id) + .is_some() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + assert_eq!( + state.identity.session_ref_for("t1"), + Some(freshell_protocol::SessionLocator { + provider: "codex".to_string(), + session_id: TID.to_string(), + }) + ); + + let dir_entry = state + .registry + .directory() + .into_iter() + .find(|e| e.terminal_id == "t1") + .unwrap(); + assert_eq!(dir_entry.resume_session_id.as_deref(), Some(TID)); + + let hit = state + .pane_ledger + .lookup_by_session("codex", TID) + .expect("binding row written at resolution"); + assert_eq!(hit.row.live_terminal_id.as_deref(), Some("t1")); + assert!(state.pane_ledger.pending_for_terminal("t1").is_none()); + + // Broadcasts, in the pinned order: `terminal.session.associated` + // FIRST, then `terminal.meta.updated` (frame type assertions exactly + // as the opencode sibling does them, plus the order pin). + let mut frames = Vec::new(); + while let Ok(frame) = rx.try_recv() { + frames.push(frame); + } + let associated_at = frames + .iter() + .position(|f| f.contains("terminal.session.associated") && f.contains(TID)) + .expect("expected a terminal.session.associated broadcast"); + let meta_at = frames + .iter() + .position(|f| f.contains("terminal.meta.updated") && f.contains(TID)) + .expect("expected a terminal.meta.updated broadcast"); + assert!( + associated_at < meta_at, + "pinned order: associated THEN meta.updated" + ); + + state.registry.kill("t1"); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&ledger_dir); + } + + /// Wave-A re-arm contract (the codex mirror of P1.10): a restore-created + /// pane WITHOUT identity (resume None) arms like a fresh pane, records a + /// pending marker, and resolves into the ledger — binding row first, + /// marker gone after. + #[tokio::test] + async fn restore_created_pane_without_identity_arms_and_resolves_into_the_ledger() { + const TID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + let home = unique_temp_dir("p110-restore-rearm"); + let ledger_dir = unique_temp_dir("p110-ledger"); + let (state, _rx) = state_with_locator_and_ledger(home.clone(), &ledger_dir); + + // A real PTY registry row, exactly as the sibling resolve-path test + // spawns it (the controller's reject checks read mode/status/resume + // from `state.registry`). + let spec = freshell_platform::build_spawn_spec( + freshell_platform::ShellType::System, + freshell_platform::detect::HostOs::Linux, + false, + Some("/tmp"), + &freshell_platform::RealEnv, + &freshell_platform::RealFileProbe, + &std::collections::BTreeMap::new(), + None, + None, + ); + state + .registry + .create( + &spec, + &std::collections::BTreeMap::new(), + "t1".to_string(), + "stream-1".to_string(), + "codex", + None, + None, + None, + None, + ) + .expect("spawn a real shell for the test PTY"); + state + .registry + .set_meta("t1", None, None, Some("codex".to_string()), None); + + // The restore-shaped arm: identity absent, so resume is None — the + // exact argument shape terminal.rs's handle_create produces for a + // restore:true create that carried no sessionRef. + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + assert_eq!(state.codex_locator.as_ref().unwrap().armed_count(), 1); + + // The spawn-time pending marker (written by handle_create in + // production — written directly here because this test drives the + // module, not the WS handler). + state + .pane_ledger + .record_pending("t1", "codex", Some("/tmp"), now_ms()) + .unwrap(); + + // Enter-anchored window needs the submit; the first-submit + // re-snapshot would exclude a pre-seeded file, so the rollout is + // written only AFTER this await completes. + note_possible_submit(&state, "t1", "\r").await; + + write_rollout(&home, "2026/07/26", TID, Some("/tmp")); + + // Drain repeatedly until the locator's correlation window has + // definitely closed relative to wall-clock `now_ms()`. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + for _ in 0..40 { + drain_and_associate(&state).await; + if state + .identity + .get("t1") + .and_then(|i| i.session_id) + .is_some() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // Resolution wrote the binding and deleted the marker (pinned order). + let hit = state + .pane_ledger + .lookup_by_session("codex", TID) + .expect("binding row written at resolution"); + assert_eq!(hit.row.live_terminal_id.as_deref(), Some("t1")); + assert!(state.pane_ledger.pending_for_terminal("t1").is_none()); + assert!(state.pane_ledger.list_pending_raw().is_empty()); + + state.registry.kill("t1"); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&ledger_dir); + } + + /// One-writer defense survives the channel swap: a session already bound + /// to ANOTHER terminal (including a retired binding) is never re-adopted. + #[tokio::test] + async fn located_session_bound_elsewhere_is_rejected() { + const TID: &str = "99999999-8888-7777-6666-555555555555"; + let home = unique_temp_dir("bound-elsewhere"); + let (state, _rx) = state_with_locator(home.clone()); + + // The victim's binding, RETIRED — exactly the state the exit path + // leaves behind (terminal.rs's exit hook calls + // `identity.retire(&tid)`). Retired-INCLUSIVE is the point: a dead + // pane's identity must still repel adoption by a fresh terminal. + state + .identity + .upsert("victim", Some("codex"), Some(TID), Some("/tmp"), now_ms()); + assert!(state.identity.retire("victim")); + + // A real PTY "t1" (codex mode), armed, with a locator handle kept + // for the positive resolution signal below. + let spec = freshell_platform::build_spawn_spec( + freshell_platform::ShellType::System, + freshell_platform::detect::HostOs::Linux, + false, + Some("/tmp"), + &freshell_platform::RealEnv, + &freshell_platform::RealFileProbe, + &std::collections::BTreeMap::new(), + None, + None, + ); + state + .registry + .create( + &spec, + &std::collections::BTreeMap::new(), + "t1".to_string(), + "stream-1".to_string(), + "codex", + None, + None, + None, + None, + ) + .expect("spawn a real shell for the test PTY"); + state + .registry + .set_meta("t1", None, None, Some("codex".to_string()), None); + + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + let locator = state.codex_locator.as_ref().unwrap().clone(); + assert_eq!(locator.armed_count(), 1); + + // Open the Enter-anchored window, THEN seed the rollout (after the + // submit — the first-submit re-snapshot would exclude a pre-seeded + // file) with payload.cwd set to THE PANE'S OWN cwd: the rollout must + // be a fully resolvable candidate, or this test proves nothing. + note_possible_submit(&state, "t1", "\r").await; + write_rollout(&home, "2026/07/26", TID, Some("/tmp")); + + // Poll drain_and_associate past the window. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + for _ in 0..40 { + drain_and_associate(&state).await; + if locator.armed_count() == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // POSITIVE resolution signal FIRST, so the negative assertions + // cannot pass vacuously: tick emitted Located and disarmed — + // resolution HAPPENED, so whatever follows is the adoption-tail + // guard's doing (a locator that never resolved would also leave + // identity None, and identity-only assertions cannot tell those + // worlds apart). + assert_eq!(locator.armed_count(), 0); + // Guard refused: nothing adopted. + assert!(state.identity.session_ref_for("t1").is_none()); + let dir_entry = state + .registry + .directory() + .into_iter() + .find(|e| e.terminal_id == "t1") + .unwrap(); + assert!(dir_entry.resume_session_id.is_none()); + + state.registry.kill("t1"); + let _ = std::fs::remove_dir_all(&home); + } + + /// B2xB4 misbind hardening (B2 plan item 10, fix assigned to B4's + /// territory): a freshagent codex sidecar writes rollouts into the SAME + /// `$HOME/.codex/sessions` root, so a later bare Enter on a codex + /// TERMINAL pane could adopt the fresh-agent thread as a sole candidate. + /// B4's kind:fresh-agent ledger rows (written durable-before-answer at + /// thread start) are the exclusion signal: the adoption tail must refuse + /// a freshagent-known thread id. + #[tokio::test] + async fn located_freshagent_known_thread_is_rejected() { + const TID: &str = "aaaabbbb-cccc-4ddd-8eee-ffff00001111"; + let home = unique_temp_dir("freshagent-known"); + let ledger_dir = unique_temp_dir("freshagent-known-ledger"); + let (state, _rx) = state_with_locator_and_ledger(home.clone(), &ledger_dir); + + // The fresh-agent thread's ledger row, exactly what B4's + // record_codex_binding persists at thread/start (durable BEFORE the + // create reply goes out). + state + .pane_ledger + .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + provider: "codex", + session_id: TID, + mode: "freshcodex", + cwd: Some("/tmp"), + create_request_id: None, + model: Some("gpt-5"), + sandbox: None, + permission_mode: None, + effort: None, + supersedes: None, + now_ms: now_ms(), + }) + .expect("seed fresh-agent ledger row"); + + // A real PTY "t1" (codex mode), armed. + let spec = freshell_platform::build_spawn_spec( + freshell_platform::ShellType::System, + freshell_platform::detect::HostOs::Linux, + false, + Some("/tmp"), + &freshell_platform::RealEnv, + &freshell_platform::RealFileProbe, + &std::collections::BTreeMap::new(), + None, + None, + ); + state + .registry + .create( + &spec, + &std::collections::BTreeMap::new(), + "t1".to_string(), + "stream-1".to_string(), + "codex", + None, + None, + None, + None, + ) + .expect("spawn a real shell for the test PTY"); + state + .registry + .set_meta("t1", None, None, Some("codex".to_string()), None); + + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + let locator = state.codex_locator.as_ref().unwrap().clone(); + assert_eq!(locator.armed_count(), 1); + + // Bare Enter opens the window, THEN the freshagent sidecar's rollout + // appears in the shared sessions root with the pane's own cwd -- the + // exact misbind shape from B2 plan item 10. + note_possible_submit(&state, "t1", "\r").await; + write_rollout(&home, "2026/07/26", TID, Some("/tmp")); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + for _ in 0..40 { + drain_and_associate(&state).await; + if locator.armed_count() == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // Resolution HAPPENED (locator disarmed) -- so the refusal below is + // the adoption-tail guard's doing, not a locator no-op. + assert_eq!(locator.armed_count(), 0); + // Guard refused: the fresh-agent thread never binds to the terminal. + assert!(state.identity.session_ref_for("t1").is_none()); + let dir_entry = state + .registry + .directory() + .into_iter() + .find(|e| e.terminal_id == "t1") + .unwrap(); + assert!(dir_entry.resume_session_id.is_none()); + + state.registry.kill("t1"); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&ledger_dir); + } +} diff --git a/crates/freshell-ws/src/codex_candidate.rs b/crates/freshell-ws/src/codex_candidate.rs deleted file mode 100644 index 902bdb009..000000000 --- a/crates/freshell-ws/src/codex_candidate.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! P0.3 (campaign plan §2.3.1): server-side capture of a codex terminal's -//! session identity from the client's `terminal.codex.candidate.persisted` -//! frame -- guarded so identity never becomes client-writable. -//! -//! Four guards; a candidate failing ANY check is logged at WARN and ignored -//! (never adopted, and nothing is sent back -- legacy parity with -//! `server/ws-handler.ts:2951-2963`): -//! 1. the terminalId exists in the registry -//! 2. that terminal is codex-mode -//! 3. the terminal is not already bound to a DIFFERENT thread id (stale -//! replay) and the claimed thread id is not already bound to a DIFFERENT -//! terminal -- live OR retired (cross-pane hijack, including replaying a -//! DEAD pane's candidate onto a fresh pane) -//! 4. disk truth: the rolloutPath canonicalizes under the codex sessions -//! root and its FIRST JSONL record is a `session_meta` whose -//! `payload.id` is the claimed thread id (bounded 1MB read; legacy -//! parity `server/coding-cli/codex-app-server/durability-proof.ts:88-102`) - -use std::io::{BufRead, Read}; -use std::path::{Path, PathBuf}; - -use freshell_protocol::{ - ServerMessage, SessionLocator, TerminalCodexCandidatePersisted, TerminalMetaRecord, - TerminalMetaUpdated, TerminalSessionAssociated, -}; - -use crate::terminal::now_ms; -use crate::WsState; - -/// Codex thread ids are bare hyphenated UUIDs. Cheap shape check so an empty -/// or junk id can never substring-match everything in the disk guard. -pub(crate) fn is_uuid_shaped(s: &str) -> bool { - s.len() == 36 - && s.chars().enumerate().all(|(i, c)| match i { - 8 | 13 | 18 | 23 => c == '-', - _ => c.is_ascii_hexdigit(), - }) -} - -/// Upper bound on the first-line read below. Legacy parity: -/// `MAX_FIRST_RECORD_BYTES` in `durability-proof.ts`. Real first lines -/// observed <= 22.4KB (V5 sampling); 1MB is generous headroom while capping -/// what a 152MB adversarial rollout can cost the sync dispatch loop. -const MAX_FIRST_LINE_BYTES: u64 = 1024 * 1024; - -/// Guard 4 (disk truth). Client-supplied paths are NEVER trusted raw: -/// `fs::canonicalize` both sides (stats the file and resolves `..` and -/// symlinks, so traversal and symlink escapes fail containment), require the -/// rollout to live under the sessions root, then prove OWNERSHIP with a -/// bounded read of only the FIRST line, parsed as JSON: it must be a -/// `session_meta` record whose `payload.id` equals the claimed thread id -/// (legacy parity: `durability-proof.ts:88-102`). -/// -/// `payload.id` EXACTLY -- NOT `payload.session_id`, which is fork/resume -/// LINEAGE and matches a FOREIGN session in 54/144 real rollouts (V5); and -/// never a substring match on filename or contents, which the same lineage -/// data makes spoofable (40% of sampled rollouts contain foreign uuids). -pub(crate) fn verify_rollout_path( - rollout_path: &str, - sessions_root: &Path, - thread_id: &str, -) -> Result<(), &'static str> { - let root = std::fs::canonicalize(sessions_root).map_err(|_| "sessions_root_missing")?; - let rollout = std::fs::canonicalize(rollout_path).map_err(|_| "rollout_missing")?; - if !rollout.starts_with(&root) { - return Err("rollout_outside_sessions_root"); - } - if !rollout.is_file() { - return Err("rollout_not_a_file"); - } - // Bounded first-line read: never `read_to_string` a client-named file - // (real rollouts reach 152MB, p99=28MB -- an uncapped read in the sync - // dispatch loop is an adversarial hazard). - let file = std::fs::File::open(&rollout).map_err(|_| "rollout_unreadable")?; - let mut first_line: Vec = Vec::new(); - let mut limited = std::io::BufReader::new(file).take(MAX_FIRST_LINE_BYTES); - limited - .read_until(b'\n', &mut first_line) - .map_err(|_| "rollout_unreadable")?; - if first_line.len() as u64 >= MAX_FIRST_LINE_BYTES && !first_line.ends_with(b"\n") { - return Err("rollout_first_line_too_large"); - } - let record: serde_json::Value = - serde_json::from_slice(&first_line).map_err(|_| "rollout_first_record_not_json")?; - if record.get("type").and_then(|v| v.as_str()) != Some("session_meta") { - return Err("rollout_first_record_not_session_meta"); - } - match record.pointer("/payload/id").and_then(|v| v.as_str()) { - Some(id) if id == thread_id => Ok(()), - _ => Err("thread_id_mismatch"), - } -} - -/// `CODEX_HOME` env (non-empty) else `/.codex`, then `/sessions` -- -/// mirrors `freshell-server/src/session_directory.rs::codex_home` (which is -/// crate-private there; HOME only, never FRESHELL_HOME) joined with the -/// `sessions` dir the way `freshell_sessions::directory_index::CodexSource` -/// does. -/// -/// `pub` (re-exported from the crate root) for `freshell-server`'s boot-time -/// codex rollout-locator wiring. -pub fn codex_sessions_root() -> Option { - let home = match std::env::var("CODEX_HOME") { - Ok(v) if !v.is_empty() => PathBuf::from(v), - _ => { - #[cfg(windows)] - let base = std::env::var("USERPROFILE").ok()?; - #[cfg(not(windows))] - let base = std::env::var("HOME").ok()?; - PathBuf::from(base).join(".codex") - } - }; - Some(home.join("sessions")) -} - -/// Handle one `terminal.codex.candidate.persisted` frame. No reply frame on -/// any path; rejects are WARN logs, accepts bind BOTH identity homes and -/// broadcast (mirrors `opencode_association.rs`'s resolve path). -pub(crate) async fn handle_codex_candidate_persisted( - state: &WsState, - msg: TerminalCodexCandidatePersisted, -) { - let thread_id = msg.candidate_thread_id.as_str(); - if !is_uuid_shaped(thread_id) { - tracing::warn!( - terminal_id = %msg.terminal_id, - candidate_thread_id = %thread_id, - "codex_candidate_rejected: invalid_thread_id" - ); - return; - } - // Guard 1: the terminal exists. - let Some(row) = state.registry.probe(&msg.terminal_id) else { - tracing::warn!( - terminal_id = %msg.terminal_id, - candidate_thread_id = %thread_id, - "codex_candidate_rejected: terminal_missing" - ); - return; - }; - // Guard 2: the terminal is codex-mode. - if row.mode != "codex" { - tracing::warn!( - terminal_id = %msg.terminal_id, - mode = %row.mode, - "codex_candidate_rejected: terminal_not_codex" - ); - return; - } - // Guard 3a: the terminal is not already bound to a DIFFERENT session - // (stale-replay defense; a re-announce of the SAME id is an idempotent - // no-op -- the client re-sends on every durability update). ORDER - // MATTERS: this same-terminal check must run BEFORE guard 3b's - // cross-terminal check, so a legit re-announce short-circuits here and - // never reaches the retired-inclusive lookup. - if let Some(existing) = row.resume_session_id.as_deref().filter(|s| !s.is_empty()) { - if existing != thread_id { - tracing::warn!( - terminal_id = %msg.terminal_id, - candidate_thread_id = %thread_id, - bound_session_id = %existing, - "codex_candidate_rejected: terminal_already_bound" - ); - } - return; - } - // Guard 3b: the claimed session is not bound to a DIFFERENT terminal -- - // live OR retired (cross-pane hijack). Retired-INCLUSIVE deliberately - // (ledger A8): a victim's binding is retired at exit, so a live-only - // lookup would let a DEAD pane's candidate be replayed onto a fresh - // terminal. Blocks no legitimate flow: every legit resume binds at - // create time and short-circuits at guard 3a above. - if let Some(other_tid) = state - .identity - .find_by_session_including_retired("codex", thread_id) - { - if other_tid != msg.terminal_id { - tracing::warn!( - terminal_id = %msg.terminal_id, - candidate_thread_id = %thread_id, - bound_terminal_id = %other_tid, - "codex_candidate_rejected: session_bound_elsewhere" - ); - return; - } - } - // Guard 4: disk truth. - let Some(root) = codex_sessions_root() else { - tracing::warn!( - terminal_id = %msg.terminal_id, - "codex_candidate_rejected: no_codex_sessions_root" - ); - return; - }; - if let Err(reason) = verify_rollout_path(&msg.rollout_path, &root, thread_id) { - tracing::warn!( - terminal_id = %msg.terminal_id, - candidate_thread_id = %thread_id, - rollout_path = %msg.rollout_path, - "codex_candidate_rejected: {reason}" - ); - return; - } - // Bind both identity homes (they have different consumers -- see - // opencode_association.rs:135-148), then broadcast. - state.identity.upsert( - &msg.terminal_id, - Some("codex"), - Some(thread_id), - row.cwd.as_deref(), - now_ms(), - ); - state.registry.set_meta( - &msg.terminal_id, - None, - None, - Some("codex".to_string()), - Some(thread_id.to_string()), - ); - // P1.8 (trigger b): the verified adoption is an identity event -- durable - // binding row first, then the spawn-time pending marker is deleted. - // Awaited spawn_blocking inside the helper: the fsync completes before - // the associated broadcast, without pinning the dispatch worker (V1.md). - crate::pane_ledger::ledger_resolve_identity( - state, - &msg.terminal_id, - "codex", - thread_id, - row.cwd.as_deref(), - ) - .await; - broadcast_terminal_session_associated(state, &msg.terminal_id, thread_id, row.cwd.clone()); - // G3: adopted identity also feeds the activity tracker, so this - // terminal's `codex.activity.updated` records and subsequent - // `terminal.turn.complete` frames carry the sessionId (a fresh codex - // terminal otherwise never gets one -- identity arrives only here). - // Placed AFTER the pinned associated/meta broadcast pair. - if let Some(hub) = &state.activity { - hub.bind_codex_session(&msg.terminal_id, thread_id); - // G9: the verified rollout also feeds the reconcile lane, so this - // fresh terminal gets real busy-state from disk, not just PTY - // heuristics. Both calls only enqueue hub events (Task 2/Task 6): - // no file I/O and no frame emission happens on this WS dispatch - // path -- the hub task does the work, keeping frames serialized. - hub.attach_codex_rollout( - &msg.terminal_id, - thread_id, - std::path::Path::new(&msg.rollout_path), - ); - } -} - -/// Fan `terminal.session.associated` + a `terminal.meta.updated` upsert to -/// every connection. Byte-for-byte the shape of -/// `opencode_association.rs::broadcast_terminal_session_associated` with -/// provider "codex". EMISSION ORDER IS PINNED: `associated` FIRST, then -/// `meta.updated` (mirroring opencode_association.rs:163-198) -- the -/// integration test awaits them in exactly this order, and -/// `next_frame_of_type` drops out-of-order frames. Do not reorder. -fn broadcast_terminal_session_associated( - state: &WsState, - terminal_id: &str, - session_id: &str, - cwd: Option, -) { - let associated = ServerMessage::TerminalSessionAssociated(TerminalSessionAssociated { - terminal_id: terminal_id.to_string(), - session_ref: SessionLocator { - provider: "codex".to_string(), - session_id: session_id.to_string(), - }, - }); - if let Ok(frame) = serde_json::to_string(&associated) { - let _ = state.broadcast_tx.send(frame); - } - - let meta = ServerMessage::TerminalMetaUpdated(TerminalMetaUpdated { - remove: Vec::new(), - upsert: vec![TerminalMetaRecord { - terminal_id: terminal_id.to_string(), - updated_at: now_ms(), - branch: None, - checkout_root: None, - cwd, - display_subdir: None, - is_dirty: None, - provider: Some("codex".to_string()), - repo_root: None, - session_id: Some(session_id.to_string()), - token_usage: None, - }], - }); - if let Ok(frame) = serde_json::to_string(&meta) { - let _ = state.broadcast_tx.send(frame); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const TID: &str = "0192aaaa-bbbb-cccc-dddd-eeeeffff0001"; - /// A DIFFERENT session's uuid -- plays the fork/resume-lineage foreign id. - const OTHER: &str = "0192aaaa-bbbb-cccc-dddd-eeeeffff0099"; - - /// The honest first line a real rollout starts with (durability-proof.ts - /// contract): a `session_meta` record whose `payload.id` is the file's - /// OWN session id. - fn session_meta_line(id: &str) -> String { - format!("{{\"timestamp\":\"2026-07-24T12:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\"}}}}\n") - } - - fn root_with_rollout( - file_name: &str, - contents: &str, - ) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let sessions = dir - .path() - .join("sessions") - .join("2026") - .join("07") - .join("24"); - std::fs::create_dir_all(&sessions).expect("mkdir sessions tree"); - let rollout = sessions.join(file_name); - std::fs::write(&rollout, contents).expect("write rollout"); - (dir, rollout) - } - - #[test] - fn uuid_shape_accepts_canonical_and_rejects_junk() { - assert!(is_uuid_shaped(TID)); - assert!(!is_uuid_shaped("")); - assert!(!is_uuid_shaped("not-a-uuid")); - assert!(!is_uuid_shaped("0192aaaa-bbbb-cccc-dddd-eeeeffff000")); // 35 chars - assert!(!is_uuid_shaped("0192aaaa+bbbb+cccc+dddd+eeeeffff0001")); // wrong separators - } - - #[test] - fn accepts_rollout_whose_first_record_is_own_session_meta() { - // Later lines (even ones mentioning FOREIGN ids) are irrelevant: only - // the first record is consulted. - let contents = format!( - "{}{{\"type\":\"response_item\",\"session_id\":\"{OTHER}\"}}\n", - session_meta_line(TID) - ); - let (dir, rollout) = root_with_rollout( - &format!("rollout-2026-07-24T12-00-00-{TID}.jsonl"), - &contents, - ); - let root = dir.path().join("sessions"); - assert_eq!( - verify_rollout_path(rollout.to_str().unwrap(), &root, TID), - Ok(()) - ); - } - - #[test] - fn rejects_foreign_lineage_rollout() { - // The real-world fork-lineage spoof (V5: 54/144 real rollouts): the - // first line IS a session_meta and DOES carry the claimed id -- but - // only as `payload.session_id` (fork/resume lineage). `payload.id`, - // the file's OWN id, belongs to a different session. Must reject. - let contents = format!( - "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{OTHER}\",\"session_id\":\"{TID}\"}}}}\n" - ); - let (dir, rollout) = root_with_rollout( - &format!("rollout-2026-07-24T12-00-00-{OTHER}.jsonl"), - &contents, - ); - let root = dir.path().join("sessions"); - assert_eq!( - verify_rollout_path(rollout.to_str().unwrap(), &root, TID), - Err("thread_id_mismatch") - ); - } - - #[test] - fn rejects_non_session_meta_first_record() { - // Even a record carrying the claimed id is not proof unless it is the - // session_meta header record. - let contents = format!("{{\"type\":\"response_item\",\"payload\":{{\"id\":\"{TID}\"}}}}\n"); - let (dir, rollout) = root_with_rollout( - &format!("rollout-2026-07-24T12-00-00-{TID}.jsonl"), - &contents, - ); - let root = dir.path().join("sessions"); - assert_eq!( - verify_rollout_path(rollout.to_str().unwrap(), &root, TID), - Err("rollout_first_record_not_session_meta") - ); - } - - #[test] - fn rejects_malformed_first_line() { - let (dir, rollout) = root_with_rollout( - &format!("rollout-2026-07-24T12-00-00-{TID}.jsonl"), - "not json\n", - ); - let root = dir.path().join("sessions"); - assert_eq!( - verify_rollout_path(rollout.to_str().unwrap(), &root, TID), - Err("rollout_first_record_not_json") - ); - } - - #[test] - fn rejects_oversized_first_line() { - // A >1MB first line is rejected by the cap BEFORE parsing -- even if - // the JSON would have been a valid own-session session_meta. - let contents = format!( - "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{TID}\",\"pad\":\"{}\"}}}}\n", - "a".repeat(2 * 1024 * 1024) - ); - let (dir, rollout) = root_with_rollout( - &format!("rollout-2026-07-24T12-00-00-{TID}.jsonl"), - &contents, - ); - let root = dir.path().join("sessions"); - assert_eq!( - verify_rollout_path(rollout.to_str().unwrap(), &root, TID), - Err("rollout_first_line_too_large") - ); - } - - #[test] - fn rejects_nonexistent_rollout() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path().join("sessions"); - std::fs::create_dir_all(&root).unwrap(); - let missing = root.join(format!("rollout-{TID}.jsonl")); - assert_eq!( - verify_rollout_path(missing.to_str().unwrap(), &root, TID), - Err("rollout_missing") - ); - } - - #[test] - fn rejects_rollout_outside_sessions_root() { - let (dir, _rollout) = root_with_rollout(&format!("rollout-{TID}.jsonl"), "{}"); - let root = dir.path().join("sessions"); - // A real file that exists but lives OUTSIDE the root. - let outside = dir.path().join(format!("rollout-{TID}.jsonl")); - std::fs::write(&outside, "{}").unwrap(); - assert_eq!( - verify_rollout_path(outside.to_str().unwrap(), &root, TID), - Err("rollout_outside_sessions_root") - ); - } - - #[test] - fn rejects_dotdot_traversal_escape() { - let (dir, _rollout) = root_with_rollout(&format!("rollout-{TID}.jsonl"), "{}"); - let root = dir.path().join("sessions"); - let outside = dir.path().join(format!("escape-{TID}.jsonl")); - std::fs::write(&outside, "{}").unwrap(); - // Path is SPELLED under the root but traverses out; canonicalize resolves it. - let sneaky = root.join("..").join(format!("escape-{TID}.jsonl")); - assert_eq!( - verify_rollout_path(sneaky.to_str().unwrap(), &root, TID), - Err("rollout_outside_sessions_root") - ); - } - - #[cfg(unix)] - #[test] - fn rejects_symlink_escape() { - let (dir, _rollout) = root_with_rollout(&format!("rollout-{TID}.jsonl"), "{}"); - let root = dir.path().join("sessions"); - let outside = dir.path().join(format!("target-{TID}.jsonl")); - std::fs::write(&outside, "{}").unwrap(); - let link = root.join(format!("rollout-link-{TID}.jsonl")); - std::os::unix::fs::symlink(&outside, &link).expect("symlink"); - assert_eq!( - verify_rollout_path(link.to_str().unwrap(), &root, TID), - Err("rollout_outside_sessions_root") - ); - } -} diff --git a/crates/freshell-ws/src/codex_identity.rs b/crates/freshell-ws/src/codex_identity.rs new file mode 100644 index 000000000..ae91cf479 --- /dev/null +++ b/crates/freshell-ws/src/codex_identity.rs @@ -0,0 +1,181 @@ +//! Shared codex identity adoption tail: bind a verified codex thread id into +//! every identity home (identity store, registry meta, durable pane ledger, +//! broadcast frames, activity hub) in the load-bearing order. Extracted from +//! the retired client candidate channel (`codex_candidate.rs`, campaign +//! §2.3.2) and now owned solely by the server-side rollout locator. + +use std::path::{Path, PathBuf}; + +use freshell_protocol::{ + ServerMessage, SessionLocator, TerminalMetaRecord, TerminalMetaUpdated, + TerminalSessionAssociated, +}; + +use crate::terminal::now_ms; +use crate::WsState; + +/// `CODEX_HOME` env (non-empty) else `/.codex`, then `/sessions` -- +/// mirrors `freshell-server/src/session_directory.rs::codex_home` (which is +/// crate-private there; HOME only, never FRESHELL_HOME) joined with the +/// `sessions` dir the way `freshell_sessions::directory_index::CodexSource` +/// does. +/// +/// `pub` (re-exported from the crate root) for `freshell-server`'s boot-time +/// codex rollout-locator wiring. +pub fn codex_sessions_root() -> Option { + let home = match std::env::var("CODEX_HOME") { + Ok(v) if !v.is_empty() => PathBuf::from(v), + _ => { + #[cfg(windows)] + let base = std::env::var("USERPROFILE").ok()?; + #[cfg(not(windows))] + let base = std::env::var("HOME").ok()?; + PathBuf::from(base).join(".codex") + } + }; + Some(home.join("sessions")) +} + +pub(crate) struct CodexAdoption<'a> { + pub terminal_id: &'a str, + pub thread_id: &'a str, + pub rollout_path: Option<&'a Path>, + pub cwd: Option<&'a str>, +} + +/// Bind a codex identity into every home, in the load-bearing order. +/// Returns false (and adopts nothing) when the session is already bound to a +/// DIFFERENT terminal -- retired-INCLUSIVE (ledger A8), preserving the +/// cross-pane hijack defense the candidate channel had. +pub(crate) async fn adopt_codex_identity(state: &WsState, a: CodexAdoption<'_>) -> bool { + // Cross-pane hijack / replay defense, retired-INCLUSIVE (ledger A8): a + // victim's binding retires at exit, so a live-only lookup would allow + // replaying a DEAD pane's identity onto a fresh terminal. Inherited from + // the retired candidate channel's guard 3b -- keep the exact + // comparison semantics that code used; re-adopting the SAME + // terminal is an idempotent allow. This guard is also a REQUIRED A4 + // misbind hardening for the locator path: the adoption tail must refuse + // a thread id already bound to another terminal (Validated Premise 9), + // so it must never be weakened when the candidate channel is deleted. + if let Some(existing) = state + .identity + .find_by_session_including_retired("codex", a.thread_id) + { + if existing != a.terminal_id { + tracing::warn!( + terminal_id = %a.terminal_id, + thread_id = %a.thread_id, + "codex_adopt_rejected: session_bound_elsewhere" + ); + return false; + } + } + // B2xB4 misbind hardening (B2 plan item 10, fix enabled by B4's + // kind:fresh-agent ledger rows): the freshagent codex sidecar writes + // rollouts into the SAME sessions root the locator walks, so a foreign + // same-cwd rollout appearing after a pane's first Enter could misbind as + // a sole candidate. A thread id the server knows as a FRESH-AGENT + // session -- live in the fresh_codex session map, or recorded by B4's + // durable-before-answer ledger write at thread start -- must never bind + // to a terminal pane. + if state.fresh_codex.has_live_session(a.thread_id).await { + tracing::warn!( + terminal_id = %a.terminal_id, + thread_id = %a.thread_id, + "codex_adopt_rejected: freshagent_live_session" + ); + return false; + } + if state + .pane_ledger + .lookup_by_session("codex", a.thread_id) + .is_some_and(|r| r.row.pane_kind.as_deref() == Some("fresh-agent")) + { + tracing::warn!( + terminal_id = %a.terminal_id, + thread_id = %a.thread_id, + "codex_adopt_rejected: freshagent_ledger_row" + ); + return false; + } + // Both identity homes -- different consumers (see opencode_association.rs:135-148). + state.identity.upsert( + a.terminal_id, + Some("codex"), + Some(a.thread_id), + a.cwd, + now_ms(), + ); + state.registry.set_meta( + a.terminal_id, + None, + None, + Some("codex".to_string()), + Some(a.thread_id.to_string()), + ); + // Durable ledger: binding row FIRST, pending marker delete SECOND -- + // awaited before the broadcast (fsync-before-announce). + crate::pane_ledger::ledger_resolve_identity(state, a.terminal_id, "codex", a.thread_id, a.cwd) + .await; + broadcast_terminal_session_associated( + state, + a.terminal_id, + a.thread_id, + a.cwd.map(str::to_string), + ); + // Activity hub (channel-deferred, safe off the dispatch path): G3 -- + // codex.activity.updated / terminal.turn.complete carry the sessionId; + // G9 -- the rollout reconcile lane gets its file. + if let Some(hub) = &state.activity { + hub.bind_codex_session(a.terminal_id, a.thread_id); + if let Some(path) = a.rollout_path { + hub.attach_codex_rollout(a.terminal_id, a.thread_id, path); + } + } + true +} + +/// Fan `terminal.session.associated` + a `terminal.meta.updated` upsert to +/// every connection. Byte-for-byte the shape of +/// `opencode_association.rs::broadcast_terminal_session_associated` with +/// provider "codex". EMISSION ORDER IS PINNED: `associated` FIRST, then +/// `meta.updated` (mirroring opencode_association.rs:163-198) -- the +/// integration test awaits them in exactly this order, and +/// `next_frame_of_type` drops out-of-order frames. Do not reorder. +fn broadcast_terminal_session_associated( + state: &WsState, + terminal_id: &str, + session_id: &str, + cwd: Option, +) { + let associated = ServerMessage::TerminalSessionAssociated(TerminalSessionAssociated { + terminal_id: terminal_id.to_string(), + session_ref: SessionLocator { + provider: "codex".to_string(), + session_id: session_id.to_string(), + }, + }); + if let Ok(frame) = serde_json::to_string(&associated) { + let _ = state.broadcast_tx.send(frame); + } + + let meta = ServerMessage::TerminalMetaUpdated(TerminalMetaUpdated { + remove: Vec::new(), + upsert: vec![TerminalMetaRecord { + terminal_id: terminal_id.to_string(), + updated_at: now_ms(), + branch: None, + checkout_root: None, + cwd, + display_subdir: None, + is_dirty: None, + provider: Some("codex".to_string()), + repo_root: None, + session_id: Some(session_id.to_string()), + token_usage: None, + }], + }); + if let Ok(frame) = serde_json::to_string(&meta) { + let _ = state.broadcast_tx.send(frame); + } +} diff --git a/crates/freshell-ws/src/codex_reconcile.rs b/crates/freshell-ws/src/codex_reconcile.rs index ae8791648..72a87d631 100644 --- a/crates/freshell-ws/src/codex_reconcile.rs +++ b/crates/freshell-ws/src/codex_reconcile.rs @@ -13,7 +13,7 @@ use freshell_activity::codex::CodexTaskEvents; use freshell_sessions::time::parse_timestamp_ms; /// Initial attach reads at most this much of the rollout's tail. Rollouts -/// reach 28MB+ (p99, see codex_candidate.rs:72-73); the latest task events +/// reach 28MB+ (p99, V5 sampling); the latest task events /// live at the end, and trusting the tail is the legacy sanitizer's /// converged behavior for truncated snapshots. pub(crate) const INITIAL_TAIL_BYTES: u64 = 256 * 1024; @@ -141,8 +141,8 @@ pub(crate) fn fold_task_events(lines: &[String]) -> CodexTaskEvents { /// the codex sessions root. Filename containment is only a cheap PREFILTER; /// ownership is proven by the first line being a `session_meta` whose /// `payload.id` equals the session id -- filename/substring matching alone is -/// documented-unsafe (codex_candidate.rs:46-57: 40% of sampled rollouts -/// contain foreign uuids). Bounded recursive walk (the tree is +/// documented-unsafe (V5 sampling: 40% of sampled rollouts +/// contain foreign uuids as fork/resume lineage). Bounded recursive walk (the tree is /// `sessions/YYYY/MM/DD/rollout-*.jsonl`, flat `.jsonl` in tests). pub fn locate_codex_rollout(sessions_root: &Path, session_id: &str) -> Option { fn walk(dir: &Path, session_id: &str, depth: u8, hit: &mut Option) { diff --git a/crates/freshell-ws/src/existence.rs b/crates/freshell-ws/src/existence.rs index bc88eaa44..afd455ebc 100644 --- a/crates/freshell-ws/src/existence.rs +++ b/crates/freshell-ws/src/existence.rs @@ -9,7 +9,8 @@ //! downstream), never `Unknown`. //! * [`SessionExistence::Unknown`] is reserved strictly for a *cold index on a //! known provider* (boot sweep not finished / index unavailable) — it is -//! what makes the `retry` verdict honest instead of guessing (§5.3 row 5). +//! what makes the `error{index_warming}` verdict honest instead of +//! guessing (§5.3 row 5). //! //! Trait-shaped so crate tests inject a fake; the real implementation is //! backed by the shared `freshell_sessions::directory_index::SessionIndex` @@ -28,6 +29,10 @@ pub enum SessionExistence { Absent, /// Known provider, but the index cannot answer yet (cold/unavailable). Unknown, + /// Known provider whose session root does not exist on this machine — + /// it will never warm up, so downstream answers an immediate + /// `error{provider_unavailable}` with NO deferral (never `index_warming`). + ProviderUnavailable, } /// The reconcile derivation's read-only view of disk session truth. diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index ad2e72271..b8f4f4d37 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -36,6 +36,17 @@ use crate::identity::TerminalIdentityRegistry; /// (2s) after a submit; five windows of slack keeps the alarm quiet through /// any normal association latency while still firing within seconds of a /// genuinely-lost identity. +/// +/// This same grace also covers the codex locator +/// ([`freshell_sessions::codex_locator::CODEX_WINDOW_MS`] = 2s, +/// Enter-anchored, plus +/// [`freshell_sessions::codex_locator::PENDING_FIRST_LINE_GRACE_MS`] = 10s +/// that applies ONLY in the anomalous empty-first-line gap): fresh codex +/// panes are expected to resolve via `codex_association` within this grace +/// of their first Enter. A maximally slow codex git-info gap (rollout header +/// held back until codex finishes probing repo state) can legitimately +/// outlast the alarm grace -- in that case the alarm is advisory, not a +/// lost identity. pub(crate) const IDENTITY_RESOLUTION_GRACE_MS: i64 = 5 * freshell_sessions::amplifier_locator::AMPLIFIER_DIR_APPEAR_WINDOW_MS; diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index d1b9d9f8d..b117790b2 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -23,7 +23,8 @@ pub mod activity; pub mod amplifier_association; pub mod backpressure; -pub(crate) mod codex_candidate; +pub mod codex_association; +pub(crate) mod codex_identity; pub(crate) mod codex_reconcile; pub mod create_limit; pub mod existence; @@ -33,13 +34,14 @@ pub mod opencode_association; pub mod origin; pub mod pane_ledger; pub mod reconcile; +pub mod reconcile_freshagent; pub mod screenshot; pub mod spawn_gate; pub mod tabs; pub mod tabs_persist; pub mod terminal; -pub use codex_candidate::codex_sessions_root; +pub use codex_identity::codex_sessions_root; pub use codex_reconcile::locate_codex_rollout; use std::sync::Arc; @@ -229,6 +231,23 @@ pub struct WsState { /// of `identity` and the locator handles); [`crate::existence::NoIndexProbe`] /// when no provider home resolves. pub session_existence: crate::existence::SharedExistenceProbe, + /// Reconciliation handshake (design §5.3 row 5): the budget, in + /// milliseconds, for `handle_pane_reconcile`'s ONE bounded deferral when + /// a derivation comes back `error{index_warming}` — wait at most this + /// long, re-derive once, answer. Never loops. Default + /// [`crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT`] (2000ms); + /// tests shrink it so the warming paths are observable without a real + /// 2s wait (mirrors `ping_interval_ms` / `hello_timeout_ms`). + pub reconcile_deferral_budget_ms: u64, + /// Per-boot fresh-agent respawn-answer counter, keyed `(provider, + /// sessionId)` (campaign §4.3, V2/A7). Counts RESPAWN ANSWERS only — + /// incremented by [`crate::reconcile_freshagent::build_snapshot`] when an + /// answer goes out as `respawn`, and CLEARED when the session's presence + /// resolves Live (a successful respawn is OBSERVED as the session going + /// live), so healthy sessions are never exhausted by reconnect/reload + /// storms. In-memory only: a server restart intentionally resets it. + pub fresh_agent_respawn_counts: + std::sync::Arc>>, /// The opencode terminal-pane session locator (restore-across-restart fix, /// `docs/plans/2026-07-18-opencode-terminal-restore-spec.md`): correlates a /// fresh opencode PTY's first Enter/submit (or a row written at spawn) with @@ -240,6 +259,17 @@ pub struct WsState { /// point no-ops in that case. Sibling to `amplifier_locator` (spec §8: a /// provider-parameterized locator was explicitly rejected). pub opencode_locator: Option>, + /// The codex terminal-pane rollout locator (Lane B2): correlates a fresh + /// codex PTY's first Enter with the new rollout JSONL codex writes under + /// the sessions root — real codex materializes the file only at the first + /// user prompt, so the locator's windows are Enter-anchored (no spawn + /// window) — so the terminal can be bound to a session identity and + /// `terminal.rs`'s generic resume derivation can drive `codex resume ` + /// on restart. `None` when HOME/CODEX_HOME are unresolvable — every + /// [`crate::codex_association`] entry point no-ops in that case. Sibling + /// to `opencode_locator` (a provider-parameterized locator was explicitly + /// rejected there; same call here). + pub codex_locator: Option>, /// TERM-15/TERM-16: the terminal-mode CLI activity hub (claude/codex/ /// amplifier trackers + the truly-idle gate + the amplifier events /// lanes). `None` in unit tests that never exercise activity; always @@ -357,7 +387,7 @@ pub fn spawn_idle_monitor( /// would lose scrollback). On a truly fresh boot the registry is empty, so this stays /// byte-identical to the clean-boot handshake the oracle's T0/determinism tiers pin. pub fn build_handshake(state: &WsState) -> Vec { - build_handshake_with_capabilities(state, false) + build_handshake_with_capabilities(state, false, false) } /// [`build_handshake`], parameterized on the connection's negotiated @@ -368,6 +398,7 @@ pub fn build_handshake(state: &WsState) -> Vec { pub fn build_handshake_with_capabilities( state: &WsState, pane_reconcile_v1: bool, + pane_reconcile_fresh_agent_v1: bool, ) -> Vec { let boot_id = state.boot_id.as_ref().clone(); let mut messages = vec![ @@ -375,9 +406,12 @@ pub fn build_handshake_with_capabilities( timestamp: now_iso(), boot_id: Some(boot_id.clone()), server_instance_id: Some(state.server_instance_id.as_ref().clone()), - capabilities: pane_reconcile_v1.then_some(freshell_protocol::ReadyCapabilities { - pane_reconcile_v1: Some(true), - }), + capabilities: (pane_reconcile_v1 || pane_reconcile_fresh_agent_v1).then_some( + freshell_protocol::ReadyCapabilities { + pane_reconcile_v1: pane_reconcile_v1.then_some(true), + pane_reconcile_fresh_agent_v1: pane_reconcile_fresh_agent_v1.then_some(true), + }, + ), }), ServerMessage::SettingsUpdated(SettingsUpdated { settings: state.settings.as_ref().clone(), @@ -577,8 +611,19 @@ async fn handle_socket( .and_then(|v| v.as_bool()) .unwrap_or(false); + // Fresh-agent restart resilience: same opt-in gate as `paneReconcileV1`, + // for the fresh-agent verdict families. The frozen client never sends it, + // so its handshake stays byte-for-byte unchanged. + let pane_reconcile_fresh_agent_v1 = value + .get("capabilities") + .and_then(|c| c.get("paneReconcileFreshAgentV1")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // Authenticated: emit the ordered handshake. - for msg in build_handshake_with_capabilities(&state, pane_reconcile_v1) { + for msg in + build_handshake_with_capabilities(&state, pane_reconcile_v1, pane_reconcile_fresh_agent_v1) + { let json = match serde_json::to_string(&msg) { Ok(json) => json, Err(_) => return, @@ -615,6 +660,7 @@ async fn handle_socket( terminal_output_batch_v1, ui_screenshot_v1, pane_reconcile_v1, + pane_reconcile_fresh_agent_v1, origin_kind, ) .await; @@ -665,6 +711,7 @@ async fn send_error( actual_session_ref: None, expected_session_ref: None, request_id: None, + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, }); @@ -739,8 +786,11 @@ mod tests { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), } } @@ -800,7 +850,7 @@ mod tests { #[test] fn handshake_advertises_pane_reconcile_only_when_negotiated() { let s = state(); - let negotiated = build_handshake_with_capabilities(&s, true); + let negotiated = build_handshake_with_capabilities(&s, true, false); let ready = serde_json::to_value(&negotiated[0]).unwrap(); assert_eq!( ready["capabilities"], @@ -814,7 +864,7 @@ mod tests { "non-negotiating hello must not change ready's shape: {ready}" ); // Same shape as an explicit `false` negotiation. - let unnegotiated = build_handshake_with_capabilities(&s, false); + let unnegotiated = build_handshake_with_capabilities(&s, false, false); let ready2 = serde_json::to_value(&unnegotiated[0]).unwrap(); assert!(ready2.get("capabilities").is_none()); } diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index df20d50a6..c7e1155f1 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -289,8 +289,11 @@ mod tests { config_fallback: None, amplifier_locator: None, opencode_locator: Some(StdArc::new(OpencodeLocator::new(data_home))), + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; (state, rx) } diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index ca940f1c4..b7adaa4de 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -112,6 +112,20 @@ pub struct BindingRow { pub retired_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub superseded_by: Option, + /// "fresh-agent" for fresh-agent rows (P1.13); absent on terminal rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_kind: Option, + /// Resume-invocation record (campaign plan §4.2): exactly what the + /// provider-native resume command needs. Updated when the user changes + /// them. All optional under LEDGER_VERSION 1 — no version bump. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } /// Evidence that identity establishment was in flight (G1: never a binding). @@ -137,6 +151,27 @@ pub struct BindingWrite<'a> { pub now_ms: i64, } +/// One fresh-agent identity event's worth of binding-row input (P1.13). +/// Settings are a FULL snapshot: callers always know the current values, +/// so writes replace rather than merge. +#[derive(Debug, Clone, Copy)] +pub struct FreshAgentBindingWrite<'a> { + pub provider: &'a str, + pub session_id: &'a str, + pub mode: &'a str, + pub cwd: Option<&'a str>, + pub create_request_id: Option<&'a str>, + pub model: Option<&'a str>, + pub sandbox: Option<&'a str>, + pub permission_mode: Option<&'a str>, + pub effort: Option<&'a str>, + /// G3 supersession (V8/A14): the OLD session id this binding replaces + /// (codex crash-respawn). When `Some`, the old `(provider, supersedes)` + /// row is retired and linked AFTER the new row persists. + pub supersedes: Option<&'a str>, + pub now_ms: i64, +} + /// A chain-terminus lookup result. `corrected == true` means the caller's /// claimed ref was superseded and this row is the live successor. #[derive(Debug, Clone)] @@ -366,25 +401,135 @@ impl PaneLedger { state: RowState::Bound, retired_reason: None, superseded_by: None, + pane_kind: None, + model: None, + sandbox: None, + permission_mode: None, + effort: None, }; self.write_binding(root, index, &row)?; // new bound row FIRST (pinned) - if let Some(mut old) = previous { - old.state = RowState::Retired; - old.retired_reason = Some(RetiredReason::Superseded); - old.superseded_by = Some(SessionLocator { - provider: w.provider.to_string(), - session_id: w.session_id.to_string(), - }); - old.updated_at = w.now_ms; - tracing::info!( + if let Some(old) = previous { + self.retire_and_link_locked( + root, + index, + old, + SessionLocator { + provider: w.provider.to_string(), + session_id: w.session_id.to_string(), + }, + w.now_ms, + Some(w.terminal_id), + )?; + } + Ok(()) + } + + /// Retire an old bound row and link it to the session that superseded it + /// (G3 retire-never-defend; the ONE supersession block shared by + /// [`Self::record_binding_locked`] and [`Self::record_fresh_agent_binding`] + /// so the two sites can never drift): state→Retired, + /// retired_reason→Superseded, superseded_by→the new session's locator, + /// updated_at→now, one info log, then persist. Callers write the new bound + /// row FIRST and call this AFTER (order pinned). `terminal_id` is `Some` + /// for terminal-pane rows (logged) and `None` for fresh-agent rows (which + /// own no terminal). + fn retire_and_link_locked( + &self, + root: &Path, + index: &mut LedgerIndex, + mut old: BindingRow, + superseded_by: SessionLocator, + now_ms: i64, + terminal_id: Option<&str>, + ) -> std::io::Result<()> { + old.state = RowState::Retired; + old.retired_reason = Some(RetiredReason::Superseded); + old.updated_at = now_ms; + match terminal_id { + Some(terminal_id) => tracing::info!( target: "freshell_ws::pane_ledger", - terminal_id = %w.terminal_id, + terminal_id = %terminal_id, old_session_id = %old.session_id, - new_session_id = %w.session_id, + new_session_id = %superseded_by.session_id, "pane_ledger_superseded: binding moved; old row retired, never defended" - ); - self.write_binding(root, index, &old)?; // THEN retire the old + ), + None => tracing::info!( + target: "freshell_ws::pane_ledger", + old_session_id = %old.session_id, + new_session_id = %superseded_by.session_id, + "pane_ledger_superseded: fresh-agent binding moved; \ + old row retired, never defended" + ), + } + old.superseded_by = Some(superseded_by); + self.write_binding(root, index, &old) // THEN retire the old + } + + /// Record (or refresh) a `bound` row for a fresh-agent identity event + /// (P1.13). Upsert keyed `(provider, session_id)`: settings are a FULL + /// snapshot (replace, not merge); `created_at` is preserved on rewrite. + /// When `w.supersedes` names a different old session id, the old row is + /// retired and linked AFTER the new bound row persists (G3 order pinned, + /// V8/A14) — a missing old row is a silent no-op. + pub fn record_fresh_agent_binding( + &self, + w: &FreshAgentBindingWrite<'_>, + ) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + + let key = (w.provider.to_string(), w.session_id.to_string()); + let existing = index.bindings.get(&key); + let created_at = existing.map(|r| r.created_at).unwrap_or(w.now_ms); + // Advisory field: keep the existing row's value when the new write + // has none (latest-observed semantics, D4). + let create_request_id = w + .create_request_id + .map(str::to_string) + .or_else(|| existing.and_then(|r| r.create_request_id.clone())); + let row = BindingRow { + ledger_version: LEDGER_VERSION, + provider: w.provider.to_string(), + session_id: w.session_id.to_string(), + mode: w.mode.to_string(), + cwd: w.cwd.map(str::to_string), + live_terminal_id: None, // fresh-agent panes have no terminal + create_request_id, + created_at, + updated_at: w.now_ms, + last_observed_at: w.now_ms, + state: RowState::Bound, + retired_reason: None, + superseded_by: None, + pane_kind: Some("fresh-agent".into()), + model: w.model.map(str::to_string), + sandbox: w.sandbox.map(str::to_string), + permission_mode: w.permission_mode.map(str::to_string), + effort: w.effort.map(str::to_string), + }; + self.write_binding(root, &mut index, &row)?; // new bound row FIRST (pinned) + + if let Some(old_id) = w.supersedes { + if old_id != w.session_id { + let old_key = (w.provider.to_string(), old_id.to_string()); + if let Some(old) = index.bindings.get(&old_key).cloned() { + self.retire_and_link_locked( + root, + &mut index, + old, + SessionLocator { + provider: w.provider.to_string(), + session_id: w.session_id.to_string(), + }, + w.now_ms, + None, // fresh-agent rows own no terminal + )?; + } + // Missing old row: silent no-op. + } } Ok(()) } diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 1b48311b5..b4cbfb4f9 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -545,6 +545,11 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { state: RowState::Bound, retired_reason: None, superseded_by: None, + pane_kind: None, + model: None, + sandbox: None, + permission_mode: None, + effort: None, }; write_row_atomic( &root @@ -637,6 +642,195 @@ fn gc_expired_tombstone_rebinds_on_a_live_identity_event() { std::fs::remove_dir_all(&root).ok(); } +#[test] +fn fresh_agent_binding_roundtrips_settings_and_pane_kind() { + let root = temp_root("fresh-agent-roundtrip"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + provider: "codex", + session_id: "thread-1", + mode: "freshcodex", + cwd: Some("/home/u/proj"), + create_request_id: Some("req-1"), + model: Some("gpt-5.3-codex-spark"), + sandbox: Some("workspace-write"), + permission_mode: Some("on-request"), + effort: Some("high"), + supersedes: None, + now_ms: 1_000, + }) + .unwrap(); + let row = ledger.load_binding("codex", "thread-1").expect("row"); + assert_eq!(row.pane_kind.as_deref(), Some("fresh-agent")); + assert_eq!(row.model.as_deref(), Some("gpt-5.3-codex-spark")); + assert_eq!(row.sandbox.as_deref(), Some("workspace-write")); + assert_eq!(row.permission_mode.as_deref(), Some("on-request")); + assert_eq!(row.effort.as_deref(), Some("high")); + assert_eq!(row.cwd.as_deref(), Some("/home/u/proj")); + assert_eq!(row.created_at, 1_000); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn fresh_agent_binding_upsert_preserves_created_at_and_refreshes_settings() { + let root = temp_root("fresh-agent-upsert"); + let ledger = PaneLedger::new(Some(root.clone())); + let base = FreshAgentBindingWrite { + provider: "opencode", + session_id: "ses_abc", + mode: "freshopencode", + cwd: Some("/w"), + create_request_id: None, + model: Some("m1"), + sandbox: None, + permission_mode: None, + effort: Some("low"), + supersedes: None, + now_ms: 1_000, + }; + ledger.record_fresh_agent_binding(&base).unwrap(); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + model: Some("m2"), + effort: None, + now_ms: 2_000, + ..base + }) + .unwrap(); + let row = ledger.load_binding("opencode", "ses_abc").expect("row"); + assert_eq!(row.created_at, 1_000, "upsert must preserve created_at"); + assert_eq!(row.updated_at, 2_000); + assert_eq!(row.model.as_deref(), Some("m2")); + assert_eq!( + row.effort, None, + "settings are a full snapshot, not a merge" + ); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn supersedes_retires_the_old_row_and_links_the_chain() { + // G3 supersession (V8/A14): codex crash-respawn must retire the OLD + // thread row and link it to the new one — never leave two Bound rows. + let root = temp_root("fresh-agent-supersedes"); + let ledger = PaneLedger::new(Some(root.clone())); + let base = FreshAgentBindingWrite { + provider: "codex", + session_id: "old-thread", + mode: "freshcodex", + cwd: Some("/w"), + create_request_id: None, + model: Some("m"), + sandbox: None, + permission_mode: None, + effort: None, + supersedes: None, + now_ms: 1_000, + }; + ledger.record_fresh_agent_binding(&base).unwrap(); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + session_id: "new-thread", + supersedes: Some("old-thread"), + now_ms: 2_000, + ..base + }) + .unwrap(); + let old = ledger + .load_binding("codex", "old-thread") + .expect("old row kept"); + assert_eq!( + old.state, + RowState::Retired, + "old row retired, never left Bound" + ); + assert_eq!( + old.superseded_by.as_ref().map(|l| l.session_id.as_str()), + Some("new-thread"), + "supersededBy links old → new" + ); + let res = ledger + .lookup_by_session("codex", "old-thread") + .expect("chain resolves"); + assert!( + res.corrected, + "claiming the old id is a corrected resolution" + ); + assert_eq!( + res.row.session_id, "new-thread", + "resolution lands at the terminus" + ); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn fresh_agent_upsert_preserves_advisory_create_request_id_when_absent() { + // create_request_id is advisory, latest-observed (D4): a rewrite that + // carries None must not erase the previously observed value. + let root = temp_root("fresh-agent-req-id"); + let ledger = PaneLedger::new(Some(root.clone())); + let base = FreshAgentBindingWrite { + provider: "codex", + session_id: "thread-1", + mode: "freshcodex", + cwd: None, + create_request_id: Some("req-1"), + model: None, + sandbox: None, + permission_mode: None, + effort: None, + supersedes: None, + now_ms: 1_000, + }; + ledger.record_fresh_agent_binding(&base).unwrap(); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + create_request_id: None, + now_ms: 2_000, + ..base + }) + .unwrap(); + let row = ledger.load_binding("codex", "thread-1").expect("row"); + assert_eq!(row.create_request_id.as_deref(), Some("req-1")); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn supersedes_of_a_missing_old_row_is_a_silent_noop() { + let root = temp_root("fresh-agent-supersedes-missing"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + provider: "codex", + session_id: "new-thread", + mode: "freshcodex", + cwd: None, + create_request_id: None, + model: None, + sandbox: None, + permission_mode: None, + effort: None, + supersedes: Some("never-existed"), + now_ms: 1_000, + }) + .expect("missing old row is a silent no-op, not an error"); + assert!(ledger.load_binding("codex", "never-existed").is_none()); + let row = ledger.load_binding("codex", "new-thread").expect("row"); + assert_eq!(row.state, RowState::Bound); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn old_terminal_rows_without_settings_fields_still_deserialize() { + // A wave-A row serialized before this change has none of the new fields. + let json = r#"{"ledgerVersion":1,"provider":"claude","sessionId":"s1","mode":"claude", + "createdAt":1,"updatedAt":1,"lastObservedAt":1,"state":"bound"}"#; + let row: BindingRow = serde_json::from_str(json).expect("old row must parse"); + assert_eq!(row.pane_kind, None); + assert_eq!(row.model, None); +} + #[test] fn rebind_to_the_same_identity_is_not_a_supersession() { let root = temp_root("samebind"); diff --git a/crates/freshell-ws/src/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index b82eef02e..136a97e7d 100644 --- a/crates/freshell-ws/src/reconcile.rs +++ b/crates/freshell-ws/src/reconcile.rs @@ -18,10 +18,11 @@ use crate::identity::TerminalIdentityRegistry; /// never silently truncated. pub const MAX_RECONCILE_PANES: usize = 200; -/// §5.3 row 5 placeholder cadence for `retry(index_warming)` (the retry -/// mechanism itself is an OPEN user decision, design §8.0 — this implements -/// the documented placeholder). -pub const RETRY_AFTER_MS: i64 = 2000; +/// §5.3 row 5 default budget for the handler's ONE bounded deferral before +/// re-deriving verdicts once (council-pinned single deferral): when a +/// derivation comes back `error{index_warming}`, `handle_pane_reconcile` +/// waits at most this long, re-derives once, and answers — never loops. +pub const RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT: u64 = 2000; /// The read-only inputs of the derivation (§5.1) — all shared handles that /// already live on [`crate::WsState`]. @@ -29,6 +30,11 @@ pub struct ReconcileDeps<'a> { pub registry: &'a TerminalRegistry, pub identity: &'a TerminalIdentityRegistry, pub existence: &'a dyn SessionExistenceProbe, + /// Fresh-agent facts snapshot (campaign §4.3): `Some` only on a + /// connection that negotiated `paneReconcileFreshAgentV1` AND presented + /// fresh-agent panes; `None` keeps the frozen client's + /// `invalid{unsupported_kind}` contract. + pub fresh_agent: Option<&'a crate::reconcile_freshagent::FreshAgentReconcileSnapshot>, } /// Derive one verdict per presented pane, 1:1 by `paneKey`, order preserved @@ -60,7 +66,6 @@ fn invalid(pane: &ReconcilePane, reason: &str) -> PaneVerdict { session_ref: None, corrected: None, reason: Some(reason.to_string()), - retry_after_ms: None, duplicate: None, } } @@ -73,7 +78,6 @@ fn base(pane: &ReconcilePane, verdict: ReconcileVerdict) -> PaneVerdict { session_ref: None, corrected: None, reason: None, - retry_after_ms: None, duplicate: None, } } @@ -127,7 +131,13 @@ fn resolve_authoritative_ref( if let Some(sref) = pane.session_ref.clone() { return Some(sref); } - // 4. ONE uniform promotion rule: {provider: mode, sessionId: resumeSessionId}. + // 4. ONE uniform promotion rule. + promoted_legacy_claim(pane) +} + +/// §5.2's ONE uniform promotion rule: a legacy `mode` + `resumeSessionId` +/// pair IS a sessionRef claim — `{provider: mode, sessionId: resumeSessionId}`. +fn promoted_legacy_claim(pane: &ReconcilePane) -> Option { let mode = pane .mode .as_deref() @@ -159,6 +169,28 @@ fn attach( } } +/// Council rule 6: the live terminal (if any) currently carrying this CLAIMED +/// sessionRef — consulted across BOTH identity stores, mirroring +/// [`resolve_authoritative_ref`]'s dual-source read: the ws-owned identity +/// registry (live entries, re-verified against registry liveness so a stale +/// un-retired entry never produces a phantom attach), then the registry-row +/// join (REST-created resumes whose identity never reached the identity +/// registry across the crate boundary). +fn live_terminal_for_claimed_ref( + deps: &ReconcileDeps<'_>, + claim: &SessionLocator, +) -> Option { + if let Some(entry) = deps + .identity + .find_by_session(&claim.provider, &claim.session_id) + { + if deps.registry.is_live(&entry.terminal_id) { + return Some(entry.terminal_id); + } + } + deps.registry.live_terminal_for_session_ref(claim) +} + fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdict { // §5.3 row 10 protocol hygiene: malformed entries get an explicit reason. if pane.pane_key.is_empty() { @@ -166,6 +198,9 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi } match pane.kind.as_deref() { Some("terminal") => {} + Some("fresh-agent") => { + return crate::reconcile_freshagent::verdict_for_pane(deps.fresh_agent, pane) + } Some(_) => return invalid(pane, "unsupported_kind"), None => return invalid(pane, "missing_kind"), } @@ -178,6 +213,34 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi return invalid(pane, "missing_create_request_id"); }; + // Council rule 6 (sessionRef-level single-flight, reconcile side): a + // live terminal ALREADY carrying the claimed sessionRef wins over + // createRequestId keying — every other client's claim for the same ref + // (regardless of createRequestId) attaches to the winner, never a + // respawn that would open a second JSONL writer (D8). The effective + // claim is the structured `sessionRef`, or the legacy `mode` + + // `resumeSessionId` pair promoted by §5.2's ONE uniform rule — a + // legacy-claiming client must not bypass this branch. + if let Some(claim) = pane + .session_ref + .clone() + .or_else(|| promoted_legacy_claim(pane)) + { + if let Some(tid) = live_terminal_for_claimed_ref(deps, &claim) { + let server_ref = deps + .identity + .session_ref_for(&tid) + .or_else(|| Some(claim.clone())); + let corrected = corrected_flag(Some(&claim), server_ref.as_ref()); + return PaneVerdict { + terminal_id: Some(tid), + session_ref: server_ref, + corrected, + ..base(pane, ReconcileVerdict::Attach) + }; + } + } + // Rows 1/2/2b: any LIVE terminal wins. let t1 = deps.registry.newest_live_by_create_request_id(&key); let t2 = pane @@ -248,8 +311,14 @@ fn verdict_for_pane(deps: &ReconcileDeps<'_>, pane: &ReconcilePane) -> PaneVerdi } SessionExistence::Unknown => PaneVerdict { reason: Some("index_warming".to_string()), - retry_after_ms: Some(RETRY_AFTER_MS), - ..base(pane, ReconcileVerdict::Retry) + ..base(pane, ReconcileVerdict::Error) + }, + // A known provider with no home on this machine is NOT warming — the + // honest, immediate provider_unavailable (the handler's single + // deferral fires only on index_warming, `terminal.rs`). + SessionExistence::ProviderUnavailable => PaneVerdict { + reason: Some("provider_unavailable".to_string()), + ..base(pane, ReconcileVerdict::Error) }, } } @@ -328,6 +397,7 @@ mod tests { registry: &self.registry, identity: &self.identity, existence: &self.probe, + fresh_agent: None, } } @@ -477,18 +547,35 @@ mod tests { ); } - /// Row 5 (§9.1 test 6): cold index on a known provider → honest retry, - /// never dead_session, never optimistic respawn. + /// 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 + /// deferral is the recovery attempt, `terminal.rs`.) #[test] - fn row5_unknown_existence_yields_retry_with_backoff() { + fn row5_unknown_existence_yields_error_index_warming() { let f = Fixture::new(); f.probe.set("claude", "s-cold", SessionExistence::Unknown); let mut p = pane("cr-5"); p.session_ref = Some(sref("claude", "s-cold")); let v = f.one(p); - assert_eq!(v.verdict, ReconcileVerdict::Retry); + assert_eq!(v.verdict, ReconcileVerdict::Error); assert_eq!(v.reason.as_deref(), Some("index_warming")); - assert_eq!(v.retry_after_ms, Some(RETRY_AFTER_MS)); + } + + /// A known provider whose session root does not exist on this machine → + /// immediate error{provider_unavailable} — never index_warming (which + /// would trigger the handler's deferral), never dead_session. + #[test] + fn provider_unavailable_existence_yields_error_provider_unavailable() { + let f = Fixture::new(); + f.probe + .set("codex", "s-pu", SessionExistence::ProviderUnavailable); + let mut p = pane("cr-pu"); + p.mode = Some("codex".to_string()); + p.session_ref = Some(sref("codex", "s-pu")); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Error); + assert_eq!(v.reason.as_deref(), Some("provider_unavailable")); } /// Row 6: no terminalId at all, just a claim that IS on disk → respawn @@ -543,7 +630,7 @@ mod tests { assert_eq!(v.reason.as_deref(), Some("missing_create_request_id")); let mut bad_kind = pane("cr-10c"); - bad_kind.kind = Some("fresh-agent".to_string()); + bad_kind.kind = Some("browser".to_string()); let v = f.one(bad_kind); assert_eq!(v.verdict, ReconcileVerdict::Invalid); assert_eq!(v.reason.as_deref(), Some("unsupported_kind")); @@ -668,6 +755,69 @@ mod tests { ); } + /// Council rule 6, registry-row half: a REST-created LIVE terminal whose + /// identity lives ONLY on the registry row (resume_session_id, no + /// identity-registry entry) still answers a different client's claim for + /// the same sessionRef with attach{its terminalId} — never a second + /// writer (D8). + #[test] + fn cross_client_claim_attaches_to_live_rest_created_terminal() { + let f = Fixture::new(); + f.registry.register_headless(HeadlessTerminal { + terminal_id: "T-rest-live".to_string(), + stream_id: "S-rest-live".to_string(), + mode: "codex".to_string(), + resume_session_id: Some("s-shared".to_string()), + create_request_id: Some("cr-WINNER".to_string()), + created_at: Some(1_000), + }); + // Session Present on disk — the D8 shape would otherwise be respawn. + f.probe.set("codex", "s-shared", SessionExistence::Present); + + let mut p = pane("cr-OTHER"); + p.mode = Some("codex".to_string()); + p.session_ref = Some(sref("codex", "s-shared")); + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Attach); + assert_eq!(v.terminal_id.as_deref(), Some("T-rest-live")); + assert_eq!(v.session_ref, Some(sref("codex", "s-shared"))); + assert_eq!(v.corrected, None, "the claim matched server truth"); + } + + /// Council rule 6, legacy-claim half: §5.2's ONE uniform promotion rule + /// applies BEFORE the sessionRef-attach lookup — a pane claiming only the + /// legacy `mode` + `resumeSessionId` pair (NO structured sessionRef) + /// whose session is carried by a live terminal under a DIFFERENT + /// createRequestId attaches to that terminal, never the D8 respawn that + /// would open a second JSONL writer. + #[test] + fn legacy_resume_claim_attaches_to_live_terminal_across_create_request_ids() { + let f = Fixture::new(); + f.registry.register_headless(HeadlessTerminal { + terminal_id: "T-legacy-live".to_string(), + stream_id: "S-legacy-live".to_string(), + mode: "codex".to_string(), + resume_session_id: Some("s-legacy".to_string()), + create_request_id: Some("cr-WINNER".to_string()), + created_at: Some(1_000), + }); + // Session Present on disk — the fall-through path would derive the + // D8 respawn. + f.probe.set("codex", "s-legacy", SessionExistence::Present); + + let mut p = pane("cr-OTHER"); + p.mode = Some("codex".to_string()); + p.resume_session_id = Some("s-legacy".to_string()); // legacy claim only + let v = f.one(p); + assert_eq!(v.verdict, ReconcileVerdict::Attach); + assert_eq!(v.terminal_id.as_deref(), Some("T-legacy-live")); + assert_eq!(v.session_ref, Some(sref("codex", "s-legacy"))); + assert_eq!( + v.corrected, None, + "a matching promoted claim is not a correction" + ); + } + /// §9.1 test 8 trust boundary, respawn shape: the claim contradicts the /// retired server identity → server ref + corrected: true. #[test] diff --git a/crates/freshell-ws/src/reconcile_freshagent.rs b/crates/freshell-ws/src/reconcile_freshagent.rs new file mode 100644 index 000000000..402a44d5b --- /dev/null +++ b/crates/freshell-ws/src/reconcile_freshagent.rs @@ -0,0 +1,409 @@ +//! Fresh-agent pane verdicts (P1.13, campaign §4.3): same verdict vocabulary +//! as terminals — attach (tracked live) / respawn (resumable via +//! provider-native resume) / dead_session (positively gone) / fresh — no new +//! states. All async work (session maps, probes) happens in the snapshot +//! builder (Task 13); this module is pure + sync so it stays legal inside +//! derive_verdicts' catch_unwind. +//! +//! Verdict-mapping caveats (V5/A8, documented here for future readers): +//! - Zero-turn codex threads have NO rollout file until the first PERSISTED +//! user message (vendor deferred materialization, verified at rust-v0.145.0) +//! ⇒ the probe answers Absent ⇒ verdict `fresh` (identity never observed) +//! or `dead_session` (ledger already bound it). Acceptable either way: +//! zero turns means there is no conversation content to lose. +//! - WATCH: codex `.jsonl.zst` cold-rollout compression (vendor feature, +//! default-OFF today) would hide ≥7-day-old sessions from the `.jsonl`-only +//! index walk ⇒ false Absent. Revisit if the vendor flag graduates. +//! - WATCH: CLAUDE_CONFIG_DIR/CLAUDE_HOME reader/writer split (pre-existing +//! wave-A exposure, out of scope this lane). + +use freshell_protocol::{PaneVerdict, ReconcilePane, ReconcileVerdict, SessionLocator}; + +pub const FRESH_AGENT_RESPAWN_CAP: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreshAgentPresence { + Live, + OnDisk, + GoneObserved, + NeverObserved, + Unknown, +} +// B1 pre-decision (V9/A12): when B1's `SessionExistence::ProviderUnavailable` +// lands, it maps to FreshAgentPresence::Unknown (conservative: respawn-with-cap, +// never dead_session). Keep every SessionExistence match exhaustive, no catch-all. + +#[derive(Debug, Clone, PartialEq)] +pub struct FreshAgentPaneFacts { + pub presence: FreshAgentPresence, + pub duplicate_of: Option, // paneKey of the earlier pane claiming the same session + pub respawn_exhausted: bool, + /// G3 supersession terminus (V8/A14): when the claimed sessionRef resolved + /// through the ledger's supersededBy chain to a DIFFERENT id, this is the + /// terminus id — the verdict answers with it + `corrected: true`. + pub resolved_session_id: Option, +} + +#[derive(Debug, Default)] +pub struct FreshAgentReconcileSnapshot { + pub facts: std::collections::HashMap, +} + +/// The ONE async gathering pass per reconcile request (Task 13): session-map +/// liveness, disk existence, ledger supersession resolution, in-request +/// dedupe, and the respawn cap all condense into per-pane facts here, so +/// [`verdict_for_pane`] stays pure + sync inside derive_verdicts' +/// catch_unwind. Built ONCE per request and REUSED if deps are re-derived — +/// rebuilding would double-burn the respawn counter. +/// +/// Respawn-cap rationale (V2/A7): the terminal donor counts actual process +/// exits within a liveness window and RESETS on survival +/// (`registry.rs:1271-1283`) — per-answer burning without decay would let 3 +/// reloads kill a healthy session. Counting respawn ANSWERS with +/// reset-on-live means only a genuinely non-recovering loop trips the cap. +/// Residual A19 (accepted): the next-wave client's reconcile cadence is +/// unpinned — REVISIT the cap value/semantics when that client lands. +pub async fn build_snapshot( + state: &crate::WsState, + panes: &[ReconcilePane], +) -> FreshAgentReconcileSnapshot { + let mut facts = std::collections::HashMap::new(); + let mut claimed: std::collections::HashMap<(String, String), String> = + std::collections::HashMap::new(); + for pane in panes + .iter() + .filter(|p| p.kind.as_deref() == Some("fresh-agent")) + { + let Some(sref) = pane.session_ref.as_ref() else { + continue; // verdict fn answers no_recoverable_identity + }; + // G3 reader rule (V8/A14): resolve the CLAIM through the ledger's + // supersession chain FIRST — dedupe, liveness, existence, and the + // respawn counter all key on the TERMINUS id (lookup_by_session is + // public + memory-only). + let resolved = state + .pane_ledger + .lookup_by_session(&sref.provider, &sref.session_id) + .filter(|r| r.corrected) + .map(|r| r.row.session_id); + let session_id = resolved.clone().unwrap_or_else(|| sref.session_id.clone()); + let key = (sref.provider.clone(), session_id.clone()); + if let Some(winner) = claimed.get(&key) { + facts.insert( + pane.pane_key.clone(), + FreshAgentPaneFacts { + presence: FreshAgentPresence::Unknown, + duplicate_of: Some(winner.clone()), + respawn_exhausted: false, + resolved_session_id: resolved, + }, + ); + continue; + } + claimed.insert(key.clone(), pane.pane_key.clone()); + let live = match sref.provider.as_str() { + "codex" => state.fresh_codex.has_live_session(&session_id).await, + "claude" => state.fresh_claude.has_live_session(&session_id).await, + "opencode" => state.fresh_opencode.has_live_session(&session_id).await, + _ => false, + }; + use crate::existence::SessionExistence as E; + let presence = if live { + FreshAgentPresence::Live + } else { + // Exhaustive match, NO catch-all (B1 hardening). ProviderUnavailable + // arm is the B4 pre-decision (V9/A12, module doc above): conservative + // respawn-with-cap via Unknown, never dead_session. + match state.session_existence.exists(&sref.provider, &session_id) { + E::Present => FreshAgentPresence::OnDisk, + E::ProviderUnavailable => FreshAgentPresence::Unknown, + E::Absent => { + if state + .session_existence + .ever_observed(&sref.provider, &session_id) + { + FreshAgentPresence::GoneObserved + } else { + FreshAgentPresence::NeverObserved + } + } + E::Unknown => { + // The ledger is positive evidence the session existed. + if state.pane_ledger.ever_bound(&sref.provider, &session_id) { + FreshAgentPresence::OnDisk + } else { + FreshAgentPresence::Unknown + } + } + } + }; + // Respawn cap (V2/A7 — reworked): count only answers that actually + // come back `respawn`, and CLEAR the counter when presence resolves + // Live (a successful respawn is OBSERVED as the session going live). + // The counting lives HERE because for non-duplicate panes the verdict + // is fully determined by presence: OnDisk/Unknown + !exhausted ⇒ + // respawn (see verdict_for_pane) — so "this answer will be respawn" + // is known at increment time. Mutation stays in this async builder, + // OUTSIDE derive_verdicts' catch_unwind. + let respawn_exhausted = match presence { + FreshAgentPresence::Live => { + state + .fresh_agent_respawn_counts + .lock() + .expect("respawn counts poisoned") + .remove(&key); // reset-on-live + false + } + FreshAgentPresence::OnDisk | FreshAgentPresence::Unknown => { + let mut counts = state + .fresh_agent_respawn_counts + .lock() + .expect("respawn counts poisoned"); + let c = counts.entry(key).or_insert(0); + if *c >= FRESH_AGENT_RESPAWN_CAP { + true // this answer becomes dead_session{respawn_exhausted}; no burn + } else { + *c += 1; // this answer goes out as `respawn` — burn one + false + } + } + FreshAgentPresence::GoneObserved | FreshAgentPresence::NeverObserved => false, + }; + facts.insert( + pane.pane_key.clone(), + FreshAgentPaneFacts { + presence, + duplicate_of: None, + respawn_exhausted, + resolved_session_id: resolved, + }, + ); + } + FreshAgentReconcileSnapshot { facts } +} + +/// The SINGLE PaneVerdict construction point in this module (B1-coexistence +/// hardening, V9/A12 — keep it that way). +fn base(pane: &ReconcilePane, verdict: ReconcileVerdict) -> PaneVerdict { + PaneVerdict { + pane_key: pane.pane_key.clone(), + verdict, + terminal_id: None, + session_ref: None, + corrected: None, + reason: None, + duplicate: None, + } +} + +pub fn verdict_for_pane( + snapshot: Option<&FreshAgentReconcileSnapshot>, + pane: &ReconcilePane, +) -> PaneVerdict { + let Some(snapshot) = snapshot else { + // Capability not negotiated: the frozen client keeps today's contract. + let mut v = base(pane, ReconcileVerdict::Invalid); + v.reason = Some("unsupported_kind".into()); + return v; + }; + let Some(sref) = pane.session_ref.clone() else { + let mut v = base(pane, ReconcileVerdict::Fresh); + v.reason = Some("no_recoverable_identity".into()); + return v; + }; + let Some(facts) = snapshot.facts.get(&pane.pane_key) else { + let mut v = base(pane, ReconcileVerdict::Fresh); + v.reason = Some("no_recoverable_identity".into()); + return v; + }; + if let Some(winner) = &facts.duplicate_of { + let mut v = base(pane, ReconcileVerdict::Fresh); + v.reason = Some("duplicate_session_claim".into()); + v.duplicate = Some(winner.clone()); + return v; + } + // G3 reader rule (V8/A14): when the claim resolved through the ledger's + // supersession chain, every echoed session_ref carries the TERMINUS id + // and the verdict is marked corrected (never answer the retired ref). + let (sref, corrected) = match &facts.resolved_session_id { + Some(terminus) if *terminus != sref.session_id => ( + SessionLocator { + provider: sref.provider.clone(), + session_id: terminus.clone(), + }, + Some(true), + ), + _ => (sref, None), + }; + match facts.presence { + FreshAgentPresence::Live => { + let mut v = base(pane, ReconcileVerdict::Attach); + v.session_ref = Some(sref); + v.corrected = corrected; + v + } + FreshAgentPresence::OnDisk | FreshAgentPresence::Unknown => { + if facts.respawn_exhausted { + let mut v = base(pane, ReconcileVerdict::DeadSession); + v.reason = Some("respawn_exhausted".into()); + v.session_ref = Some(sref); + v.corrected = corrected; + v + } else { + let mut v = base(pane, ReconcileVerdict::Respawn); + v.session_ref = Some(sref); + v.corrected = corrected; + v + } + } + FreshAgentPresence::GoneObserved => { + let mut v = base(pane, ReconcileVerdict::DeadSession); + v.reason = Some("session_not_on_disk".into()); + v.session_ref = Some(sref); + v.corrected = corrected; + v + } + FreshAgentPresence::NeverObserved => { + let mut v = base(pane, ReconcileVerdict::Fresh); + v.reason = Some("identity_never_observed".into()); + v + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pane(key: &str, sref: Option<(&str, &str)>) -> ReconcilePane { + ReconcilePane { + pane_key: key.to_string(), + kind: Some("fresh-agent".into()), + mode: None, + create_request_id: None, + terminal_id: None, + server_instance_id: None, + session_ref: sref.map(|(provider, session_id)| SessionLocator { + provider: provider.to_string(), + session_id: session_id.to_string(), + }), + resume_session_id: None, + status: None, + } + } + + fn snap( + entries: &[(&str, FreshAgentPresence, Option<&str>, bool)], + ) -> FreshAgentReconcileSnapshot { + let mut snapshot = FreshAgentReconcileSnapshot::default(); + for (pane_key, presence, duplicate_of, respawn_exhausted) in entries { + snapshot.facts.insert( + pane_key.to_string(), + FreshAgentPaneFacts { + presence: *presence, + duplicate_of: duplicate_of.map(|s| s.to_string()), + respawn_exhausted: *respawn_exhausted, + resolved_session_id: None, + }, + ); + } + snapshot + } + + #[test] + fn no_snapshot_means_capability_off_and_stays_invalid_unsupported() { + let v = verdict_for_pane(None, &pane("p", Some(("claude", "s")))); + assert_eq!(v.verdict, ReconcileVerdict::Invalid); + assert_eq!(v.reason.as_deref(), Some("unsupported_kind")); + } + + #[test] + fn missing_session_ref_is_fresh_no_recoverable_identity() { + let s = snap(&[]); + let v = verdict_for_pane(Some(&s), &pane("p", None)); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason.as_deref(), Some("no_recoverable_identity")); + } + + #[test] + fn live_maps_to_attach_with_session_ref_echoed() { + let s = snap(&[("p", FreshAgentPresence::Live, None, false)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("codex", "t1")))); + assert_eq!(v.verdict, ReconcileVerdict::Attach); + assert_eq!(v.session_ref.as_ref().unwrap().session_id, "t1"); + assert_eq!(v.terminal_id, None, "fresh-agent panes have no terminal id"); + } + + #[test] + fn on_disk_maps_to_respawn() { + let s = snap(&[("p", FreshAgentPresence::OnDisk, None, false)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("opencode", "ses_1")))); + assert_eq!(v.verdict, ReconcileVerdict::Respawn); + assert_eq!(v.session_ref.as_ref().unwrap().session_id, "ses_1"); + } + + #[test] + fn gone_observed_maps_to_dead_session_not_on_disk() { + let s = snap(&[("p", FreshAgentPresence::GoneObserved, None, false)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("claude", "gone")))); + assert_eq!(v.verdict, ReconcileVerdict::DeadSession); + assert_eq!(v.reason.as_deref(), Some("session_not_on_disk")); + assert_eq!( + v.session_ref.as_ref().unwrap().session_id, + "gone", + "claimed identity echoed for the error UI" + ); + } + + #[test] + fn never_observed_maps_to_fresh_identity_never_observed() { + let s = snap(&[("p", FreshAgentPresence::NeverObserved, None, false)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("claude", "never")))); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason.as_deref(), Some("identity_never_observed")); + } + + #[test] + fn unknown_prefers_respawn_over_memory_loss() { + // Cost asymmetry: respawn on a gone session degrades gracefully via the + // providers' native not-found fallbacks; fresh on a live-on-disk session + // loses conversation memory permanently. + let s = snap(&[("p", FreshAgentPresence::Unknown, None, false)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("codex", "warm")))); + assert_eq!(v.verdict, ReconcileVerdict::Respawn); + } + + #[test] + fn duplicate_claim_yields_fresh_with_duplicate_marker() { + let s = snap(&[("p2", FreshAgentPresence::OnDisk, Some("p1"), false)]); + let v = verdict_for_pane(Some(&s), &pane("p2", Some(("codex", "t1")))); + assert_eq!(v.verdict, ReconcileVerdict::Fresh); + assert_eq!(v.reason.as_deref(), Some("duplicate_session_claim")); + assert_eq!(v.duplicate.as_deref(), Some("p1")); + } + + #[test] + fn respawn_exhausted_yields_dead_session() { + let s = snap(&[("p", FreshAgentPresence::OnDisk, None, true)]); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("codex", "t1")))); + assert_eq!(v.verdict, ReconcileVerdict::DeadSession); + assert_eq!(v.reason.as_deref(), Some("respawn_exhausted")); + } + + #[test] + fn superseded_claim_is_answered_from_the_chain_terminus_with_corrected() { + // G3 reader rule (V8/A14): a client claiming a superseded ref is + // answered from the chain terminus — never respawn the retired ref. + // Facts built directly: presence OnDisk, resolved_session_id Some("t2"). + let mut s = snap(&[("p", FreshAgentPresence::OnDisk, None, false)]); + s.facts.get_mut("p").unwrap().resolved_session_id = Some("t2".into()); + let v = verdict_for_pane(Some(&s), &pane("p", Some(("codex", "t1")))); + assert_eq!(v.verdict, ReconcileVerdict::Respawn); + assert_eq!( + v.session_ref.as_ref().unwrap().session_id, + "t2", + "answer carries the terminus id, not the retired claim" + ); + assert_eq!(v.corrected, Some(true)); + } +} diff --git a/crates/freshell-ws/src/screenshot.rs b/crates/freshell-ws/src/screenshot.rs index e455c6e83..005ebc110 100644 --- a/crates/freshell-ws/src/screenshot.rs +++ b/crates/freshell-ws/src/screenshot.rs @@ -7,29 +7,23 @@ //! 1. the REST handler [`register`]s a `requestId` and gets a [`oneshot`] receiver; //! 2. it [`send_capture`]s a `{type:"ui.command", command:"screenshot.capture", //! payload:{requestId, scope, tabId?, paneId?}}` frame onto the shared broadcast -//! bus (the exact shape `src/lib/ui-commands.ts:73` dispatches), or -//! [`send_capture_to`] sends it to one connection for a restore delivery fence; +//! bus (the exact shape `src/lib/ui-commands.ts:73` dispatches); //! 3. the screenshot-capable SPA client renders the DOM (`captureUiScreenshot` / //! html2canvas) and replies `{type:"ui.screenshot.result", requestId, ...}` //! (`src/lib/ui-commands.ts:51`); //! 4. the `/ws` inbound loop routes that reply through [`resolve_from`], waking the //! awaiting REST handler with the base64 PNG. -//! -//! Ordinary screenshot requests retain broadcast behavior. Restore uses the -//! connection registry plus target-bound requests: a result from any connection -//! other than the selected target is ignored, closing the connection-churn race -//! between the exactly-one-client gate, tab delivery, and acknowledgement. use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use freshell_protocol::{ServerMessage, UiCommand}; +use freshell_protocol::ServerMessage; use serde_json::json; use tokio::sync::oneshot; -/// A direct per-connection delivery sink. Unlike the terminal registry's -/// fire-and-forget `FrameSink`, restore needs to know whether the outbound -/// channel actually accepted a command. +/// A direct per-connection delivery sink registered alongside each +/// screenshot-capable connection (`add_capable_client`). Reports whether the +/// outbound channel actually accepted a command. pub type ClientSink = Arc bool + Send + Sync>; /// The normalized screenshot outcome the REST handler consumes. Mirrors the @@ -105,44 +99,11 @@ impl ScreenshotBroker { self.capable_client_count() > 0 } - /// Return the stable connection id iff exactly one capable client is - /// connected at this instant. - pub fn exclusive_client_id(&self) -> Option { - self.client_snapshot().1 - } - - /// Atomically snapshot the capable-client count and exclusive id. - pub fn client_snapshot(&self) -> (i64, Option) { - let capable = self.inner.capable.lock().unwrap(); - let exclusive = (capable.len() == 1) - .then(|| capable.keys().next().copied()) - .flatten(); - (capable.len() as i64, exclusive) - } - - /// Whether the selected capable connection is still registered. - pub fn has_client(&self, connection_id: u64) -> bool { - self.inner - .capable - .lock() - .unwrap() - .contains_key(&connection_id) - } - /// Register a pending request, returning the receiver the REST handler awaits. pub fn register(&self, request_id: String) -> oneshot::Receiver { self.register_expected(request_id, None) } - /// Register a request that only `connection_id` is permitted to resolve. - pub fn register_for_client( - &self, - request_id: String, - connection_id: u64, - ) -> oneshot::Receiver { - self.register_expected(request_id, Some(connection_id)) - } - fn register_expected( &self, request_id: String, @@ -195,23 +156,6 @@ impl ScreenshotBroker { } } - /// Send one already-typed frame to a specific capable connection. Returns - /// false when that exact connection is no longer registered. - pub fn send_to_client(&self, connection_id: u64, frame: ServerMessage) -> bool { - let sink = self - .inner - .capable - .lock() - .unwrap() - .get(&connection_id) - .cloned(); - if let Some(sink) = sink { - sink(frame) - } else { - false - } - } - /// Broadcast the `screenshot.capture` `ui.command` to every connection; the /// capable SPA client renders + replies. Frame shape is byte-compatible with /// `ws-handler.ts:1072` (`{type, command, payload:{requestId, scope, tabId?, @@ -239,31 +183,6 @@ impl ScreenshotBroker { // and reports the same "no UI answered" outcome the original would. let _ = self.inner.broadcast_tx.send(frame.to_string()); } - - /// Send the capture fence only to `connection_id`. - pub fn send_capture_to( - &self, - connection_id: u64, - request_id: &str, - scope: &str, - tab_id: Option<&str>, - pane_id: Option<&str>, - ) -> bool { - let mut payload = json!({ "requestId": request_id, "scope": scope }); - if let Some(tab_id) = tab_id { - payload["tabId"] = json!(tab_id); - } - if let Some(pane_id) = pane_id { - payload["paneId"] = json!(pane_id); - } - self.send_to_client( - connection_id, - ServerMessage::UiCommand(UiCommand { - command: "screenshot.capture".to_string(), - payload: Some(payload), - }), - ) - } } #[cfg(test)] @@ -284,10 +203,8 @@ mod tests { b.add_capable_client(2, sink); assert_eq!(b.capable_client_count(), 2); assert!(b.has_capable_client()); - assert_eq!(b.exclusive_client_id(), None); b.remove_capable_client(1); assert_eq!(b.capable_client_count(), 1); - assert_eq!(b.exclusive_client_id(), Some(2)); b.remove_capable_client(2); b.remove_capable_client(2); assert_eq!(b.capable_client_count(), 0); @@ -339,67 +256,6 @@ mod tests { assert!(rx.await.is_err(), "cancelled sender dropped"); } - #[tokio::test] - async fn target_bound_request_ignores_another_client() { - let b = broker(); - let mut rx = b.register_for_client("targeted".to_string(), 7); - b.resolve_from( - 8, - "targeted", - ScreenshotResult { - ok: true, - ..Default::default() - }, - ); - assert!(rx.try_recv().is_err(), "wrong client must not resolve"); - b.resolve_from( - 7, - "targeted", - ScreenshotResult { - ok: true, - ..Default::default() - }, - ); - assert!(rx.await.expect("target resolved").ok); - } - - #[test] - fn direct_capture_reaches_only_the_selected_client() { - let b = broker(); - let seen_1 = Arc::new(Mutex::new(Vec::new())); - let seen_2 = Arc::new(Mutex::new(Vec::new())); - for (id, seen) in [(1, seen_1.clone()), (2, seen_2.clone())] { - b.add_capable_client( - id, - Arc::new(move |message| { - seen.lock().unwrap().push(message); - true - }), - ); - } - assert!(b.send_capture_to(1, "req-direct", "view", None, None)); - assert_eq!(seen_1.lock().unwrap().len(), 1); - assert!(seen_2.lock().unwrap().is_empty()); - } - - #[test] - fn direct_send_reports_a_closed_outbound_channel() { - let b = broker(); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - drop(rx); - b.add_capable_client(7, Arc::new(move |message| tx.send(message).is_ok())); - assert!( - !b.send_to_client( - 7, - ServerMessage::UiCommand(UiCommand { - command: "tab.create".to_string(), - payload: None, - }), - ), - "a registered sink with a closed channel is not successful delivery" - ); - } - #[test] fn send_capture_frame_matches_ui_command_shape() { let b = broker(); diff --git a/crates/freshell-ws/src/tabs_persist_validation_tests.rs b/crates/freshell-ws/src/tabs_persist_validation_tests.rs index f0e2fbc10..5c2a3bbb2 100644 --- a/crates/freshell-ws/src/tabs_persist_validation_tests.rs +++ b/crates/freshell-ws/src/tabs_persist_validation_tests.rs @@ -110,7 +110,7 @@ fn wrong_typed_create_request_id_never_poisons_the_device() { .expect("wrong-typed createRequestId must NOT make the device unreadable") .expect("generation present"); // Current (locked) behavior: the unknown field passes through verbatim — - // nothing strips it; the CONSUMERS (pane_to_create_body, REST ingress) + // nothing strips it; the CONSUMERS (REST ingress, recovery inventory) // drop non-strings, so the only cost is that one pane loses key identity. let panes = &union["records"][0]["panes"]; assert_eq!(panes[0]["payload"]["createRequestId"], 42); diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index eec05ee2b..6bff79f64 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -122,6 +122,7 @@ pub async fn run( terminal_output_batch_v1: bool, ui_screenshot_v1: bool, pane_reconcile_v1: bool, + pane_reconcile_fresh_agent_v1: bool, origin_kind: &'static str, ) { let (mut ws_tx, mut ws_rx) = socket.split(); @@ -259,6 +260,7 @@ pub async fn run( &conn_sink, terminal_output_batch_v1, pane_reconcile_v1, + pane_reconcile_fresh_agent_v1, &mut create_limiter, ) .await @@ -436,6 +438,24 @@ pub async fn run( state.screenshots.remove_capable_client(conn_id); } state.registry.remove_connection(conn_id); + + // D8 conn-death lease release (council rule 8): pid-less in-flight leases + // are released inside the registry call; pid-carrying ones come back + // STILL-HELD — kill via the registry handle, confirm, then force-release + // (kill-before-release). Unconfirmed kills hold the lease closed. + for (locator, pid) in state.registry.release_session_ref_leases_for_conn(conn_id) { + let Some(pid) = pid else { continue }; + if kill_session_ref_holder_and_confirm(&state.registry, pid).await { + state.registry.force_release_after_confirmed_kill(&locator); + } else { + tracing::error!(target: "invariant", + connection_id = conn_id, + provider = %locator.provider, + session_id = %locator.session_id, + pid, + "session_ref_lease_conn_death_kill_unconfirmed: holding lease closed"); + } + } } /// Parse + dispatch one inbound client text frame. Returns `false` to close the @@ -449,6 +469,7 @@ async fn handle_client_text( conn_sink: &FrameSink, terminal_output_batch_v1: bool, pane_reconcile_v1: bool, + pane_reconcile_fresh_agent_v1: bool, create_limiter: &mut crate::create_limit::CreateRateLimiter, ) -> bool { // Accept-and-strip: unknown/unparseable frames are ignored (matches the @@ -506,13 +527,26 @@ async fn handle_client_text( } ClientMessage::ClientDiagnostic(_) => true, ClientMessage::TerminalCreate(create) => { - handle_create(create, ws_tx, state, pane_reconcile_v1, create_limiter).await + handle_create( + create, + ws_tx, + state, + conn_id, + pane_reconcile_v1, + create_limiter, + ) + .await } - // P0.3: server-side codex identity capture from the client's persisted - // candidate -- guarded (campaign plan §2.3.1); rejects are logged and - // ignored, never answered (legacy parity ws-handler.ts:2951-2963). + // RETIRED (campaign §2.3.2, Lane B2): codex identity has exactly one + // writer -- the server-side rollout locator (codex_association). The + // frozen client still sends this frame (TerminalView.tsx durability + // handler), so accept-and-ignore with a debug breadcrumb; never an + // error to the client, never an identity write. ClientMessage::TerminalCodexCandidatePersisted(candidate) => { - crate::codex_candidate::handle_codex_candidate_persisted(state, candidate).await; + tracing::debug!( + terminal_id = %candidate.terminal_id, + "codex_candidate_ignored: client candidate channel retired (server locator is authoritative)" + ); true } ClientMessage::TerminalAttach(attach) => { @@ -524,6 +558,16 @@ async fn handle_client_text( } } ClientMessage::TerminalInput(input) => { + // Lane B2: MUST complete before the Enter reaches the PTY — the + // codex locator's FIRST-submit re-snapshot has to finish before + // codex can materialize the rollout this very Enter triggers + // (else the pane's own file could land in the snapshot and be + // permanently excluded). Sound here: this socket task processes + // frames sequentially, and the enclosing handler is already + // async. Non-submit data returns immediately; only the first + // Enter of an armed codex pane scans (7-9 ms warm — A6). + crate::codex_association::note_possible_submit(state, &input.terminal_id, &input.data) + .await; state .registry .input(&input.terminal_id, input.data.as_bytes()); @@ -708,7 +752,8 @@ async fn handle_client_text( // byte-inertness does not depend on this, since it never sends // the request at all (§3). if pane_reconcile_v1 { - return handle_pane_reconcile(request, ws_tx, state).await; + return handle_pane_reconcile(request, ws_tx, state, pane_reconcile_fresh_agent_v1) + .await; } true } @@ -915,6 +960,115 @@ impl Drop for KeyedCreateGuard { } } +/// The sessionRef a create claims at spawn time (council rule 7, D8), when +/// resolvable from the create BODY alone: a non-shell mode whose session id +/// comes from `sessionRef` (provider must match the mode — the same filter +/// as the resume derivation in `handle_create`) or the legacy +/// `resumeSessionId`. Later-resolved identities (fresh-claude preallocation, +/// the P0.4 restore ladder) are freshly minted or single-source and carry no +/// concurrent-duplicate shape, so they claim nothing. +fn create_session_locator(create: &TerminalCreate) -> Option { + if create.mode == "shell" { + return None; + } + let session_id = create + .session_ref + .as_ref() + .filter(|r| r.provider == create.mode) + .map(|r| r.session_id.clone()) + .or_else(|| create.resume_session_id.clone()) + .filter(|s| !s.is_empty())?; + Some(SessionLocator { + provider: create.mode.clone(), + session_id, + }) +} + +/// RAII release of a D8 sessionRef lease claim +/// ([`freshell_terminal::TerminalRegistry::claim_session_ref`] → +/// `Acquired`): on EVERY non-complete exit of `handle_create` — pre-spawn +/// error, spawn failure, or task cancellation — drop releases the lease via +/// `fail_session_ref_claim` (a no-op once `complete_session_ref_claim` +/// removed it). The winner path disarms and completes explicitly. +struct SessionRefLeaseGuard { + registry: freshell_terminal::TerminalRegistry, + locator: SessionLocator, + holder_create_request_id: String, + claimed_at_ms: i64, + armed: bool, +} + +impl SessionRefLeaseGuard { + /// Hand ownership of the release decision back to the caller (winner + /// bind or the revoked-lease kill path). + fn disarm(mut self) -> (SessionLocator, i64) { + self.armed = false; + (self.locator.clone(), self.claimed_at_ms) + } +} + +impl Drop for SessionRefLeaseGuard { + fn drop(&mut self) { + if self.armed { + self.registry + .fail_session_ref_claim(&self.locator, &self.holder_create_request_id); + } + } +} + +/// Poll `kill(pid, 0)` for ESRCH for up to 500ms (the PTY's dedicated waiter +/// thread reaps promptly — `pty.rs` reader/waiter). `true` = death CONFIRMED. +async fn confirm_pid_dead_within_500ms(pid: u32) -> bool { + for _ in 0..20u8 { + if !freshell_terminal::registry::pid_alive(pid) { + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + !freshell_terminal::registry::pid_alive(pid) +} + +/// Kill-before-release (council rule 8): kill a lease holder's spawn via the +/// REGISTRY PTY handle ([`freshell_terminal::TerminalRegistry::kill`] — +/// group-kill discipline, `pty.rs`; NEVER a raw single-pid SIGKILL), then +/// confirm death. `true` = confirmed dead — only then may the caller +/// `force_release_after_confirmed_kill`. Unconfirmed (or a pid no registry +/// terminal owns, where nothing can be group-killed) holds the lease closed. +async fn kill_session_ref_holder_and_confirm( + registry: &freshell_terminal::TerminalRegistry, + pid: u32, +) -> bool { + match registry.live_terminal_for_pid(pid) { + Some(terminal_id) => { + registry.kill(&terminal_id); + } + None => { + tracing::error!(target: "invariant", + pid, + "session_ref_lease_kill_unroutable: no registry terminal owns the holder pid; holding lease closed"); + return false; + } + } + confirm_pid_dead_within_500ms(pid).await +} + +/// The D8 loser reply: `error{SESSION_RESERVED, requestId, retryAfterMs}` — +/// the only error frame that carries `retryAfterMs`. +async fn send_session_reserved(ws_tx: &mut WsSink, request_id: &str, retry_after_ms: u64) -> bool { + let msg = ServerMessage::Error(ErrorMsg { + code: ErrorCode::SessionReserved, + message: "Another terminal.create for this sessionRef is in flight".to_string(), + timestamp: crate::now_iso(), + actual_session_ref: None, + expected_session_ref: None, + request_id: Some(request_id.to_string()), + retry_after_ms: Some(retry_after_ms), + terminal_exit_code: None, + terminal_id: None, + }); + send(ws_tx, &msg).await +} + /// `terminal.create` — spawn + register the PTY in the shared registry (owned by no /// connection), then reply `terminal.created`. Create does NOT attach; the client /// sends `terminal.attach` next. @@ -922,6 +1076,7 @@ async fn handle_create( create: TerminalCreate, ws_tx: &mut WsSink, state: &WsState, + conn_id: u64, pane_reconcile_v1: bool, create_limiter: &mut crate::create_limit::CreateRateLimiter, ) -> bool { @@ -978,6 +1133,89 @@ async fn handle_create( // the outcome itself is discoverable through the registry. let _keyed_create_guard = keyed_create_guard; + // Council rule 7 (D8 two-writers closure): per-sessionRef single-flight, + // negotiated connections ONLY. The claim runs BEFORE the rate limiter — + // a loser's SESSION_RESERVED (like the §5.4 adopt above, the + // create_protection.rs precedent) is neither checked nor charged. Legacy + // connections never enter this branch: their create flow stays + // byte-for-byte unchanged (§11 fence). + let mut session_ref_lease: Option = None; + if pane_reconcile_v1 { + if let Some(locator) = create_session_locator(&create) { + use freshell_terminal::registry::SessionRefClaim; + // Bounded: at most one ExpiredNeedsKill kill→confirm→re-claim + // round per create; a second expiry answers reserved instead. + for round in 0..2u8 { + match state.registry.claim_session_ref( + &locator, + &create.request_id, + conn_id, + now_ms().max(0) as u64, + ) { + SessionRefClaim::Acquired => { + session_ref_lease = Some(SessionRefLeaseGuard { + registry: state.registry.clone(), + locator: locator.clone(), + holder_create_request_id: create.request_id.clone(), + claimed_at_ms: now_ms(), + armed: true, + }); + break; + } + SessionRefClaim::BoundElsewhere { terminal_id } => { + // Attach to the winner: mirror the §5.4 adopt emission + // above — name the EXISTING terminal, spawn nothing. + tracing::info!( + terminal_id = %terminal_id, + create_request_id = %create.request_id, + provider = %locator.provider, + session_id = %locator.session_id, + "terminal.create.session_ref_attached" + ); + let created = ServerMessage::TerminalCreated(TerminalCreated { + created_at: now_ms(), + request_id: create.request_id, + terminal_id: terminal_id.clone(), + clear_codex_durability: None, + cwd: state.registry.probe(&terminal_id).and_then(|row| row.cwd), + restore_error: None, + session_ref: state + .identity + .session_ref_for(&terminal_id) + .or(Some(locator)), + }); + return send(ws_tx, &created).await; + } + SessionRefClaim::Held { retry_after_ms } => { + return send_session_reserved(ws_tx, &create.request_id, retry_after_ms) + .await; + } + SessionRefClaim::ExpiredNeedsKill { pid } => { + if round == 0 + && kill_session_ref_holder_and_confirm(&state.registry, pid).await + { + state.registry.force_release_after_confirmed_kill(&locator); + continue; // the slot is now free — re-claim + } + // Unconfirmed kill (or a second expiry in one create): + // hold closed, fail loud, answer reserved. + tracing::error!(target: "invariant", + provider = %locator.provider, + session_id = %locator.session_id, + pid, + "session_ref_lease_expired_kill_unconfirmed: holding lease closed"); + return send_session_reserved( + ws_tx, + &create.request_id, + freshell_terminal::registry::SESSION_RESERVED_RETRY_AFTER_MS, + ) + .await; + } + } + } + } + } + // Per-connection create rate limit (legacy parity: ws-handler.ts:2376-2389). // restore:true bypasses — neither checked nor recorded (`if (!m.restore)`). if create.restore != Some(true) && !create_limiter.try_acquire(crate::create_limit::epoch_ms()) @@ -1116,6 +1354,61 @@ async fn handle_create( } } + // D7 liveness guard on the DIRECT wire-sessionRef rung (recover-my-panes + // Task 2b, defense-in-depth). Every other live-guard lives inside the + // createRequestId-keyed ladder (`resolve_claude_restore_session_id`), which + // the direct rung above bypasses entirely -- and the D5 recovery path + // re-mints the createRequestId, so a session that goes live between the + // inventory fetch and the user's accept would otherwise silently spawn a + // SECOND ` --resume S` while the original live PTY owns S (the + // one-JSONL-writer doctrine: "silently wrong"). Mirrors the ladder's A13 + // row-matching -- the identity-registry owner check plus the REST-shaped + // live-registry-row scan -- but MODE-GENERIC: codex/opencode/amplifier had + // no live-guard on ANY path, so this closes their gap too. Applies only + // when the resume id was derived from the wire `sessionRef` (the + // resumeSessionId fallback and ladder-resolved ids keep their existing + // guards). + if let Some(live_sid) = create + .session_ref + .as_ref() + .filter(|r| r.provider == mode) + .map(|r| r.session_id.as_str()) + .filter(|sid| !sid.is_empty() && resume_session_id.as_deref() == Some(*sid)) + { + let identity_owner_live = + state + .identity + .find_by_session(&mode, live_sid) + .is_some_and(|owner| { + state + .registry + .probe(&owner.terminal_id) + .is_some_and(|r| r.status == freshell_protocol::TerminalRunStatus::Running) + }); + let registry_row_live = identity_owner_live + || state.registry.directory().into_iter().any(|entry| { + entry.mode == mode + && entry.resume_session_id.as_deref() == Some(live_sid) + && entry.status == freshell_protocol::TerminalRunStatus::Running + }); + if registry_row_live { + tracing::warn!( + target: "freshell_ws::terminal", + mode = %mode, + session_id = %live_sid, + request_id = %create.request_id, + "create_refused: a Running terminal already owns this session (D7 live-guard)" + ); + return send_create_error( + ws_tx, + ErrorCode::RestoreUnavailable, + format!("Session {live_sid} is still running on the server."), + &create.request_id, + ) + .await; + } + } + // Provider settings `codingCli.providers[mode]` (`ws:2317-2319`), with the // codex strip (`ws:2464-2465` — model/sandbox/permissionMode route to the // app-server plan instead). Boot-snapshot settings (same documented caveat @@ -1352,6 +1645,10 @@ async fn handle_create( // (never-submitted, or already-associated) opencode terminal's armed // entry is never left dangling. let opencode_locator = state.opencode_locator.clone(); + // Lane B2: sibling disarm for the codex rollout locator, so an + // exited (never-submitted, or already-associated) codex terminal's + // armed entry is never left dangling. + let codex_locator = state.codex_locator.clone(); Some(Box::new(move |exit_code: i64| { cleanup_mcp_config(&RealMcpRuntime, &tid, &cleanup_mode, cleanup_cwd.as_deref()); registry.finish_pty_exit(&tid, exit_code); @@ -1377,6 +1674,9 @@ async fn handle_create( if let Some(locator) = &opencode_locator { locator.disarm(&tid); } + if let Some(locator) = &codex_locator { + locator.disarm(&tid); + } })) }; @@ -1474,6 +1774,19 @@ async fn handle_create( .await; } + // D8: arm the lease's TTL kill path — record the just-spawned child's pid + // on the winner's lease immediately (its presence decides ExpiredNeedsKill + // vs revoke-and-hold-closed on expiry). + if let Some(guard) = &session_ref_lease { + if let Some(pid) = state.registry.pid_of(&terminal_id) { + state.registry.set_session_ref_lease_pid( + &guard.locator, + &guard.holder_create_request_id, + pid, + ); + } + } + // DEV-0006 S4: adopt the managed codex launch for this terminal // (`codexPlan.sidecar.adopt({terminalId, generation: 0})`, `ws:2511`) — ownership // transfers from the planner to the terminal; the PTY exit hook above tears it @@ -1526,6 +1839,40 @@ async fn handle_create( resume_session_id.as_deref(), ); + // Lane B2: arm the codex rollout locator for a FRESH (non-resuming) + // codex pane. Restore-created panes WITHOUT identity arm too — arm() + // gates on resume_session_id, never a restore flag (the wave-A re-arm + // contract). + // + // ORDERING NOTE (A5, validated): this arm runs AFTER the PTY spawn + // (registry.create + adopt() have already awaited above). That is safe + // ONLY because windows are Enter-anchored: real codex materializes its + // rollout at the first user prompt, never in the spawn→arm gap, so the + // snapshot cannot miss the pane's own file. Do not reinterpret this as + // "snapshot precedes spawn" — it does not need to. + // + // The arm() snapshot walks the sessions tree; run it on the blocking + // pool, not the WS dispatch path (A6: warm walks are 7-9 ms today and + // ~100 ms at 100k files, but cold dentry cache after a reboot is the + // one unmeasured tail). + { + let state = state.clone(); + let terminal_id = terminal_id.clone(); + let mode = mode.clone(); + let cwd = resolved_cwd.clone(); + let resume = resume_session_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + crate::codex_association::maybe_arm( + &state, + &terminal_id, + &mode, + cwd.as_deref(), + resume.as_deref(), + ); + }) + .await; + } + // Snapshot the id before it's moved into `created` below -- needed for the // `terminal.meta.updated` create-time slice after the create frame is sent. let terminal_id_for_meta = terminal_id.clone(); @@ -1618,6 +1965,56 @@ async fn handle_create( crate::pane_ledger::surface_write_failure(state, &terminal_id_for_meta, result); } + // D8 winner bind: record sessionRef→terminalId in the REGISTRY binding + // map (inside `complete_session_ref_claim` — atomic with the lease + // release, then the duplicate alarm). The pane-ledger binding write above + // is PRE-EXISTING create-path behavior, deliberately not duplicated here. + if let Some(guard) = session_ref_lease.take() { + let (locator, claimed_at_ms) = guard.disarm(); + if state + .registry + .complete_session_ref_claim(&locator, &create.request_id, &terminal_id) + { + // Spawn-duration instrumentation: the evidence stream for tuning + // FRESHELL_SESSION_REF_LEASE_TTL_MS (registry.rs). + tracing::info!( + terminal_id = %terminal_id, + provider = %locator.provider, + session_id = %locator.session_id, + spawn_elapsed_ms = now_ms().saturating_sub(claimed_at_ms), + "session_ref.winner_bound" + ); + } else { + // Revoked while spawning (TTL expired on the then-pid-less lease): + // kill OUR OWN just-spawned child via the registry handle + // (group-kill discipline), confirm, and fail the create loudly. + let pid = state.registry.pid_of(&terminal_id); + state.registry.kill(&terminal_id); + let confirmed = match pid { + Some(pid) => confirm_pid_dead_within_500ms(pid).await, + // No pid handle to probe: the registry kill removed the row; + // nothing is left to signal, so treat as confirmed. + None => true, + }; + if confirmed { + state.registry.force_release_after_confirmed_kill(&locator); + } else { + tracing::error!(target: "invariant", + terminal_id = %terminal_id, + provider = %locator.provider, + session_id = %locator.session_id, + "session_ref_lease_revoked_child_kill_unconfirmed: holding lease closed"); + } + return send_create_error( + ws_tx, + ErrorCode::InternalError, + "Terminal create lost its sessionRef lease during spawn; the spawned process was killed".to_string(), + &create.request_id, + ) + .await; + } + } + let created = ServerMessage::TerminalCreated(TerminalCreated { created_at: now_ms(), request_id: create.request_id, @@ -1646,8 +2043,9 @@ async fn handle_create( /// regex /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i): /// canonical UUID shape with a version digit 1-5 (position 14) and a variant /// digit [89ab] (position 19), case-insensitive. Same chars-based idiom as -/// `codex_candidate::is_uuid_shaped`, extended with the version/variant -/// constraints. Used ONLY by the P0.4 restore gate below -- non-restore +/// the codex locator's uuid shape gate +/// (`freshell_sessions::codex_locator::is_uuid_shaped`), extended with the +/// version/variant constraints. Used ONLY by the P0.4 restore gate below -- non-restore /// resume derivation is deliberately untouched. fn is_canonical_claude_session_id(s: &str) -> bool { s.len() == 36 @@ -1902,16 +2300,21 @@ pub fn broadcast_sessions_changed(state: &WsState) { /// Send the reference's `sendError` frame for a failed `terminal.create` /// (`ws-handler.ts:2606-2614`): `{ code, message, requestId }`. -/// `pane.reconcile.request` (reconciliation design §5): a PURE READ over the -/// terminal registry × identity registry × disk index — one verdict per -/// presented pane. Safe to receive N times on N sockets (§7); the only error -/// paths are the explicit `RECONCILE_TOO_LARGE` cap and the +/// `pane.reconcile.request` (reconciliation design §5): the terminal sources +/// remain a PURE READ over the terminal registry × identity registry × disk +/// index — one verdict per presented pane, safe to receive N times on N +/// sockets (§7). The capability-gated fresh-agent snapshot is the ONE +/// exception: it mutates the per-boot respawn counter +/// (`WsState.fresh_agent_respawn_counts`, bounded per `(provider, +/// sessionId)`; reset when the session resolves Live — V2/A7). The only +/// error paths are the explicit `RECONCILE_TOO_LARGE` cap and the /// `RECONCILE_UNAVAILABLE` derivation-failure frame, both carrying the /// `reconcileId` for correlation. async fn handle_pane_reconcile( request: freshell_protocol::PaneReconcileRequest, ws_tx: &mut WsSink, state: &WsState, + pane_reconcile_fresh_agent_v1: bool, ) -> bool { if request.panes.len() > crate::reconcile::MAX_RECONCILE_PANES { return send_create_error( @@ -1926,16 +2329,37 @@ async fn handle_pane_reconcile( ) .await; } - let deps = crate::reconcile::ReconcileDeps { - registry: &state.registry, - identity: &state.identity, - existence: state.session_existence.as_ref(), + // Built ONCE per reconcile request and reused for any re-derivation of deps + // (B1's warming deferral re-derives via rebuild_deps — rebuilding the + // snapshot would double-burn the respawn counter; V9 §3.6). + let fresh_agent_snapshot = if pane_reconcile_fresh_agent_v1 + && request + .panes + .iter() + .any(|p| p.kind.as_deref() == Some("fresh-agent")) + { + Some(crate::reconcile_freshagent::build_snapshot(state, &request.panes).await) + } else { + None }; // §8 frame-level failure: a panicking derivation (poisoned lock) must - // surface as an explicit error frame, never silence. - let verdicts = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - crate::reconcile::derive_verdicts(&deps, &request.panes) - })) { + // surface as an explicit error frame, never silence. The deps are rebuilt + // per derivation so nothing borrowed for the pure read is ever held + // across the deferral await below. The fresh-agent snapshot is NOT + // rebuilt — it was built once above (rebuilding would double-burn the + // respawn counter; V9 §3.6) and each rebuilt deps borrows the same one. + let derive = || { + let deps = crate::reconcile::ReconcileDeps { + registry: &state.registry, + identity: &state.identity, + existence: state.session_existence.as_ref(), + fresh_agent: fresh_agent_snapshot.as_ref(), + }; + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::reconcile::derive_verdicts(&deps, &request.panes) + })) + }; + let mut verdicts = match derive() { Ok(verdicts) => verdicts, Err(_) => { return send_create_error( @@ -1947,6 +2371,41 @@ async fn handle_pane_reconcile( .await; } }; + // §5.3 row 5: ONE bounded deferral when the index is still warming, then + // exactly one re-derivation — never a loop. The wait is a plain bounded + // sleep: the `SessionIndex` behind the probe exposes no sweep-completion + // signal to await (`peek()`/`is_fresh()` are sync and non-blocking by + // design, and `snapshot().await` on a truly cold index runs the sweep + // inline for potentially minutes — unbounded, so unusable here). The + // honest expectation stands: a truly cold scan takes far longer than the + // budget, so `error{index_warming}` is EXPECTED on cold boots and the + // client's manual retry affordance is the recovery path. The stall is + // delay-only and scoped to THIS connection's select loop, bounded by the + // budget (default 2000ms, shrunk in tests). + let warming = |vs: &[freshell_protocol::PaneVerdict]| { + vs.iter().any(|v| { + matches!(v.verdict, freshell_protocol::ReconcileVerdict::Error) + && v.reason.as_deref() == Some("index_warming") + }) + }; + if warming(&verdicts) { + tokio::time::sleep(std::time::Duration::from_millis( + state.reconcile_deferral_budget_ms, + )) + .await; + verdicts = match derive() { + Ok(verdicts) => verdicts, + Err(_) => { + return send_create_error( + ws_tx, + ErrorCode::ReconcileUnavailable, + "reconcile derivation failed; keep current state and re-send".to_string(), + &request.reconcile_id, + ) + .await; + } + }; + } let result = ServerMessage::PaneReconcileResult(freshell_protocol::PaneReconcileResult { reconcile_id: request.reconcile_id, boot_id: state.boot_id.as_ref().clone(), @@ -1985,6 +2444,7 @@ async fn send_create_error( actual_session_ref: None, expected_session_ref: None, request_id: Some(request_id.to_string()), + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, }); @@ -2199,6 +2659,7 @@ fn invalid_dims_error(cols: i64, rows: i64) -> ServerMessage { actual_session_ref: None, expected_session_ref: None, request_id: None, + retry_after_ms: None, terminal_exit_code: None, terminal_id: None, }) @@ -2269,6 +2730,7 @@ async fn handle_kill(kill: TerminalKill, ws_tx: &mut WsSink, state: &WsState) -> actual_session_ref: None, expected_session_ref: None, request_id: None, + retry_after_ms: None, terminal_id: Some(kill.terminal_id), terminal_exit_code: None, }); @@ -3193,8 +3655,11 @@ mod terminals_changed_tests { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; (state, rx) } @@ -3400,8 +3865,11 @@ mod terminal_meta_created_tests { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; (state, rx) } diff --git a/crates/freshell-ws/tests/codex_candidate_inert.rs b/crates/freshell-ws/tests/codex_candidate_inert.rs new file mode 100644 index 000000000..a3141ce55 --- /dev/null +++ b/crates/freshell-ws/tests/codex_candidate_inert.rs @@ -0,0 +1,221 @@ +//! Lane B2 / campaign §2.3.2: terminal.codex.candidate.persisted is RETIRED +//! as a writer. The frozen client still SENDS it (TerminalView.tsx:4009-4018), +//! so the server must accept-and-ignore with a debug log — never an error to +//! the client, and NEVER an identity write. + +mod common; + +use common::{connect_and_capture_inventory, next_frame_of_type, spawn_server_with_ledger}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +const THREAD: &str = "0192cccc-dddd-4eee-8fff-000011112222"; + +/// Fake codex: records argv to $CODEX_ARGV_CAPTURE_PATH (atomic tmp+mv) then +/// sleeps. Copied from the retired tests/codex_candidate_persisted.rs (which +/// copied it from tests/codex_session_ref_resume.rs:85-103). +fn write_fake_codex() -> std::path::PathBuf { + let script_path = std::env::temp_dir().join(format!( + "freshell-codex-inert-fake-{}.sh", + std::process::id() + )); + let script = "#!/bin/sh\n\ + printf '%s\\n' \"$@\" > \"$CODEX_ARGV_CAPTURE_PATH.tmp\"\n\ + mv \"$CODEX_ARGV_CAPTURE_PATH.tmp\" \"$CODEX_ARGV_CAPTURE_PATH\"\n\ + exec sleep 300\n"; + std::fs::write(&script_path, script).expect("write fake codex script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script_path, perms).unwrap(); + } + script_path +} + +fn codex_capture_spec() -> freshell_platform::CliCommandSpec { + freshell_platform::CliCommandSpec { + name: "codex".to_string(), + label: "Codex CLI".to_string(), + env_var: None, + default_cmd: write_fake_codex().to_string_lossy().to_string(), + base_args: vec![], + base_env: std::collections::BTreeMap::new(), + // Real codex manifest shape: resume subcommand, no createSessionArgs. + resume_args: Some(vec!["resume".to_string(), "{{sessionId}}".to_string()]), + create_session_args: None, + model_args: None, + sandbox_args: None, + permission_mode_args: None, + } +} + +fn registry_resume_id( + registry: &freshell_terminal::TerminalRegistry, + terminal_id: &str, +) -> Option { + registry + .identity_probe_rows() + .into_iter() + .find(|row| row.terminal_id == terminal_id) + .unwrap_or_else(|| panic!("registry must list {terminal_id}")) + .resume_session_id +} + +/// Send a candidate that must be IGNORED: the ping/pong round-trip proves the +/// frame was consumed AND that nothing was sent back (silence proof -- +/// precedent: pane_reconcile.rs uses exactly this to prove nothing was sent). +/// Copied from the retired tests/codex_candidate_persisted.rs:112-131. +async fn send_candidate_expect_silence( + ws: &mut common::TestWs, + terminal_id: &str, + thread_id: &str, + rollout_path: &str, +) { + ws.send(WsMessage::Text( + json!({ + "type": "terminal.codex.candidate.persisted", + "terminalId": terminal_id, + "candidateThreadId": thread_id, + "rolloutPath": rollout_path, + "capturedAt": 1_753_300_000_000i64, + }) + .to_string(), + )) + .await + .expect("send candidate"); + ws.send(WsMessage::Text(json!({"type": "ping"}).to_string())) + .await + .expect("send ping"); + let _pong = next_frame_of_type(ws, "pong").await; +} + +/// Ping, then read frames until the pong arrives — panicking on any `error` +/// frame in between (the connection must stay healthy; broadcasts like +/// `terminals.changed` are fine and skipped). +async fn expect_no_error_until_pong(ws: &mut common::TestWs) { + ws.send(WsMessage::Text(json!({"type": "ping"}).to_string())) + .await + .expect("send ping"); + for _ in 0..20u8 { + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + .await + .expect("frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + assert_ne!( + value["type"], + json!("error"), + "connection must stay error-free: {value}" + ); + if value["type"] == json!("pong") { + return; + } + } + } + panic!("no pong frame within 20 messages"); +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(unix)] +async fn candidate_frame_is_accepted_ignored_and_writes_nothing() { + // ---- env setup (single sequential test: this binary owns process env) ---- + let codex_home = tempfile::tempdir().expect("codex home"); + let sessions_day = codex_home + .path() + .join("sessions") + .join("2026") + .join("07") + .join("24"); + std::fs::create_dir_all(&sessions_day).expect("sessions tree"); + std::env::set_var("CODEX_HOME", codex_home.path()); + std::env::remove_var("FRESHELL_CODEX_MANAGED_LAUNCH"); + let capture = std::env::temp_dir().join(format!("codex-inert-argv-{}.txt", std::process::id())); + let _ = std::fs::remove_file(&capture); + std::env::set_var("CODEX_ARGV_CAPTURE_PATH", &capture); + + // A VALID rollout for THREAD — one that would have passed all four old + // guards. Written BEFORE the terminal exists, so the locator's arm-time + // snapshot excludes it forever: any identity write observed below could + // only have come from the candidate channel. + let rollout = sessions_day.join(format!("rollout-2026-07-24T12-00-00-{THREAD}.jsonl")); + std::fs::write( + &rollout, + format!("{{\"timestamp\":\"2026-07-24T12:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD}\"}}}}\n"), + ) + .unwrap(); + let rollout = rollout.to_string_lossy().to_string(); + + // A REAL ledger dir so "writes nothing" covers the durable home too. + let ledger_dir = + std::env::temp_dir().join(format!("codex-inert-ledger-{}", std::process::id())); + std::fs::create_dir_all(&ledger_dir).unwrap(); + let (url, registry, _server_ledger) = + spawn_server_with_ledger(vec![codex_capture_spec()], &ledger_dir).await; + let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; + + // A REAL fresh codex terminal with NO identity (no sessionRef, no resume). + ws.send(WsMessage::Text( + json!({ + "type": "terminal.create", + "requestId": "req-codex-inert-1", + "mode": "codex", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let codex_tid = created["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + assert_eq!( + registry_resume_id(®istry, &codex_tid), + None, + "fresh codex must start unbound" + ); + + // The frozen client's durability announce: a candidate that the OLD + // handler would have adopted. Retired channel: accept-and-ignore, + // nothing sent back, connection healthy. + send_candidate_expect_silence(&mut ws, &codex_tid, THREAD, &rollout).await; + + // NO identity write in ANY home: registry meta stays unbound... + assert_eq!( + registry_resume_id(®istry, &codex_tid), + None, + "retired candidate channel must never write identity" + ); + // ...and the durable ledger holds no binding row for the thread id + // (fresh reader instance: construction-time scan sees disk truth). + let ledger = freshell_ws::pane_ledger::PaneLedger::new(Some(ledger_dir.clone())); + assert!( + ledger.load_binding("codex", THREAD).is_none(), + "retired candidate channel must never write a ledger binding" + ); + + // The terminal still works: input flows and no protocol error comes back. + ws.send(WsMessage::Text( + json!({ + "type": "terminal.input", + "terminalId": codex_tid, + "data": "\r", + }) + .to_string(), + )) + .await + .expect("send terminal.input"); + expect_no_error_until_pong(&mut ws).await; + + registry.kill(&codex_tid); + std::env::remove_var("CODEX_HOME"); + std::env::remove_var("CODEX_ARGV_CAPTURE_PATH"); + std::fs::remove_dir_all(&ledger_dir).ok(); +} diff --git a/crates/freshell-ws/tests/codex_candidate_persisted.rs b/crates/freshell-ws/tests/codex_candidate_persisted.rs deleted file mode 100644 index 76d478e5f..000000000 --- a/crates/freshell-ws/tests/codex_candidate_persisted.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! P0.3 integration: `terminal.codex.candidate.persisted` handling. -//! Campaign plan §2.3.1: four guards; reject = WARN + ignore, nothing sent back. - -mod common; - -use common::{ - connect_and_capture_inventory, next_frame_of_type, sleeper_cli_spec, spawn_server_with_ledger, -}; -use futures_util::SinkExt; -use serde_json::json; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -const THREAD_A: &str = "0192aaaa-bbbb-4ccc-8ddd-eeeeffff0001"; -const THREAD_B: &str = "0192aaaa-bbbb-4ccc-8ddd-eeeeffff0002"; - -/// Fake codex: records argv to $CODEX_ARGV_CAPTURE_PATH (atomic tmp+mv) then -/// sleeps. Copied from tests/codex_session_ref_resume.rs:85-103. -fn write_fake_codex() -> std::path::PathBuf { - let script_path = std::env::temp_dir().join(format!( - "freshell-codex-candidate-fake-{}.sh", - std::process::id() - )); - let script = "#!/bin/sh\n\ - printf '%s\\n' \"$@\" > \"$CODEX_ARGV_CAPTURE_PATH.tmp\"\n\ - mv \"$CODEX_ARGV_CAPTURE_PATH.tmp\" \"$CODEX_ARGV_CAPTURE_PATH\"\n\ - exec sleep 300\n"; - std::fs::write(&script_path, script).expect("write fake codex script"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script_path, perms).unwrap(); - } - script_path -} - -fn codex_capture_spec() -> freshell_platform::CliCommandSpec { - freshell_platform::CliCommandSpec { - name: "codex".to_string(), - label: "Codex CLI".to_string(), - env_var: None, - default_cmd: write_fake_codex().to_string_lossy().to_string(), - base_args: vec![], - base_env: std::collections::BTreeMap::new(), - // Real codex manifest shape: resume subcommand, no createSessionArgs. - resume_args: Some(vec!["resume".to_string(), "{{sessionId}}".to_string()]), - create_session_args: None, - model_args: None, - sandbox_args: None, - permission_mode_args: None, - } -} - -fn registry_resume_id( - registry: &freshell_terminal::TerminalRegistry, - terminal_id: &str, -) -> Option { - registry - .identity_probe_rows() - .into_iter() - .find(|row| row.terminal_id == terminal_id) - .unwrap_or_else(|| panic!("registry must list {terminal_id}")) - .resume_session_id -} - -async fn send_create( - ws: &mut common::TestWs, - request_id: &str, - mode: &str, - extra: serde_json::Value, -) { - let mut msg = json!({ - "type": "terminal.create", - "requestId": request_id, - "mode": mode, - "shell": "system", - "cwd": std::env::temp_dir().to_string_lossy(), - }); - if let (Some(obj), Some(extra_obj)) = (msg.as_object_mut(), extra.as_object()) { - for (k, v) in extra_obj { - obj.insert(k.clone(), v.clone()); - } - } - ws.send(WsMessage::Text(msg.to_string())) - .await - .expect("send terminal.create"); -} - -/// Plain send, NO sync gate. Used by the HAPPY PATH only, which proves -/// consumption by awaiting the broadcasts themselves. -async fn send_candidate( - ws: &mut common::TestWs, - terminal_id: &str, - thread_id: &str, - rollout_path: &str, -) { - ws.send(WsMessage::Text( - json!({ - "type": "terminal.codex.candidate.persisted", - "terminalId": terminal_id, - "candidateThreadId": thread_id, - "rolloutPath": rollout_path, - "capturedAt": 1_753_300_000_000i64, - }) - .to_string(), - )) - .await - .expect("send candidate"); -} - -/// Send a candidate that must be REJECTED: the ping/pong round-trip proves -/// the frame was consumed AND that nothing was sent back (silence proof -- -/// precedent: pane_reconcile.rs:230-250 uses exactly this to prove nothing -/// was sent). NEVER use this on the accept path: `next_frame_of_type` -/// permanently DROPS mismatched frames (tests/common/mod.rs:327-342), and the -/// connection loop is one unbiased `tokio::select!`, so broadcasts queued -/// during candidate handling commonly hit the wire BEFORE the pong -- -/// awaiting the pong first would eat the association broadcasts. -async fn send_candidate_expect_silence( - ws: &mut common::TestWs, - terminal_id: &str, - thread_id: &str, - rollout_path: &str, -) { - send_candidate(ws, terminal_id, thread_id, rollout_path).await; - ws.send(WsMessage::Text(json!({"type": "ping"}).to_string())) - .await - .expect("send ping"); - let _pong = next_frame_of_type(ws, "pong").await; -} - -fn wait_for_captured_argv(path: &std::path::Path) -> Vec { - for _ in 0..100 { - if let Ok(contents) = std::fs::read_to_string(path) { - return contents.lines().map(str::to_string).collect(); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - panic!("fake codex never wrote argv capture at {}", path.display()); -} - -#[tokio::test(flavor = "multi_thread")] -#[cfg(unix)] -async fn codex_candidate_persisted_guards_and_happy_path() { - // ---- env setup (single sequential test: this binary owns process env) ---- - let codex_home = tempfile::tempdir().expect("codex home"); - let sessions_day = codex_home - .path() - .join("sessions") - .join("2026") - .join("07") - .join("24"); - std::fs::create_dir_all(&sessions_day).expect("sessions tree"); - std::env::set_var("CODEX_HOME", codex_home.path()); - std::env::remove_var("FRESHELL_CODEX_MANAGED_LAUNCH"); - let capture = - std::env::temp_dir().join(format!("codex-candidate-argv-{}.txt", std::process::id())); - let _ = std::fs::remove_file(&capture); - std::env::set_var("CODEX_ARGV_CAPTURE_PATH", &capture); - - // P1.8: a REAL ledger dir -- adoption must durably record the identity. - // The third tuple element (the server's own Arc) is unused: durability is - // verified via a FRESH reader instance constructed AFTER the adoption. - let ledger_dir = - std::env::temp_dir().join(format!("codex-adopt-ledger-{}", std::process::id())); - std::fs::create_dir_all(&ledger_dir).unwrap(); - let (url, registry, _server_ledger) = spawn_server_with_ledger( - vec![sleeper_cli_spec("claude"), codex_capture_spec()], - &ledger_dir, - ) - .await; - let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; - - // A codex terminal with NO identity yet (fresh create, no resume). - send_create(&mut ws, "req-codex-cand-1", "codex", json!({})).await; - let created = next_frame_of_type(&mut ws, "terminal.created").await; - let codex_tid = created["terminalId"] - .as_str() - .expect("terminalId") - .to_string(); - assert_eq!( - registry_resume_id(®istry, &codex_tid), - None, - "fresh codex must start unbound" - ); - - // A valid on-disk rollout for THREAD_A: first line is the session_meta - // header whose payload.id is the rollout's OWN id (guard 4's contract). - let rollout_a = sessions_day.join(format!("rollout-2026-07-24T12-00-00-{THREAD_A}.jsonl")); - std::fs::write( - &rollout_a, - format!("{{\"timestamp\":\"2026-07-24T12:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD_A}\"}}}}\n"), - ) - .unwrap(); - let rollout_a = rollout_a.to_string_lossy().to_string(); - - // ---- Guard 1: unknown terminal is ignored ---- - send_candidate_expect_silence(&mut ws, "no-such-terminal", THREAD_A, &rollout_a).await; - assert_eq!(registry_resume_id(®istry, &codex_tid), None); - - // ---- Guard 2: non-codex terminal is ignored ---- - send_create(&mut ws, "req-claude-cand-1", "claude", json!({})).await; - let claude_created = next_frame_of_type(&mut ws, "terminal.created").await; - let claude_tid = claude_created["terminalId"] - .as_str() - .expect("terminalId") - .to_string(); - send_candidate_expect_silence(&mut ws, &claude_tid, THREAD_A, &rollout_a).await; - assert_ne!( - registry_resume_id(®istry, &claude_tid).as_deref(), - Some(THREAD_A), - "claude terminal must never adopt a codex candidate" - ); - assert_eq!(registry_resume_id(®istry, &codex_tid), None); - - // ---- Guard 4: out-of-root rolloutPath is ignored (fails containment, - // so its contents are never read) ---- - let outside = std::env::temp_dir().join(format!("outside-rollout-{THREAD_A}.jsonl")); - std::fs::write(&outside, format!("{THREAD_A}\n")).unwrap(); - send_candidate_expect_silence(&mut ws, &codex_tid, THREAD_A, &outside.to_string_lossy()).await; - assert_eq!(registry_resume_id(®istry, &codex_tid), None); - - // ---- Guard 4: nonexistent rolloutPath is ignored ---- - let missing = sessions_day.join("rollout-nope.jsonl"); - send_candidate_expect_silence(&mut ws, &codex_tid, THREAD_A, &missing.to_string_lossy()).await; - assert_eq!(registry_resume_id(®istry, &codex_tid), None); - - // ---- Guard 4: foreign-lineage rollout is ignored (in-root, real file, - // session_meta first line -- but payload.id is ANOTHER session's; - // the claimed id appears only as fork lineage payload.session_id) ---- - let foreign = sessions_day.join(format!("rollout-2026-07-24T11-00-00-{THREAD_B}.jsonl")); - std::fs::write( - &foreign, - format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD_B}\",\"session_id\":\"{THREAD_A}\"}}}}\n"), - ) - .unwrap(); - send_candidate_expect_silence(&mut ws, &codex_tid, THREAD_A, &foreign.to_string_lossy()).await; - assert_eq!(registry_resume_id(®istry, &codex_tid), None); - - // ---- Happy path: binds both identity homes + broadcasts ---- - // NO ping gate here (see send_candidate_expect_silence's doc): receipt of - // the two broadcasts IS the consumption proof. Their order is pinned by - // the handler: associated BEFORE meta.updated. - send_candidate(&mut ws, &codex_tid, THREAD_A, &rollout_a).await; - let associated = next_frame_of_type(&mut ws, "terminal.session.associated").await; - assert_eq!(associated["terminalId"], json!(codex_tid)); - assert_eq!( - associated["sessionRef"], - json!({ "provider": "codex", "sessionId": THREAD_A }) - ); - let meta = next_frame_of_type(&mut ws, "terminal.meta.updated").await; - let upsert = &meta["upsert"][0]; - assert_eq!(upsert["terminalId"], json!(codex_tid)); - assert_eq!(upsert["provider"], json!("codex")); - assert_eq!(upsert["sessionId"], json!(THREAD_A)); - assert_eq!( - registry_resume_id(®istry, &codex_tid).as_deref(), - Some(THREAD_A) - ); - - // P1.8: adoption is an identity event -- the ledger must now hold a - // binding row for THREAD_A, and the spawn-time pending marker must be - // gone (binding-first pinned order, spec §4.2). - let ledger = freshell_ws::pane_ledger::PaneLedger::new(Some(ledger_dir.clone())); - let row = ledger - .load_binding("codex", THREAD_A) - .expect("adoption wrote a binding row"); - assert_eq!(row.state, freshell_ws::pane_ledger::RowState::Bound); - assert_eq!(row.live_terminal_id.as_deref(), Some(codex_tid.as_str())); - assert!( - ledger.pending_for_terminal(&codex_tid).is_none(), - "pending marker resolved away" - ); - assert!(ledger - .list_pending_raw() - .iter() - .all(|m| m.terminal_id != codex_tid)); - - // ---- Guard 3b: cross-pane hijack -- THREAD_A is live-bound to codex_tid ---- - send_create(&mut ws, "req-codex-cand-2", "codex", json!({})).await; - let created2 = next_frame_of_type(&mut ws, "terminal.created").await; - let codex_tid2 = created2["terminalId"] - .as_str() - .expect("terminalId") - .to_string(); - send_candidate_expect_silence(&mut ws, &codex_tid2, THREAD_A, &rollout_a).await; - assert_eq!( - registry_resume_id(®istry, &codex_tid2), - None, - "a sessionRef bound to a different live terminal must never be adopted" - ); - - // ---- Guard 3a: stale replayed candidate once a newer binding exists ---- - let rollout_b = sessions_day.join(format!("rollout-2026-07-24T13-00-00-{THREAD_B}.jsonl")); - std::fs::write( - &rollout_b, - format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD_B}\"}}}}\n"), - ) - .unwrap(); - send_candidate_expect_silence(&mut ws, &codex_tid, THREAD_B, &rollout_b.to_string_lossy()) - .await; - assert_eq!( - registry_resume_id(®istry, &codex_tid).as_deref(), - Some(THREAD_A), - "an already-bound terminal must keep its binding; replayed/stale candidates are ignored" - ); - - // ---- Guard 3b (retired-INCLUSIVE): dead-pane candidate replay ---- - // Kill THREAD_A's owner over the WS protocol: `handle_kill` retires the - // identity entry SYNCHRONOUSLY in the dispatch loop - // (`state.identity.retire(terminal_id)` in terminal.rs's handle_kill), so - // the ping/pong gate deterministically orders retirement before the - // replay. (A direct `registry.kill()` would leave retirement to the - // async pty exit hook -- racy, and the red test must observe a RETIRED - // binding to distinguish retired-inclusive from live-only guard 3b.) - ws.send(WsMessage::Text( - json!({"type": "terminal.kill", "terminalId": codex_tid}).to_string(), - )) - .await - .expect("send terminal.kill"); - ws.send(WsMessage::Text(json!({"type": "ping"}).to_string())) - .await - .expect("send ping"); - let _pong = next_frame_of_type(&mut ws, "pong").await; - - send_create(&mut ws, "req-codex-cand-3", "codex", json!({})).await; - let created3 = next_frame_of_type(&mut ws, "terminal.created").await; - let codex_tid3 = created3["terminalId"] - .as_str() - .expect("terminalId") - .to_string(); - // Replay the SAME candidate (THREAD_A, its genuine rollout) onto the - // fresh pane: WARN + ignore, nothing sent back, tid3 stays unbound -- a - // retired binding still blocks a DIFFERENT terminal's claim (ledger A8). - send_candidate_expect_silence(&mut ws, &codex_tid3, THREAD_A, &rollout_a).await; - assert_eq!( - registry_resume_id(®istry, &codex_tid3), - None, - "a DEAD pane's session identity must never be claimable by a fresh terminal" - ); - - // ---- Subsequent restore create builds `codex ... resume ` ---- - // (codex_tid was already killed in the dead-pane phase above.) - // Per-phase capture path (precedent: codex_session_ref_resume.rs's - // `capture_for(phase)`): the earlier fresh codex spawns (tid2/tid3) hold - // the ORIGINAL path in their env and may write it late -- a shared path - // would let a stale fresh-create argv shadow the restore argv. - let capture_restore = std::env::temp_dir().join(format!( - "codex-candidate-argv-restore-{}.txt", - std::process::id() - )); - let _ = std::fs::remove_file(&capture_restore); - std::env::set_var("CODEX_ARGV_CAPTURE_PATH", &capture_restore); - send_create( - &mut ws, - "req-codex-cand-restore", - "codex", - json!({ "restore": true, "sessionRef": { "provider": "codex", "sessionId": THREAD_A } }), - ) - .await; - let restored = next_frame_of_type(&mut ws, "terminal.created").await; - let restored_tid = restored["terminalId"] - .as_str() - .expect("terminalId") - .to_string(); - let argv = wait_for_captured_argv(&capture_restore); - let pos = argv.iter().position(|a| a == "resume"); - assert!( - pos.is_some_and(|p| argv.get(p + 1).map(String::as_str) == Some(THREAD_A)), - "restore create must spawn `codex ... resume {THREAD_A}`: {argv:?}" - ); - - registry.kill(&restored_tid); - registry.kill(&codex_tid2); - registry.kill(&codex_tid3); - registry.kill(&claude_tid); - std::env::remove_var("CODEX_HOME"); - std::env::remove_var("CODEX_ARGV_CAPTURE_PATH"); - std::fs::remove_dir_all(&ledger_dir).ok(); -} diff --git a/crates/freshell-ws/tests/codex_candidate_activity.rs b/crates/freshell-ws/tests/codex_locator_activity.rs similarity index 52% rename from crates/freshell-ws/tests/codex_candidate_activity.rs rename to crates/freshell-ws/tests/codex_locator_activity.rs index aa3ac6303..9657bf3c0 100644 --- a/crates/freshell-ws/tests/codex_candidate_activity.rs +++ b/crates/freshell-ws/tests/codex_locator_activity.rs @@ -1,10 +1,11 @@ -//! G3 integration: a FRESH codex terminal (no resume id) whose candidate is -//! adopted must broadcast `codex.activity.updated` carrying the sessionId, -//! and its subsequent turn completion must carry the same sessionId. -//! Uses the activity-enabled harness (the default harness has `activity: None`). +//! Lane B2: a FRESH codex terminal (no resume id, NO client candidate frame) +//! gains identity from the server-side rollout locator, and its activity +//! frames carry the sessionId — closing the "terminals created before any +//! candidate" status gap. //! -//! This binary asserts the identity upsert only; the completion payoff is -//! covered at hub level (activity.rs unit tests) and e2e level. +//! Harness copied from the (retired) codex_candidate_activity.rs: real +//! server, real socket, real PTY running a fake codex binary, CODEX_HOME +//! pointed at a tempdir. #[cfg(unix)] mod common; @@ -19,11 +20,11 @@ use std::time::Duration; use tokio_tungstenite::tungstenite::Message as WsMessage; /// Fake codex: records argv to $CODEX_ARGV_CAPTURE_PATH (atomic tmp+mv) then -/// sleeps. Copied from tests/codex_candidate_persisted.rs. +/// sleeps. Copied from the (retired) tests/codex_candidate_persisted.rs. #[cfg(unix)] fn write_fake_codex() -> std::path::PathBuf { let script_path = std::env::temp_dir().join(format!( - "freshell-codex-candidate-activity-fake-{}.sh", + "freshell-codex-locator-activity-fake-{}.sh", std::process::id() )); let script = "#!/bin/sh\n\ @@ -65,7 +66,7 @@ async fn send_create(ws: &mut common::TestWs, mode: &str) -> String { ws.send(WsMessage::Text( json!({ "type": "terminal.create", - "requestId": "req-codex-activity-1", + "requestId": "req-codex-locator-activity-1", "mode": mode, "shell": "system", "cwd": std::env::temp_dir().to_string_lossy(), @@ -81,29 +82,6 @@ async fn send_create(ws: &mut common::TestWs, mode: &str) -> String { .to_string() } -/// Plain candidate announce (client announcing the persisted rollout). -/// Copied from tests/codex_candidate_persisted.rs. -#[cfg(unix)] -async fn send_candidate( - ws: &mut common::TestWs, - terminal_id: &str, - thread_id: &str, - rollout_path: &str, -) { - ws.send(WsMessage::Text( - json!({ - "type": "terminal.codex.candidate.persisted", - "terminalId": terminal_id, - "candidateThreadId": thread_id, - "rolloutPath": rollout_path, - "capturedAt": 1_753_300_000_000i64, - }) - .to_string(), - )) - .await - .expect("send candidate"); -} - /// Scan WS text frames until `pred` matches or the 10s budget elapses. /// Non-matching frames are simply skipped (no drop-on-mismatch semantics). #[cfg(unix)] @@ -129,14 +107,30 @@ async fn wait_for_frame( false } +/// Wall-clock ms — matches the tracker's `now_ms` timestamp domain. +#[cfg(unix)] +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_millis() as i64 +} + +/// A codex rollout `event_msg` line (shape copied from activity.rs's +/// `codex_event_line` test helper). +#[cfg(unix)] +fn codex_event_line(payload_type: &str, at_ms: i64) -> String { + format!(r#"{{"timestamp":{at_ms},"type":"event_msg","payload":{{"type":"{payload_type}"}}}}"#) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] -async fn adopted_candidate_identity_reaches_codex_activity() { +async fn fresh_pane_locator_identity_reaches_activity_and_turn_complete() { const THREAD: &str = "11111111-2222-3333-4444-555555555555"; // ---- env setup (single sequential test: this binary owns process env) ---- - // CODEX_HOME tempdir + a real rollout whose first line is - // session_meta { payload: { id: THREAD } } (adoption guard 4 disk truth). + // CODEX_HOME tempdir; the sessions day tree exists but holds NO rollout + // yet — the locator's FIRST-submit re-snapshot must see zero files. let codex_home = tempfile::tempdir().expect("codex home"); let sessions_day = codex_home .path() @@ -148,32 +142,53 @@ async fn adopted_candidate_identity_reaches_codex_activity() { std::env::set_var("CODEX_HOME", codex_home.path()); std::env::remove_var("FRESHELL_CODEX_MANAGED_LAUNCH"); let capture = std::env::temp_dir().join(format!( - "codex-candidate-activity-argv-{}.txt", + "codex-locator-activity-argv-{}.txt", std::process::id() )); let _ = std::fs::remove_file(&capture); std::env::set_var("CODEX_ARGV_CAPTURE_PATH", &capture); + let (url, registry) = common::spawn_server_with_specs_activity_and_codex_locator( + vec![codex_capture_spec()], + &codex_home.path().join("sessions"), + ) + .await; + let (mut ws, _inventory) = common::connect_and_capture_inventory(&url).await; + + // 1. Create a FRESH codex terminal (no sessionRef, no resume id, and — + // unlike the retired candidate test — NO candidate frame ever sent). + // The create arms the locator server-side. + let terminal_id = send_create(&mut ws, "codex").await; + + // 2a. First Enter: windows are Enter-anchored (no spawn window). This + // submit takes the FIRST-submit re-snapshot of known_files and opens + // the 2 s window — the rollout must NOT exist yet (a pre-seeded file + // would be captured by the re-snapshot and permanently excluded). + common::send_input(&mut ws, &terminal_id, "\r").await; + + // 2b. Let that first window resolve with zero candidates (deadline 2 s + + // 150 ms sweep, with margin). + tokio::time::sleep(Duration::from_secs(3)).await; + + // 2c. NOW write the rollout the locator must find. payload.cwd must + // match the terminal's cwd (the value send_create passed). + let cwd = std::env::temp_dir().to_string_lossy().to_string(); let rollout = sessions_day.join(format!("rollout-2026-07-24T12-00-00-{THREAD}.jsonl")); std::fs::write( &rollout, - format!("{{\"timestamp\":\"2026-07-24T12:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD}\"}}}}\n"), + format!("{{\"timestamp\":\"2026-07-24T12:00:00.000Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{THREAD}\",\"cwd\":\"{cwd}\"}}}}\n"), ) .unwrap(); - let rollout_path = rollout.to_string_lossy().to_string(); - let (url, registry) = - common::spawn_server_with_specs_and_activity(vec![codex_capture_spec()]).await; - let (mut ws, _inventory) = common::connect_and_capture_inventory(&url).await; - - // 1. Create a FRESH codex terminal (no sessionRef, no resume id). - let terminal_id = send_create(&mut ws, "codex").await; + // 2d. Second Enter: a later Enter re-opens a resolved window WITHOUT + // re-snapshotting (Task 2's later_enter_reopen_keeps_the_first_submit_snapshot), + // so the file written in 2c is deterministically the sole new candidate. + common::send_input(&mut ws, &terminal_id, "\r").await; - // 2. Send the candidate frame (client announcing the persisted rollout). - send_candidate(&mut ws, &terminal_id, THREAD, &rollout_path).await; - - // 3. The adopt path must now emit codex.activity.updated with sessionId. - // Collect frames by scanning (never drop-on-mismatch). + // 3. The locator sweep resolves and the adoption tail must emit + // codex.activity.updated with the sessionId. Collect frames by + // scanning (never drop-on-mismatch); the 10 s budget covers the ~2 s + // Enter-anchored deadline + 150 ms sweep. let bound = wait_for_frame(&mut ws, |v| { v["type"] == "codex.activity.updated" && v["upsert"] @@ -188,7 +203,44 @@ async fn adopted_candidate_identity_reaches_codex_activity() { .await; assert!( bound, - "expected codex.activity.updated carrying the adopted sessionId" + "expected codex.activity.updated carrying the locator-resolved sessionId" + ); + + // 4. The adoption tail also attached the rollout to the status watcher + // (attach_codex_rollout). Append a task_started/task_complete pair to + // the SAME rollout — inotify drives the reads — and the completion + // frame must be stamped with the provider AND sessionId. + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&rollout) + .expect("open rollout for append"); + writeln!(f, "{}", codex_event_line("task_started", now_ms())).expect("append task_started"); + } + // Let the busy edge land before completing the turn (mirrors the + // seed-then-append shape of activity.rs's rollout-lane unit tests). + tokio::time::sleep(Duration::from_millis(300)).await; + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&rollout) + .expect("open rollout for append"); + writeln!(f, "{}", codex_event_line("task_complete", now_ms())) + .expect("append task_complete"); + } + + let completed = wait_for_frame(&mut ws, |v| { + v["type"] == "terminal.turn.complete" + && v["terminalId"] == terminal_id.as_str() + && v["provider"] == "codex" + && v["sessionId"] == THREAD + }) + .await; + assert!( + completed, + "expected terminal.turn.complete with provider=codex and sessionId stamped by the locator adoption" ); registry.kill(&terminal_id); diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index 3c87985f9..71057902f 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -153,8 +153,11 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/codex_session_ref_resume.rs b/crates/freshell-ws/tests/codex_session_ref_resume.rs index 57bc9d155..13476739d 100644 --- a/crates/freshell-ws/tests/codex_session_ref_resume.rs +++ b/crates/freshell-ws/tests/codex_session_ref_resume.rs @@ -146,8 +146,11 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 778e734e1..f3b38e8b4 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -134,8 +134,11 @@ pub async fn spawn_server_with_specs( config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); @@ -214,8 +217,11 @@ pub async fn spawn_server_with_ledger( config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); @@ -290,8 +296,11 @@ pub async fn spawn_server_with_specs_and_activity( config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: Some(activity_hub.clone()), session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); @@ -306,6 +315,93 @@ pub async fn spawn_server_with_specs_and_activity( (format!("ws://{addr}/ws", addr = addr), registry) } +/// [`spawn_server_with_specs_and_activity`], with the codex rollout LOCATOR +/// wired and its sweep spawned (Lane B2: fresh codex panes gain identity +/// server-side with NO client candidate frame). Identical body except two +/// deltas: `codex_locator` is `Some(CodexLocator)` rooted at +/// `codex_sessions_root` (tests pass `/sessions` — the same root +/// `codex_sessions_root()` resolves in the real server), and the locator +/// sweep is spawned before the router. +#[allow(dead_code)] // not every test binary uses the codex-locator variant +pub async fn spawn_server_with_specs_activity_and_codex_locator( + cli_commands: Vec, + codex_sessions_root: &std::path::Path, +) -> (String, freshell_terminal::TerminalRegistry) { + let auth_token = Arc::new(AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = + Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); + let registry = freshell_terminal::TerminalRegistry::new(); + + let activity_hub = + freshell_ws::activity::ActivityHub::new(std::sync::Arc::clone(&broadcast_tx), None); + registry.set_activity_observer(activity_hub.registry_observer()); + + let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), + 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), + 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(cli_commands), + 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: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + config_fallback: None, + amplifier_locator: None, + opencode_locator: None, + codex_locator: Some(std::sync::Arc::new( + freshell_sessions::codex_locator::CodexLocator::new(codex_sessions_root.to_path_buf()), + )), + activity: Some(activity_hub.clone()), + session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + }; + + // Mirrors main.rs's sweep wiring; 150 ms is re-declared here because + // main.rs's AMPLIFIER_LOCATOR_SWEEP_INTERVAL is private to the server + // binary. + freshell_ws::codex_association::spawn_codex_locator_sweep( + state.clone(), + std::time::Duration::from_millis(150), + ); + let router = freshell_ws::router(state); + 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", addr = addr), registry) +} + /// [`spawn_server`] variant with injectable `terminal.create` protection /// knobs (rate limit + spawn gate). Identical `WsState` otherwise; returns /// only the ws URL (the create-protection tests never need the registry). @@ -355,8 +451,11 @@ pub async fn spawn_server_with_create_protect( config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/diag01_lifecycle_events.rs b/crates/freshell-ws/tests/diag01_lifecycle_events.rs index 912db6baf..49b1fb346 100644 --- a/crates/freshell-ws/tests/diag01_lifecycle_events.rs +++ b/crates/freshell-ws/tests/diag01_lifecycle_events.rs @@ -170,8 +170,11 @@ async fn spawn_server(ping_interval_ms: u64) -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index 6014b3f3b..0493ad76d 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -174,8 +174,11 @@ async fn spawn_server() -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index df44c89ef..789e0de06 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -205,8 +205,11 @@ async fn spawn_server() -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/hello_timeout.rs b/crates/freshell-ws/tests/hello_timeout.rs index c3d08eec0..9bae6d70b 100644 --- a/crates/freshell-ws/tests/hello_timeout.rs +++ b/crates/freshell-ws/tests/hello_timeout.rs @@ -90,8 +90,11 @@ async fn spawn_server(hello_timeout_ms: u64) -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/keepalive.rs b/crates/freshell-ws/tests/keepalive.rs index 37132e772..f0c9bd00e 100644 --- a/crates/freshell-ws/tests/keepalive.rs +++ b/crates/freshell-ws/tests/keepalive.rs @@ -91,8 +91,11 @@ async fn spawn_server( config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/live_session_ref_guard.rs b/crates/freshell-ws/tests/live_session_ref_guard.rs new file mode 100644 index 000000000..f8ac14e95 --- /dev/null +++ b/crates/freshell-ws/tests/live_session_ref_guard.rs @@ -0,0 +1,129 @@ +//! Task 2b (recover-my-panes, D7 defense-in-depth): a `terminal.create` with +//! `restore:true` + a wire `sessionRef` whose `(provider, sessionId)` is +//! already owned by a currently-RUNNING terminal must be REFUSED loudly +//! (`RESTORE_UNAVAILABLE`) — never a second `claude --resume S` while the +//! original live PTY owns S (one-JSONL-writer doctrine, terminal.rs:933). +//! +//! Why the direct rung needs its own guard: every existing live-guard lives +//! inside the createRequestId-keyed ladder (terminal.rs:1690-1745), and the +//! direct wire-sessionRef rung (terminal.rs:1074-1078) bypasses the ladder +//! entirely. The D5 recovery path re-mints the createRequestId and carries +//! identity ONLY in `sessionRef`, so a session that goes live between the +//! inventory fetch and the user's accept would silently double-spawn without +//! this guard (the fetch→accept race client-side stripping cannot close). + +mod common; + +use common::{connect_and_capture_inventory, next_frame_of_type, session_ref_of, spawn_server}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +async fn send_create(ws: &mut common::TestWs, body: serde_json::Value) { + ws.send(WsMessage::Text(body.to_string())) + .await + .expect("send terminal.create"); +} + +/// Read frames until either an `error` or a `terminal.created` correlated to +/// `request_id` arrives. Returns the frame. Panics if a `terminal.created` +/// for the request shows up — that IS the duplicate spawn this guard forbids. +async fn expect_refusal_for(ws: &mut common::TestWs, request_id: &str) -> serde_json::Value { + for _ in 0..20u8 { + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + .await + .expect("frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + match value["type"].as_str() { + Some("terminal.created") if value["requestId"] == json!(request_id) => { + panic!("duplicate spawn: create must be refused, got {value}"); + } + Some("error") if value["requestId"] == json!(request_id) => { + return value; + } + _ => {} + } + } + } + panic!("no error frame for {request_id} within 20 messages"); +} + +/// The D5 recovery wire shape against a LIVE session: restore:true + +/// sessionRef owned by a currently-Running terminal, under a fresh +/// (re-minted) requestId that has no ladder lineage. Must be refused loud; +/// the registry must still hold exactly ONE terminal owning S. +#[tokio::test] +async fn live_session_ref_create_is_refused_loudly() { + let (url, registry) = spawn_server().await; // sleeper claude: stays Running + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + // A fresh claude terminal reaches Running, owning preallocated session S. + send_create( + &mut ws, + json!({ + "type": "terminal.create", + "requestId": "req-live-owner-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }), + ) + .await; + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let tid1 = created["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + let session_id = session_ref_of(&created).expect("fresh claude carries sessionRef") + ["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + + // The exact D5 recreation shape: fresh requestId (no lineage), restore, + // wire sessionRef pointing at the LIVE session. + send_create( + &mut ws, + json!({ + "type": "terminal.create", + "requestId": "req-live-recreate-9f2a", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + "restore": true, + "sessionRef": { "provider": "claude", "sessionId": session_id }, + }), + ) + .await; + + let err = expect_refusal_for(&mut ws, "req-live-recreate-9f2a").await; + assert_eq!( + err["code"], + json!("RESTORE_UNAVAILABLE"), + "exact wire code: {err}" + ); + let message = err["message"].as_str().expect("error message"); + assert!( + message.contains(&session_id), + "message must name the live session {session_id}: {err}" + ); + + // No duplicate spawn: the registry still holds exactly ONE terminal, the + // original owner of S. + let rows = registry.identity_probe_rows(); + assert_eq!( + rows.len(), + 1, + "only the original live terminal may exist: {rows:?}" + ); + assert_eq!(rows[0].terminal_id, tid1); + assert_eq!( + rows[0].resume_session_id.as_deref(), + Some(session_id.as_str()) + ); + + registry.kill(&tid1); +} diff --git a/crates/freshell-ws/tests/max_payload.rs b/crates/freshell-ws/tests/max_payload.rs index aa9a0b06c..7cd56a988 100644 --- a/crates/freshell-ws/tests/max_payload.rs +++ b/crates/freshell-ws/tests/max_payload.rs @@ -91,8 +91,11 @@ async fn spawn_server(ws_max_payload_bytes: usize) -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/origin_policy.rs b/crates/freshell-ws/tests/origin_policy.rs index b8f197f4f..5e6fd8bc0 100644 --- a/crates/freshell-ws/tests/origin_policy.rs +++ b/crates/freshell-ws/tests/origin_policy.rs @@ -81,8 +81,11 @@ async fn spawn_server(allowed_origins: Vec) -> (String, String) { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/pane_reconcile.rs b/crates/freshell-ws/tests/pane_reconcile.rs index c1db5b723..1e55c0cf3 100644 --- a/crates/freshell-ws/tests/pane_reconcile.rs +++ b/crates/freshell-ws/tests/pane_reconcile.rs @@ -57,10 +57,64 @@ struct Server { identity: freshell_ws::identity::TerminalIdentityRegistry, } +/// Test probe that answers `exists` from a scripted sequence (pops from the +/// Vec, repeats the last answer forever) — how the deferral tests simulate an +/// index that warms up (or never does) between derivations. +struct FlippingProbe { + answers: std::sync::Mutex>, + last: std::sync::Mutex, +} + +impl FlippingProbe { + fn new(answers: Vec) -> Self { + let last = *answers.last().expect("at least one scripted answer"); + Self { + answers: std::sync::Mutex::new(answers.into_iter().collect()), + last: std::sync::Mutex::new(last), + } + } +} + +impl freshell_ws::existence::SessionExistenceProbe for FlippingProbe { + fn exists( + &self, + _provider: &str, + _session_id: &str, + ) -> freshell_ws::existence::SessionExistence { + match self.answers.lock().unwrap().pop_front() { + Some(answer) => answer, + None => *self.last.lock().unwrap(), + } + } + + fn ever_observed(&self, _provider: &str, _session_id: &str) -> bool { + false + } +} + /// Real axum server on an ephemeral loopback port. Returns handles to the /// SHARED registry + identity registry so tests can seed generations /// deterministically (the §9.1 headless convention). async fn spawn_server() -> Server { + spawn_server_with(|_| {}).await +} + +/// [`spawn_server`] with a state mutator (e.g. shrink the deferral budget so +/// the warming tests never wait a real 2s). +async fn spawn_server_with(mutate: impl FnOnce(&mut WsState)) -> Server { + spawn_server_with_probe( + std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + mutate, + ) + .await +} + +/// [`spawn_server_with`] with an injected existence probe (design §5.1: the +/// disk-truth input is a test fake). +async fn spawn_server_with_probe( + probe: freshell_ws::existence::SharedExistenceProbe, + mutate: impl FnOnce(&mut WsState), +) -> Server { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = @@ -68,7 +122,7 @@ async fn spawn_server() -> Server { let registry = freshell_terminal::TerminalRegistry::new(); let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); - let state = WsState { + let mut state = WsState { pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: identity.clone(), auth_token: Arc::clone(&auth_token), @@ -105,9 +159,13 @@ async fn spawn_server() -> Server { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, - session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + session_existence: probe, + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; + mutate(&mut state); let router = freshell_ws::router(state); let listener = TcpListener::bind("127.0.0.1:0") @@ -193,6 +251,21 @@ fn reconcile_request(reconcile_id: &str, panes: serde_json::Value) -> WsMessage ) } +/// One-pane request whose only recoverable identity is a structured claim — +/// existence-probe-driven rows (5/warming) are reached deterministically. +fn reconcile_request_with_session_ref(provider: &str, session_id: &str) -> WsMessage { + reconcile_request( + "rec-warming", + serde_json::json!([{ + "paneKey": "pk-warm", + "kind": "terminal", + "mode": provider, + "createRequestId": format!("cr-{session_id}"), + "sessionRef": { "provider": provider, "sessionId": session_id } + }]), + ) +} + fn headless(server: &Server, id: &str, key: Option<&str>, mode: &str, created_at: i64) { server .registry @@ -437,6 +510,45 @@ async fn contradicting_claim_is_answered_with_server_ref_and_corrected() { assert_eq!(verdict["corrected"], true); } +/// Council rule 6 (sessionRef-level single-flight, reconcile side): a live +/// terminal spawned under createRequestId A and identity-stamped with +/// sessionRef {claude, sess-x} answers a reconcile claim from +/// createRequestId B (a different client) with attach{terminalId of A's +/// terminal} — never a second writer for the same session file (D8). +#[tokio::test] +async fn different_create_request_id_same_session_ref_gets_attach_to_winner() { + let server = spawn_server().await; + // Seed: headless terminal live in the registry, identity-stamped with + // sessionRef {claude, sess-x} (the existing seeding pattern). + headless(&server, "T-winner", Some("cr-WINNER"), "claude", 1_000); + server + .identity + .upsert("T-winner", Some("claude"), Some("sess-x"), None, 1); + + let (mut ws, _ready) = connect(&server.url, true).await; + ws.send(reconcile_request( + "rec-xclient", + serde_json::json!([{ + "paneKey": "pk-x", + "kind": "terminal", + "mode": "claude", + "createRequestId": "cr-OTHER", + "sessionRef": { "provider": "claude", "sessionId": "sess-x" } + }]), + )) + .await + .expect("send request"); + + let result = next_frame_of_type(&mut ws, "pane.reconcile.result").await; + let v = &result["verdicts"][0]; + assert_eq!(v["verdict"], "attach"); + assert_eq!(v["terminalId"], "T-winner"); + assert_eq!( + v["sessionRef"], + serde_json::json!({ "provider": "claude", "sessionId": "sess-x" }) + ); +} + // --- 9.1.10 single-flight create-dedupe -------------------------------------------- /// Change #1 (the council's two-tab double-respawn blocker): on a @@ -537,3 +649,83 @@ async fn frozen_client_create_path_is_unchanged_no_dedupe() { server.registry.kill(&id1); server.registry.kill(&id2); } + +// --- 9.1.6 index warming: error{index_warming} + bounded single deferral ---------- + +/// warming-never-completes (council red test): a probe pinned to Unknown +/// forever must yield error{index_warming} after ONE bounded deferral — +/// never a hang, never a fake fresh/dead_session. +#[tokio::test] +async fn warming_never_completes_yields_error_index_warming() { + // Server with deferral budget shrunk for tests (50ms) and the default + // NoIndexProbe (always Unknown for known providers). + let server = spawn_server_with(|state| state.reconcile_deferral_budget_ms = 50).await; + let (mut ws, _ready) = connect(&server.url, true).await; + let started = std::time::Instant::now(); + ws.send(reconcile_request_with_session_ref("claude", "sess-1")) + .await + .expect("send request"); + let result = next_frame_of_type(&mut ws, "pane.reconcile.result").await; + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "bounded, single deferral" + ); + let v = &result["verdicts"][0]; + assert_eq!(v["verdict"], "error"); + assert_eq!(v["reason"], "index_warming"); + assert!( + v.get("retryAfterMs").is_none(), + "retry is deleted from the wire" + ); +} + +/// A known provider with no home on this machine is NOT warming — it gets +/// the honest provider_unavailable label, immediately (no 2s deferral). +#[tokio::test] +async fn provider_unavailable_is_immediate_and_honest() { + // A single scripted answer repeats forever — "always ProviderUnavailable". + let probe = FlippingProbe::new(vec![ + freshell_ws::existence::SessionExistence::ProviderUnavailable, + ]); + // Deliberately LARGE budget: proves no deferral happens for this reason. + let server = spawn_server_with_probe(std::sync::Arc::new(probe), |state| { + state.reconcile_deferral_budget_ms = 30_000 + }) + .await; + let (mut ws, _ready) = connect(&server.url, true).await; + let started = std::time::Instant::now(); + ws.send(reconcile_request_with_session_ref("codex", "sess-9")) + .await + .expect("send request"); + let result = next_frame_of_type(&mut ws, "pane.reconcile.result").await; + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "provider_unavailable must never trigger the warming deferral" + ); + let v = &result["verdicts"][0]; + assert_eq!(v["verdict"], "error"); + assert_eq!(v["reason"], "provider_unavailable"); +} + +/// The deferral is real: index warms during the wait -> the SECOND derivation +/// answers with the warm verdict, not error. +#[tokio::test] +async fn warming_resolves_during_deferral_rederives() { + // Fake probe: first call Unknown, subsequent calls Absent (never observed + // -> per existing rules the verdict for a never-observed identity is + // fresh{identity_never_observed}). + let probe = FlippingProbe::new(vec![ + freshell_ws::existence::SessionExistence::Unknown, + freshell_ws::existence::SessionExistence::Absent, + ]); + let server = spawn_server_with_probe(std::sync::Arc::new(probe), |state| { + state.reconcile_deferral_budget_ms = 50 + }) + .await; + let (mut ws, _ready) = connect(&server.url, true).await; + ws.send(reconcile_request_with_session_ref("claude", "sess-2")) + .await + .expect("send request"); + let result = next_frame_of_type(&mut ws, "pane.reconcile.result").await; + assert_ne!(result["verdicts"][0]["verdict"], "error"); +} diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs new file mode 100644 index 000000000..d7d526bd4 --- /dev/null +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -0,0 +1,748 @@ +//! `paneReconcileFreshAgentV1` capability + verdict-derivation wire tests — +//! raw-WS (tokio-tungstenite) integration against an in-process axum server, +//! on the `pane_reconcile.rs` harness convention (ephemeral loopback ports, +//! never a fixed one). +//! +//! Covered here: +//! * negotiation — `hello.capabilities.paneReconcileFreshAgentV1` → echoed in +//! `ready.capabilities` (typed `ReadyCapabilities`, omitted when absent). +//! * frozen-client protection — a connection WITHOUT the capability keeps the +//! pre-existing verdict for `kind: "fresh-agent"`: `invalid` / +//! `unsupported_kind` (the permanent regression guard). +//! * Task 13 — fresh-agent verdict derivation: the four states, live→attach, +//! in-request dedupe, the respawn cap + reset-on-live, and the G3 +//! supersession-chain reader rule end-to-end through the real ledger. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use freshell_ws::existence::SessionExistence; +use freshell_ws::pane_ledger::{FreshAgentBindingWrite, PaneLedger}; +use freshell_ws::WsState; + +const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; + +/// Serializes every test in this file that mutates the process-global +/// `FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR` +/// env vars, mirroring `freshagent_claude_attach.rs`'s convention for the +/// same hazard. +static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +// ── scripted disk-truth probe ───────────────────────────────────────────────── + +#[derive(Default)] +struct StubProbe { + answers: std::sync::Mutex>, + observed: std::sync::Mutex>, +} +impl freshell_ws::existence::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 { + self.observed + .lock() + .unwrap() + .contains(&(provider.into(), session_id.into())) + } +} + +// ── fake claude sidecar (donor: freshagent_claude_attach.rs) ────────────────── + +/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) +/// speaking the SAME newline-JSON protocol `spawn_sidecar()` drives the +/// vendored package with. On `create` it replies `created`, then +/// `sdk.session.init` echoing `resumeSessionId` (or a fixed durable UUID the +/// tests control) as the durable `cliSessionId`, then `sdk.status idle`; on +/// `shutdown` it exits. +const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" +import readline from 'node:readline' + +let counter = 0 +const rl = readline.createInterface({ input: process.stdin, terminal: false }) +rl.on('line', (line) => { + const trimmed = line.trim() + if (!trimmed) return + let msg + try { + msg = JSON.parse(trimmed) + } catch { + return + } + if (msg.type === 'create') { + counter += 1 + const sessionId = `fake-claude-session-${process.pid}-${counter}` + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') + const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') + process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') + } else if (msg.type === 'shutdown') { + process.exit(0) + } +}) +"#; + +/// The fixed durable `cliSessionId` the fake sidecar mints on a plain (non +/// resume) `create` — the id the live-session tests key their probe on. +const FAKE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +/// A fresh temp dir holding the fake sidecar script, with +/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` pointed at it, PLUS an +/// empty claude store with `CLAUDE_CONFIG_DIR` pointed at it (so no test ever +/// touches the real home). Caller must hold [`CLAUDE_ENV_LOCK`] for the +/// lifetime of the returned guard. +struct FakeClaudeEnv { + dir: std::path::PathBuf, +} +impl FakeClaudeEnv { + fn install() -> Self { + let dir = std::env::temp_dir().join(format!( + "freshell-fake-claude-reconcile-ws-{}", + uuid_like_suffix() + )); + std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); + let script = dir.join("fake-claude-sidecar.mjs"); + std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); + let store = dir.join("claude-store"); + std::fs::create_dir_all(&store).expect("create claude store dir"); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var("CLAUDE_CONFIG_DIR", &store); + Self { dir } + } +} +impl Drop for FakeClaudeEnv { + fn drop(&mut self) { + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("FRESHELL_CLAUDE_NODE"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). +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()) +} + +fn test_settings_value() -> serde_json::Value { + serde_json::json!({ + "ai": {}, + "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, + "editor": { "externalEditor": "auto" }, + "extensions": { "disabled": [] }, + "freshAgent": { "defaultPlugins": [], "enabled": true, "providers": {} }, + "logging": { "debug": false }, + "network": { "configured": true, "host": "127.0.0.1" }, + "panes": { "defaultNewPane": "ask" }, + "safety": { "autoKillIdleMinutes": 15 }, + "sidebar": { + "autoGenerateTitles": true, + "excludeFirstChatMustStart": false, + "excludeFirstChatSubstrings": [] + }, + "terminal": { "scrollback": 10000 } + }) +} + +struct Server { + url: String, + // Shared-registry handles, donor-shaped: derivation tests seed state + // through these; the negotiation tests don't need to. + #[allow(dead_code)] + registry: freshell_terminal::TerminalRegistry, + #[allow(dead_code)] + identity: freshell_ws::identity::TerminalIdentityRegistry, + /// The REAL temp-root ledger shared with the server (G3 tests seed it). + pane_ledger: Arc, + /// Clone of `WsState.fresh_agent_respawn_counts` for direct assertions. + respawn_counts: Arc>>, + /// Keeps the ledger's temp root alive for the server's lifetime. + #[allow(dead_code)] + ledger_root: tempfile::TempDir, +} + +/// Real axum server on an ephemeral loopback port, with the scripted +/// disk-truth probe + a REAL temp-root pane ledger injected via the pub +/// `WsState` fields. Returns handles to the SHARED registries/ledger/counter +/// so tests can seed and assert deterministically (the §9.1 headless +/// convention). +async fn spawn_server_with_probe(probe: Arc) -> Server { + let auth_token = Arc::new(AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = + Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); + let registry = freshell_terminal::TerminalRegistry::new(); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + let ledger_root = tempfile::tempdir().expect("ledger temp root"); + let pane_ledger = Arc::new(PaneLedger::new_locked(Some( + ledger_root.path().to_path_buf(), + ))); + let respawn_counts: Arc>> = Arc::default(); + + let state = WsState { + pane_ledger: Arc::clone(&pane_ledger), + identity: identity.clone(), + 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), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": true } }), + ), + 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::new()), + 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: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + config_fallback: None, + amplifier_locator: 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: Arc::clone(&respawn_counts), + }; + + let router = freshell_ws::router(state); + 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; + }); + + Server { + url: format!("ws://{addr}/ws"), + registry, + identity, + pane_ledger, + respawn_counts, + ledger_root, + } +} + +async fn spawn_server() -> Server { + spawn_server_with_probe(Arc::new(StubProbe::default())).await +} + +type TestWs = + tokio_tungstenite::WebSocketStream>; + +/// Connect + hello (negotiating `paneReconcileV1` / `paneReconcileFreshAgentV1` +/// per the flags), consuming the 4-frame handshake. Returns the socket and the +/// parsed `ready` frame. +async fn connect( + url: &str, + pane_reconcile_v1: bool, + fresh_agent_v1: bool, +) -> (TestWs, serde_json::Value) { + let (mut ws, _resp) = tokio_tungstenite::connect_async(url) + .await + .expect("ws connect"); + let mut hello = serde_json::json!({ + "type": "hello", + "token": AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + }); + hello["capabilities"] = serde_json::json!({ + "paneReconcileV1": pane_reconcile_v1, + "paneReconcileFreshAgentV1": fresh_agent_v1, + }); + ws.send(WsMessage::Text(hello.to_string())) + .await + .expect("send hello"); + + let mut ready = serde_json::Value::Null; + for _ in 0..4u8 { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("handshake message within timeout") + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + if value["type"] == serde_json::json!("ready") { + ready = value; + } + } + } + assert!(!ready.is_null(), "handshake must contain ready"); + (ws, ready) +} + +/// Read text frames until one with `type == wanted` arrives (bounded). +async fn next_frame_of_type(ws: &mut TestWs, wanted: &str) -> serde_json::Value { + for _ in 0..30u8 { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for a {wanted} frame")) + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + if value["type"] == serde_json::json!(wanted) { + return value; + } + } + } + panic!("no {wanted} frame within 30 messages"); +} + +/// Send a `pane.reconcile.request` for `panes` and return the result's +/// `verdicts` array. +async fn reconcile_request(ws: &mut TestWs, panes: serde_json::Value) -> serde_json::Value { + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "pane.reconcile.request", + "reconcileId": "rec-fa", + "panes": panes, + }) + .to_string(), + )) + .await + .expect("send reconcile request"); + let result = next_frame_of_type(ws, "pane.reconcile.result").await; + result["verdicts"].clone() +} + +/// Drain frames until one matching `predicate` arrives (or the budget expires). +async fn await_frame( + ws: &mut TestWs, + budget: Duration, + predicate: impl Fn(&Value) -> bool, +) -> Value { + tokio::time::timeout(budget, async { + loop { + let msg = ws + .next() + .await + .expect("stream not ended") + .expect("no ws error"); + let WsMessage::Text(text) = msg else { + continue; + }; + let value: Value = serde_json::from_str(&text).unwrap(); + if predicate(&value) { + return value; + } + } + }) + .await + .expect("expected frame did not arrive within budget") +} + +/// Drive a claude `freshAgent.create` through the fake sidecar and return the +/// durable `cliSessionId` from the `freshAgent.session.init` event. +async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "freshAgent.create", + "requestId": request_id, + "sessionType": "freshclaude", + "provider": "claude", + }) + .to_string(), + )) + .await + .expect("send freshAgent.create"); + let init = await_frame(ws, Duration::from_secs(15), |v| { + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" + }) + .await; + init["event"]["cliSessionId"] + .as_str() + .expect("session.init carries the durable cliSessionId") + .to_string() +} + +// --- negotiation --------------------------------------------------------------- + +#[tokio::test] +async fn ready_echoes_fresh_agent_capability_when_negotiated() { + let server = spawn_server().await; + let (_ws, ready) = connect(&server.url, true, true).await; + assert_eq!( + ready["capabilities"]["paneReconcileFreshAgentV1"], + serde_json::json!(true) + ); +} + +// --- frozen-client protection (permanent regression guard) ---------------------- + +#[tokio::test] +async fn without_the_capability_fresh_agent_kind_stays_invalid_unsupported() { + let server = spawn_server().await; + let (mut ws, _ready) = connect(&server.url, true, false).await; // frozen-client shape + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([{ + "paneKey": "p1", "kind": "fresh-agent", + "sessionRef": {"provider": "claude", "sessionId": "s-1"} + }]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "invalid"); + assert_eq!(verdicts[0]["reason"], "unsupported_kind"); +} + +// --- Task 13: fresh-agent verdict derivation ------------------------------------- + +#[tokio::test] +async fn fresh_agent_verdicts_cover_the_four_states() { + let probe = std::sync::Arc::new(StubProbe::default()); + probe.answers.lock().unwrap().insert( + ("codex".into(), "resumable".into()), + SessionExistence::Present, + ); + probe.answers.lock().unwrap().insert( + ("claude".into(), "deleted".into()), + SessionExistence::Absent, + ); + probe + .observed + .lock() + .unwrap() + .insert(("claude".into(), "deleted".into())); + probe.answers.lock().unwrap().insert( + ("opencode".into(), "never".into()), + SessionExistence::Absent, + ); + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "a", "kind": "fresh-agent", "sessionRef": {"provider": "codex", "sessionId": "resumable"} }, + { "paneKey": "b", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "deleted"} }, + { "paneKey": "c", "kind": "fresh-agent", "sessionRef": {"provider": "opencode", "sessionId": "never"} }, + { "paneKey": "d", "kind": "fresh-agent" }, + { "paneKey": "t", "kind": "terminal", "mode": "shell", "createRequestId": "cr-t" } + ]), + ) + .await; + assert_eq!( + verdicts[0]["verdict"], "respawn", + "killed-server-but-resumable" + ); + assert_eq!(verdicts[0]["sessionRef"]["sessionId"], "resumable"); + assert_eq!(verdicts[1]["verdict"], "dead_session", "transcript deleted"); + assert_eq!(verdicts[1]["reason"], "session_not_on_disk"); + assert_eq!(verdicts[2]["verdict"], "fresh", "never existed"); + assert_eq!(verdicts[2]["reason"], "identity_never_observed"); + assert_eq!(verdicts[3]["verdict"], "fresh"); + assert_eq!(verdicts[3]["reason"], "no_recoverable_identity"); + // Terminal panes in the same request still work (mixed-kind request): + assert_eq!(verdicts[4]["paneKey"], "t"); + assert_eq!( + verdicts[4]["verdict"], "fresh", + "shell terminal answered normally" + ); +} + +#[tokio::test] +async fn live_fresh_agent_session_gets_attach() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeEnv::install(); + + let server = spawn_server().await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + + let cli_session_id = create_live_claude_session(&mut ws, "req-live-attach").await; + assert_eq!(cli_session_id, FAKE_DURABLE_ID); + + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "live", "kind": "fresh-agent", + "sessionRef": {"provider": "claude", "sessionId": cli_session_id} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "attach"); + assert_eq!(verdicts[0]["sessionRef"]["sessionId"], cli_session_id); + assert!(verdicts[0].get("terminalId").is_none()); +} + +/// WAVE-B B1xB4 seam pin (V9 3.6): a mixed request whose TERMINAL pane +/// triggers B1's bounded index-warming deferral re-derives the verdicts ONCE +/// -- and that re-derivation must REUSE the fresh-agent snapshot built at +/// request start, so the fresh-agent respawn counter burns exactly once per +/// request (never once per derivation). +#[tokio::test] +async fn warming_deferral_rederivation_burns_the_respawn_counter_once() { + let probe = std::sync::Arc::new(StubProbe::default()); + probe.answers.lock().unwrap().insert( + ("codex".into(), "fa-once".into()), + SessionExistence::Present, + ); + // The terminal claim "warm-x" stays unset => Unknown => error{index_warming} + // on BOTH derivations, so the deferral + re-derive path definitely runs. + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "t", "kind": "terminal", "mode": "codex", "createRequestId": "cr-warm", + "sessionRef": {"provider": "codex", "sessionId": "warm-x"} }, + { "paneKey": "fa", "kind": "fresh-agent", + "sessionRef": {"provider": "codex", "sessionId": "fa-once"} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "error"); + assert_eq!(verdicts[0]["reason"], "index_warming"); + assert_eq!(verdicts[1]["verdict"], "respawn"); + let counts = server.respawn_counts.lock().expect("counts lock"); + assert_eq!( + counts.get(&("codex".into(), "fa-once".into())).copied(), + Some(1), + "the deferral's re-derivation must not double-burn the respawn counter" + ); +} + +/// WAVE-B B1xB4 seam pin: B1's `ProviderUnavailable` existence answer maps to +/// the B4 pre-decision (V9/A12): presence Unknown => conservative +/// respawn-with-cap -- never dead_session, and never the terminal arm's +/// error{provider_unavailable} shape (fresh-agent liveness does not depend on +/// the disk index being able to warm). +#[tokio::test] +async fn provider_unavailable_existence_maps_to_respawn_with_cap() { + let probe = std::sync::Arc::new(StubProbe::default()); + probe.answers.lock().unwrap().insert( + ("codex".into(), "pu-1".into()), + SessionExistence::ProviderUnavailable, + ); + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "pu", "kind": "fresh-agent", + "sessionRef": {"provider": "codex", "sessionId": "pu-1"} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "respawn"); + assert_eq!(verdicts[0]["sessionRef"]["sessionId"], "pu-1"); + let counts = server.respawn_counts.lock().expect("counts lock"); + assert_eq!( + counts.get(&("codex".into(), "pu-1".into())).copied(), + Some(1), + "the answer counted against the respawn cap" + ); +} + +#[tokio::test] +async fn duplicate_session_claims_dedupe_within_one_request() { + let probe = std::sync::Arc::new(StubProbe::default()); + probe + .answers + .lock() + .unwrap() + .insert(("codex".into(), "t1".into()), SessionExistence::Present); + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "first", "kind": "fresh-agent", "sessionRef": {"provider": "codex", "sessionId": "t1"} }, + { "paneKey": "second", "kind": "fresh-agent", "sessionRef": {"provider": "codex", "sessionId": "t1"} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "respawn"); + assert_eq!(verdicts[1]["verdict"], "fresh"); + assert_eq!(verdicts[1]["reason"], "duplicate_session_claim"); + assert_eq!(verdicts[1]["duplicate"], "first"); +} + +#[tokio::test] +async fn respawn_cap_turns_the_fourth_answer_into_dead_session() { + // The session stays Present-but-never-live, so every answer maps to + // respawn; only RESPAWN ANSWERS burn the cap (V2/A7). + let probe = std::sync::Arc::new(StubProbe::default()); + probe + .answers + .lock() + .unwrap() + .insert(("codex".into(), "cap".into()), SessionExistence::Present); + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + for i in 0..4 { + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "p", "kind": "fresh-agent", "sessionRef": {"provider": "codex", "sessionId": "cap"} } + ]), + ) + .await; + if i < 3 { + assert_eq!(verdicts[0]["verdict"], "respawn", "answer {i}"); + } else { + assert_eq!(verdicts[0]["verdict"], "dead_session"); + assert_eq!(verdicts[0]["reason"], "respawn_exhausted"); + } + } +} + +#[tokio::test] +async fn a_session_resolving_live_clears_the_respawn_counter() { + // Reset-on-live (V2/A7): a successful respawn is OBSERVED as the session + // going live; the counter must clear so healthy sessions are never + // exhausted by reconnect/reload storms. + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeEnv::install(); + + let probe = std::sync::Arc::new(StubProbe::default()); + probe.answers.lock().unwrap().insert( + ("claude".into(), FAKE_DURABLE_ID.into()), + SessionExistence::Present, + ); + let server = spawn_server_with_probe(probe).await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + + // TWO reconcile requests while the session is NOT yet live → both respawn. + for i in 0..2 { + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "p", "kind": "fresh-agent", + "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "respawn", "pre-live answer {i}"); + } + assert_eq!( + server + .respawn_counts + .lock() + .unwrap() + .get(&("claude".to_string(), FAKE_DURABLE_ID.to_string())) + .copied(), + Some(2), + "two respawn answers burned two" + ); + + // Drive freshAgent.create through the fake sidecar so has_live_session + // becomes true for the durable id. + let cli_session_id = create_live_claude_session(&mut ws, "req-reset-on-live").await; + assert_eq!(cli_session_id, FAKE_DURABLE_ID); + + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "p", "kind": "fresh-agent", + "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "attach"); + assert!( + !server + .respawn_counts + .lock() + .unwrap() + .contains_key(&("claude".to_string(), FAKE_DURABLE_ID.to_string())), + "counter cleared (not merely un-incremented) when presence resolved Live" + ); +} + +#[tokio::test] +async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { + // G3 reader rule end-to-end (V8/A14). Seed the REAL temp-root ledger. + let probe = std::sync::Arc::new(StubProbe::default()); + // The old rollout may ALSO still exist on disk — the point is we never + // answer the retired ref. + probe + .answers + .lock() + .unwrap() + .insert(("codex".into(), "new-t".into()), SessionExistence::Present); + let server = spawn_server_with_probe(probe).await; + let now = 1_000; + server + .pane_ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + provider: "codex", + session_id: "old-t", + mode: "freshcodex", + cwd: Some("/w"), + create_request_id: None, + model: Some("m"), + sandbox: None, + permission_mode: None, + effort: None, + supersedes: None, + now_ms: now, + }) + .unwrap(); + server + .pane_ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + provider: "codex", + session_id: "new-t", + mode: "freshcodex", + cwd: Some("/w"), + create_request_id: None, + model: Some("m"), + sandbox: None, + permission_mode: None, + effort: None, + supersedes: Some("old-t"), + now_ms: now + 1, + }) + .unwrap(); + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "p", "kind": "fresh-agent", "sessionRef": {"provider": "codex", "sessionId": "old-t"} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "respawn"); + assert_eq!( + verdicts[0]["sessionRef"]["sessionId"], "new-t", + "answer from the chain terminus" + ); + assert_eq!(verdicts[0]["corrected"], true); +} diff --git a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs index c0a949981..a02b59ff4 100644 --- a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs +++ b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs @@ -177,8 +177,11 @@ async fn spawn_server() -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/crates/freshell-ws/tests/session_ref_singleflight.rs b/crates/freshell-ws/tests/session_ref_singleflight.rs new file mode 100644 index 000000000..d52ea6b4b --- /dev/null +++ b/crates/freshell-ws/tests/session_ref_singleflight.rs @@ -0,0 +1,226 @@ +//! D8 per-sessionRef single-flight wire tests (council rules 6/7/8): on a +//! `paneReconcileV1`-negotiated connection, a `terminal.create` carrying a +//! resume `sessionRef` runs the lease discipline — exactly one PTY per +//! sessionRef; losers get `error{code: SESSION_RESERVED, retryAfterMs}` or +//! attach to the winner's terminal. Frozen (non-negotiated) connections are +//! byte-for-byte unchanged. +//! +//! Harness: `mod common;` + `spawn_server_with_specs(vec![sleeper_cli_spec +//! ("claude")])` — real resume-create→live-PTY round trips (the verified +//! recipe from `claude_restore_unavailable.rs`). SessionIds are +//! canonical-UUID-shaped throughout (the claude restore gate rejects +//! non-UUID pre-spawn, and D8 tests must stay valid under it). + +mod common; + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use common::{sleeper_cli_spec, spawn_server_with_specs, TestWs, AUTH_TOKEN}; + +/// Connect + hello (optionally negotiating `paneReconcileV1`), consuming the +/// 4-frame handshake (same shape as `tests/pane_reconcile.rs::connect`). +async fn connect(url: &str, pane_reconcile_v1: bool) -> TestWs { + let (mut ws, _resp) = tokio_tungstenite::connect_async(url) + .await + .expect("ws connect"); + let mut hello = serde_json::json!({ + "type": "hello", + "token": AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + }); + if pane_reconcile_v1 { + hello["capabilities"] = serde_json::json!({ "paneReconcileV1": true }); + } + ws.send(WsMessage::Text(hello.to_string())) + .await + .expect("send hello"); + for _ in 0..4u8 { + tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("handshake message within timeout") + .expect("stream not ended") + .expect("no ws error"); + } + ws +} + +/// The same `terminal.create` JSON the client sends for a sessionRef resume +/// (field names per `shared/ws-protocol.ts` `TerminalCreateSchema` — it is +/// `.strict()`, so it IS the wire truth). +fn terminal_create_resume(request_id: &str, mode: &str, session_id: &str) -> serde_json::Value { + serde_json::json!({ + "type": "terminal.create", + "requestId": request_id, + "mode": mode, + "shell": "system", + "sessionRef": { "provider": mode, "sessionId": session_id }, + }) +} + +async fn send_json(ws: &mut TestWs, value: serde_json::Value) { + ws.send(WsMessage::Text(value.to_string())) + .await + .expect("send frame"); +} + +/// Read frames until a `terminal.created` or `error` for `request_id` +/// arrives (broadcast frames like `terminals.changed` are skipped). +async fn next_created_or_error(ws: &mut TestWs, request_id: &str) -> serde_json::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 created/error for {request_id}")) + .expect("stream not ended") + .expect("no ws error"); + if let WsMessage::Text(text) = &msg { + let value: serde_json::Value = serde_json::from_str(text).expect("json frame"); + let ty = value["type"].as_str().unwrap_or_default(); + if (ty == "terminal.created" || ty == "error") + && value["requestId"] == serde_json::json!(request_id) + { + return value; + } + } + } + panic!("no terminal.created/error for {request_id} within 40 messages"); +} + +/// Count live PTYs carrying `session_id` via the registry-row join +/// (`identity_probe_rows`): `mode` + `resume_session_id` + Running. +fn live_pty_count_for_session( + registry: &freshell_terminal::TerminalRegistry, + mode: &str, + session_id: &str, +) -> usize { + registry + .identity_probe_rows() + .into_iter() + .filter(|row| { + row.mode == mode + && row.status == freshell_protocol::TerminalRunStatus::Running + && row.resume_session_id.as_deref() == Some(session_id) + }) + .count() +} + +/// The registry's sessionRef→terminalId binding map (recorded at winner bind). +fn registry_binding_for( + registry: &freshell_terminal::TerminalRegistry, + mode: &str, + session_id: &str, +) -> Option { + registry.bound_terminal_for_session_ref(&freshell_protocol::SessionLocator { + provider: mode.to_string(), + session_id: session_id.to_string(), + }) +} + +/// two-clients-same-sessionRef (council red test): two negotiated +/// connections, DIFFERENT createRequestIds, same sessionRef resume -> +/// exactly one PTY; the loser is reserved then attaches to the winner. +#[tokio::test] +async fn two_clients_same_session_ref_yield_exactly_one_pty() { + const SESS_DUP: &str = "11111111-1111-4111-8111-111111111111"; + let (url, registry) = spawn_server_with_specs(vec![sleeper_cli_spec("claude")]).await; + let mut a = connect(&url, true).await; + let mut b = connect(&url, true).await; + send_json(&mut a, terminal_create_resume("cr-A", "claude", SESS_DUP)).await; + send_json(&mut b, terminal_create_resume("cr-B", "claude", SESS_DUP)).await; + + let fa = next_created_or_error(&mut a, "cr-A").await; + let fb = next_created_or_error(&mut b, "cr-B").await; + // Exactly one connection wins the spawn; identify winner and other. + let (created, other, loser_is_b) = if fa["type"] == serde_json::json!("terminal.created") { + (fa, fb, true) + } else { + (fb, fa, false) + }; + assert_eq!( + created["type"], + serde_json::json!("terminal.created"), + "at least one create must win: {created}" + ); + let tid = created["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + + if other["type"] == serde_json::json!("error") { + // Loser path: reserved now, adopts the winner on re-send. + assert_eq!(other["code"], serde_json::json!("SESSION_RESERVED")); + let retry_after = other["retryAfterMs"].as_u64().expect("retryAfterMs"); + assert!(retry_after >= 1); + tokio::time::sleep(Duration::from_millis(retry_after)).await; + let (loser_ws, loser_req) = if loser_is_b { + (&mut b, "cr-B") + } else { + (&mut a, "cr-A") + }; + send_json( + loser_ws, + terminal_create_resume(loser_req, "claude", SESS_DUP), + ) + .await; + let readopt = next_created_or_error(loser_ws, loser_req).await; + assert_eq!( + readopt["type"], + serde_json::json!("terminal.created"), + "re-send after retryAfterMs must adopt the winner: {readopt}" + ); + assert_eq!(readopt["terminalId"], serde_json::json!(tid.clone())); + } else { + // Loser adopted immediately (winner already bound). + assert_eq!(other["terminalId"], serde_json::json!(tid.clone())); + } + + assert_eq!( + live_pty_count_for_session(®istry, "claude", SESS_DUP), + 1, + "exactly one live PTY per sessionRef" + ); + registry.kill_all(); +} + +/// Legacy connections (no capability) never see SESSION_RESERVED — the +/// frozen-client create path is byte-for-byte unchanged. +#[tokio::test] +async fn legacy_connection_create_path_unchanged() { + const SESS_LEGACY: &str = "22222222-2222-4222-8222-222222222222"; + let (url, registry) = spawn_server_with_specs(vec![sleeper_cli_spec("claude")]).await; + let mut legacy = connect(&url, false).await; + send_json( + &mut legacy, + terminal_create_resume("cr-L", "claude", SESS_LEGACY), + ) + .await; + let created = next_created_or_error(&mut legacy, "cr-L").await; + assert_eq!(created["type"], serde_json::json!("terminal.created")); + assert!(created["terminalId"].is_string()); + // The legacy path never touches the lease/binding machinery. + assert_eq!(registry_binding_for(®istry, "claude", SESS_LEGACY), None); + registry.kill_all(); +} + +/// Winner bind populates the REGISTRY binding map: after the winner's +/// `terminal.created`, the binding map answers with that terminalId (the +/// NEW behavior this task adds; the pane-ledger write is pre-existing +/// create-path behavior and not asserted here). +#[tokio::test] +async fn winner_bind_populates_registry_binding() { + const SESS_LED: &str = "33333333-3333-4333-8333-333333333333"; + let (url, registry) = spawn_server_with_specs(vec![sleeper_cli_spec("claude")]).await; + let mut a = connect(&url, true).await; + send_json(&mut a, terminal_create_resume("cr-A", "claude", SESS_LED)).await; + let created = next_created_or_error(&mut a, "cr-A").await; + assert_eq!(created["type"], serde_json::json!("terminal.created")); + let tid = created["terminalId"].as_str().expect("terminalId"); + assert_eq!( + registry_binding_for(®istry, "claude", SESS_LED).as_deref(), + Some(tid), + "the registry binding map must answer for this sessionRef" + ); + registry.kill_all(); +} diff --git a/crates/freshell-ws/tests/term09_output_queue.rs b/crates/freshell-ws/tests/term09_output_queue.rs index 9edcedb52..f6748b3a3 100644 --- a/crates/freshell-ws/tests/term09_output_queue.rs +++ b/crates/freshell-ws/tests/term09_output_queue.rs @@ -84,8 +84,11 @@ async fn spawn_server(term09: Term09Config) -> String { config_fallback: None, amplifier_locator: None, opencode_locator: None, + codex_locator: None, activity: None, session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), }; let router = freshell_ws::router(state); diff --git a/docs/plans/2026-07-26-codex-rollout-locator.md b/docs/plans/2026-07-26-codex-rollout-locator.md new file mode 100644 index 000000000..449e4421e --- /dev/null +++ b/docs/plans/2026-07-26-codex-rollout-locator.md @@ -0,0 +1,2800 @@ +# Codex Server-Side Rollout Identity Locator 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:** Give the Rust server its own authoritative identity source for fresh +codex terminal panes — a server-side locator that detects the new +`rollout--.jsonl` appearing under the codex sessions root, +binds the pane to that thread id (identity registry + terminal meta + durable +pane ledger + activity hub), and retires the client-driven +`terminal.codex.candidate.persisted` channel so exactly one writer owns codex +identity facts (campaign item P1.12, plan §2.3.2). + +**Architecture:** A third sibling locator following the amplifier/opencode +precedent (a provider-parameterized locator was explicitly rejected by spec — +see `crates/freshell-ws/src/lib.rs` doc on `opencode_locator`). Pure detection +lives in `crates/freshell-sessions/src/codex_locator.rs` (sync, `Mutex`-guarded, +injected clock, filesystem-snapshot correlation); a thin async controller lives +in `crates/freshell-ws/src/codex_association.rs` (arm at create, submit seam, +150 ms sweep, resolve → adopt). The adoption tail (identity upsert → registry +meta → ledger resolve → pinned broadcasts → activity bind + rollout attach) is +extracted from `codex_candidate.rs` into a shared `codex_identity.rs` before +the candidate channel is deleted. The wire message stays in the protocol enum +but becomes accept-and-ignore-with-debug-log. + +**Tech Stack:** Rust (freshell-sessions, freshell-ws, freshell-server crates), +serde_json, tokio; Playwright e2e (`test/e2e-browser/`, RustServer harness); +Node .mjs fake-CLI fixture. + +## Global Constraints + +- Base: `origin/main` @ `2dfbba58`. Work only inside the worktree + `/home/dan/code/freshell/.worktrees/codex-rollout-locator`. All paths below + are relative to the worktree root. +- SCOPE FENCE (Lane B2 of a 4-lane wave). You own: + `crates/freshell-ws/src/codex_candidate.rs` (retirement), the new locator + modules, the minimal `crates/freshell-ws/src/terminal.rs` call sites, + freshell-activity codex identity-bind touchpoints, ledger write calls, plus + the unavoidable wiring in `crates/freshell-server/src/main.rs` and + `crates/freshell-ws/src/lib.rs`. Do NOT touch: + `crates/freshell-ws/src/reconcile.rs`, `crates/freshell-ws/src/existence.rs`, + `src/lib/ws-client*`, `src/App.tsx` (Lane B1); `tabs_snapshots.rs` + recovery + UI (Lane B3); ANY file under `crates/freshell-freshagent/` (Lane B4); + opencode files beyond reading. No kimi/gemini work. +- The frozen client is untouched: `src/components/TerminalView.tsx`'s candidate + sender stays; the server accepts-and-ignores its frame with a debug log — + never an error to the client. +- Rust CI gate: `cargo fmt --all --check` and + `cargo clippy --workspace --all-targets -- -D warnings` (toolchain 1.96.0). + `cargo test` is a local-only gate — run it anyway, every task. +- Coordinated TS suites: `env -u FRESHELL_BIND_HOST npm test` waits on the + shared coordinator gate — if another agent holds it, WAIT (3 sibling lanes + run concurrently). Set `FRESHELL_TEST_SUMMARY="lane B2 codex locator"` for + broad runs. Focused runs go through `npm run test:vitest -- ...`. +- E2E: every spec owns its RustServer instances on ephemeral ports + (`findFreePort()`), NEVER 3001/3002. NEVER restart the user's self-hosted + Freshell server. NEVER use broad kill patterns. +- Worktree may need `npm ci` and the tsx symlink: + `ln -s ../node_modules/tsx node_modules/tsx` (only if `npm test` complains). +- ~78 GB disk free — halt and report on any ENOSPC. +- PR POLICY: NOT approved. Push the branch, STOP before `gh pr create`, report + branch + proof. +- Server TS uses NodeNext/ESM (relative imports need `.js`) — no server TS is + modified in this plan; the new fixture is a self-contained `.mjs`. + +## Premise Corrections (verified against the codebase — trust these over the task prose) + +1. **There is no per-terminal isolated CODEX_HOME.** `CODEX_HOME` is a + process-global env read (`codex_sessions_root()`, resolved once at boot, + `crates/freshell-server/src/main.rs:412-417`). All codex terminals of a + server share ONE sessions tree: + `/sessions/YYYY/MM/DD/rollout--.jsonl` + (flat `.jsonl` also supported, used by tests). Pane↔rollout attribution + therefore needs a disambiguator set: known-files snapshot (taken at arm, + re-snapshotted at the first Enter) + + Enter-anchored windows + REQUIRED-cwd match + pending-first-line + bind-blocking + contested-cwd refusal (see Validated Premises below). +2. **"One watcher, two consumers" is not feasible for discovery.** The wave-2 + status watcher (`activity.rs:349`) watches a SINGLE already-known rollout + file, NonRecursive, and only exists once identity is known — chicken-and-egg + for discovery. The locator therefore polls (bounded scan, only while a + terminal is armed — the opencode/amplifier precedent), and at resolve it + hands the discovered path to the EXISTING status watcher via + `attach_codex_rollout` — one file-watcher, both consumers downstream. + The parsers ARE reused (`first_line_owns`'s ownership predicate shape, + 1 MB bounded first-line read). +3. **`bind_terminal_session` does not exist.** The activity-hub bind path is + `ActivityHub::bind_codex_session(terminal_id, session_id)` + (`crates/freshell-ws/src/activity.rs:251`) → + `CodexActivityTracker::bind_session` (`crates/freshell-activity/src/codex.rs:207`). +4. **The candidate channel is production-DORMANT on the Rust server** — its + sole trigger (`terminal.codex.durability.updated`) has no Rust emitter. + Retiring it costs zero Rust production behavior; its only exercisers are + two Rust integration tests that inject the frame over a raw socket. +5. **The two candidate restore-contract-wall pins are NOT codex-shaped**: + `:1679` is claude-based, `:1803` is opencode-based. Do not flip pins + blindly — Task 11 evaluates them by running the wall (Playwright turns an + unexpected PASS into a hard failure, which is the flip signal). +6. **REST-created codex panes (freshell-freshagent) do not arm in this lane.** + Arming the shared locator instance from `terminal_tabs.rs` (the way + opencode does) requires touching `crates/freshell-freshagent/` — explicitly + forbidden by the scope fence (Lane B4 owns those crates). WS-created panes + (the frozen client's only path) are covered. This is a spec-directed + boundary, not a deferral: record it in the final commit message. + +### Validated Premises (load-bearing validation, 2026-07-26 — trust these over any stale task prose) + +Evidence sources: codex source @ tag `rust-v0.145.0` + installed +`codex-cli 0.145.0`; a 3,858-rollout local corpus (100% first-line parse); +frozen-client code inspection. Full reports: +`.worktrees/.the-usual-logs/codex-rollout-locator/reports/validator-*.md`. +A codex upgrade re-opens every codex-behavior item below. + +7. **(A1, FALSIFIED spawn premise) Rollout files are Enter-anchored.** Codex + creates the rollout file ONLY when the first user prompt is recorded + (`RolloutRecorder` defers file creation to `persist()`, materialized via + `ensure_rollout_materialized()`). A rollout can NEVER legitimately appear + for a pane's own codex before Enter — so the locator has NO spawn window: + windows open only on `note_submit`. Arm still snapshots immediately. +8. **(A3, FALSIFIED one-shot read) Create→meta-line gap is real.** After + creating the file codex awaits git-info collection (subprocesses, 5 s + timeout each; typically ms, worst ~10 s) BEFORE writing the session_meta + first line. A new file with an empty/incomplete first line is therefore a + PENDING, BIND-BLOCKING candidate re-probed up to + `PENDING_FIRST_LINE_GRACE_MS = 10_000` — never dropped by a one-shot read, + and never outvoted by a readable foreign file while pending. +9. **(A4, FALSIFIED — misbind defenses insufficient as originally planned.)** + The original snapshot+cwd+same-tick-ambiguity trio allowed clean misbinds + (same-cwd foreign codex/exec/fork, staggered same-cwd panes, the + spawn-window pure-foreign capture). Mandatory hardenings, all encoded in + Tasks 1-4: Enter-only windows (item 7); cwd REQUIRED — a no-cwd + session_meta NEVER binds (`SessionMeta.cwd` is non-optional at 0.145.0, + 3,858/3,858 corpus; a permissive no-cwd rule is pure attack surface); + pending-candidate bind-blocking (item 8); CROSS-TICK contested-cwd + refusal — while ≥2 armed terminals share a normalized cwd nothing binds + for any of them; the adoption tail refuses a thread id already bound + to another terminal (retired-inclusive; idempotent same-terminal re-adopt + allowed); and a FIRST-SUBMIT `known_files` re-snapshot — the first + `note_submit` replaces the arm-time snapshot with a fresh scan, strictly + safe by item 7 (the pane's own rollout cannot exist before its first + Enter), closing the arm→first-Enter foreign-capture gap. The re-snapshot + is sound ONLY if it completes BEFORE the Enter byte is written to the + PTY (codex materializes the rollout in response to that very Enter) — + Task 4's submit seam encodes this ordering; later window re-opens NEVER + re-snapshot (the pane's own slow-materializing rollout must stay a + candidate). +10. **(A4 residual risk — documented, NOT solvable in this lane.)** The + first-submit re-snapshot (item 9) closes the arm→first-Enter capture + gap, but a foreign same-cwd rollout created AFTER the pane's first + Enter can still misbind as a sole candidate: every window from the + first Enter onward diffs against the first-submit snapshot, and there + is deliberately no time bound (item 7's re-open mitigation depends on + that). Realistic same-cwd writer: a freshell-freshagent codex agent + session — freshagent's `codex app-server` sidecar writes rollouts into + the same `$HOME/.codex/sessions` root — combined with a later bare + Enter on the pane (empty composer or a codex dialog; `is_submit_input` + treats any pure CR/LF as a submit). Recommended future fix (Lane B4 + coordination): exclude freshagent-known thread ids (`thread/start` + results) at adoption. Recorded here so the risk is owned, not silent. +11. **(A5) The post-spawn arm site is safe — but only because of item 7.** + The planned `maybe_arm` call site (terminal.rs ~:1521-1527) runs AFTER + the PTY spawn, with awaits in between. Enter-anchored discovery makes + the spawn→arm gap harmless: the pane's own rollout cannot exist yet. Do + not describe the snapshot as preceding spawn — it doesn't need to. +12. **(A6, verified with corrections) Walk performance.** Real tree + (3,858 files): 7-9 ms warm. Synthetic 100k files: p95 96 ms warm, worst + 117 ms under 8-way concurrency (native ext4, WSL2). The earlier + "35-55 ms on an 8k-file tree" figure did NOT reproduce — do not cite it. + Cold cache is unmeasured (needs sudo): run the `arm()`, first-submit + `note_submit()`, and `tick()` + scans inside `spawn_blocking`, never on the WS dispatch path. +13. **(A2/A9, confirmed) cwd + shape + resume argv.** `payload.cwd` is the + codex process's physical `getcwd` path recorded verbatim (no + canonicalization codex-side); equality holds BECAUSE freshell's + `normalize_cwd` opportunistically canonicalizes the pane side — that + canonicalize is load-bearing. `payload.id` == the filename threadId, a + 36-char UUID; `codex resume ` positional is first-class. 0.145.0 + source contains `.jsonl.zst` rollout compression — fresh sessions still + write plain `.jsonl`; the `.jsonl` suffix filter excludes compressed + artifacts. Filename timestamp and `YYYY/MM/DD` dir are precomputed at + session construction — can predate on-disk creation by the whole idle + gap / cross midnight; the locator never filters on either. Resumed + sessions append to their existing file (no new file) — consistent with + the arm gate refusing resume panes. +14. **(A10/A7, confirmed) Env + frozen client.** The PTY child inherits the + server env minus a STRIP_ENV list that touches neither `CODEX_HOME` nor + `HOME`, and codex is exec'd directly (no shell rc) — the shared + sessions-root premise holds. Residual (noted, not fixed): a per-terminal + `envOverrides` containing `CODEX_HOME` would diverge the child's root. + The frozen client applies `terminal.session.associated` + provider-generically (`App.tsx:1004-1017` → panesSlice `sessionRef`) and + its candidate sender is dormant against the Rust server; its conflict + guard drops a push only when the pane already holds a DIFFERENT + sessionRef — impossible for the fresh panes the locator arms. + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `crates/freshell-sessions/src/codex_locator.rs` | Create | Pure detection: arm/disarm/note_submit/tick over a filesystem-snapshot substrate | +| `crates/freshell-sessions/src/lib.rs` | Modify | `pub mod codex_locator;` | +| `crates/freshell-sessions/src/opencode_locator.rs` | Modify (1 line) | `fn normalize_cwd` → `pub(crate) fn` for reuse | +| `crates/freshell-ws/src/codex_identity.rs` | Create | Shared adoption tail `adopt_codex_identity` + `codex_sessions_root` (moved) | +| `crates/freshell-ws/src/codex_association.rs` | Create | Controller: maybe_arm / note_possible_submit / drain_and_associate / sweep | +| `crates/freshell-ws/src/codex_candidate.rs` | Delete (Task 7) | Retired client candidate channel | +| `crates/freshell-ws/src/lib.rs` | Modify | mod decls, `pub use`, `WsState.codex_locator` field | +| `crates/freshell-ws/src/terminal.rs` | Modify | arm-at-create, submit seam, exit disarm, candidate match arm → debug log | +| `crates/freshell-ws/src/invariants.rs` | Modify (docs) | identity-unresolved alarm doc names the codex locator | +| `crates/freshell-server/src/main.rs` | Modify | Locator construction + WsState field + sweep spawn | +| `crates/freshell-ws/tests/common/mod.rs` | Modify (Task 6) | New spawn helper wiring a codex locator + 150 ms sweep (existing helpers have no locator and spawn no sweep) | +| `crates/freshell-ws/tests/codex_locator_activity.rs` | Create | Fresh pane → locator → turn.complete carries sessionId | +| `crates/freshell-ws/tests/codex_candidate_inert.rs` | Create | Candidate frame is accepted, ignored, logged; no identity written | +| `crates/freshell-ws/tests/codex_candidate_persisted.rs` | Delete (Task 7) | Superseded by inert test + locator tests | +| `crates/freshell-ws/tests/codex_candidate_activity.rs` | Delete (Task 7) | Superseded by `codex_locator_activity.rs` | +| `test/e2e-browser/fixtures/fake-codex-terminal.mjs` | Create | Fake codex CLI that writes a real rollout JSONL | +| `test/e2e-browser/specs/codex-terminal-restore-rust.spec.ts` | Create | Fresh codex pane → server-side identity → restart → `resume ` | +| `test/e2e-browser/specs/pane-ledger-restart-rust.spec.ts` | Modify | Codex SIGKILL-inside-window fresh-by-race ledger wall | +| `test/e2e-browser/playwright.config.ts` | Modify | Register the new rust-only spec (two places) | + +Key existing reference files (read, don't modify beyond what tasks say): +`crates/freshell-sessions/src/opencode_locator.rs` (the detection template), +`crates/freshell-ws/src/opencode_association.rs` (the controller template), +`crates/freshell-ws/src/codex_reconcile.rs` (`locate_codex_rollout`, +`first_line_owns`), `crates/freshell-ws/src/pane_ledger.rs` +(`ledger_resolve_identity`), `test/e2e-browser/specs/opencode-terminal-restore-rust.spec.ts` +(the e2e template). + +--- + +### Task 1: CodexLocator skeleton — types, arm gates, scan substrate, Enter-anchored happy path + +**Files:** +- Create: `crates/freshell-sessions/src/codex_locator.rs` +- Modify: `crates/freshell-sessions/src/lib.rs` (add `pub mod codex_locator;` alphabetically among the existing `pub mod` lines) +- Modify: `crates/freshell-sessions/src/opencode_locator.rs` (make `fn normalize_cwd` → `pub(crate) fn normalize_cwd`; it is currently private at ~`:369`) +- Test: in-file `mod tests` in `codex_locator.rs` + +**Interfaces:** +- Consumes: `crate::opencode_locator::normalize_cwd(&str) -> String` +- Produces (later tasks rely on these EXACT signatures): + +```rust +pub const CODEX_WINDOW_MS: i64 = 2_000; +pub const PENDING_FIRST_LINE_GRACE_MS: i64 = 10_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Located { + pub terminal_id: String, + pub thread_id: String, + pub rollout_path: std::path::PathBuf, + pub cwd: String, +} + +pub struct CodexLocator { /* private */ } + +impl CodexLocator { + pub fn new(sessions_root: std::path::PathBuf) -> Self; + pub fn with_config(sessions_root: std::path::PathBuf, window_ms: i64) -> Self; + pub fn armed_count(&self) -> usize; + pub fn fs_scan_count(&self) -> u64; + pub fn arm(&self, terminal_id: &str, mode: &str, status_running: bool, + resume_session_id: Option<&str>, cwd: Option<&str>) -> bool; + pub fn disarm(&self, terminal_id: &str); + pub fn note_submit(&self, terminal_id: &str, at_ms: i64) -> bool; + pub fn tick(&self, now_ms: i64) -> Vec; +} +``` + +Design notes to encode in the module doc (deliberate deviations from the +opencode locator, each with rationale — the codex-behavior facts below are +validated against codex source @ rust-v0.145.0 and a 3,858-rollout local +corpus; see "Validated Premises" above): +- Substrate is a filesystem snapshot-diff, not SQLite row-diff: codex persists + one JSONL rollout file per session under the sessions root. +- There is NO `pre_epsilon_ms` and NO created-at time bound: filesystems have + no reliable cross-platform creation time. The `known_files` snapshot is the + primary safety (same as opencode's `known_ids`): a file already present in + the snapshot can never bind to this terminal. The locator + also never filters by the FILENAME timestamp or by today's `YYYY/MM/DD` + directory: the filename timestamp is precomputed at codex session + construction and can predate on-disk creation by the entire user idle gap, + and the dated dir can be a previous day across midnight — the full-tree + snapshot-diff sidesteps both. +- FIRST-SUBMIT re-snapshot (A4 hardening; fresh-eyes iteration 1): the first + `note_submit` replaces `known_files` with a fresh scan. Strictly safe by + the Enter-anchored premise — the pane's own rollout cannot exist before its + first Enter, so everything that appeared between arm and the first Enter is + foreign by construction (freshagent sidecar, `codex exec`, codex outside + freshell). SOUNDNESS PRECONDITION: the caller must complete the first + `note_submit` BEFORE the Enter byte is written to the PTY — codex + materializes the rollout in response to that very Enter, so a re-snapshot + racing after the write could capture (and permanently exclude) the pane's + own file. Task 4's submit seam encodes this ordering. Later window + re-opens NEVER re-snapshot: the slow-materialization mitigation (a >2 s + Enter→creation latency recovered by a later Enter) depends on the pane's + own late file staying a candidate. +- Windows are ENTER-ANCHORED ONLY — there is NO spawn-anchored window (a + deliberate deviation from opencode). Real codex (0.145.0) defers rollout + file creation until the first user prompt is recorded (`RolloutRecorder` + defers to `persist()`, materialized via `ensure_rollout_materialized()`), + so a rollout can NEVER legitimately appear for this pane's codex before + Enter — a spawn window would be a pure FOREIGN-capture window. `arm()` + still takes the known-files snapshot immediately; it schedules no deadline + until `note_submit`. Deadline is `enter_ms + window_ms`; evaluation happens + once per open window (`resolved` flag), and a later Enter re-opens it. +- cwd is REQUIRED: a first line whose `session_meta.payload` lacks `cwd` is + never a candidate (`SessionMeta.cwd` is non-optional at 0.145.0; + 3,858/3,858 real rollouts carry it — a no-cwd rule would be pure foreign + attack surface). The cwd match relies on `normalize_cwd`'s opportunistic + `fs::canonicalize` of the pane side being load-bearing: `payload.cwd` is + the codex process's physical `getcwd` path, recorded verbatim. +- Pending first-line grace: codex CREATES the file, then awaits git-info + collection (subprocesses, 5 s timeout each, worst ~10 s) BEFORE writing the + `session_meta` line — a deadline scan can see an empty/incomplete first + line. Such a NEW file is a PENDING candidate: re-probed each sweep up to + `PENDING_FIRST_LINE_GRACE_MS`, and while ANY pending candidate exists the + terminal binds NOTHING (bind-blocking — a readable foreign file must not + win while the pane's own file sits in its git-info gap). An Enter→creation + latency exceeding the 2 s window (slow first-prompt recording) is mitigated + by this grace plus window re-open on a later Enter. +- Contested-cwd refusal is CROSS-TICK: while ≥2 armed terminals share a + normalized cwd, no candidate with that cwd binds for any of them — + staggered deadlines must not let one pane grab a sibling's rollout + uncontested. +- Resumed codex sessions append to their EXISTING rollout file (no new file) + — consistent with the arm gate refusing resume panes. Compressed rollout + artifacts (`.jsonl.zst`, present in 0.145.0 source) are excluded by the + `.jsonl` suffix filter; fresh sessions always write plain `.jsonl`. +- Scans happen ONLY at arm time, at the FIRST `note_submit` (the re-snapshot + above), and at deadline evaluations (never on idle + ticks — proven by `fs_scan_count`; a pending candidate keeps its window's + evaluation due, so re-probes ride the same gate), bounded to walk depth 5 + (the tree is `sessions/YYYY/MM/DD/rollout-*.jsonl`; flat `.jsonl` in + tests). Measured (A6, native ext4 under WSL2): 7-9 ms on the real + 3.8k-file tree; synthetic 100k files p95 96 ms warm, worst 117 ms under + 8-way concurrency. Cold-cache is unmeasured — callers run BOTH `arm()` and + `tick()` inside `spawn_blocking`. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/freshell-sessions/src/codex_locator.rs` with ONLY the test +module and a `#![allow(dead_code)]`-free skeleton that does not yet exist — +i.e. write the tests first; the file will not compile until Step 3 adds the +implementation above the tests. In this SAME step, register the module so +cargo actually compiles it: add `pub mod codex_locator;` to +`crates/freshell-sessions/src/lib.rs` (alphabetically among the existing +`pub mod` lines). An unregistered `.rs` file is invisible to cargo — without +this line, Step 2's test filter would exit green with 0 tests instead of the +COMPILE ERROR the RED gate asserts. Test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Same convention as opencode_locator.rs tests: no tempfile crate. + fn unique_temp_dir(label: &str) -> PathBuf { + let n = TEST_DIR_COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "freshell-codex-locator-test-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create test dir"); + dir + } + + /// Write a rollout file whose FIRST line is the session_meta identity + /// record, exactly the shape the real codex CLI writes + /// (payload.id = identity; payload.cwd = the session's working dir). + fn write_rollout(root: &Path, rel_dir: &str, thread_id: &str, cwd: Option<&str>) -> PathBuf { + let dir = root.join(rel_dir); + std::fs::create_dir_all(&dir).expect("create rollout dir"); + let file = dir.join(format!("rollout-2026-07-26T08-00-00-{thread_id}.jsonl")); + let payload = match cwd { + Some(c) => format!(r#"{{"id":"{thread_id}","cwd":"{c}"}}"#), + None => format!(r#"{{"id":"{thread_id}"}}"#), + }; + let line = format!( + r#"{{"timestamp":"2026-07-26T08:00:00.000Z","type":"session_meta","payload":{payload}}}"# + ); + std::fs::write(&file, format!("{line}\n")).expect("write rollout"); + file + } + + const TID: &str = "11111111-2222-3333-4444-555555555555"; + + #[test] + fn fresh_rollout_after_first_enter_resolves_via_enter_window() { + let root = unique_temp_dir("enter-happy"); + let cwd = root.join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let cwd_s = cwd.to_string_lossy().to_string(); + let locator = CodexLocator::new(root.clone()); + + assert!(locator.arm("t1", "codex", true, None, Some(&cwd_s))); + // No submit yet -> no deadline exists; nothing to evaluate. + assert!(locator.tick(10_000).is_empty()); + // Enter at 20_000; the rollout appears AFTER the submit (real codex + // materializes the file only when the first user prompt is recorded). + assert!(locator.note_submit("t1", 20_000)); + let path = write_rollout(&root, "2026/07/26", TID, Some(&cwd_s)); + + // Before the Enter-anchored deadline: nothing yet. + assert!(locator.tick(20_000 + CODEX_WINDOW_MS - 1).is_empty()); + let located = locator.tick(20_000 + CODEX_WINDOW_MS); + assert_eq!( + located, + vec![Located { + terminal_id: "t1".into(), + thread_id: TID.into(), + rollout_path: path, + cwd: crate::opencode_locator::normalize_cwd(&cwd_s), + }] + ); + // Success fully resolves and disarms; tick() drains. + assert_eq!(locator.armed_count(), 0); + assert!(locator.tick(20_000 + CODEX_WINDOW_MS + 1).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_after_arm_without_submit_is_never_bound_and_never_scanned() { + // A1 (validated): real codex creates the rollout ONLY at the first + // user prompt, so before Enter every new same-cwd rollout is by + // construction FOREIGN. With no submit there is NO window: the file + // must never bind and no deadline scans may run. + let root = unique_temp_dir("no-submit"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.fs_scan_count(), 1); // the arm snapshot + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert!(locator.tick(100 * CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + assert_eq!(locator.fs_scan_count(), 1); // still only the arm snapshot + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_created_between_arm_and_first_enter_never_binds() { + // A4 hardening (first-submit re-snapshot): Premise 7 guarantees the + // pane's own rollout cannot exist before its first Enter, so EVERY + // file that appears between arm and the first submit is foreign by + // construction (freshagent sidecar, `codex exec`, codex outside + // freshell in the same cwd). The FIRST note_submit re-snapshots + // known_files, so a bare Enter (empty composer, trust dialog) can + // never hand the window to that foreign file as a sole candidate. + let root = unique_temp_dir("resnapshot"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + // Foreign rollout lands AFTER arm but BEFORE the first Enter. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.note_submit("t1", 1_000)); // first submit re-snapshots + assert_eq!(locator.fs_scan_count(), 2); // arm + first-submit scans + assert!(locator.tick(1_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); // zero candidates → keep watching + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn arm_admission_gates() { + let root = unique_temp_dir("gates"); + let locator = CodexLocator::new(root.clone()); + // wrong mode + assert!(!locator.arm("t1", "opencode", true, None, Some("/tmp"))); + // not running + assert!(!locator.arm("t1", "codex", false, None, Some("/tmp"))); + // resume id present — the ONLY already-bound gate (no restore flag) + assert!(!locator.arm("t1", "codex", true, Some(TID), Some("/tmp"))); + // missing / empty cwd + assert!(!locator.arm("t1", "codex", true, None, None)); + assert!(!locator.arm("t1", "codex", true, None, Some(""))); + // happy arm, then idempotent re-arm returns false + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(!locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn disarmed_terminal_never_resolves() { + let root = unique_temp_dir("disarm"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + locator.disarm("t1"); + assert!(locator.tick(CODEX_WINDOW_MS + 1).is_empty()); + assert_eq!(locator.armed_count(), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn tick_while_unarmed_performs_zero_fs_scans() { + let root = unique_temp_dir("idle"); + let locator = CodexLocator::new(root.clone()); + // Construction must not scan eagerly either. + assert_eq!(locator.fs_scan_count(), 0); + assert!(locator.tick(10_000).is_empty()); + assert_eq!(locator.fs_scan_count(), 0); + // Arming scans once (the known-files snapshot)… + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert_eq!(locator.fs_scan_count(), 1); + // …and a tick BEFORE any Enter-anchored deadline is due (here: no + // submit at all, so no deadline exists) still scans nothing. + let before = locator.fs_scan_count(); + assert!(locator.tick(1).is_empty()); + assert_eq!(locator.fs_scan_count(), before); + let _ = std::fs::remove_dir_all(&root); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +cd /home/dan/code/freshell/.worktrees/codex-rollout-locator +cargo test -p freshell-sessions codex_locator 2>&1 | tail -20 +``` +Expected: COMPILE ERROR — `CodexLocator` / `Located` / `CODEX_WINDOW_MS` not +found (the module exists with only tests). A compile failure of the new test +module is the RED state for a new module. + +- [ ] **Step 3: Write the implementation (above the test module, same file)** + +```rust +//! Server-side codex terminal-pane session locator (Lane B2, campaign §2.3.2). +//! +//! Third sibling of `amplifier_locator` / `opencode_locator` (a +//! provider-parameterized locator was explicitly rejected — the substrates +//! share zero code). Substrate: codex persists ONE JSONL rollout file per +//! session under a process-global sessions root +//! (`/sessions/YYYY/MM/DD/rollout--.jsonl`, +//! flat `.jsonl` in tests). A new session is a NEW FILE — so the locator +//! does a snapshot-diff of the file set, not a row-diff. +//! +//! Codex-behavior facts below are validated against codex source @ +//! rust-v0.145.0 and a 3,858-rollout corpus; a codex upgrade re-opens them. +//! +//! Deliberate deviations from the opencode locator, with rationale: +//! - Windows are ENTER-ANCHORED ONLY — no spawn window. Real codex defers +//! rollout file creation until the first user prompt is recorded +//! (`RolloutRecorder` defers to `persist()`, materialized via +//! `ensure_rollout_materialized()`), so before the pane's first Enter every +//! new same-cwd rollout is by construction FOREIGN. `arm()` takes the +//! known-files snapshot immediately but schedules NO deadline until +//! `note_submit`. +//! - NO `pre_epsilon_ms` and NO created-at time bound: filesystems have no +//! reliable cross-platform creation time (mtime moves on every append). +//! The `known_files` snapshot is the primary safety — a file +//! already present in the snapshot can never bind to this terminal. The FILENAME +//! timestamp and the dated `YYYY/MM/DD` dir are never used as filters +//! either: both are precomputed at codex session construction and can +//! predate on-disk creation by the entire user idle gap (the dir can even +//! be "yesterday" across midnight). The full-tree snapshot-diff sidesteps +//! both. +//! - FIRST-SUBMIT re-snapshot (A4 hardening): the first `note_submit` +//! replaces `known_files` with a fresh scan — strictly safe because the +//! pane's own rollout cannot exist before its first Enter, so everything +//! that appeared between arm and the first Enter is foreign by +//! construction. SOUNDNESS PRECONDITION: the caller completes the first +//! `note_submit` BEFORE the Enter byte is written to the PTY (codex +//! materializes the rollout in response to that very Enter) — the +//! `codex_association` submit seam encodes this ordering. Later window +//! re-opens NEVER re-snapshot: a >2 s Enter→creation latency is recovered +//! by a later Enter only if the pane's own late file stays a candidate. +//! - Attribution disambiguator: the rollout's own first-line +//! `session_meta.payload.cwd` is REQUIRED and must match the armed +//! terminal's cwd (`SessionMeta.cwd` is non-optional at 0.145.0; +//! 3,858/3,858 real rollouts carry it — accepting a no-cwd line would be +//! pure foreign attack surface). `payload.cwd` is the codex process's +//! physical `getcwd` path recorded verbatim; equality holds because +//! `normalize_cwd` opportunistically canonicalizes the pane side — that +//! canonicalize is load-bearing for symlinked spawn dirs. +//! - Pending first-line grace: codex CREATES the file, then awaits git-info +//! collection (subprocesses, 5 s timeout each, worst ~10 s) BEFORE writing +//! the `session_meta` line. A NEW file whose first line is empty/incomplete +//! is a PENDING candidate: re-probed each sweep up to +//! `PENDING_FIRST_LINE_GRACE_MS`, and while ANY pending candidate exists +//! this terminal binds NOTHING (bind-blocking — a readable foreign file +//! must not win while the pane's own file sits in its git-info gap). +//! Enter→creation latency beyond the 2 s window is mitigated by this grace +//! plus window re-open on a later Enter. +//! - Contested-cwd refusal is CROSS-TICK: while ≥2 armed terminals share a +//! normalized cwd, no candidate with that cwd binds for any of them. +//! - Ownership is proven ONLY by `payload.id` on line 1 — NEVER the filename +//! (prefilter-grade at best), NEVER `payload.session_id` (fork/resume +//! LINEAGE: matches a FOREIGN session in 54/144 sampled real rollouts) — +//! same predicate as `freshell-ws`'s `first_line_owns`. +//! - Resumed codex sessions append to their EXISTING rollout file (no new +//! file) — consistent with the arm gate refusing resume panes. Compressed +//! artifacts (`.jsonl.zst`, present in 0.145.0 source) fail the `.jsonl` +//! suffix filter; fresh sessions always write plain `.jsonl`. +//! +//! Zero cost when idle: scans happen only at arm, at the FIRST `note_submit` +//! (the re-snapshot), and at due Enter-anchored +//! evaluations (a pending candidate keeps its evaluation due, so re-probes +//! ride the same gate), proven by `fs_scan_count`. Callers run `arm()`, +//! `note_submit()`, and `tick()` inside `tokio::task::spawn_blocking` (cold +//! dentry cache is the one unmeasured tail — A6). + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use crate::opencode_locator::normalize_cwd; + +/// Correlation window after a submit (Enter-anchored deadline). There is NO +/// spawn-anchored window: real codex (0.145.0) creates the rollout file only +/// when the first user prompt is recorded, so before the pane's first Enter +/// every new same-cwd rollout is by construction foreign. +pub const CODEX_WINDOW_MS: i64 = 2_000; + +/// Bounded re-probe grace for a NEW file whose first line is not yet +/// readable: codex creates the file, then awaits git-info collection +/// (subprocesses, 5 s timeout each, worst ~10 s) before writing the +/// `session_meta` line. Matches codex's worst case and the magnitude of the +/// existing `IDENTITY_RESOLUTION_GRACE_MS`. +pub const PENDING_FIRST_LINE_GRACE_MS: i64 = 10_000; + +/// Bounded first-line read cap — real rollouts reach 152 MB; observed real +/// first lines are ≤ 22.4 KB. Mirrors `codex_reconcile.rs`. +const MAX_FIRST_LINE_BYTES: u64 = 1024 * 1024; + +/// Bounded walk depth — `sessions/YYYY/MM/DD/` is depth 3; 5 mirrors +/// `locate_codex_rollout`. +const MAX_WALK_DEPTH: u8 = 5; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Located { + pub terminal_id: String, + pub thread_id: String, + pub rollout_path: PathBuf, + pub cwd: String, +} + +#[derive(Debug, Clone)] +struct Armed { + cwd_normalized: String, + known_files: HashSet, + enter_ms: Option, + resolved: bool, + /// NEW files whose first line was empty/incomplete when probed, keyed to + /// first-seen ms. While any un-expired entry exists, this terminal binds + /// NOTHING (bind-blocking); entries older than + /// `PENDING_FIRST_LINE_GRACE_MS` are merged into `known_files` + /// (permanently excluded — fail toward refusal). + pending_first_line: HashMap, +} + +#[derive(Default)] +struct Inner { + armed: HashMap, +} + +pub struct CodexLocator { + sessions_root: PathBuf, + window_ms: i64, + inner: Mutex, + fs_scan_count: AtomicU64, +} + +impl CodexLocator { + pub fn new(sessions_root: PathBuf) -> Self { + Self::with_config(sessions_root, CODEX_WINDOW_MS) + } + + pub fn with_config(sessions_root: PathBuf, window_ms: i64) -> Self { + Self { + sessions_root, + window_ms, + inner: Mutex::new(Inner::default()), + fs_scan_count: AtomicU64::new(0), + } + } + + pub fn armed_count(&self) -> usize { + self.inner.lock().unwrap().armed.len() + } + + pub fn fs_scan_count(&self) -> u64 { + self.fs_scan_count.load(Ordering::SeqCst) + } + + /// Admission rules (mirrors `OpencodeLocator::arm`): codex mode, running, + /// NO resume id (the only already-bound gate — never a restore flag, so + /// restore-created identity-less panes re-arm for free), non-empty cwd, + /// not already armed. On success takes the arm-time known-files snapshot. + /// Arming schedules NO deadline — windows open only on `note_submit` + /// (Enter-anchored; see module doc). + pub fn arm( + &self, + terminal_id: &str, + mode: &str, + status_running: bool, + resume_session_id: Option<&str>, + cwd: Option<&str>, + ) -> bool { + if mode != "codex" || !status_running || resume_session_id.is_some() { + return false; + } + let Some(cwd) = cwd.filter(|c| !c.is_empty()) else { + return false; + }; + let mut inner = self.inner.lock().unwrap(); + if inner.armed.contains_key(terminal_id) { + return false; + } + let known_files = self.scan_rollout_files(); + inner.armed.insert( + terminal_id.to_string(), + Armed { + cwd_normalized: normalize_cwd(cwd), + known_files, + enter_ms: None, + resolved: false, + pending_first_line: HashMap::new(), + }, + ); + true + } + + pub fn disarm(&self, terminal_id: &str) { + self.inner.lock().unwrap().armed.remove(terminal_id); + } + + /// The FIRST submit is what opens a window at all (windows are + /// Enter-anchored — arm schedules no deadline), and it RE-SNAPSHOTS + /// `known_files` (see module doc: strictly safe because the pane's own + /// rollout cannot exist before its first Enter; the caller must complete + /// this call BEFORE the Enter byte reaches the PTY, and must run it on + /// the blocking pool — the re-snapshot walks the sessions tree). + /// Re-open semantics mirror + /// opencode: a mid-turn Enter never re-opens a still-pending evaluation; + /// a resolved (zero-candidate / ambiguous / contested) terminal gets a + /// fresh Enter-anchored deadline. Re-opens NEVER re-snapshot. + pub fn note_submit(&self, terminal_id: &str, at_ms: i64) -> bool { + let mut inner = self.inner.lock().unwrap(); + let Some(armed) = inner.armed.get_mut(terminal_id) else { + return false; + }; + if !armed.resolved && armed.enter_ms.is_some() { + return false; + } + if armed.enter_ms.is_none() { + // FIRST submit: everything that appeared between arm and this + // Enter is foreign by construction (A1/A4) — replace the + // snapshot. Holding the lock across the scan is deliberate and + // bounded (warm walks are 7-9 ms — A6; callers are on the + // blocking pool); it also keeps the re-snapshot atomic with the + // window open. + armed.known_files = self.scan_rollout_files(); + } + armed.enter_ms = Some(at_ms); + armed.resolved = false; + true + } + + /// A terminal is due only when an Enter-anchored deadline exists and has + /// passed. No submit -> no window -> never evaluated (see module doc). + fn due(&self, armed: &Armed, now_ms: i64) -> bool { + matches!(armed.enter_ms, Some(enter_ms) if !armed.resolved && now_ms >= enter_ms + self.window_ms) + } + + /// Evaluation at (or after) an Enter-anchored deadline. Outcomes: + /// any NEW file with an empty/incomplete first line (codex's + /// create→session_meta git-info gap) → PENDING: bind NOTHING for this + /// terminal, stay unresolved, re-probe each sweep up to + /// `PENDING_FIRST_LINE_GRACE_MS` (grace-expired files are permanently + /// excluded); + /// 0 candidates → keep watching (stays armed, `resolved = true`); + /// >1 candidates for one terminal → WARN + refuse (never guess); + /// exactly one candidate but ≥2 ARMED terminals share this cwd → WARN + + /// refuse (contested cwd — cross-tick, so staggered deadlines can't grab + /// a sibling's rollout uncontested); + /// one candidate claimed by ≥2 terminals in the same tick → WARN + refuse + /// ALL claimants (defense-in-depth behind the cwd census); + /// exactly one clean match → emit `Located` and disarm. `tick()` drains. + pub fn tick(&self, now_ms: i64) -> Vec { + { + let inner = self.inner.lock().unwrap(); + if inner.armed.is_empty() { + return Vec::new(); + } + if !inner.armed.values().any(|a| self.due(a, now_ms)) { + return Vec::new(); + } + } + let current = self.scan_rollout_files(); + let mut inner = self.inner.lock().unwrap(); + + // Cross-tick contested-cwd census over ALL armed terminals (not just + // the due ones): two armed same-cwd panes are indistinguishable on + // this substrate, whatever their deadlines. + let mut cwd_counts: HashMap = HashMap::new(); + for a in inner.armed.values() { + *cwd_counts.entry(a.cwd_normalized.clone()).or_insert(0) += 1; + } + + // Pass 1: per-terminal candidate evaluation. + let mut claims: Vec<(String, Located)> = Vec::new(); + for (terminal_id, armed) in inner.armed.iter_mut() { + if !matches!(armed.enter_ms, Some(e) if !armed.resolved && now_ms >= e + self.window_ms) + { + continue; + } + let new_paths: Vec = + current.difference(&armed.known_files).cloned().collect(); + let mut matches: Vec<(PathBuf, RolloutMeta)> = Vec::new(); + let mut pending_blocking = false; + for path in new_paths { + match probe_rollout(&path) { + Probe::Candidate(meta) => { + armed.pending_first_line.remove(&path); + if normalize_cwd(&meta.cwd) == armed.cwd_normalized { + matches.push((path, meta)); + } + } + Probe::NotYet => { + let first_seen = + *armed.pending_first_line.entry(path.clone()).or_insert(now_ms); + if now_ms - first_seen >= PENDING_FIRST_LINE_GRACE_MS { + // Grace exhausted: permanently excluded (fail + // toward refusal — A4 hardening 1). + armed.pending_first_line.remove(&path); + armed.known_files.insert(path); + } else { + pending_blocking = true; + } + } + Probe::Never => {} + } + } + if pending_blocking { + // A new file is still inside codex's create→session_meta gap + // (git-info collection, worst ~10 s). It may be THIS pane's + // rollout — binding any other candidate now could hand the + // window to a foreign file while the true owner is unreadable. + // Bind nothing, stay unresolved, re-probe next sweep. + tracing::debug!( + terminal_id = %terminal_id, + "codex_locator_pending: new rollout first line not yet readable; deferring evaluation" + ); + continue; + } + armed.resolved = true; + match matches.len() { + 0 => {} // keep watching + 1 => { + if cwd_counts.get(&armed.cwd_normalized).copied().unwrap_or(0) >= 2 { + tracing::warn!( + terminal_id = %terminal_id, + "codex_locator_contested_cwd: >=2 armed terminals share this cwd; refusing to bind" + ); + } else { + let (path, meta) = matches.remove(0); + claims.push(( + terminal_id.clone(), + Located { + terminal_id: terminal_id.clone(), + thread_id: meta.thread_id, + rollout_path: path, + cwd: armed.cwd_normalized.clone(), + }, + )); + } + } + n => { + tracing::warn!( + terminal_id = %terminal_id, + candidates = n, + "codex_locator_ambiguous: multiple new rollouts in one window; refusing to bind" + ); + } + } + } + + // Pass 2: same-tick cross-terminal conflict — the same rollout (or + // thread id) claimed by two armed terminals in one tick is + // unattributable. (Defense-in-depth: the contested-cwd census above + // already refuses same-cwd claimants across ticks.) + let mut located = Vec::new(); + for (terminal_id, candidate) in &claims { + let contested = claims.iter().any(|(other_tid, other)| { + other_tid != terminal_id + && (other.rollout_path == candidate.rollout_path + || other.thread_id == candidate.thread_id) + }); + if contested { + tracing::warn!( + terminal_id = %terminal_id, + thread_id = %candidate.thread_id, + "codex_locator_contested: rollout claimed by multiple armed terminals; refusing to bind" + ); + continue; + } + located.push(candidate.clone()); + } + for l in &located { + inner.armed.remove(&l.terminal_id); + } + located + } + + fn scan_rollout_files(&self) -> HashSet { + self.fs_scan_count.fetch_add(1, Ordering::SeqCst); + fn walk(dir: &Path, depth: u8, out: &mut HashSet) { + if depth > MAX_WALK_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; // missing/corrupt root tolerated, never a panic + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, depth + 1, out); + } else if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".jsonl")) + .unwrap_or(false) + { + out.insert(path); + } + } + } + let mut out = HashSet::new(); + walk(&self.sessions_root, 0, &mut out); + out + } +} + +struct RolloutMeta { + thread_id: String, + /// REQUIRED — `SessionMeta.cwd` is non-optional at codex 0.145.0 + /// (3,858/3,858 real rollouts carry it); a no-cwd first line is a + /// foreign shape, never a candidate (A4 hardening). + cwd: String, +} + +/// Tri-state probe result. The distinction between `NotYet` and `Never` is +/// load-bearing: codex writes the whole session_meta line + '\n' in one +/// write-then-flush, so a COMPLETE (newline-terminated) line that fails the +/// candidate shape will never become one, while an empty file or a line +/// without its trailing newline is codex's create→meta gap (or a raced +/// write) — "not yet", not "never" (A3, validated). +enum Probe { + /// Parseable `session_meta` with a bare-UUID `payload.id` AND a + /// `payload.cwd` — a real candidate shape. + Candidate(RolloutMeta), + /// Empty file, transient open/read failure, or first line still missing + /// its trailing newline — re-probe within the pending grace. + NotYet, + /// Complete first line that is not a codex session_meta candidate + /// (non-JSON, wrong type, non-UUID id, missing cwd, oversized) — never + /// a candidate; the locator stays silent on foreign files. + Never, +} + +/// Identity probe: bounded first-line read (see `Probe` for the tri-state +/// semantics). +fn probe_rollout(path: &Path) -> Probe { + use std::io::BufRead; + let Ok(file) = std::fs::File::open(path) else { + return Probe::NotYet; + }; + let mut reader = std::io::BufReader::new(file).take(MAX_FIRST_LINE_BYTES); + let mut first_line = Vec::new(); + if reader.read_until(b'\n', &mut first_line).is_err() { + return Probe::NotYet; + } + if first_line.len() as u64 >= MAX_FIRST_LINE_BYTES && !first_line.ends_with(b"\n") { + return Probe::Never; // oversized: will never fit the cap + } + if first_line.is_empty() || !first_line.ends_with(b"\n") { + return Probe::NotYet; // create→meta gap, or a raced partial write + } + let Ok(record) = serde_json::from_slice::(&first_line) else { + return Probe::Never; + }; + if record.get("type").and_then(|v| v.as_str()) != Some("session_meta") { + return Probe::Never; + } + let Some(thread_id) = record.pointer("/payload/id").and_then(|v| v.as_str()) else { + return Probe::Never; + }; + if !is_uuid_shaped(thread_id) { + return Probe::Never; + } + let Some(cwd) = record.pointer("/payload/cwd").and_then(|v| v.as_str()) else { + return Probe::Never; // cwd REQUIRED (A4 hardening) + }; + Probe::Candidate(RolloutMeta { + thread_id: thread_id.to_string(), + cwd: cwd.to_string(), + }) +} + +/// Bare hyphenated 36-char UUID shape gate (deliberate small duplicate of +/// `freshell-ws`'s predicate — this crate sits below it in the dep graph). +fn is_uuid_shaped(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (i, b) in bytes.iter().enumerate() { + let is_hyphen_pos = matches!(i, 8 | 13 | 18 | 23); + if is_hyphen_pos { + if *b != b'-' { + return false; + } + } else if !b.is_ascii_hexdigit() { + return false; + } + } + true +} +``` + +(`pub mod codex_locator;` was already added to +`crates/freshell-sessions/src/lib.rs` in Step 1.) +Also in `crates/freshell-sessions/src/opencode_locator.rs` change +`fn normalize_cwd(input: &str) -> String` to +`pub(crate) fn normalize_cwd(input: &str) -> String`. + +Check whether `tracing` is already a dependency of `freshell-sessions` +(`grep tracing crates/freshell-sessions/Cargo.toml`); if not, add +`tracing = { workspace = true }` (matching how other crates declare it). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p freshell-sessions codex_locator` +Expected: PASS (6 tests). + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-sessions --all-targets -- -D warnings +git add crates/freshell-sessions/src/codex_locator.rs crates/freshell-sessions/src/lib.rs crates/freshell-sessions/src/opencode_locator.rs crates/freshell-sessions/Cargo.toml +git commit -m "feat(sessions): CodexLocator skeleton - arm gates, fs-snapshot substrate, Enter-anchored resolve with pending-first-line grace" +``` +(Omit `Cargo.toml` from the add list if it was not modified.) + +--- + +### Task 2: CodexLocator window semantics and correlation guards + +**Files:** +- Modify: `crates/freshell-sessions/src/codex_locator.rs` (test module only — the Task 1 implementation is expected to already satisfy these; any test that fails RED-for-real reveals an implementation gap to fix minimally) +- Test: same file, `mod tests` + +**Interfaces:** +- Consumes: Task 1's full `CodexLocator` API and test helpers (`unique_temp_dir`, `write_rollout`, `TID`). +- Produces: no new API — behavioral pins later tasks and reviewers rely on. + +- [ ] **Step 1: Write the tests (append to `mod tests`)** + +```rust + const TID2: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + #[test] + fn rollout_present_at_arm_is_never_a_candidate() { + let root = unique_temp_dir("snapshot"); + // File exists BEFORE arm — the known-files snapshot must exclude it + // forever, regardless of any timing. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 1_000)); + assert!(locator.tick(1_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); // zero candidates → keep watching + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn foreign_cwd_rollout_is_never_a_candidate() { + let root = unique_temp_dir("cwd"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/home/me/project-a"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/home/me/project-b")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollout_without_cwd_field_never_binds() { + // cwd is REQUIRED (A4 hardening): `SessionMeta.cwd` is non-optional + // at codex 0.145.0 and 3,858/3,858 + 500/500 real rollouts carry it. + // A no-cwd first line is a foreign shape — accepting it would be + // pure attack surface (a location-blind universal candidate). + let root = unique_temp_dir("no-cwd"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, None); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn two_new_rollouts_in_one_window_refuse_to_bind() { + let root = unique_temp_dir("ambiguous"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // Refusal marks the evaluation resolved but stays armed… + assert_eq!(locator.armed_count(), 1); + // …and a later Enter re-opens a fresh window (both files are now + // still absent from known_files, so still ambiguous — proves the + // refusal is repeatable, never a guess). + assert!(locator.note_submit("t1", CODEX_WINDOW_MS + 100)); + assert!(locator.tick(CODEX_WINDOW_MS + 100 + CODEX_WINDOW_MS).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn same_rollout_claimed_by_two_armed_terminals_refuses_both() { + let root = unique_temp_dir("contested"); + let locator = CodexLocator::new(root.clone()); + // Two panes, SAME cwd, armed concurrently, submitting in the same + // tick; ONE new rollout. The contested-cwd census refuses both + // (Pass 2's same-tick claim check remains as defense-in-depth). + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.arm("t2", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.note_submit("t2", 0)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 2); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn staggered_same_cwd_armed_terminals_never_bind_uncontested() { + // Cross-tick contested guard (A4 hardening): ambiguity refusal must + // not be same-tick-only. While ≥2 ARMED terminals share a normalized + // cwd, NO candidate with that cwd binds for any of them — staggered + // deadlines must not let pane A grab pane B's rollout uncontested. + let root = unique_temp_dir("staggered"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.arm("t2", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.note_submit("t2", 5_000)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + // t1's deadline fires alone: contested cwd → refuse. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // t2's deadline: still contested → refuse. + assert!(locator.tick(5_000 + CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 2); + // One pane exits (disarm); the survivor re-opens with a fresh Enter + // and may now bind — re-evaluation is legitimate once uncontested. + locator.disarm("t2"); + assert!(locator.note_submit("t1", 10_000)); + let located = locator.tick(10_000 + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].terminal_id, "t1"); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn zero_candidate_window_keeps_watching_and_later_enter_reopens() { + let root = unique_temp_dir("reopen"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + // Window closes with zero candidates → keep watching (stays armed). + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // A later Enter re-opens; the rollout appears; resolves via the new + // Enter-anchored window. + let enter_at = 10 * CODEX_WINDOW_MS; + assert!(locator.note_submit("t1", enter_at)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(enter_at + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn later_enter_reopen_keeps_the_first_submit_snapshot() { + // Slow materialization (>2 s Enter→creation) is recovered by a later + // Enter ONLY if re-opens never re-snapshot: the pane's own late + // rollout appears between the first window's close and the second + // Enter, and must STAY a candidate. Only the FIRST submit + // re-snapshots (pinned via fs_scan_count). + let root = unique_temp_dir("reopen-snapshot"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); // first submit: re-snapshot + // First window closes empty. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // The pane's own rollout lands LATE — after the window, before the + // next Enter. + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let scans_before = locator.fs_scan_count(); + assert!(locator.note_submit("t1", 10_000)); // re-open: NO re-snapshot + assert_eq!(locator.fs_scan_count(), scans_before); + let located = locator.tick(10_000 + CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn mid_turn_enter_never_reopens_a_pending_evaluation() { + let root = unique_temp_dir("midturn"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 100)); + // Second Enter while the first evaluation is still pending: no-op. + assert!(!locator.note_submit("t1", 200)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn non_session_meta_or_malformed_first_line_is_never_a_candidate() { + // COMPLETE (newline-terminated) garbage lines are `Probe::Never` — + // not pending: codex writes the whole meta line + '\n' in one + // write-then-flush, so a complete non-candidate line never becomes + // one. (Empty/torn lines are the pending case — see the tests below.) + let root = unique_temp_dir("badmeta"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")), + format!("{{\"type\":\"event_msg\",\"payload\":{{\"id\":\"{TID}\"}}}}\n"), + ) + .unwrap(); + std::fs::write( + dir.join(format!("rollout-2026-07-26T08-00-01-{TID2}.jsonl")), + "not json at all\n", + ) + .unwrap(); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn empty_first_line_file_is_pending_and_binds_once_meta_lands() { + // A3 (validated): codex CREATES the rollout file, then awaits + // git-info collection (subprocesses, 5 s timeout each, worst ~10 s) + // BEFORE writing the session_meta first line. A deadline scan can + // observe the empty file — it must be a re-probed PENDING candidate, + // never dropped by a one-shot read. + let root = unique_temp_dir("pending"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&file, "").unwrap(); // created, meta not yet written + // Deadline scan: pending candidate → bind NOTHING, stay unresolved. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // Meta line lands (well within grace); the next sweep binds it. + // (write_rollout reuses the same filename — same ts, same TID.) + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(CODEX_WINDOW_MS + 300); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID); + assert_eq!(locator.armed_count(), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn readable_candidate_never_binds_while_another_new_file_is_pending() { + // A4 (validated, CRITICAL): the pane's OWN rollout can sit + // unreadable in the git-info gap while a FOREIGN same-cwd rollout is + // already readable. Pending candidates are BIND-BLOCKING — the + // readable file must not win the window. + let root = unique_temp_dir("pending-block"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + // Pane's own file: created, first line not yet written. + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&own, "").unwrap(); + // Foreign file: fully readable, same cwd. + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + // Deadline: NOTHING binds while the pending file exists. + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + assert_eq!(locator.armed_count(), 1); + // Own meta line lands → TWO candidates → ambiguity refusal (fail + // toward refusal, never a guess). + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + assert!(locator.tick(CODEX_WINDOW_MS + 300).is_empty()); + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn pending_file_that_never_parses_expires_after_grace() { + // Grace is bounded (A4 hardening 1): once PENDING_FIRST_LINE_GRACE_MS + // elapses without a readable first line, the file is permanently + // excluded and stops blocking; a surviving sole candidate may then + // bind. + let root = unique_temp_dir("pending-expiry"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + let dir = root.join("2026/07/26"); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join(format!("rollout-2026-07-26T08-00-00-{TID}.jsonl")); + std::fs::write(&own, "").unwrap(); // never gains a first line + write_rollout(&root, "2026/07/26", TID2, Some("/tmp")); + // First due scan sees the pending file (grace clock starts here). + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); + // Still blocked just before grace expiry… + assert!(locator + .tick(CODEX_WINDOW_MS + PENDING_FIRST_LINE_GRACE_MS - 1) + .is_empty()); + // …then the never-parsed file expires and the sole survivor binds. + let located = locator.tick(CODEX_WINDOW_MS + PENDING_FIRST_LINE_GRACE_MS); + assert_eq!(located.len(), 1); + assert_eq!(located[0].thread_id, TID2); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn missing_sessions_root_is_tolerated_and_resolves_once_it_appears() { + let base = unique_temp_dir("missing-root"); + let root = base.join("does-not-exist-yet"); + let locator = CodexLocator::new(root.clone()); + // arm() scans the missing root — tolerated, never a panic. + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + assert!(locator.tick(CODEX_WINDOW_MS).is_empty()); // no panic, keep watching + assert_eq!(locator.armed_count(), 1); + assert!(locator.note_submit("t1", 2 * CODEX_WINDOW_MS)); + write_rollout(&root, "2026/07/26", TID, Some("/tmp")); + let located = locator.tick(3 * CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn flat_test_shape_rollout_resolves() { + // locate_codex_rollout supports flat `.jsonl`; the locator's walk + // must too (integration fixtures seed this shape). + let root = unique_temp_dir("flat"); + let locator = CodexLocator::new(root.clone()); + assert!(locator.arm("t1", "codex", true, None, Some("/tmp"))); + assert!(locator.note_submit("t1", 0)); + write_rollout(&root, ".", TID, Some("/tmp")); + let located = locator.tick(CODEX_WINDOW_MS); + assert_eq!(located.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } +``` + +- [ ] **Step 2: Run the new tests, observe each result honestly** + +Run: `cargo test -p freshell-sessions codex_locator` +Expected: most pass against Task 1's implementation (these are pins of +designed behavior). Any failure is a real implementation gap — fix the +implementation minimally (never weaken a test) and note which test caught it +in the commit message. + +- [ ] **Step 3: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-sessions --all-targets -- -D warnings +git add crates/freshell-sessions/src/codex_locator.rs +git commit -m "test(sessions): pin CodexLocator window semantics and correlation guards" +``` + +--- + +### Task 3: Extract the shared adoption tail into `codex_identity.rs` + +**Files:** +- Create: `crates/freshell-ws/src/codex_identity.rs` +- Modify: `crates/freshell-ws/src/codex_candidate.rs` (handler delegates its adoption tail; `codex_sessions_root` moves out) +- Modify: `crates/freshell-ws/src/lib.rs` (add `pub(crate) mod codex_identity;` after the `codex_candidate` line at `:26`; change `:42` to `pub use codex_identity::codex_sessions_root;`) +- Test: existing `crates/freshell-ws/tests/codex_candidate_persisted.rs` and `codex_candidate_activity.rs` are the refactor guard + +**Interfaces:** +- Consumes: `crate::pane_ledger::ledger_resolve_identity` (`pane_ledger.rs:699`), + `state.identity.upsert` / `state.identity.find_by_session_including_retired` + (`identity.rs:65`, `:176-188`), `state.registry.set_meta`, + `ActivityHub::bind_codex_session` / `attach_codex_rollout` (`activity.rs:251`, `:271`), + the existing broadcast frames (`TerminalSessionAssociated`, `TerminalMetaUpdated`). +- Produces (Tasks 5-7 rely on these EXACT signatures): + +```rust +// crates/freshell-ws/src/codex_identity.rs +pub fn codex_sessions_root() -> Option; // moved verbatim + +pub(crate) struct CodexAdoption<'a> { + pub terminal_id: &'a str, + pub thread_id: &'a str, + pub rollout_path: Option<&'a std::path::Path>, + pub cwd: Option<&'a str>, +} + +/// Bind a codex identity into every home, in the load-bearing order. +/// Returns false (and adopts nothing) when the session is already bound to a +/// DIFFERENT terminal — retired-INCLUSIVE (ledger A8), preserving the +/// cross-pane hijack defense the candidate channel had. +pub(crate) async fn adopt_codex_identity(state: &crate::WsState, a: CodexAdoption<'_>) -> bool; +``` + +- [ ] **Step 1: Establish the green refactor baseline** + +Run: +```bash +cargo test -p freshell-ws --test codex_candidate_persisted --test codex_candidate_activity -- --nocapture 2>&1 | tail -5 +``` +Expected: PASS (these integration tests are the refactor's safety net). +If not green on the base, STOP and report — do not refactor on red. + +- [ ] **Step 2: Create `codex_identity.rs` by MOVING code (not copying)** + +Move from `codex_candidate.rs` into the new module: +1. `pub fn codex_sessions_root()` (`codex_candidate.rs:102-114`) — verbatim, + including its doc comment. +2. The private `fn broadcast_terminal_session_associated(...)` + (`codex_candidate.rs:260-296`) — verbatim, including the PINNED-ORDER doc + comment (`terminal.session.associated` FIRST, `terminal.meta.updated` + SECOND; the integration tests await them in exactly this order). +3. The adoption tail (`codex_candidate.rs:204-250`) — reshaped into: + +```rust +pub(crate) struct CodexAdoption<'a> { + pub terminal_id: &'a str, + pub thread_id: &'a str, + pub rollout_path: Option<&'a std::path::Path>, + pub cwd: Option<&'a str>, +} + +pub(crate) async fn adopt_codex_identity(state: &crate::WsState, a: CodexAdoption<'_>) -> bool { + // Cross-pane hijack / replay defense, retired-INCLUSIVE (ledger A8): a + // victim's binding retires at exit, so a live-only lookup would allow + // replaying a DEAD pane's identity onto a fresh terminal. Copied from + // candidate guard 3b (codex_candidate.rs:167-186) — keep the exact + // comparison semantics that code uses today; re-adopting the SAME + // terminal is an idempotent allow. This guard is also a REQUIRED A4 + // misbind hardening for the locator path: the adoption tail must refuse + // a thread id already bound to another terminal (Validated Premise 9), + // so it must never be weakened when the candidate channel is deleted. + if let Some(existing) = state + .identity + .find_by_session_including_retired("codex", a.thread_id) + { + if existing != a.terminal_id { + tracing::warn!( + terminal_id = %a.terminal_id, + thread_id = %a.thread_id, + "codex_adopt_rejected: session_bound_elsewhere" + ); + return false; + } + } + // Both identity homes — different consumers (see opencode_association.rs:135-148). + state.identity.upsert(a.terminal_id, Some("codex"), Some(a.thread_id), a.cwd, now_ms()); + state.registry.set_meta( + a.terminal_id, + None, + None, + Some("codex".to_string()), + Some(a.thread_id.to_string()), + ); + // Durable ledger: binding row FIRST, pending marker delete SECOND — + // awaited before the broadcast (fsync-before-announce). + crate::pane_ledger::ledger_resolve_identity(state, a.terminal_id, "codex", a.thread_id, a.cwd) + .await; + broadcast_terminal_session_associated(state, a.terminal_id, a.thread_id, a.cwd.map(str::to_string)); + // Activity hub (channel-deferred, safe off the dispatch path): G3 — + // codex.activity.updated / terminal.turn.complete carry the sessionId; + // G9 — the rollout reconcile lane gets its file. + if let Some(hub) = &state.activity { + hub.bind_codex_session(a.terminal_id, a.thread_id); + if let Some(path) = a.rollout_path { + hub.attach_codex_rollout(a.terminal_id, a.thread_id, path); + } + } + true +} +``` + +IMPORTANT adaptation notes for the mover: +- `find_by_session_including_retired`'s exact return type and comparison are + whatever `codex_candidate.rs:167-186` does today — transplant that code, do + not re-derive it. Same for the `now_ms()` helper import and the exact + `identity.upsert` / `set_meta` argument forms at `codex_candidate.rs:204-218`. +- `attach_codex_rollout` at the old site passed the CLIENT's path; here the + path is server-discovered. The hub signature takes `&Path` — pass it through. + +4. Rewrite `handle_codex_candidate_persisted` so that after guards 0-3a it + calls `adopt_codex_identity(state, CodexAdoption { terminal_id: &msg.terminal_id, thread_id, rollout_path: Some(Path::new(&msg.rollout_path)), cwd: row.cwd.as_deref() }).await` + — deleting the now-duplicated guard 3b block and the old inline tail. + Guard 4 (`verify_rollout_path`) STAYS in the handler for now (client paths + are untrusted; the locator never uses it) — it is deleted with the whole + file in Task 7. +5. Update `lib.rs` as listed above. `main.rs` continues to compile unchanged + because the `pub use ...::codex_sessions_root` re-export path is stable. + +- [ ] **Step 3: Verify the refactor is behavior-preserving** + +Run: +```bash +cargo test -p freshell-ws --test codex_candidate_persisted --test codex_candidate_activity +cargo test -p freshell-ws +``` +Expected: PASS, same tests as Step 1. (The `session_bound_elsewhere` reject +now logs target `codex_identity` instead of `codex_candidate` — the +integration test asserts silence-to-client, not log text, so it stays green.) + +- [ ] **Step 4: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-ws --all-targets -- -D warnings +git add crates/freshell-ws/src/codex_identity.rs crates/freshell-ws/src/codex_candidate.rs crates/freshell-ws/src/lib.rs +git commit -m "refactor(ws): extract adopt_codex_identity shared adoption tail from the candidate channel" +``` + +--- + +### Task 4: Controller module + wiring (WsState, main.rs, terminal.rs call sites) + +**Files:** +- Create: `crates/freshell-ws/src/codex_association.rs` +- Modify: `crates/freshell-ws/src/lib.rs` (mod decl + `WsState.codex_locator` field) +- Modify: `crates/freshell-ws/src/terminal.rs` (arm at create, submit seam, exit disarm) +- Modify: `crates/freshell-server/src/main.rs` (construct + field + sweep) +- Test: in-file `mod tests` in `codex_association.rs` + +**Interfaces:** +- Consumes: `freshell_sessions::codex_locator::CodexLocator` (Task 1), + `crate::codex_identity::{codex_sessions_root, adopt_codex_identity, CodexAdoption}` (Task 3). +- Produces (Task 5 relies on these EXACT signatures): + +```rust +// crates/freshell-ws/src/codex_association.rs +pub(crate) fn is_submit_input(data: &str) -> bool; +pub(crate) fn maybe_arm(state: &WsState, terminal_id: &str, mode: &str, + cwd: Option<&str>, resume_session_id: Option<&str>); +// async, unlike the opencode sibling: the FIRST submit re-snapshots +// known_files on the blocking pool, and the caller must await completion +// BEFORE writing the Enter to the PTY (see the submit seam below). +pub(crate) async fn note_possible_submit(state: &WsState, terminal_id: &str, data: &str); +pub(crate) async fn drain_and_associate(state: &WsState); +pub fn spawn_codex_locator_sweep(state: WsState, interval: std::time::Duration); +``` +plus the new state field: +```rust +// crates/freshell-ws/src/lib.rs (next to opencode_locator at ~:242) +pub codex_locator: Option>, +``` + +- [ ] **Step 1: Write the failing controller unit tests** + +Create `codex_association.rs` containing (initially) only the test module, +and in this SAME step register it so cargo actually compiles it: add +`pub mod codex_association;` to `crates/freshell-ws/src/lib.rs` (next to +`opencode_association` at `:36`-ish, alphabetical). An unregistered `.rs` +file is invisible to cargo — without this line, Step 2's test filter would +exit green with 0 tests instead of the COMPILE ERROR the RED gate asserts. +Mirror `opencode_association.rs`'s test harness EXACTLY: copy its +`state_with_locator(...)` helper (`opencode_association.rs:227+` builds a full +literal `WsState` with `pane_ledger: PaneLedger::disabled()`), adapting the +locator field to `codex_locator: Some(Arc::new(CodexLocator::new(data_home)))` +(and `opencode_locator: None`). Tests: + +```rust + #[test] + fn is_submit_input_matches_enter_only_sequences() { + for yes in ["\r", "\n", "\r\n", "\r\r\n\n"] { + assert!(is_submit_input(yes), "{yes:?} should be a submit"); + } + for no in ["", "hello", "hello\r\n", "\u{1b}[A"] { + assert!(!is_submit_input(no), "{no:?} should not be a submit"); + } + } + + #[test] + fn maybe_arm_arms_a_fresh_codex_terminal_and_ignores_others() { + let dir = unique_temp_dir("assoc-arm"); + let (state, _rx) = state_with_locator(dir.clone()); + let locator = state.codex_locator.as_ref().unwrap().clone(); + maybe_arm(&state, "t1", "opencode", Some("/tmp"), None); // wrong mode + assert_eq!(locator.armed_count(), 0); + maybe_arm(&state, "t1", "codex", Some("/tmp"), Some("resume-id")); // resuming + assert_eq!(locator.armed_count(), 0); + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); // fresh + assert_eq!(locator.armed_count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn note_possible_submit_feeds_only_enter_sequences() { + let dir = unique_temp_dir("assoc-submit"); + let (state, _rx) = state_with_locator(dir.clone()); + let locator = state.codex_locator.as_ref().unwrap().clone(); + maybe_arm(&state, "t1", "codex", Some("/tmp"), None); + note_possible_submit(&state, "t1", "hello").await; + // Observable proof via the locator's own seam: "hello" must not have + // consumed the window — a direct note_submit still returns true. + assert!(locator.note_submit("t1", freshell_sessions::time::now_ms())); + let _ = std::fs::remove_dir_all(&dir); + } +``` + +(If `opencode_association.rs`'s tests source `now_ms` differently, copy that +exact form — the harness copy is authoritative over this sketch.) + +- [ ] **Step 2: Run to verify RED** + +Run: `cargo test -p freshell-ws codex_association` +Expected: COMPILE ERROR — the module's functions (`is_submit_input`, +`maybe_arm`, `note_possible_submit`, …) do not exist yet, and the test +harness's `codex_locator:` field is not yet declared on `WsState`. Both +error families are the RED for this task. + +- [ ] **Step 3: Implement the controller + wiring** + +`codex_association.rs` implementation — transplant `opencode_association.rs` +(`:38-225`) with these substitutions; the drain body defers adoption to +`adopt_codex_identity`: + +```rust +//! Codex terminal-pane association controller (Lane B2): arm the +//! CodexLocator at create, feed Enter submits, and on resolution adopt the +//! identity through the shared `codex_identity::adopt_codex_identity` tail. +//! Structure mirrors `opencode_association.rs` — deliberately (spec §5-shape +//! duplication over a premature provider-generic controller). One deliberate +//! deviation: the locator's windows are ENTER-ANCHORED ONLY (no spawn +//! window) — real codex materializes its rollout only at the first user +//! prompt, so `maybe_arm` only takes the snapshot and `note_possible_submit` +//! is what opens a correlation window (async: the FIRST submit re-snapshots +//! `known_files` on the blocking pool and MUST complete before the Enter is +//! written to the PTY — see the terminal.rs seam). + +use crate::WsState; + +/// Deliberate one-line duplicate of `opencode_association::is_submit_input`. +pub(crate) fn is_submit_input(data: &str) -> bool { + !data.is_empty() && data.chars().all(|c| c == '\r' || c == '\n') +} + +pub(crate) fn maybe_arm( + state: &WsState, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + resume_session_id: Option<&str>, +) { + if mode != "codex" { + return; + } + if let Some(locator) = &state.codex_locator { + locator.arm(terminal_id, mode, true, resume_session_id, cwd); + } +} + +/// Async, unlike the opencode sibling: the FIRST `note_submit` re-snapshots +/// `known_files` (a bounded sessions-tree walk), so it runs on the blocking +/// pool, and the CALLER MUST AWAIT this BEFORE writing the Enter to the PTY +/// — codex materializes the rollout in response to that very Enter, and a +/// re-snapshot racing after the write could capture (permanently exclude) +/// the pane's own file. Non-submit data returns immediately; later submits +/// are a cheap mutex hop. +pub(crate) async fn note_possible_submit(state: &WsState, terminal_id: &str, data: &str) { + if !is_submit_input(data) { + return; + } + let Some(locator) = state.codex_locator.clone() else { + return; + }; + let terminal_id = terminal_id.to_string(); + let at_ms = now_ms(); + if let Err(err) = + tokio::task::spawn_blocking(move || locator.note_submit(&terminal_id, at_ms)).await + { + tracing::warn!(error = %err, "codex_note_submit_panicked"); + } +} + +pub(crate) async fn drain_and_associate(state: &WsState) { + let Some(locator) = state.codex_locator.clone() else { + return; + }; + // The tick does bounded filesystem walks + first-line reads — never on + // an async worker (same discipline as the opencode sweep). + let located = match tokio::task::spawn_blocking(move || locator.tick(now_ms())).await { + Ok(located) => located, + Err(err) => { + tracing::warn!(error = %err, "codex_locator_tick_panicked; skipping cycle"); + return; + } + }; + for hit in located { + // Defense-in-depth rejects against registry truth (mirrors + // opencode_association.rs:105-133). + let Some(entry) = state.registry.directory().into_iter().find(|e| e.id == hit.terminal_id) else { + tracing::warn!(terminal_id = %hit.terminal_id, "codex_association_rejected: terminal_missing"); + continue; + }; + if entry.mode.as_deref() != Some("codex") + || entry.status != freshell_terminal::TerminalRunStatus::Running + { + tracing::warn!(terminal_id = %hit.terminal_id, "codex_association_rejected: terminal_not_codex_or_not_running"); + continue; + } + if entry.resume_session_id.is_some() { + tracing::warn!(terminal_id = %hit.terminal_id, "codex_association_rejected: terminal_already_bound"); + continue; + } + crate::codex_identity::adopt_codex_identity( + state, + crate::codex_identity::CodexAdoption { + terminal_id: &hit.terminal_id, + thread_id: &hit.thread_id, + rollout_path: Some(&hit.rollout_path), + cwd: entry.cwd.as_deref(), + }, + ) + .await; + } +} + +pub fn spawn_codex_locator_sweep(state: WsState, interval: std::time::Duration) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + drain_and_associate(&state).await; + } + }); +} +``` + +CRITICAL adaptation instruction: the registry access pattern, the exact +`directory()` entry field names/types, and the `now_ms()` import in the sketch +above MUST be transplanted from `opencode_association.rs:86-169` — that file +is the compile-truth for those forms. Where the sketch and the transplant +disagree, the transplant wins. + +Wiring edits: + +1. `crates/freshell-ws/src/lib.rs`: + - (`pub mod codex_association;` was already added in Step 1.) + - Add the `codex_locator` field to `WsState` next to `opencode_locator` + (~`:242`), doc comment mirroring opencode's shape but Enter-anchored + ("correlates a fresh codex PTY's first Enter with the new rollout JSONL + codex writes under the sessions root — real codex materializes the file + only at the first user prompt — so the terminal can be bound and + `terminal.rs`'s generic resume derivation can drive `codex resume ` + on restart"). + - The compiler will enumerate every literal `WsState` constructor that now + misses the field (association tests, other in-crate tests, the + `tests/common/mod.rs` spawn helpers, main.rs). + Add `codex_locator: None,` everywhere except the sites Tasks 4-6 say + otherwise (Task 6 adds ONE locator-wired spawn helper to + `tests/common/mod.rs`; that file's existing helpers stay `None`). +2. `crates/freshell-server/src/main.rs`: + - Next to the opencode locator construction (`:340-345` area): + ```rust + // Lane B2 (campaign §2.3.2): server-side codex identity locator. Same + // sessions root the resume-time rollout locator below walks. `None` + // when HOME/CODEX_HOME are unresolvable — every codex_association + // entry point no-ops in that case. + let codex_locator = freshell_ws::codex_sessions_root().map(|root| { + std::sync::Arc::new(freshell_sessions::codex_locator::CodexLocator::new(root)) + }); + ``` + - Add `codex_locator: codex_locator.clone(),` to the `WsState` literal. + - Next to the opencode sweep spawn (`:634-640`): + ```rust + // Lane B2: codex locator sweep — same cadence as the sibling sweeps. + if codex_locator.is_some() { + freshell_ws::codex_association::spawn_codex_locator_sweep( + ws_state.clone(), + AMPLIFIER_LOCATOR_SWEEP_INTERVAL, + ); + } + ``` +3. `crates/freshell-ws/src/terminal.rs`: + - Arm at create — immediately after the opencode `maybe_arm` (`:1518-1527`): + ```rust + // Lane B2: arm the codex rollout locator for a FRESH (non-resuming) + // codex pane. Restore-created panes WITHOUT identity arm too — arm() + // gates on resume_session_id, never a restore flag (the wave-A re-arm + // contract). + // + // ORDERING NOTE (A5, validated): this arm runs AFTER the PTY spawn + // (registry.create + adopt() have already awaited above). That is safe + // ONLY because windows are Enter-anchored: real codex materializes its + // rollout at the first user prompt, never in the spawn→arm gap, so the + // snapshot cannot miss the pane's own file. Do not reinterpret this as + // "snapshot precedes spawn" — it does not need to. + // + // The arm() snapshot walks the sessions tree; run it on the blocking + // pool, not the WS dispatch path (A6: warm walks are 7-9 ms today and + // ~100 ms at 100k files, but cold dentry cache after a reboot is the + // one unmeasured tail). + { + let state = state.clone(); + let terminal_id = terminal_id.clone(); + let mode = mode.clone(); + let cwd = resolved_cwd.clone(); + let resume = resume_session_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + crate::codex_association::maybe_arm( + &state, + &terminal_id, + &mode, + cwd.as_deref(), + resume.as_deref(), + ); + }) + .await; + } + ``` + (Adapt the exact clone set to the local variable types at the call site + — the compile-truth is the surrounding function; the requirement is that + `maybe_arm` executes on the blocking pool and completes before the + create handler returns.) + - Submit seam — ORDERING IS LOAD-BEARING, do NOT copy the opencode seam + placement. In the `TerminalInput` match arm of `handle_client_text` + (`terminal.rs:526-548`), the PTY write + (`state.registry.input(&input.terminal_id, input.data.as_bytes())`, + `:527-529`) is currently the FIRST statement, and the amplifier/opencode + `note_possible_submit` calls come after it. The codex call must go + BEFORE the `registry.input(...)` line and be awaited: + ```rust + // Lane B2: MUST complete before the Enter reaches the PTY — the codex + // locator's FIRST-submit re-snapshot has to finish before codex can + // materialize the rollout this very Enter triggers (else the pane's + // own file could land in the snapshot and be permanently excluded). + // Sound here: this socket task processes frames sequentially, and the + // enclosing handler is already async. Non-submit data returns + // immediately; only the first Enter of an armed codex pane scans + // (7-9 ms warm — A6). + crate::codex_association::note_possible_submit(state, &input.terminal_id, &input.data).await; + ``` + Leave the amplifier/opencode seams where they are (they are in-memory + and order-insensitive). + - Exit disarm — in the `ExitHook` closure (`:1354`, `:1377-1379` pattern): + clone `let codex_locator = state.codex_locator.clone();` next to the + opencode clone, and inside the closure after the opencode disarm add + `if let Some(locator) = &codex_locator { locator.disarm(&tid); }`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +cargo test -p freshell-ws codex_association +cargo test -p freshell-ws +cargo test -p freshell-server 2>&1 | tail -3 +``` +Expected: PASS (new tests + no regressions across the two crates). + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add crates/freshell-ws/src/codex_association.rs crates/freshell-ws/src/lib.rs crates/freshell-ws/src/terminal.rs crates/freshell-server/src/main.rs +git commit -m "feat(ws): codex locator controller + arm/submit/disarm seams + 150ms sweep wiring" +``` +(Include any other files the WsState-field compile sweep touched.) + +--- + +### Task 5: drain_and_associate end-to-end behavior (identity, ledger, broadcasts, re-arm, hijack) + +**Files:** +- Modify: `crates/freshell-ws/src/codex_association.rs` (test module) +- Test: same file + +**Interfaces:** +- Consumes: everything from Tasks 1-4; `PaneLedger::new(Some(dir))`, + `state.pane_ledger` (`pane_ledger.rs`), broadcast receiver from the harness. +- Produces: behavioral pins (no new API). + +- [ ] **Step 1: Write the failing tests** + +Copy the two `#[tokio::test]` patterns from `opencode_association.rs:434` and +`:530` (they spawn a REAL shell PTY via `freshell_platform::build_spawn_spec` ++ `registry.create`, then poll `drain_and_associate` up to 40× / 100 ms) and +adapt to codex. Also copy `state_with_locator_and_ledger(data_home, ledger_dir)` +(`opencode_association.rs` harness) so the ledger assertions run against a +REAL `PaneLedger::new(Some(dir))`. The codex adaptations, per test: + +```rust + /// Fresh codex pane: rollout appears after arm → sweep binds identity, + /// writes the durable binding row, and broadcasts both frames in the + /// pinned order. + #[tokio::test] + async fn drain_and_associate_binds_identity_ledger_and_broadcasts() { + // Harness: state_with_locator_and_ledger; register a real PTY as + // terminal "t1" with mode "codex" and cwd exactly the way the + // opencode sibling test does; maybe_arm(&state, "t1", "codex", + // Some(&cwd), None). + // + // Then OPEN THE WINDOW FIRST: + // note_possible_submit(&state, "t1", "\r").await — windows are + // Enter-anchored (no spawn window), so resolution requires a submit, + // and the FIRST submit re-snapshots known_files, so the rollout MUST + // be seeded AFTER this call (a pre-seeded file would be captured by + // the re-snapshot and never bind — that exclusion is the Task 1/2 + // hardening, not a bug). + // + // THEN write the rollout the locator must find: + // write a file /2026/07/26/rollout-2026-07-26T08-00-00-.jsonl + // whose first line is {"timestamp":"...","type":"session_meta", + // "payload":{"id":"","cwd":""}} + // (reuse the write_rollout shape from codex_locator.rs tests inline; + // the file lands well inside the 2 s window, and the 40×100 ms drain + // poll comfortably covers the Enter-anchored deadline). + // + // Poll drain_and_associate until resolution, then assert: + // state.identity.session_ref_for("t1") == Some(codex/) (provider + id) + // registry directory entry for t1: resume_session_id == Some() + // ledger.lookup_by_session("codex", ).unwrap().row.live_terminal_id == Some("t1") + // ledger.pending_for_terminal("t1").is_none() + // broadcast rx yields terminal.session.associated THEN terminal.meta.updated + // (frame type assertions exactly as the opencode sibling does them) + } + + /// Wave-A re-arm contract (the codex mirror of P1.10): a restore-created + /// pane WITHOUT identity (resume None) arms like a fresh pane, records a + /// pending marker, and resolves into the ledger — binding row first, + /// marker gone after. + #[tokio::test] + async fn restore_created_pane_without_identity_arms_and_resolves_into_the_ledger() { + // Mirror opencode_association.rs:530 verbatim, swapping: mode "codex", + // the rollout-file seed above instead of the sqlite row, and provider + // "codex" in the ledger lookups. Record the pending marker via + // state.pane_ledger.record_pending("t1", "codex", Some(cwd), now) + // before resolution, exactly as the sibling does. As in the first + // test, call note_possible_submit(&state, "t1", "\r").await BEFORE + // seeding the rollout — Enter-anchored windows need the submit, and + // the first-submit re-snapshot would exclude a pre-seeded file. + } + + /// One-writer defense survives the channel swap: a session already bound + /// to ANOTHER terminal (including a retired binding) is never re-adopted. + #[tokio::test] + async fn located_session_bound_elsewhere_is_rejected() { + // Arrange: state_with_locator; upsert identity for terminal "victim" + // with ("codex", ) via state.identity.upsert(...), then retire it + // via state.identity.retire("victim") (the exit-path call — + // terminal.rs:1370 area shows the exact form). + // Register a real PTY "t1" (codex mode), arm it, and keep a locator + // handle (let locator = state.codex_locator.as_ref().unwrap().clone()). + // Then note_possible_submit(&state, "t1", "\r").await to open the + // Enter-anchored window, THEN seed the rollout for (after the + // submit — the first-submit re-snapshot would exclude a pre-seeded + // file) with payload.cwd set to THE PANE'S OWN cwd, exactly as in the + // first test — the rollout must be a fully resolvable candidate, or + // this test proves nothing. Poll drain_and_associate past the window. + // + // Assert BOTH halves. The positive resolution signal comes FIRST so + // the negative assertions cannot pass vacuously — a locator that + // never resolved (wrong cwd, bad seed timing) also leaves identity + // None, and identity-only assertions cannot tell those worlds apart: + // locator.armed_count() == 0 + // // tick emitted Located and disarmed: resolution HAPPENED, so + // // whatever follows is the adoption-tail guard's doing + // state.identity.session_ref_for("t1") is None // guard refused + // registry entry for t1: resume_session_id is None + } +``` + +Write these as REAL tests (full bodies) by transplanting the opencode sibling +bodies — the comments above are the complete adaptation delta, and the +opencode file is the compile-truth for harness forms. Do not invent new +harness helpers; copy. + +- [ ] **Step 2: Run to verify RED-for-the-right-reason** + +Run: `cargo test -p freshell-ws codex_association -- --nocapture` +Expected: the first two tests FAIL only if Tasks 1-4 left a real gap — +otherwise they PASS immediately, which for TRANSPLANTED pins of +already-implemented behavior is acceptable ONLY after you verify each test +can fail: temporarily invert one core assertion per test (e.g. assert the +identity is `None`), watch it fail, restore it. Record "verified-red by +inversion" in the commit message. The third test (bound-elsewhere) exercises +Task 3's new guard end-to-end for the first time — if it passes first try, +invert its POSITIVE signal (assert `locator.armed_count() == 1`), watch it +fail, restore. Inverting only the identity-is-None assertions is NOT a valid +check for this test: those fail identically whether the guard fired or the +locator never resolved, so they cannot prove the test is non-vacuous — the +armed_count inversion is the one that proves resolution actually occurred. + +- [ ] **Step 3: Fix any real gaps minimally; re-run to GREEN** + +Run: `cargo test -p freshell-ws` +Expected: PASS. + +- [ ] **Step 4: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-ws --all-targets -- -D warnings +git add crates/freshell-ws/src/codex_association.rs +git commit -m "test(ws): codex drain_and_associate binds identity+ledger+broadcasts; re-arm and hijack pins" +``` + +--- + +### Task 6: Fresh-pane turn.complete carries sessionId (activity integration) + +**Files:** +- Create: `crates/freshell-ws/tests/codex_locator_activity.rs` +- Modify: `crates/freshell-ws/tests/common/mod.rs` (ONE new spawn helper — see Step 1; without it this test CANNOT pass) +- Test: same file + +**Interfaces:** +- Consumes: the per-test helpers in + `crates/freshell-ws/tests/codex_candidate_activity.rs` (`write_fake_codex` + `:24`, `codex_capture_spec` `:44`, `send_create` `:64`, `wait_for_frame` + `:110` — all local to that file; copy them, that file is deleted next + task, so the new file must be self-contained), the shared harness in + `tests/common/mod.rs`, and the locator pipeline from Tasks 1-5. +- HARNESS GAP (load-bearing): every existing `spawn_server_*` helper in + `tests/common/mod.rs` builds its `WsState` literal with + `amplifier_locator: None, opencode_locator: None` (`:135/:215/:291/:356`) + — after Task 4's compile sweep they all get `codex_locator: None` too — + and NO test harness spawns any locator sweep (sweeps live only in + `main.rs:626/:636`). A test spawned through the unmodified harness has no + locator and no sweep, so identity can never resolve and every await below + would time out. Step 1 therefore adds a locator-wired helper. +- Produces: the closing pin for the identity-less status gap — fresh codex + panes' `codex.activity.updated` / `terminal.turn.complete` carry + `sessionId` with NO client candidate frame. + +- [ ] **Step 1: Write the failing test (and the harness helper it needs)** + +First, in `crates/freshell-ws/tests/common/mod.rs`, add ONE new spawn helper +following that file's established copy-a-variant convention: copy +`spawn_server_with_specs_and_activity` (`:243-307`) into a sibling + +```rust +pub async fn spawn_server_with_specs_activity_and_codex_locator( + specs: /* same as the donor */, + codex_sessions_root: &std::path::Path, +) -> /* same return type as the donor */ +``` + +with exactly two deltas from the copied body (everything else identical): +1. In the `WsState` literal, set + `codex_locator: Some(std::sync::Arc::new(freshell_sessions::codex_locator::CodexLocator::new(codex_sessions_root.to_path_buf()))),` + instead of `None`. +2. Immediately BEFORE the `freshell_ws::router(state)` line, spawn the sweep + on a clone (`WsState` is `Clone` with shared-`Arc` fields, so the sweep + and the router see the same locator/registry/identity): + ```rust + // Mirrors main.rs's sweep wiring; 150 ms is re-declared here because + // main.rs's AMPLIFIER_LOCATOR_SWEEP_INTERVAL is private to the server + // binary. + freshell_ws::codex_association::spawn_codex_locator_sweep( + state.clone(), + std::time::Duration::from_millis(150), + ); + ``` +The other spawn helpers in `common/mod.rs` keep `codex_locator: None` +(Task 4's compile sweep). The test passes +`/sessions` as `codex_sessions_root` — the same root +`codex_sessions_root()` resolves in the real server. + +Then create `crates/freshell-ws/tests/codex_locator_activity.rs`: + +```rust +//! Lane B2: a FRESH codex terminal (no resume id, NO client candidate frame) +//! gains identity from the server-side rollout locator, and its activity +//! frames carry the sessionId — closing the "terminals created before any +//! candidate" status gap. +//! +//! Harness copied from the (retired) codex_candidate_activity.rs: real +//! server, real socket, real PTY running a fake codex binary, CODEX_HOME +//! pointed at a tempdir. +``` + +Body (single `#[tokio::test(flavor = "multi_thread")] #[cfg(unix)]`), copied +from `codex_candidate_activity.rs::adopted_candidate_identity_reaches_codex_activity` +(`:133+`) with this exact delta: +1. Keep: `CODEX_HOME` tempdir env, server spawn, fake codex + binary/`codex_capture_spec()`, creating a FRESH codex terminal over the + socket, the `codex.activity.updated` await helper. +2. SPAWN through the new helper: set the `CODEX_HOME` tempdir env exactly as + the donor does, then call + `spawn_server_with_specs_activity_and_codex_locator(vec![codex_capture_spec()], &codex_home.join("sessions"))` + instead of `spawn_server_with_specs_and_activity(...)`. +3. DELETE the candidate-frame send. Instead, AFTER the terminal is created + (locator armed at create), run this exact sequence — the order is + load-bearing because the FIRST submit re-snapshots `known_files` (Task 1 + hardening), so the rollout must NOT exist yet at the first Enter: + a. Send a single `terminal.input` of `"\r"` over the socket: windows are + Enter-anchored (validated A1 — real codex materializes the rollout + only at the first user prompt; there is no spawn window), and this + first Enter takes the re-snapshot and opens the 2 s window. + b. Sleep ~3 s (`tokio::time::sleep`) so that first window resolves with + zero candidates (deadline 2 s + 150 ms sweep, with margin). + c. Write the rollout file the locator must find: + `/sessions/2026/07/24/rollout-2026-07-24T12-00-00-.jsonl` + with first line + `{"timestamp":"2026-07-24T12:00:00.000Z","type":"session_meta","payload":{"id":"","cwd":""}}` + — the terminal's cwd is whatever the harness passed to + terminal.create; use that same value. + d. Send a second `terminal.input` of `"\r"`: a later Enter re-opens a + resolved window WITHOUT re-snapshotting (pinned by Task 2's + `later_enter_reopen_keeps_the_first_submit_snapshot`), so the file + written in (c) is deterministically the sole new candidate. +4. Await `codex.activity.updated` carrying `sessionId == ` (the + harness helper spawns the same 150 ms sweep main.rs uses; give the await + the same generous timeout the donor test used — it must cover the ~2 s + Enter-anchored deadline after (d)). +5. Then append two task-event lines to the SAME rollout file (copy the + `event_msg` line shapes from `activity.rs`'s `codex_event_line` helper, + `:2381-2385` — `task_started` then `task_complete`, timestamps now-ish), + and await a `terminal.turn.complete` frame asserting + `provider == "codex"` AND `session_id == Some()` — this proves the + locator's `attach_codex_rollout` handed the file to the status watcher and + completions are stamped. + +Note on determinism: resolution REQUIRES an Enter — there is no spawn +window — and the rollout must be written strictly BETWEEN the first Enter's +re-snapshot and a later Enter's re-opened window; the two-Enter sequence in +step 3 guarantees that ordering without racing the server (the test cannot +observe when the server finishes processing an input frame, so "write the +file right after sending the first `\r`" would race the re-snapshot and +could permanently exclude the file). If flake appears, lengthen the (b) +sleep or send another `"\r"` — every later Enter re-opens the window against +the same frozen snapshot (pinned by Task 2's +`zero_candidate_window_keeps_watching_and_later_enter_reopens` and +`later_enter_reopen_keeps_the_first_submit_snapshot`). + +- [ ] **Step 2: Run to verify it fails for the right reason** + +Run: `cargo test -p freshell-ws --test codex_locator_activity -- --nocapture` +Expected: with Tasks 1-5 landed this may already PASS — apply the inversion +check (assert `session_id` is `None`, watch it fail, restore). If it fails +for a REAL reason (e.g. `turn.complete` lacks sessionId because +`attach_codex_rollout` wasn't reached), that is the red this task exists to +fix — fix in the Task 3/4 modules, minimally. If instead the awaits time out +with identity never resolving at all, first suspect THIS task's harness +wiring (the new `common/mod.rs` helper: locator field set, sweep spawned, +root == `/sessions`) before touching Task 3/4 code. + +- [ ] **Step 3: Run to GREEN** + +Run: `cargo test -p freshell-ws --test codex_locator_activity` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-ws --all-targets -- -D warnings +git add crates/freshell-ws/tests/codex_locator_activity.rs crates/freshell-ws/tests/common/mod.rs +git commit -m "test(ws): fresh codex pane turn.complete carries sessionId via server-side locator (adds locator-wired spawn helper to the shared harness)" +``` + +--- + +### Task 7: Retire the client candidate channel + +**Files:** +- Modify: `crates/freshell-ws/src/terminal.rs:511-517` (match arm → accept-and-ignore + debug log) +- Delete: `crates/freshell-ws/src/codex_candidate.rs` +- Delete: `crates/freshell-ws/tests/codex_candidate_persisted.rs`, `crates/freshell-ws/tests/codex_candidate_activity.rs` +- Create: `crates/freshell-ws/tests/codex_candidate_inert.rs` +- Modify: `crates/freshell-ws/src/lib.rs` (remove `pub(crate) mod codex_candidate;` at `:26`) +- Modify: `crates/freshell-ws/src/invariants.rs` (doc text only) +- Test: `crates/freshell-ws/tests/codex_candidate_inert.rs` + +**Interfaces:** +- Consumes: `TerminalCodexCandidatePersisted` protocol struct — which STAYS in + `crates/freshell-protocol` (wire-compat: the frozen client still sends it; + contract inventory files stay valid). +- Produces: the message is out of the accepted-writer set. One writer per + codex identity fact: the server locator (plus create-time resume ids). + +- [ ] **Step 1: Write the failing inert-channel test** + +Create `crates/freshell-ws/tests/codex_candidate_inert.rs` by copying the +server/socket harness AND the `send_candidate_expect_silence` ping/pong helper +from `codex_candidate_persisted.rs` (`:112-131`, before deleting it), plus its +`CODEX_HOME` tempdir setup. Single test: + +```rust +//! Lane B2 / campaign §2.3.2: terminal.codex.candidate.persisted is RETIRED +//! as a writer. The frozen client still SENDS it (TerminalView.tsx:4009-4018), +//! so the server must accept-and-ignore with a debug log — never an error to +//! the client, and NEVER an identity write. + +#[tokio::test(flavor = "multi_thread")] +#[cfg(unix)] +async fn candidate_frame_is_accepted_ignored_and_writes_nothing() { + // 1. Spawn the real server; create a REAL fresh codex terminal t1 (fake + // codex binary, exactly as the donor harness does). + // 2. Seed a VALID rollout on disk for thread id T (the donor's + // session_meta seed) — i.e. a frame that would have passed all four + // old guards. + // 3. Send terminal.codex.candidate.persisted{terminalId: t1, + // candidateThreadId: T, rolloutPath: , capturedAt: now}. + // 4. send_candidate_expect_silence-style ping/pong round trip: nothing + // is sent back, the connection stays healthy. + // 5. Assert NO identity was written: create/list probe shows t1 with no + // sessionRef and no resume_session_id (use the same identity-probe + // assertion form the donor test used for its REJECT paths). + // 6. The terminal still works: send terminal.input "\r" and expect no + // protocol error. +} +``` + +IMPORTANT: seed the rollout in a cwd that does NOT match t1's cwd, or create +the terminal with `resume_session_id` unset and the rollout BEFORE the +terminal exists — otherwise the LOCATOR may legitimately adopt the identity +and turn step 5 into a false failure. Simplest deterministic arrangement: +write the rollout file BEFORE creating the terminal (arm-time snapshot then +excludes it forever). + +- [ ] **Step 2: Run to verify RED** + +Run: `cargo test -p freshell-ws --test codex_candidate_inert -- --nocapture` +Expected: FAIL — today the handler ADOPTS the valid candidate, so step 5's +no-identity assertion fails. (If it fails to compile because the harness +helpers were copied wrong, fix the copy first; the RED must be the adoption.) + +- [ ] **Step 3: Retire the channel** + +1. `crates/freshell-ws/src/terminal.rs:511-517` — replace the match arm body: + ```rust + // RETIRED (campaign §2.3.2, Lane B2): codex identity has exactly one + // writer — the server-side rollout locator (codex_association). The + // frozen client still sends this frame (TerminalView.tsx durability + // handler), so accept-and-ignore with a debug breadcrumb; never an + // error to the client, never an identity write. + ClientMessage::TerminalCodexCandidatePersisted(candidate) => { + tracing::debug!( + terminal_id = %candidate.terminal_id, + "codex_candidate_ignored: client candidate channel retired (server locator is authoritative)" + ); + true + } + ``` +2. Delete `crates/freshell-ws/src/codex_candidate.rs` and its `lib.rs` mod + line. (`verify_rollout_path` and `is_uuid_shaped` die with it: the locator + proves ownership from paths it discovered itself under the sessions root, + with its own shape gate — the containment check existed only for + client-supplied paths. Coverage re-homes: ownership predicate edges live in + `codex_locator.rs` probe tests + `codex_reconcile.rs`'s decoy test.) +3. Delete `crates/freshell-ws/tests/codex_candidate_persisted.rs` and + `crates/freshell-ws/tests/codex_candidate_activity.rs` (superseded by + `codex_candidate_inert.rs` and `codex_locator_activity.rs`). Dead code is + context poison — no commented-out remnants, no "reference" copies. +4. `crates/freshell-ws/src/invariants.rs` — update the + `terminal_identity_unresolved` doc comment (`:39-47` area): the grace + window (`IDENTITY_RESOLUTION_GRACE_MS = 5 × AMPLIFIER_DIR_APPEAR_WINDOW_MS` + = 10 s) now also covers the codex locator (`CODEX_WINDOW_MS` = 2 s, + Enter-anchored, plus `PENDING_FIRST_LINE_GRACE_MS` = 10 s that applies + ONLY in the anomalous empty-first-line gap — no numeric change here); + mention that fresh codex panes are expected to resolve via + `codex_association` within grace of their first Enter, and that a + maximally slow codex git-info gap can legitimately outlast the alarm + grace (the alarm is advisory in that case). Doc text only — no logic + change. + +- [ ] **Step 4: Run to GREEN + full-crate regression** + +Run: +```bash +cargo test -p freshell-ws --test codex_candidate_inert +cargo test -p freshell-ws --test codex_session_ref_resume # restore path UNCHANGED: sessionRef → `codex … resume ` argv +cargo test --workspace 2>&1 | tail -5 +``` +Expected: all PASS. The `codex_session_ref_resume` run is the explicit +"restore path unchanged" verification the spec demands. + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add -A crates/freshell-ws +git commit -m "feat(ws)!: retire terminal.codex.candidate.persisted writer - accept-and-ignore with debug log + +One writer per codex identity fact (campaign §2.3.2): the server-side +rollout locator is authoritative. The protocol variant stays for +wire-compat with the frozen client; the write path, its four guards, +and both candidate integration tests are deleted (superseded by +codex_candidate_inert.rs and codex_locator_activity.rs)." +``` + +--- + +### Task 8: E2E fixture — fake codex terminal CLI that writes a real rollout + +**Files:** +- Create: `test/e2e-browser/fixtures/fake-codex-terminal.mjs` + +**Interfaces:** +- Consumes: env `CODEX_HOME` (set per-server by the RustServer harness), + `FAKE_CODEX_TERMINAL_ARGV_LOG`, `FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH`. +- Produces (Tasks 9-10 rely on these EXACT behaviors): + - fresh mode: prints `codex> `, and on the FIRST stdin chunk containing an + Enter (CR or LF) writes + `/sessions/YYYY/MM/DD/rollout--.jsonl` + (first line `session_meta` with `payload.id` + `payload.cwd`) then prints + `codex: session started`. Enter-gating (NOT first-data) is + load-bearing: it mirrors validated Premise 7 (real codex materializes + the rollout only at the first user prompt), and the server re-snapshots + `known_files` before forwarding the first Enter to the PTY — a fixture + that wrote on mere typing would land its rollout PRE-snapshot and be + permanently excluded; + - gate: when `FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH` is set, the write + waits (50 ms poll) until that file exists — a never-created gate makes + "identity never resolves" deterministic; + - resume mode (`resume` anywhere in argv): prints + `codex: resumed session `, writes NO rollout; + - always mirrors argv as JSONL to `FAKE_CODEX_TERMINAL_ARGV_LOG`. + +- [ ] **Step 1: Write the fixture (the e2e spec in Task 9 is its failing test)** + +```js +#!/usr/bin/env node +// Fake codex TERMINAL CLI for the rollout-locator e2e specs (Lane B2). +// Mirrors fake-opencode-terminal.mjs's contract, on codex's substrate: the +// identity artifact is a rollout JSONL under CODEX_HOME/sessions whose FIRST +// line is the session_meta ownership record (payload.id — never the +// filename — is the identity; payload.cwd is the locator's disambiguator). +// - fresh: prints `codex> `; on the FIRST stdin chunk containing an Enter +// (CR/LF) writes the rollout (gated by +// FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH when set) and prints +// `codex: session started`. Enter-gating mirrors real codex +// (Premise 7: the rollout materializes only at the first user prompt) and +// keeps the fixture on the safe side of the server's first-submit +// known_files re-snapshot, which completes before the Enter reaches this +// process. +// - resume (`resume` ANYWHERE in argv — resumeArgs are appended LAST after +// `-c` overrides): prints `codex: resumed session `, writes nothing. +// - argv mirrored to FAKE_CODEX_TERMINAL_ARGV_LOG as JSONL. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +const argv = process.argv.slice(2) + +function appendArgvLog() { + const logPath = process.env.FAKE_CODEX_TERMINAL_ARGV_LOG + if (!logPath) return + fs.mkdirSync(path.dirname(logPath), { recursive: true }) + fs.appendFileSync(logPath, `${JSON.stringify({ pid: process.pid, t: Date.now(), argv })}\n`) +} +appendArgvLog() + +function codexSessionsDir() { + const home = process.env.CODEX_HOME && process.env.CODEX_HOME.length > 0 + ? process.env.CODEX_HOME + : path.join(process.env.HOME ?? '', '.codex') + const now = new Date() + const yyyy = String(now.getUTCFullYear()) + const mm = String(now.getUTCMonth() + 1).padStart(2, '0') + const dd = String(now.getUTCDate()).padStart(2, '0') + return path.join(home, 'sessions', yyyy, mm, dd) +} + +function writeRollout(threadId) { + const now = new Date() + const ts = now.toISOString().slice(0, 19).replace(/:/g, '-') + const dir = codexSessionsDir() + fs.mkdirSync(dir, { recursive: true }) + const file = path.join(dir, `rollout-${ts}-${threadId}.jsonl`) + const meta = { + timestamp: now.toISOString(), + type: 'session_meta', + payload: { id: threadId, cwd: process.cwd() }, + } + fs.writeFileSync(file, `${JSON.stringify(meta)}\n`) +} + +const resumeIndex = argv.indexOf('resume') +if (resumeIndex !== -1) { + const sessionId = argv[resumeIndex + 1] ?? '' + process.stdout.write(`codex: resumed session ${sessionId}\r\n`) +} else { + process.stdout.write('codex> \r\n') + let wrote = false + process.stdin.on('data', (chunk) => { + if (wrote) return + const s = String(chunk) + // Enter-anchored, like real codex (Premise 7): typing alone must not + // create the rollout — only the first Enter does. + if (!s.includes('\r') && !s.includes('\n')) return + wrote = true + const threadId = crypto.randomUUID() + const finish = () => { + writeRollout(threadId) + process.stdout.write(`codex: session ${threadId} started\r\n`) + } + const gate = process.env.FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH + if (gate) { + const poll = setInterval(() => { + if (fs.existsSync(gate)) { + clearInterval(poll) + finish() + } + }, 50) + } else { + finish() + } + }) +} +process.stdin.resume() +``` + +- [ ] **Step 2: Smoke the fixture standalone (cheap sanity, not the real test)** + +Run: +```bash +cd /home/dan/code/freshell/.worktrees/codex-rollout-locator +CODEX_HOME=/tmp/fake-codex-home-$$ node test/e2e-browser/fixtures/fake-codex-terminal.mjs <<'EOF' +hello +EOF +find /tmp/fake-codex-home-$$ -name 'rollout-*.jsonl' -exec head -c 200 {} \; ; rm -rf /tmp/fake-codex-home-$$ +``` +Expected: prints `codex> `, then `codex: session started`, and the +find shows one rollout whose first line is the `session_meta` JSON with the +uuid and cwd. (stdin EOF ends the process.) + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e-browser/fixtures/fake-codex-terminal.mjs +git commit -m "test(e2e): fake codex terminal fixture that writes a real gated rollout JSONL" +``` + +--- + +### Task 9: E2E — fresh codex pane gains server-side identity and resumes across restart + +**Files:** +- Create: `test/e2e-browser/specs/codex-terminal-restore-rust.spec.ts` +- Modify: `test/e2e-browser/playwright.config.ts` (TWO edits — see step 3) + +**Interfaces:** +- Consumes: `test/e2e-browser/specs/opencode-terminal-restore-rust.spec.ts` + (the donor — copy its helper set verbatim; helpers are per-spec-owned by + convention, copied not imported), the Task 8 fixture, the RustServer + harness (`createE2eServerHandle`, isolated HOME, ephemeral port). +- Produces: the end-to-end user-story proof: fresh codex terminal → identity + captured SERVER-SIDE with no client candidate → restart → relaunched with + `codex … resume `. + +- [ ] **Step 1: Write the spec (this IS the failing e2e test)** + +Create the spec by copying `opencode-terminal-restore-rust.spec.ts` wholesale +and applying this delta (everything not listed stays structurally identical — +boot, harness waits, leaf-diff pane identification, `persist/flushNow`, +restart-same-port, negative control): + +1. Header comment: codex Lane B2; rust-only (legacy has no codex terminal + locator). +2. Fixture install: + ```ts + const FAKE_CODEX_TERMINAL_SOURCE = path.resolve(__dirname, '../fixtures/fake-codex-terminal.mjs') + // installFakeCli(source, 'codex', binDir) — copy the donor's helper. + ``` +3. Server env: `CODEX_CMD: fakeCodexPath`, + `FAKE_CODEX_TERMINAL_ARGV_LOG: argLogPath` (no gate var — this is the + positive path). +4. `setupHome` seeds `settings.codingCli.enabledProviders: ['codex']`. +5. Pane open: picker button `/^Codex CLI$/i` (manifest label is "Codex CLI" — + `/^Codex$/` matches nothing), then the Starting-directory combobox Enter, + exactly the donor's `openOpencodePaneAndGetLeaf` flow renamed for codex; + wait for `codex> ` in the buffer. +6. Submit: type `hello codex` + Enter → fixture writes the rollout and prints + `codex: session started`. +7. Identity assertion (the server-side capture proof): poll + `leaf.content.sessionRef?.sessionId ?? leaf.content.resumeSessionId` until + it matches `/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i` + and `sessionRef.provider === 'codex'`. NO client candidate can exist here: + the Rust server never emits `terminal.codex.durability.updated`, so the + frozen client's candidate sender never fires — identity arriving at all IS + the server-locator proof. State this in a comment, citing the validated + client path (A7): the frozen client applies `terminal.session.associated` + provider-generically (`App.tsx:1004-1017` → panesSlice `sessionRef`, + `resumeSessionId` cleared), and its conflict guard only drops a push when + the pane ALREADY holds a different sessionRef — impossible for fresh panes + (the locator arms only unbound panes). +8. Restart + reload (donor flow). Post-restart positive proofs (BOTH): + - buffer contains `codex: resumed session `; + - the argv log's post-restart entries contain the ADJACENT PAIR + `resume, `: + ```ts + const entries = await readArgvLog(argLogPath) + const resumed = entries.some((e) => { + const i = e.argv.indexOf('resume') + return i !== -1 && e.argv[i + 1] === associatedSessionId + }) + expect(resumed).toBe(true) + ``` + (codex's `resumeArgs: ["resume", "{{sessionId}}"]` is a subcommand + appended LAST — never assert `argv[0]`.) +9. Negative control (donor's shape): a second codex pane that NEVER submits — + the fixture writes no rollout, so after restart it restores fresh: + `sessionRef`/`resumeSessionId` both undefined, status not `error`, fresh + `codex> ` in the buffer. + +- [ ] **Step 2: Run to verify RED-then-GREEN honestly** + +The spec is new; with Tasks 1-7 landed it should pass. Prove it tests the +right thing exactly like the wall convention does: first run it, and if it +passes first try, re-run once with the gate env +(`FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH` pointed at a never-created path) +temporarily added to the server env — the identity poll in step 7 must then +time out (RED), proving the assertion depends on the real locator resolution. +Remove the temporary gate. Run: +```bash +cd /home/dan/code/freshell/.worktrees/codex-rollout-locator +npx playwright test -c test/e2e-browser/playwright.config.ts --project=rust-chromium codex-terminal-restore-rust +``` +Expected: 1 passed (after the deliberate gate-RED check). +Note: e2e builds `freshell-server` in release mode on first run — allow a +long timeout (10+ min) for the first invocation. + +- [ ] **Step 3: Register the spec (two edits, both required)** + +In `test/e2e-browser/playwright.config.ts`: +1. Add to `RUST_ONLY_SPECS` (`:81-130`): + ```ts + // Lane B2 codex rollout locator: rust-only (legacy has no codex terminal + // locator); imports the RustServer-backed harness for same-port restart. + /codex-terminal-restore-rust\.spec\.ts$/, + ``` +2. Repeat the same regex (with the same comment) in the `rust-chromium` + project's explicit `testMatch` array (`:183-287`). Do NOT add to + `MATRIX_SPECS`. +Re-run the Step 2 command; also run +`npx playwright test -c test/e2e-browser/playwright.config.ts --project=chromium --list | grep -c codex-terminal-restore` +Expected: rust-chromium runs it (1 passed); the chromium `--list` count is 0 +(ignored in match-all projects). + +- [ ] **Step 4: Commit** + +```bash +git add test/e2e-browser/specs/codex-terminal-restore-rust.spec.ts test/e2e-browser/playwright.config.ts +git commit -m "test(e2e): fresh codex pane gains server-side identity and resumes via 'codex resume ' across restart" +``` + +--- + +### Task 10: E2E — SIGKILL inside the codex locator window leaves a durable fresh-by-race marker + +**Files:** +- Modify: `test/e2e-browser/specs/pane-ledger-restart-rust.spec.ts` + +**Interfaces:** +- Consumes: that file's own helpers (`installFakeCli(binDir, name, source)` — + NOTE the argument order differs from the Task 9 donor; `seedConfig()`, + `openCliPane`, `listFiles`, `within5s`, `selectShellIfPickerShowing`), the + Task 8 fixture with `FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH`. +- Produces: the codex fresh-by-race durability pin — pending marker survives + SIGKILL, zero binding rows. + +- [ ] **Step 1: Write the failing-shaped test** + +Append a sibling of the opencode test at `:194` (copy its body verbatim, +then apply this delta): + +```ts + test('SIGKILL inside the codex locator window leaves a durable fresh-by-race marker', async ({ page, e2eServerKind }) => { + expect(e2eServerKind).toBe('rust') + // Delta from the opencode sibling above: + // - fixture: fake-codex-terminal.mjs installed as 'codex' + // (this file's installFakeCli argument order: (binDir, name, source)). + // - env: CODEX_CMD + FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH pointed at a + // path we NEVER create — the rollout deterministically never lands, so + // identity provably cannot resolve pre-kill and the pending marker is + // the only evidence. + // - pane: openCliPane(page, /^Codex CLI$/i), then click the pane's xterm + // and type + Enter (the codex ledger pending marker exists from spawn — + // codex is already in MARKER_MODES — and the typed Enter is what opens + // the locator window at all: windows are Enter-anchored, so this is a + // true inside-the-window kill). + // - assertions: identical — pending/*.json survives restartAbrupt(), + // bindings/ has zero *.json. + }) +``` + +Write the FULL body (no elisions) by transplanting `:194-255` with the delta +above. + +- [ ] **Step 2: Run it** + +```bash +npx playwright test -c test/e2e-browser/playwright.config.ts --project=rust-chromium pane-ledger-restart-rust +``` +Expected: ALL tests in the file pass, including the new codex one and the +pre-existing codex pending-marker test at `:149`. The new test's premise is +made deterministic by the never-created gate (mirror of the opencode +DETERMINISM note). If the new test passes first try, verify it can fail: +temporarily flip the bindings assertion to `toBeGreaterThan(0)`, watch it +fail, restore. + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e-browser/specs/pane-ledger-restart-rust.spec.ts +git commit -m "test(e2e): codex SIGKILL-inside-locator-window leaves durable fresh-by-race pending marker" +``` + +--- + +### Task 11: Full verification, contract-wall pin evaluation, push (STOP before PR) + +**Files:** +- Possibly modify: `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` (pin deletions ONLY if the wall run demands them) + +**Interfaces:** +- Consumes: everything above. +- Produces: green evidence across all gates; pushed branch; NO PR. + +- [ ] **Step 1: Rust gates (the CI-exact commands)** + +```bash +cd /home/dan/code/freshell/.worktrees/codex-rollout-locator +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` +Expected: all clean/PASS. Fix anything real; never `#[allow]` your way past +clippy without a stated reason in the code comment. + +- [ ] **Step 2: Coordinated TS suites** + +```bash +FRESHELL_TEST_SUMMARY="lane B2 codex rollout locator - full verification" env -u FRESHELL_BIND_HOST npm test +``` +Expected: PASS. If the coordinator gate is held by a sibling lane, WAIT (check +`npm run test:status`) — never kill a foreign holder. If the worktree lacks +deps: `npm ci` and, if tsx is missing, `ln -s ../node_modules/tsx node_modules/tsx`. + +- [ ] **Step 3: E2E suite for the touched specs + the contract wall** + +```bash +npx playwright test -c test/e2e-browser/playwright.config.ts --project=rust-chromium \ + codex-terminal-restore-rust pane-ledger-restart-rust codex-terminal-bounce-rust compound-restart-rust +npx playwright test -c test/e2e-browser/playwright.config.ts --project=rust-chromium restore-contract-wall-rust +``` +Expected for the first command: all pass — `codex-terminal-bounce-rust` and +`compound-restart-rust` pin `not.toContain('terminal_identity_unresolved')`, +which the locator must keep green. + +Contract-wall interpretation (Playwright semantics: a `test.fail`-pinned test +that PASSES is reported as a hard failure — that is the flip signal): +- Pins `:1679` (claude-shaped) and `:1803` (opencode-shaped) are expected to + STILL FAIL-AS-PINNED — this lane's locator is codex-scoped. Leave them. +- If any pinned test reports "passed unexpectedly", DELETE that test's + `test.fail(...)` line (nothing else), re-run the wall, and name the flipped + pin in the commit message. +- The un-pinned codex contracts `:738` (sessionRef-bound codex resumes with + `resume ` after SIGKILL) and `:925` (freshcodex rebind) MUST still pass + — any regression here is a stop-the-line bug in this lane. + +- [ ] **Step 4: Commit any pin flips (only if Step 3 demanded them)** + +```bash +git add test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +git commit -m "test(e2e): flip restore-contract-wall pins now green under the codex server-side locator" +``` + +- [ ] **Step 5: One-writer merge-time gate (mechanical, before push)** + +Sibling lanes run concurrently; the "one writer per codex identity fact" +claim must be re-proven against the integration base, not just this +worktree. After rebasing/merging onto the integration base (and again if the +base moves before push), run: + +```bash +rg "adopt_codex_identity|ledger_resolve_identity|codex.candidate|session\.associated" crates/ --type rust +git diff "$(git merge-base origin/main HEAD)"..HEAD -- crates/freshell-ws/src/terminal.rs +``` + +Confirm: (a) the only codex identity writers are `codex_identity.rs` / +`codex_association.rs` and the create-time resume path — no sibling lane +added another; (b) no sibling lane moved the `terminal.rs` seams this lane +edited (arm at create, submit seam, exit disarm, retired candidate match +arm). Investigate ANY unexpected hit BEFORE the final "one writer" commit +message is written; do not push over an unexplained conflict. + +- [ ] **Step 6: Push the branch and STOP** + +```bash +git log --oneline origin/main..HEAD # review the task commits +git push -u origin "$(git branch --show-current)" +``` +Then STOP — PR creation is NOT approved. Report: branch name, the commit +list, and the proof summary (cargo gates, coordinated suite result, e2e +results incl. wall disposition, and the scope-fence note that REST-lane +(freshell-freshagent) arming is deferred to Lane B4 by the wave's fence). + +--- + +## Self-Review (performed at plan-writing time) + +**1. Spec coverage:** +- Server-side rollout-appear locator, arm at create, Enter-anchored + correlation windows (Validated Premise 7: real codex materializes the + rollout only at the first user prompt — there is no spawn window; arm + takes only the snapshot) → Tasks 1-2, 4. +- Misbind hardenings from the A4 adversarial validation (required cwd, + pending-first-line bind-blocking grace, cross-tick contested-cwd refusal, + bound-elsewhere adoption refusal, first-submit known_files re-snapshot with + Task 4's before-PTY-write seam ordering) → Tasks 1-4, pinned by Task 1/2's + guard tests and Task 5 test 3; the post-first-Enter same-cwd residual + (freshagent sidecar) is documented in + Validated Premise 10 and named in Task 11's report, not silently absorbed. +- Reuse-don't-duplicate the wave-2 watcher → Premise Correction 2: discovery + cannot ride the per-file status watcher (verified structural fact); the + locator polls only-while-armed and hands the discovered path to the + EXISTING watcher via `attach_codex_rollout` (Task 3/6) — one file-watcher, + two consumers downstream, parsers' ownership predicate reused. +- Ledger pending marker at spawn → already live (`MARKER_MODES` includes + "codex", `terminal.rs:101`/`:1604`); binding row at resolve → Task 3 + (`ledger_resolve_identity` inside adopt), pinned by Task 5 + Task 10. +- Broadcasts `terminal.session.associated` + `terminal.meta.updated` (pinned + order) → Task 3 (moved verbatim), pinned by Task 5. +- Activity-hub identity bind → Task 3 (`bind_codex_session` — the real name + of the spec's "bind_terminal_session path"), pinned by Task 6. +- Retire candidate channel: accept-and-ignore + debug log, never an error; + write path deleted; dead code removed; reusable helpers kept only where + reused → Task 7 (protocol variant retained for wire-compat; containment + helper deleted WITH its only consumer; ownership-predicate coverage + re-homed). +- Invariants reflect the locator → Task 7 step 3.4. +- Restore path (sessionRef → codex resume argv) unchanged → Task 7 step 4 + runs `codex_session_ref_resume` explicitly; Task 9 proves it end-to-end. +- Re-arm on restore-created identity-less panes → arm() gates on + resume_session_id only (Task 1 gate test), controller comment (Task 4), + end-to-end pin (Task 5 test 2). +- TDD red-first list: locator-resolves-after-first-Enter (T1), + no-submit-never-binds (T1), SIGKILL-inside-window fresh-by-race + pending-marker-no-binding (T10 e2e + T5 ledger pins), window re-open on a + later Enter (T2), no-cwd-never-binds (T2), pending-first-line + bind-blocking + grace expiry (T2), staggered contested-cwd refusal (T2), + candidate inert (T7), binding row at resolve (T5), turn.complete carries + sessionId (T6). E2E fixture + fresh → no-candidate → restart → + `resume ` (T8-9). Pin flips + one-writer merge gate (T11). +- Scope fence → Global Constraints + Premise Correction 6 (freshell-freshagent + untouched; REST-lane arming named in the final report, not silently + dropped). + +**1b. No silent deferrals:** Every user-facing behavior lands with production +code and a real end-to-end proof (Task 9 drives a real browser, real server, +real PTY, restart, argv evidence). The fixtures are test doubles for the +external codex binary only — the identity substrate they write (rollout JSONL +with session_meta first line) is the REAL production artifact shape, verified +against the repo's own real-data documentation (`durability.rs:9`, +`codex_reconcile.rs` tests). REST-created panes not arming is a spec-directed +scope fence (Lane B4 owns those crates), surfaced loudly in Task 11's report +— not silently deferred. No other requirement is stubbed. + +**2. Placeholder scan:** Two tests in Task 5 and one in Task 10 direct +transplantation from a named donor test with a complete, enumerated delta +instead of repeating ~80-line harness bodies whose exact forms (55-field +WsState literals) only the donor file can supply — each names the donor +file:line and every changed element, and instructs "the transplant wins" on +any conflict. No TBDs, no "handle edge cases", no undefined names: every +type/function a later task uses is defined in an earlier task's Interfaces +block or exists on main at a cited file:line. + +**3. Type consistency:** `Located { terminal_id, thread_id, rollout_path, cwd }` +(T1) is consumed field-for-field in T4's drain; `CodexAdoption`/`adopt_codex_identity` +(T3) match T4's call site; `spawn_codex_locator_sweep(WsState, Duration)` (T4) +matches main.rs wiring; `with_config(PathBuf, i64)` has no pre-epsilon +anywhere; fixture env names (`FAKE_CODEX_TERMINAL_ARGV_LOG`, +`FAKE_CODEX_TERMINAL_ROLLOUT_GATE_PATH`) are identical across T8/T9/T10. diff --git a/docs/plans/2026-07-26-freshagent-verdicts-resume.md b/docs/plans/2026-07-26-freshagent-verdicts-resume.md new file mode 100644 index 000000000..20ddf8d7d --- /dev/null +++ b/docs/plans/2026-07-26-freshagent-verdicts-resume.md @@ -0,0 +1,2455 @@ +# Fresh-Agent Verdicts + Settings-From-Ledger Resume (Lane B4) 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:** Bring fresh-agent panes (freshcodex / freshopencode / freshclaude / kilroy) into the server-side pane-reconcile verdict system, persist a resume-invocation record (model, sandbox, permissionMode, effort, cwd) in the pane-identity ledger at every fresh-agent identity event, reapply those settings automatically on every resume path, and broadcast a user-visible degradation frame when codex crash-respawn discards conversation memory. + +**Architecture:** A `PaneIdentitySink` trait defined in `freshell-freshagent` (which cannot depend on `freshell-ws`) is implemented in `freshell-server` over the existing `Arc` and injected into the three provider states at wiring time — this solves the wave-A crate-boundary deferral without hoisting the ledger module. `BindingRow` gains optional resume-settings fields under `LEDGER_VERSION 1` (no version bump). Reconcile gains a new sync-safe module `reconcile_freshagent.rs`: an async snapshot of fresh-agent liveness/existence is built in `handle_pane_reconcile` (which is async) *before* the sync `derive_verdicts` runs inside `catch_unwind`; the only `reconcile.rs` changes are one `ReconcileDeps` field, one match arm, and inverting one unit test. The new arm is gated behind a NEW hello capability `paneReconcileFreshAgentV1` so the frozen client (which never sends it) is completely unaffected. + +**Tech Stack:** Rust (tokio, serde, axum WS), Playwright e2e (rust-chromium project), fake CLI/sidecar fixtures (Node). + +## Global Constraints + +- Base: `origin/main @ 2dfbba58`; all work in worktree `/home/dan/code/freshell/.worktrees/freshagent-verdicts-resume`. +- CI Rust gate (must stay green after every task): `cargo fmt --all --check` and `cargo clippy --workspace --all-targets -- -D warnings`. +- Rust tests: `cargo test -p ` locally (no cargo test in CI, run anyway). +- Coordinated node suites: check `npm run test:status` first; run `FRESHELL_TEST_SUMMARY="B4 freshagent-verdicts-resume" env -u FRESHELL_BIND_HOST npm test`; if the coordinator gate is held by another agent, WAIT (3 sibling lanes run concurrently — never kill a foreign holder). +- E2E: own `RustServer` instances on ephemeral ports only. NEVER ports 3001/3002. NEVER touch the user's self-hosted server. No broad kill patterns. +- SCOPE FENCE — do NOT touch: the rest of `crates/freshell-ws/src/reconcile.rs` beyond the single dispatch arm + one `ReconcileDeps` field + inverting the `unsupported_kind` unit test (Lane B1 owns the rest, incl. retry-verdict deletion — a trivial merge conflict at the arm is expected and fine); `crates/freshell-ws/src/existence.rs` (B1; read-only use is fine); codex_candidate/rollout locator (B2); tabs_snapshots + recovery UI (B3); ALL client code (`src/`, ws-client, App.tsx) — client folding of fresh-agent verdicts is the NEXT wave. No kimi/gemini work. +- `LEDGER_VERSION` stays `1`. All new `BindingRow` fields are `Option` with `skip_serializing_if` — no `deny_unknown_fields`, no required non-defaulted fields. +- Ledger writes are sync + fsync: async call sites MUST wrap them in `tokio::task::spawn_blocking` AND `.await` the handle before replying/proceeding — durable-before-answer, the wave-A rule every shipped async write site already follows (`terminal.rs:1589/1613/2252`; module doc `pane_ledger.rs:34-43`). Write failures are never silent warn-and-drop: surface them user-visibly at the call site, then proceed (a failed write never blocks the identity event — campaign §4.2 policy, per V8/A11). +- B1 COEXISTENCE (V9/A12 — evidence is B1's written plan in worktree `reconcile-client-adoption`; B1 has NO code commits yet): B1 will DELETE `ReconcileVerdict::Retry` + `PaneVerdict.retry_after_ms` and ADD `SessionExistence::ProviderUnavailable`; both lanes edit `handle_pane_reconcile`. Hardening rules for this lane: keep exactly ONE `PaneVerdict` construction point in `reconcile_freshagent.rs` (`base()`), with its `retry_after_ms: None` line annotated `// DELETE-AT-MERGE: B1 removes this field`; NEVER construct/match `ReconcileVerdict::Retry` or assert `retryAfterMs` in any B4 test; keep every `SessionExistence` match exhaustive (no catch-all) with the pre-decided merge mapping `SessionExistence::ProviderUnavailable => FreshAgentPresence::Unknown`; the fresh-agent snapshot is built ONCE per reconcile request and REUSED if deps are re-derived (B1's warming deferral re-derives via `rebuild_deps` — rebuilding the snapshot would double-burn the respawn counter). Merge order: B1 lands first, B4 rebases (expected fixes: one field line + one match arm + one `fresh_agent` field in B1's `rebuild_deps`). +- TypeScript (e2e specs): NodeNext/ESM — relative imports include `.js` where the repo convention requires (spec files follow the sibling specs' import style). +- Alarm/degradation frames use `freshAgent.error` event codes that are NOT `INVALID_SESSION_ID` (that code dispatches `markSessionLost`, not the banner) and do NOT start with `RESTORE_` (RESTORE_-prefixed codes ALSO render the banner but additionally trip the client's restore-failure state — wrong semantics for our alarms; V1/A1). Frozen-client chain (verified, V1): `src/lib/fresh-agent-ws.ts:325-335` → `src/store/freshAgentSlice.ts:444` (`lastError`, set unconditionally, never cleared by any reducer) → `src/components/fresh-agent/FreshAgentView.tsx:1700/2046` → `FreshAgentApprovalBanner` (amber, `role="alert"`). Every alarm frame MUST carry top-level `sessionType` + `provider` (locator resolution, V1/A2) and a user-facing `message` (the banner shows the message, never the code; code-only frames render "Agent error: Unknown error"). The banner is in-memory Redux state: it does NOT survive page reload — e2e must assert before any reload. +- PR POLICY: NOT approved. Push the branch, STOP before `gh pr create`. +- ~78GB disk free — halt on ENOSPC. +- Reference (read-only context, do not commit): campaign plan at `/home/dan/code/freshell/docs/plans/2026-07-24-restart-resilience-architecture-analysis.md` (untracked, absolute path). + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `crates/freshell-ws/src/pane_ledger.rs` | Modify | `BindingRow` optional resume-settings fields; `FreshAgentBindingWrite` (incl. `supersedes`) + `record_fresh_agent_binding` upsert with G3 supersession retire | +| `crates/freshell-freshagent/src/identity_sink.rs` | Create | `PaneIdentitySink` trait (awaited writes), `FreshAgentSettings`, `FreshAgentBindingUpsert`, `FakeIdentitySink` (cfg(test)) | +| `crates/freshell-freshagent/src/lib.rs` | Modify | `pub mod identity_sink;` + re-exports; `FreshAgentState` sink field/setter; REST send-keys materialization binding write | +| `crates/freshell-freshagent/src/codex.rs` | Modify | sink field/setter; awaited ledger writes at identity events (crash-respawn supersedes); `emit_fresh_agent_error`; settings-from-ledger on R1/R2/R3; degradation frame | +| `crates/freshell-freshagent/src/opencode_ws.rs` | Modify | sink field/setter; pending + binding writes; `handle_create` honors `resumeSessionId`; settings-from-ledger resume | +| `crates/freshell-freshagent/src/claude.rs` | Modify | sink field/setter; binding write at `sdk.session.init`; settings-from-ledger resume | +| `crates/freshell-server/src/identity_sink.rs` | Create | `LedgerIdentitySink: PaneIdentitySink` over `Arc` (awaited spawn_blocking writes) | +| `crates/freshell-server/src/main.rs` | Modify | construct + inject the sink into the three provider states + `FreshAgentState` (REST surface) | +| `crates/freshell-ws/src/reconcile_freshagent.rs` | Create | snapshot types, async snapshot builder (supersession-chain resolution, respawn cap w/ reset-on-live), pure verdict mapping, dedupe | +| `crates/freshell-ws/src/reconcile.rs` | Modify (minimal) | one `ReconcileDeps` field + one match arm + invert one unit test | +| `crates/freshell-protocol/src/server_messages.rs` | Modify (minimal) | `ReadyCapabilities` gains an optional `pane_reconcile_fresh_agent_v1` field (the `paneReconcileFreshAgentV1` ready echo — the ready frame is typed; there is no raw-JSON path) | +| `crates/freshell-ws/src/lib.rs` | Modify | `mod reconcile_freshagent;`, `paneReconcileFreshAgentV1` capability parse/echo, `WsState.fresh_agent_respawn_counts` | +| `crates/freshell-ws/src/terminal.rs` | Modify | thread capability bool; build fresh-agent snapshot in `handle_pane_reconcile` | +| `crates/freshell-ws/tests/pane_reconcile_freshagent.rs` | Create | direct-WS integration tests for the fresh-agent arm | +| `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs` | Modify | `crashOnPromptMarker` + cross-process once-marker knob (crash ONCE, exit 1) | +| `test/e2e-browser/fixtures/fake-opencode.cjs` | Modify | audit the parsed `prompt_async` body (model/effort) — today it is discarded (V4) | +| `test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts` | Create | per-provider settings-survive-restart + codex degradation banner | +| `test/e2e-browser/playwright.config.ts` | Modify | register new spec in `RUST_ONLY_SPECS` + `rust-chromium` `testMatch` | +| `test/e2e-browser/specs/restore-contract-wall-rust.spec.ts` | Modify | flip/narrow the P1.13 freshopencode pin | + +--- + +### Task 1: Ledger schema — resume-invocation record fields + fresh-agent upsert + +**Files:** +- Modify: `crates/freshell-ws/src/pane_ledger.rs` +- Test: `crates/freshell-ws/src/pane_ledger_tests.rs` — the tests live in this sibling file, pulled in by `#[cfg(test)] #[path = "pane_ledger_tests.rs"] mod tests;` at `pane_ledger.rs:797-799` (follow the existing tests there, e.g. `record_binding_roundtrips_all_fields` at `pane_ledger_tests.rs:41`) + +**Interfaces:** +- Consumes: existing `BindingRow`, `RowState`, `PaneLedger` internals (`record_binding` at `pane_ledger.rs:315` is the structural donor). +- Produces (later tasks rely on these EXACT names): + - `BindingRow` new pub fields: `pane_kind: Option`, `model: Option`, `sandbox: Option`, `permission_mode: Option`, `effort: Option` (serde camelCase: `paneKind`, `model`, `sandbox`, `permissionMode`, `effort`). + - `pub struct FreshAgentBindingWrite<'a>` (fields below, incl. `supersedes: Option<&'a str>` — the OLD session id this binding replaces; G3 supersession per V8/A14). + - `pub fn PaneLedger::record_fresh_agent_binding(&self, w: &FreshAgentBindingWrite<'_>) -> std::io::Result<()>` — upsert + (when `supersedes` is Some) retire-and-link of the old row. + +- [ ] **Step 1: Write the failing tests** + +Add to `crates/freshell-ws/src/pane_ledger_tests.rs` (the sibling tests file — `pane_ledger.rs:797-799` declares `#[cfg(test)] #[path = "pane_ledger_tests.rs"] mod tests;`). There is NO combined ledger-constructor helper; follow the file's existing convention (see `record_binding_roundtrips_all_fields`): `let root = temp_root("