diff --git a/Cargo.lock b/Cargo.lock index 727bd8b4a..04bbb3ae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,6 +1471,7 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "rustls", "saphyr", "saphyr-parser", "semver", diff --git a/README.md b/README.md index ba58f782e..97ad3b109 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **Live pane headers** — See your active directory, git branch, and context usage in every pane title bar, updating live as you work. Fresh-agent panes carry their context meter in their status strip instead of the header. - **Host pressure dashboard pane** — CPU, memory, pressure, and I/O at a glance with near-zero overhead (metrics stream only while you're watching). Linux, WSL, and macOS only — not shown on Windows. - **Activity notifications** — Configurable attention indicators (highlight, pulse, darken) on tabs and pane headers when a coding CLI finishes its turn, with click or type dismiss modes -- **One name per agent conversation** — Every Claude, Codex, and OpenCode conversation gets a single short name that appears on its pane, tab, sidebar, and history — and stays that same name across refreshes, restarts, and other devices. Freshell names new conversations automatically as you work (short names built for a tab), you can rename from anywhere, and the name is written back to the CLI's own session list where the CLI supports it. +- **One name per agent conversation** — Every Claude, Codex, and OpenCode conversation gets a single short name that appears on its pane, tab, sidebar, and history — and stays that same name across refreshes, restarts, and other devices. Freshell names new conversations automatically as you work (short names built for a tab), you can rename from anywhere, and the name is written back to the CLI's own session list where the CLI supports it. AI naming uses `GOOGLE_GENERATIVE_AI_API_KEY` at startup, or the Gemini key in Settings → Naming. Saving a nonempty Settings key replaces the running server's key; clearing the field takes effect after a restart, provided no environment key is set. With no key, naming uses the Freshell server's OneCLI connection, if it has one. - **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search - **Mobile responsive** — Auto-collapsing sidebar and overlay navigation for phones and tablets - **Stream Deck** — Drive freshell from an Elgato Stream Deck: tabs on keys with repo icons and status backgrounds (or classic live previews and status rings), press to focus, long-press to approve or stop agents. See [Stream Deck](#stream-deck). @@ -209,7 +209,7 @@ Then unplug and replug the deck. Without the rule, the connection status shows " | `GEMINI_CMD` | No | Gemini CLI command override | | `KIMI_CMD` | No | Kimi CLI command override | | `AMPLIFIER_CMD` | No | Amplifier CLI command override | -| `GOOGLE_GENERATIVE_AI_API_KEY` | No | Gemini API key for AI-powered terminal summaries | +| `GOOGLE_GENERATIVE_AI_API_KEY` | No | Gemini API key for automatic conversation names and terminal summaries; takes precedence over Settings at startup | | `FRESHELL_CLAUDE_NODE` | No | Node executable for the isolated Claude SDK sidecar (normally set by Electron) | | `FRESHELL_CLAUDE_SIDECAR` | No | Claude sidecar entrypoint override for Rust development/service runs | | `FRESHELL_MCP_NODE` | No | Node executable for the standalone MCP client | diff --git a/crates/freshell-codex/tests/remote_proxy_relay.rs b/crates/freshell-codex/tests/remote_proxy_relay.rs index c599d4dc6..d235760ae 100644 --- a/crates/freshell-codex/tests/remote_proxy_relay.rs +++ b/crates/freshell-codex/tests/remote_proxy_relay.rs @@ -13,6 +13,8 @@ //! (`transport.rs` is gated the same way). #![cfg(feature = "real-transport")] +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; @@ -1818,3 +1820,153 @@ async fn canary_warning_does_not_suppress_the_first_over_cap_capacity_trigger() proxy.close().await; } + +// ── 14. the live repair-trigger loop stays fixed (name-reliability plan +// 2026-10-21, Task 12 Step 0) ──────────────────────────────────────────── + +/// The live log's repair-trigger flood (~1.6 lines per second per codex pane, +/// "upstream connection errored." then "client connection errored.") was one +/// loop per pane: the TUI polls `plugin/list`, whose ~17.8 MB response +/// exceeded the proxy's old 16 MiB transport default, so the upstream read +/// failed, the hub tore the connection down, and the TUI reconnected and +/// polled again. This is the size of that response. +const INCIDENT_PLUGIN_LIST_BYTES: usize = 17_800_000; + +/// How long the reconnecting TUI runs. The old caps looped roughly every +/// second on loopback, so this window would have seen several cycles. +const RECONNECT_LOOP_WINDOW: Duration = Duration::from_secs(5); + +/// An upstream app-server that answers EVERY request on EVERY connection with +/// the incident-sized `plugin/list` response (its sockets read at the proxy +/// caps, like a real app-server's uncapped reader). Counts accepted +/// connections. +async fn start_plugin_list_upstream() -> (String, Arc) { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&accepted); + let response = Arc::new( + json!({"id": 1, "result": {"data": huge_string(INCIDENT_PLUGIN_LIST_BYTES)}}).to_string(), + ); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + counter.fetch_add(1, Ordering::SeqCst); + let response = Arc::clone(&response); + tokio::spawn(async move { + let Ok(mut ws) = accept_async_with_config(stream, Some(proxy_ws_config())).await + else { + return; + }; + while let Some(Ok(message)) = ws.next().await { + if matches!(message, Message::Text(_)) + && ws + .send(Message::Text(response.as_ref().clone())) + .await + .is_err() + { + return; + } + } + }); + } + }); + (format!("ws://{}:{}", addr.ip(), addr.port()), accepted) +} + +/// What the reconnecting TUI saw by the end of its window. +struct TuiLoopReport { + connects: usize, + responses: usize, + /// The connection open at the deadline (closed by the caller after it has + /// drained the proxy's events, so the test's own teardown is not counted). + open: Option< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + >, +} + +/// The incident TUI: poll `plugin/list` every 500 ms and reconnect at once +/// whenever the socket ends, until `window` elapses. +async fn run_reconnecting_plugin_list_tui(proxy_url: &str, window: Duration) -> TuiLoopReport { + let deadline = tokio::time::Instant::now() + window; + let mut report = TuiLoopReport { + connects: 0, + responses: 0, + open: None, + }; + let poll = json!({"id": 1, "method": "plugin/list", "params": {}}).to_string(); + while tokio::time::Instant::now() < deadline { + let mut ws = connect_tui_with_ws_config(proxy_url, proxy_ws_config()).await; + report.connects += 1; + let mut next_poll = tokio::time::Instant::now(); + loop { + if tokio::time::Instant::now() >= deadline { + report.open = Some(ws); + return report; + } + if tokio::time::Instant::now() >= next_poll { + if ws.send(Message::Text(poll.clone())).await.is_err() { + break; + } + next_poll += Duration::from_millis(500); + } + let wake = next_poll.min(deadline); + match tokio::time::timeout_at(wake, ws.next()).await { + Err(_) => continue, + Ok(Some(Ok(Message::Text(text)))) => { + if text.len() >= INCIDENT_PLUGIN_LIST_BYTES { + report.responses += 1; + } + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(_))) | Ok(None) => break, + } + } + } + report +} + +#[tokio::test] +async fn incident_shaped_polling_tui_keeps_one_connection_and_logs_no_repair_trigger() { + // Production-default options (no cap injection): the 17.8 MB response + // relays, so the TUI is never disconnected, never reconnects, and the + // proxy reports nothing. With the old 16 MiB transport cap the same + // TUI reconnected every cycle (the loop the live log recorded). + let (upstream_url, accepted) = start_plugin_list_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream_url, true)) + .await + .unwrap(); + + let report = run_reconnecting_plugin_list_tui(proxy.ws_url(), RECONNECT_LOOP_WINDOW).await; + + let mut triggers = Vec::new(); + while let Ok(event) = events.try_recv() { + if let RemoteProxyEvent::RepairTrigger(trigger) = event { + triggers.push(trigger); + } + } + assert_eq!( + (report.connects, accepted.load(Ordering::SeqCst)), + (1, 1), + "the TUI keeps one connection (and the proxy one upstream dial) for the whole window \ + (repair triggers seen: {triggers:?})" + ); + assert!( + report.responses >= 2, + "the TUI must actually receive the incident-sized response (got {} in {:?})", + report.responses, + RECONNECT_LOOP_WINDOW + ); + assert_eq!( + triggers, + Vec::::new(), + "an incident-sized response must not produce repair triggers" + ); + + if let Some(mut ws) = report.open { + let _ = ws.close(None).await; + } + proxy.close().await; +} diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index 0123fe209..0b31c669a 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -108,8 +108,8 @@ tracing = "0.1" libc = "0.2" [dev-dependencies] -# `test-util` unlocks `#[tokio::test(start_paused = true)]`, so the shared quiet -# deadman's waiter tests drive its window on a paused clock. +# `test-util` drives the quiet deadman and naming bind's bounded retries on +# paused clocks. tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] } tower = { version = "0.5", features = ["util"] } # DIAG-01 test facility: a capturing `Layer` + `tracing::subscriber::set_default` diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index f9dd787b3..e55885555 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -6532,6 +6532,7 @@ mod send_outcome; pub use send_outcome::ClaudeSendOutcome; mod attach; +mod continuation_name; mod establish; mod fork; mod genesis; diff --git a/crates/freshell-freshagent/src/claude/continuation_name.rs b/crates/freshell-freshagent/src/claude/continuation_name.rs new file mode 100644 index 000000000..631be9cb7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/continuation_name.rs @@ -0,0 +1,41 @@ +//! Undo/redo replaces a runtime while continuing the same conversation. +//! Its child inherits the current saved name once; explicit fork-to-new +//! and ordinary conversation switches never call this operation. + +use super::*; +use crate::naming::NameError; +use freshell_protocol::session_names::{NamedProvider, SessionNameRef}; + +impl FreshClaudeState { + pub(super) async fn preserve_rollback_name( + &self, + original_session: &str, + replacement: &str, + session_type: &str, + ) -> Result<(), NameError> { + if session_type == "kilroy" { + return Ok(()); + } + let Some(sink) = self.naming() else { + return Ok(()); + }; + let durable = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: original_session.into(), + }; + // Establishment binds creates to their native UUID. A prior undo + // may have independently renamed this child, so copy this current + // durable record, never an ancestor's pending handle. + let source = durable; + let target = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: replacement.into(), + }; + sink.continue_session(source, target.clone()) + .await + .inspect_err(|error| { + crate::naming::log_name_error("internal_continuation", &target, error); + })?; + Ok(()) + } +} diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index d752c6c65..666f82317 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -965,6 +965,26 @@ impl FreshClaudeState { } }; + // Preserve the name before any identity announcement can re-key a + // resumed pane. The source stays independent, including its manual + // provenance. A failed name commit leaves the original live. + if self + .preserve_rollback_name(&durable_id, &replacement, session_type) + .await + .is_err() + { + self.unwind_replacement(&replacement, &durable_id, lease_held) + .await; + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "NAME_PERSISTENCE_FAILED", + "The conversation's saved name could not be retained. Try again.", + )); + return; + } + // ONE awaited ledger batch: the replacement's binding (its genesis, // the original's settings, `supersedes` = the original, which retires // the original's row and links it to the replacement) with the diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 2e0bbc63c..c6a1a5645 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -12282,6 +12282,7 @@ pub(crate) mod tests { document_generation: 1, changed: false, native_sync: None, + outcome: None, } } } diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index c6b924988..f53a635b9 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -67,6 +67,7 @@ pub mod layout_tree; pub mod model_capabilities; pub mod naming; pub mod native_history; +pub mod native_observation_retry; pub mod opencode_ws; pub mod pane_ops; mod pane_resize; @@ -78,10 +79,13 @@ mod process_probe; /// The wedged-runtime quiet deadman shared by the fresh-agent lanes (design /// 2026-10-22 §3.2, §5.6). pub(crate) mod quiet_deadman; +mod rename_response; +mod rename_target_cwd; pub mod rest_lane; pub(crate) mod rest_lane_create; mod rest_lane_sync; pub mod rollback_record; +mod scoped_name_rename; pub mod session_handoff; pub mod session_lease; pub mod session_metadata; @@ -89,6 +93,7 @@ pub mod snapshot; pub mod spawn_gate; pub(crate) mod summary; pub mod target_resolver; +pub mod terminal_name_binding; pub mod terminal_tabs; #[cfg(test)] mod test_environment; @@ -2241,6 +2246,12 @@ pub struct FreshAgentState { /// wired (the Option-until-wired convention): creates proceed, only the /// meta seeding is skipped. pub(crate) terminal_created_hook: Option, + /// Name reliability (Task 10): the shared identity table the REST door's + /// terminal naming bind writes through (`freshell-server`'s `main.rs` + /// injects the WebSocket door's identity binder, so both doors bind the + /// SAME table). `None` until wired: REST binds still write the registry + /// row and this state's handle stash. + pub(crate) terminal_name_binder: Option>, /// The `GET`/`POST /api/fresh-agent/model-capabilities/*` registry /// ([`model_capabilities`]): cwd-keyed TTL cache + single-flight coalescing /// over the transient opencode catalog probe, plus the static codex/claude @@ -2452,6 +2463,7 @@ impl FreshAgentState { sidecar_liveness: None, layout: layout_store::LayoutStore::default(), terminal_created_hook: None, + terminal_name_binder: None, model_capabilities: Arc::new(model_capabilities::ModelCapabilityRegistry::new( Arc::new(model_capabilities::OpencodeCatalogProbe::default()), )), @@ -2819,6 +2831,16 @@ impl FreshAgentState { self } + /// Name reliability (Task 10): wire the shared identity table the REST + /// door's terminal naming bind writes (see the field doc). + pub fn with_terminal_name_binder( + mut self, + binder: Arc, + ) -> Self { + self.terminal_name_binder = Some(binder); + self + } + /// Slice 1 (`docs/plans/2026-07-18-agent-api-mcp-parity-spec.md` \u00a79 Risk 1): /// wire in the SAME [`freshell_terminal::TerminalRegistry`] the WS /// `terminal.create` path uses, so Agent-API-created terminals live in ONE @@ -5050,6 +5072,7 @@ async fn create_tab( name: seed.to_string(), intent: freshell_protocol::session_names::NameIntent::Automatic, if_revision: None, + cwd: cwd.clone(), }) .await { @@ -5715,6 +5738,7 @@ async fn resume_session_ref_tab( name: seed.to_string(), intent: freshell_protocol::session_names::NameIntent::Automatic, if_revision: None, + cwd: cwd.clone(), }) .await { @@ -5838,8 +5862,16 @@ pub(crate) fn resolve_pane_name_target( target: None, }); } - // Unified agent names (Task 8, the T6-R3 sender-repair consequence): the - // pane's canonical binding rungs — `nameRef`, then the PRE-DURABLE + // The terminal's current naming binding is authoritative while the + // browser layout catches up with an in-pane conversation switch. + if let Some(target) = scoped_name_rename::terminal_binding(state, content) { + return Some(PaneNameResolution { + scoped: true, + target: Some(target), + }); + } + // Without a live terminal binding, use the client's canonical rungs + // (including fresh-agent panes): `nameRef`, then the PRE-DURABLE // `namingHandle`, then the durable `sessionRef`, then the create-lane // stash — in EXACTLY the client's approved order // (`resolvePaneNameRef`, sessionNameSelectors). The pre-durable handle @@ -6008,6 +6040,14 @@ pub(crate) fn effective_pane_name_resolution( body: &Value, ) -> Option { let capture = scoped_capture_of(body); + if let Some(target) = + scoped_name_rename::legacy_prebind_target(state, pane_id, capture.as_ref()) + { + return Some(PaneNameResolution { + scoped: true, + target: Some(target), + }); + } match resolve_pane_name_target(state, pane_id) { Some(resolution) if resolution.scoped => Some(PaneNameResolution { scoped: true, @@ -6101,7 +6141,23 @@ pub(crate) async fn rename_scoped_session( if let Some(expected) = body.get("expectedNameRef") { let expected_ref: Option = serde_json::from_value(expected.clone()).ok(); - if expected_ref.as_ref() != Some(&target) { + let capture_matches = match scoped_name_rename::capture_matches( + state, + expected_ref.as_ref(), + &target, + ) + .await + { + Ok(matches) => matches, + Err(error) => { + naming::log_name_error("rename_capture", &target, &error); + return scoped_name_error_response( + &error, + &serde_json::to_value(&target).unwrap_or(Value::Null), + ); + } + }; + if !capture_matches { return ( StatusCode::CONFLICT, Json(json!({ @@ -6162,89 +6218,24 @@ pub(crate) async fn rename_scoped_session( "session naming is unavailable on this server".to_string(), ); }; - match sink - .rename(naming::RenameNameInput { + // Name-reliability Task 4 (LB-B8): the pane's best-known folder. + let cwd = pane_id.and_then(|pane_id| rename_target_cwd::pane_rename_cwd(state, pane_id)); + match scoped_name_rename::rename( + state, + sink.as_ref(), + pane_id, + naming::RenameNameInput { target: target.clone(), name: name.clone(), intent, if_revision, - }) - .await + cwd: cwd.clone(), + }, + ) + .await { - Ok(update) => { - let mut data = Map::new(); - if let Some(tab_id) = tab_id { - data.insert("tabId".into(), json!(tab_id)); - } - if let Some(pane_id) = pane_id { - data.insert("paneId".into(), json!(pane_id)); - } - data.insert( - "nameRef".into(), - serde_json::to_value(&update.record.name_ref).unwrap_or(Value::Null), - ); - data.insert( - "sessionName".into(), - serde_json::to_value(&update).unwrap_or(Value::Null), - ); - ok_json(Value::Object(data), "session renamed") - } + Ok(update) => rename_response::scoped_rename_response(tab_id, pane_id, &update), Err(error) => { - // Unified agent names (Task 8 acceptance): the pre-bind - // fallback. A fresh claude pane's content carries the - // PROSPECTIVE preallocated sessionRef (both create doors put - // it there before any transcript exists), so the primary - // session-target rename answers NotFound until the first - // message materializes the identity. The pane's own naming - // binding pre-bind is its PENDING handle — resolve that and - // retry once. The `expectedNameRef` capture guard already - // passed against the pane's visible binding, and the 404 - // itself proves no established record exists to have switched - // conversations from, so the retry needs no second guard. - if matches!(error, naming::NameError::NotFound(_)) - && matches!( - target, - freshell_protocol::session_names::SessionNameRef::Session { .. } - ) - { - if let Some(pane_id) = pane_id { - if let Some(pending) = resolve_pane_pending_binding(state, pane_id) { - match sink - .rename(naming::RenameNameInput { - target: pending.clone(), - name, - intent, - if_revision, - }) - .await - { - Ok(update) => { - let mut data = Map::new(); - if let Some(tab_id) = tab_id { - data.insert("tabId".into(), json!(tab_id)); - } - data.insert("paneId".into(), json!(pane_id)); - data.insert( - "nameRef".into(), - serde_json::to_value(&update.record.name_ref) - .unwrap_or(Value::Null), - ); - data.insert( - "sessionName".into(), - serde_json::to_value(&update).unwrap_or(Value::Null), - ); - return ok_json(Value::Object(data), "session renamed"); - } - Err(retry_error) => { - naming::log_name_error("rename", &pending, &retry_error); - let target_value = - serde_json::to_value(&pending).unwrap_or(Value::Null); - return scoped_name_error_response(&retry_error, &target_value); - } - } - } - } - } naming::log_name_error("rename", &target, &error); let target_value = serde_json::to_value(&target).unwrap_or(Value::Null); scoped_name_error_response(&error, &target_value) diff --git a/crates/freshell-freshagent/src/naming.rs b/crates/freshell-freshagent/src/naming.rs index 23289a82d..5db8812aa 100644 --- a/crates/freshell-freshagent/src/naming.rs +++ b/crates/freshell-freshagent/src/naming.rs @@ -36,13 +36,38 @@ pub trait SessionNaming: Send + Sync { /// Idempotent: re-ensuring a bound handle resolves its durable record. fn ensure_pending(&self, input: PendingNameInput) -> NameFuture; + /// Admit a known session that has no name yet (for example immediately + /// after an in-pane conversation switch). Existing records are unchanged. + fn ensure_session( + &self, + _target: SessionNameRef, + _cwd: Option, + ) -> NameFuture { + Box::pin(async { Err(naming_unavailable("durable session admission")) }) + } + + /// Seed an internal continuation once from its source's saved name. + /// Durable identities remain independent: no redirect is created, an + /// existing child is unchanged, and an unnamed source produces no name. + /// Ordinary forks and new conversations must never call this operation. + fn continue_session( + &self, + _source: SessionNameRef, + _target: SessionNameRef, + ) -> NameFuture> { + Box::pin(async { Ok(None) }) + } + /// Atomically transfer a pending record onto its verified durable /// provider/session identity (rejecting prospective evidence). fn bind_pending(&self, input: BindNameInput) -> NameFuture; /// Rename with intent. `user` is the explicit human boundary (manual - /// source); `automatic` is an agent suggestion (provider_ai rank that can - /// never freeze a name). Optional `if_revision` is compare-and-set. + /// source, always applied); `automatic` is an agent suggestion recorded + /// at provider_ai rank, refused over a protected name or for a default + /// or derived label (classified with `cwd`). The answer's `outcome` + /// says whether the rename applied. Optional `if_revision` is + /// compare-and-set. fn rename(&self, input: RenameNameInput) -> NameFuture; /// Fold server-side coding-agent session activity: first-message @@ -90,13 +115,18 @@ pub struct BindNameInput { pub acquisition: NativeAcquisition, } -/// `rename` input. +/// `rename` input. `cwd` (name-reliability Task 4) is the best-known folder +/// of the rename's target — the terminal row's cwd for the pane, tab and +/// terminal routes, the session index row's cwd for the session-key routes — +/// so an automation rename to the target's own folder name is classified as +/// a derived label and refused. `None` recognizes folder-free labels only. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenameNameInput { pub target: SessionNameRef, pub name: String, pub intent: NameIntent, pub if_revision: Option, + pub cwd: Option, } /// Why activity arrived. `AcceptedUserMessage`/`IndexUserMessage` may carry a @@ -611,8 +641,15 @@ pub async fn session_projection( /// stamps the observation with the store's CURRENT verified location /// revision for the target before folding it — a genuinely stale observation /// (from a superseded route) is then provenance-only, exactly the plan's -/// old-location rule. `None` when no authority is wired or the fold failed — -/// an observation never blocks or fails the provider lane. +/// old-location rule. `None` when no authority is wired, the title is a +/// provider placeholder, or the inline attempt did not apply — an +/// observation never blocks or fails the provider lane. +/// +/// Name-reliability Task 4: a provider placeholder ("New session - ") +/// never reaches the store, and the observation makes ONE inline attempt; a +/// busy or failing store hands it to the background retrier +/// ([`crate::native_observation_retry`]), so the relay never waits for a +/// retry and an older title can never land after a newer one. pub async fn observe_native_live( sink: &Option>, target: SessionNameRef, @@ -621,21 +658,24 @@ pub async fn observe_native_live( event_id: Option, ) -> Option { let sink = sink.as_ref()?; - let location_revision = sink - .current_location_revision(&target) - .await - .inspect_err(|error| log_name_error("current_location_revision", &target, error)) - .ok()?; - sink.observe_native(NativeNameObservation { - target: target.clone(), - title: title.to_string(), - origin, - location_revision, - event_id, - }) - .await - .inspect_err(|error| log_name_error("observe_native", &target, error)) - .ok() + if is_placeholder_for_target(&target, title) { + return None; + } + let ticket = crate::native_observation_retry::reserve(sink, &target); + crate::native_observation_retry::submit(ticket, title.to_string(), origin, event_id).await +} + +/// Whether `title` is a provider placeholder for the target's provider (a +/// pending target's provider is not known yet: any provider's placeholder). +pub fn is_placeholder_for_target(target: &SessionNameRef, title: &str) -> bool { + match target { + SessionNameRef::Session { provider, .. } => { + freshell_sessions::is_provider_placeholder_title(provider.as_str(), title) + } + SessionNameRef::Pending { .. } => { + freshell_sessions::is_provider_placeholder_title("opencode", title) + } + } } #[cfg(test)] @@ -665,18 +705,67 @@ pub(crate) mod test_support { pub(crate) struct RecordingSink { pub renames: Mutex>, pub activities: Mutex>, + /// Name-reliability Task 4: every `observe_native` call, in arrival + /// order (failed ones included). + pub observe_attempts: Mutex>, + /// Name-reliability Task 4: the observations the sink ACCEPTED (an + /// injected failure is not accepted), in the order their + /// transactions finished. + pub observed: std::sync::Arc>>, + /// Name-reliability Task 4 review: the next `observe_native` call + /// waits on this before it finishes (a slow store transaction). + observe_gate: Mutex>>, + /// Name-reliability Task 4: failures answered by the next + /// `observe_native` calls, front first (a stalled store). + pub observe_failures: Mutex>, + /// Name-reliability Task 4: the answer of `current_location_revision` + /// (the trait default's ceiling unless a test moves it). + pub location_revision: std::sync::atomic::AtomicU64, records: Mutex>, redirects: Mutex>, } impl RecordingSink { pub fn new() -> std::sync::Arc { - std::sync::Arc::new(Self { - renames: Mutex::new(Vec::new()), - activities: Mutex::new(Vec::new()), - records: Mutex::new(Vec::new()), - redirects: Mutex::new(Vec::new()), - }) + std::sync::Arc::new(Self::default()) + } + + /// Make the next `count` observations fail with `LockUnavailable`. + pub fn fail_next_observations(&self, count: usize) { + let mut failures = self.observe_failures.lock().unwrap(); + for _ in 0..count { + failures.push_back(NameError::LockUnavailable( + "injected: the document lock stayed busy".into(), + )); + } + } + + /// Make the next `observe_native` call wait inside its transaction + /// until the returned sender fires (or is dropped). + pub fn block_next_observation(&self) -> tokio::sync::oneshot::Sender<()> { + let (release, gate) = tokio::sync::oneshot::channel(); + *self.observe_gate.lock().unwrap() = Some(gate); + release + } + + /// The titles of the observations the sink accepted, in order. + pub fn observed_titles(&self) -> Vec { + self.observed + .lock() + .unwrap() + .iter() + .map(|observation| observation.title.clone()) + .collect() + } + + /// The titles of every attempted observation, in order. + pub fn attempted_titles(&self) -> Vec { + self.observe_attempts + .lock() + .unwrap() + .iter() + .map(|observation| observation.title.clone()) + .collect() } fn resolve(&self, target: &SessionNameRef) -> Option { @@ -727,6 +816,7 @@ pub(crate) mod test_support { document_generation, changed, native_sync: None, + outcome: None, } } @@ -882,6 +972,10 @@ pub(crate) mod test_support { } fn observe_native(&self, input: NativeNameObservation) -> NameFuture { + self.observe_attempts.lock().unwrap().push(input.clone()); + if let Some(error) = self.observe_failures.lock().unwrap().pop_front() { + return Box::pin(async move { Err(error) }); + } let answer = match self.record_of(&input.target) { Some(record) => Ok(self.update_for(record, false)), None => Err(NameError::NotFound(format!( @@ -889,7 +983,27 @@ pub(crate) mod test_support { name_ref_debug_key(&input.target) ))), }; - Box::pin(async move { answer }) + let gate = self.observe_gate.lock().unwrap().take(); + let observed = std::sync::Arc::clone(&self.observed); + Box::pin(async move { + if let Some(gate) = gate { + let _ = gate.await; + } + if answer.is_ok() { + observed.lock().unwrap().push(input); + } + answer + }) + } + + fn current_location_revision( + &self, + _target: &SessionNameRef, + ) -> NameFuture { + let revision = self + .location_revision + .load(std::sync::atomic::Ordering::SeqCst); + Box::pin(async move { Ok(revision) }) } fn record_acquisition( @@ -913,12 +1027,27 @@ pub(crate) mod test_support { Self { renames: Mutex::new(Vec::new()), activities: Mutex::new(Vec::new()), + observe_attempts: Mutex::new(Vec::new()), + observed: std::sync::Arc::new(Mutex::new(Vec::new())), + observe_gate: Mutex::new(None), + observe_failures: Mutex::new(std::collections::VecDeque::new()), + location_revision: std::sync::atomic::AtomicU64::new( + freshell_protocol::session_names::MAX_NAME_REVISION, + ), records: Mutex::new(Vec::new()), redirects: Mutex::new(Vec::new()), } } } + /// Seed a record directly (tests that observe a durable session the + /// sink never admitted). + impl RecordingSink { + pub fn seed_record(&self, record: SessionNameRecord) { + self.set_record(record.name_ref.clone(), record); + } + } + /// A verified claude acquisition for bind tests. pub(crate) fn verified_claude_acquisition( session_id: &str, @@ -1179,6 +1308,7 @@ mod tests { name: "Named Before Identity".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); diff --git a/crates/freshell-freshagent/src/native_observation_retry.rs b/crates/freshell-freshagent/src/native_observation_retry.rs new file mode 100644 index 000000000..71c02c3cf --- /dev/null +++ b/crates/freshell-freshagent/src/native_observation_retry.rs @@ -0,0 +1,556 @@ +//! Native title observations never stall an event relay (name-reliability +//! plan, Task 4; G14, G46). +//! +//! Provider lanes (the freshopencode SSE bridge, the codex proxy route, the +//! CLI opencode lane, freshcodex) fold every native title they see into the +//! naming store. The store's transaction takes a cross-process file lock +//! with a one-second budget, so a relay loop that awaited a retry inline +//! would stall behind a busy store. Instead each observation is built ONCE — +//! its location revision stamped at observation time — and makes one +//! attempt; a `LockUnavailable`/`Persistence` failure hands that SAME +//! observation to a background retry (after 200 ms, then 1 s, then it is +//! dropped: the next observation of the target records it again). A retry +//! re-submits the captured observation unchanged, never re-stamped, so if +//! the provider location moved meanwhile it folds as stale (provenance +//! only). +//! +//! Ordering: every observation reserves a per-target sequence number when it +//! is observed (in relay order), and a submission — inline or retry — +//! applies only while its sequence is still the latest reserved for its +//! target, one submission per target at a time. An older title can therefore +//! never land after a newer one. +//! +//! The inline attempt never waits for its target's turn: when another +//! submission of the same target is still inside the store (a background +//! retry, or a concurrent lane's inline attempt), the new observation goes +//! straight to the background, where it takes the turn as soon as it is +//! free (then the same 200 ms / 1 s ladder). The relay's worst case stays +//! one store attempt, never two back to back. +//! +//! The bookkeeping is process-wide, keyed by (sink, target): one sink is one +//! naming store, so this is a per-sink retrier. A target's entry lives only +//! while one of its observations is in flight. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use freshell_protocol::session_names::{SessionNameRef, SessionNameUpdate}; + +use crate::naming::{ + log_name_error, name_ref_debug_key, NameError, NativeNameObservation, NativeNameOrigin, + SessionNaming, +}; + +/// The background retry delays after a failed inline attempt. +const RETRY_DELAYS: [Duration; 2] = [Duration::from_millis(200), Duration::from_millis(1_000)]; + +/// (sink identity, target key). +type TargetKey = (usize, String); + +struct Slot { + /// The newest sequence reserved for the target. + latest: u64, + /// Reserved observations of the target not yet finished. + in_flight: usize, + /// One submission per target at a time. + submit: Arc>, +} + +#[derive(Default)] +struct Registry { + next_seq: u64, + slots: HashMap, +} + +fn registry() -> std::sync::MutexGuard<'static, Registry> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY + .get_or_init(|| Mutex::new(Registry::default())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// One reserved observation of a target. Dropping it releases its claim on +/// the target's bookkeeping. +pub struct ObservationTicket { + key: TargetKey, + seq: u64, + sink: Arc, + target: SessionNameRef, + submit: Arc>, +} + +impl ObservationTicket { + fn is_latest(&self) -> bool { + registry() + .slots + .get(&self.key) + .is_some_and(|slot| slot.latest == self.seq) + } +} + +impl Drop for ObservationTicket { + fn drop(&mut self) { + let mut registry = registry(); + let remove = match registry.slots.get_mut(&self.key) { + Some(slot) => { + slot.in_flight = slot.in_flight.saturating_sub(1); + slot.in_flight == 0 + } + None => false, + }; + if remove { + registry.slots.remove(&self.key); + } + } +} + +/// Reserve the next sequence for an observation of `target`, superseding +/// every older observation of it still waiting. Synchronous, so a lane that +/// spawns its folds reserves them in relay order. +pub fn reserve(sink: &Arc, target: &SessionNameRef) -> ObservationTicket { + let key: TargetKey = ( + Arc::as_ptr(sink) as *const () as usize, + name_ref_debug_key(target), + ); + let mut registry = registry(); + registry.next_seq += 1; + let seq = registry.next_seq; + let slot = registry.slots.entry(key.clone()).or_insert_with(|| Slot { + latest: 0, + in_flight: 0, + submit: Arc::new(tokio::sync::Mutex::new(())), + }); + slot.latest = seq; + slot.in_flight += 1; + let submit = Arc::clone(&slot.submit); + ObservationTicket { + key, + seq, + sink: Arc::clone(sink), + target: target.clone(), + submit, + } +} + +enum Attempt { + Applied(SessionNameUpdate), + Superseded, + Retryable(NameError), + Failed(NameError), +} + +/// How an observation reaches the background. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Handoff { + /// The inline attempt failed on a busy or failing store: the backoff + /// ladder follows. + AfterFailure, + /// Another submission of the target held its turn: the observation has + /// made no attempt yet, so it takes the turn as soon as it is free, then + /// the ladder. + TargetBusy, +} + +/// One submission. The caller holds the target's turn for the whole call. +async fn fold(ticket: &ObservationTicket, observation: NativeNameObservation) -> Attempt { + if !ticket.is_latest() { + return Attempt::Superseded; + } + match ticket.sink.observe_native(observation).await { + Ok(update) => Attempt::Applied(update), + Err(error @ (NameError::LockUnavailable(_) | NameError::Persistence(_))) => { + Attempt::Retryable(error) + } + Err(error) => Attempt::Failed(error), + } +} + +/// One submission that waits for the target's turn (background only). +async fn attempt(ticket: &ObservationTicket, observation: NativeNameObservation) -> Attempt { + let _turn = ticket.submit.lock().await; + fold(ticket, observation).await +} + +/// Fold one reserved observation: stamp it with the store's current +/// location revision NOW, make one attempt, and on a busy or failing store +/// hand the same observation to the background retry. When another +/// submission of the target is still inside the store, the observation goes +/// to the background without an inline attempt. Returns the accepted update +/// when the inline attempt applied; `None` otherwise (the caller never waits +/// for a retry or for another submission). +pub async fn submit( + ticket: ObservationTicket, + title: String, + origin: NativeNameOrigin, + event_id: Option, +) -> Option { + let location_revision = match ticket.sink.current_location_revision(&ticket.target).await { + Ok(revision) => revision, + Err(error) => { + log_name_error("current_location_revision", &ticket.target, &error); + return None; + } + }; + let observation = NativeNameObservation { + target: ticket.target.clone(), + title, + origin, + location_revision, + event_id, + }; + let turn = Arc::clone(&ticket.submit); + let outcome = match turn.try_lock() { + Ok(_turn) => fold(&ticket, observation.clone()).await, + Err(_) => { + tracing::debug!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + "session_names.native_observation_queued_behind_in_flight" + ); + tokio::spawn(retry(ticket, observation, Handoff::TargetBusy)); + return None; + } + }; + match outcome { + Attempt::Applied(update) => Some(update), + Attempt::Superseded => None, + Attempt::Failed(error) => { + log_name_error("observe_native", &ticket.target, &error); + None + } + Attempt::Retryable(error) => { + tracing::debug!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + class = %error.code(), + "session_names.native_observation_retry_scheduled" + ); + tokio::spawn(retry(ticket, observation, Handoff::AfterFailure)); + None + } + } +} + +/// The background attempts: a busy-target handoff first takes the turn as +/// soon as it is free; then the backoff ladder. Every observation makes at +/// most `RETRY_DELAYS.len() + 1` attempts in all. +async fn retry(ticket: ObservationTicket, observation: NativeNameObservation, handoff: Handoff) { + // `None`: the queued attempt (no delay, not a retry). + let queued = (handoff == Handoff::TargetBusy).then_some(None); + let schedule: Vec> = queued + .into_iter() + .chain(RETRY_DELAYS.into_iter().map(Some)) + .collect(); + let last = schedule.len() - 1; + let mut retries = 0usize; + for (index, delay) in schedule.into_iter().enumerate() { + if let Some(delay) = delay { + retries += 1; + tokio::time::sleep(delay).await; + } + match attempt(&ticket, observation.clone()).await { + Attempt::Applied(_) => { + // A busy-target handoff that lands at once was queued, not + // retried: only a real retry is worth an INFO line. + if delay.is_none() { + tracing::debug!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + "session_names.native_observation_queued_applied" + ); + } else { + tracing::info!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + retry = retries, + "session_names.native_observation_retry_applied" + ); + } + return; + } + Attempt::Superseded => { + tracing::debug!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + "session_names.native_observation_retry_superseded" + ); + return; + } + Attempt::Failed(error) => { + log_name_error("observe_native", &ticket.target, &error); + return; + } + Attempt::Retryable(error) => { + if index == last { + tracing::warn!( + target: "freshell_freshagent::naming", + op = "observe_native", + name_ref = %name_ref_debug_key(&ticket.target), + revision = error.log_revision(), + class = %error.code(), + attempts = RETRY_DELAYS.len() + 1, + "session_names.native_observation_retry_gave_up: {error}" + ); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::{Duration, Instant}; + + use freshell_protocol::session_names::{ + NameSource, NamedProvider, SessionNameRecord, SessionNameRef, + }; + + use crate::naming::test_support::RecordingSink; + use crate::naming::{observe_native_live, NativeNameOrigin, SessionNaming}; + + fn target(id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Opencode, + session_id: id.to_string(), + } + } + + /// A sink that already holds a record for `id`, so an accepted + /// observation answers `Ok`. + fn sink_for(id: &str) -> Arc { + let sink = RecordingSink::new(); + sink.seed_record(SessionNameRecord { + name_ref: target(id), + name: "proj".into(), + source: NameSource::Directory, + revision: 1, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }); + sink + } + + fn dyn_sink(sink: &Arc) -> Option> { + Some(sink.clone() as Arc) + } + + async fn wait_until(budget: Duration, mut done: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + budget; + while Instant::now() < deadline { + if done() { + return true; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + done() + } + + /// A locked store never stalls the caller: the inline attempt fails, the + /// call returns at once, and the background retry lands the title. + #[tokio::test] + async fn lock_error_retries_in_background_without_blocking_the_caller() { + let sink = sink_for("ses_lock"); + sink.fail_next_observations(1); + let started = Instant::now(); + let answer = observe_native_live( + &dyn_sink(&sink), + target("ses_lock"), + "Publishing blocked by token error", + NativeNameOrigin::Snapshot, + None, + ) + .await; + let elapsed = started.elapsed(); + assert!(answer.is_none(), "the inline attempt failed"); + assert!( + elapsed < Duration::from_millis(20), + "the caller returned at once ({elapsed:?})" + ); + assert!( + wait_until(Duration::from_secs(3), || { + sink.observed_titles() == vec!["Publishing blocked by token error".to_string()] + }) + .await, + "the background retry landed the title: attempts {:?}", + sink.attempted_titles() + ); + } + + /// A retry applies only while it is still the latest observation of its + /// target: "A" fails, "B" succeeds inline, so the retry of "A" is dropped + /// and "B" stays the last word. + #[tokio::test] + async fn an_older_title_never_lands_after_a_newer_one() { + let sink = sink_for("ses_order"); + sink.fail_next_observations(1); + observe_native_live( + &dyn_sink(&sink), + target("ses_order"), + "A", + NativeNameOrigin::Snapshot, + None, + ) + .await; + let b = observe_native_live( + &dyn_sink(&sink), + target("ses_order"), + "B", + NativeNameOrigin::Snapshot, + None, + ) + .await; + assert!(b.is_some(), "B succeeds inline"); + // Past both retry delays (200 ms, then 1 s). + tokio::time::sleep(Duration::from_millis(1_500)).await; + assert_eq!( + sink.observed_titles(), + vec!["B".to_string()], + "the superseded retry of A never landed" + ); + assert_eq!( + sink.attempted_titles(), + vec!["A".to_string(), "B".to_string()], + "A was never re-submitted" + ); + } + + /// The retry re-submits the captured observation unchanged: its + /// location stamp is the one taken at observation time, so a location + /// that moved meanwhile makes it stale, never current. + #[tokio::test] + async fn a_retry_keeps_the_original_location_stamp() { + let sink = sink_for("ses_stamp"); + sink.location_revision + .store(3, std::sync::atomic::Ordering::SeqCst); + sink.fail_next_observations(1); + observe_native_live( + &dyn_sink(&sink), + target("ses_stamp"), + "Stamped title", + NativeNameOrigin::Snapshot, + None, + ) + .await; + // The provider location moves before the retry runs. + sink.location_revision + .store(4, std::sync::atomic::Ordering::SeqCst); + assert!( + wait_until(Duration::from_secs(3), || !sink + .observed_titles() + .is_empty()) + .await, + "the retry ran" + ); + let observed = sink.observed.lock().unwrap().clone(); + assert_eq!(observed.len(), 1); + assert_eq!( + observed[0].location_revision, 3, + "the retry folds with the original stamp" + ); + } + + /// The retries are bounded: 200 ms, then 1 s, then the observation is + /// dropped (the next observation of the target records it again). + #[tokio::test] + async fn a_retry_gives_up_after_its_bounded_attempts() { + let sink = sink_for("ses_bounded"); + sink.fail_next_observations(10); + observe_native_live( + &dyn_sink(&sink), + target("ses_bounded"), + "Never lands", + NativeNameOrigin::Snapshot, + None, + ) + .await; + tokio::time::sleep(Duration::from_millis(2_000)).await; + assert_eq!( + sink.attempted_titles().len(), + 3, + "one inline attempt plus two retries: {:?}", + sink.attempted_titles() + ); + assert!(sink.observed_titles().is_empty()); + } + + /// A new observation never waits behind its target's in-flight + /// background retry: while that retry sits inside a slow store + /// transaction, the next inline call returns at once, and its title + /// lands once the retry finishes — after it, so the newest title is the + /// last word. + #[tokio::test] + async fn an_inline_observation_never_waits_behind_an_in_flight_retry() { + let sink = sink_for("ses_busy"); + sink.fail_next_observations(1); + observe_native_live( + &dyn_sink(&sink), + target("ses_busy"), + "A", + NativeNameOrigin::Snapshot, + None, + ) + .await; + // The background retry of "A" blocks inside the store until released. + let release = sink.block_next_observation(); + assert!( + wait_until(Duration::from_secs(2), || sink.attempted_titles().len() + == 2) + .await, + "the retry of A is in flight: {:?}", + sink.attempted_titles() + ); + + let inline = tokio::time::timeout( + Duration::from_millis(200), + observe_native_live( + &dyn_sink(&sink), + target("ses_busy"), + "B", + NativeNameOrigin::Snapshot, + None, + ), + ) + .await; + let inline = inline.expect("the inline call returned while the retry was in flight"); + assert!(inline.is_none(), "B is not answered inline"); + + release.send(()).expect("the retry of A still waits"); + assert!( + wait_until(Duration::from_secs(3), || { + sink.observed_titles() == vec!["A".to_string(), "B".to_string()] + }) + .await, + "B lands after the retry of A finished: observed {:?}, attempted {:?}", + sink.observed_titles(), + sink.attempted_titles() + ); + } + + /// OpenCode's placeholder title never even reaches the store. + #[tokio::test] + async fn a_provider_placeholder_never_reaches_the_sink() { + let sink = sink_for("ses_placeholder"); + let answer = observe_native_live( + &dyn_sink(&sink), + target("ses_placeholder"), + "New session - 2026-10-20T00:09:25.350Z", + NativeNameOrigin::Snapshot, + None, + ) + .await; + assert!(answer.is_none()); + assert!( + sink.attempted_titles().is_empty(), + "no transaction for a placeholder" + ); + } +} diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index c2c2f06b3..286d15038 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -10147,6 +10147,103 @@ mod tests { ); } + /// Name-reliability Task 4: the SSE bridge folds `session.updated` titles + /// without ever stalling its relay. OpenCode's placeholder title never + /// reaches the naming store; the real title's inline fold fails on a + /// locked store, yet the following non-title event is relayed at once, + /// and the background retry lands the real title afterwards. + #[tokio::test] + async fn bridge_title_observations_skip_placeholders_and_never_stall_the_relay() { + let (tx, mut rx) = tokio::sync::broadcast::channel::(64); + let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + let (manager, _killed) = started_manager().await; + fresh_agent.set_manager_for_test(manager.clone()).await; + let st = FreshOpencodeState::new(fresh_agent); + let sink = crate::naming::test_support::RecordingSink::new(); + sink.seed_record(freshell_protocol::session_names::SessionNameRecord { + name_ref: freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Opencode, + session_id: "ses_title".to_string(), + }, + name: "proj".into(), + source: freshell_protocol::session_names::NameSource::Directory, + revision: 1, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }); + // The store is locked for the real title's inline attempt. + sink.fail_next_observations(1); + st.set_session_naming(sink.clone()); + let _bridge = st.spawn_serve_bridge( + manager.clone(), + "ses_title".to_string(), + Arc::new(AtomicBool::new(false)), + ); + + let title_event = |title: &str| freshell_opencode::ParsedServeEvent { + kind: "session.updated".to_string(), + session_id: Some("ses_title".to_string()), + properties: { + let mut properties = serde_json::Map::new(); + properties.insert( + "info".to_string(), + json!({ "id": "ses_title", "title": title }), + ); + properties + }, + raw: serde_json::Map::new(), + }; + manager.dispatch_event(title_event("New session - 2026-10-20T00:09:25.350Z")); + manager.dispatch_event(title_event("Publishing blocked by token error")); + let relay_started = std::time::Instant::now(); + manager.dispatch_event(freshell_opencode::ParsedServeEvent { + kind: "message.updated".to_string(), + session_id: Some("ses_title".to_string()), + properties: serde_json::Map::new(), + raw: serde_json::Map::new(), + }); + + // The non-title event is relayed promptly, before any retry delay. + let relayed = tokio::time::timeout(Duration::from_millis(150), async { + loop { + let raw = rx.recv().await.expect("bridge frame"); + let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); + if frame["event"]["type"] == "freshAgent.session.changed" { + return frame; + } + } + }) + .await + .expect("the following frame is relayed while the store is locked"); + assert_eq!(relayed["sessionId"], "ses_title"); + assert!(relay_started.elapsed() < Duration::from_millis(150)); + + // The retry lands the real title; the placeholder never reached the store. + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if !sink.observed_titles().is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the background retry folded the real title"); + assert_eq!( + sink.observed_titles(), + vec!["Publishing blocked by token error".to_string()] + ); + assert!( + !sink + .attempted_titles() + .iter() + .any(|title| title.starts_with("New session - ")), + "the placeholder never reached the store: {:?}", + sink.attempted_titles() + ); + } + /// The concurrent variant: two GENUINELY CONCURRENT creates sharing a `requestId` /// must still construct exactly ONE session object (never two, racing to overwrite /// each other in the `sessions` map). diff --git a/crates/freshell-freshagent/src/rename_response.rs b/crates/freshell-freshagent/src/rename_response.rs new file mode 100644 index 000000000..03999245b --- /dev/null +++ b/crates/freshell-freshagent/src/rename_response.rs @@ -0,0 +1,52 @@ +//! Name-reliability Task 4: the success response of the fresh-agent pane and +//! tab rename routes once the naming store accepted the request. The message +//! follows the rename's `outcome`, so a refused automation rename never says +//! "session renamed" beside `applied: false` (the CLI prints this body and +//! the MCP tool returns it). + +use axum::response::Response; +use freshell_protocol::session_names::{RenameOutcome, SessionNameUpdate}; +use serde_json::{json, Map, Value}; + +/// `{status:'ok', data:{tabId?, paneId?, nameRef, sessionName}, message}`. +pub(crate) fn scoped_rename_response( + tab_id: Option<&str>, + pane_id: Option<&str>, + update: &SessionNameUpdate, +) -> Response { + let mut data = Map::new(); + if let Some(tab_id) = tab_id { + data.insert("tabId".into(), json!(tab_id)); + } + if let Some(pane_id) = pane_id { + data.insert("paneId".into(), json!(pane_id)); + } + data.insert( + "nameRef".into(), + serde_json::to_value(&update.record.name_ref).unwrap_or(Value::Null), + ); + data.insert( + "sessionName".into(), + serde_json::to_value(update).unwrap_or(Value::Null), + ); + crate::ok_json(Value::Object(data), &rename_message(update)) +} + +/// "session renamed" when the saved name now is the requested text (or no +/// outcome was reported); otherwise `session name not applied: `, +/// the reason as the wire spells it (`protected_name`, `default_label`). +fn rename_message(update: &SessionNameUpdate) -> String { + match update.outcome { + Some(RenameOutcome { + applied: false, + reason, + }) => { + let reason = reason + .and_then(|reason| serde_json::to_value(reason).ok()) + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + format!("session name not applied: {reason}") + } + _ => "session renamed".to_string(), + } +} diff --git a/crates/freshell-freshagent/src/rename_target_cwd.rs b/crates/freshell-freshagent/src/rename_target_cwd.rs new file mode 100644 index 000000000..54944688a --- /dev/null +++ b/crates/freshell-freshagent/src/rename_target_cwd.rs @@ -0,0 +1,41 @@ +//! Name-reliability Task 4 (LB-B8): the best-known folder of a pane rename's +//! target, so an automation rename to the target's own folder name is +//! classified as a derived label (and refused) by the naming store. +//! +//! A terminal pane's folder is its terminal row's cwd (the shared registry); +//! a fresh-agent pane's is the folder its session was created in (the pane +//! content's `initialCwd`). `None` when neither is known: the store then +//! recognizes folder-free labels only. + +use serde_json::Value; + +use crate::FreshAgentState; + +/// The folder a rename through the pane (or its tab) should be classified +/// with. +pub(crate) fn pane_rename_cwd(state: &FreshAgentState, pane_id: &str) -> Option { + let snapshot = state.layout.get_pane_snapshot(pane_id)?; + let content = snapshot.pane_content.as_ref()?; + content_rename_cwd(state, content) +} + +fn content_rename_cwd(state: &FreshAgentState, content: &Value) -> Option { + let from_terminal = content + .get("terminalId") + .and_then(Value::as_str) + .and_then(|terminal_id| { + state + .terminal_registry + .as_ref() + .and_then(|registry| registry.probe(terminal_id)) + .and_then(|row| row.cwd) + }); + from_terminal + .or_else(|| { + content + .get("initialCwd") + .and_then(Value::as_str) + .map(str::to_string) + }) + .filter(|cwd| !cwd.trim().is_empty()) +} diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs index 3b15548e0..cf93f22c9 100644 --- a/crates/freshell-freshagent/src/rest_lane_create.rs +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -162,6 +162,7 @@ impl FreshAgentState { name: name.to_string(), intent, if_revision: None, + cwd: request.cwd.clone(), }) .await { diff --git a/crates/freshell-freshagent/src/scoped_name_rename.rs b/crates/freshell-freshagent/src/scoped_name_rename.rs new file mode 100644 index 000000000..d421bd588 --- /dev/null +++ b/crates/freshell-freshagent/src/scoped_name_rename.rs @@ -0,0 +1,182 @@ +//! A pane's prospective session can still name through its unspent pending +//! handle. After a conversation switch a spent handle must never retarget +//! the rename to the old conversation: admit the new known session instead. + +use crate::naming::{ + is_unified_agent_mode, named_provider_for, NameError, RenameNameInput, SessionNaming, +}; +use crate::FreshAgentState; +use freshell_protocol::session_names::{SessionNameRef, SessionNameUpdate}; + +pub(crate) fn terminal_binding( + state: &FreshAgentState, + content: &serde_json::Value, +) -> Option { + if content.get("kind").and_then(serde_json::Value::as_str) != Some("terminal") { + return None; + } + let terminal_id = content.get("terminalId")?.as_str()?; + state.terminal_registry.as_ref()?.name_ref_of(terminal_id) +} + +struct LegacyPrebind { + prospective: SessionNameRef, + pending: SessionNameRef, +} + +fn legacy_prebind(state: &FreshAgentState, pane_id: &str) -> Option { + let snapshot = state.layout.get_pane_snapshot(pane_id)?; + let content = snapshot.pane_content.as_ref()?; + if !is_unified_agent_mode( + content.get("mode").and_then(serde_json::Value::as_str), + None, + ) || content.get("nameRef").is_some_and(|value| !value.is_null()) + || content + .get("namingHandle") + .and_then(serde_json::Value::as_str) + .is_some_and(|handle| !handle.trim().is_empty()) + { + return None; + } + let pending = terminal_binding(state, content)?; + if !matches!(pending, SessionNameRef::Pending { .. }) { + return None; + } + let locator: freshell_protocol::SessionLocator = + serde_json::from_value(content.get("sessionRef")?.clone()).ok()?; + if locator.session_id.trim().is_empty() { + return None; + } + let provider = named_provider_for(Some(&locator.provider), None)?; + Some(LegacyPrebind { + prospective: SessionNameRef::Session { + provider, + session_id: locator.session_id, + }, + pending, + }) +} + +/// Older editors know only the prospective sessionRef. Keep their original +/// session-first/pending-fallback path. A modern pending capture still uses +/// the authoritative registry, even when its layout replica lacks the fields. +pub(crate) fn legacy_prebind_target( + state: &FreshAgentState, + pane_id: &str, + capture: Option<&SessionNameRef>, +) -> Option { + let legacy = legacy_prebind(state, pane_id)?; + capture + .is_none_or(|target| target == &legacy.prospective) + .then_some(legacy.prospective) +} + +/// A bare sessionRef may be a preallocated runtime id rather than an +/// established naming target. Only an explicit durable naming ref or the +/// current durable registry binding authorizes creating its missing name. +fn may_admit_session( + state: &FreshAgentState, + pane_id: Option<&str>, + target: &SessionNameRef, +) -> bool { + let Some(snapshot) = pane_id.and_then(|id| state.layout.get_pane_snapshot(id)) else { + return false; + }; + let Some(content) = snapshot.pane_content.as_ref() else { + return false; + }; + terminal_binding(state, content).as_ref() == Some(target) + || content + .get("nameRef") + .and_then(|raw| serde_json::from_value::(raw.clone()).ok()) + .as_ref() + == Some(target) +} + +/// Materialization changes a pending ref to its own session between editor +/// capture and submit. That verified redirect keeps the same target. A +/// redirect to an earlier conversation never makes a switched capture valid. +pub(crate) async fn capture_matches( + state: &FreshAgentState, + expected: Option<&SessionNameRef>, + target: &SessionNameRef, +) -> Result { + if expected == Some(target) { + return Ok(true); + } + let Some(expected @ SessionNameRef::Pending { .. }) = expected else { + return Ok(false); + }; + if !matches!(target, SessionNameRef::Session { .. }) { + return Ok(false); + } + let sink = state + .naming() + .ok_or_else(|| crate::naming::naming_unavailable("rename capture resolution"))?; + let updates = sink.get(vec![expected.clone()]).await?; + Ok(updates.into_iter().any(|update| { + &update.record.name_ref == target + && update + .redirects + .iter() + .any(|redirect| &redirect.from == expected && &redirect.to == target) + })) +} + +pub(crate) async fn rename( + state: &FreshAgentState, + sink: &dyn SessionNaming, + pane_id: Option<&str>, + input: RenameNameInput, +) -> Result { + if let Some(legacy) = pane_id + .and_then(|id| legacy_prebind(state, id)) + .filter(|legacy| legacy.prospective == input.target) + { + let updates = sink.get(vec![legacy.pending.clone()]).await?; + if updates.iter().any(|update| { + update.record.name_ref != legacy.pending && update.record.name_ref != input.target + }) { + return Err(NameError::NotFound( + "the pre-bind naming handle belongs to a different conversation".into(), + )); + } + } + let error = match sink.rename(input.clone()).await { + Ok(update) => return Ok(update), + Err(error) => error, + }; + if !matches!(error, NameError::NotFound(_)) + || !matches!(input.target, SessionNameRef::Session { .. }) + { + return Err(error); + } + + if let Some(pending) = pane_id.and_then(|id| crate::resolve_pane_pending_binding(state, id)) { + let updates = sink.get(vec![pending.clone()]).await?; + if let Some(update) = updates.into_iter().find(|update| { + update.record.name_ref == pending || update.record.name_ref == input.target + }) { + // The pending handle may bind while this request is in flight. + // Pin the observed revision so that redirect cannot silently + // send this rename to a different conversation after the read. + let revision = input.if_revision.or(Some(update.record.revision)); + return sink + .rename(RenameNameInput { + target: pending, + if_revision: revision, + ..input + }) + .await; + } + } + // With no applicable pending name, a genuinely established target may + // precede its index row. A bare prospective id still returns 404. + if !may_admit_session(state, pane_id, &input.target) { + return Err(error); + } + // Admission never replaces an existing (possibly protected) name. + sink.ensure_session(input.target.clone(), input.cwd.clone()) + .await?; + sink.rename(input).await +} diff --git a/crates/freshell-freshagent/src/terminal_name_binding.rs b/crates/freshell-freshagent/src/terminal_name_binding.rs new file mode 100644 index 000000000..dc032f63a --- /dev/null +++ b/crates/freshell-freshagent/src/terminal_name_binding.rs @@ -0,0 +1,976 @@ +//! Name reliability (Task 10): one naming bind for every terminal row path. +//! +//! Every terminal row the server creates or adopts — the WebSocket and REST +//! creates, resume, auto-resume, managed register/reattach, compatibility +//! adoption — binds its saved name through [`bind_terminal_naming_from_identity`]. +//! It resolves the naming target (the durable scoped session when the caller +//! knows one, else a pre-durable pending handle), writes the door's identity +//! binding and the registry binding together, then reads the record into the +//! row's display cache and title. A transient store failure is retried +//! (50 ms, 250 ms, 1 s) and then deferred to the 2 s tick with the binding +//! kept — a row is never left unbound, and "read failed" never falls into the +//! fresh pending path the way "no record" may. +//! +//! The core lives in this crate so both doors can call it: `freshell-ws` +//! depends on `freshell-freshagent`, never the reverse, so each door bridges +//! its own identity table in through [`TerminalNameBinder`] (the +//! `identity_sink.rs` trait precedent). +//! +//! [`follow_identity_name_ref`] is the conversation-switch arm: when a door's +//! identity table moves a terminal onto a new durable session (Claude's +//! `/clear`, an OpenCode switch, a new Codex thread), the registry binding +//! follows and the row adopts the NEW session's record — or, with none yet, +//! drops the old session's cached name for the mode label. The switch never +//! creates or changes a record. [`reconcile_terminal_names`] is the tick: +//! it backstops moves no caller reported, binds scoped rows that have no +//! binding, and fills live bound rows whose cache is empty. +//! +//! Every read here is awaited after the binding it serves was written, and a +//! newer move (a quick second switch, a switch during a create) can land +//! while it is in flight. An answer is therefore applied only while the row +//! is still bound to the ref that was read; otherwise it is dropped +//! ([`NamingBindOutcome::Superseded`]) and the newer move owns the row. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use freshell_protocol::session_names::{NamedProvider, SessionNameRecord, SessionNameRef}; +use freshell_terminal::TerminalRegistry; + +use crate::naming::{ + log_name_error, name_ref_debug_key, named_provider_for, NameError, PendingNameInput, + SessionNaming, +}; + +#[path = "terminal_name_cache.rs"] +mod cache; +use cache::{answers, fill_uncached_rows, UncachedRow}; + +/// The bounded retry schedule for a transient store failure: each delay +/// precedes one more attempt, so a persistently busy store costs at most +/// ~1.3 s before the bind defers to the tick. +const BIND_RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(50), + Duration::from_millis(250), + Duration::from_millis(1000), +]; + +/// How long a scoped row with no binding is left to its own create path +/// before the tick binds it. A row is visible in the registry from its spawn, +/// a beat before the create's naming admission (ledger writes and the +/// ownership settle run in between); binding it from the tick inside that +/// window would mint a handle that pre-empts the client's own `namingHandle`. +const UNBOUND_ROW_GRACE_MS: i64 = 30_000; + +/// The longest back-off, in tick passes (~64 s at the 2 s cadence), before a +/// live row bound to a record-less durable session is read again. +const EMPTY_ANSWER_MAX_SKIP_PASSES: u64 = 32; + +/// A door's identity table, as the naming bind needs it. The WebSocket door +/// implements it over `freshell-ws`'s identity registry; the REST door over +/// the same registry through its injected binder plus its own handle stash. +pub trait TerminalNameBinder: Send + Sync { + /// Record the terminal's naming binding. Option-write for the handle: + /// `None` keeps the handle already retained. + fn set_name_binding( + &self, + terminal_id: &str, + name_ref: &SessionNameRef, + naming_handle: Option<&str>, + ); + /// The naming ref the identity table holds for this terminal. + fn name_ref_of(&self, terminal_id: &str) -> Option; + /// The pre-durable handle the identity table retains for this terminal. + fn naming_handle_of(&self, terminal_id: &str) -> Option; + /// The durable `(provider, session id)` the door knows for this terminal. + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)>; +} + +/// What the caller knows about the row it is binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamingBindHint { + /// The terminal mode (`claude`, `codex`, `opencode`, …); out-of-scope + /// modes bind nothing. + pub mode: String, + /// The row's folder (the pending record's directory-name fallback). + pub cwd: Option, + /// An ESTABLISHED durable session id (a resume's sessionRef, a managed + /// soul's native id, an auto-resume target). Never a prospective id. + pub resume_session_id: Option, + /// A persisted or client-sent pre-durable handle. Only a persisted handle + /// lets a record-less durable session fall back to the pending path. + pub naming_handle: Option, + /// The row title when no saved name is known (the mode label). + pub fallback_title: String, +} + +/// The binding a row ended up with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameBinding { + pub name_ref: SessionNameRef, + /// The pending handle the row retains (the pending path only). + pub naming_handle: Option, +} + +/// How a bind ended. +#[derive(Debug, Clone, PartialEq)] +pub enum NamingBindOutcome { + /// Bound, and the saved record is cached on the row. + Bound { + binding: NameBinding, + record: SessionNameRecord, + }, + /// Bound to a durable session that has no record yet; the row shows the + /// fallback title until the normal naming paths create one. + BoundNoRecord { binding: NameBinding }, + /// Out of naming scope, or no naming authority is wired. + NotScoped, + /// Bound, but the store stayed unavailable through the bounded retry; + /// the tick fills the cache (or admits the pending record) later. + Deferred { + binding: NameBinding, + reason: String, + }, + /// Bound, but a newer move retargeted the row while this bind's read was + /// in flight; the read's answer was dropped and the newer move owns the + /// row's binding and cache. + Superseded { binding: NameBinding }, +} + +impl NamingBindOutcome { + /// The row's binding, when it has one. + pub fn binding(&self) -> Option<&NameBinding> { + match self { + Self::Bound { binding, .. } + | Self::BoundNoRecord { binding } + | Self::Deferred { binding, .. } + | Self::Superseded { binding } => Some(binding), + Self::NotScoped => None, + } + } + + /// The `nameRef` + `sessionName` frame projection, when a record is known. + pub fn projection(&self) -> Option<(SessionNameRef, SessionNameRecord)> { + match self { + Self::Bound { record, .. } => Some((record.name_ref.clone(), record.clone())), + _ => None, + } + } +} + +/// Bind a terminal row's saved name (see the module doc). A row the registry +/// already holds a binding for keeps it — re-adopting an existing facade +/// never re-targets its name — and only has its cache refreshed. +pub async fn bind_terminal_naming_from_identity( + sink: Option<&Arc>, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + hint: NamingBindHint, +) -> NamingBindOutcome { + let Some(provider) = named_provider_for(Some(hint.mode.as_str()), None) else { + return NamingBindOutcome::NotScoped; + }; + let Some(sink) = sink else { + return NamingBindOutcome::NotScoped; + }; + if let Some(existing) = registry.name_ref_of(terminal_id) { + return refresh_existing_binding( + sink.as_ref(), + registry, + binder, + terminal_id, + existing, + &hint, + ) + .await; + } + let durable = hint + .resume_session_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|session_id| SessionNameRef::Session { + provider, + session_id: session_id.to_string(), + }); + if let Some(target) = durable { + let binding = NameBinding { + name_ref: target.clone(), + naming_handle: None, + }; + apply_binding(registry, binder, terminal_id, &binding); + let answer = read_record(sink.as_ref(), &target).await; + if !still_bound(registry, terminal_id, &target) { + return superseded(terminal_id, binding); + } + match answer { + Ok(Some(record)) => { + registry.update_session_name(terminal_id, &record); + log_bound(terminal_id, &binding, "durable"); + return NamingBindOutcome::Bound { binding, record }; + } + Ok(None) => { + // A recovery create against a PROSPECTIVE (record-less) + // session — a zero-turn pane whose server restarted — must + // re-stamp the PERSISTED pre-durable handle: only a pending + // binding lets the verified bind transfer the record once the + // transcript materializes. Without a persisted handle the + // durable binding stands, unnamed until the normal paths name + // the session — and so it does when the handle turns out to + // be spent on another session (see `bind_pending_path`). + let Some(handle) = persisted_handle(&hint).map(str::to_string) else { + present_unnamed(registry, terminal_id, &target, &hint.fallback_title); + log_bound(terminal_id, &binding, "durable_no_record"); + return NamingBindOutcome::BoundNoRecord { binding }; + }; + return bind_pending_path( + sink.as_ref(), + registry, + binder, + terminal_id, + provider, + handle, + Some(&target), + &hint, + ) + .await; + } + Err(error) => { + // "Read failed" is not "no record": the durable binding is + // kept and NEVER replaced by the fresh pending path (that + // would orphan the session's saved name behind a new handle). + present_unnamed(registry, terminal_id, &target, &hint.fallback_title); + return deferred(terminal_id, binding, &error); + } + } + } + let handle = persisted_handle(&hint) + .map(str::to_string) + .unwrap_or_else(mint_naming_handle); + bind_pending_path( + sink.as_ref(), + registry, + binder, + terminal_id, + provider, + handle, + None, + &hint, + ) + .await +} + +/// The conversation-switch arm: the door's identity table moved this +/// terminal onto a new naming ref, so the registry binding follows it and +/// the row adopts THAT ref's record. With no record, the row drops the old +/// session's cached name and shows `fallback_title`; the normal naming paths +/// name the new session later (the publisher refreshes every row bound to +/// it). `None` when nothing moved. Creates and changes no record. +pub async fn follow_identity_name_ref( + sink: Option<&Arc>, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + fallback_title: &str, +) -> Option { + let sink = sink?; + let moved_to = binder.name_ref_of(terminal_id)?; + let moved_from = registry.name_ref_of(terminal_id); + if moved_from.as_ref() == Some(&moved_to) || !registry.exists(terminal_id) { + return None; + } + registry.set_naming(terminal_id, Some(moved_to.clone()), None); + let binding = NameBinding { + name_ref: moved_to.clone(), + naming_handle: binder.naming_handle_of(terminal_id), + }; + let answer = read_record(sink.as_ref(), &moved_to).await; + if !still_bound(registry, terminal_id, &moved_to) { + return Some(superseded(terminal_id, binding)); + } + let outcome = match answer { + Ok(Some(record)) => { + registry.update_session_name(terminal_id, &record); + NamingBindOutcome::Bound { binding, record } + } + Ok(None) => { + present_unnamed(registry, terminal_id, &moved_to, fallback_title); + NamingBindOutcome::BoundNoRecord { binding } + } + Err(error) => { + // The old session's name must not keep showing on the moved row. + present_unnamed(registry, terminal_id, &moved_to, fallback_title); + deferred(terminal_id, binding, &error) + } + }; + tracing::info!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + from = %moved_from + .as_ref() + .map(name_ref_debug_key) + .unwrap_or_else(|| "-".to_string()), + to = %name_ref_debug_key(&moved_to), + has_record = matches!(outcome, NamingBindOutcome::Bound { .. }), + "session_names.terminal_binding_moved" + ); + Some(outcome) +} + +/// The established durable session a managed create's supervisor answered +/// with: an existing soul's native session (a retried create reusing the +/// running soul after a web restart). The supervisor otherwise echoes the +/// create's OWN launch id, so `preallocated_session_id` — this create's +/// server-preallocated `--session-id`, prospective until its transcript +/// exists — is never answered as durable: a record-less durable binding +/// would leave no pending record for the verified bind lane to transfer. +pub fn managed_reused_session( + registry: &TerminalRegistry, + terminal_id: &str, + preallocated_session_id: Option<&str>, +) -> Option { + registry + .managed_descriptor(terminal_id) + .and_then(|descriptor| descriptor.resume_session_id) + .filter(|session_id| Some(session_id.as_str()) != preallocated_session_id) +} + +/// The REST door's identity table: its own create-lane handle stash (the +/// pane rename resolver's last fallback) plus the shared identity table +/// `freshell-server` injects (the same one the WebSocket door binds). +pub(crate) struct RestDoorBinder<'a> { + state: &'a crate::FreshAgentState, +} + +impl TerminalNameBinder for RestDoorBinder<'_> { + fn set_name_binding( + &self, + terminal_id: &str, + name_ref: &SessionNameRef, + naming_handle: Option<&str>, + ) { + if let Some(handle) = naming_handle { + self.state.stash_naming_handle(terminal_id, handle); + } + if let Some(door) = self.state.terminal_name_binder.as_ref() { + door.set_name_binding(terminal_id, name_ref, naming_handle); + } + } + + fn name_ref_of(&self, terminal_id: &str) -> Option { + self.state + .terminal_name_binder + .as_ref() + .and_then(|door| door.name_ref_of(terminal_id)) + } + + fn naming_handle_of(&self, terminal_id: &str) -> Option { + self.state + .terminal_name_binder + .as_ref() + .and_then(|door| door.naming_handle_of(terminal_id)) + .or_else(|| self.state.peek_naming_handle(terminal_id)) + } + + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)> { + self.state + .terminal_name_binder + .as_ref() + .and_then(|door| door.durable_session_of(terminal_id)) + } +} + +/// Bind one REST-created terminal row through the shared helper. +pub(crate) async fn bind_rest_terminal_naming( + state: &crate::FreshAgentState, + registry: &TerminalRegistry, + terminal_id: &str, + hint: NamingBindHint, +) -> NamingBindOutcome { + let sink = state.naming(); + let binder = RestDoorBinder { state }; + bind_terminal_naming_from_identity(sink.as_ref(), registry, &binder, terminal_id, hint).await +} + +/// What one tick pass did (for the tick's own structured log). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TerminalNamesReconcile { + /// Rows whose registry binding followed a moved identity ref. + pub moved: usize, + /// Scoped rows that had no binding and got one. + pub bound: usize, + /// Bound rows whose empty cache was filled. + pub filled: usize, +} + +/// The tick's memory across passes: the per-row back-off for live rows bound +/// to a durable session that answered "no record". The naming publisher +/// fills such a row the moment the session is named (it refreshes every row +/// bound to a committed record), so the tick only backstops a missed push: +/// re-reading every 2 s would cost one locked document read per pass for as +/// long as the session stays unnamed. After the n-th consecutive empty +/// answer the row is skipped for 2^n passes (capped at +/// [`EMPTY_ANSWER_MAX_SKIP_PASSES`]); a binding move resets it. +#[derive(Debug, Default)] +pub struct TerminalNamesTick { + state: Mutex, +} + +#[derive(Debug, Default)] +struct TickMemory { + pass: u64, + empty_answers: HashMap, +} + +#[derive(Debug)] +struct EmptyAnswer { + name_ref: SessionNameRef, + misses: u32, + next_pass: u64, +} + +impl TerminalNamesTick { + fn begin_pass(&self) -> u64 { + let mut memory = self.state.lock().expect("terminal names tick lock"); + memory.pass += 1; + memory.pass + } + + /// Whether this row's empty-cache read waits out its back-off this pass. + fn backing_off(&self, terminal_id: &str, name_ref: &SessionNameRef, pass: u64) -> bool { + self.state + .lock() + .expect("terminal names tick lock") + .empty_answers + .get(terminal_id) + .is_some_and(|miss| &miss.name_ref == name_ref && pass < miss.next_pass) + } + + /// Fold this pass's read outcomes in: `empty` rows (durable, no record) + /// extend their back-off; every other memory not `waiting` this pass + /// (filled, moved, exited, gone) is forgotten. + fn settle_pass( + &self, + pass: u64, + empty: &[(String, SessionNameRef)], + waiting: &HashSet, + ) { + let mut memory = self.state.lock().expect("terminal names tick lock"); + let mut next: HashMap = HashMap::new(); + for (terminal_id, name_ref) in empty { + let misses = memory + .empty_answers + .get(terminal_id) + .filter(|miss| &miss.name_ref == name_ref) + .map_or(1, |miss| miss.misses.saturating_add(1)); + let skip = 2u64 + .saturating_pow(misses) + .min(EMPTY_ANSWER_MAX_SKIP_PASSES); + next.insert( + terminal_id.clone(), + EmptyAnswer { + name_ref: name_ref.clone(), + misses, + next_pass: pass + skip, + }, + ); + } + for (terminal_id, miss) in memory.empty_answers.drain() { + if waiting.contains(&terminal_id) && !next.contains_key(&terminal_id) { + next.insert(terminal_id, miss); + } + } + memory.empty_answers = next; + } +} + +/// The 2 s tick's terminal-naming reconcile (see the module doc). Bounded: +/// one batched read for the uncached live rows not backing off, plus at most +/// one bind per row that has no binding at all. `label_for` maps a mode to +/// its row title; `tick` carries the back-off between passes. +pub async fn reconcile_terminal_names( + sink: Option<&Arc>, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + label_for: &(dyn Fn(&str) -> String + Sync), + tick: &TerminalNamesTick, +) -> TerminalNamesReconcile { + let mut report = TerminalNamesReconcile::default(); + let Some(sink) = sink else { + return report; + }; + let pass = tick.begin_pass(); + let mut uncached: Vec = Vec::new(); + let mut waiting: HashSet = HashSet::new(); + for row in registry.identity_probe_rows() { + let Some(provider) = named_provider_for(Some(row.mode.as_str()), None) else { + continue; + }; + let terminal_id = row.terminal_id.as_str(); + let identity_ref = binder.name_ref_of(terminal_id); + let registry_ref = registry.name_ref_of(terminal_id); + let bound = match (identity_ref, registry_ref) { + (Some(identity_ref), Some(registry_ref)) if identity_ref == registry_ref => { + identity_ref + } + (Some(_), _) => { + if follow_identity_name_ref( + Some(sink), + registry, + binder, + terminal_id, + &label_for(&row.mode), + ) + .await + .is_some() + { + report.moved += 1; + } + continue; + } + (None, Some(registry_ref)) => { + // Keep both homes consistent: the rename resolvers read the + // identity table. + binder.set_name_binding(terminal_id, ®istry_ref, None); + registry_ref + } + (None, None) => { + if row.status != freshell_protocol::TerminalRunStatus::Running { + continue; // an exited row is never minted a new record + } + if now_ms().saturating_sub(row.created_at) < UNBOUND_ROW_GRACE_MS { + continue; // still inside its create path's admission window + } + let durable = binder + .durable_session_of(terminal_id) + .filter(|(door_provider, _)| door_provider.as_str() == provider.as_str()) + .map(|(_, session_id)| session_id); + // The door's session binds directly only when it has a + // record: it may be a fresh create's server-preallocated + // (prospective) id, and only a pending binding lets the + // verified bind lanes transfer the name at materialization. + // A handle is therefore always supplied, so a record-less + // session takes the pending path. + let hint = NamingBindHint { + mode: row.mode.clone(), + cwd: row.cwd.clone(), + resume_session_id: durable, + naming_handle: Some( + binder + .naming_handle_of(terminal_id) + .unwrap_or_else(mint_naming_handle), + ), + fallback_title: label_for(&row.mode), + }; + let outcome = bind_terminal_naming_from_identity( + Some(sink), + registry, + binder, + terminal_id, + hint, + ) + .await; + tracing::info!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + name_ref = %outcome + .binding() + .map(|binding| name_ref_debug_key(&binding.name_ref)) + .unwrap_or_else(|| "-".to_string()), + "session_names.terminal_binding_reconciled: bound a scoped row that had no binding" + ); + report.bound += 1; + continue; + } + }; + // An exited row is never re-read: only the publisher (on a record + // commit) refreshes it from here on. + if row.status != freshell_protocol::TerminalRunStatus::Running + || registry.session_name_of(terminal_id).is_some() + { + continue; + } + if tick.backing_off(terminal_id, &bound, pass) { + waiting.insert(row.terminal_id.clone()); + continue; + } + uncached.push(UncachedRow { + terminal_id: row.terminal_id.clone(), + provider, + cwd: row.cwd.clone(), + name_ref: bound, + durable_target: binder.durable_session_of(terminal_id).and_then( + |(provider, session_id)| { + named_provider_for(Some(&provider), None).map(|provider| { + SessionNameRef::Session { + provider, + session_id, + } + }) + }, + ), + fallback_title: label_for(&row.mode), + }); + } + let fill = fill_uncached_rows(sink.as_ref(), registry, binder, &uncached).await; + tick.settle_pass(pass, &fill.empty, &waiting); + report.filled = fill.filled; + report +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +/// A row already bound keeps its binding; its identity home is re-stamped +/// and its cache refreshed (a compatibility re-adoption of a live facade). +async fn refresh_existing_binding( + sink: &dyn SessionNaming, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + existing: SessionNameRef, + hint: &NamingBindHint, +) -> NamingBindOutcome { + let binding = NameBinding { + name_ref: existing.clone(), + naming_handle: binder.naming_handle_of(terminal_id), + }; + binder.set_name_binding(terminal_id, &existing, None); + let answer = read_record(sink, &existing).await; + if !still_bound(registry, terminal_id, &existing) { + return superseded(terminal_id, binding); + } + match answer { + Ok(Some(record)) => { + registry.update_session_name(terminal_id, &record); + NamingBindOutcome::Bound { binding, record } + } + Ok(None) => { + if registry.session_name_of(terminal_id).is_none() { + registry.set_meta( + terminal_id, + Some(hint.fallback_title.clone()), + None, + None, + None, + ); + } + NamingBindOutcome::BoundNoRecord { binding } + } + Err(error) => deferred(terminal_id, binding, &error), + } +} + +/// Admit (or re-resolve) the pending record for `handle` and bind the row +/// to it. On a persistent store failure the row is STILL bound to the +/// pending ref, so the tick can admit the record later. A row whose binding +/// moved while the admission was in flight (a switch onto a new session) is +/// left to that move. +/// +/// `record_less_target` is the durable session this bind first targeted when +/// its read answered "no record" (a recovery create's fallback to the +/// persisted handle). Then only the handle's OWN record, or the target's, +/// is accepted: a handle the store resolves to any other session was spent +/// on an earlier conversation, and binding it would show (and rename) that +/// session's name on this row. The row keeps the durable binding instead. +#[allow(clippy::too_many_arguments)] +async fn bind_pending_path( + sink: &dyn SessionNaming, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + provider: NamedProvider, + handle: String, + record_less_target: Option<&SessionNameRef>, + hint: &NamingBindHint, +) -> NamingBindOutcome { + let input = PendingNameInput { + handle: handle.clone(), + provider, + cwd: hint.cwd.clone(), + }; + let pending = SessionNameRef::Pending { id: handle.clone() }; + // Both homes: a switch retargets the identity table first and the + // registry row only on its follow-up. + let bound_before = ( + registry.name_ref_of(terminal_id), + binder.name_ref_of(terminal_id), + ); + let mut attempt = 0; + let admitted = loop { + match sink.ensure_pending(input.clone()).await { + Ok(update) => break Ok(update), + Err(error) if is_transient(&error) && attempt < BIND_RETRY_DELAYS.len() => { + log_retry("ensure_pending", &pending, &error, attempt); + tokio::time::sleep(BIND_RETRY_DELAYS[attempt]).await; + attempt += 1; + } + Err(error) => break Err(error), + } + }; + if ( + registry.name_ref_of(terminal_id), + binder.name_ref_of(terminal_id), + ) != bound_before + { + return superseded( + terminal_id, + NameBinding { + name_ref: pending, + naming_handle: Some(handle), + }, + ); + } + match admitted { + Ok(update) => { + if let Some(target) = record_less_target.filter(|target| { + update.record.name_ref != pending && &update.record.name_ref != *target + }) { + return keep_record_less_target( + registry, + terminal_id, + target, + &pending, + &update.record.name_ref, + &hint.fallback_title, + ); + } + // `ensure_pending` resolves a handle already bound to a durable + // session onto that session's record; the row follows it. + let binding = NameBinding { + name_ref: update.record.name_ref.clone(), + naming_handle: Some(handle), + }; + apply_binding(registry, binder, terminal_id, &binding); + registry.update_session_name(terminal_id, &update.record); + log_bound(terminal_id, &binding, "pending"); + NamingBindOutcome::Bound { + binding, + record: update.record, + } + } + Err(error) => { + log_name_error("ensure_pending", &pending, &error); + let binding = NameBinding { + name_ref: pending, + naming_handle: Some(handle), + }; + apply_binding(registry, binder, terminal_id, &binding); + registry.set_meta( + terminal_id, + Some(hint.fallback_title.clone()), + None, + None, + None, + ); + deferred(terminal_id, binding, &error) + } + } +} + +/// The recovery fallback's persisted handle was spent on another session: +/// the row stays on its record-less durable target (both homes already hold +/// it — the caller checked nothing moved) and presents `fallback_title` +/// until the normal naming paths name the target. +fn keep_record_less_target( + registry: &TerminalRegistry, + terminal_id: &str, + target: &SessionNameRef, + spent: &SessionNameRef, + resolved_to: &SessionNameRef, + fallback_title: &str, +) -> NamingBindOutcome { + present_unnamed(registry, terminal_id, target, fallback_title); + tracing::info!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + name_ref = %name_ref_debug_key(target), + handle = %name_ref_debug_key(spent), + resolved_to = %name_ref_debug_key(resolved_to), + "session_names.terminal_binding_spent_handle: the persisted handle names another session; the row keeps its own" + ); + NamingBindOutcome::BoundNoRecord { + binding: NameBinding { + name_ref: target.clone(), + naming_handle: None, + }, + } +} + +/// Read one ref's record with the bounded retry; `Ok(None)` = no record. +async fn read_record( + sink: &dyn SessionNaming, + target: &SessionNameRef, +) -> Result, NameError> { + let mut attempt = 0; + loop { + match sink.get(vec![target.clone()]).await { + Ok(updates) => { + return Ok(updates + .into_iter() + .find(|update| answers(update, target)) + .map(|update| update.record)) + } + Err(error) if is_transient(&error) && attempt < BIND_RETRY_DELAYS.len() => { + log_retry("get", target, &error, attempt); + tokio::time::sleep(BIND_RETRY_DELAYS[attempt]).await; + attempt += 1; + } + Err(error) => { + log_name_error("get", target, &error); + return Err(error); + } + } + } +} + +/// A verified bind lane's accepted answer (the pending→durable transfer at +/// materialization): when the store answered the requested durable target, +/// the row's binding advances onto it in BOTH homes; either way the row +/// caches the accepted record (an already-bound handle answers its own). +pub fn apply_transferred_record( + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + target: &SessionNameRef, + record: &SessionNameRecord, +) { + if &record.name_ref == target { + apply_binding( + registry, + binder, + terminal_id, + &NameBinding { + name_ref: target.clone(), + naming_handle: None, + }, + ); + } + registry.update_session_name(terminal_id, record); +} + +/// Write the binding into BOTH homes together: the door's identity table +/// (the rename resolvers) and the registry row (the projections). +fn apply_binding( + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + terminal_id: &str, + binding: &NameBinding, +) { + binder.set_name_binding( + terminal_id, + &binding.name_ref, + binding.naming_handle.as_deref(), + ); + registry.set_naming( + terminal_id, + Some(binding.name_ref.clone()), + binding.naming_handle.clone(), + ); +} + +/// Whether the row is still bound to `name_ref`: an answer read for a ref +/// the row has since moved off belongs to a superseded move and is dropped. +/// (Check-then-write with no await between, the publisher's own pattern.) +fn still_bound(registry: &TerminalRegistry, terminal_id: &str, name_ref: &SessionNameRef) -> bool { + registry.name_ref_of(terminal_id).as_ref() == Some(name_ref) +} + +/// A row bound to `bound_to`, which has no record (or could not be read), +/// presents `fallback_title` and drops a cached record of ANOTHER ref (the +/// previous session's name). A record for `bound_to` itself is kept: the +/// naming publisher wrote it after the store answered, when the session was +/// named in between. +fn present_unnamed( + registry: &TerminalRegistry, + terminal_id: &str, + bound_to: &SessionNameRef, + fallback_title: &str, +) { + let cached = registry.session_name_of(terminal_id); + if cached.is_some_and(|record| &record.name_ref == bound_to) { + return; + } + registry.clear_session_name(terminal_id, fallback_title); +} + +/// A fresh server-minted pre-durable naming handle. +fn mint_naming_handle() -> String { + format!("nh-{}", uuid::Uuid::new_v4()) +} + +fn persisted_handle(hint: &NamingBindHint) -> Option<&str> { + hint.naming_handle + .as_deref() + .map(str::trim) + .filter(|handle| !handle.is_empty()) +} + +/// Store failures worth a bounded retry: lock contention, a document +/// read/write failure with the old state intact, and an uncertain commit +/// (every bind operation here is idempotent). +fn is_transient(error: &NameError) -> bool { + matches!( + error, + NameError::LockUnavailable(_) | NameError::Persistence(_) | NameError::CommitUncertain(_) + ) +} + +fn deferred(terminal_id: &str, binding: NameBinding, error: &NameError) -> NamingBindOutcome { + tracing::warn!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + name_ref = %name_ref_debug_key(&binding.name_ref), + class = %error.code(), + "session_names.terminal_binding_deferred: the binding is kept and the 2s tick fills the name" + ); + NamingBindOutcome::Deferred { + binding, + reason: error.code().to_string(), + } +} + +fn superseded(terminal_id: &str, binding: NameBinding) -> NamingBindOutcome { + log_superseded(terminal_id, &binding.name_ref); + NamingBindOutcome::Superseded { binding } +} + +fn log_superseded(terminal_id: &str, read_for: &SessionNameRef) { + tracing::debug!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + read_for = %name_ref_debug_key(read_for), + "session_names.terminal_read_superseded: a newer move retargeted the row; the answer was dropped" + ); +} + +fn log_retry(op: &str, target: &SessionNameRef, error: &NameError, attempt: usize) { + tracing::debug!( + target: "freshell_freshagent::naming", + op = %op, + name_ref = %name_ref_debug_key(target), + class = %error.code(), + attempt = attempt + 1, + "session_names.terminal_binding_retry" + ); +} + +fn log_bound(terminal_id: &str, binding: &NameBinding, path: &str) { + tracing::debug!( + target: "freshell_freshagent::naming", + terminal_id = %terminal_id, + name_ref = %name_ref_debug_key(&binding.name_ref), + path = %path, + "session_names.terminal_binding_bound" + ); +} + +#[cfg(test)] +#[path = "terminal_name_binding_tests.rs"] +mod tests; diff --git a/crates/freshell-freshagent/src/terminal_name_binding_tests.rs b/crates/freshell-freshagent/src/terminal_name_binding_tests.rs new file mode 100644 index 000000000..391f6a2d0 --- /dev/null +++ b/crates/freshell-freshagent/src/terminal_name_binding_tests.rs @@ -0,0 +1,1401 @@ +//! Name reliability (Task 10): the shared terminal naming bind — every +//! terminal row path binds its name through one helper, a transient store +//! error is retried instead of leaving the row nameless or unbound, and the +//! 2 s tick reconciles what a failed or skipped bind left behind. +//! +//! Driven over the in-crate `RecordingSink` (wrapped to inject store +//! errors) and a fake [`TerminalNameBinder`] standing in for a door's +//! identity table. Paused tokio time auto-advances the bounded retry sleeps. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use freshell_protocol::native_location::NativeAcquisition; +use freshell_protocol::session_names::{ + NameIntent, NamedProvider, SessionNameRecord, SessionNameRef, SessionNameUpdate, +}; +use freshell_terminal::registry::{HeadlessTerminal, ManagedTerminalDescriptor}; +use freshell_terminal::TerminalRegistry; +use tokio::sync::Notify; + +use super::{ + bind_terminal_naming_from_identity, follow_identity_name_ref, managed_reused_session, + reconcile_terminal_names, NameBinding, NamingBindHint, NamingBindOutcome, TerminalNameBinder, + TerminalNamesTick, +}; +use crate::naming::test_support::{verified_claude_acquisition, RecordingSink}; +use crate::naming::{ + BindNameInput, NameActivity, NameError, NameFuture, NativeNameObservation, PendingNameInput, + RenameNameInput, SessionNaming, +}; + +// ── fakes ──────────────────────────────────────────────────────────────── + +/// The recording sink with injectable store failures: each queued error is +/// answered by one call; `fail_*` makes every call fail until cleared. A +/// [`ReadGate`] holds one read's ANSWER in flight, so a test can interleave +/// a newer binding move (or a publisher write) between the store's answer and +/// the caller acting on it. +struct FaultySink { + inner: Arc, + get_failures: Mutex>, + fail_gets: AtomicBool, + fail_ensures: AtomicBool, + gets: AtomicUsize, + ensures: AtomicUsize, + gate: Mutex>, + ensure_gate: Mutex>, +} + +/// The first read that asks for `target` is answered by the store at once, +/// then held: `entered` fires, and the answer is returned only after +/// `release` fires. +struct ReadGate { + target: SessionNameRef, + entered: Arc, + release: Arc, +} + +impl FaultySink { + fn new() -> Arc { + Arc::new(Self { + inner: RecordingSink::new(), + get_failures: Mutex::new(VecDeque::new()), + fail_gets: AtomicBool::new(false), + fail_ensures: AtomicBool::new(false), + gets: AtomicUsize::new(0), + ensures: AtomicUsize::new(0), + gate: Mutex::new(None), + ensure_gate: Mutex::new(None), + }) + } + + /// Hold the next `ensure_pending` of `handle` the same way (its answer is + /// computed at once, then held until `release`). + fn hold_next_ensure_of(&self, handle: &str) -> (Arc, Arc) { + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + *self.ensure_gate.lock().unwrap() = Some(ReadGate { + target: SessionNameRef::Pending { + id: handle.to_string(), + }, + entered: entered.clone(), + release: release.clone(), + }); + (entered, release) + } + + /// Hold the next read of `target` (see [`ReadGate`]); answers the + /// `(entered, release)` pair. + fn hold_next_read_of(&self, target: &SessionNameRef) -> (Arc, Arc) { + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + *self.gate.lock().unwrap() = Some(ReadGate { + target: target.clone(), + entered: entered.clone(), + release: release.clone(), + }); + (entered, release) + } +} + +impl SessionNaming for FaultySink { + fn get(&self, refs: Vec) -> NameFuture> { + self.gets.fetch_add(1, Ordering::SeqCst); + if self.fail_gets.load(Ordering::SeqCst) { + return Box::pin(async { Err(NameError::Persistence("disk read failed".into())) }); + } + if let Some(error) = self.get_failures.lock().unwrap().pop_front() { + return Box::pin(async move { Err(error) }); + } + let gate = { + let mut gate = self.gate.lock().unwrap(); + if gate + .as_ref() + .is_some_and(|gate| refs.contains(&gate.target)) + { + gate.take() + } else { + None + } + }; + let answer = self.inner.get(refs); + match gate { + Some(gate) => Box::pin(async move { + let answer = answer.await; + gate.entered.notify_one(); + gate.release.notified().await; + answer + }), + None => answer, + } + } + + fn ensure_pending(&self, input: PendingNameInput) -> NameFuture { + self.ensures.fetch_add(1, Ordering::SeqCst); + if self.fail_ensures.load(Ordering::SeqCst) { + return Box::pin(async { Err(NameError::LockUnavailable("lock busy".into())) }); + } + let pending = SessionNameRef::Pending { + id: input.handle.clone(), + }; + let gate = { + let mut gate = self.ensure_gate.lock().unwrap(); + if gate.as_ref().is_some_and(|gate| gate.target == pending) { + gate.take() + } else { + None + } + }; + let answer = self.inner.ensure_pending(input); + match gate { + Some(gate) => Box::pin(async move { + let answer = answer.await; + gate.entered.notify_one(); + gate.release.notified().await; + answer + }), + None => answer, + } + } + + fn bind_pending(&self, input: BindNameInput) -> NameFuture { + self.inner.bind_pending(input) + } + + fn rename(&self, input: RenameNameInput) -> NameFuture { + self.inner.rename(input) + } + + fn activity(&self, input: NameActivity) -> NameFuture { + self.inner.activity(input) + } + + fn observe_native(&self, input: NativeNameObservation) -> NameFuture { + self.inner.observe_native(input) + } + + fn record_acquisition( + &self, + target: SessionNameRef, + acquisition: NativeAcquisition, + ) -> NameFuture { + self.inner.record_acquisition(target, acquisition) + } +} + +/// A door's identity table: option-write bindings plus the durable session +/// each terminal is known to run. +#[derive(Default)] +struct FakeBinder { + bindings: Mutex)>>, + durable: Mutex>, +} + +impl FakeBinder { + fn binding(&self, terminal_id: &str) -> Option<(SessionNameRef, Option)> { + self.bindings.lock().unwrap().get(terminal_id).cloned() + } +} + +impl TerminalNameBinder for FakeBinder { + fn set_name_binding( + &self, + terminal_id: &str, + name_ref: &SessionNameRef, + naming_handle: Option<&str>, + ) { + let mut bindings = self.bindings.lock().unwrap(); + let handle = naming_handle + .map(str::to_string) + .or_else(|| bindings.get(terminal_id).and_then(|(_, h)| h.clone())); + bindings.insert(terminal_id.to_string(), (name_ref.clone(), handle)); + } + + fn name_ref_of(&self, terminal_id: &str) -> Option { + self.binding(terminal_id).map(|(name_ref, _)| name_ref) + } + + fn naming_handle_of(&self, terminal_id: &str) -> Option { + self.binding(terminal_id).and_then(|(_, handle)| handle) + } + + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)> { + self.durable.lock().unwrap().get(terminal_id).cloned() + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +fn session(provider: NamedProvider, session_id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider, + session_id: session_id.to_string(), + } +} + +/// Seed a durable record named `name` through the public naming surface +/// (admit a handle, verify-bind it, then a user rename). +async fn seed(sink: &dyn SessionNaming, target: &SessionNameRef, name: &str) -> SessionNameRecord { + let handle = format!("seed-{name}"); + let provider = match target { + SessionNameRef::Session { provider, .. } => *provider, + SessionNameRef::Pending { .. } => NamedProvider::Claude, + }; + sink.ensure_pending(PendingNameInput { + handle: handle.clone(), + provider, + cwd: None, + }) + .await + .unwrap(); + sink.bind_pending(BindNameInput { + pending: SessionNameRef::Pending { id: handle }, + target: target.clone(), + acquisition: verified_claude_acquisition("seed"), + }) + .await + .unwrap(); + sink.rename(RenameNameInput { + target: target.clone(), + name: name.to_string(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap() + .record +} + +/// A row created long ago (well past the tick's create-path grace). +fn headless_row(registry: &TerminalRegistry, terminal_id: &str, mode: &str) { + headless_row_created_at(registry, terminal_id, mode, Some(1_000)); +} + +fn headless_row_created_at( + registry: &TerminalRegistry, + terminal_id: &str, + mode: &str, + created_at: Option, +) { + registry.register_headless(HeadlessTerminal { + terminal_id: terminal_id.to_string(), + stream_id: format!("S-{terminal_id}"), + mode: mode.to_string(), + resume_session_id: None, + create_request_id: None, + created_at, + }); +} + +fn hint(mode: &str, resume: Option<&str>, handle: Option<&str>) -> NamingBindHint { + NamingBindHint { + mode: mode.to_string(), + cwd: Some("/work/project".to_string()), + resume_session_id: resume.map(str::to_string), + naming_handle: handle.map(str::to_string), + fallback_title: format!("{mode}-label"), + } +} + +fn label(mode: &str) -> String { + format!("{mode}-label") +} + +fn dyn_sink(sink: &Arc) -> Option> { + Some(sink.clone() as Arc) +} + +// ── create / resume ────────────────────────────────────────────────────── + +/// A resume create whose first read hits the busy document lock retries and +/// fills the row's cache with the durable session's saved name. +#[tokio::test(start_paused = true)] +async fn resume_create_retries_a_lock_error_and_fills_the_cache() { + let sink = FaultySink::new(); + let durable = session(NamedProvider::Claude, "s-resume"); + seed(sink.as_ref(), &durable, "LotL").await; + sink.get_failures + .lock() + .unwrap() + .push_back(NameError::LockUnavailable("busy".into())); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-resume", "claude"); + let binder = FakeBinder::default(); + let gets_before = sink.gets.load(Ordering::SeqCst); + + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-resume", + hint("claude", Some("s-resume"), None), + ) + .await; + + match &outcome { + NamingBindOutcome::Bound { binding, record } => { + assert_eq!(binding.name_ref, durable); + assert_eq!(record.name, "LotL"); + } + other => panic!("expected a bound outcome, got {other:?}"), + } + assert_eq!( + sink.gets.load(Ordering::SeqCst) - gets_before, + 2, + "one failed read, one retried read" + ); + assert_eq!( + registry.session_name_of("T-resume").map(|r| r.name), + Some("LotL".to_string()) + ); + assert_eq!(registry.title_of("T-resume").as_deref(), Some("LotL")); + assert_eq!(registry.name_ref_of("T-resume"), Some(durable.clone())); + assert_eq!(binder.name_ref_of("T-resume"), Some(durable)); +} + +/// A resume create whose reads keep failing keeps its DURABLE binding (it +/// never falls into the fresh pending path, even with a persisted handle), +/// answers `Deferred`, and a later tick fills the cache. +#[tokio::test(start_paused = true)] +async fn resume_create_read_failure_never_falls_into_the_fresh_pending_path() { + let sink = FaultySink::new(); + let durable = session(NamedProvider::Claude, "s-unreadable"); + seed(sink.as_ref(), &durable, "LotL").await; + let ensures_before = sink.ensures.load(Ordering::SeqCst); + sink.fail_gets.store(true, Ordering::SeqCst); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-unreadable", "claude"); + let binder = FakeBinder::default(); + + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-unreadable", + hint("claude", Some("s-unreadable"), Some("nh-persisted")), + ) + .await; + + match &outcome { + NamingBindOutcome::Deferred { binding, .. } => { + assert_eq!(binding.name_ref, durable); + } + other => panic!("expected a deferred outcome, got {other:?}"), + } + assert_eq!( + sink.ensures.load(Ordering::SeqCst), + ensures_before, + "a read failure must never mint or ensure a pending record" + ); + assert_eq!(registry.name_ref_of("T-unreadable"), Some(durable.clone())); + assert_eq!(binder.name_ref_of("T-unreadable"), Some(durable)); + assert!(registry.session_name_of("T-unreadable").is_none()); + assert_eq!( + registry.title_of("T-unreadable").as_deref(), + Some("claude-label") + ); + + // The store recovers; the tick's empty-cache pass fills the row. + sink.fail_gets.store(false, Ordering::SeqCst); + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + assert_eq!( + registry.session_name_of("T-unreadable").map(|r| r.name), + Some("LotL".to_string()) + ); + assert_eq!(registry.title_of("T-unreadable").as_deref(), Some("LotL")); +} + +/// A fresh create whose `ensure_pending` keeps failing is still bound to the +/// pending ref (never left unbound); the tick's next pass creates the record. +#[tokio::test(start_paused = true)] +async fn ensure_pending_failure_keeps_a_pending_binding_for_the_tick() { + let sink = FaultySink::new(); + sink.fail_ensures.store(true, Ordering::SeqCst); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-fresh", "codex"); + let binder = FakeBinder::default(); + let pending = SessionNameRef::Pending { id: "nh-1".into() }; + + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-fresh", + hint("codex", None, Some("nh-1")), + ) + .await; + + match &outcome { + NamingBindOutcome::Deferred { binding, .. } => assert_eq!( + binding, + &NameBinding { + name_ref: pending.clone(), + naming_handle: Some("nh-1".into()), + } + ), + other => panic!("expected a deferred outcome, got {other:?}"), + } + assert!( + sink.ensures.load(Ordering::SeqCst) > 1, + "ensure_pending is retried before deferring" + ); + assert_eq!(registry.name_ref_of("T-fresh"), Some(pending.clone())); + assert_eq!( + binder.binding("T-fresh"), + Some((pending.clone(), Some("nh-1".into()))) + ); + assert_eq!(registry.title_of("T-fresh").as_deref(), Some("codex-label")); + + sink.fail_ensures.store(false, Ordering::SeqCst); + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + let record = registry + .session_name_of("T-fresh") + .expect("the tick created and cached the pending record"); + assert_eq!(record.name_ref, pending); + assert_eq!( + record.name, "Codex", + "the admitted fallback (a headless row has no folder, so the provider label)" + ); + assert_eq!(registry.title_of("T-fresh").as_deref(), Some("Codex")); +} + +/// A managed OpenCode soul's facade row binds through the helper: its title +/// becomes the saved name, and with no record it stays the mode label — +/// never "Shell". +#[tokio::test(start_paused = true)] +async fn register_managed_opencode_soul_binds_saved_name_and_mode_title() { + let sink = FaultySink::new(); + let named = session(NamedProvider::Opencode, "ses_named"); + seed(sink.as_ref(), &named, "Survivor").await; + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + for (terminal_id, session_id) in [("T-soul-named", "ses_named"), ("T-soul-new", "ses_new")] { + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: format!("soul-{terminal_id}"), + incarnation_id: format!("incarnation-{terminal_id}"), + terminal_id: terminal_id.to_string(), + stream_id: format!("S-{terminal_id}"), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some(session_id.into()), + create_request_id: Some(format!("req-{terminal_id}")), + }, + "OpenCode".to_string(), + ); + let mut soul_hint = hint("opencode", Some(session_id), None); + soul_hint.fallback_title = "OpenCode".into(); + bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + terminal_id, + soul_hint, + ) + .await; + } + + assert_eq!( + registry.title_of("T-soul-named").as_deref(), + Some("Survivor") + ); + assert_eq!(registry.name_ref_of("T-soul-named"), Some(named)); + assert_eq!( + registry.title_of("T-soul-new").as_deref(), + Some("OpenCode"), + "a soul with no record presents its mode label, never Shell" + ); + assert_eq!( + registry.name_ref_of("T-soul-new"), + Some(session(NamedProvider::Opencode, "ses_new")), + "a record-less durable session stays bound to its own ref" + ); + assert!(registry.session_name_of("T-soul-new").is_none()); +} + +/// Out-of-scope modes and an unwired naming authority bind nothing. +#[tokio::test(start_paused = true)] +async fn unscoped_rows_and_unwired_naming_bind_nothing() { + let sink = FaultySink::new(); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-shell", "shell"); + headless_row(®istry, "T-unwired", "claude"); + let binder = FakeBinder::default(); + + let shell = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-shell", + hint("shell", None, None), + ) + .await; + let unwired = bind_terminal_naming_from_identity( + None, + ®istry, + &binder, + "T-unwired", + hint("claude", Some("s-1"), None), + ) + .await; + + assert_eq!(shell, NamingBindOutcome::NotScoped); + assert_eq!(unwired, NamingBindOutcome::NotScoped); + assert!(registry.name_ref_of("T-shell").is_none()); + assert!(registry.name_ref_of("T-unwired").is_none()); + assert!(binder.binding("T-shell").is_none()); +} + +// ── the tick ───────────────────────────────────────────────────────────── + +/// A scoped row that no create/adopt path bound gets a binding from the +/// tick: its door-known durable session when it has a record, else a +/// freshly admitted pending handle. Shell rows stay unbound. +#[tokio::test(start_paused = true)] +async fn tick_binds_a_scoped_row_with_no_ref() { + let sink = FaultySink::new(); + let known = session(NamedProvider::Codex, "thread-known"); + seed(sink.as_ref(), &known, "Known thread").await; + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-orphan", "opencode"); + headless_row(®istry, "T-known", "codex"); + headless_row(®istry, "T-shell", "shell"); + let binder = FakeBinder::default(); + binder + .durable + .lock() + .unwrap() + .insert("T-known".into(), ("codex".into(), "thread-known".into())); + + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + + let (orphan_ref, orphan_handle) = binder + .binding("T-orphan") + .expect("the tick binds the orphan row"); + let handle = orphan_handle.expect("a pending binding retains its handle"); + assert_eq!(orphan_ref, SessionNameRef::Pending { id: handle }); + assert_eq!(registry.name_ref_of("T-orphan"), Some(orphan_ref)); + assert!( + registry.session_name_of("T-orphan").is_some(), + "the admitted fallback record is cached" + ); + assert_eq!(registry.name_ref_of("T-known"), Some(known.clone())); + assert_eq!(binder.name_ref_of("T-known"), Some(known)); + assert_eq!( + registry.title_of("T-known").as_deref(), + Some("Known thread") + ); + assert!(registry.name_ref_of("T-shell").is_none()); + assert!(binder.binding("T-shell").is_none()); +} + +/// A row the tick sees mid-create (registered, its create path's naming +/// admission still in flight) is left to that create path: binding it here +/// would mint a handle that pre-empts the client's own `namingHandle`. +#[tokio::test(start_paused = true)] +async fn tick_leaves_a_just_created_row_to_its_create_path() { + let sink = FaultySink::new(); + let registry = TerminalRegistry::new(); + headless_row_created_at(®istry, "T-creating", "claude", None); + let binder = FakeBinder::default(); + + let report = reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + + assert_eq!(report.bound, 0); + assert!(registry.name_ref_of("T-creating").is_none()); + assert!(binder.binding("T-creating").is_none()); + assert_eq!( + sink.ensures.load(Ordering::SeqCst), + 0, + "no handle was minted" + ); +} + +/// Bound rows with an empty display cache are filled from their records. +#[tokio::test(start_paused = true)] +async fn tick_reconciles_bound_rows_with_empty_cache() { + let sink = FaultySink::new(); + let durable = session(NamedProvider::Claude, "s-bound"); + seed(sink.as_ref(), &durable, "LotL").await; + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-bound", "claude"); + let binder = FakeBinder::default(); + binder.set_name_binding("T-bound", &durable, None); + registry.set_naming("T-bound", Some(durable.clone()), None); + assert!(registry.session_name_of("T-bound").is_none()); + + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + + assert_eq!( + registry.session_name_of("T-bound").map(|r| r.name), + Some("LotL".to_string()) + ); + assert_eq!(registry.title_of("T-bound").as_deref(), Some("LotL")); +} + +/// The tick's backstop for a binding move it did not see happen: the door's +/// identity table names a different session than the registry row, so the +/// row follows the identity and adopts that session's record — or, with no +/// record, drops the old session's cached name for the mode label. +#[tokio::test(start_paused = true)] +async fn tick_moves_a_row_whose_identity_ref_moved() { + let sink = FaultySink::new(); + let old = session(NamedProvider::Claude, "s-old"); + let new_named = session(NamedProvider::Claude, "s-new-named"); + let new_bare = session(NamedProvider::Claude, "s-new-bare"); + let old_record = seed(sink.as_ref(), &old, "LotL").await; + seed(sink.as_ref(), &new_named, "Billing bug").await; + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + for (terminal_id, moved_to) in [("T-a", &new_named), ("T-b", &new_bare)] { + headless_row(®istry, terminal_id, "claude"); + registry.set_naming(terminal_id, Some(old.clone()), None); + registry.update_session_name(terminal_id, &old_record); + binder.set_name_binding(terminal_id, moved_to, None); + } + + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + + assert_eq!(registry.name_ref_of("T-a"), Some(new_named)); + assert_eq!(registry.title_of("T-a").as_deref(), Some("Billing bug")); + assert_eq!(registry.name_ref_of("T-b"), Some(new_bare)); + assert!(registry.session_name_of("T-b").is_none()); + assert_eq!(registry.title_of("T-b").as_deref(), Some("claude-label")); + let kept = sink.get(vec![old]).await.unwrap(); + assert_eq!( + kept[0].record.name, "LotL", + "the old session keeps its name" + ); +} + +// ── a read that a newer move superseded ────────────────────────────────── + +/// A claude row bound (in both homes) to `bound_to`, with `cached` on it. +fn bound_row( + registry: &TerminalRegistry, + binder: &FakeBinder, + terminal_id: &str, + bound_to: &SessionNameRef, + cached: Option<&SessionNameRecord>, +) { + headless_row(registry, terminal_id, "claude"); + registry.set_naming(terminal_id, Some(bound_to.clone()), None); + binder.set_name_binding(terminal_id, bound_to, None); + if let Some(record) = cached { + registry.update_session_name(terminal_id, record); + } +} + +/// Two quick switches A→B→C: B's read answers after C's follow finished. +/// The row stays bound to C and keeps C's record — B's late answer is +/// dropped instead of caching B's name on a row bound to C. +#[tokio::test(start_paused = true)] +async fn a_superseded_switch_read_never_overwrites_the_newer_binding() { + let sink = FaultySink::new(); + let a = session(NamedProvider::Claude, "s-a"); + let b = session(NamedProvider::Claude, "s-b"); + let c = session(NamedProvider::Claude, "s-c"); + let a_record = seed(sink.as_ref(), &a, "LotL").await; + seed(sink.as_ref(), &b, "Billing bug").await; + seed(sink.as_ref(), &c, "Cache layer").await; + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + bound_row(®istry, &binder, "T-switch", &a, Some(&a_record)); + binder.set_name_binding("T-switch", &b, None); + let (entered, release) = sink.hold_next_read_of(&b); + let naming = dyn_sink(&sink); + + let follow_b = follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-switch", + "claude-label", + ); + let switch_to_c = async { + entered.notified().await; // B's answer is in flight + binder.set_name_binding("T-switch", &c, None); + let outcome = follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-switch", + "claude-label", + ) + .await; + release.notify_one(); + outcome + }; + let (b_outcome, c_outcome) = tokio::join!(follow_b, switch_to_c); + + assert!( + matches!(c_outcome, Some(NamingBindOutcome::Bound { .. })), + "{c_outcome:?}" + ); + assert!( + matches!(b_outcome, Some(NamingBindOutcome::Superseded { .. })), + "the late answer reports that a newer move owns the row: {b_outcome:?}" + ); + assert_eq!(registry.name_ref_of("T-switch"), Some(c.clone())); + assert_eq!( + registry.session_name_of("T-switch").map(|r| r.name), + Some("Cache layer".to_string()), + "the row bound to C must never cache B's record" + ); + assert_eq!( + registry.title_of("T-switch").as_deref(), + Some("Cache layer") + ); +} + +/// A resume create's read of its durable session answers after a +/// conversation switch moved the row on: the create drops that answer and +/// projects nothing, leaving the switch's record on the row. +#[tokio::test(start_paused = true)] +async fn a_create_read_superseded_by_a_switch_drops_its_record() { + let sink = FaultySink::new(); + let b = session(NamedProvider::Claude, "s-b"); + let c = session(NamedProvider::Claude, "s-c"); + seed(sink.as_ref(), &b, "Billing bug").await; + seed(sink.as_ref(), &c, "Cache layer").await; + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-create", "claude"); + let binder = FakeBinder::default(); + let (entered, release) = sink.hold_next_read_of(&b); + let naming = dyn_sink(&sink); + + let create = bind_terminal_naming_from_identity( + naming.as_ref(), + ®istry, + &binder, + "T-create", + hint("claude", Some("s-b"), None), + ); + let switch_to_c = async { + entered.notified().await; + binder.set_name_binding("T-create", &c, None); + follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-create", + "claude-label", + ) + .await; + release.notify_one(); + }; + let (created, ()) = tokio::join!(create, switch_to_c); + + assert!( + matches!(created, NamingBindOutcome::Superseded { .. }), + "{created:?}" + ); + assert_eq!( + created.projection(), + None, + "the created frame projects nothing stale" + ); + assert_eq!(registry.name_ref_of("T-create"), Some(c.clone())); + assert_eq!(binder.name_ref_of("T-create"), Some(c)); + assert_eq!( + registry.session_name_of("T-create").map(|r| r.name), + Some("Cache layer".to_string()) + ); + assert_eq!( + registry.title_of("T-create").as_deref(), + Some("Cache layer") + ); +} + +/// A recovery create against a record-less durable session falls back to its +/// persisted pending handle; a conversation switch that lands while that +/// handle's admission is in flight keeps the row — the late admission never +/// re-binds it to the pending handle. +#[tokio::test(start_paused = true)] +async fn a_pending_fallback_superseded_by_a_switch_never_rebinds_the_row() { + let sink = FaultySink::new(); + let c = session(NamedProvider::Claude, "s-c"); + seed(sink.as_ref(), &c, "Cache layer").await; + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-fallback", "claude"); + let binder = FakeBinder::default(); + let (entered, release) = sink.hold_next_ensure_of("nh-persisted"); + let naming = dyn_sink(&sink); + + let create = bind_terminal_naming_from_identity( + naming.as_ref(), + ®istry, + &binder, + "T-fallback", + hint("claude", Some("s-prospective"), Some("nh-persisted")), + ); + // The switch's identity upsert lands during the admission; its + // follow-up runs only after the admission answered. + let switch_to_c = async { + entered.notified().await; + binder.set_name_binding("T-fallback", &c, None); + release.notify_one(); + }; + let (created, ()) = tokio::join!(create, switch_to_c); + follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-fallback", + "claude-label", + ) + .await; + + assert_eq!(registry.name_ref_of("T-fallback"), Some(c.clone())); + assert_eq!(binder.name_ref_of("T-fallback"), Some(c)); + assert_eq!( + registry.session_name_of("T-fallback").map(|r| r.name), + Some("Cache layer".to_string()) + ); + assert!( + matches!(created, NamingBindOutcome::Superseded { .. }), + "{created:?}" + ); +} + +/// Move `handle` onto `target` (a materialization's verified transfer) and +/// name the target `name`. +async fn spend_handle_onto( + sink: &dyn SessionNaming, + handle: &str, + target: &SessionNameRef, + name: &str, +) { + sink.ensure_pending(PendingNameInput { + handle: handle.to_string(), + provider: NamedProvider::Codex, + cwd: None, + }) + .await + .unwrap(); + sink.bind_pending(BindNameInput { + pending: SessionNameRef::Pending { + id: handle.to_string(), + }, + target: target.clone(), + acquisition: verified_claude_acquisition("spend"), + }) + .await + .unwrap(); + sink.rename(RenameNameInput { + target: target.clone(), + name: name.to_string(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); +} + +/// A restore create for a record-less durable session T2 whose persisted +/// handle was already SPENT (moved onto T1 when T1 materialized) keeps the +/// row bound to T2: the handle now resolves to T1's record, and on this arm +/// that is always another session (a handle moved onto T2 itself would have +/// answered the durable read). The row shows the mode label; no record is +/// created or changed. +#[tokio::test(start_paused = true)] +async fn a_record_less_resume_target_never_binds_through_a_spent_handle() { + let sink = FaultySink::new(); + let t1 = session(NamedProvider::Codex, "thread-T1"); + let t2 = session(NamedProvider::Codex, "thread-T2"); + spend_handle_onto(sink.as_ref(), "nh-spent", &t1, "First thread").await; + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-restore", "codex"); + let binder = FakeBinder::default(); + + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-restore", + hint("codex", Some("thread-T2"), Some("nh-spent")), + ) + .await; + + assert_eq!( + outcome, + NamingBindOutcome::BoundNoRecord { + binding: NameBinding { + name_ref: t2.clone(), + naming_handle: None, + } + } + ); + assert_eq!(registry.name_ref_of("T-restore"), Some(t2.clone())); + assert_eq!(binder.name_ref_of("T-restore"), Some(t2.clone())); + assert!( + registry.session_name_of("T-restore").is_none(), + "the other session's name is never cached on the row" + ); + assert_eq!( + registry.title_of("T-restore").as_deref(), + Some("codex-label") + ); + let reads = sink.inner.get(vec![t1.clone(), t2]).await.unwrap(); + assert_eq!(reads.len(), 1, "no record was created for T2: {reads:?}"); + assert_eq!(reads[0].record.name_ref, t1); + assert_eq!(reads[0].record.name, "First thread", "T1 is untouched"); +} + +#[tokio::test(start_paused = true)] +async fn tick_after_failed_pending_admission_rejects_a_handle_spent_on_another_session() { + let sink = FaultySink::new(); + let old = session(NamedProvider::Codex, "thread-old"); + let current = session(NamedProvider::Codex, "thread-current"); + spend_handle_onto(sink.as_ref(), "spent-retry", &old, "Old thread").await; + sink.fail_ensures.store(true, Ordering::SeqCst); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-retry", "codex"); + let binder = FakeBinder::default(); + binder + .durable + .lock() + .unwrap() + .insert("T-retry".into(), ("codex".into(), "thread-current".into())); + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-retry", + hint("codex", Some("thread-current"), Some("spent-retry")), + ) + .await; + assert!(matches!(outcome, NamingBindOutcome::Deferred { .. })); + sink.fail_ensures.store(false, Ordering::SeqCst); + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &|mode| format!("{mode}-label"), + &TerminalNamesTick::default(), + ) + .await; + assert_eq!(registry.name_ref_of("T-retry"), Some(current.clone())); + assert_eq!(binder.name_ref_of("T-retry"), Some(current)); + assert!( + registry.session_name_of("T-retry").is_none(), + "a successful retry cannot cache the old conversation" + ); +} + +/// The same arm with a STILL-pending persisted handle (a zero-turn pane whose +/// server restarted) takes the pending path: the row binds the handle's own +/// record, which the verified bind lane later transfers. +#[tokio::test(start_paused = true)] +async fn a_record_less_resume_target_rejoins_its_still_pending_handle() { + let sink = FaultySink::new(); + let pending = SessionNameRef::Pending { + id: "nh-zero-turn".into(), + }; + sink.ensure_pending(PendingNameInput { + handle: "nh-zero-turn".into(), + provider: NamedProvider::Codex, + cwd: None, + }) + .await + .unwrap(); + sink.rename(RenameNameInput { + target: pending.clone(), + name: "Billing".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-zero-turn", "codex"); + let binder = FakeBinder::default(); + + let outcome = bind_terminal_naming_from_identity( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + "T-zero-turn", + hint("codex", Some("thread-prospective"), Some("nh-zero-turn")), + ) + .await; + + match &outcome { + NamingBindOutcome::Bound { binding, record } => { + assert_eq!( + binding, + &NameBinding { + name_ref: pending.clone(), + naming_handle: Some("nh-zero-turn".into()), + } + ); + assert_eq!(record.name, "Billing"); + } + other => panic!("expected a pending binding, got {other:?}"), + } + assert_eq!(registry.name_ref_of("T-zero-turn"), Some(pending.clone())); + assert_eq!( + binder.binding("T-zero-turn"), + Some((pending, Some("nh-zero-turn".into()))) + ); + assert_eq!(registry.title_of("T-zero-turn").as_deref(), Some("Billing")); +} + +/// The same arm when the persisted handle moves onto the TARGET itself +/// between the durable read ("no record") and the pending admission (the +/// session materialized in between): the admission answers the target's own +/// record, so the row binds it. +#[tokio::test(start_paused = true)] +async fn a_record_less_resume_target_accepts_its_handle_moving_onto_it() { + let sink = FaultySink::new(); + let target = session(NamedProvider::Codex, "thread-materializing"); + sink.ensure_pending(PendingNameInput { + handle: "nh-moving".into(), + provider: NamedProvider::Codex, + cwd: None, + }) + .await + .unwrap(); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-moving", "codex"); + let binder = FakeBinder::default(); + let (entered, release) = sink.hold_next_read_of(&target); + let naming = dyn_sink(&sink); + + let create = bind_terminal_naming_from_identity( + naming.as_ref(), + ®istry, + &binder, + "T-moving", + hint("codex", Some("thread-materializing"), Some("nh-moving")), + ); + let materialize = async { + entered.notified().await; + spend_handle_onto(sink.inner.as_ref(), "nh-moving", &target, "Materialized").await; + release.notify_one(); + }; + let (outcome, ()) = tokio::join!(create, materialize); + + match &outcome { + NamingBindOutcome::Bound { binding, record } => { + assert_eq!(binding.name_ref, target); + assert_eq!(record.name, "Materialized"); + } + other => panic!("expected the target's own record, got {other:?}"), + } + assert_eq!(registry.name_ref_of("T-moving"), Some(target.clone())); + assert_eq!(binder.name_ref_of("T-moving"), Some(target)); + assert_eq!( + registry.title_of("T-moving").as_deref(), + Some("Materialized") + ); +} + +/// A switch onto a session whose read answered "no record" keeps the record +/// the naming publisher wrote onto the row meanwhile (the session was named +/// between the store's answer and the follow acting on it) — only the OLD +/// session's cached name is dropped. +#[tokio::test(start_paused = true)] +async fn a_switch_to_a_bare_session_keeps_a_record_the_publisher_just_wrote() { + let sink = FaultySink::new(); + let a = session(NamedProvider::Claude, "s-a"); + let c = session(NamedProvider::Claude, "s-c"); + let a_record = seed(sink.as_ref(), &a, "LotL").await; + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + bound_row(®istry, &binder, "T-named", &a, Some(&a_record)); + binder.set_name_binding("T-named", &c, None); + let (entered, release) = sink.hold_next_read_of(&c); + let naming = dyn_sink(&sink); + + let follow_c = follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-named", + "claude-label", + ); + let publisher_names_c = async { + entered.notified().await; // the read already answered "no record" + let named = seed(sink.as_ref(), &c, "Fresh title").await; + for terminal_id in registry.terminals_bound_to(&c) { + registry.update_session_name(&terminal_id, &named); + } + release.notify_one(); + }; + tokio::join!(follow_c, publisher_names_c); + + assert_eq!(registry.name_ref_of("T-named"), Some(c)); + assert_eq!( + registry.session_name_of("T-named").map(|r| r.name), + Some("Fresh title".to_string()), + "the publisher's record for the new session survives the follow" + ); + assert_eq!(registry.title_of("T-named").as_deref(), Some("Fresh title")); +} + +/// The tick's batched cache fill drops an answer for a row whose binding +/// moved while the read was in flight. +#[tokio::test(start_paused = true)] +async fn tick_fill_drops_an_answer_for_a_row_that_moved() { + let sink = FaultySink::new(); + let b = session(NamedProvider::Claude, "s-b"); + let c = session(NamedProvider::Claude, "s-c"); + seed(sink.as_ref(), &b, "Billing bug").await; + seed(sink.as_ref(), &c, "Cache layer").await; + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + bound_row(®istry, &binder, "T-fill", &b, None); + let (entered, release) = sink.hold_next_read_of(&b); + let naming = dyn_sink(&sink); + let tick = TerminalNamesTick::default(); + + let fill = reconcile_terminal_names(naming.as_ref(), ®istry, &binder, &label, &tick); + let switch_to_c = async { + entered.notified().await; + binder.set_name_binding("T-fill", &c, None); + follow_identity_name_ref( + naming.as_ref(), + ®istry, + &binder, + "T-fill", + "claude-label", + ) + .await; + release.notify_one(); + }; + let (report, ()) = tokio::join!(fill, switch_to_c); + + assert_eq!(report.filled, 0, "the stale answer fills nothing"); + assert_eq!(registry.name_ref_of("T-fill"), Some(c)); + assert_eq!( + registry.session_name_of("T-fill").map(|r| r.name), + Some("Cache layer".to_string()) + ); +} + +// ── the tick's cost bounds ─────────────────────────────────────────────── + +/// An exited row is never re-read for its empty cache: its process is gone, +/// and only the publisher (on a record commit) refreshes it from here on. +#[tokio::test(start_paused = true)] +async fn tick_never_reads_for_an_exited_rows_empty_cache() { + let sink = FaultySink::new(); + let durable = session(NamedProvider::Opencode, "ses_exited"); + seed(sink.as_ref(), &durable, "Finished").await; + let registry = TerminalRegistry::new(); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-exited".into(), + incarnation_id: "incarnation-exited".into(), + terminal_id: "T-exited".into(), + stream_id: "S-exited".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some("ses_exited".into()), + create_request_id: Some("req-exited".into()), + }, + "OpenCode".to_string(), + ); + registry.finish_managed_exit("T-exited", 0); + let binder = FakeBinder::default(); + registry.set_naming("T-exited", Some(durable.clone()), None); + binder.set_name_binding("T-exited", &durable, None); + let gets_before = sink.gets.load(Ordering::SeqCst); + let tick = TerminalNamesTick::default(); + + for _ in 0..3 { + reconcile_terminal_names(dyn_sink(&sink).as_ref(), ®istry, &binder, &label, &tick).await; + } + + assert_eq!(sink.gets.load(Ordering::SeqCst), gets_before); + assert!(registry.session_name_of("T-exited").is_none()); +} + +/// A live row bound to a durable session that has no record yet is read on +/// a back-off (passes 1, 3, 7, … — doubling, capped), not on every 2 s pass: +/// the naming publisher fills the row the moment the session is named, so +/// the tick only backstops a missed push. A due re-check still fills it. +#[tokio::test(start_paused = true)] +async fn tick_backs_off_a_bound_session_that_has_no_record() { + let sink = FaultySink::new(); + let bare = session(NamedProvider::Claude, "s-bare"); + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + bound_row(®istry, &binder, "T-bare", &bare, None); + let tick = TerminalNamesTick::default(); + let mut reads_per_pass = Vec::new(); + + for _ in 0..7 { + let before = sink.gets.load(Ordering::SeqCst); + reconcile_terminal_names(dyn_sink(&sink).as_ref(), ®istry, &binder, &label, &tick).await; + reads_per_pass.push(sink.gets.load(Ordering::SeqCst) - before); + } + assert_eq!(reads_per_pass, vec![1, 0, 1, 0, 0, 0, 1]); + + // The session is named but the push never reached the row: the next + // due re-check (8 passes after the third miss) fills it. + seed(sink.as_ref(), &bare, "Named later").await; + let mut filled_on = None; + for pass in 8..=15 { + let report = + reconcile_terminal_names(dyn_sink(&sink).as_ref(), ®istry, &binder, &label, &tick) + .await; + if report.filled == 1 { + filled_on = Some(pass); + break; + } + } + assert_eq!(filled_on, Some(15)); + assert_eq!(registry.title_of("T-bare").as_deref(), Some("Named later")); +} + +/// A row rebound to another session leaves the back-off of its old binding +/// behind: the new binding is read on the very next pass. +#[tokio::test(start_paused = true)] +async fn tick_back_off_resets_when_the_binding_moves() { + let sink = FaultySink::new(); + let first = session(NamedProvider::Claude, "s-first"); + let second = session(NamedProvider::Claude, "s-second"); + let registry = TerminalRegistry::new(); + let binder = FakeBinder::default(); + bound_row(®istry, &binder, "T-rebound", &first, None); + let tick = TerminalNamesTick::default(); + reconcile_terminal_names(dyn_sink(&sink).as_ref(), ®istry, &binder, &label, &tick).await; + + registry.set_naming("T-rebound", Some(second.clone()), None); + binder.set_name_binding("T-rebound", &second, None); + let before = sink.gets.load(Ordering::SeqCst); + reconcile_terminal_names(dyn_sink(&sink).as_ref(), ®istry, &binder, &label, &tick).await; + + assert_eq!(sink.gets.load(Ordering::SeqCst) - before, 1); +} + +/// The tick binds an unbound row whose door-known session has NO record +/// through a pending handle — never to the bare session. The door's session +/// may be a fresh create's preallocated (prospective) id; only a pending +/// binding lets the verified bind lanes transfer the name at +/// materialization. +#[tokio::test(start_paused = true)] +async fn tick_binds_a_record_less_door_session_through_a_pending_handle() { + let sink = FaultySink::new(); + let registry = TerminalRegistry::new(); + headless_row(®istry, "T-prealloc", "claude"); + let binder = FakeBinder::default(); + binder + .durable + .lock() + .unwrap() + .insert("T-prealloc".into(), ("claude".into(), "s-prealloc".into())); + + reconcile_terminal_names( + dyn_sink(&sink).as_ref(), + ®istry, + &binder, + &label, + &TerminalNamesTick::default(), + ) + .await; + + let (bound, handle) = binder + .binding("T-prealloc") + .expect("the tick binds the row"); + let handle = handle.expect("a pending binding retains its handle"); + assert_eq!(bound, SessionNameRef::Pending { id: handle }); + assert_eq!(registry.name_ref_of("T-prealloc"), Some(bound)); + assert_eq!( + sink.ensures.load(Ordering::SeqCst), + 1, + "the pending record was admitted" + ); + assert!(registry.session_name_of("T-prealloc").is_some()); +} + +// ── the managed descriptor's session ───────────────────────────────────── + +/// The supervisor echoes a create's own launch session id into the managed +/// descriptor. A fresh Claude create's server-preallocated `--session-id` is +/// prospective, so it never counts as an established durable session; any +/// other descriptor session (an existing soul's, reused by a retried create) +/// does. +#[test] +fn a_managed_descriptor_names_a_durable_session_only_when_it_is_not_the_creates_own_preallocation() +{ + let registry = TerminalRegistry::new(); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-T-managed".into(), + incarnation_id: "incarnation-T-managed".into(), + terminal_id: "T-managed".into(), + stream_id: "S-T-managed".into(), + mode: "claude".into(), + cwd: "/workspace".into(), + resume_session_id: Some("s-echoed".into()), + create_request_id: Some("req-T-managed".into()), + }, + "Claude CLI".to_string(), + ); + + assert_eq!( + managed_reused_session(®istry, "T-managed", Some("s-echoed")), + None, + "this create's own preallocation is prospective" + ); + assert_eq!( + managed_reused_session(®istry, "T-managed", Some("s-this-create")), + Some("s-echoed".to_string()), + "a reused soul's session differs from this create's preallocation" + ); + assert_eq!( + managed_reused_session(®istry, "T-managed", None), + Some("s-echoed".to_string()) + ); + assert_eq!(managed_reused_session(®istry, "T-unknown", None), None); +} diff --git a/crates/freshell-freshagent/src/terminal_name_cache.rs b/crates/freshell-freshagent/src/terminal_name_cache.rs new file mode 100644 index 000000000..2fef1a09a --- /dev/null +++ b/crates/freshell-freshagent/src/terminal_name_cache.rs @@ -0,0 +1,152 @@ +//! Fill uncached terminal names in one read, retry missing pending records, +//! and reject handles that moved to an earlier conversation. The tick and +//! create bind share the same record/redirect matching rule. + +use freshell_protocol::session_names::{ + NamedProvider, SessionNameRecord, SessionNameRef, SessionNameUpdate, +}; +use freshell_terminal::TerminalRegistry; + +use super::{ + apply_binding, keep_record_less_target, log_superseded, still_bound, NameBinding, + TerminalNameBinder, +}; +use crate::naming::{log_name_error, PendingNameInput, SessionNaming}; + +/// A bound row whose display cache is empty. +pub(super) struct UncachedRow { + pub(super) terminal_id: String, + pub(super) provider: NamedProvider, + pub(super) cwd: Option, + pub(super) name_ref: SessionNameRef, + pub(super) durable_target: Option, + pub(super) fallback_title: String, +} + +/// What one cache-fill pass found. +#[derive(Default)] +pub(super) struct FillReport { + pub(super) filled: usize, + /// Rows still bound to a durable session that answered "no record". + pub(super) empty: Vec<(String, SessionNameRef)>, +} + +/// Fill every uncached bound row from ONE batched read; a pending binding +/// whose record was never admitted (its create's `ensure_pending` failed) +/// is admitted here, once per tick. An answer for a row whose binding moved +/// during the read is dropped. +pub(super) async fn fill_uncached_rows( + sink: &dyn SessionNaming, + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + uncached: &[UncachedRow], +) -> FillReport { + let mut report = FillReport::default(); + if uncached.is_empty() { + return report; + } + let mut refs: Vec = Vec::new(); + for row in uncached { + if !refs.contains(&row.name_ref) { + refs.push(row.name_ref.clone()); + } + } + let updates = match sink.get(refs).await { + Ok(updates) => updates, + Err(error) => { + tracing::warn!( + target: "freshell_freshagent::naming", + op = "get", + rows = uncached.len(), + class = %error.code(), + "session_names.terminal_cache_fill_deferred: {error}" + ); + return report; + } + }; + for row in uncached { + if !still_bound(registry, &row.terminal_id, &row.name_ref) { + log_superseded(&row.terminal_id, &row.name_ref); + continue; + } + if let Some(update) = updates.iter().find(|update| answers(update, &row.name_ref)) { + if accept_answer(registry, binder, row, &update.record) { + report.filled += 1; + } + continue; + } + let SessionNameRef::Pending { id } = &row.name_ref else { + // A durable session with no record yet: nothing to fill; the + // publisher fills the row when the session is named. + report + .empty + .push((row.terminal_id.clone(), row.name_ref.clone())); + continue; + }; + let admitted = sink + .ensure_pending(PendingNameInput { + handle: id.clone(), + provider: row.provider, + cwd: row.cwd.clone(), + }) + .await; + match admitted { + Ok(_) if !still_bound(registry, &row.terminal_id, &row.name_ref) => { + log_superseded(&row.terminal_id, &row.name_ref); + } + Ok(update) => { + if accept_answer(registry, binder, row, &update.record) { + report.filled += 1; + } + } + Err(error) => log_name_error("ensure_pending", &row.name_ref, &error), + } + } + report +} + +/// Whether `update` answers a read of `name_ref` (directly, or through the +/// pending→durable redirect the store traversed). +pub(super) fn answers(update: &SessionNameUpdate, name_ref: &SessionNameRef) -> bool { + &update.record.name_ref == name_ref + || update + .redirects + .iter() + .any(|redirect| &redirect.from == name_ref) +} + +fn accept_answer( + registry: &TerminalRegistry, + binder: &dyn TerminalNameBinder, + row: &UncachedRow, + record: &SessionNameRecord, +) -> bool { + if matches!(row.name_ref, SessionNameRef::Pending { .. }) { + if let Some(target) = row + .durable_target + .as_ref() + .filter(|target| record.name_ref != row.name_ref && &record.name_ref != *target) + { + apply_binding( + registry, + binder, + &row.terminal_id, + &NameBinding { + name_ref: target.clone(), + naming_handle: None, + }, + ); + keep_record_less_target( + registry, + &row.terminal_id, + target, + &row.name_ref, + &record.name_ref, + &row.fallback_title, + ); + return false; + } + } + registry.update_session_name(&row.terminal_id, record); + true +} diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 25ab07a72..05ba2b84d 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -2867,7 +2867,7 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result { - registry.register_managed(descriptor); + registry.register_managed(descriptor, mode_label(&mode, cli.as_ref())); Ok(()) } Err(error) => Err(std::io::Error::other(format!( @@ -3426,90 +3426,84 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result { - naming_name_ref = - Some(freshell_protocol::session_names::SessionNameRef::Session { - provider, - session_id, - }); - } - None => { - let handle = body - .get("namingHandle") - .and_then(Value::as_str) - .filter(|h| !h.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("nh-{}", Uuid::new_v4().simple())); - let pending = freshell_protocol::session_names::SessionNameRef::Pending { - id: handle.clone(), - }; - match sink - .ensure_pending(crate::naming::PendingNameInput { - handle: handle.clone(), - provider, - cwd: spec.cwd.clone(), - }) - .await - { - Ok(update) => { - state.stash_naming_handle(&terminal_id, &handle); - pane_content["namingHandle"] = json!(handle); - naming_handle = Some(handle); - // Task 8 acceptance (WS parity): report the - // Pending binding as the row's name_ref too — - // `freshell-server`'s hook wiring writes it onto - // the shared identity registry, where the 2s - // tick's `pending_naming_binds()` lane reads it. - // Without it, a REST-created scoped pane's - // record can never transfer at materialization - // (the WS door's `admit_create_naming` stamps - // exactly this). - naming_name_ref = Some(pending.clone()); - // Unified agent names (Task 2 review, I3): the - // CLI-supplied `name` seeds the admitted record - // with the SAME intent default as the fresh-agent - // create lane (`lib.rs` `create_tab`'s seed — - // automatic: an agent/API suggestion never - // acquires a user rename's permanence), so the - // saved name agrees with the layout title. - // Logged, never blocking. - if let Some(seed) = body - .get("name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - { - if let Err(error) = sink - .rename(crate::naming::RenameNameInput { - target: pending, - name: seed.to_string(), - intent: - freshell_protocol::session_names::NameIntent::Automatic, - if_revision: None, - }) - .await - { - crate::naming::log_name_error( - "rename", - &update.record.name_ref, - &error, - ); - } - } - } - Err(error) => { - crate::naming::log_name_error("ensure_pending", &pending, &error); - } - } - } + Ok(update) => registry.update_session_name(&terminal_id, &update.record), + Err(error) => crate::naming::log_name_error("rename", &pending, &error), } } } @@ -3994,6 +3988,11 @@ pub(crate) async fn wait_for( // Slice 1 route tests (docs/plans/2026-07-18-agent-api-mcp-parity-spec.md §8.1) // ═══════════════════════════════════════════════════════════════════════════ +/// Name reliability (Task 10): the REST door's terminal naming tests. +#[cfg(test)] +#[path = "terminal_tabs_naming_tests.rs"] +mod naming_tests; + #[cfg(test)] mod tests { use super::*; @@ -4039,7 +4038,7 @@ mod tests { .clone() } - fn state_with_registry() -> FreshAgentState { + pub(super) fn state_with_registry() -> FreshAgentState { // Every REST test that can reach an amplifier create flows through // this constructor — isolate at the choke point (Task 11 Step 1). let _ = isolate_amplifier_home(); @@ -4048,7 +4047,7 @@ mod tests { .with_terminal_registry(freshell_terminal::TerminalRegistry::new()) } - fn app(state: FreshAgentState) -> Router { + pub(super) fn app(state: FreshAgentState) -> Router { crate::router(state) } @@ -4066,7 +4065,12 @@ mod tests { String::from_utf8(bytes.to_vec()).unwrap() } - async fn post(router: Router, uri: &str, body: Value, auth: bool) -> (StatusCode, Value) { + pub(super) async fn post( + router: Router, + uri: &str, + body: Value, + auth: bool, + ) -> (StatusCode, Value) { let mut req = Request::builder() .method("POST") .uri(uri) diff --git a/crates/freshell-freshagent/src/terminal_tabs_naming_tests.rs b/crates/freshell-freshagent/src/terminal_tabs_naming_tests.rs new file mode 100644 index 000000000..ded183bf8 --- /dev/null +++ b/crates/freshell-freshagent/src/terminal_tabs_naming_tests.rs @@ -0,0 +1,341 @@ +//! Name reliability (Task 10): the REST door's terminal naming bind. A REST +//! managed create binds its row through the shared helper in BOTH homes (the +//! registry row and the shared identity table) and presents the saved name; +//! a fresh managed Claude create's own server-preallocated `--session-id` +//! (which the supervisor echoes back) is prospective, so the row binds to a +//! pending handle instead of the bare, record-less session. +//! +//! A sibling of `terminal_tabs::tests`, reusing its REST harness. Managed +//! Claude exists only in a `provider-qualification` build with the +//! qualification policy naming claude, so that case is feature-gated and +//! re-runs itself alone in a child process with the policy set. + +use std::sync::Arc; + +use axum::http::StatusCode; +use serde_json::json; + +use super::tests::{app, post, state_with_registry}; + +/// A supervisor that launches whatever it is asked to (the REST managed +/// door's controller for the naming tests). +struct LaunchingManagedController; + +impl freshell_terminal::registry::ManagedTerminalController for LaunchingManagedController { + fn lookup_terminal<'a>( + &'a self, + _: &'a str, + _: Option, + ) -> freshell_terminal::registry::ManagedTerminalFuture< + 'a, + Result, String>, + > { + Box::pin(async { Ok(None) }) + } + + fn launch<'a>( + &'a self, + request: freshell_terminal::registry::ManagedTerminalLaunch, + ) -> freshell_terminal::registry::ManagedTerminalFuture< + 'a, + Result< + freshell_terminal::registry::ManagedTerminalDescriptor, + freshell_terminal::registry::ManagedTerminalLaunchError, + >, + > { + Box::pin(async move { + Ok(freshell_terminal::registry::ManagedTerminalDescriptor { + soul_id: format!("soul-{}", request.terminal_id), + incarnation_id: format!("incarnation-{}", request.terminal_id), + terminal_id: request.terminal_id, + stream_id: request.stream_id, + mode: request.mode, + cwd: request.spec.cwd.unwrap_or_else(|| "/workspace".into()), + resume_session_id: request.resume_session_id, + create_request_id: request.create_request_id, + }) + }) + } + + fn input<'a>( + &'a self, + _: freshell_terminal::registry::ManagedTerminalDescriptor, + _: String, + ) -> freshell_terminal::registry::ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn resize<'a>( + &'a self, + _: freshell_terminal::registry::ManagedTerminalDescriptor, + _: u16, + _: u16, + ) -> freshell_terminal::registry::ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn stop<'a>( + &'a self, + _: freshell_terminal::registry::ManagedTerminalDescriptor, + ) -> freshell_terminal::registry::ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn read_output<'a>( + &'a self, + _: freshell_terminal::registry::ManagedTerminalDescriptor, + _: i64, + _: u64, + ) -> freshell_terminal::registry::ManagedTerminalFuture< + 'a, + Result, + > { + Box::pin(async { Err("no output in the naming test".into()) }) + } +} + +/// The shared identity table `freshell-server` injects into the REST +/// door, as a recording fake. +#[derive(Default)] +struct RecordingNameBinder { + bindings: std::sync::Mutex< + std::collections::HashMap, + >, +} + +impl crate::terminal_name_binding::TerminalNameBinder for RecordingNameBinder { + fn set_name_binding( + &self, + terminal_id: &str, + name_ref: &freshell_protocol::session_names::SessionNameRef, + _naming_handle: Option<&str>, + ) { + self.bindings + .lock() + .unwrap() + .insert(terminal_id.to_string(), name_ref.clone()); + } + + fn name_ref_of( + &self, + terminal_id: &str, + ) -> Option { + self.bindings.lock().unwrap().get(terminal_id).cloned() + } + + fn naming_handle_of(&self, _terminal_id: &str) -> Option { + None + } + + fn durable_session_of(&self, _terminal_id: &str) -> Option<(String, String)> { + None + } +} + +/// Name reliability (Task 10): a REST managed create resuming a soul's +/// session binds the row to that session — in the registry AND the +/// shared identity table — and presents its saved name (the row was +/// titled "Shell" and its name cache stayed empty). +#[tokio::test] +async fn rest_managed_create_binds_naming() { + use freshell_protocol::session_names::{NamedProvider, SessionNameRef}; + + let binder = Arc::new(RecordingNameBinder::default()); + let state = state_with_registry() + .with_cli_commands(Arc::new(vec![freshell_platform::CliCommandSpec { + name: "opencode".into(), + label: "OpenCode".into(), + default_cmd: "opencode".into(), + ..Default::default() + }])) + .with_terminal_name_binder(binder.clone()); + let sink = crate::naming::test_support::RecordingSink::new(); + state.set_session_naming(sink.clone()); + let durable = SessionNameRef::Session { + provider: NamedProvider::Opencode, + session_id: "ses_rest_soul".into(), + }; + seed_saved_name(&sink, &durable, "seed-rest-soul", "Survivor").await; + let registry = state.terminal_registry.clone().expect("registry wired"); + registry.set_managed_controller(Some(Arc::new(LaunchingManagedController))); + + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ + "mode": "opencode", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": { "provider": "opencode", "sessionId": "ses_rest_soul" }, + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let terminal_id = body["data"]["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + + assert!(registry.is_managed(&terminal_id), "the managed door ran"); + assert_eq!(registry.name_ref_of(&terminal_id), Some(durable.clone())); + assert_eq!( + binder.bindings.lock().unwrap().get(&terminal_id), + Some(&durable), + "the shared identity table carries the same binding" + ); + assert_eq!( + registry.session_name_of(&terminal_id).map(|r| r.name), + Some("Survivor".to_string()) + ); + assert_eq!(registry.title_of(&terminal_id).as_deref(), Some("Survivor")); +} + +/// Seed `target` with a saved name through the public naming surface (admit +/// a handle, verify-bind it, then a user rename). +async fn seed_saved_name( + sink: &crate::naming::test_support::RecordingSink, + target: &freshell_protocol::session_names::SessionNameRef, + handle: &str, + name: &str, +) { + use crate::naming::SessionNaming as _; + use freshell_protocol::session_names::{NameIntent, NamedProvider, SessionNameRef}; + + let provider = match target { + SessionNameRef::Session { provider, .. } => *provider, + SessionNameRef::Pending { .. } => NamedProvider::Claude, + }; + sink.ensure_pending(crate::naming::PendingNameInput { + handle: handle.into(), + provider, + cwd: None, + }) + .await + .unwrap(); + sink.bind_pending(crate::naming::BindNameInput { + pending: SessionNameRef::Pending { id: handle.into() }, + target: target.clone(), + acquisition: crate::naming::test_support::verified_claude_acquisition("seed"), + }) + .await + .unwrap(); + sink.rename(crate::naming::RenameNameInput { + target: target.clone(), + name: name.into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); +} + +/// The qualification policy is a process-wide env var, so the case re-runs +/// itself alone in a child process with it set (and a throwaway HOME) — +/// never `set_var` in the shared test process. Answers true in the parent, +/// after the child passed. +#[cfg(feature = "provider-qualification")] +fn run_with_claude_qualification(test: &str) -> bool { + const CHILD: &str = "FRESHELL_TEST_CLAUDE_QUALIFICATION_CHILD"; + let exact = format!( + "{}::{test}", + module_path!() + .split_once("::") + .map_or(module_path!(), |(_, path)| path) + ); + if let Ok(child) = std::env::var(CHILD) { + assert_eq!(child, exact, "the qualification child ran the wrong case"); + return false; + } + let home = tempfile::tempdir().expect("child home"); + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", exact.as_str(), "--nocapture"]) + .env(CHILD, &exact) + .env("FRESHELL_MANAGED_PROVIDER_QUALIFICATION", "claude") + .env("HOME", home.path()) + .output() + .expect("run the qualification child"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed"), + "the qualification child failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + true +} + +/// A handle-less REST fresh managed Claude create: the supervisor echoes the +/// create's own server-preallocated `--session-id` back in its descriptor. +/// That id is prospective (no transcript, no record), so the row binds to a +/// pending handle — admitted, retained, and carried in the pane content — +/// never to the bare session, which would leave no pending record for the +/// verified bind lane to transfer at materialization. +#[cfg(feature = "provider-qualification")] +#[tokio::test] +async fn rest_fresh_managed_claude_create_binds_a_pending_handle_not_its_preallocated_id() { + use freshell_protocol::session_names::SessionNameRef; + + if run_with_claude_qualification( + "rest_fresh_managed_claude_create_binds_a_pending_handle_not_its_preallocated_id", + ) { + return; + } + + let binder = Arc::new(RecordingNameBinder::default()); + let state = state_with_registry() + .with_cli_commands(Arc::new(vec![freshell_platform::CliCommandSpec { + name: "claude".into(), + label: "Claude CLI".into(), + default_cmd: "claude".into(), + resume_args: Some(vec!["--resume".into(), "{{sessionId}}".into()]), + create_session_args: Some(vec!["--session-id".into(), "{{sessionId}}".into()]), + ..Default::default() + }])) + .with_terminal_name_binder(binder.clone()); + let sink = crate::naming::test_support::RecordingSink::new(); + state.set_session_naming(sink.clone()); + let registry = state.terminal_registry.clone().expect("registry wired"); + registry.set_managed_controller(Some(Arc::new(LaunchingManagedController))); + + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ + "mode": "claude", + "cwd": std::env::temp_dir().to_string_lossy(), + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let terminal_id = body["data"]["terminalId"] + .as_str() + .expect("terminalId") + .to_string(); + + assert!(registry.is_managed(&terminal_id), "the managed door ran"); + let preallocated = registry + .managed_descriptor(&terminal_id) + .and_then(|descriptor| descriptor.resume_session_id) + .expect("the fresh create preallocated a --session-id"); + let bound = registry + .name_ref_of(&terminal_id) + .expect("the row is bound"); + let SessionNameRef::Pending { id: handle } = &bound else { + panic!("the row must bind a pending handle, not the prospective session {preallocated}: {bound:?}"); + }; + assert_eq!( + binder.bindings.lock().unwrap().get(&terminal_id), + Some(&bound), + "the shared identity table carries the same pending binding" + ); + assert!( + registry.session_name_of(&terminal_id).is_some(), + "the admitted pending record is cached" + ); + assert_eq!( + state.peek_naming_handle(&terminal_id).as_deref(), + Some(handle.as_str()), + "the REST door retains the handle for the bind lane" + ); +} diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index da8788075..1734cfb30 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -779,6 +779,13 @@ pub struct ReconcilePane { /// Informational only — never trusted. #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, + /// Name reliability: the pane's persisted pre-durable naming handle + /// (`terminal.create.namingHandle`). Additive optional. Read only when + /// the managed pre-pass reconstructs a supervisor-owned row after a web + /// restart: a zero-turn soul's name still lives under this handle, so + /// the adopted row rejoins it. The verdict ladder ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub naming_handle: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 9fd53cbe4..484d0036d 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1784,6 +1784,11 @@ pub struct TerminalReplaced { pub struct TerminalSessionAssociated { pub session_ref: SessionLocator, pub terminal_id: String, + /// The authoritative naming binding after association. A prospective + /// zero-turn session can retain its pending handle; a conversation + /// switch names the new durable session even before it has a record. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_ref: Option, /// Present only on a server-authoritative mid-session rebind; names the /// session id this association supersedes. Optional+additive on the wire /// (WS_PROTOCOL_VERSION deliberately not bumped -- see plan diff --git a/crates/freshell-protocol/src/session_names.rs b/crates/freshell-protocol/src/session_names.rs index e8b699b54..a9e53050c 100644 --- a/crates/freshell-protocol/src/session_names.rs +++ b/crates/freshell-protocol/src/session_names.rs @@ -179,6 +179,32 @@ pub struct NativeSyncProjection { pub reason: Option, } +/// Why a rename did not change the saved name (name-reliability Task 4). +/// `ProtectedName`: an automation rename never replaces a name a person +/// chose (manual), a migration-protected label, or an accepted Freshell AI +/// name. `DefaultLabel`: an automation rename never saves a default or +/// derived label ("Tab 3", a provider label, the target's own folder name). +/// `Unchanged`: the saved name already is the requested text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenameOutcomeReason { + ProtectedName, + DefaultLabel, + Unchanged, +} + +/// Whether a rename applied (name-reliability Task 4). Additive and +/// optional: only rename RESPONSES carry it — broadcasts never do. `applied` +/// is true when the saved name now is the requested text (including the +/// `unchanged` case); false means the record kept its name for `reason`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameOutcome { + pub applied: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + /// The common response/broadcast shape for every naming operation. `changed` /// is true only when the accepted record (text/source/revision) changed vs /// the store's previously established state — losing automatic offers and @@ -186,6 +212,7 @@ pub struct NativeSyncProjection { /// `native_sync` (Task 3) is the additive writeback status projection; /// status-only updates (an unchanged record whose nativeSync moved) publish /// with `changed: false` and fold on the client by `documentGeneration`. +/// `outcome` (name-reliability Task 4) rides rename responses only. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionNameUpdate { @@ -195,6 +222,8 @@ pub struct SessionNameUpdate { pub changed: bool, #[serde(skip_serializing_if = "Option::is_none")] pub native_sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, } /// Rename request body (HTTP `PATCH /api/session-names` and the MCP-bridged diff --git a/crates/freshell-runtime-observability/src/lib.rs b/crates/freshell-runtime-observability/src/lib.rs index a3a033b41..f2e76a394 100644 --- a/crates/freshell-runtime-observability/src/lib.rs +++ b/crates/freshell-runtime-observability/src/lib.rs @@ -62,7 +62,7 @@ fn common_secret_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { Regex::new( - r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{20,})\b", + r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{20,}|AQ\.[0-9A-Za-z_-]{20,})\b", ) .expect("valid common-secret redaction regex") }) @@ -84,6 +84,60 @@ fn provider_content_field_re() -> &'static Regex { }) } +/// The shortest value [`LiveSecrets`] accepts: a real credential is far +/// longer, and a very short value would shred ordinary log text. +pub const MIN_LIVE_SECRET_LEN: usize = 8; + +/// Secret values that are only known while the process runs (a Gemini key +/// saved in Settings, say), shared between whoever learns them and the +/// writers that must redact them. Clones share one set. +#[derive(Clone, Default)] +pub struct LiveSecrets(std::sync::Arc>>); + +impl LiveSecrets { + /// Add a value; `false` when it is too short or already present. Values + /// are never removed: a key replaced in Settings may still appear in a + /// late line, and the failure direction is over-redaction. + pub fn insert(&self, secret: &str) -> bool { + if secret.len() < MIN_LIVE_SECRET_LEN { + return false; + } + let mut values = self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if values.iter().any(|value| value == secret) { + return false; + } + values.push(secret.to_string()); + // Longest first, so a secret that contains another is replaced whole. + values.sort_by_key(|value| std::cmp::Reverse(value.len())); + true + } + + /// Replace every live secret value in `line`. + pub fn redact(&self, line: String) -> String { + let values = self + .0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + values.iter().fold(line, |line, value| { + if line.contains(value.as_str()) { + line.replace(value.as_str(), "***REDACTED***") + } else { + line + } + }) + } +} + +/// [`scrub`] plus the live secret values. The exact values are replaced +/// FIRST: a pattern that matches only part of a value (an `AQ.` key with a +/// second `.`, say) would otherwise leave the rest of it behind. +pub fn scrub_with_live_secrets(line: &str, process_secret: &str, live: &LiveSecrets) -> String { + scrub(&live.redact(line.to_string()), process_secret) +} + /// Scrub one already-rendered record before it reaches any persistent sink. /// /// The exact process secret is replaced everywhere, then common and future @@ -125,6 +179,22 @@ pub fn scrub(line: &str, process_secret: &str) -> String { .into_owned() } +fn redact_live_values(value: &mut Value, live: &LiveSecrets) { + match value { + Value::String(text) => { + let redacted = live.redact(std::mem::take(text)); + *text = redacted; + } + Value::Object(fields) => fields + .values_mut() + .for_each(|child| redact_live_values(child, live)), + Value::Array(items) => items + .iter_mut() + .for_each(|child| redact_live_values(child, live)), + _ => {} + } +} + fn redact_sensitive_json(value: &mut Value) { match value { Value::Object(fields) => { @@ -187,6 +257,7 @@ struct RotatingInner { file: File, size: u64, process_secret: String, + live_secrets: LiveSecrets, } impl RotatingInner { @@ -247,16 +318,35 @@ impl RotatingJsonlWriter { file, size, process_secret: process_secret.into(), + live_secrets: LiveSecrets::default(), }), }) } + /// [`create`](Self::create), also redacting every value in `live_secrets` + /// (including values added after creation). + pub fn create_with_live_secrets( + path: impl Into, + max_bytes: u64, + max_backups: u32, + process_secret: impl Into, + live_secrets: LiveSecrets, + ) -> std::io::Result { + let writer = Self::create(path, max_bytes, max_backups, process_secret)?; + writer + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .live_secrets = live_secrets; + Ok(writer) + } + pub fn write_line(&self, line: &str) -> std::io::Result<()> { let mut inner = self .inner .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let scrubbed = scrub(line, &inner.process_secret); + let scrubbed = scrub_with_live_secrets(line, &inner.process_secret, &inner.live_secrets); let mut bytes = scrubbed.into_bytes(); bytes.push(b'\n'); if inner.size > 0 && inner.size.saturating_add(bytes.len() as u64) > inner.max_bytes { @@ -269,13 +359,18 @@ impl RotatingJsonlWriter { } pub fn write_json(&self, value: &T) -> std::io::Result<()> { - let process_secret = self - .inner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .process_secret - .clone(); - let line = redacted_json(value, false, &process_secret)?; + let (process_secret, live_secrets) = { + let inner = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (inner.process_secret.clone(), inner.live_secrets.clone()) + }; + // Exact live values first (see `scrub_with_live_secrets`): JSON + // field redaction below would otherwise run on a partly matched key. + let mut value = serde_json::to_value(value).map_err(std::io::Error::other)?; + redact_live_values(&mut value, &live_secrets); + let line = redacted_json(&value, false, &process_secret)?; self.write_line(&line) } @@ -382,6 +477,40 @@ mod tests { assert!(nested.contains("retained")); } + /// Name-reliability Task 6: values added to the live set after the + /// writer exists are redacted WHOLE on both write paths, even where a + /// common pattern would match only part of them (a second `.` stops the + /// `AQ.` pattern). + #[test] + fn live_secrets_are_redacted_whole_on_both_write_paths() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let live = LiveSecrets::default(); + let writer = + RotatingJsonlWriter::create_with_live_secrets(&path, 1 << 20, 1, "", live.clone()) + .unwrap(); + let key = "AQ.firstPartOfTheKey12345.tail-after-the-second-dot"; + assert!(live.insert(key)); + assert!(!live.insert(key), "a value is kept once"); + writer.write_line(&format!("line carrying {key}")).unwrap(); + writer + .write_json( + &serde_json::json!({ "detail": format!("json carrying {key}"), "list": [key] }), + ) + .unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(!content.contains("tail-after-the-second-dot"), "{content}"); + assert!(!content.contains("firstPartOfTheKey"), "{content}"); + assert!( + content.contains("line carrying ***REDACTED***"), + "{content}" + ); + assert!( + content.contains("json carrying ***REDACTED***"), + "{content}" + ); + } + #[test] fn rotation_and_atomic_documents_are_private_and_bounded() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-server/Cargo.toml b/crates/freshell-server/Cargo.toml index 979a377de..56d717cfb 100644 --- a/crates/freshell-server/Cargo.toml +++ b/crates/freshell-server/Cargo.toml @@ -96,6 +96,12 @@ uuid = { version = "1", features = ["v4"] } # provider (`ring`) is actually installed; see freshell-protocol's Cargo.toml # for the `rustls-tls-no-provider` hazard this deliberately avoids. reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls"] } +# Name-reliability Task 6: an untrusted certificate is told apart from a +# refused connection by downcasting reqwest's error chain to +# `rustls::Error::InvalidCertificate` (`gemini_failure.rs`). The version +# reqwest already resolves in the workspace lock (no new crate); no +# provider features, only the error type. +rustls = { version = "0.23.41", default-features = false, features = ["std"] } # Decode the base64 PNG the UI returns in `ui.screenshot.result` before writing it # (POST /api/screenshots). Already resolved in the workspace lock (no new download). base64 = "0.22" diff --git a/crates/freshell-server/src/ai_title.rs b/crates/freshell-server/src/ai_title.rs index ec72582cf..60a612aaa 100644 --- a/crates/freshell-server/src/ai_title.rs +++ b/crates/freshell-server/src/ai_title.rs @@ -3,6 +3,11 @@ //! no HTTP-mock crates; see crates/freshell-opencode for precedent). use std::sync::{Arc, RwLock}; +use crate::gemini_failure::GeminiFailure; +use crate::session_name_capability::{ + KeyRouteTransport, NamingRoute, ProxyFacts, SessionNameTransport, +}; + pub const GEMINI_MODEL: &str = "gemini-3.5-flash-lite"; pub const GEMINI_DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; pub const SESSION_TITLE_MAX_OUTPUT_TOKENS: u32 = 30; @@ -139,13 +144,6 @@ pub trait GeminiTransport: Send + Sync { ) -> BoxFuture>; } -/// Authentication source selected for session-name Gemini requests. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum GeminiCredentialRoute { - Direct, - OneCliProxy, -} - /// The environment values that reqwest's system proxy matcher uses. Values /// stay private because they can contain proxy credentials. #[derive(Clone, Default)] @@ -217,6 +215,20 @@ impl GeminiProxyEnvironment { }) } + /// The proxy reqwest would use for `url` under this environment, if any + /// (NO_PROXY applied). Callers reduce it to booleans immediately: proxy + /// URLs can carry credentials. + pub(crate) fn proxy_for_url(&self, url: &str) -> Option<&str> { + let destination = url.parse::().ok()?; + let (Some(scheme), Some(host)) = (destination.scheme_str(), destination.host()) else { + return None; + }; + if self.bypasses_proxy(host) { + return None; + } + self.effective_proxy(scheme) + } + fn bypasses_proxy(&self, host: &str) -> bool { self.no_proxy.split(',').map(str::trim).any(|entry| { if entry == "*" { @@ -237,161 +249,196 @@ impl GeminiProxyEnvironment { } } -/// Whether session-name requests can use either the selected OneCLI proxy or -/// an existing direct API key. The proxy route is captured at construction; -/// the shared key cell remains live for settings-save updates. +/// The credential and proxy facts for session-name Gemini requests +/// (name-reliability Task 6). The key cell stays live, so a Settings save +/// takes effect at the next attempt; the proxy facts are read once at +/// construction, as reqwest reads the proxy environment once per client. +/// The route itself is chosen per attempt by +/// [`crate::session_name_capability::select_naming_route`]. #[derive(Clone)] pub struct GeminiSessionNameAuth { - direct_key: AiKeyCell, - route: GeminiCredentialRoute, + key: AiKeyCell, + facts: ProxyFacts, } impl GeminiSessionNameAuth { - pub fn from_environment(direct_key: AiKeyCell, gemini_base_url: &str) -> Self { - let proxy_environment = GeminiProxyEnvironment::from_environment(); - let route = Self::route_for(gemini_base_url, &proxy_environment); - Self { direct_key, route } + pub fn from_environment(key: AiKeyCell, gemini_base_url: &str) -> Self { + Self::from_proxy_environment( + key, + gemini_base_url, + &GeminiProxyEnvironment::from_environment(), + ) } - pub fn enabled(&self) -> bool { - self.route == GeminiCredentialRoute::OneCliProxy || self.direct_key().is_some() + pub fn from_proxy_environment( + key: AiKeyCell, + gemini_base_url: &str, + proxy_environment: &GeminiProxyEnvironment, + ) -> Self { + Self { + key, + facts: crate::session_name_capability::proxy_facts(proxy_environment, gemini_base_url), + } } - pub fn direct_key(&self) -> Option { - self.direct_key.get().filter(|key| !key.is_empty()) + /// The route the next attempt uses. + pub fn route(&self) -> NamingRoute { + crate::session_name_capability::select_naming_route(&self.key, self.facts) } - #[cfg(test)] - pub fn route(&self) -> GeminiCredentialRoute { - self.route + pub fn facts(&self) -> ProxyFacts { + self.facts } - pub fn route_for( - gemini_base_url: &str, - proxy_environment: &GeminiProxyEnvironment, - ) -> GeminiCredentialRoute { - let Ok(destination) = gemini_base_url.parse::() else { - return GeminiCredentialRoute::Direct; - }; - let (Some(scheme), Some(host)) = (destination.scheme_str(), destination.host()) else { - return GeminiCredentialRoute::Direct; - }; - if proxy_environment.bypasses_proxy(host) { - return GeminiCredentialRoute::Direct; - } - proxy_environment - .effective_proxy(scheme) - .filter(|proxy| has_onecli_authorization(proxy)) - .map_or(GeminiCredentialRoute::Direct, |_| { - GeminiCredentialRoute::OneCliProxy - }) + /// Whether any credential route is available (a key, or a OneCLI proxy). + pub fn enabled(&self) -> bool { + self.route().is_available() + } + + pub fn key(&self) -> Option { + self.key.get().filter(|key| !key.is_empty()) + } + + pub fn key_configured(&self) -> bool { + self.key.enabled() } + /// No ambient proxy: a configured key is the only route. #[cfg(test)] - pub(crate) fn direct_for_test(direct_key: AiKeyCell) -> Self { + pub(crate) fn direct_for_test(key: AiKeyCell) -> Self { Self { - direct_key, - route: GeminiCredentialRoute::Direct, + key, + facts: ProxyFacts::default(), } } + /// A OneCLI-marked ambient proxy. #[cfg(test)] - pub(crate) fn onecli_for_test(direct_key: AiKeyCell) -> Self { + pub(crate) fn onecli_for_test(key: AiKeyCell) -> Self { Self { - direct_key, - route: GeminiCredentialRoute::OneCliProxy, + key, + facts: ProxyFacts { + via_proxy: true, + onecli_marker: true, + }, } } } -/// Name-specific Gemini transport. Unlike [`GeminiHttp`], it leaves the -/// direct API-key header off when reqwest routes through OneCLI. +/// The session-name Gemini transport (name-reliability Task 6): one request +/// per attempt on the route [`GeminiSessionNameAuth::route`] picks. A keyed +/// request always carries the key; it goes DIRECT when the ambient proxy is +/// OneCLI's and through the normal (proxy-honoring) client otherwise; a +/// keyless request (the inherited OneCLI route) uses the normal client. +/// Failures come back classified ([`crate::gemini_failure`]) and never carry +/// reqwest's error text, which can name the proxy. pub struct GeminiSessionNameHttp { + /// Honors the ambient proxy environment (`reqwest::Client::builder()`). client: reqwest::Client, + /// Ignores it. + direct_client: reqwest::Client, auth: GeminiSessionNameAuth, base_url: String, } +/// How long either session-name client waits for a connection (name +/// resolution, TCP, the proxy and the TLS handshake). Shorter than the +/// worker's 20-second request bound, so a route whose packets are silently +/// dropped fails as a connect error, which pauses naming and uses no +/// attempt, instead of timing out and using one. +pub const SESSION_NAME_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + impl GeminiSessionNameHttp { - pub fn new(client: reqwest::Client, auth: GeminiSessionNameAuth, base_url: String) -> Self { + /// `client` is the normal (proxy-honoring) client's builder; both + /// clients get [`SESSION_NAME_CONNECT_TIMEOUT`]. + pub fn new( + client: reqwest::ClientBuilder, + auth: GeminiSessionNameAuth, + base_url: String, + ) -> Self { + // A build fails only when the TLS backend cannot initialize, which + // `reqwest::Client::new()` would panic on too. Never fall back to + // the proxy-honoring client: the direct route must not reach a + // OneCLI proxy. + let build = |builder: reqwest::ClientBuilder| { + builder + .connect_timeout(SESSION_NAME_CONNECT_TIMEOUT) + .build() + .expect("the TLS backend initializes, as for reqwest::Client::new()") + }; Self { - client, + client: build(client), + direct_client: build(reqwest::Client::builder().no_proxy()), auth, base_url, } } } -impl GeminiTransport for GeminiSessionNameHttp { +impl SessionNameTransport for GeminiSessionNameHttp { fn generate_content( &self, prompt: String, max_output_tokens: u32, - ) -> BoxFuture> { - let client = self.client.clone(); - let direct_key = match self.auth.route { - GeminiCredentialRoute::Direct => self.auth.direct_key(), - GeminiCredentialRoute::OneCliProxy => None, + ) -> BoxFuture> { + let route = self.auth.route(); + let (client, key) = match route { + NamingRoute::Key(KeyRouteTransport::Direct) => { + (self.direct_client.clone(), self.auth.key()) + } + NamingRoute::Key(KeyRouteTransport::AmbientProxy) => { + (self.client.clone(), self.auth.key()) + } + NamingRoute::InheritedOneCli => (self.client.clone(), None), + NamingRoute::Unavailable => { + return Box::pin(async { Err(GeminiFailure::no_credential()) }); + } }; - let fallback_key = self.auth.direct_key(); - let route = self.auth.route; + let via_proxy = route.request_via_proxy(self.auth.facts()); let url = format!( "{}/models/{GEMINI_MODEL}:generateContent", self.base_url.trim_end_matches('/') ); Box::pin(async move { - if route == GeminiCredentialRoute::Direct && direct_key.is_none() { - return Err("no gemini api key".to_string()); - } - let request = - build_gemini_request(&client, &url, prompt.clone(), max_output_tokens, direct_key)?; - let response = request - .send() - .await - .map_err(|_| "gemini request failed".to_string())?; - let status = response.status(); - - if !status.is_success() { - // Only an explicit structured OneCLI credential error can - // authorize the one direct-key retry. A status by itself, - // unreadable body, or any other response remains an error. - let body = response - .bytes() - .await - .map_err(|_| format!("gemini http {status}"))?; - if route == GeminiCredentialRoute::OneCliProxy { - if let (Some(classification), Some(key)) = ( - classify_onecli_credential_error(status, &body), - fallback_key.filter(|key| !key.is_empty()), - ) { - tracing::debug!( - target: "freshell_server::ai_title", - operation = "onecli_direct_key_fallback", - classification, - "ai_title.onecli_direct_key_fallback" - ); - let retry = build_gemini_request( - &client, - &url, - prompt, - max_output_tokens, - Some(key), - )?; - return send_gemini_request(retry) - .await - .map_err(safe_session_name_request_error); - } - } - return Err(format!("gemini http {status}")); + let request = build_gemini_request(&client, &url, prompt, max_output_tokens, key) + .map_err(|_| GeminiFailure::request_encoding())?; + let response = request.send().await.map_err(|error| { + crate::gemini_failure::classify_reqwest_error(&error, via_proxy) + })?; + let status = response.status().as_u16(); + if !response.status().is_success() { + // An unreadable error body still classifies by status. + let body = response.bytes().await.unwrap_or_default(); + return Err(crate::gemini_failure::classify_gemini_failure( + status, &body, + )); } - - parse_gemini_success_response(response) + let bytes = response + .bytes() .await - .map_err(safe_session_name_request_error) + .map_err(|_| GeminiFailure::response_read_failed(status))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| GeminiFailure::malformed_response(status))?; + Ok(extract_candidate_text(&value)) }) } } +/// The legacy string-error view, for the callers that still take a +/// [`GeminiTransport`] (the sweep's excluded-provider ladder and the +/// generate-title compatibility route): the same route and request, with +/// the failure rendered as its safe text. +impl GeminiTransport for GeminiSessionNameHttp { + fn generate_content( + &self, + prompt: String, + max_output_tokens: u32, + ) -> BoxFuture> { + let call = SessionNameTransport::generate_content(self, prompt, max_output_tokens); + Box::pin(async move { call.await.map_err(|failure| failure.to_string()) }) + } +} + /// Build the shared Gemini wire request. reqwest's `json` feature is disabled /// in this crate, so serialize the body explicitly. fn build_gemini_request( @@ -431,35 +478,6 @@ async fn parse_gemini_success_response(response: reqwest::Response) -> Result Option<&'static str> { - if status.is_success() || status == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED { - return None; - } - let value: serde_json::Value = serde_json::from_slice(body).ok()?; - match value.get("error")?.as_str()? { - "credential_not_found" => Some("credential_not_found"), - "app_not_connected" => Some("app_not_connected"), - _ => None, - } -} - -fn safe_session_name_request_error(reason: String) -> String { - if reason.starts_with("gemini http ") { - reason - } else { - // reqwest errors can include details from its proxy connection. The - // naming worker logs returned errors, so keep those details out of - // diagnostics. - "gemini request failed".to_string() - } -} - fn first_present<'a>(upper: Option<&'a str>, lower: Option<&'a str>) -> Option<&'a str> { upper.or(lower) } @@ -468,7 +486,7 @@ fn first_present<'a>(upper: Option<&'a str>, lower: Option<&'a str>) -> Option<& /// environment matcher, then inspect its parsed authority for OneCLI's /// authorization marker. This deliberately returns only a boolean so proxy /// credentials can never enter diagnostics. -fn has_onecli_authorization(proxy: &str) -> bool { +pub(crate) fn has_onecli_authorization(proxy: &str) -> bool { parse_proxy(proxy).unwrap_or(false) } @@ -595,99 +613,23 @@ pub async fn generate_ai_session_title( let text = transport .generate_content(prompt, SESSION_TITLE_MAX_OUTPUT_TOKENS) .await?; + Ok(finalize_session_title(&text)) +} + +/// Trim the model's answer, cap it at 80 characters, and map empty to None. +pub fn finalize_session_title(text: &str) -> Option { let title: String = text.trim().chars().take(SESSION_TITLE_CHAR_CAP).collect(); - Ok(if title.is_empty() { None } else { Some(title) }) + if title.is_empty() { + None + } else { + Some(title) + } } #[cfg(test)] mod tests { use super::*; - const TEST_DIRECT_FALLBACK_KEY: &str = "direct-fallback-sentinel"; - - struct SessionNameResponseFixture { - base_url: String, - observed_keys: Arc>>>, - } - - async fn start_session_name_response_fixture( - responses: Vec<(axum::http::StatusCode, String)>, - ) -> SessionNameResponseFixture { - use axum::body::Body; - use axum::routing::post; - use axum::{http::Response, Router}; - use std::collections::VecDeque; - use std::sync::Mutex; - - let observed_keys = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&observed_keys); - let responses = Arc::new(Mutex::new(VecDeque::from(responses))); - let app = Router::new().route( - "/v1beta/models/gemini-3.5-flash-lite:generateContent", - post(move |headers: axum::http::HeaderMap| { - let observed = Arc::clone(&observed); - let responses = Arc::clone(&responses); - async move { - observed.lock().unwrap().push( - headers - .get("x-goog-api-key") - .and_then(|value| value.to_str().ok()) - .map(str::to_string), - ); - let (status, body) = responses - .lock() - .unwrap() - .pop_front() - .expect("fixture has a response for each request"); - let mut response = Response::new(Body::from(body)); - *response.status_mut() = status; - response - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - SessionNameResponseFixture { - base_url: format!("http://{addr}/v1beta"), - observed_keys, - } - } - - fn onecli_session_name_transport( - fixture: &SessionNameResponseFixture, - direct_key: Option<&str>, - ) -> GeminiSessionNameHttp { - let auth = GeminiSessionNameAuth { - direct_key: AiKeyCell::init(direct_key.map(str::to_string), None), - route: GeminiCredentialRoute::OneCliProxy, - }; - let client = reqwest::Client::builder().no_proxy().build().unwrap(); - GeminiSessionNameHttp::new(client, auth, fixture.base_url.clone()) - } - - fn assert_onecli_fallback_keys(observed_keys: &std::sync::Mutex>>) { - let observed = observed_keys.lock().unwrap(); - assert_eq!(observed.len(), 2, "fallback makes exactly two requests"); - assert!( - observed[0].is_none(), - "the OneCLI request has no direct key" - ); - assert!( - observed[1].as_deref() == Some(TEST_DIRECT_FALLBACK_KEY), - "the single fallback request carries the existing direct key" - ); - } - - fn assert_onecli_no_fallback_key(observed_keys: &std::sync::Mutex>>) { - let observed = observed_keys.lock().unwrap(); - assert_eq!(observed.len(), 1, "a rejected response makes one request"); - assert!( - observed[0].is_none(), - "a rejected OneCLI request has no direct key" - ); - } - #[test] fn key_cell_boot_env_wins_over_settings_nonforcing() { let cell = AiKeyCell::init(Some("envkey".into()), Some("settingskey".into())); @@ -823,8 +765,15 @@ mod tests { fn onecli_route_detection_matches_effective_reqwest_proxy() { let https_gemini = "https://generativelanguage.googleapis.com/v1beta"; let http_gemini = "http://generativelanguage.googleapis.com/v1beta"; + // The OneCLI marker of the effective proxy (name-reliability Task 6 + // moved route choice to `session_name_capability`; the marker + // detection itself is unchanged). let route = |base_url: &str, env: &GeminiProxyEnvironment| { - GeminiSessionNameAuth::route_for(base_url, env) + if crate::session_name_capability::proxy_facts(env, base_url).onecli_marker { + "onecli" + } else { + "none" + } }; let env = |https: Option<&str>, http: Option<&str>, all: Option<&str>, no: Option<&str>, cgi| { @@ -842,7 +791,7 @@ mod tests { false ), ), - GeminiCredentialRoute::OneCliProxy, + "onecli", ); assert_eq!( route( @@ -855,7 +804,7 @@ mod tests { false ), ), - GeminiCredentialRoute::OneCliProxy, + "onecli", ); assert_eq!( route( @@ -868,7 +817,7 @@ mod tests { false ), ), - GeminiCredentialRoute::OneCliProxy, + "onecli", ); assert_eq!( route( @@ -881,7 +830,7 @@ mod tests { false, ), ), - GeminiCredentialRoute::Direct, + "none", "a valid scheme-specific proxy takes precedence over ALL_PROXY", ); assert_eq!( @@ -895,7 +844,7 @@ mod tests { false, ), ), - GeminiCredentialRoute::OneCliProxy, + "onecli", "an unparseable scheme-specific proxy falls back to ALL_PROXY", ); @@ -916,7 +865,7 @@ mod tests { false, ), ), - GeminiCredentialRoute::Direct, + "none", "NO_PROXY entry {no_proxy:?} bypasses the proxy", ); } @@ -931,7 +880,7 @@ mod tests { false, ), ), - GeminiCredentialRoute::OneCliProxy, + "onecli", "a nonmatching domain must not bypass the proxy", ); assert_eq!( @@ -945,7 +894,7 @@ mod tests { true, ), ), - GeminiCredentialRoute::Direct, + "none", "REQUEST_METHOD disables environment proxies", ); assert_eq!( @@ -955,12 +904,9 @@ mod tests { ); let no_key = AiKeyCell::default(); - let proxy_auth = GeminiSessionNameAuth { - direct_key: no_key.clone(), - route: GeminiCredentialRoute::OneCliProxy, - }; + let proxy_auth = GeminiSessionNameAuth::onecli_for_test(no_key.clone()); assert!(proxy_auth.enabled()); - assert_eq!(proxy_auth.direct_key(), None); + assert_eq!(proxy_auth.key(), None); assert!(!GeminiSessionNameAuth::direct_for_test(no_key).enabled()); } @@ -972,9 +918,9 @@ mod tests { "http://proxy:%61oc_fixture@127.0.0.1:10255", ] { let environment = GeminiProxyEnvironment::literal(Some(proxy), None, None, None, false); - assert_eq!( - GeminiSessionNameAuth::route_for(https_gemini, &environment), - GeminiCredentialRoute::OneCliProxy, + assert!( + crate::session_name_capability::proxy_facts(&environment, https_gemini) + .onecli_marker, "a percent-encoded OneCLI marker in either proxy credential selects the proxy route", ); } @@ -1011,17 +957,20 @@ mod tests { tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let key_cell = AiKeyCell::init(Some("synthetic-direct-key".into()), None); - let auth = GeminiSessionNameAuth { - direct_key: key_cell, - route: GeminiCredentialRoute::Direct, - }; - assert_eq!(auth.route(), GeminiCredentialRoute::Direct); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let auth = GeminiSessionNameAuth::direct_for_test(key_cell); + assert_eq!( + auth.route(), + NamingRoute::Key(KeyRouteTransport::AmbientProxy) + ); + let client = reqwest::Client::builder().no_proxy(); let http = GeminiSessionNameHttp::new(client, auth, format!("http://{addr}/v1beta")); - let title = http - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .unwrap(); + let title = SessionNameTransport::generate_content( + &http, + "hello world".into(), + SESSION_TITLE_MAX_OUTPUT_TOKENS, + ) + .await + .unwrap(); assert_eq!(title, "Flux repair"); assert_eq!( @@ -1029,163 +978,4 @@ mod tests { vec![Some("synthetic-direct-key".to_string())], ); } - - #[tokio::test] - async fn session_name_onecli_route_omits_direct_key() { - use axum::{routing::post, Json, Router}; - use std::sync::{Arc, Mutex}; - - let observed_keys = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&observed_keys); - let app = Router::new().route( - "/v1beta/models/gemini-3.5-flash-lite:generateContent", - post( - move |headers: axum::http::HeaderMap, Json(_body): Json| { - let observed = Arc::clone(&observed); - async move { - observed - .lock() - .unwrap() - .push(headers.contains_key("x-goog-api-key")); - Json(serde_json::json!({ - "candidates": [{ "content": { "parts": [{ "text": "Flux repair" }] } }] - })) - } - }, - ), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let key_cell = AiKeyCell::init(Some("synthetic-direct-key".into()), None); - let auth = GeminiSessionNameAuth { - direct_key: key_cell, - route: GeminiCredentialRoute::OneCliProxy, - }; - let client = reqwest::Client::builder().no_proxy().build().unwrap(); - let http = GeminiSessionNameHttp::new(client, auth, format!("http://{addr}/v1beta")); - let title = http - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .unwrap(); - - assert_eq!(title, "Flux repair"); - assert_eq!(*observed_keys.lock().unwrap(), vec![false]); - } - - #[tokio::test] - async fn onecli_missing_credential_retries_with_direct_key() { - let fixture = start_session_name_response_fixture(vec![ - ( - axum::http::StatusCode::UNAUTHORIZED, - r#"{"error":"credential_not_found"}"#.to_string(), - ), - ( - axum::http::StatusCode::OK, - r#"{"candidates":[{"content":{"parts":[{"text":"Flux repair"}]}}]}"#.to_string(), - ), - ]) - .await; - let transport = onecli_session_name_transport(&fixture, Some(TEST_DIRECT_FALLBACK_KEY)); - - let title = transport - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .unwrap(); - - assert_eq!(title, "Flux repair"); - assert_onecli_fallback_keys(&fixture.observed_keys); - } - - #[tokio::test] - async fn onecli_missing_app_retries_with_direct_key() { - let fixture = start_session_name_response_fixture(vec![ - ( - axum::http::StatusCode::UNAUTHORIZED, - r#"{"error":"app_not_connected"}"#.to_string(), - ), - ( - axum::http::StatusCode::OK, - r#"{"candidates":[{"content":{"parts":[{"text":"Flux repair"}]}}]}"#.to_string(), - ), - ]) - .await; - let transport = onecli_session_name_transport(&fixture, Some(TEST_DIRECT_FALLBACK_KEY)); - - let title = transport - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .unwrap(); - - assert_eq!(title, "Flux repair"); - assert_onecli_fallback_keys(&fixture.observed_keys); - } - - #[tokio::test] - async fn onecli_missing_credential_does_not_retry_other_failures() { - let cases = [ - ( - axum::http::StatusCode::FORBIDDEN, - r#"{"error":"access_restricted"}"#, - ), - ( - axum::http::StatusCode::FORBIDDEN, - r#"{"error":"approval_required"}"#, - ), - (axum::http::StatusCode::UNAUTHORIZED, "unauthorized"), - (axum::http::StatusCode::FORBIDDEN, "forbidden"), - (axum::http::StatusCode::UNAUTHORIZED, "{malformed-json"), - ( - axum::http::StatusCode::PROXY_AUTHENTICATION_REQUIRED, - "proxy auth", - ), - ( - axum::http::StatusCode::PROXY_AUTHENTICATION_REQUIRED, - r#"{"error":"credential_not_found"}"#, - ), - ( - axum::http::StatusCode::PROXY_AUTHENTICATION_REQUIRED, - r#"{"error":"app_not_connected"}"#, - ), - ]; - - for (status, body) in cases { - let fixture = - start_session_name_response_fixture(vec![(status, body.to_string())]).await; - let transport = onecli_session_name_transport(&fixture, Some(TEST_DIRECT_FALLBACK_KEY)); - - let error = transport - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .expect_err("unapproved fallback cases remain errors"); - - assert!( - error.contains(&status.as_u16().to_string()), - "the original HTTP status remains visible" - ); - assert_onecli_no_fallback_key(&fixture.observed_keys); - } - } - - #[tokio::test] - async fn onecli_missing_credential_without_direct_key_does_not_retry() { - let fixture = start_session_name_response_fixture(vec![( - axum::http::StatusCode::UNAUTHORIZED, - r#"{"error":"credential_not_found"}"#.to_string(), - )]) - .await; - let transport = onecli_session_name_transport(&fixture, None); - - let error = transport - .generate_content("hello world".into(), SESSION_TITLE_MAX_OUTPUT_TOKENS) - .await - .expect_err("a missing direct key cannot be used for fallback"); - - assert!( - error.contains("401"), - "the original HTTP status remains visible" - ); - assert_onecli_no_fallback_key(&fixture.observed_keys); - } } diff --git a/crates/freshell-server/src/auto_title_sweep.rs b/crates/freshell-server/src/auto_title_sweep.rs index 1ec9755ae..5f8db3d6e 100644 --- a/crates/freshell-server/src/auto_title_sweep.rs +++ b/crates/freshell-server/src/auto_title_sweep.rs @@ -70,8 +70,18 @@ pub struct AutoTitleSweepState { /// messages (no paid titles across history); later passes feed activity /// so genuinely newly observed messages arm generation. pub index_hydrated: Arc, + /// Name-reliability Task 5 (C5): the targeted OpenCode first-message + /// lookups' memo, kept ACROSS passes (keyed by session id + the index + /// row's last activity). + pub first_message_lookups: FirstMessageLookupMemo, } +// Name-reliability Task 5 (C5): the targeted OpenCode first-message lookup +// and its cross-pass memo. +#[path = "auto_title_sweep_lookup.rs"] +mod first_message_lookup; +pub use first_message_lookup::FirstMessageLookupMemo; + /// Minimum age before a cwd's git enrichment is re-run when its terminal-set /// signature is unchanged (throttled refresh so dirty-status drift still /// surfaces without a git storm). @@ -187,6 +197,11 @@ pub struct SweepSession { /// `session.gitBranch ?? current.branch`, `terminal-metadata-service.ts:195`, /// then live git wins in `enrichFromCwd`, `:283`). pub git_branch: Option, + /// The index row's last activity (`IndexedSession::last_activity_at`): + /// it moves whenever the session's transcript does, so it keys the + /// sweep's cross-pass first-message lookup memo (name-reliability + /// Task 5, C5). + pub last_activity_at: i64, } /// One of Node's two `terminal.title.updated` emit sites (the sweep push and @@ -416,6 +431,9 @@ pub async fn run_auto_title_pass(state: &AutoTitleSweepState, sessions: &[SweepS // later pass re-proves against the (refreshed) view, so foreign // changes always resurface through the existing refresh discipline. let mut settled_this_pass: HashSet = HashSet::new(); + // Name-reliability Task 4 (LB-B1): self-heal replacements ride ONE + // maintenance transaction after the row loop. + let mut maintenance = crate::session_names::SweepMaintenanceQueue::default(); for s in sessions { let Some(provider) = freshell_freshagent::naming::named_provider_for(Some(&s.provider), None) @@ -439,26 +457,21 @@ pub async fn run_auto_title_pass(state: &AutoTitleSweepState, sessions: &[SweepS session_id: s.session_id.clone(), }; // OpenCode already-named sessions carry no first message in the - // bounded listing: the targeted lookup serves eligibility, but - // only while the session's generation can still use input (an - // exhausted or protected session never pays the read again). + // bounded listing: the targeted lookup serves it, but only while + // the store can still use it — as generation input, or as a name + // for a record below `first_message` or stuck on a junk label + // under this row's folder (name-reliability Task 5, C5). Answers + // are remembered across passes per (session, row activity), so a + // session with no user message is read once, not every pass; a + // failed read is retried after a short window. let first_user_message = match s.first_user_message.clone() { Some(message) => Some(message), None => { if provider == freshell_protocol::session_names::NamedProvider::Opencode - && names.needs_generation_input(&target) + && (names.needs_generation_input(&target) + || names.needs_first_message_input(&target, s.cwd.as_deref())) { - if let Some(index) = state.index.clone() { - let session_id = s.session_id.clone(); - tokio::task::spawn_blocking(move || { - index.opencode_first_user_message(&session_id) - }) - .await - .ok() - .flatten() - } else { - None - } + first_message_lookup::opencode_first_message(state, s).await } else { None } @@ -486,19 +499,31 @@ pub async fn run_auto_title_pass(state: &AutoTitleSweepState, sessions: &[SweepS // Delta-review round 3, finding 2 — the adopted-view pre-checks: // a settled session (hydrated record, absorbed/duplicate message, // no pending work) must not pay a strict cross-process store - // transaction per pass. `hydrate_indexed_pending`/ - // `activity_pending` prove the no-op from the in-memory view - // (the same plan the transaction applies); a stale view can only - // make the pre-check transact unnecessarily, never skip real - // work — the store's own doc comments carry the full argument. + // transaction per pass. `hydrate_work`/`activity_work` prove the + // no-op from the in-memory view (the same plan the transaction + // applies); a stale view can only delay work, never lose it — + // the store's own doc comments carry the full argument. A + // self-heal replacement (name-reliability Task 4) is queued for + // the pass's ONE maintenance transaction instead of its own. let mut transacted = false; - if names.hydrate_indexed_pending(&input) { - transacted = true; - if let Err(error) = names - .hydrate_indexed(input.clone(), hydrating && !explicitly_open) - .await - { - freshell_freshagent::naming::log_name_error("hydrate_indexed", &target, &error); + let mut deferred_hydrate = None; + match names.hydrate_work(&input) { + crate::session_names::SweepWork::None => {} + crate::session_names::SweepWork::Immediate => { + transacted = true; + if let Err(error) = names + .hydrate_indexed(input.clone(), hydrating && !explicitly_open) + .await + { + freshell_freshagent::naming::log_name_error( + "hydrate_indexed", + &target, + &error, + ); + } + } + crate::session_names::SweepWork::Maintenance => { + deferred_hydrate = Some(input.clone()); } } // The boot pass arms only explicitly open sessions; every later @@ -522,23 +547,68 @@ pub async fn run_auto_title_pass(state: &AutoTitleSweepState, sessions: &[SweepS cwd: s.cwd.clone(), }); } + let mut deferred_activity = None; if let Some(activity) = activity { // Re-checked AFTER the hydrate above: a boot-pass hydration // that just created/absorbed makes this a provable no-op on // the now-current view (no duplicate arming transaction). - if names.activity_pending(&activity) { - transacted = true; - if let Err(error) = names.activity(activity).await { - freshell_freshagent::naming::log_name_error("activity", &target, &error); + match names.activity_work(&activity) { + crate::session_names::SweepWork::None => {} + crate::session_names::SweepWork::Immediate => { + transacted = true; + if let Err(error) = names.activity(activity).await { + freshell_freshagent::naming::log_name_error( + "activity", &target, &error, + ); + } + // The activity may itself have healed the record: a + // deferred hydration heal re-checks the current view. + if deferred_hydrate.as_ref().is_some_and(|input| { + names.hydrate_work(input) + != crate::session_names::SweepWork::Maintenance + }) { + deferred_hydrate = None; + } + } + crate::session_names::SweepWork::Maintenance => { + deferred_activity = Some(activity); } } } - if !transacted { - // Proven settled on the adopted view: memo so a repeated row - // later in THIS pass never re-evaluates. + let queued = deferred_hydrate.is_some() || deferred_activity.is_some(); + if queued { + // Into this pass's batch, or left for a later pass's + // pre-checks when the batch is full. + maintenance.push(crate::session_names::SweepMaintenanceItem { + hydrate: deferred_hydrate, + activity: deferred_activity, + }); + } + if !transacted || queued { + // Proven settled on the adopted view (or already queued for + // the batch): memo so a repeated row later in THIS pass never + // re-evaluates or queues the same record twice. settled_this_pass.insert(session_key); } } + state.first_message_lookups.retain_sessions( + &sessions + .iter() + .map(|s| s.session_id.as_str()) + .collect::>(), + ); + if !maintenance.is_empty() || names.has_staged_native_baselines() { + if let Err(error) = names.sweep_maintenance(maintenance).await { + tracing::warn!( + target: "freshell_server::session_names", + op = "sweep_maintenance", + name_ref = "-", + revision = error.log_revision(), + class = %error.code(), + "session_names.operation_failed: {error}" + ); + } + } } // Match sessions to live terminals ONCE — both the meta refresh and the @@ -814,6 +884,7 @@ pub fn spawn_auto_title_sweep( first_user_message: s.first_user_message.clone(), title_source: s.title_source.clone(), git_branch: s.git_branch.clone(), + last_activity_at: s.last_activity_at, } }) .collect(); @@ -912,6 +983,7 @@ mod tests { metadata: crate::session_metadata::SessionMetadataStore::new(dir), index, index_hydrated: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + first_message_lookups: Default::default(), }; (state, rx) } @@ -935,6 +1007,7 @@ mod tests { first_user_message: first.map(str::to_string), title_source: None, git_branch: None, + last_activity_at: 0, } } @@ -1525,6 +1598,7 @@ mod tests { first_user_message: s.first_user_message.clone(), title_source: s.title_source.clone(), git_branch: s.git_branch.clone(), + last_activity_at: s.last_activity_at, } }) .collect(); @@ -1835,6 +1909,89 @@ mod tests { ); } + /// Name-reliability Task 4: the sweep pre-checks re-offer to a junk + /// saved name. A provider-level "Tab 23" heals to the session's first + /// message within at most two passes (boot, then one later pass), a + /// manual "Tab 8" stays exactly as it is, and once healed a further pass + /// transacts zero times. + #[tokio::test] + async fn sweep_heals_a_default_label_and_then_settles() { + let dir = tempfile::tempdir().unwrap(); + let names = crate::session_names::SessionNames::open(dir.path().to_path_buf()).unwrap(); + let (state, _rx) = sweep_state_with(dir.path(), None, Some(names.clone()), None); + let junk = scoped_session_ref("claude", "s-junk"); + let manual = scoped_session_ref("claude", "s-manual"); + names + .hydrate_indexed( + crate::session_name_generation::IndexedNameInput { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: "s-junk".into(), + cwd: Some("/x/proj".into()), + first_user_message: Some("Fix the sardine importer".into()), + provider_title: Some("Tab 23".into()), + }, + true, + ) + .await + .unwrap(); + names + .hydrate_indexed( + crate::session_name_generation::IndexedNameInput { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: "s-manual".into(), + cwd: Some("/x/proj".into()), + first_user_message: Some("Plan the offsite".into()), + provider_title: None, + }, + true, + ) + .await + .unwrap(); + names + .rename(freshell_freshagent::naming::RenameNameInput { + target: manual.clone(), + name: "Tab 8".into(), + intent: freshell_protocol::session_names::NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + let manual_before = naming_record(&names, manual.clone()).await.unwrap().record; + let rows = [ + session( + "claude", + "s-junk", + "/x/proj", + Some("Fix the sardine importer"), + ), + session("claude", "s-manual", "/x/proj", Some("Plan the offsite")), + ]; + + // Boot pass, then one later pass. + run_auto_title_pass(&state, &rows).await; + run_auto_title_pass(&state, &rows).await; + let healed = naming_record(&names, junk.clone()).await.unwrap().record; + assert_eq!(healed.name, "Fix the sardine importer"); + assert_eq!( + healed.source, + freshell_protocol::session_names::NameSource::FirstMessage + ); + assert_eq!( + naming_record(&names, manual.clone()).await.unwrap().record, + manual_before, + "a manual Tab 8 is never touched" + ); + + let before = crate::session_names::test_store_transaction_count(dir.path()); + run_auto_title_pass(&state, &rows).await; + assert_eq!( + crate::session_names::test_store_transaction_count(dir.path()), + before, + "healed and settled: a further pass transacts zero times" + ); + } + /// An already-named opencode session carries no first message in the /// bounded listing: the targeted lookup serves it, the provider title /// installs at its own fallback rank, and the newly observed message diff --git a/crates/freshell-server/src/auto_title_sweep_lookup.rs b/crates/freshell-server/src/auto_title_sweep_lookup.rs new file mode 100644 index 000000000..05be26c6e --- /dev/null +++ b/crates/freshell-server/src/auto_title_sweep_lookup.rs @@ -0,0 +1,363 @@ +//! The sweep's targeted OpenCode first-message lookup (name-reliability +//! plan, Task 5, condition C5). +//! +//! OpenCode names a session itself after its first exchange, so the bounded +//! session listing carries a first user message only for rows OpenCode has +//! not named; for the others the sweep reads the session's first message on +//! demand (one read-only query). It does so while the naming store can +//! still use the message — as AI-naming input, or as a name for a record +//! below `first_message` or stuck on a junk default label such as its +//! folder name. A session that has no user message would then be read on +//! every 5-second pass forever, so every ANSWER (the message, or "no user +//! message") is remembered ACROSS passes, keyed by session id and the index +//! row's last activity: a new message moves the row's activity, which reads +//! again. A FAILED read (the database stayed busy past its timeout, the +//! query failed, the task died) is not an answer: it is logged as a warning +//! and retried on the first pass after [`FAILED_LOOKUP_RETRY`] — or at once +//! when the row's activity moves — so one transient failure never puts off +//! a session's first-message name, and a lasting one costs one read and one +//! warning per session per window. Entries for sessions that left the index +//! are forgotten at the end of each pass, so the memo never outgrows the +//! index. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use freshell_sessions::directory_index::FirstUserMessageRead; +use freshell_sessions::parse::OpencodeByIdError; + +/// How long a FAILED read waits before a later pass retries it. +pub(crate) const FAILED_LOOKUP_RETRY: Duration = Duration::from_secs(60); + +/// What the memo holds for one session, at one index activity stamp. +enum Remembered { + /// A known answer: the first user message, or `None` (no user message). + Answer { + last_activity_at: i64, + message: Option, + }, + /// The read failed; no read again before `retry_at` at this stamp. + Failed { + last_activity_at: i64, + retry_at: Instant, + }, +} + +/// What a lookup at one activity stamp should do. +enum Recall { + /// Serve this remembered answer. + Answer(Option), + /// A recent read failed: no message this pass, no read. + Waiting, + /// Read now. + Read, +} + +impl Remembered { + fn recall(&self, last_activity_at: i64, now: Instant) -> Recall { + match self { + Self::Answer { + last_activity_at: at, + message, + } if *at == last_activity_at => Recall::Answer(message.clone()), + Self::Failed { + last_activity_at: at, + retry_at, + } if *at == last_activity_at && now < *retry_at => Recall::Waiting, + _ => Recall::Read, + } + } +} + +/// The cross-pass memo of targeted first-message lookups. Cheap to clone +/// (shared state). +#[derive(Clone)] +pub struct FirstMessageLookupMemo { + inner: Arc>>, + failure_retry: Duration, +} + +impl Default for FirstMessageLookupMemo { + fn default() -> Self { + Self::with_failure_retry(FAILED_LOOKUP_RETRY) + } +} + +impl FirstMessageLookupMemo { + /// A memo whose failed reads are retried after `failure_retry` (the + /// sweep uses [`FAILED_LOOKUP_RETRY`]). + pub(crate) fn with_failure_retry(failure_retry: Duration) -> Self { + Self { + inner: Arc::default(), + failure_retry, + } + } + + fn map(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// The session's first user message at `last_activity_at`: the + /// remembered answer when this activity was already read, `None` while + /// a failed read waits out its retry window, else `read()`'s — an + /// answer is remembered for later passes, a failure only delays the + /// next read. + pub(crate) async fn first_message( + &self, + session_id: &str, + last_activity_at: i64, + read: F, + ) -> Option + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let recall = self + .map() + .get(session_id) + .map_or(Recall::Read, |remembered| { + remembered.recall(last_activity_at, Instant::now()) + }); + match recall { + Recall::Answer(message) => return message, + Recall::Waiting => return None, + Recall::Read => {} + } + let (remembered, message) = match read().await { + Ok(message) => { + tracing::debug!( + target: "freshell_server::auto_title_sweep", + op = "first_message_lookup", + session_id = %session_id, + found = message.is_some(), + "session_names.first_message_lookup" + ); + ( + Remembered::Answer { + last_activity_at, + message: message.clone(), + }, + message, + ) + } + Err(failure) => { + tracing::warn!( + target: "freshell_server::auto_title_sweep", + op = "first_message_lookup", + session_id = %session_id, + code = failure.code.as_deref().unwrap_or("unknown"), + error = %failure.message, + retry_in_ms = u64::try_from(self.failure_retry.as_millis()).unwrap_or(u64::MAX), + "session_names.first_message_lookup_failed: the targeted first-message read failed; a later pass retries it" + ); + ( + Remembered::Failed { + last_activity_at, + retry_at: Instant::now() + self.failure_retry, + }, + None, + ) + } + }; + self.map().insert(session_id.to_string(), remembered); + message + } + + /// Forget every session that is not in this pass's index rows. + pub(crate) fn retain_sessions(&self, live: &HashSet<&str>) { + self.map() + .retain(|session_id, _| live.contains(session_id.as_str())); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.map().len() + } +} + +/// The targeted lookup for one OpenCode row through the shared index (a +/// blocking read-only query, off the async executor), through the sweep +/// state's memo. A failed query and a failed blocking task are both failed +/// reads (retried later), never "no user message". +pub(crate) async fn opencode_first_message( + state: &super::AutoTitleSweepState, + row: &super::SweepSession, +) -> Option { + let index = state.index.clone()?; + let session_id = row.session_id.clone(); + state + .first_message_lookups + .first_message(&row.session_id, row.last_activity_at, || async move { + tokio::task::spawn_blocking(move || index.opencode_first_user_message(&session_id)) + .await + .unwrap_or_else(|join| { + Err(OpencodeByIdError { + code: None, + message: format!("the lookup task failed: {join}"), + }) + }) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + async fn look( + memo: &FirstMessageLookupMemo, + reads: &AtomicUsize, + session_id: &str, + at: i64, + answer: Option<&str>, + ) -> Option { + read_with(memo, reads, session_id, at, Ok(answer.map(str::to_string))).await + } + + async fn read_with( + memo: &FirstMessageLookupMemo, + reads: &AtomicUsize, + session_id: &str, + at: i64, + read: FirstUserMessageRead, + ) -> Option { + memo.first_message(session_id, at, || async { + reads.fetch_add(1, Ordering::SeqCst); + read + }) + .await + } + + fn busy() -> FirstUserMessageRead { + Err(OpencodeByIdError { + code: Some("SQLITE_BUSY".to_string()), + message: "database is locked".to_string(), + }) + } + + #[tokio::test] + async fn an_answer_is_read_once_per_activity_stamp() { + let memo = FirstMessageLookupMemo::default(); + let reads = AtomicUsize::new(0); + for _ in 0..3 { + assert_eq!(look(&memo, &reads, "ses_silent", 10, None).await, None); + } + assert_eq!( + reads.load(Ordering::SeqCst), + 1, + "no user message: read once" + ); + assert_eq!( + look(&memo, &reads, "ses_silent", 20, Some("Now it has one")) + .await + .as_deref(), + Some("Now it has one"), + "newer activity reads again" + ); + assert_eq!( + look(&memo, &reads, "ses_silent", 20, None).await.as_deref(), + Some("Now it has one"), + "a found message is remembered too" + ); + assert_eq!(reads.load(Ordering::SeqCst), 2); + } + + /// A failed read (busy past the timeout, a query error, a failed task) + /// is never remembered as "no user message": the pass after the retry + /// window reads again, and the found message is then remembered. + #[tokio::test] + async fn a_failed_read_is_retried_after_the_retry_window() { + let memo = FirstMessageLookupMemo::with_failure_retry(Duration::ZERO); + let reads = AtomicUsize::new(0); + assert_eq!( + read_with(&memo, &reads, "ses_flaky", 10, busy()).await, + None + ); + assert_eq!( + look( + &memo, + &reads, + "ses_flaky", + 10, + Some("Fix the sardine importer") + ) + .await + .as_deref(), + Some("Fix the sardine importer"), + "the same activity is read again once the window passed" + ); + assert_eq!(reads.load(Ordering::SeqCst), 2); + assert_eq!( + look(&memo, &reads, "ses_flaky", 10, None).await.as_deref(), + Some("Fix the sardine importer"), + "the answer that followed is remembered" + ); + assert_eq!(reads.load(Ordering::SeqCst), 2); + } + + /// Inside the retry window a failed session is not re-read every pass + /// (a lasting failure costs one read, and one warning, per window); a + /// move of its activity reads at once. + #[tokio::test] + async fn a_failed_read_waits_out_the_window_unless_the_activity_moves() { + let events = crate::test_trace_capture::captured_trace_events(); + let session_id = format!("ses_window_{}", uuid::Uuid::new_v4()); + let memo = FirstMessageLookupMemo::with_failure_retry(Duration::from_secs(3_600)); + let reads = AtomicUsize::new(0); + assert_eq!( + read_with(&memo, &reads, &session_id, 10, busy()).await, + None + ); + assert_eq!( + look(&memo, &reads, &session_id, 10, Some("unread")).await, + None, + "inside the window: no read" + ); + assert_eq!(reads.load(Ordering::SeqCst), 1); + assert_eq!( + look(&memo, &reads, &session_id, 20, Some("Now it has one")) + .await + .as_deref(), + Some("Now it has one"), + "newer activity reads at once" + ); + assert_eq!(reads.load(Ordering::SeqCst), 2); + + let warnings: Vec<_> = events + .snapshot() + .into_iter() + .filter(|event| { + event.level == tracing::Level::WARN + && event + .message + .starts_with("session_names.first_message_lookup_failed") + && event.fields.get("session_id") == Some(&session_id) + }) + .collect(); + assert_eq!(warnings.len(), 1, "one warning for the one failed read"); + assert_eq!( + warnings[0].fields.get("code").map(String::as_str), + Some("SQLITE_BUSY") + ); + } + + #[tokio::test] + async fn sessions_that_left_the_index_are_forgotten() { + let memo = FirstMessageLookupMemo::default(); + let reads = AtomicUsize::new(0); + look(&memo, &reads, "ses_kept", 1, None).await; + look(&memo, &reads, "ses_gone", 1, None).await; + memo.retain_sessions(&HashSet::from(["ses_kept"])); + assert_eq!(memo.len(), 1); + look(&memo, &reads, "ses_kept", 1, None).await; + assert_eq!( + reads.load(Ordering::SeqCst), + 2, + "the kept session is still remembered" + ); + } +} diff --git a/crates/freshell-server/src/claude_freshell_mcp.rs b/crates/freshell-server/src/claude_freshell_mcp.rs index ee026526c..ff1a23c02 100644 --- a/crates/freshell-server/src/claude_freshell_mcp.rs +++ b/crates/freshell-server/src/claude_freshell_mcp.rs @@ -109,6 +109,8 @@ mod tests { ), registry: freshell_terminal::TerminalRegistry::new(), ai_key: crate::ai_title::AiKeyCell::default(), + log_secrets: Default::default(), + naming_generation: None, }; (router(state), store) } diff --git a/crates/freshell-server/src/existence.rs b/crates/freshell-server/src/existence.rs index eaf6cea0a..6eb6383a9 100644 --- a/crates/freshell-server/src/existence.rs +++ b/crates/freshell-server/src/existence.rs @@ -1455,6 +1455,7 @@ mod tests { }), resume_session_id: None, status: None, + naming_handle: None, }; let verdicts = derive_verdicts( &deps, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 5c8e25ede..0b1457291 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -18,8 +18,7 @@ use freshell_runtime_client::RuntimeClient; use freshell_runtime_protocol::{ AgentEvent, DesiredState, FreshAgentFixtureTransport, FreshAgentLaunchSpec, FreshAgentRollbackDirection, FreshAgentRollbackMode, FreshProvider, LaunchRequest, RequestId, - RuntimeErrorCode, RuntimeLimits, RuntimeProfile, SoulId, StopOutcome, ViewIntentKind, - ViewIntentRequest, ViewVisibilityIntent, + RuntimeErrorCode, RuntimeLimits, RuntimeProfile, SoulId, StopOutcome, }; use freshell_ws::hosted_fresh_agent::{HostedFreshAgentCommand, HostedFreshAgentGateway}; use sha2::{Digest, Sha256}; @@ -81,6 +80,17 @@ impl HostedFreshAgentProxy { format!("{OPT_IN_ENV}=1 requires an available managed runtime controller") })?; let fixture_modes = configured_fixture_modes()?; + // Name reliability (Task 10, Residual 4): hosted fresh agents admit a + // pending name at create and read a resumed session's name, but no + // lane reports their first message or transfers the pending name onto + // the native session, so they never get a first-message or AI name. + // Said once at boot so enabling the flag cannot hide the gap. + tracing::warn!( + target: "freshell_server::session_names", + flag = OPT_IN_ENV, + "session_names.hosted_fresh_agent_naming_incomplete: hosted fresh-agent \ + sessions keep their create-time name only (no first-message or AI naming)" + ); Ok(Some(Arc::new(Self { client, broadcast, @@ -754,16 +764,11 @@ impl HostedFreshAgentProxy { fixture: None, terminal: None, fresh_agent: Some(launch), - view_intent: Some(ViewIntentRequest { - owner_id: String::new(), - workspace_id: project_key, - kind: ViewIntentKind::AutomaticPrimary, - preferred_tab_id: message.tab_id.clone(), - preferred_pane_id: None, - title: Some(format!("{} agent", provider.as_str())), - placement_group: None, - visibility: ViewVisibilityIntent::Visible, - }), + view_intent: Some(crate::managed_view_intent::automatic_primary_view_request( + project_key, + message.tab_id.clone(), + None, + )), expected_control_epoch: None, }; let launched = if provider == FreshProvider::Opencode { diff --git a/crates/freshell-server/src/gemini_failure.rs b/crates/freshell-server/src/gemini_failure.rs new file mode 100644 index 000000000..63181faa2 --- /dev/null +++ b/crates/freshell-server/src/gemini_failure.rs @@ -0,0 +1,287 @@ +//! Name-reliability Task 6: what a failed Gemini request means for AI naming. +//! +//! Every failure falls in one class, and the class decides what the naming +//! worker does next: +//! +//! - [`GeminiFailureClass::Capability`]: the credential, the permission or the +//! route is refused (a OneCLI gateway refusal, 401/403/407, Google's +//! `API_KEY_INVALID`, an untrusted certificate, an unreachable proxy or +//! host). Naming pauses; the attempt is refunded. +//! - [`GeminiFailureClass::Throttled`]: a quota answer (429). Naming pauses +//! with a growing re-probe; the attempt is refunded. +//! - [`GeminiFailureClass::Transient`] / [`GeminiFailureClass::Invalid`]: the +//! request failed in a way a retry may fix, or the provider answered +//! something unusable. The attempt is consumed, as before. +//! +//! The classification reads only status codes, structured JSON error fields +//! and error TYPES (a `rustls` certificate error found by downcasting); it +//! never matches message text, and nothing here keeps request URLs, proxy +//! URLs, keys or response bodies: the provider code is a short sanitized +//! token, so a failure is always safe to log. + +/// The class of one failed Gemini request (see the module doc). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GeminiFailureClass { + Capability, + Throttled, + Transient, + Invalid, +} + +/// One classified Gemini failure: the class, the HTTP status when the +/// request got an answer, and a sanitized provider or transport code. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeminiFailure { + pub class: GeminiFailureClass, + pub status: Option, + pub provider_code: Option, +} + +impl GeminiFailure { + fn new(class: GeminiFailureClass, status: Option, provider_code: Option) -> Self { + Self { + class, + status, + provider_code, + } + } + + /// No credential route is available (no key and no OneCLI proxy). + pub fn no_credential() -> Self { + Self::new( + GeminiFailureClass::Capability, + None, + Some("no_credential".to_string()), + ) + } + + /// The request body could not be encoded (never expected in practice). + pub fn request_encoding() -> Self { + Self::new( + GeminiFailureClass::Invalid, + None, + Some("request_encoding".to_string()), + ) + } + + /// The naming worker's own 20-second request bound elapsed. + pub fn request_timed_out() -> Self { + Self::new( + GeminiFailureClass::Transient, + None, + Some("timeout".to_string()), + ) + } + + /// A 2xx answer whose body could not be read. + pub fn response_read_failed(status: u16) -> Self { + Self::new( + GeminiFailureClass::Transient, + Some(status), + Some("response_read_failed".to_string()), + ) + } + + /// A 2xx answer whose body is not the expected JSON. + pub fn malformed_response(status: u16) -> Self { + Self::new( + GeminiFailureClass::Invalid, + Some(status), + Some("malformed_response".to_string()), + ) + } + + /// Whether this failure pauses naming (and refunds the attempt). + pub fn pauses_naming(&self) -> bool { + matches!( + self.class, + GeminiFailureClass::Capability | GeminiFailureClass::Throttled + ) + } + + /// The code logs carry: `throttled` for a quota answer, else the + /// provider or transport code, else `http_`. + pub fn code(&self) -> String { + if self.class == GeminiFailureClass::Throttled { + return "throttled".to_string(); + } + if let Some(code) = &self.provider_code { + return code.clone(); + } + match self.status { + Some(status) => format!("http_{status}"), + None => "request_failed".to_string(), + } + } +} + +impl std::fmt::Display for GeminiFailure { + /// The text the naming worker has always logged (`gemini http ` + /// for an answer, `gemini request failed` otherwise), plus the code in + /// parentheses when one is known. Never a URL, key or body. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.status { + Some(status) => match reqwest::StatusCode::from_u16(status) { + Ok(code) => write!(f, "gemini http {code}")?, + Err(_) => write!(f, "gemini http {status}")?, + }, + None => write!(f, "gemini request failed")?, + } + if self.provider_code.is_some() || self.class == GeminiFailureClass::Throttled { + write!(f, " ({})", self.code())?; + } + Ok(()) + } +} + +/// Classify a non-2xx Gemini answer from its status and body, in this order: +/// 429 is [`GeminiFailureClass::Throttled`] (a quota answer rewritten by the +/// OneCLI gateway must not pause for 15 minutes); any body whose top-level +/// `error` is a string (the OneCLI gateway's shape: `access_restricted`, +/// `credential_not_found`, `app_not_connected`, …) is +/// [`GeminiFailureClass::Capability`] with that code, checked before the +/// status rules because the gateway reports a bad key as 400; then Google's +/// `API_KEY_INVALID` and any 401/403/407 are Capability; 5xx and 408 are +/// [`GeminiFailureClass::Transient`]; anything else is +/// [`GeminiFailureClass::Invalid`]. +pub fn classify_gemini_failure(status: u16, body: &[u8]) -> GeminiFailure { + let value: Option = serde_json::from_slice(body).ok(); + let error = value.as_ref().and_then(|value| value.get("error")); + let onecli_code = error.and_then(serde_json::Value::as_str).map(sanitize_code); + let google_status = error + .and_then(|error| error.get("status")) + .and_then(serde_json::Value::as_str) + .map(sanitize_code); + let api_key_invalid = error + .and_then(|error| error.get("details")) + .and_then(serde_json::Value::as_array) + .is_some_and(|details| { + details.iter().any(|detail| { + detail.get("reason").and_then(serde_json::Value::as_str) == Some("API_KEY_INVALID") + }) + }); + let status_code = Some(status); + if status == 429 { + return GeminiFailure::new( + GeminiFailureClass::Throttled, + status_code, + onecli_code.or(google_status), + ); + } + if let Some(code) = onecli_code { + return GeminiFailure::new(GeminiFailureClass::Capability, status_code, Some(code)); + } + if api_key_invalid { + return GeminiFailure::new( + GeminiFailureClass::Capability, + status_code, + Some("API_KEY_INVALID".to_string()), + ); + } + let class = match status { + 401 | 403 | 407 => GeminiFailureClass::Capability, + 408 | 500..=599 => GeminiFailureClass::Transient, + _ => GeminiFailureClass::Invalid, + }; + GeminiFailure::new(class, status_code, google_status) +} + +/// Classify a request that got no answer, from its error source chain: a +/// `rustls` certificate error anywhere in the chain (found by downcasting, +/// also inside `std::io::Error`, never by message text) is Capability +/// `proxy_tls` through a proxy and `tls_untrusted` otherwise; any other +/// connect failure is Capability `proxy_unreachable` through a proxy (a +/// refused CONNECT, a proxy that is down) and `direct_unreachable` +/// otherwise, including a connect that ran out the session-name clients' +/// connect bound ([`crate::ai_title::SESSION_NAME_CONNECT_TIMEOUT`]: a +/// firewall that drops packets); any other timeout and anything else are +/// Transient. +pub fn classify_transport_error( + chain: &(dyn std::error::Error + 'static), + is_connect: bool, + is_timeout: bool, + via_proxy: bool, +) -> GeminiFailure { + let (class, code) = if holds_invalid_certificate(chain) { + ( + GeminiFailureClass::Capability, + if via_proxy { + "proxy_tls" + } else { + "tls_untrusted" + }, + ) + } else if is_connect { + ( + GeminiFailureClass::Capability, + if via_proxy { + "proxy_unreachable" + } else { + "direct_unreachable" + }, + ) + } else if is_timeout { + (GeminiFailureClass::Transient, "timeout") + } else { + (GeminiFailureClass::Transient, "request_failed") + }; + GeminiFailure::new(class, None, Some(code.to_string())) +} + +/// The deepest source chain the classifier walks (a cycle guard). +const MAX_CHAIN_DEPTH: usize = 32; + +fn holds_invalid_certificate(error: &(dyn std::error::Error + 'static)) -> bool { + let mut current = Some(error); + for _ in 0..MAX_CHAIN_DEPTH { + let Some(error) = current else { + return false; + }; + if matches!( + error.downcast_ref::(), + Some(rustls::Error::InvalidCertificate(_)) + ) { + return true; + } + // `std::io::Error::source()` skips the error it wraps, so look + // inside it explicitly. + if let Some(inner) = error + .downcast_ref::() + .and_then(std::io::Error::get_ref) + { + if holds_invalid_certificate(inner) { + return true; + } + } + current = error.source(); + } + false +} + +/// The longest provider code kept. +const MAX_CODE_LEN: usize = 64; + +/// Keep a provider code only as a short token of `[A-Za-z0-9_.-]`; anything +/// else (free text, URLs) becomes `unrecognized`, so a code is always safe +/// to log. +fn sanitize_code(code: &str) -> String { + let token = !code.is_empty() + && code.len() <= MAX_CODE_LEN + && code + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')); + if token { + code.to_string() + } else { + "unrecognized".to_string() + } +} + +/// [`classify_transport_error`] for a `reqwest` error. +pub fn classify_reqwest_error(error: &reqwest::Error, via_proxy: bool) -> GeminiFailure { + classify_transport_error(error, error.is_connect(), error.is_timeout(), via_proxy) +} + +#[cfg(test)] +#[path = "gemini_failure_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/gemini_failure_tests.rs b/crates/freshell-server/src/gemini_failure_tests.rs new file mode 100644 index 000000000..5137177bf --- /dev/null +++ b/crates/freshell-server/src/gemini_failure_tests.rs @@ -0,0 +1,376 @@ +//! Name-reliability Task 6: Gemini failure classification. HTTP answers are +//! fed as status + body; transport failures come from REAL `reqwest` errors +//! against loopback fixtures (a proxy that refuses the CONNECT, a closed +//! port, a server that never answers) or from synthetic source chains for +//! the certificate case. No request leaves the host. + +use std::time::Duration; + +use super::{ + classify_gemini_failure, classify_reqwest_error, classify_transport_error, GeminiFailure, + GeminiFailureClass, +}; + +/// The live gateway's refusal (`scratchpad/live-evidence/gemini-probe.json`) +/// with its manage URL removed. +const ONECLI_ACCESS_RESTRICTED: &str = r#"{"error":"access_restricted","manage_url":"","message":"generativelanguage.googleapis.com credentials exist in OneCLI but this agent does not have access. Ask the user to attach the account to this agent: ","provider":"generativelanguage.googleapis.com"}"#; + +const GOOGLE_PERMISSION_DENIED: &str = r#"{"error":{"code":403,"message":"Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API.","status":"PERMISSION_DENIED"}}"#; + +const GOOGLE_API_KEY_INVALID: &str = r#"{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com"}}]}}"#; + +const GOOGLE_RESOURCE_EXHAUSTED: &str = r#"{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}"#; + +const GOOGLE_INTERNAL: &str = + r#"{"error":{"code":500,"message":"An internal error has occurred.","status":"INTERNAL"}}"#; + +fn assert_failure(failure: &GeminiFailure, class: GeminiFailureClass, code: &str) { + assert_eq!(failure.class, class, "class of {failure:?}"); + assert_eq!(failure.code(), code, "code of {failure:?}"); +} + +#[test] +fn classify_onecli_access_restricted_as_capability() { + let failure = classify_gemini_failure(403, ONECLI_ACCESS_RESTRICTED.as_bytes()); + assert_failure( + &failure, + GeminiFailureClass::Capability, + "access_restricted", + ); + assert_eq!(failure.status, Some(403)); + assert!(failure.pauses_naming()); + // The failure keeps a code, never the body or its URLs. + let text = failure.to_string(); + assert_eq!(text, "gemini http 403 Forbidden (access_restricted)"); + assert!(!format!("{failure:?}").contains("removed")); +} + +#[test] +fn classify_onecli_400_access_restricted_as_capability() { + // The gateway reports a BAD key as 400 with its own body (V1): that is a + // credential refusal, not an invalid request. + let failure = classify_gemini_failure(400, ONECLI_ACCESS_RESTRICTED.as_bytes()); + assert_failure( + &failure, + GeminiFailureClass::Capability, + "access_restricted", + ); + // Other OneCLI codes ride the same rule. + for code in [ + "credential_not_found", + "app_not_connected", + "approval_required", + ] { + let body = format!(r#"{{"error":"{code}"}}"#); + assert_failure( + &classify_gemini_failure(401, body.as_bytes()), + GeminiFailureClass::Capability, + code, + ); + } +} + +#[test] +fn classify_google_permission_denied_as_capability() { + let failure = classify_gemini_failure(403, GOOGLE_PERMISSION_DENIED.as_bytes()); + assert_failure( + &failure, + GeminiFailureClass::Capability, + "PERMISSION_DENIED", + ); + // 401 and 407 without a structured body are credential refusals too. + assert_failure( + &classify_gemini_failure(401, b"unauthorized"), + GeminiFailureClass::Capability, + "http_401", + ); + assert_failure( + &classify_gemini_failure(407, b""), + GeminiFailureClass::Capability, + "http_407", + ); +} + +#[test] +fn classify_api_key_invalid_as_capability() { + let failure = classify_gemini_failure(400, GOOGLE_API_KEY_INVALID.as_bytes()); + assert_failure(&failure, GeminiFailureClass::Capability, "API_KEY_INVALID"); + // A plain 400 without that reason is an invalid request (consumed). + let plain = classify_gemini_failure( + 400, + br#"{"error":{"code":400,"message":"bad","status":"INVALID_ARGUMENT"}}"#, + ); + assert_failure(&plain, GeminiFailureClass::Invalid, "INVALID_ARGUMENT"); + assert!(!plain.pauses_naming()); +} + +#[test] +fn classify_429_as_throttled() { + let failure = classify_gemini_failure(429, GOOGLE_RESOURCE_EXHAUSTED.as_bytes()); + assert_failure(&failure, GeminiFailureClass::Throttled, "throttled"); + assert_eq!(failure.provider_code.as_deref(), Some("RESOURCE_EXHAUSTED")); + assert!(failure.pauses_naming()); + assert_failure( + &classify_gemini_failure(429, b""), + GeminiFailureClass::Throttled, + "throttled", + ); +} + +#[test] +fn classify_429_before_the_onecli_body_rule() { + // A quota answer rewritten by the gateway (LB-B10) must not latch as a + // 15-minute capability pause. + let failure = classify_gemini_failure(429, br#"{"error":"rate_limited"}"#); + assert_failure(&failure, GeminiFailureClass::Throttled, "throttled"); +} + +#[test] +fn classify_500_as_transient() { + let failure = classify_gemini_failure(500, GOOGLE_INTERNAL.as_bytes()); + assert_failure(&failure, GeminiFailureClass::Transient, "INTERNAL"); + assert!(!failure.pauses_naming()); + assert_failure( + &classify_gemini_failure(503, b"unavailable"), + GeminiFailureClass::Transient, + "http_503", + ); + assert_failure( + &classify_gemini_failure(404, b"{}"), + GeminiFailureClass::Invalid, + "http_404", + ); + // A code from the body is a short safe token, never free text. + let odd = classify_gemini_failure(403, br#"{"error":"has spaces and https://x.test/"}"#); + assert_eq!(odd.class, GeminiFailureClass::Capability); + assert!(!odd.code().contains("https"), "{}", odd.code()); +} + +/// A loopback "proxy" that answers every CONNECT with 407. +async fn start_refusing_connect_proxy() -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + let mut seen = Vec::new(); + let mut buf = [0u8; 1024]; + while !seen.windows(4).any(|w| w == b"\r\n\r\n") { + match socket.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => seen.extend_from_slice(&buf[..n]), + } + } + let _ = socket + .write_all( + b"HTTP/1.1 407 Proxy Authentication Required\r\ncontent-length: 0\r\n\r\n", + ) + .await; + }); + } + }); + addr +} + +async fn closed_port() -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + port +} + +/// A target that cannot resolve: if a request ever bypassed the proxy it +/// would fail locally, never reach the internet. +const UNREACHABLE_HTTPS_TARGET: &str = "https://gemini.invalid/v1beta/models/m:generateContent"; + +#[tokio::test] +async fn classify_proxy_connect_refusal_as_capability() { + // A proxy that refuses the CONNECT with 407. + let proxy = start_refusing_connect_proxy().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::https(format!("http://{proxy}")).unwrap()) + .build() + .unwrap(); + let error = client + .post(UNREACHABLE_HTTPS_TARGET) + .body("{}") + .send() + .await + .expect_err("the proxy refuses the tunnel"); + let failure = classify_reqwest_error(&error, true); + assert_failure( + &failure, + GeminiFailureClass::Capability, + "proxy_unreachable", + ); + assert_eq!(failure.status, None); + + // A proxy that is not listening. + let port = closed_port().await; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::https(format!("http://127.0.0.1:{port}")).unwrap()) + .build() + .unwrap(); + let error = client + .post(UNREACHABLE_HTTPS_TARGET) + .body("{}") + .send() + .await + .expect_err("nothing listens on the proxy port"); + assert_failure( + &classify_reqwest_error(&error, true), + GeminiFailureClass::Capability, + "proxy_unreachable", + ); +} + +#[derive(Debug)] +struct ChainWrapper(std::io::Error); + +impl std::fmt::Display for ChainWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "client error (Connect)") + } +} + +impl std::error::Error for ChainWrapper { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +#[derive(Debug)] +struct RustlsWrapper(rustls::Error); + +impl std::fmt::Display for RustlsWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "tls handshake") + } +} + +impl std::error::Error for RustlsWrapper { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +#[test] +fn classify_untrusted_certificate_by_downcast() { + let unknown_issuer = + || rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer); + // The shape reqwest produces: wrapper → io::Error → rustls error. The + // io::Error's own `source()` skips its inner error, so the classifier + // must look inside it. + let chain = ChainWrapper(std::io::Error::new( + std::io::ErrorKind::InvalidData, + unknown_issuer(), + )); + assert_failure( + &classify_transport_error(&chain, true, false, true), + GeminiFailureClass::Capability, + "proxy_tls", + ); + assert_failure( + &classify_transport_error(&chain, true, false, false), + GeminiFailureClass::Capability, + "tls_untrusted", + ); + // A rustls error reached through `source()` directly. + let direct = RustlsWrapper(unknown_issuer()); + assert_failure( + &classify_transport_error(&direct, true, false, false), + GeminiFailureClass::Capability, + "tls_untrusted", + ); + // Text alone is never a certificate class. + let text_only = ChainWrapper(std::io::Error::other( + "invalid peer certificate: UnknownIssuer", + )); + assert_failure( + &classify_transport_error(&text_only, true, false, true), + GeminiFailureClass::Capability, + "proxy_unreachable", + ); + // A non-certificate rustls error is not a certificate class either. + let other = ChainWrapper(std::io::Error::new( + std::io::ErrorKind::InvalidData, + rustls::Error::DecryptError, + )); + assert_failure( + &classify_transport_error(&other, true, false, false), + GeminiFailureClass::Capability, + "direct_unreachable", + ); +} + +#[tokio::test] +async fn classify_direct_connect_failure_as_capability() { + let port = closed_port().await; + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let error = client + .post(format!( + "http://127.0.0.1:{port}/v1beta/models/m:generateContent" + )) + .body("{}") + .send() + .await + .expect_err("nothing listens"); + assert!(error.is_connect()); + let failure = classify_reqwest_error(&error, false); + assert_failure( + &failure, + GeminiFailureClass::Capability, + "direct_unreachable", + ); + assert_eq!( + failure.to_string(), + "gemini request failed (direct_unreachable)" + ); +} + +#[tokio::test] +async fn classify_timeout_as_transient() { + // Synthetic: a timeout that is not a connect failure. + let chain = ChainWrapper(std::io::Error::other("operation timed out")); + assert_failure( + &classify_transport_error(&chain, false, true, false), + GeminiFailureClass::Transient, + "timeout", + ); + // Real: a server that accepts and never answers. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(100)) + .build() + .unwrap(); + let error = client + .post(format!("http://{addr}/v1beta/models/m:generateContent")) + .body("{}") + .send() + .await + .expect_err("the server never answers"); + assert!(error.is_timeout()); + assert_failure( + &classify_reqwest_error(&error, false), + GeminiFailureClass::Transient, + "timeout", + ); + // Anything else is transient too. + assert_failure( + &classify_transport_error(&chain, false, false, true), + GeminiFailureClass::Transient, + "request_failed", + ); +} diff --git a/crates/freshell-server/src/logging.rs b/crates/freshell-server/src/logging.rs index 41b6e4c70..f702db607 100644 --- a/crates/freshell-server/src/logging.rs +++ b/crates/freshell-server/src/logging.rs @@ -51,7 +51,16 @@ //! carries `provider` + the spawned process `pid` (no `session_id` -- //! the spawn can precede session minting on the create path; the //! pid<->session join is the same session's adjacent created/reaped -//! events). Prompt/turn CONTENT is never logged anywhere. +//! events). Prompt/turn CONTENT is never logged anywhere, with one +//! deliberate exception: saved session names (next item). +//! - Saved session names: `session_names.name_changed` (INFO, one line per +//! accepted saved-name change, after the durable commit) carries +//! `name_ref`, `cause`, `old_name`/`old_source`/`old_revision`, +//! `new_name`/`new_source`/`new_revision` and `self_heal` (field +//! meanings: `session_name_events`). It logs the saved names +//! themselves, which are at most 200 characters and can be the first +//! words of a session's first user message (the `first_message` +//! source, cut to 50 characters). //! - Server lifecycle: `server.started` (`bind`, `port`, `boot_id`, //! `instance_id`, `commit`, `dirty`) -> `server.stopping` (`signal`) //! -> `server.stopped` (plus the `shutdown_forensics` diagnostic record) @@ -106,7 +115,7 @@ use axum::middleware::Next; use axum::response::Response; #[cfg(test)] use freshell_runtime_observability::scrub; -use freshell_runtime_observability::RotatingJsonlWriter as RotatingWriter; +use freshell_runtime_observability::{LiveSecrets, RotatingJsonlWriter as RotatingWriter}; use serde_json::{Map, Value}; use tracing::field::{Field, Visit}; use tracing::span::{Attributes, Id, Record}; @@ -140,6 +149,11 @@ pub struct LoggingConfig { /// every log line. Never logged itself, including here (this struct is /// never `Debug`-derived/printed). pub secret: String, + /// Name-reliability Task 6: secret values learned while the process runs + /// (the configured Gemini key, then any key saved in Settings), redacted + /// verbatim from every line like `secret`. Shared: values added after + /// [`init`] are redacted from then on. + pub live_secrets: LiveSecrets, /// The app version stamped onto EVERY line as `app_version` (DIAG-01's /// "app version" field): any arbitrary log tail is then attributable to /// the release that produced it without cross-referencing boot lines. @@ -154,7 +168,12 @@ pub struct LoggingConfig { /// `resolveDebugLogPath`) and adding two new, narrowly-scoped overrides /// (`FRESHELL_LOG_MAX_BYTES`/`FRESHELL_LOG_MAX_BACKUPS`) so the rotation /// bound is testable without waiting to actually accumulate 10 MiB. -pub fn resolve_config(home: Option<&Path>, secret: String, app_version: String) -> LoggingConfig { +pub fn resolve_config( + home: Option<&Path>, + secret: String, + live_secrets: LiveSecrets, + app_version: String, +) -> LoggingConfig { let log_dir = std::env::var("FRESHELL_LOG_DIR") .ok() .filter(|v| !v.is_empty()) @@ -176,6 +195,7 @@ pub fn resolve_config(home: Option<&Path>, secret: String, app_version: String) max_bytes, max_backups, secret, + live_secrets, app_version, } } @@ -191,7 +211,13 @@ pub fn resolve_config(home: Option<&Path>, secret: String, app_version: String) /// running server either way). pub fn init(config: LoggingConfig) -> std::io::Result<()> { let path = config.log_dir.join(LOG_FILE_NAME); - let writer = RotatingWriter::create(path, config.max_bytes, config.max_backups, config.secret)?; + let writer = RotatingWriter::create_with_live_secrets( + path, + config.max_bytes, + config.max_backups, + config.secret, + config.live_secrets, + )?; let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -527,6 +553,84 @@ mod tests { assert!(!out.contains("freshell-auth=abcdef123456")); } + /// Name-reliability Task 6 (G48): the newer `AQ.` Gemini key form is a + /// common secret pattern, like `AIza…`. + #[test] + fn scrub_redacts_aq_form_gemini_keys() { + // A synthetic key of the live key's shape: `AQ.` + 50 characters. + let key = format!("AQ.{}", "Ab3_x-Yz9Q".repeat(5)); + assert_eq!(key.len(), 53); + for line in [ + format!("plain line with {key} inside"), + format!(r#"{{"ts":"now","detail":"{key}"}}"#), + format!("gemini request rejected: key={key}; retry later"), + ] { + let out = scrub(&line, ""); + assert!(!out.contains(&key[3..20]), "AQ. key leaked: {out}"); + assert!(out.contains("***REDACTED***"), "{out}"); + } + // The existing AIza arm and non-secret text are unchanged. + let aiza = format!("AIza{}", "B3c".repeat(12)); + assert!(!scrub(&format!("key {aiza}"), "").contains(&aiza)); + let benign = r#"{"msg":"AQ.short, AQUARIUM and Q.A"}"#; + assert_eq!(scrub(benign, ""), benign); + } + + /// Name-reliability Task 6 (LB-B13): the configured key VALUE is redacted + /// even when no pattern covers all of it (here a second `.` stops the + /// `AQ.` pattern early), and a key saved in Settings later is redacted + /// from then on. + #[test] + fn scrub_redacts_the_configured_key_value() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rust-server.jsonl"); + let secrets = LiveSecrets::default(); + let writer = RotatingWriter::create_with_live_secrets( + path.clone(), + 1 << 20, + 1, + String::new(), + secrets.clone(), + ) + .unwrap(); + // What main does after the key cell is initialized. + let configured = "AQ.firstPartOfTheKey12345.second-part_after-the-dot"; + assert!(secrets.insert(configured)); + writer + .write_line(&format!( + r#"{{"msg":"failure for {configured}","nested":"see {configured}"}}"# + )) + .unwrap(); + // A Settings save of a new key, through the path the PATCH route uses. + let cell = crate::ai_title::AiKeyCell::default(); + let saved = "plain.saved-gemini.key-value-99"; + assert!(crate::session_name_capability::apply_saved_gemini_key( + &cell, + &secrets, + None, + Some(saved), + )); + assert_eq!(cell.get().as_deref(), Some(saved)); + writer + .write_line(&format!("later line with {saved}")) + .unwrap(); + // A value too short to be a credential never shreds ordinary text. + assert!(!secrets.insert("k2")); + writer.write_line("k2 stays readable").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + for fragment in [ + configured, + "second-part_after-the-dot", + "firstPartOfTheKey", + saved, + ] { + assert!(!content.contains(fragment), "{fragment} leaked: {content}"); + } + assert!(content.contains("k2 stays readable")); + assert!(content.matches("***REDACTED***").count() >= 3, "{content}"); + } + #[test] fn scrub_preserves_non_secret_content() { let line = r#"{"ts":"now","level":"INFO","target":"freshell_server","msg":"hello","route":"/api/health","status":200}"#; diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 97db3e9bb..373d60382 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -32,6 +32,7 @@ mod files; mod fresh_agent_extras; #[cfg(feature = "managed-runtime-v1")] mod fresh_agent_proxy; +mod gemini_failure; mod host_stats; mod identity_sink; mod instance_id; @@ -54,6 +55,8 @@ mod managed_provider_sdk_tests; mod managed_runtime; #[cfg(feature = "managed-runtime-v1")] mod managed_runtime_api; +#[cfg(feature = "managed-runtime-v1")] +mod managed_view_intent; mod migrations; mod net_bind; mod network; @@ -70,10 +73,12 @@ mod screenshots; mod serve_client; mod session_directory; mod session_metadata; +mod session_name_capability; mod session_name_generation; mod session_name_migration; mod session_name_native; mod session_name_routes; +mod session_name_tick; mod session_names; mod sessions; mod settings; @@ -829,9 +834,14 @@ async fn main() -> ExitCode { // here (e.g. an unwritable log dir) must never prevent boot -- the // pre-existing stderr "listening on" line below still gets the // operator to a running server either way. + // Name-reliability Task 6: secret values learned later (the configured + // Gemini key below, keys saved in Settings) are redacted from the first + // line that could carry them. + let log_secrets = freshell_runtime_observability::LiveSecrets::default(); let logging_config = logging::resolve_config( home.as_deref(), auth_token.as_str().to_string(), + log_secrets.clone(), app_version.as_str().to_string(), ); if let Err(err) = logging::init(logging_config) { @@ -965,6 +975,12 @@ async fn main() -> ExitCode { .filter(|v| !v.is_empty()), settings.ai.gemini_api_key.clone(), ); + for key in [ai_key.get(), settings.ai.gemini_api_key.clone()] + .into_iter() + .flatten() + { + log_secrets.insert(&key); + } // `FRESHELL_GEMINI_BASE_URL` is a Rust-only test seam for the e2e // fake-Gemini server (Task 21) — Node has NO env base-URL override (its // default is hardcoded), a deliberate documented superset (validator-A1). @@ -978,28 +994,31 @@ async fn main() -> ExitCode { ai_key.clone(), gemini_base_url.clone(), )); - // Session naming alone uses the OneCLI proxy credential route when the - // effective reqwest proxy is marked as OneCLI. The generic transport above - // remains direct-key-only for summaries and other AI features. + // Session naming's own credential route (name-reliability Task 6): a + // configured key always wins (sent direct past a OneCLI ambient proxy, + // through any other ambient proxy); with no key, the inherited OneCLI + // proxy. The generic transport above stays direct-key-only for + // summaries and other AI features. let session_name_auth = ai_title::GeminiSessionNameAuth::from_environment(ai_key.clone(), &gemini_base_url); + let session_name_http = std::sync::Arc::new(ai_title::GeminiSessionNameHttp::new( + reqwest::Client::builder(), + session_name_auth.clone(), + gemini_base_url, + )); let session_name_gemini: std::sync::Arc = - std::sync::Arc::new(ai_title::GeminiSessionNameHttp::new( - reqwest::Client::new(), - session_name_auth.clone(), - gemini_base_url, - )); + session_name_http.clone(); // Unified agent names (Task 4): the generation participant of the ONE // shared serial naming worker — execution only, never another loop. - // Capability (the naming toggle + the OneCLI/direct credential route) is - // checked before selection, so disabled naming pauses without consuming - // anything. + // Capability (the naming toggle, the credential route and the naming + // pause) is checked before selection, so paused naming consumes nothing. let session_name_generator = std::sync::Arc::new(session_name_generation::SessionNameGenerator::new( settings_store.clone(), session_name_auth.clone(), - session_name_gemini.clone(), + session_name_http, )); + session_name_generator.log_boot_route(); // The shared server→client broadcast bus (pre-serialized frames). REST handlers // (fresh-agent create/send) push here; every `/ws` connection fans it out to its @@ -1375,54 +1394,16 @@ async fn main() -> ExitCode { fresh_codex_state.set_session_naming(names.clone()); fresh_opencode_state.set_session_naming(names.clone()); - // The 2s adoption/reconcile tick: (1) adopt a same-home cooperating - // process's committed generation (CLI/MCP writes) — the strict - // full-document re-read under the lock, never an mtime shortcut; - // (2) bind still-pending handles whose durability has since become - // verifiable. Claude: the transcript locator (the signal-first and - // zero-turn create lanes bind as soon as the transcript exists). - // Codex/opencode: their runtime edges (the rollout walk / the DB - // row) own the verified binds — the tick never fabricates one. - { - let names = names.clone(); - let identity = terminal_identity.clone(); - let registry = registry.clone(); - tokio::spawn(async move { - let mut tick = tokio::time::interval(std::time::Duration::from_secs(2)); - tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - tick.tick().await; - if let Err(error) = names.refresh_current().await { - tracing::warn!( - target: "freshell_server::session_names", - op = "refresh_current", - name_ref = "-", - revision = 0, - class = %error.code(), - "session_names.operation_failed: {error}" - ); - } - for (terminal_id, provider, session_id, _handle) in - identity.pending_naming_binds() - { - if provider != "claude" { - continue; - } - // One bounded locate + bind per still-pending claude - // row; a miss or failure keeps the row pending for - // the next tick's visible retry (the helper logs its - // own failure classes). - let _ = freshell_ws::identity::bind_pending_claude_transcript( - &identity, - ®istry, - &terminal_id, - &session_id, - ) - .await; - } - } - }); - } + // The 2s adoption/reconcile tick (`session_name_tick`): adopt a + // same-home cooperating process's committed generation through the + // cheap stamp peek, then bind still-pending claude handles whose + // transcripts now exist. + tokio::spawn(session_name_tick::run_naming_tick( + names.clone(), + terminal_identity.clone(), + registry.clone(), + session_name_tick::NAMING_TICK_PERIOD, + )); // The naming publisher: every committed update (this process's own // writes and adopted external ones) reaches WS clients as the @@ -1805,8 +1786,6 @@ async fn main() -> ExitCode { .with_terminal_created_hook({ let terminal_meta = terminal_meta.clone(); let broadcast_tx = Arc::clone(&broadcast_tx); - let terminal_identity = terminal_identity.clone(); - let registry = registry.clone(); Arc::new(move |event: freshell_freshagent::TerminalCreatedEvent| { freshell_ws::terminal_meta::seed_from_terminal( &terminal_meta, @@ -1816,27 +1795,16 @@ async fn main() -> ExitCode { event.resume_session_id.as_deref(), event.cwd.as_deref(), ); - // Unified agent names (Task 2): write the REST create-lane - // naming binding onto the SHARED identity registry (the CLI - // locator bind lanes and every rename resolver read it) and - // the terminal registry row (the display cache the - // `/api/terminals` projection carries). The WS create path - // performs its own admission — this hook only fires for - // REST-pipeline creates, so no row is double-bound. - if event.naming_handle.is_some() || event.name_ref.is_some() { - terminal_identity.set_name_binding( - &event.terminal_id, - event.name_ref.clone(), - event.naming_handle.clone(), - ); - registry.set_naming( - &event.terminal_id, - event.name_ref.clone(), - event.naming_handle.clone(), - ); - } }) - }); + }) + // Name reliability (Task 10): the REST door's terminal naming bind + // writes the SAME shared identity registry the WS door binds (the + // CLI locator bind lanes and every rename resolver read it), through + // the one shared helper — replacing this hook's former binding + // write, so the registry row's cache and title are filled too. + .with_terminal_name_binder(Arc::new( + freshell_ws::terminal_naming::IdentityNameBinder::new(terminal_identity.clone()), + )); // Batch B: `session_directory` no longer re-walks + re-parses every // transcript on every request -- it reads a cached, TTL-refreshed // `SessionIndex`. Batch C adds `CodexSource` (file-based, same shape as @@ -1978,37 +1946,15 @@ async fn main() -> ExitCode { // the durable opencode session's record, else the terminal's own // (pending) naming ref. A detached fold never disturbs the activity // lane; no wired store (degraded boot) drops the observation. - let opencode_native_title_observer: freshell_ws::opencode_lane::NativeTitleObserver = { - let identity = terminal_identity.clone(); - let names = session_names.clone(); - std::sync::Arc::new(move |terminal_id: &str, session_id: &str, title: &str| { - let Some(names) = names.clone() else { - return; - }; - let identity = identity.clone(); - let terminal_id = terminal_id.to_string(); - let session_id = session_id.to_string(); - let title = title.to_string(); - tokio::spawn(async move { - let sink: Option> = - Some(names); - let target = identity - .named_session_ref_of("opencode", &session_id) - .or_else(|| identity.name_ref_for(&terminal_id)); - let Some(target) = target else { - return; - }; - let _ = freshell_freshagent::naming::observe_native_live( - &sink, - target, - &title, - freshell_freshagent::naming::NativeNameOrigin::Snapshot, - None, - ) - .await; - }); - }) - }; + // Name-reliability Task 4: built in `freshell_ws::opencode_title_naming` + // (stream-ordered, placeholder-free, never stalls the lane). + let opencode_native_title_observer: freshell_ws::opencode_lane::NativeTitleObserver = + freshell_ws::opencode_title_naming::naming_title_observer( + terminal_identity.clone(), + session_names.clone().map(|names| { + names as std::sync::Arc + }), + ); activity_hub.set_opencode_lane_deps(std::sync::Arc::new( freshell_ws::opencode_lane::OpencodeLaneDeps { http: std::sync::Arc::new(freshell_ws::opencode_lane::ReqwestLaneHttp::new()), @@ -2711,6 +2657,8 @@ async fn main() -> ExitCode { // first-message lookup for already-named sessions. index: Some(Arc::clone(index)), index_hydrated: Arc::new(std::sync::atomic::AtomicBool::new(false)), + // Name-reliability Task 5 (C5): remembered across passes. + first_message_lookups: Default::default(), }, Arc::clone(index), SESSIONS_SWEEP_INTERVAL, @@ -2783,6 +2731,12 @@ async fn main() -> ExitCode { ws_state.clone(), codex_proxy_events_rx, ); + // Name reliability (Task 10): the 2s terminal-naming reconcile — rows + // whose identity ref moved (a conversation switch no caller reported), + // scoped rows with no naming binding, and bound rows with an empty name + // cache. Runs beside the naming tick above (which owns store adoption and + // the pending binds); one batched read per pass. + freshell_ws::terminal_naming::spawn_terminal_naming_reconcile(ws_state.clone()); // P4 (stale-resume-identity): claude SessionStart signal sweep — drains // the signal files Task 11's launch hook writes // (`$HOME/.freshell/session-signals/claude/__.json`) @@ -2816,6 +2770,8 @@ async fn main() -> ExitCode { // Task 6: the sessions router's provider-generated short-circuit reads // the SAME session index (another clone before the move below). let sessions_state_index = session_index.clone(); + // Name-reliability Task 4: the canonical rename route's folder lookup. + let session_name_routes_index = session_index.clone(); // The session-metadata store was created above (before the sweeps) — // both the `POST /api/session-metadata` write route below and Task // 20's session-directory read-join share that instance. @@ -3170,6 +3126,10 @@ async fn main() -> ExitCode { // Task 2: the SAME process-local AI key cell constructed at // boot, so every settings save force-re-applies the key. ai_key: ai_key.clone(), + // Name-reliability Task 6: a saved key becomes a live log + // secret, and a changed key resets the naming pause. + log_secrets: log_secrets.clone(), + naming_generation: Some(session_name_generator.clone()), }, )) .merge(boot::router(boot_state)) @@ -3259,6 +3219,9 @@ async fn main() -> ExitCode { // terminal import targets (the same registry every // rename surface reads). identity: terminal_identity.clone(), + // Name-reliability Task 4: the best-known folder of a + // session-key rename target. + index: session_name_routes_index.clone(), }) }) .unwrap_or_default(), diff --git a/crates/freshell-server/src/managed_launch_view_title_tests.rs b/crates/freshell-server/src/managed_launch_view_title_tests.rs new file mode 100644 index 000000000..ca8d6b0a6 --- /dev/null +++ b/crates/freshell-server/src/managed_launch_view_title_tests.rs @@ -0,0 +1,111 @@ +//! Name reliability (Task 8, D6): a managed terminal launch requests its +//! automatic view WITHOUT a title. The view title used to be the provider +//! label ("Shell agent", "OpenCode agent"), which every browser refresh then +//! wrote over the tab's real name. Names belong to the user and the saved +//! name document, never to the runtime. +//! +//! The actual controller launch path runs against a synthetic supervisor on +//! an owned Unix socket; only the supervisor's replies are fixed. +use super::*; +use freshell_runtime_protocol::*; +use std::collections::BTreeMap; +use tokio::net::UnixListener; + +#[tokio::test] +async fn managed_launch_sends_no_provider_label_title() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let socket = root.path().join("supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (captured_tx, captured_rx) = tokio::sync::oneshot::channel::(); + let supervisor = tokio::spawn(async move { + let mut captured = Some(captured_tx); + while captured.is_some() { + let (mut peer, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut peer).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 1, + installation_id: InstallationId::new(), + }), + AdminCommand::Inventory => Ok(AdminResult::Inventory(vec![])), + AdminCommand::Launch(request) => { + let view: RuntimeView = serde_json::from_value(serde_json::json!({ + "soulId": request.soul_id, "incarnationId": IncarnationId::new(), + "launchState": "running", "cleanupState": "none", "intentRevision": 1, + "executionGeneration": 1, "desiredState": "running", "recoveryState": "live", + "durabilityState": "unknown", "allocationState": "allocated", + "evidenceRevision": 0, "successfulRecoveriesInWindow": 0 + })) + .unwrap(); + let reply = AdminResult::Launch(LaunchResult { + view, + worker_pid: 1, + worker_launch_count: 1, + host_boot_id: HostBootId::new(), + effective_limits: request.limits, + fixture_evidence: serde_json::Value::Null, + }); + if let Some(sender) = captured.take() { + let _ = sender.send(*request); + } + Ok(reply) + } + other => panic!("unexpected supervisor command during a shell launch: {other:?}"), + }; + write_frame( + &mut peer, + &AdminReply { + request_id: envelope.request_id, + result, + }, + ) + .await + .unwrap(); + } + }); + let controller = ServerManagedRuntimeController { + client: RuntimeClient::new(&socket, "fixture-control-secret"), + recovery: Arc::new(ManagedRecoveryState::default()), + }; + + controller + .launch(ManagedTerminalLaunch { + spec: freshell_platform::SpawnSpec { + program: "bash".into(), + args: vec![], + env_overrides: BTreeMap::new(), + cwd: Some(workspace.to_string_lossy().into_owned()), + cols: 80, + rows: 24, + }, + env: BTreeMap::new(), + terminal_id: "terminal-untitled".into(), + stream_id: "stream-untitled".into(), + mode: "shell".into(), + resume_session_id: None, + provider_model: None, + provider_reasoning_effort: None, + provider_sandbox: None, + provider_permission_mode: None, + view_tab_id: Some("tab-untitled".into()), + view_pane_id: Some("pane-untitled".into()), + create_request_id: Some("create-untitled".into()), + }) + .await + .expect("the managed shell launch succeeds against the synthetic supervisor"); + let request = captured_rx.await.unwrap(); + supervisor.await.unwrap(); + + let view = request + .view_intent + .expect("a managed launch requests its automatic primary view"); + assert_eq!( + view.title, None, + "the runtime never names a view (it used to send a provider label)" + ); + assert_eq!(view.kind, ViewIntentKind::AutomaticPrimary); + assert_eq!(view.preferred_tab_id.as_deref(), Some("tab-untitled")); + assert_eq!(view.preferred_pane_id.as_deref(), Some("pane-untitled")); +} diff --git a/crates/freshell-server/src/managed_output_rpc_tests.rs b/crates/freshell-server/src/managed_output_rpc_tests.rs index 19629f901..02a333e99 100644 --- a/crates/freshell-server/src/managed_output_rpc_tests.rs +++ b/crates/freshell-server/src/managed_output_rpc_tests.rs @@ -233,7 +233,7 @@ async fn managed_runtime_failed_retained_read_publishes_blocked_without_fabricat resume_session_id: Some("native-retained-failure".into()), create_request_id: Some("failed-read-create".into()), }; - registry.register_managed(descriptor.clone()); + registry.register_managed(descriptor.clone(), "Shell".to_string()); registry.set_managed_controller(Some(controller.clone())); let result = registry .refresh_managed_output(&descriptor.terminal_id, 65536) diff --git a/crates/freshell-server/src/managed_runtime.rs b/crates/freshell-server/src/managed_runtime.rs index 2d02737e1..e2c7cd432 100644 --- a/crates/freshell-server/src/managed_runtime.rs +++ b/crates/freshell-server/src/managed_runtime.rs @@ -5,7 +5,6 @@ use freshell_runtime_protocol::{ CleanupState, DesiredState, LaunchRequest, LaunchState, RecoveryBlockReason, RecoveryOutcome, RecoveryProbe, RecoveryResult, RecoveryState, RecoveryTrigger, RequestId, RuntimeErrorCode, RuntimeLimits, RuntimeProfile, RuntimeView, SoulId, StopOutcome, TerminalLaunchSpec, - ViewIntentKind, ViewIntentRequest, ViewVisibilityIntent, }; use freshell_terminal::registry::{ ManagedLifecycle, ManagedLifecycleState, ManagedOutputChunk, ManagedOutputCompletion, @@ -767,16 +766,11 @@ impl ManagedTerminalController for ServerManagedRuntimeController { provider_secret_references, provider_launch_context, }), - view_intent: Some(ViewIntentRequest { - owner_id: String::new(), - workspace_id: project_key.clone(), - kind: ViewIntentKind::AutomaticPrimary, - preferred_tab_id: request.view_tab_id.clone(), - preferred_pane_id: request.view_pane_id.clone(), - title: Some(format!("{} agent", provider_label(&request.mode))), - placement_group: None, - visibility: ViewVisibilityIntent::Visible, - }), + view_intent: Some(crate::managed_view_intent::automatic_primary_view_request( + project_key.clone(), + request.view_tab_id.clone(), + request.view_pane_id.clone(), + )), expected_control_epoch: None, }; // A view returned by this launch may arrive after a concurrent @@ -1336,17 +1330,6 @@ fn managed_provider_args(mode: &str, args: Vec) -> Result, S .map_err(|error| error.message) } -fn provider_label(provider: &str) -> &str { - match provider { - "claude" => "Claude", - "codex" => "Codex", - "opencode" => "OpenCode", - "amplifier" => "Amplifier", - "shell" => "Shell", - other => other, - } -} - fn stable_soul_id(key: &str) -> Result { SoulId::parse(format!("soul-terminal-{}", stable_hex(key))).map_err(|e| e.to_string()) } @@ -1812,3 +1795,7 @@ mod managed_output_rpc_tests; #[cfg(all(test, unix))] #[path = "managed_runtime_opencode_replan_tests.rs"] mod opencode_replan_tests; + +#[cfg(all(test, unix))] +#[path = "managed_launch_view_title_tests.rs"] +mod launch_view_title_tests; diff --git a/crates/freshell-server/src/managed_view_intent.rs b/crates/freshell-server/src/managed_view_intent.rs new file mode 100644 index 000000000..146473d9f --- /dev/null +++ b/crates/freshell-server/src/managed_view_intent.rs @@ -0,0 +1,27 @@ +//! The view a managed launch asks the supervisor to keep discoverable. + +use freshell_runtime_protocol::{ViewIntentKind, ViewIntentRequest, ViewVisibilityIntent}; + +/// The automatic primary view a managed launch (terminal or fresh agent) +/// requests for `workspace_id`. +/// +/// It never carries a title: names belong to the user and the saved-name +/// document, never to the runtime. A provider label here ("Shell agent", +/// "OpenCode agent") used to be written over tab names by every browser +/// refresh (name reliability, Task 8). +pub(crate) fn automatic_primary_view_request( + workspace_id: String, + preferred_tab_id: Option, + preferred_pane_id: Option, +) -> ViewIntentRequest { + ViewIntentRequest { + owner_id: String::new(), + workspace_id, + kind: ViewIntentKind::AutomaticPrimary, + preferred_tab_id, + preferred_pane_id, + title: None, + placement_group: None, + visibility: ViewVisibilityIntent::Visible, + } +} diff --git a/crates/freshell-server/src/recovery_inventory.rs b/crates/freshell-server/src/recovery_inventory.rs index 593b67129..cc9dd8688 100644 --- a/crates/freshell-server/src/recovery_inventory.rs +++ b/crates/freshell-server/src/recovery_inventory.rs @@ -10,6 +10,9 @@ use freshell_ws::pane_ledger::{ use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; +#[path = "recovery_inventory_titles.rs"] +mod titles; + pub struct DeviceUnion { pub device_id: String, pub union_doc: Value, @@ -1007,7 +1010,7 @@ pub fn build_inventory( eff_str, pane_live )); - json!({ + let mut pane_projection = 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), @@ -1016,7 +1019,11 @@ pub fn build_inventory( "sessionRef": eff_ref.unwrap_or(Value::Null), "ledgerState": ledger_state, "live": pane_live, - }) + }); + // Name reliability (Task 9): the pane's title and + // user flag ride along (display data, not substance). + titles::forward_pane_names(pane, &mut pane_projection); + pane_projection }) .collect(); // Unified agent names (Task 6): the tab's stable @@ -1026,6 +1033,7 @@ pub fn build_inventory( let mut tab_projection = serde_json::Map::new(); tab_projection.insert("tabKey".to_string(), rec["tabKey"].clone()); tab_projection.insert("tabName".to_string(), rec["tabName"].clone()); + titles::forward_tab_user_flag(rec, &mut tab_projection); if rec.get("nameSource").is_some_and(|v| !v.is_null()) { tab_projection.insert("nameSource".to_string(), rec["nameSource"].clone()); } diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index 3ef8b1f41..e8f7bb8c5 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -6284,3 +6284,110 @@ fn the_inventory_forwards_the_tab_name_source_additively() { out2["device"]["tabs"][0] ); } + +/// Name reliability (Task 9, D7): a new window rebuilds its workspace from +/// this inventory, so the pane titles and the "user renamed" flags (pane and +/// tab) forward with each tab. Only well-typed values forward: a title is a +/// string and a flag is a boolean; anything else is left out. +#[test] +fn inventory_forwards_pane_titles_and_user_flags() { + let mut d = DeviceUnion { + device_id: "dev1".into(), + union_doc: union_doc_with_tab_key( + "dev1", + 1000, + "dev1:t1", + json!([ + { "paneId": "p-renamed", "kind": "terminal", "title": "KeepMePane", + "titleSetByUser": true, + "payload": { "mode": "shell", "shell": "system", "createRequestId": "req-r" } }, + { "paneId": "p-osc", "kind": "terminal", "title": "user@host: ~", + "payload": { "mode": "shell", "shell": "system", "createRequestId": "req-o" } }, + { "paneId": "p-untitled", "kind": "editor", + "payload": { "filePath": "/tmp/notes.md" } }, + { "paneId": "p-malformed", "kind": "browser", "title": 42, + "titleSetByUser": "yes", + "payload": { "url": "https://example.com" } } + ]), + ), + }; + d.union_doc["records"][0]["titleSetByUser"] = json!(true); + + let out = build_inventory(vec![d], vec![], no_live(), &no_evidence(), &no_closes()); + let tab = &out["device"]["tabs"][0]; + assert_eq!( + tab["titleSetByUser"], + json!(true), + "the tab flag forwards: {tab}" + ); + let panes = tab["panes"].as_array().expect("panes"); + assert_eq!(panes[0]["title"], json!("KeepMePane"), "{}", panes[0]); + assert_eq!(panes[0]["titleSetByUser"], json!(true), "{}", panes[0]); + assert_eq!(panes[1]["title"], json!("user@host: ~"), "{}", panes[1]); + assert!(panes[1].get("titleSetByUser").is_none(), "{}", panes[1]); + assert!(panes[2].get("title").is_none(), "{}", panes[2]); + assert!(panes[2].get("titleSetByUser").is_none(), "{}", panes[2]); + assert!(panes[3].get("title").is_none(), "{}", panes[3]); + assert!(panes[3].get("titleSetByUser").is_none(), "{}", panes[3]); + + // A record whose tab nobody renamed forwards its false flag as recorded. + let mut d2 = DeviceUnion { + device_id: "dev2".into(), + union_doc: union_doc( + "dev2", + 1000, + json!([{ "paneId": "p1", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", "createRequestId": "r" } }]), + ), + }; + d2.union_doc["records"][0]["titleSetByUser"] = json!(false); + let out2 = build_inventory(vec![d2], vec![], no_live(), &no_evidence(), &no_closes()); + assert_eq!(out2["device"]["tabs"][0]["titleSetByUser"], json!(false)); +} + +/// Name reliability (Task 9) pinning test: names are not offer substance. +/// Renaming a tab or a pane (or setting its user flag) must not change the +/// dismissal id, or a declined offer would come back after every rename. +#[test] +fn inventory_digest_ignores_titles() { + let build = |tab_name: &str, tab_flag: bool, pane_title: Option<&str>, pane_flag: bool| { + let mut pane = json!({ "paneId": "p1", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", "createRequestId": "r" } }); + if let Some(title) = pane_title { + pane["title"] = json!(title); + } + if pane_flag { + pane["titleSetByUser"] = json!(true); + } + let mut d = DeviceUnion { + device_id: "dev".into(), + union_doc: union_doc("dev", 1000, json!([pane])), + }; + d.union_doc["records"][0]["tabName"] = json!(tab_name); + d.union_doc["records"][0]["titleSetByUser"] = json!(tab_flag); + build_inventory(vec![d], vec![], no_live(), &no_evidence(), &no_closes()) + }; + let before = build("work", false, None, false); + let after = build("MyShellTab", true, Some("KeepMePane"), true); + assert_eq!( + before["contentId"], after["contentId"], + "a rename must not re-key the dismissal id" + ); + // Control: a material change (another pane) does re-key it. + let mut d = DeviceUnion { + device_id: "dev".into(), + union_doc: union_doc( + "dev", + 1000, + json!([ + { "paneId": "p1", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", "createRequestId": "r" } }, + { "paneId": "p2", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", "createRequestId": "r2" } } + ]), + ), + }; + d.union_doc["records"][0]["titleSetByUser"] = json!(false); + let material = build_inventory(vec![d], vec![], no_live(), &no_evidence(), &no_closes()); + assert_ne!(before["contentId"], material["contentId"]); +} diff --git a/crates/freshell-server/src/recovery_inventory_titles.rs b/crates/freshell-server/src/recovery_inventory_titles.rs new file mode 100644 index 000000000..f2a65b408 --- /dev/null +++ b/crates/freshell-server/src/recovery_inventory_titles.rs @@ -0,0 +1,46 @@ +//! Name reliability (plan Task 9, D7): the names a rebuilt window restores. +//! +//! A new window (or any inventory rebuild) rebuilds its workspace from the +//! recovery inventory, so each inventory tab and pane carries the title its +//! registry snapshot recorded together with the "user renamed" flags: the +//! pane's `titleSetByUser` and the tab's `titleSetByUser`. Without the flags +//! a restored shell, browser or editor name would lose its protection and +//! the next automatic title (a terminal's OSC title, for example) would +//! replace it. +//! +//! These fields are display data, never offer substance: they are NOT part +//! of the dismissal digest (`contentId`), so renaming a tab or a pane never +//! re-keys an offer the user already declined. Only well-typed values +//! forward (a title is a string, a flag is a boolean); a malformed value is +//! left out rather than passed on. Scoped agent panes are named by their +//! saved session record; the client drops their titles when it rebuilds. + +use serde_json::{Map, Value}; + +/// Copy a registry pane snapshot's `title` (a string) and `titleSetByUser` +/// (a boolean) into its inventory pane projection. +pub(super) fn forward_pane_names(snapshot: &Value, projection: &mut Value) { + let Some(projection) = projection.as_object_mut() else { + return; + }; + if let Some(title) = snapshot.get("title").filter(|value| value.is_string()) { + projection.insert("title".to_string(), title.clone()); + } + if let Some(flag) = snapshot + .get("titleSetByUser") + .filter(|value| value.is_boolean()) + { + projection.insert("titleSetByUser".to_string(), flag.clone()); + } +} + +/// Copy a registry record's tab-level `titleSetByUser` (a boolean) into its +/// inventory tab projection. +pub(super) fn forward_tab_user_flag(record: &Value, projection: &mut Map) { + if let Some(flag) = record + .get("titleSetByUser") + .filter(|value| value.is_boolean()) + { + projection.insert("titleSetByUser".to_string(), flag.clone()); + } +} diff --git a/crates/freshell-server/src/session_name_apply.rs b/crates/freshell-server/src/session_name_apply.rs new file mode 100644 index 000000000..0c42d16a7 --- /dev/null +++ b/crates/freshell-server/src/session_name_apply.rs @@ -0,0 +1,84 @@ +//! Applying a decided automatic name (name-reliability plan, Task 4): the +//! small document mutations the store's decisions use once +//! `session_name_policy` has decided an offer applies — install the name +//! exactly as planned (never re-offered through the strict raise) and +//! attach a rename outcome. (A record's native baseline is read through +//! `session_name_sweep_batch::StagedNativeBaselines`; every accepted change, +//! self-heals included, is logged by `session_name_events`.) + +use freshell_protocol::session_names::{ + NameSource, RenameOutcome, SessionNameRecord, SessionNameRef, SessionNameUpdate, +}; + +use super::{name_ref_key, Decision, NameError, StoredDocument}; +use crate::session_name_generation::IndexedNameInput; + +/// Name-reliability Task 4: install an automatic name the policy already +/// decided (`session_name_policy`) — no rank check here, so a planned +/// equal-rank replacement (a self-heal, an automation rename) applies exactly +/// as planned instead of being re-offered through `offer_mut`'s strict +/// raise (C1). Never used for manual names. +pub(super) fn replace_automatic( + document: &mut StoredDocument, + key: &str, + name: String, + source: NameSource, +) -> Result<(), NameError> { + debug_assert!( + source != NameSource::Manual, + "manual names go through offer_mut" + ); + let Some(record) = document.record_at(key).cloned() else { + return Err(NameError::NotFound(format!("no naming record for {key}"))); + }; + let revision = document.allocate_revision()?; + document.records.insert( + key.to_string(), + SessionNameRecord { + name_ref: record.name_ref, + name, + source, + revision, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }, + ); + Ok(()) +} + +/// A source as the stored document and the wire spell it (`provider_ai`, +/// `directory`), taken from its serde form so a log line matches a search +/// for that spelling. +pub(super) fn wire_source(source: NameSource) -> String { + serde_json::to_value(source) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| format!("{source:?}")) +} + +/// The durable session key an index row names. +pub(super) fn indexed_key(input: &IndexedNameInput) -> String { + name_ref_key(&SessionNameRef::Session { + provider: input.provider, + session_id: input.session_id.clone(), + }) +} + +/// Attach a rename outcome to a rename decision's answer (only rename +/// responses carry one; the publish list never does). +pub(super) fn with_outcome( + decision: Decision, + outcome: RenameOutcome, +) -> Decision { + match decision { + Decision::Read(mut update) => { + update.outcome = Some(outcome); + Decision::Read(update) + } + Decision::Write { mut value, publish } => { + value.outcome = Some(outcome); + Decision::Write { value, publish } + } + } +} diff --git a/crates/freshell-server/src/session_name_capability.rs b/crates/freshell-server/src/session_name_capability.rs new file mode 100644 index 000000000..211a9217d --- /dev/null +++ b/crates/freshell-server/src/session_name_capability.rs @@ -0,0 +1,432 @@ +//! Name-reliability Task 6: which credential AI naming uses, and the pause +//! that keeps a refused credential from using up anything. +//! +//! **Route by credential (D5 as amended), decided per attempt from the live +//! key cell:** +//! 1. A key is configured (`GOOGLE_GENERATIVE_AI_API_KEY`, else +//! `settings.ai.geminiApiKey`; a Settings save takes effect at the next +//! attempt) → the [`NamingRoute::Key`] route: the key is always sent. When +//! the ambient proxy for the Gemini URL carries the OneCLI marker, the +//! keyed request goes DIRECT ([`KeyRouteTransport::Direct`]): OneCLI is +//! the launching agent's credential broker, its token may not outlive that +//! agent, and direct was proven on this host. Any other ambient proxy is +//! honored ([`KeyRouteTransport::AmbientProxy`]), so hosts whose only way +//! out is an ordinary proxy keep working. +//! 2. Else the ambient proxy is OneCLI's → [`NamingRoute::InheritedOneCli`]: +//! no key is sent and the server borrows the launching shell's OneCLI +//! identity (one boot WARN says so). +//! 3. Else [`NamingRoute::Unavailable`]: no claims. +//! +//! One request per attempt; there is no fallback between routes. +//! +//! **The pause ([`CapabilityLatch`]).** A capability failure (credential, +//! permission, proxy, certificate) pauses AI naming until a re-probe time +//! (15 minutes; Rust-only test seam `FRESHELL_NAMING_CAPABILITY_REPROBE_MS`) +//! or a key change; a quota answer pauses with a shorter, growing re-probe +//! (60 s doubling per consecutive throttled probe, capped at 15 minutes; +//! test seam `FRESHELL_NAMING_THROTTLE_REPROBE_MS` sets the base). While +//! paused the worker claims nothing; one probe runs per window. Both kinds +//! refund the attempt, so a paused series keeps its allowance and its +//! naming input. A key change starts a new latch epoch: an attempt that was +//! already in flight on the old key still has its refusal refunded, but its +//! outcome neither pauses, re-arms nor restores naming, so the new key is +//! tried at the next poll. + +use std::sync::Mutex; +use std::time::Duration; + +use tokio::time::Instant; + +use crate::ai_title::{AiKeyCell, BoxFuture, GeminiProxyEnvironment}; +use crate::gemini_failure::{GeminiFailure, GeminiFailureClass}; + +/// The session-name provider call: one prompt in, the answer text or a +/// classified failure out. +pub trait SessionNameTransport: Send + Sync { + fn generate_content( + &self, + prompt: String, + max_output_tokens: u32, + ) -> BoxFuture>; +} + +/// How a keyed request leaves the host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyRouteTransport { + /// A client that ignores the ambient proxy (the ambient proxy is OneCLI's). + Direct, + /// The normal client, which honors `HTTPS_PROXY`/`NO_PROXY`. + AmbientProxy, +} + +impl KeyRouteTransport { + pub fn as_str(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::AmbientProxy => "ambient_proxy", + } + } +} + +/// The credential route of one naming attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NamingRoute { + Key(KeyRouteTransport), + InheritedOneCli, + Unavailable, +} + +impl NamingRoute { + pub fn as_str(self) -> &'static str { + match self { + Self::Key(_) => "key", + Self::InheritedOneCli => "inherited_onecli", + Self::Unavailable => "unavailable", + } + } + + pub fn is_available(self) -> bool { + self != Self::Unavailable + } + + /// Whether a request on this route traverses a proxy. + pub fn request_via_proxy(self, facts: ProxyFacts) -> bool { + match self { + Self::Key(KeyRouteTransport::Direct) | Self::Unavailable => false, + Self::Key(KeyRouteTransport::AmbientProxy) | Self::InheritedOneCli => facts.via_proxy, + } + } +} + +/// What the ambient proxy environment does to the Gemini URL, computed once +/// at construction (the environment is read once, as reqwest does). Only +/// booleans: proxy URLs can carry credentials and never leave this check. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProxyFacts { + /// A proxy applies to the Gemini URL. + pub via_proxy: bool, + /// That proxy carries OneCLI's authorization marker. + pub onecli_marker: bool, +} + +impl ProxyFacts { + /// How a keyed request would leave the host under these facts. + pub fn key_route_transport(self) -> KeyRouteTransport { + if self.onecli_marker { + KeyRouteTransport::Direct + } else { + KeyRouteTransport::AmbientProxy + } + } +} + +/// The proxy facts for `url` under `proxy_env` (the effective proxy reqwest +/// would use, NO_PROXY applied). +pub fn proxy_facts(proxy_env: &GeminiProxyEnvironment, url: &str) -> ProxyFacts { + let proxy = proxy_env.proxy_for_url(url); + ProxyFacts { + via_proxy: proxy.is_some(), + onecli_marker: proxy.is_some_and(crate::ai_title::has_onecli_authorization), + } +} + +/// The route of the next attempt (see the module doc). +pub fn select_naming_route(key_cell: &AiKeyCell, facts: ProxyFacts) -> NamingRoute { + if key_cell.enabled() { + NamingRoute::Key(facts.key_route_transport()) + } else if facts.onecli_marker { + NamingRoute::InheritedOneCli + } else { + NamingRoute::Unavailable + } +} + +// --------------------------------------------------------------------------- +// The pause +// --------------------------------------------------------------------------- + +/// Default re-probe after a capability failure. +pub const DEFAULT_CAPABILITY_REPROBE: Duration = Duration::from_secs(15 * 60); +/// Default first re-probe after a quota answer. +pub const DEFAULT_THROTTLE_REPROBE: Duration = Duration::from_secs(60); +/// The longest throttle re-probe. +pub const THROTTLE_REPROBE_CAP: Duration = Duration::from_secs(15 * 60); + +const CAPABILITY_REPROBE_ENV: &str = "FRESHELL_NAMING_CAPABILITY_REPROBE_MS"; +const THROTTLE_REPROBE_ENV: &str = "FRESHELL_NAMING_THROTTLE_REPROBE_MS"; + +/// The two re-probe windows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LatchWindows { + pub capability: Duration, + pub throttle_base: Duration, +} + +impl Default for LatchWindows { + fn default() -> Self { + Self { + capability: DEFAULT_CAPABILITY_REPROBE, + throttle_base: DEFAULT_THROTTLE_REPROBE, + } + } +} + +impl LatchWindows { + /// The windows from the Rust-only test seams (read once, at boot). + pub fn from_environment() -> Self { + Self::from_values( + std::env::var(CAPABILITY_REPROBE_ENV).ok().as_deref(), + std::env::var(THROTTLE_REPROBE_ENV).ok().as_deref(), + ) + } + + /// Parse the seam values: a positive millisecond count, else the default. + pub fn from_values(capability_ms: Option<&str>, throttle_ms: Option<&str>) -> Self { + fn millis(value: Option<&str>) -> Option { + value + .and_then(|value| value.trim().parse::().ok()) + .filter(|ms| *ms > 0) + .map(Duration::from_millis) + } + let defaults = Self::default(); + Self { + capability: millis(capability_ms).unwrap_or(defaults.capability), + throttle_base: millis(throttle_ms).unwrap_or(defaults.throttle_base), + } + } +} + +/// What kind of pause is in force. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PauseKind { + Capability, + Throttled, +} + +/// What one recorded outcome did to the pause (the caller logs it). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LatchTransition { + /// Nothing a log needs to say. + Unchanged, + /// Naming is newly paused, or paused for a different reason. + Paused { kind: PauseKind, code: String }, + /// A probe reached the provider: naming runs again. + Restored, + /// The attempt began before a key change: its outcome says nothing + /// about the current key, so the pause is left as it is. + Superseded, +} + +/// Which key change an attempt began under (see [`CapabilityLatch::epoch`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LatchEpoch(u64); + +/// The in-memory naming pause (see the module doc). Tokio's clock, so the +/// worker's sleeps and the windows agree. +pub struct CapabilityLatch { + windows: LatchWindows, + state: Mutex, +} + +#[derive(Default)] +struct LatchState { + pause: Option, + /// Consecutive throttled outcomes (sets the next throttle window). + throttle_streak: u32, + /// Bumped by every [`CapabilityLatch::clear`] (a key change). + epoch: u64, +} + +struct Pause { + kind: PauseKind, + code: String, + window: Duration, + until: Instant, +} + +impl CapabilityLatch { + pub fn new(windows: LatchWindows) -> Self { + Self { + windows, + state: Mutex::new(LatchState::default()), + } + } + + fn state(&self) -> std::sync::MutexGuard<'_, LatchState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether an attempt may run now: no pause, or its re-probe time came. + pub fn ready(&self, now: Instant) -> bool { + self.state() + .pause + .as_ref() + .is_none_or(|pause| now >= pause.until) + } + + /// Time left until the re-probe, when paused. + pub fn reprobe_in(&self, now: Instant) -> Option { + self.state() + .pause + .as_ref() + .map(|pause| pause.until.saturating_duration_since(now)) + } + + /// The key change in force now. Read it before an attempt builds its + /// request and hand it back with the outcome. + pub fn epoch(&self) -> LatchEpoch { + LatchEpoch(self.state().epoch) + } + + /// The pause in force, if any. + #[cfg(test)] + pub fn pause_kind(&self) -> Option { + self.state().pause.as_ref().map(|pause| pause.kind) + } + + /// Record a failed attempt that began under `began`. A capability + /// failure pauses for the capability window; a quota answer pauses for + /// the throttle window, doubling per consecutive throttled outcome up to + /// the cap. A failure that still got an HTTP answer reached the provider + /// and restores naming; one with no answer (a timeout, a broken + /// connection) proves nothing and only re-arms a pause already in force. + /// An attempt that began before a key change changes nothing + /// ([`LatchTransition::Superseded`]): it was judged on the old key. + pub fn record_failure( + &self, + failure: &GeminiFailure, + now: Instant, + began: LatchEpoch, + ) -> LatchTransition { + let mut state = self.state(); + if state.epoch != began.0 { + return LatchTransition::Superseded; + } + match failure.class { + GeminiFailureClass::Capability => { + state.throttle_streak = 0; + Self::pause( + &mut state, + PauseKind::Capability, + failure.code(), + self.windows.capability, + now, + ) + } + GeminiFailureClass::Throttled => { + state.throttle_streak = state.throttle_streak.saturating_add(1); + let doublings = state.throttle_streak.saturating_sub(1).min(31); + let window = self + .windows + .throttle_base + .saturating_mul(1u32 << doublings) + .min(THROTTLE_REPROBE_CAP); + Self::pause( + &mut state, + PauseKind::Throttled, + failure.code(), + window, + now, + ) + } + GeminiFailureClass::Transient | GeminiFailureClass::Invalid => { + if failure.status.is_some() { + return Self::restore(&mut state); + } + if let Some(pause) = state.pause.as_mut() { + pause.until = now + pause.window; + } + LatchTransition::Unchanged + } + } + } + + /// Record an attempt that began under `began` and whose request reached + /// the provider without being refused (an answer, an empty answer). One + /// that began before a key change changes nothing. + pub fn record_reachable(&self, began: LatchEpoch) -> LatchTransition { + let mut state = self.state(); + if state.epoch != began.0 { + return LatchTransition::Superseded; + } + Self::restore(&mut state) + } + + /// A key change: clear both pauses and start a new epoch, so outcomes of + /// attempts already in flight no longer count. `true` when a pause was + /// in force. + pub fn clear(&self) -> bool { + let mut state = self.state(); + state.epoch = state.epoch.wrapping_add(1); + state.throttle_streak = 0; + state.pause.take().is_some() + } + + fn pause( + state: &mut LatchState, + kind: PauseKind, + code: String, + window: Duration, + now: Instant, + ) -> LatchTransition { + let same = state + .pause + .as_ref() + .is_some_and(|pause| pause.kind == kind && pause.code == code); + state.pause = Some(Pause { + kind, + code: code.clone(), + window, + until: now + window, + }); + if same { + LatchTransition::Unchanged + } else { + LatchTransition::Paused { kind, code } + } + } + + fn restore(state: &mut LatchState) -> LatchTransition { + state.throttle_streak = 0; + if state.pause.take().is_some() { + LatchTransition::Restored + } else { + LatchTransition::Unchanged + } + } +} + +// --------------------------------------------------------------------------- +// A key saved in Settings +// --------------------------------------------------------------------------- + +/// The Settings save path for the Gemini key (blank never clears the cell). +/// The saved value joins the log writer's live secrets before anything can +/// log, and a CHANGED key tells the naming generator, which clears both +/// pauses and wakes the worker (an unchanged re-save, which every Settings +/// save performs, changes nothing). `true` when the key changed. +pub fn apply_saved_gemini_key( + key_cell: &AiKeyCell, + log_secrets: &freshell_runtime_observability::LiveSecrets, + generator: Option<&crate::session_name_generation::SessionNameGenerator>, + saved: Option<&str>, +) -> bool { + let Some(saved) = saved.filter(|key| !key.is_empty()) else { + return false; + }; + log_secrets.insert(saved); + let changed = key_cell.get().as_deref() != Some(saved); + key_cell.apply_settings_key_forced(Some(saved)); + if changed { + if let Some(generator) = generator { + generator.notify_capability_change(); + } + } + changed +} + +#[cfg(test)] +#[path = "session_name_capability_tests.rs"] +pub(crate) mod tests; diff --git a/crates/freshell-server/src/session_name_capability_tests.rs b/crates/freshell-server/src/session_name_capability_tests.rs new file mode 100644 index 000000000..fb4a76f8c --- /dev/null +++ b/crates/freshell-server/src/session_name_capability_tests.rs @@ -0,0 +1,676 @@ +//! Name-reliability Task 6: route selection, the naming pause, and the +//! session-name HTTP transport over each route. The fake Gemini and the fake +//! proxies are loopback axum servers; the "ambient" proxy a route would +//! honor is given to the normal client explicitly (never through the +//! process environment), and the same proxy URL feeds the route facts, as +//! the boot wiring derives both from one environment. No request leaves the +//! host. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::time::Instant; + +use super::{ + proxy_facts, select_naming_route, CapabilityLatch, KeyRouteTransport, LatchTransition, + LatchWindows, NamingRoute, PauseKind, ProxyFacts, SessionNameTransport, + DEFAULT_CAPABILITY_REPROBE, DEFAULT_THROTTLE_REPROBE, THROTTLE_REPROBE_CAP, +}; +use crate::ai_title::{ + AiKeyCell, BoxFuture, GeminiProxyEnvironment, GeminiSessionNameAuth, GeminiSessionNameHttp, + SESSION_TITLE_MAX_OUTPUT_TOKENS, +}; +use crate::gemini_failure::{classify_gemini_failure, GeminiFailure, GeminiFailureClass}; + +pub(crate) const GEMINI_GENERATE_PATH: &str = + "/v1beta/models/gemini-3.5-flash-lite:generateContent"; + +pub(crate) const ONECLI_ACCESS_RESTRICTED_BODY: &str = r#"{"error":"access_restricted","manage_url":"","message":"credentials exist in OneCLI but this agent does not have access","provider":"generativelanguage.googleapis.com"}"#; + +const GOOGLE_API_KEY_INVALID_BODY: &str = r#"{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID"}]}}"#; + +fn gemini_answer(text: &str) -> String { + serde_json::json!({ "candidates": [{ "content": { "parts": [{ "text": text }] } }] }) + .to_string() +} + +fn response(status: u16, body: String) -> axum::response::Response { + let mut response = axum::response::Response::new(axum::body::Body::from(body)); + *response.status_mut() = axum::http::StatusCode::from_u16(status).unwrap(); + response + .headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + response +} + +fn api_key_of(headers: &axum::http::HeaderMap) -> Option { + headers + .get("x-goog-api-key") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +/// A loopback fake Gemini. With `valid_key`, any other key answers Google's +/// 400 `API_KEY_INVALID` and no key answers 403; without it every request +/// gets `reply`. +pub(crate) struct FakeGemini { + pub base_url: String, + pub keys: Arc>>>, +} + +pub(crate) async fn start_fake_gemini(reply: &str, valid_key: Option<&str>) -> FakeGemini { + let keys = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&keys); + let reply = reply.to_string(); + let valid_key = valid_key.map(str::to_string); + let app = axum::Router::new().route( + GEMINI_GENERATE_PATH, + axum::routing::post(move |headers: axum::http::HeaderMap| { + let observed = Arc::clone(&observed); + let reply = reply.clone(); + let valid_key = valid_key.clone(); + async move { + let key = api_key_of(&headers); + observed.lock().unwrap().push(key.clone()); + match (&valid_key, &key) { + (Some(_), None) => response( + 403, + r#"{"error":{"code":403,"status":"PERMISSION_DENIED"}}"#.to_string(), + ), + (Some(valid), Some(key)) if valid != key => { + response(400, GOOGLE_API_KEY_INVALID_BODY.to_string()) + } + _ => response(200, gemini_answer(&reply)), + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + FakeGemini { + base_url: format!("http://{addr}/v1beta"), + keys, + } +} + +/// How the fake proxy answers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProxyMode { + /// Answer as the gateway's own credential would: the AI reply. + Ok, + /// The real gateway's refusal: 403 without a key, 400 with one. + AccessRestricted, +} + +/// One request the fake proxy saw (never its body or credentials). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ProxiedRequest { + pub path: String, + pub has_api_key: bool, +} + +/// A loopback forward proxy for `http://` targets. It records every request +/// it receives and answers itself (it never forwards). +pub(crate) struct FakeProxy { + pub port: u16, + pub requests: Arc>>, + pub mode: Arc>, +} + +impl FakeProxy { + /// A proxy URL carrying OneCLI's authorization marker (percent-encoded, + /// as the e2e fake does). + pub fn onecli_url(&self) -> String { + format!("http://%61oc_test_marker@127.0.0.1:{}", self.port) + } + + pub fn ordinary_url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + pub fn set_mode(&self, mode: ProxyMode) { + *self.mode.lock().unwrap() = mode; + } + + pub fn recorded(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +pub(crate) async fn start_fake_proxy(reply: &str, mode: ProxyMode) -> FakeProxy { + let requests = Arc::new(Mutex::new(Vec::new())); + let mode = Arc::new(Mutex::new(mode)); + let (observed, current_mode) = (Arc::clone(&requests), Arc::clone(&mode)); + let reply = reply.to_string(); + let app = axum::Router::new().fallback(move |request: axum::extract::Request| { + let observed = Arc::clone(&observed); + let current_mode = Arc::clone(¤t_mode); + let reply = reply.clone(); + async move { + let has_api_key = api_key_of(request.headers()).is_some(); + observed.lock().unwrap().push(ProxiedRequest { + path: request.uri().path().to_string(), + has_api_key, + }); + let mode = *current_mode.lock().unwrap(); + match (mode, has_api_key) { + (ProxyMode::Ok, _) => response(200, gemini_answer(&reply)), + (ProxyMode::AccessRestricted, false) => { + response(403, ONECLI_ACCESS_RESTRICTED_BODY.to_string()) + } + (ProxyMode::AccessRestricted, true) => { + response(400, ONECLI_ACCESS_RESTRICTED_BODY.to_string()) + } + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + FakeProxy { + port, + requests, + mode, + } +} + +/// The session-name transport the boot wiring builds for `key` under an +/// ambient `http_proxy` (None = no proxy): the route facts come from that +/// environment and the normal client honors the same proxy. +pub(crate) fn session_name_http( + key: AiKeyCell, + gemini_base_url: &str, + http_proxy: Option<&str>, +) -> (GeminiSessionNameAuth, Arc) { + let environment = GeminiProxyEnvironment::literal(None, http_proxy, None, None, false); + let auth = GeminiSessionNameAuth::from_proxy_environment(key, gemini_base_url, &environment); + let client = match http_proxy { + Some(proxy) => reqwest::Client::builder().proxy(reqwest::Proxy::http(proxy).unwrap()), + None => reqwest::Client::builder().no_proxy(), + }; + let http = Arc::new(GeminiSessionNameHttp::new( + client, + auth.clone(), + gemini_base_url.to_string(), + )); + (auth, http) +} + +async fn generate(http: &GeminiSessionNameHttp) -> Result { + SessionNameTransport::generate_content( + http, + "Name this session".to_string(), + SESSION_TITLE_MAX_OUTPUT_TOKENS, + ) + .await +} + +// --------------------------------------------------------------------------- +// Route selection over the real HTTP transport +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn configured_key_goes_direct_when_the_ambient_proxy_is_onecli() { + let gemini = start_fake_gemini("Flux repair", None).await; + let proxy = start_fake_proxy("Proxy answer", ProxyMode::Ok).await; + let key = AiKeyCell::init(Some("configured-key-value".to_string()), None); + let (auth, http) = session_name_http(key, &gemini.base_url, Some(&proxy.onecli_url())); + assert_eq!(auth.route(), NamingRoute::Key(KeyRouteTransport::Direct)); + + assert_eq!(generate(&http).await.unwrap(), "Flux repair"); + assert_eq!( + *gemini.keys.lock().unwrap(), + vec![Some("configured-key-value".to_string())], + "exactly one keyed request reaches Gemini directly" + ); + assert!( + proxy.recorded().is_empty(), + "the OneCLI proxy sees no request: {:?}", + proxy.recorded() + ); +} + +#[tokio::test] +async fn configured_key_uses_an_ordinary_ambient_proxy() { + let gemini = start_fake_gemini("Direct answer", None).await; + let proxy = start_fake_proxy("Proxied answer", ProxyMode::Ok).await; + let key = AiKeyCell::init(Some("configured-key-value".to_string()), None); + let (auth, http) = session_name_http(key, &gemini.base_url, Some(&proxy.ordinary_url())); + assert_eq!( + auth.route(), + NamingRoute::Key(KeyRouteTransport::AmbientProxy) + ); + + assert_eq!(generate(&http).await.unwrap(), "Proxied answer"); + assert_eq!( + proxy.recorded(), + vec![ProxiedRequest { + path: GEMINI_GENERATE_PATH.to_string(), + has_api_key: true, + }], + "the keyed request goes through the ordinary proxy" + ); + assert!(gemini.keys.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn settings_key_is_used_when_no_env_key() { + let gemini = start_fake_gemini("Settings answer", Some("settings-only-key")).await; + let key = AiKeyCell::init(None, Some("settings-only-key".to_string())); + let (auth, http) = session_name_http(key, &gemini.base_url, None); + assert_eq!( + auth.route(), + NamingRoute::Key(KeyRouteTransport::AmbientProxy) + ); + assert_eq!(generate(&http).await.unwrap(), "Settings answer"); + assert_eq!( + *gemini.keys.lock().unwrap(), + vec![Some("settings-only-key".to_string())] + ); +} + +#[tokio::test] +async fn no_key_uses_the_inherited_onecli_proxy_without_a_key() { + let gemini = start_fake_gemini("Direct answer", None).await; + let proxy = start_fake_proxy("Gateway answer", ProxyMode::Ok).await; + let (auth, http) = session_name_http( + AiKeyCell::default(), + &gemini.base_url, + Some(&proxy.onecli_url()), + ); + assert_eq!(auth.route(), NamingRoute::InheritedOneCli); + assert!(auth.enabled()); + + assert_eq!(generate(&http).await.unwrap(), "Gateway answer"); + assert_eq!( + proxy.recorded(), + vec![ProxiedRequest { + path: GEMINI_GENERATE_PATH.to_string(), + has_api_key: false, + }] + ); + assert!(gemini.keys.lock().unwrap().is_empty()); + + // A refusing gateway is a classified capability failure, one request, + // no retry. + proxy.set_mode(ProxyMode::AccessRestricted); + let failure = generate(&http).await.expect_err("the gateway refuses"); + assert_eq!(failure.class, GeminiFailureClass::Capability); + assert_eq!(failure.code(), "access_restricted"); + assert_eq!(proxy.recorded().len(), 2); +} + +#[tokio::test] +async fn no_credential_route_sends_nothing() { + let gemini = start_fake_gemini("Unused", None).await; + let (auth, http) = session_name_http(AiKeyCell::default(), &gemini.base_url, None); + assert_eq!(auth.route(), NamingRoute::Unavailable); + assert!(!auth.enabled()); + let failure = generate(&http).await.expect_err("no route"); + assert_eq!(failure, GeminiFailure::no_credential()); + assert!(gemini.keys.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn keyed_failures_are_classified_on_the_direct_route() { + let gemini = start_fake_gemini("Unused", Some("the-valid-key")).await; + let proxy = start_fake_proxy("Unused", ProxyMode::AccessRestricted).await; + let key = AiKeyCell::init(None, Some("a-wrong-key".to_string())); + let (_auth, http) = session_name_http(key, &gemini.base_url, Some(&proxy.onecli_url())); + let failure = generate(&http).await.expect_err("Gemini refuses the key"); + assert_eq!(failure.class, GeminiFailureClass::Capability); + assert_eq!(failure.code(), "API_KEY_INVALID"); + assert_eq!(failure.status, Some(400)); + assert!(proxy.recorded().is_empty()); +} + +#[test] +fn proxy_facts_follow_the_effective_proxy_and_no_proxy() { + let url = "http://generativelanguage.googleapis.com/v1beta"; + let https_url = "https://generativelanguage.googleapis.com/v1beta"; + let env = |https: Option<&str>, http: Option<&str>, no: Option<&str>| { + GeminiProxyEnvironment::literal(https, http, None, no, false) + }; + assert_eq!( + proxy_facts(&env(None, Some("http://aoc_x@127.0.0.1:1"), None), url), + ProxyFacts { + via_proxy: true, + onecli_marker: true, + } + ); + assert_eq!( + proxy_facts(&env(None, Some("http://127.0.0.1:1"), None), url), + ProxyFacts { + via_proxy: true, + onecli_marker: false, + } + ); + assert_eq!( + proxy_facts(&env(Some("http://aoc_x@127.0.0.1:1"), None, None), url), + ProxyFacts::default(), + "an https proxy does not apply to an http URL" + ); + assert_eq!( + proxy_facts( + &env( + Some("http://aoc_x@127.0.0.1:1"), + None, + Some(".googleapis.com") + ), + https_url + ), + ProxyFacts::default(), + "NO_PROXY bypasses the proxy" + ); + assert_eq!( + ProxyFacts { + via_proxy: true, + onecli_marker: true, + } + .key_route_transport(), + KeyRouteTransport::Direct + ); + assert_eq!( + ProxyFacts::default().key_route_transport(), + KeyRouteTransport::AmbientProxy + ); +} + +#[test] +fn select_naming_route_prefers_a_configured_key() { + let onecli = ProxyFacts { + via_proxy: true, + onecli_marker: true, + }; + let ordinary = ProxyFacts { + via_proxy: true, + onecli_marker: false, + }; + let key = AiKeyCell::init(Some("k".repeat(12)), None); + let none = AiKeyCell::default(); + assert_eq!( + select_naming_route(&key, onecli), + NamingRoute::Key(KeyRouteTransport::Direct) + ); + assert_eq!( + select_naming_route(&key, ordinary), + NamingRoute::Key(KeyRouteTransport::AmbientProxy) + ); + assert_eq!( + select_naming_route(&none, onecli), + NamingRoute::InheritedOneCli + ); + assert_eq!( + select_naming_route(&none, ordinary), + NamingRoute::Unavailable + ); + // A Settings save takes effect at the next selection. + none.apply_settings_key_forced(Some("saved-key-value")); + assert_eq!( + select_naming_route(&none, onecli), + NamingRoute::Key(KeyRouteTransport::Direct) + ); + assert!(!NamingRoute::Key(KeyRouteTransport::Direct).request_via_proxy(onecli)); + assert!(NamingRoute::Key(KeyRouteTransport::AmbientProxy).request_via_proxy(ordinary)); + assert!( + !NamingRoute::Key(KeyRouteTransport::AmbientProxy).request_via_proxy(ProxyFacts::default()) + ); + assert!(NamingRoute::InheritedOneCli.request_via_proxy(onecli)); +} + +// --------------------------------------------------------------------------- +// The pause +// --------------------------------------------------------------------------- + +fn capability(code: &str) -> GeminiFailure { + classify_gemini_failure(403, format!(r#"{{"error":"{code}"}}"#).as_bytes()) +} + +fn throttled() -> GeminiFailure { + classify_gemini_failure(429, b"") +} + +fn windows(capability_ms: u64, throttle_ms: u64) -> LatchWindows { + LatchWindows { + capability: Duration::from_millis(capability_ms), + throttle_base: Duration::from_millis(throttle_ms), + } +} + +#[test] +fn latch_windows_read_the_test_seams() { + assert_eq!( + LatchWindows::from_values(None, None), + LatchWindows { + capability: DEFAULT_CAPABILITY_REPROBE, + throttle_base: DEFAULT_THROTTLE_REPROBE, + } + ); + assert_eq!(DEFAULT_CAPABILITY_REPROBE, Duration::from_secs(900)); + assert_eq!(DEFAULT_THROTTLE_REPROBE, Duration::from_secs(60)); + assert_eq!( + LatchWindows::from_values(Some("2000"), Some("20")), + windows(2000, 20) + ); + // Garbage and zero fall back to the defaults. + assert_eq!( + LatchWindows::from_values(Some("0"), Some("soon")), + LatchWindows::default() + ); +} + +#[test] +fn a_capability_failure_pauses_until_the_reprobe_time() { + let latch = CapabilityLatch::new(windows(50, 20)); + let t0 = Instant::now(); + assert!(latch.ready(t0)); + assert_eq!( + latch.record_failure(&capability("access_restricted"), t0, latch.epoch()), + LatchTransition::Paused { + kind: PauseKind::Capability, + code: "access_restricted".to_string(), + } + ); + assert_eq!(latch.pause_kind(), Some(PauseKind::Capability)); + assert!(!latch.ready(t0 + Duration::from_millis(49))); + assert_eq!( + latch.reprobe_in(t0 + Duration::from_millis(10)), + Some(Duration::from_millis(40)) + ); + assert!( + latch.ready(t0 + Duration::from_millis(50)), + "one probe per window" + ); + + // The probe fails the same way: no new transition, a new window. + let t1 = t0 + Duration::from_millis(50); + assert_eq!( + latch.record_failure(&capability("access_restricted"), t1, latch.epoch()), + LatchTransition::Unchanged + ); + assert!(!latch.ready(t1 + Duration::from_millis(49))); + // A different code is a new transition. + assert_eq!( + latch.record_failure(&capability("API_KEY_INVALID"), t1, latch.epoch()), + LatchTransition::Paused { + kind: PauseKind::Capability, + code: "API_KEY_INVALID".to_string(), + } + ); + // A transport failure with no answer keeps the pause and re-arms it. + let t2 = t1 + Duration::from_millis(60); + assert!(latch.ready(t2)); + let timeout = crate::gemini_failure::classify_transport_error( + &std::io::Error::other("timed out"), + false, + true, + false, + ); + assert_eq!( + latch.record_failure(&timeout, t2, latch.epoch()), + LatchTransition::Unchanged + ); + assert!(!latch.ready(t2 + Duration::from_millis(10))); + // A probe that reaches the provider restores naming. + assert_eq!( + latch.record_reachable(latch.epoch()), + LatchTransition::Restored + ); + assert!(latch.ready(t2)); + assert_eq!(latch.pause_kind(), None); + assert_eq!( + latch.record_reachable(latch.epoch()), + LatchTransition::Unchanged + ); + // An HTTP answer that is neither refused nor throttled also restores. + latch.record_failure(&capability("access_restricted"), t2, latch.epoch()); + assert_eq!( + latch.record_failure(&classify_gemini_failure(500, b""), t2, latch.epoch()), + LatchTransition::Restored + ); +} + +#[test] +fn throttle_reprobe_doubles_caps_and_resets() { + let latch = CapabilityLatch::new(windows(50, 20)); + let mut now = Instant::now(); + assert_eq!( + latch.record_failure(&throttled(), now, latch.epoch()), + LatchTransition::Paused { + kind: PauseKind::Throttled, + code: "throttled".to_string(), + } + ); + let mut expected = Vec::new(); + for _ in 0..4 { + let window = latch.reprobe_in(now).unwrap(); + expected.push(window.as_millis()); + now += window; + assert!(latch.ready(now)); + assert_eq!( + latch.record_failure(&throttled(), now, latch.epoch()), + LatchTransition::Unchanged + ); + } + assert_eq!(expected, vec![20, 40, 80, 160]); + // One success resets the base. + assert_eq!( + latch.record_reachable(latch.epoch()), + LatchTransition::Restored + ); + latch.record_failure(&throttled(), now, latch.epoch()); + assert_eq!(latch.reprobe_in(now), Some(Duration::from_millis(20))); + // A key change clears the pause and the streak. + assert!(latch.clear()); + assert!(latch.ready(now)); + assert!(!latch.clear()); + latch.record_failure(&throttled(), now, latch.epoch()); + assert_eq!(latch.reprobe_in(now), Some(Duration::from_millis(20))); + + // The window never exceeds the cap. + let capped = CapabilityLatch::new(windows(50, 400_000)); + let mut now = Instant::now(); + for _ in 0..4 { + capped.record_failure(&throttled(), now, capped.epoch()); + now += capped.reprobe_in(now).unwrap(); + } + capped.record_failure(&throttled(), now, capped.epoch()); + assert_eq!(capped.reprobe_in(now), Some(THROTTLE_REPROBE_CAP)); +} + +/// An attempt that began before a key change says nothing about the new +/// key: its refusal does not pause naming, its timeout does not re-arm a +/// pause, and its answer does not lift one. +#[test] +fn an_outcome_from_before_a_key_change_leaves_the_pause_alone() { + let latch = CapabilityLatch::new(windows(600_000, 600_000)); + let now = Instant::now(); + let old_key = latch.epoch(); + assert!(!latch.clear(), "the key changes while nothing is paused"); + let new_key = latch.epoch(); + assert_ne!(old_key, new_key); + + assert_eq!( + latch.record_failure(&capability("access_restricted"), now, old_key), + LatchTransition::Superseded + ); + assert_eq!( + latch.record_failure(&throttled(), now, old_key), + LatchTransition::Superseded + ); + assert!(latch.ready(now), "the new key is tried at once"); + assert_eq!(latch.pause_kind(), None); + + // A pause the new key earned is not touched by the old key's outcomes. + assert_eq!( + latch.record_failure(&capability("API_KEY_INVALID"), now, new_key), + LatchTransition::Paused { + kind: PauseKind::Capability, + code: "API_KEY_INVALID".to_string(), + } + ); + assert_eq!(latch.record_reachable(old_key), LatchTransition::Superseded); + let later = now + Duration::from_secs(600); + let timeout = crate::gemini_failure::classify_transport_error( + &std::io::Error::other("timed out"), + false, + true, + false, + ); + assert_eq!( + latch.record_failure(&timeout, later, old_key), + LatchTransition::Superseded + ); + assert!( + latch.ready(later), + "the old key's timeout did not re-arm the window" + ); + assert_eq!(latch.pause_kind(), Some(PauseKind::Capability)); + assert_eq!( + latch.record_reachable(new_key), + LatchTransition::Restored, + "the new key's own answer still restores naming" + ); +} + +/// A counting transport for tests: answers come from a script, one per call +/// (the last entry repeats). +pub(crate) struct ScriptedTransport { + pub script: Mutex>>, + pub calls: AtomicU32, + pub call_times: Mutex>, +} + +impl ScriptedTransport { + pub fn new(script: Vec>) -> Arc { + Arc::new(Self { + script: Mutex::new(script), + calls: AtomicU32::new(0), + call_times: Mutex::new(Vec::new()), + }) + } + + pub fn calls(&self) -> u32 { + self.calls.load(Ordering::SeqCst) + } +} + +impl SessionNameTransport for ScriptedTransport { + fn generate_content( + &self, + _prompt: String, + _max_output_tokens: u32, + ) -> BoxFuture> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.call_times.lock().unwrap().push(Instant::now()); + let mut script = self.script.lock().unwrap(); + let answer = if script.len() > 1 { + script.remove(0) + } else { + script.first().cloned().unwrap_or_else(|| Ok(String::new())) + }; + Box::pin(async move { answer }) + } +} diff --git a/crates/freshell-server/src/session_name_continuation.rs b/crates/freshell-server/src/session_name_continuation.rs new file mode 100644 index 000000000..c9dbc47c3 --- /dev/null +++ b/crates/freshell-server/src/session_name_continuation.rs @@ -0,0 +1,106 @@ +//! Admission of a known session and one-time name inheritance for an +//! explicit internal continuation. Neither operation creates redirects +//! between durable identities or changes an existing target's saved name. + +use super::*; + +fn durable_provider(target: &SessionNameRef) -> Result { + match target { + SessionNameRef::Session { + provider, + session_id, + } if !session_id.trim().is_empty() => Ok(*provider), + _ => Err(NameError::InvalidName( + "a durable nonempty session target is required".into(), + )), + } +} + +pub(super) fn ensure_session( + document: &mut StoredDocument, + target: SessionNameRef, + cwd: Option, +) -> Result, NameError> { + let provider = durable_provider(&target)?; + let key = name_ref_key(&target); + if document.record_at(&key).is_some() { + return Ok(Decision::Read(read_update(document, &key))); + } + let revision = document.allocate_revision()?; + document.records.insert( + key.clone(), + SessionNameRecord { + name_ref: target, + name: directory_fallback_name(cwd.as_deref(), &provider), + source: NameSource::Directory, + revision, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }, + ); + Ok(commit_decision( + document, + &key, + Some(NameChangeEvent::new( + NameChangeCause::EnsureSession, + None, + None, + )), + )) +} + +pub(super) fn continue_session( + document: &mut StoredDocument, + now_ms: i64, + source: SessionNameRef, + target: SessionNameRef, +) -> Result>, NameError> { + let provider = durable_provider(&target)?; + let key = name_ref_key(&target); + if document.record_at(&key).is_some() { + return Ok(Decision::Read(Some(read_update(document, &key)))); + } + let (resolved, _) = document.resolve_ref(&source); + if let SessionNameRef::Session { + provider: source_provider, + .. + } = &resolved + { + if *source_provider != provider { + return Err(NameError::IdentityAmbiguous( + "an internal continuation must keep its provider".into(), + )); + } + } + let source_key = name_ref_key(&resolved); + let Some(mut record) = document.record_at(&source_key).cloned() else { + return Ok(Decision::Read(None)); + }; + record.name_ref = target; + record.revision = document.allocate_revision()?; + document.records.insert(key.clone(), record.clone()); + // A continuation keeps the source's generation history (including an + // exhausted series); it is not a new opportunity to rename a session. + if let Some(mut series) = document.generation.get(&source_key).cloned() { + // The worker's live request belongs only to the source key. The + // copied child must settle that inherited start as interrupted, + // retaining its bounded accounting instead of waiting forever. + if series.status == GenerationStatus::InFlight { + session_name_generation_series::settle_consumed_start(&mut series, now_ms); + } + document.generation.insert(key.clone(), series); + } + if source_is_writable(record.source) { + reset_native_series(document, &key, &record); + } + let change = NameChangeEvent::new(NameChangeCause::InternalContinuation, None, None) + .with_from_ref(&source_key); + Ok(Decision::Write { + value: Some( + update_after_commit(document, &key, true, RedirectScope::None) + .expect("the inserted child resolves"), + ), + publish: vec![PublishSpec::changed(key, change)], + }) +} diff --git a/crates/freshell-server/src/session_name_continuation_tests.rs b/crates/freshell-server/src/session_name_continuation_tests.rs new file mode 100644 index 000000000..ee70279de --- /dev/null +++ b/crates/freshell-server/src/session_name_continuation_tests.rs @@ -0,0 +1,169 @@ +//! Real-store admission and explicit internal-continuation behavior. + +use super::*; + +fn session(id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: id.into(), + } +} + +#[tokio::test] +async fn known_session_admission_is_idempotent_and_rename_keeps_revision_guards() { + let home = tempfile::tempdir().unwrap(); + let names = SessionNames::open(home.path().to_path_buf()).unwrap(); + let first = names + .ensure_session(session("switched"), Some("/work/billing".into())) + .await + .unwrap(); + assert_eq!(first.record.name, "billing"); + assert_eq!(first.record.source, NameSource::Directory); + let renamed = names + .rename(RenameNameInput { + target: session("switched"), + name: "Invoices".into(), + intent: NameIntent::User, + if_revision: Some(first.record.revision), + cwd: None, + }) + .await + .unwrap(); + let again = names + .ensure_session(session("switched"), Some("/other".into())) + .await + .unwrap(); + assert_eq!(again.record, renamed.record); + assert!(matches!( + names + .rename(RenameNameInput { + target: session("switched"), + name: "Stale".into(), + intent: NameIntent::User, + if_revision: Some(first.record.revision), + cwd: None, + }) + .await, + Err(NameError::Conflict { .. }) + )); +} + +#[tokio::test] +async fn internal_continuation_preserves_manual_name_once_without_aliasing_sessions() { + let home = tempfile::tempdir().unwrap(); + let names = SessionNames::open(home.path().to_path_buf()).unwrap(); + names + .ensure_pending(PendingNameInput { + handle: "source".into(), + provider: NamedProvider::Claude, + cwd: None, + }) + .await + .unwrap(); + let pending = SessionNameRef::Pending { + id: "source".into(), + }; + let original = names + .rename(RenameNameInput { + target: pending.clone(), + name: "Keep this name".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + let child = names + .continue_session(pending.clone(), session("child")) + .await + .unwrap() + .unwrap(); + assert_eq!(child.record.name, original.record.name); + assert_eq!(child.record.source, NameSource::Manual); + assert_eq!( + child.record.manual_revision, + original.record.manual_revision + ); + assert_eq!(child.record.renamed_at, original.record.renamed_at); + assert_eq!(child.record.name_ref, session("child")); + assert!(child.redirects.is_empty()); + names + .rename(RenameNameInput { + target: pending.clone(), + name: "Changed source".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + let again = names + .continue_session(pending, session("child")) + .await + .unwrap() + .unwrap(); + assert_eq!( + again.record, child.record, + "repeated continuation must not replace a child's independent name" + ); + let reopened = SessionNames::open(home.path().to_path_buf()).unwrap(); + assert_eq!( + reopened.get(vec![session("child")]).await.unwrap()[0].record, + child.record + ); +} + +#[tokio::test] +async fn an_unnamed_continuation_does_not_invent_or_copy_a_name() { + let home = tempfile::tempdir().unwrap(); + let names = SessionNames::open(home.path().to_path_buf()).unwrap(); + assert!(names + .continue_session(session("unknown"), session("child")) + .await + .unwrap() + .is_none()); + assert!(names.get(vec![session("child")]).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn continuation_cannot_inherit_an_inflight_request_that_only_belongs_to_its_source() { + let home = tempfile::tempdir().unwrap(); + let names = SessionNames::open(home.path().to_path_buf()).unwrap(); + names.ensure_session(session("source"), None).await.unwrap(); + names + .activity(NameActivity { + target: session("source"), + mode: "freshclaude".into(), + event_id: "first".into(), + reason: NameActivityReason::AcceptedUserMessage, + first_user_message: Some("Explore the cache layer".into()), + cwd: None, + }) + .await + .unwrap(); + names + .claim_generation_start(session("source"), "inflight-attempt".into()) + .await + .unwrap() + .unwrap(); + names + .continue_session(session("source"), session("child")) + .await + .unwrap() + .unwrap(); + let view = names.core.current_view(); + let source = &view.document.generation[&name_ref_key(&session("source"))]; + let child = &view.document.generation[&name_ref_key(&session("child"))]; + assert_eq!( + source.status, + GenerationStatus::InFlight, + "the source's request is still live" + ); + assert_eq!( + child.status, + GenerationStatus::Eligible, + "the child's series must remain retryable without a server restart" + ); + assert_eq!(child.consumed, source.consumed); + assert_eq!(child.excerpt, source.excerpt); +} diff --git a/crates/freshell-server/src/session_name_events.rs b/crates/freshell-server/src/session_name_events.rs new file mode 100644 index 000000000..dd2468ba1 --- /dev/null +++ b/crates/freshell-server/src/session_name_events.rs @@ -0,0 +1,449 @@ +//! Name-reliability Task 12 (D11): one structured INFO line per accepted +//! saved-name change, so a name's history can be rebuilt from the log. +//! +//! A store decision that changes a record attaches a [`NameChangeEvent`] to +//! the record's publish entry: why it changed (the [`NameChangeCause`]) and +//! the record as it stood before the decision touched it. After the durable +//! replace succeeds, the transaction runner hands those entries to +//! [`log_name_changes`], which writes `session_names.name_changed` for each +//! record whose saved name visibly changed: the record is new, or its name +//! or source differs from before. A failed commit logs nothing; a decision +//! that changes only bookkeeping (a generation advance, a native status) +//! attaches no event. Changes another process committed are logged once +//! when this store adopts them ([`AdoptedChanges`], cause +//! `adopted_external`) — never for the document this store loads at open. +//! A commit whose durable replace ends uncertain (the replacement landed, +//! its durability is unproven) logs nothing when it is made; the next +//! transaction that reconciles and adopts or commits over that document +//! logs its changes with their own causes ([`UncertainCommits`]), so this +//! store's own change is never reported as another process's. +//! +//! The line carries the record key (`name_ref`), the old and new name, +//! source and revision (the old ones are absent when the record is new), +//! the cause, the self-heal flag, and `from_ref` / `import_id` / +//! `evidence_key` / `series` when they apply. Names are logged by design: +//! they are what a reader needs to rebuild a name's history, they are the +//! same text the name document and every naming surface already show, and +//! every line passes the log writer's secret scrubber. Saved names are the +//! only prompt-derived text these lines carry: a saved name is at most 200 +//! characters, and a `first_message` name is the first words of the +//! session's first user message, cut to 50 characters. +//! +//! `self_heal` is true exactly when an automatic offer — a native +//! observation, a first-message fallback, the session index (`hydrate`) or +//! a bind's pending name (`bind_collision`) — replaced a self-heal record +//! (an unprotected saved name the default/derived-name classifier +//! recognizes, with the folder the offer knew) by a name whose source +//! ranks the same or lower. Outside the self-heal rule an automatic offer +//! applies only by raising the rank (or, for a provider's changed native +//! title, at equal rank over an older provider-level name), so the flag +//! marks the junk names the rule replaced. A change that raises the source +//! rank (a folder name giving way to the first message) is `false` even +//! when the replaced name was a self-heal record, whichever path made it, +//! so the same before/after pair always logs the same flag. Renames, AI +//! answers, imports and adopted external changes are always `false`. + +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard}; + +use freshell_protocol::session_names::{NameRevision, NameSource, SessionNameRecord}; + +use super::session_name_apply::wire_source; +use super::session_name_policy::is_self_heal_record; +use super::{source_rank, StoredDocument}; + +/// Why a saved name changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum NameChangeCause { + /// The one-time legacy consolidation installed a migrated name. + Import, + /// A provider reported a native title. + NativeObservation, + /// The session's first user message became its name. + FirstMessageFallback, + /// Freshell AI naming answered. + Generation, + /// A person renamed the session. + ManualRename, + /// An agent or tool renamed the session without user intent. + AutomationRename, + /// A pending handle was bound to a durable session that had no record. + Bind, + /// A pending handle was bound to a durable session that had a record, + /// and the pending record's name won. + BindCollision, + /// A new terminal got its pending record (the folder name). + EnsurePending, + /// A known session was named before its index row arrived. + EnsureSession, + /// An undo/redo replacement inherited its source's name once. + InternalContinuation, + /// The session index created or healed the session's record. + Hydrate, + /// Another process committed the change; this store adopted it. + AdoptedExternal, +} + +impl NameChangeCause { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Import => "import", + Self::NativeObservation => "native_observation", + Self::FirstMessageFallback => "first_message_fallback", + Self::Generation => "generation", + Self::ManualRename => "manual_rename", + Self::AutomationRename => "automation_rename", + Self::Bind => "bind", + Self::BindCollision => "bind_collision", + Self::EnsurePending => "ensure_pending", + Self::EnsureSession => "ensure_session", + Self::InternalContinuation => "internal_continuation", + Self::Hydrate => "hydrate", + Self::AdoptedExternal => "adopted_external", + } + } + + /// The internal `offer` seam names its offer only by source. + pub(super) fn of_offer(source: NameSource) -> Self { + match source { + NameSource::Manual => Self::ManualRename, + NameSource::FreshellAi => Self::Generation, + NameSource::ProviderAi => Self::NativeObservation, + NameSource::FirstMessage => Self::FirstMessageFallback, + NameSource::Directory => Self::Hydrate, + NameSource::LegacyProtected => Self::Import, + } + } + + /// The automatic offers the self-heal rule governs + /// (`session_name_policy`). User and automation renames, AI answers and + /// imports follow their own rules, so they never heal. + fn can_heal(self) -> bool { + matches!( + self, + Self::NativeObservation + | Self::FirstMessageFallback + | Self::Hydrate + | Self::BindCollision + ) + } +} + +/// A record's saved name at one revision. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NameState { + name: String, + source: NameSource, + revision: NameRevision, +} + +impl NameState { + fn of(record: &SessionNameRecord) -> Self { + Self { + name: record.name.clone(), + source: record.source, + revision: record.revision, + } + } + + /// The name or the source moved (a revision alone is not a visible + /// change). + fn differs_from(&self, other: &Self) -> bool { + self.name != other.name || self.source != other.source + } +} + +/// The record a change replaced, captured before the decision mutated it. +#[derive(Debug, Clone)] +struct Prior { + state: NameState, + /// Whether the self-heal rule recognizes it as junk under the folder + /// the deciding offer knew. + self_heal_record: bool, +} + +/// One decision's change to one record (see the module docs). +#[derive(Debug, Clone)] +pub(super) struct NameChangeEvent { + cause: NameChangeCause, + prior: Option, + from_ref: Option, + import_id: Option, + evidence_key: Option, + series: Option, +} + +impl NameChangeEvent { + /// A change of `prior` (`None`: the decision creates the record). `cwd` + /// is the folder the decision classified names with, so the self-heal + /// flag uses the same context as the self-heal rule did. + pub(super) fn new( + cause: NameChangeCause, + prior: Option<&SessionNameRecord>, + cwd: Option<&str>, + ) -> Self { + Self { + cause, + prior: prior.map(|record| Prior { + state: NameState::of(record), + self_heal_record: is_self_heal_record(record, cwd), + }), + from_ref: None, + import_id: None, + evidence_key: None, + series: None, + } + } + + /// The pending handle a bind moved into the record. + pub(super) fn with_from_ref(mut self, pending_key: &str) -> Self { + self.from_ref = Some(pending_key.to_string()); + self + } + + /// The import and the evidence a migrated name came from. + pub(super) fn with_import(mut self, import_id: &str, evidence_key: &str) -> Self { + self.import_id = Some(import_id.to_string()); + self.evidence_key = Some(evidence_key.to_string()); + self + } + + /// The generation series whose answer was accepted. + pub(super) fn with_series(mut self, series_id: &str) -> Self { + self.series = Some(series_id.to_string()); + self + } + + /// This change, measured from where `earlier` — an earlier change of the + /// same record in the same transaction — started. + pub(super) fn after(mut self, earlier: Option) -> Self { + if let Some(earlier) = earlier { + self.prior = earlier.prior; + } + self + } + + /// Whether the change replaced junk only because of the self-heal rule: + /// an automatic offer over a self-heal record that did not outrank it + /// (an ordinary rank raise, such as a folder name giving way to the + /// first message, is not a heal — also when the session index made it, + /// so the flag depends on the before/after pair, never on the path; see + /// the module docs). + fn heals(&self, new: &NameState) -> bool { + self.cause.can_heal() + && self.prior.as_ref().is_some_and(|prior| { + prior.self_heal_record && source_rank(new.source) <= source_rank(prior.state.source) + }) + } +} + +/// One `session_names.name_changed` line waiting to be written: the record +/// key, the change, and the saved name it ends at. +type PendingLine = (String, NameChangeEvent, NameState); + +/// Log the changes a committed transaction made. A record changed by more +/// than one decision in the transaction (an import with several winning +/// candidates for one session) logs once: the last change measured from +/// where the first one started ([`NameChangeEvent::after`], the same merge +/// the sweep batch applies per record), so the old name and the self-heal +/// flag are both judged against the record before the transaction. +pub(super) fn log_name_changes<'a>( + document: &StoredDocument, + changes: impl IntoIterator, +) { + for (key, event, new) in &change_lines(document, changes) { + emit(key, event, new); + } +} + +/// The lines [`log_name_changes`] writes, one per changed record. +fn change_lines<'a>( + document: &StoredDocument, + changes: impl IntoIterator, +) -> Vec { + let mut order: Vec<&str> = Vec::new(); + let mut merged: BTreeMap<&str, NameChangeEvent> = BTreeMap::new(); + for (key, event) in changes { + let earlier = merged.remove(key); + if earlier.is_none() { + order.push(key); + } + merged.insert(key, event.clone().after(earlier)); + } + order + .into_iter() + .filter_map(|key| { + let record = document.record_at(key)?; + let event = merged.remove(key)?; + Some((key.to_string(), event, NameState::of(record))) + }) + .collect() +} + +/// The visible record changes between `adopted` and a newer document +/// another participant committed. Nothing when `adopted` is the pre-load +/// placeholder (generation 0: opening a store loads the document, it adopts +/// no change) or `next` is not newer. +fn adopted_external(adopted: &StoredDocument, next: &StoredDocument) -> Vec { + if adopted.document_generation == 0 || next.document_generation <= adopted.document_generation { + return Vec::new(); + } + next.records + .iter() + .filter_map(|(key, record)| { + let old = adopted.records.get(key); + let visible = + old.is_none_or(|old| old.name != record.name || old.source != record.source); + visible.then(|| { + ( + key.clone(), + NameChangeEvent::new(NameChangeCause::AdoptedExternal, old, None), + NameState::of(record), + ) + }) + }) + .collect() +} + +/// The name changes a transaction brings in when it reads a newer document +/// than this store's adopted view: this store's own commit whose replace +/// ended uncertain (with its own causes) and other participants' commits +/// (`adopted_external`). Captured when the transaction reads the document, +/// before its decision mutates it; written once the transaction adopts or +/// commits over that document ([`UncertainCommits::log_adopted`]). +#[derive(Debug, Default)] +pub(super) struct AdoptedChanges { + lines: Vec, +} + +/// This store's own commit whose durable replace ended uncertain +/// (`NameError::CommitUncertain`: the replacement landed, its durability is +/// unproven), with the lines its transaction did not write — what that +/// transaction adopted, then its own changes. +#[derive(Debug)] +struct UncertainCommit { + document: StoredDocument, + digest: [u8; 32], + lines: Vec, +} + +impl UncertainCommit { + /// Whether `next`, read over the view `adopted`, holds this commit: the + /// view is still older than it, and `next` is either these exact bytes + /// or a later document (only built on top of them — this store held + /// the lock when the replacement landed). + fn is_held_by( + &self, + adopted: &StoredDocument, + next: &StoredDocument, + digest: &[u8; 32], + ) -> bool { + let generation = self.document.document_generation; + adopted.document_generation < generation + && match next.document_generation.cmp(&generation) { + Ordering::Less => false, + Ordering::Equal => *digest == self.digest, + Ordering::Greater => true, + } + } +} + +/// The store's last uncertain commit, kept until a later transaction +/// confirms the document — reconciles its durability, then adopts or +/// commits over it — and writes the commit's lines with their own causes. +/// Only what changed after it counts as another participant's. +#[derive(Debug, Default)] +pub(super) struct UncertainCommits(Mutex>); + +impl UncertainCommits { + fn slot(&self) -> MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// What adopting `next` (whose bytes hash to `digest`) over the view + /// `adopted` brings in. + pub(super) fn adopted_changes( + &self, + adopted: &StoredDocument, + next: &StoredDocument, + digest: &[u8; 32], + ) -> AdoptedChanges { + let slot = self.slot(); + let lines = match slot + .as_ref() + .filter(|commit| commit.is_held_by(adopted, next, digest)) + { + Some(commit) => { + let mut lines = commit.lines.clone(); + lines.extend(adopted_external(&commit.document, next)); + lines + } + None => adopted_external(adopted, next), + }; + AdoptedChanges { lines } + } + + /// Keep this store's commit of `document` (its bytes hash to `digest`), + /// whose replace ended uncertain, with the lines its transaction would + /// have written: `adopted`, then `changes`. + pub(super) fn remember<'a>( + &self, + adopted: AdoptedChanges, + document: StoredDocument, + digest: [u8; 32], + changes: impl IntoIterator, + ) { + let mut lines = adopted.lines; + lines.extend(change_lines(&document, changes)); + *self.slot() = Some(UncertainCommit { + document, + digest, + lines, + }); + } + + /// The transaction adopted or committed over the document `adopted` + /// was read from: write its lines and forget the uncertain commit (the + /// view now holds a later document). + pub(super) fn log_adopted(&self, adopted: AdoptedChanges) { + *self.slot() = None; + for (key, event, new) in &adopted.lines { + emit(key, event, new); + } + } +} + +/// Write one `session_names.name_changed` line for `event` ending at `new`: +/// the old fields and the self-heal flag both come from the event's own +/// prior record. Nothing is logged when the name and source did not move. +fn emit(key: &str, event: &NameChangeEvent, new: &NameState) { + let old = event.prior.as_ref().map(|prior| &prior.state); + if old.is_some_and(|old| !old.differs_from(new)) { + return; + } + let old_source = old.map(|old| wire_source(old.source)); + tracing::info!( + target: "freshell_server::session_names", + name_ref = %key, + cause = event.cause.as_str(), + old_name = old.map(|old| old.name.as_str()), + old_source = old_source.as_deref(), + old_revision = old.map(|old| old.revision), + new_name = new.name.as_str(), + new_source = %wire_source(new.source), + new_revision = new.revision, + self_heal = event.heals(new), + from_ref = event.from_ref.as_deref(), + import_id = event.import_id.as_deref(), + evidence_key = event.evidence_key.as_deref(), + series = event.series.as_deref(), + "session_names.name_changed" + ); +} + +#[cfg(test)] +#[path = "session_name_events_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_events_tests.rs b/crates/freshell-server/src/session_name_events_tests.rs new file mode 100644 index 000000000..7d5a0d0bf --- /dev/null +++ b/crates/freshell-server/src/session_name_events_tests.rs @@ -0,0 +1,1258 @@ +//! Name-reliability Task 12: every accepted saved-name change logs exactly +//! one INFO `session_names.name_changed`, after the durable commit, and +//! nothing else does. +//! +//! Every test drives the REAL `SessionNames` store on a temp data dir +//! through its public transaction methods and reads the events back from +//! the test binary's process-wide trace capture. The store's transactions +//! run on blocking threads, so a thread-scoped capture would miss them; +//! instead every test names its records with ids no other test uses, and +//! counts only the events for those refs logged after its own mark. + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use freshell_freshagent::naming::{ + BindNameInput, NameActivity, NameActivityReason, NameError, NativeNameObservation, + NativeNameOrigin, PendingNameInput, RenameNameInput, SessionNaming, +}; +use freshell_freshagent::terminal_name_binding::{ + follow_identity_name_ref, NamingBindOutcome, TerminalNameBinder, +}; +use freshell_protocol::native_location::{ + NativeAcquisition, NativeEvidenceKind, NativeLocation, NativePersistence, +}; +use freshell_protocol::session_names::{ + LegacyCandidateScope, LegacyEvidenceEnvelope, LegacyNameCandidate, LegacyNameImport, + LegacyNameTarget, LegacyProtectionEvidence, NameIntent, NameSource, NamedProvider, + SessionNameRecord, SessionNameRef, SessionNameUpdate, +}; +use freshell_terminal::registry::HeadlessTerminal; +use freshell_terminal::TerminalRegistry; +use serde_json::json; + +use super::{log_name_changes, NameChangeCause, NameChangeEvent}; +use crate::session_name_generation::{GenerationOutcome, IndexedNameInput}; +use crate::session_names::{ + clear_test_hooks, name_ref_key, set_test_hooks, SessionNames, StoredDocument, + SweepMaintenanceItem, TestHook, NAME_MIGRATION_BOOT_IMPORT_ID, +}; +use crate::test_trace_capture::captured_trace_events; + +const NAME_CHANGED: &str = "session_names.name_changed"; + +// --------------------------------------------------------------------------- +// Reading the events back +// --------------------------------------------------------------------------- + +/// One captured `session_names.name_changed` line, typed. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NameChanged { + cause: String, + /// `(name, source, revision)` of the replaced record; `None` when the + /// change created the record. + old: Option<(String, String, u64)>, + new: (String, String, u64), + self_heal: bool, + /// `from_ref`, `import_id`, `evidence_key` and `series`, when present. + extra: BTreeMap, +} + +fn as_u64(value: Option<&String>, field: &str) -> u64 { + value + .unwrap_or_else(|| panic!("a name_changed event carries {field}")) + .parse() + .unwrap_or_else(|_| panic!("{field} is a number")) +} + +/// The position after every event captured so far: later reads see only +/// what the test does from here on. +fn mark() -> usize { + captured_trace_events().snapshot().len() +} + +/// Every name-change event for `target` captured after `since`, in order. +fn name_changes_since(since: usize, target: &SessionNameRef) -> Vec { + let key = name_ref_key(target); + captured_trace_events() + .snapshot() + .into_iter() + .skip(since) + .filter(|event| event.message == NAME_CHANGED) + .filter(|event| event.fields.get("name_ref") == Some(&key)) + .map(|event| { + assert_eq!( + event.level, + tracing::Level::INFO, + "name changes log at INFO" + ); + let fields = event.fields; + let old = fields.get("old_name").map(|name| { + ( + name.clone(), + fields + .get("old_source") + .cloned() + .expect("an event with an old name carries its source"), + as_u64(fields.get("old_revision"), "old_revision"), + ) + }); + let extra = ["from_ref", "import_id", "evidence_key", "series"] + .into_iter() + .filter_map(|name| { + fields + .get(name) + .map(|value| (name.to_string(), value.clone())) + }) + .collect(); + NameChanged { + cause: fields + .get("cause") + .cloned() + .expect("an event carries its cause"), + old, + new: ( + fields.get("new_name").cloned().expect("new_name"), + fields.get("new_source").cloned().expect("new_source"), + as_u64(fields.get("new_revision"), "new_revision"), + ), + self_heal: fields.get("self_heal").map(String::as_str) == Some("true"), + extra, + } + }) + .collect() +} + +fn one_change(since: usize, target: &SessionNameRef) -> NameChanged { + let changes = name_changes_since(since, target); + assert_eq!( + changes.len(), + 1, + "exactly one name_changed event for {target:?}, got {changes:#?}" + ); + changes.into_iter().next().unwrap() +} + +fn assert_no_change(since: usize, target: &SessionNameRef, why: &str) { + let changes = name_changes_since(since, target); + assert!(changes.is_empty(), "{why}: {changes:#?}"); +} + +fn old_of(update: &SessionNameUpdate) -> Option<(String, String, u64)> { + Some(new_of(update)) +} + +fn new_of(update: &SessionNameUpdate) -> (String, String, u64) { + ( + update.record.name.clone(), + wire(update.record.source), + update.record.revision, + ) +} + +fn wire(source: NameSource) -> String { + serde_json::to_value(source) + .unwrap() + .as_str() + .unwrap() + .to_string() +} + +fn no_extra() -> BTreeMap { + BTreeMap::new() +} + +fn extra(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// --------------------------------------------------------------------------- +// Store fixtures +// --------------------------------------------------------------------------- + +fn open_store(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("store opens") +} + +fn pending(id: &str) -> SessionNameRef { + SessionNameRef::Pending { id: id.to_string() } +} + +fn claude(id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: id.to_string(), + } +} + +async fn ensure(names: &SessionNames, handle: &str, cwd: &str) -> SessionNameUpdate { + names + .ensure_pending(PendingNameInput { + handle: handle.to_string(), + provider: NamedProvider::Claude, + cwd: Some(cwd.to_string()), + }) + .await + .expect("ensure_pending succeeds") +} + +async fn hydrate( + names: &SessionNames, + session_id: &str, + first_user_message: Option<&str>, + provider_title: Option<&str>, +) -> SessionNameUpdate { + names + .hydrate_indexed( + IndexedNameInput { + provider: NamedProvider::Claude, + session_id: session_id.to_string(), + cwd: Some(format!("/w/{session_id}")), + first_user_message: first_user_message.map(str::to_string), + provider_title: provider_title.map(str::to_string), + }, + false, + ) + .await + .expect("hydrate succeeds") +} + +async fn rename( + names: &SessionNames, + target: &SessionNameRef, + name: &str, + intent: NameIntent, +) -> Result { + names + .rename(RenameNameInput { + target: target.clone(), + name: name.to_string(), + intent, + if_revision: None, + cwd: None, + }) + .await +} + +async fn first_message( + names: &SessionNames, + target: &SessionNameRef, + message: &str, +) -> SessionNameUpdate { + names + .activity(NameActivity { + target: target.clone(), + mode: "claude".to_string(), + event_id: format!("evt-{message}"), + reason: NameActivityReason::AcceptedUserMessage, + first_user_message: Some(message.to_string()), + cwd: None, + }) + .await + .expect("activity succeeds") +} + +async fn observe(names: &SessionNames, target: &SessionNameRef, title: &str) -> SessionNameUpdate { + names + .observe_native(NativeNameObservation { + target: target.clone(), + title: title.to_string(), + origin: NativeNameOrigin::Snapshot, + location_revision: 0, + event_id: None, + }) + .await + .expect("observation succeeds") +} + +fn verified_claude() -> NativeAcquisition { + NativeAcquisition { + location: NativeLocation::Claude { + config_root: "/w/homes/claude".to_string(), + transcript_path: Some("/w/homes/claude/projects/R/x.jsonl".to_string()), + project_directory_key: Some("R".to_string()), + transcript_cwd: Some("/w/work".to_string()), + effective_project_key_override: None, + }, + evidence: NativeEvidenceKind::SelectedTranscript, + persistence: NativePersistence::Verified, + } +} + +async fn bind(names: &SessionNames, handle: &str, target: &SessionNameRef) -> SessionNameUpdate { + names + .bind_pending(BindNameInput { + pending: pending(handle), + target: target.clone(), + acquisition: verified_claude(), + }) + .await + .expect("bind succeeds") +} + +fn label_candidate( + id: &str, + session_id: &str, + name: &str, + evidence_key: &str, +) -> LegacyNameCandidate { + LegacyNameCandidate { + id: id.to_string(), + target: LegacyNameTarget::Canonical(claude(session_id)), + name: name.to_string(), + source: NameSource::ProviderAi, + scope: LegacyCandidateScope::SourceTab, + explicit_rename_at: None, + evidence_key: evidence_key.to_string(), + protection_evidence: LegacyProtectionEvidence::None, + } +} + +// --------------------------------------------------------------------------- +// One event per cause +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ensure_pending_logs_the_new_folder_name() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-ensure"); + let since = mark(); + + let created = ensure(&names, "evt-ensure", "/w/evt-ensure-folder").await; + + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "ensure_pending".into(), + old: None, + new: new_of(&created), + self_heal: false, + extra: no_extra(), + } + ); + // Re-ensuring the same handle changes nothing and logs nothing. + let again = mark(); + ensure(&names, "evt-ensure", "/w/evt-ensure-folder").await; + assert_no_change(again, &target, "a repeated ensure is not a change"); +} + +#[tokio::test] +async fn native_observation_logs_the_provider_title_replacing_the_folder_name() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-native"); + let before = ensure(&names, "evt-native", "/w/evt-native").await; + let since = mark(); + + let after = observe(&names, &target, "Fix the login redirect").await; + + assert_eq!(after.record.source, NameSource::ProviderAi); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "native_observation".into(), + old: old_of(&before), + new: new_of(&after), + self_heal: false, + extra: no_extra(), + } + ); +} + +#[tokio::test] +async fn first_message_fallback_logs_the_first_message_name() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-first"); + let before = ensure(&names, "evt-first", "/w/evt-first").await; + let since = mark(); + + let after = first_message(&names, &target, "Repair the sardine factory line").await; + + assert_eq!(after.record.source, NameSource::FirstMessage); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&before), + new: new_of(&after), + self_heal: false, + extra: no_extra(), + } + ); +} + +#[tokio::test] +async fn generation_logs_the_accepted_ai_name_with_its_series() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-generation"); + ensure(&names, "evt-generation", "/w/evt-generation").await; + let before = first_message(&names, &target, "Export the billing ledger").await; + let claim = names + .claim_generation_start(target.clone(), "evt-generation-attempt".to_string()) + .await + .expect("claim runs") + .expect("the armed series is claimable"); + let since = mark(); + + let after = names + .fold_generation_outcome( + target.clone(), + claim.series_id.clone(), + claim.input_fingerprint.clone(), + GenerationOutcome::Answer("Billing ledger export".to_string()), + ) + .await + .expect("fold succeeds"); + + assert_eq!(after.record.source, NameSource::FreshellAi); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "generation".into(), + old: old_of(&before), + new: new_of(&after), + self_heal: false, + extra: extra(&[("series", &claim.series_id)]), + } + ); +} + +#[tokio::test] +async fn manual_rename_logs_the_users_name() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-manual"); + let before = ensure(&names, "evt-manual", "/w/evt-manual").await; + let since = mark(); + + let after = rename(&names, &target, "Ship it", NameIntent::User) + .await + .unwrap(); + + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "manual_rename".into(), + old: old_of(&before), + new: new_of(&after), + self_heal: false, + extra: no_extra(), + } + ); +} + +#[tokio::test] +async fn automation_rename_logs_the_agents_name() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-automation"); + let before = ensure(&names, "evt-automation", "/w/evt-automation").await; + let since = mark(); + + let after = rename(&names, &target, "Agent suggestion", NameIntent::Automatic) + .await + .unwrap(); + + assert_eq!(after.record.source, NameSource::ProviderAi); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "automation_rename".into(), + old: old_of(&before), + new: new_of(&after), + self_heal: false, + extra: no_extra(), + } + ); +} + +#[tokio::test] +async fn bind_without_a_collision_logs_the_session_record_appearing() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let handle = "evt-bind-plain"; + let target = claude("evt-bind-plain-session"); + ensure(&names, handle, "/w/evt-bind-plain").await; + let since = mark(); + + let bound = bind(&names, handle, &target).await; + + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "bind".into(), + old: None, + new: new_of(&bound), + self_heal: false, + extra: extra(&[("from_ref", &name_ref_key(&pending(handle)))]), + } + ); +} + +#[tokio::test] +async fn bind_collision_logs_the_winning_pending_name_and_nothing_when_the_session_keeps_its_own() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + + // The pane's user name outranks the session's first-message name. + let won = claude("evt-collision-won"); + let session_before = hydrate( + &names, + "evt-collision-won", + Some("Ship the billing report"), + None, + ) + .await; + ensure(&names, "evt-collision-pane", "/w/evt-collision-pane").await; + rename( + &names, + &pending("evt-collision-pane"), + "My pane", + NameIntent::User, + ) + .await + .unwrap(); + let since = mark(); + let bound = bind(&names, "evt-collision-pane", &won).await; + assert_eq!(bound.record.name, "My pane"); + assert_eq!( + one_change(since, &won), + NameChanged { + cause: "bind_collision".into(), + old: old_of(&session_before), + new: new_of(&bound), + self_heal: false, + extra: extra(&[("from_ref", &name_ref_key(&pending("evt-collision-pane")))]), + } + ); + + // The session's first-message name outranks the pane's folder name: the + // session's saved name does not change, so nothing is logged. + let kept = claude("evt-collision-kept"); + hydrate( + &names, + "evt-collision-kept", + Some("Keep this session name"), + None, + ) + .await; + ensure(&names, "evt-collision-folder", "/w/evt-collision-folder").await; + let since = mark(); + let bound = bind(&names, "evt-collision-folder", &kept).await; + assert_eq!(bound.record.name, "Keep this session name"); + assert_no_change(since, &kept, "the session kept its saved name"); +} + +#[tokio::test] +async fn hydrate_logs_the_indexed_session_record_appearing() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = claude("evt-hydrate"); + let since = mark(); + + let created = hydrate(&names, "evt-hydrate", Some("Audit the cache layer"), None).await; + + assert_eq!(created.record.source, NameSource::FirstMessage); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "hydrate".into(), + old: None, + new: new_of(&created), + self_heal: false, + extra: no_extra(), + } + ); +} + +#[tokio::test] +async fn adopted_external_logs_another_processes_change_once_and_never_the_boot_load() { + let dir = tempfile::tempdir().unwrap(); + let writer = open_store(dir.path()); + let target = claude("evt-adopt"); + let before = hydrate(&writer, "evt-adopt", None, None).await; + + // Loading an existing document at open is not a change. + let since_open = mark(); + let reader = open_store(dir.path()); + assert_no_change(since_open, &target, "opening a store adopts no change"); + + let renamed = rename(&writer, &target, "Renamed elsewhere", NameIntent::User) + .await + .unwrap(); + let since = mark(); + reader.refresh_current().await.expect("the reader adopts"); + + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "adopted_external".into(), + old: old_of(&before), + new: new_of(&renamed), + self_heal: false, + extra: no_extra(), + } + ); + // Adopting the same document again is not another change. + let again = mark(); + reader.refresh_current().await.unwrap(); + assert_no_change(again, &target, "nothing new to adopt"); +} + +/// A store that commits its own change on top of a newer document from +/// another process adopts that document with the commit: the other +/// process's change is logged once, as `adopted_external`, beside the +/// store's own change. +#[tokio::test] +async fn adopted_external_logs_another_processes_change_once_when_a_commit_lands_over_it() { + let dir = tempfile::tempdir().unwrap(); + let writer = open_store(dir.path()); + let theirs = claude("evt-adopt-commit"); + let before = hydrate(&writer, "evt-adopt-commit", None, None).await; + let reader = open_store(dir.path()); + let renamed = rename(&writer, &theirs, "Renamed by the writer", NameIntent::User) + .await + .unwrap(); + let own = pending("evt-adopt-commit-own"); + let since = mark(); + + // The reader writes a different record without adopting first. + let created = ensure(&reader, "evt-adopt-commit-own", "/w/evt-adopt-commit-own").await; + + assert_eq!( + one_change(since, &theirs), + NameChanged { + cause: "adopted_external".into(), + old: old_of(&before), + new: new_of(&renamed), + self_heal: false, + extra: no_extra(), + } + ); + assert_eq!( + one_change(since, &own), + NameChanged { + cause: "ensure_pending".into(), + old: None, + new: new_of(&created), + self_heal: false, + extra: no_extra(), + } + ); + // The commit adopted the writer's document: the reader's next reads and + // writes log neither change again. + let again = mark(); + reader.refresh_current().await.unwrap(); + rename(&reader, &own, "Reader's own name", NameIntent::User) + .await + .unwrap(); + assert_no_change(again, &theirs, "the writer's change was already adopted"); + assert_eq!( + name_changes_since(again, &own) + .into_iter() + .map(|change| change.cause) + .collect::>(), + vec!["manual_rename".to_string()], + "the reader's own record logs only its new rename" + ); +} + +#[tokio::test] +async fn import_logs_a_boot_installed_label_with_its_import_and_evidence() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = claude("evt-import"); + let evidence_key = "tabs-registry:device-1:claude:evt-import:source-tab"; + let since = mark(); + + let result = names + .import_legacy(LegacyNameImport { + version: 1, + import_id: NAME_MIGRATION_BOOT_IMPORT_ID.to_string(), + evidence: vec![], + candidates: vec![label_candidate( + "evt-import-candidate", + "evt-import", + "Imported label", + evidence_key, + )], + }) + .await + .expect("boot import succeeds"); + + let installed = result + .names + .iter() + .find(|update| update.record.name_ref == target) + .expect("the import installed the record"); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "import".into(), + old: None, + new: new_of(installed), + self_heal: false, + extra: extra(&[ + ("import_id", NAME_MIGRATION_BOOT_IMPORT_ID), + ("evidence_key", evidence_key), + ]), + } + ); +} + +/// Two candidates of one import that both win for the same session change +/// its record twice in one transaction: one event, measured from the record +/// before the import to the committed name, with the evidence that won. +#[tokio::test] +async fn import_logs_one_change_when_two_candidates_win_for_one_session() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = claude("evt-import-twice"); + let before = hydrate( + &names, + "evt-import-twice", + Some("Sort the import backlog"), + None, + ) + .await; + let (tab_key, session_key) = ( + "tabs-registry:device-1:claude:evt-import-twice:source-tab", + "tabs-registry:device-1:claude:evt-import-twice:session", + ); + // The tab label outranks the first-message name; the session-scoped + // label then outranks the tab label on scope. + let tab_label = label_candidate( + "evt-import-twice-tab", + "evt-import-twice", + "Tab label", + tab_key, + ); + let session_label = LegacyNameCandidate { + scope: LegacyCandidateScope::Session, + ..label_candidate( + "evt-import-twice-session", + "evt-import-twice", + "Session label", + session_key, + ) + }; + let since = mark(); + + let result = names + .import_legacy(LegacyNameImport { + version: 1, + import_id: NAME_MIGRATION_BOOT_IMPORT_ID.to_string(), + evidence: vec![], + candidates: vec![tab_label, session_label], + }) + .await + .expect("boot import succeeds"); + + let installed = result + .names + .iter() + .rev() + .find(|update| update.record.name_ref == target) + .expect("the import installed the record"); + assert_eq!(installed.record.name, "Session label"); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "import".into(), + old: old_of(&before), + new: new_of(installed), + self_heal: false, + extra: extra(&[ + ("import_id", NAME_MIGRATION_BOOT_IMPORT_ID), + ("evidence_key", session_key), + ]), + } + ); +} + +// --------------------------------------------------------------------------- +// The self-heal flag +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn self_heal_flag_marks_a_healed_default_label() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + + // A provider-level "Tab 23" is junk: the first message replaces it + // only because of the self-heal rule. + let junk = claude("evt-heal-junk"); + let junk_before = hydrate(&names, "evt-heal-junk", None, Some("Tab 23")).await; + assert_eq!( + (junk_before.record.name.as_str(), junk_before.record.source), + ("Tab 23", NameSource::ProviderAi) + ); + let since = mark(); + let healed = first_message(&names, &junk, "Fix the sardine importer").await; + assert_eq!( + one_change(since, &junk), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&junk_before), + new: new_of(&healed), + self_heal: true, + extra: no_extra(), + } + ); + + // A real-named directory record is simply outranked. + let real = pending("evt-heal-real"); + let real_before = ensure(&names, "evt-heal-real", "/w/evt-heal-real-named").await; + let since = mark(); + let raised = first_message(&names, &real, "Plan the offsite").await; + assert_eq!( + one_change(since, &real), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&real_before), + new: new_of(&raised), + self_heal: false, + extra: no_extra(), + } + ); +} + +/// The flag depends on the before/after pair, not on which path made the +/// change: a folder name giving way to the first message is an ordinary +/// rank raise, so it is not a heal when the session index makes it (which +/// can replace an existing record only through the self-heal rule) nor when +/// the first-message fallback makes it. +#[tokio::test] +async fn self_heal_flag_is_the_same_for_a_rank_raise_from_either_path() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + + let by_index = claude("evt-raise-index"); + let index_before = hydrate(&names, "evt-raise-index", None, None).await; + assert_eq!( + ( + index_before.record.name.as_str(), + index_before.record.source + ), + ("evt-raise-index", NameSource::Directory) + ); + let since = mark(); + let raised = hydrate( + &names, + "evt-raise-index", + Some("Chart the index ledger"), + None, + ) + .await; + assert_eq!(raised.record.source, NameSource::FirstMessage); + assert_eq!( + one_change(since, &by_index), + NameChanged { + cause: "hydrate".into(), + old: old_of(&index_before), + new: new_of(&raised), + self_heal: false, + extra: no_extra(), + } + ); + + let by_message = claude("evt-raise-message"); + let message_before = hydrate(&names, "evt-raise-message", None, None).await; + let since = mark(); + let raised = names + .activity(NameActivity { + target: by_message.clone(), + mode: "claude".to_string(), + event_id: "evt-raise-message-index".to_string(), + reason: NameActivityReason::IndexUserMessage, + first_user_message: Some("Chart the message ledger".to_string()), + cwd: Some("/w/evt-raise-message".to_string()), + }) + .await + .expect("activity succeeds"); + assert_eq!(raised.record.source, NameSource::FirstMessage); + assert_eq!( + one_change(since, &by_message), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&message_before), + new: new_of(&raised), + self_heal: false, + extra: no_extra(), + } + ); +} + +/// The sweep heals junk names in batches (one transaction for many +/// records): each healed record still logs its own change, marked as a heal. +#[tokio::test] +async fn sweep_maintenance_logs_each_healed_record_once_as_a_self_heal() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let (by_index, by_message) = (claude("evt-sweep-index"), claude("evt-sweep-message")); + let index_before = hydrate(&names, "evt-sweep-index", None, Some("Tab 31")).await; + let message_before = hydrate(&names, "evt-sweep-message", None, Some("Tab 32")).await; + let since = mark(); + + let report = names + .sweep_maintenance(vec![ + SweepMaintenanceItem { + hydrate: Some(IndexedNameInput { + provider: NamedProvider::Claude, + session_id: "evt-sweep-index".to_string(), + cwd: Some("/w/evt-sweep-index".to_string()), + first_user_message: Some("Audit the sweep importer".to_string()), + provider_title: None, + }), + activity: None, + }, + SweepMaintenanceItem { + hydrate: None, + activity: Some(NameActivity { + target: by_message.clone(), + mode: "claude".to_string(), + event_id: "evt-sweep-message-index".to_string(), + reason: NameActivityReason::IndexUserMessage, + first_user_message: Some("Repair the sweep exporter".to_string()), + cwd: Some("/w/evt-sweep-message".to_string()), + }), + }, + ]) + .await + .expect("the batch commits"); + + assert_eq!(report.healed, 2); + let healed_index = names.get(vec![by_index.clone()]).await.unwrap().remove(0); + let healed_message = names.get(vec![by_message.clone()]).await.unwrap().remove(0); + assert_eq!( + one_change(since, &by_index), + NameChanged { + cause: "hydrate".into(), + old: old_of(&index_before), + new: new_of(&healed_index), + self_heal: true, + extra: no_extra(), + } + ); + assert_eq!( + one_change(since, &by_message), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&message_before), + new: new_of(&healed_message), + self_heal: true, + extra: no_extra(), + } + ); +} + +/// One sweep item can change one record twice: the index heals the junk +/// name to the folder name, then the first message raises the folder name. +/// That logs one change, from the junk name to the first message, and it +/// is a heal. +#[tokio::test] +async fn sweep_maintenance_logs_a_record_healed_by_index_then_message_once() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = claude("evt-sweep-both"); + let cwd = "/w/evt-sweep-both"; + let junk = hydrate(&names, "evt-sweep-both", None, Some("Tab 33")).await; + assert_eq!( + (junk.record.name.as_str(), junk.record.source), + ("Tab 33", NameSource::ProviderAi) + ); + let since = mark(); + + let report = names + .sweep_maintenance(vec![SweepMaintenanceItem { + hydrate: Some(IndexedNameInput { + provider: NamedProvider::Claude, + session_id: "evt-sweep-both".to_string(), + cwd: Some(cwd.to_string()), + first_user_message: None, + provider_title: None, + }), + activity: Some(NameActivity { + target: target.clone(), + mode: "claude".to_string(), + event_id: "evt-sweep-both-index".to_string(), + reason: NameActivityReason::IndexUserMessage, + first_user_message: Some("Balance the sweep ledger".to_string()), + cwd: Some(cwd.to_string()), + }), + }]) + .await + .expect("the batch commits"); + + assert_eq!(report.healed, 1); + let healed = names.get(vec![target.clone()]).await.unwrap().remove(0); + assert_eq!( + (healed.record.name.as_str(), healed.record.source), + ("Balance the sweep ledger", NameSource::FirstMessage) + ); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "first_message_fallback".into(), + old: old_of(&junk), + new: new_of(&healed), + self_heal: true, + extra: no_extra(), + } + ); +} + +/// The merge rule itself: when one transaction hands the logger several +/// changes of one record, the logged change runs from the first change's +/// prior record, and the self-heal flag is judged against that same prior +/// (here a junk "Tab 41" that the first message replaced only because of +/// the self-heal rule), never against an intermediate record. +#[test] +fn merged_changes_judge_the_self_heal_flag_from_the_first_prior() { + let target = claude("evt-merge-rule"); + let key = name_ref_key(&target); + let cwd = Some("/w/evt-merge-rule"); + let record = |name: &str, source: NameSource, revision: u64| SessionNameRecord { + name_ref: target.clone(), + name: name.to_string(), + source, + revision, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }; + let junk = record("Tab 41", NameSource::ProviderAi, 1); + let folder = record("evt-merge-rule", NameSource::Directory, 2); + let mut document = StoredDocument::initial(); + document.records.insert( + key.clone(), + record("Merge the rule ledger", NameSource::FirstMessage, 3), + ); + let healed_to_folder = NameChangeEvent::new(NameChangeCause::Hydrate, Some(&junk), cwd); + let raised_to_message = + NameChangeEvent::new(NameChangeCause::FirstMessageFallback, Some(&folder), cwd); + let since = mark(); + + log_name_changes( + &document, + [ + (key.as_str(), &healed_to_folder), + (key.as_str(), &raised_to_message), + ], + ); + + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "first_message_fallback".into(), + old: Some(("Tab 41".into(), "provider_ai".into(), 1)), + new: ("Merge the rule ledger".into(), "first_message".into(), 3), + self_heal: true, + extra: no_extra(), + } + ); +} + +// --------------------------------------------------------------------------- +// Nothing else logs a change +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn no_event_for_unchanged_or_refused_offers() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + + // The same manual name again. + let manual = pending("evt-none-manual"); + ensure(&names, "evt-none-manual", "/w/evt-none-manual").await; + rename(&names, &manual, "Pinned name", NameIntent::User) + .await + .unwrap(); + let since = mark(); + rename(&names, &manual, "Pinned name", NameIntent::User) + .await + .unwrap(); + // An automation rename over a manual name is refused. + let refused = rename(&names, &manual, "Agent idea", NameIntent::Automatic) + .await + .unwrap(); + assert!(!refused.changed); + assert_no_change(since, &manual, "an unchanged or refused rename"); + + // An automation rename to a default label is refused. + let automatic = pending("evt-none-label"); + ensure(&names, "evt-none-label", "/w/evt-none-label").await; + let since = mark(); + let refused = rename(&names, &automatic, "Tab 3", NameIntent::Automatic) + .await + .unwrap(); + assert!(!refused.changed); + // A first message that is itself a default label offers nothing. + first_message(&names, &automatic, "Tab 5").await; + assert_no_change(since, &automatic, "a default label is never a name"); + + // The first native observation of a provider-level name only records + // the provider's baseline. + let baseline = pending("evt-none-baseline"); + ensure(&names, "evt-none-baseline", "/w/evt-none-baseline").await; + rename(&names, &baseline, "Agent title", NameIntent::Automatic) + .await + .unwrap(); + let since = mark(); + let observed = observe(&names, &baseline, "Native title").await; + assert_eq!(observed.record.name, "Agent title"); + assert_no_change( + since, + &baseline, + "a first observation only records the baseline", + ); +} + +#[tokio::test] +async fn no_event_for_post_completion_imports() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = claude("evt-post-import"); + hydrate( + &names, + "evt-post-import", + Some("Draft the quarterly report"), + None, + ) + .await; + names + .complete_legacy_migration() + .await + .expect("the consolidation completes"); + let since = mark(); + + let result = names + .import_legacy(LegacyNameImport { + version: 1, + import_id: "evt-post-import-browser".to_string(), + evidence: vec![LegacyEvidenceEnvelope { + storage_key: "freshell.layout.v3.window-evt".to_string(), + raw: json!({ "version": 4, "tabs": { "tabs": [] }, "panes": {} }).to_string(), + }], + candidates: vec![label_candidate( + "evt-post-import-candidate", + "evt-post-import", + "Tab 6", + "freshell.layout.v3.window-evt#tab-1", + )], + }) + .await + .expect("the import is acknowledged"); + + assert_eq!( + result.acknowledged, + vec!["evt-post-import-candidate".to_string()] + ); + assert_no_change( + since, + &target, + "a browser import after completion writes nothing", + ); +} + +/// A door's identity table: one terminal, bound to whatever the test sets. +#[derive(Default)] +struct SwitchBinder { + bound: Mutex>, +} + +impl TerminalNameBinder for SwitchBinder { + fn set_name_binding( + &self, + _terminal_id: &str, + name_ref: &SessionNameRef, + _naming_handle: Option<&str>, + ) { + *self.bound.lock().unwrap() = Some(name_ref.clone()); + } + + fn name_ref_of(&self, _terminal_id: &str) -> Option { + self.bound.lock().unwrap().clone() + } + + fn naming_handle_of(&self, _terminal_id: &str) -> Option { + None + } + + fn durable_session_of(&self, _terminal_id: &str) -> Option<(String, String)> { + None + } +} + +#[tokio::test] +async fn no_event_for_a_conversation_switch() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let (a, b) = (claude("evt-switch-a"), claude("evt-switch-b")); + hydrate(&names, "evt-switch-a", None, None).await; + rename(&names, &a, "Alpha", NameIntent::User).await.unwrap(); + hydrate(&names, "evt-switch-b", None, None).await; + rename(&names, &b, "Beta", NameIntent::User).await.unwrap(); + let registry = TerminalRegistry::new(); + registry.register_headless(HeadlessTerminal { + terminal_id: "T-evt-switch".to_string(), + stream_id: "S-evt-switch".to_string(), + mode: "claude".to_string(), + resume_session_id: None, + create_request_id: None, + created_at: Some(1_000), + }); + registry.set_naming("T-evt-switch", Some(a.clone()), None); + let binder = SwitchBinder::default(); + binder.set_name_binding("T-evt-switch", &b, None); + let sink: Arc = names.clone(); + let since = mark(); + + // The conversation moved from A to B: the row adopts B's saved name. + let outcome = + follow_identity_name_ref(Some(&sink), ®istry, &binder, "T-evt-switch", "Claude").await; + + assert!( + matches!(&outcome, Some(NamingBindOutcome::Bound { record, .. }) if record.name == "Beta"), + "{outcome:?}" + ); + assert_no_change(since, &a, "a switch changes no saved name"); + assert_no_change(since, &b, "a switch changes no saved name"); +} + +#[tokio::test] +async fn no_event_when_commit_fails() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-commit-fails"); + ensure(&names, "evt-commit-fails", "/w/evt-commit-fails").await; + set_test_hooks(dir.path(), vec![TestHook::PreReplace]); + let since = mark(); + + let failed = rename(&names, &target, "Never saved", NameIntent::User).await; + + assert!( + matches!(failed, Err(NameError::Persistence(_))), + "{failed:?}" + ); + assert_no_change(since, &target, "an uncommitted change is not logged"); + + // The same rename, committed, is logged once. + clear_test_hooks(dir.path()); + let since = mark(); + let saved = rename(&names, &target, "Never saved", NameIntent::User) + .await + .unwrap(); + assert_eq!(one_change(since, &target).new, new_of(&saved)); +} + +// A commit whose durable replace ends uncertain, logged once confirmed. +#[path = "session_name_events_uncertain_tests.rs"] +mod uncertain; diff --git a/crates/freshell-server/src/session_name_events_uncertain_tests.rs b/crates/freshell-server/src/session_name_events_uncertain_tests.rs new file mode 100644 index 000000000..9daf4e050 --- /dev/null +++ b/crates/freshell-server/src/session_name_events_uncertain_tests.rs @@ -0,0 +1,147 @@ +//! A commit whose durable replace ends uncertain (the replacement landed, +//! its durability is unproven: `NameError::CommitUncertain`) logs nothing +//! when it is made. The next transaction reconciles durability and adopts +//! or commits over that document; it then logs the change once, with its +//! own cause — never as another process's change (`adopted_external`). +//! +//! A child of the events tests (that file is over the 1000-line cap); it +//! uses their fixtures and the same process-wide trace capture. + +use super::*; + +async fn current(names: &SessionNames, target: &SessionNameRef) -> SessionNameUpdate { + names + .get(vec![target.clone()]) + .await + .expect("get") + .into_iter() + .next() + .expect("the record exists") +} + +/// The next transaction is a read: the adoption logs the store's own rename +/// as `manual_rename`, once. +#[tokio::test] +async fn an_uncertain_commit_logs_its_own_cause_once_a_read_confirms_it() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let target = pending("evt-uncertain-read"); + let created = ensure(&names, "evt-uncertain-read", "/w/evt-uncertain-read").await; + set_test_hooks(dir.path(), vec![TestHook::PostReplace]); + let since = mark(); + + let uncertain = rename(&names, &target, "Release plan", NameIntent::User).await; + + assert!( + matches!(uncertain, Err(NameError::CommitUncertain(_))), + "{uncertain:?}" + ); + assert_no_change(since, &target, "an unconfirmed commit is not logged yet"); + + clear_test_hooks(dir.path()); + names + .refresh_current() + .await + .expect("the next transaction confirms the document"); + let confirmed = current(&names, &target).await; + assert_eq!(confirmed.record.name, "Release plan"); + assert_eq!( + one_change(since, &target), + NameChanged { + cause: "manual_rename".into(), + old: old_of(&created), + new: new_of(&confirmed), + self_heal: false, + extra: no_extra(), + } + ); + + // Confirmed once: later transactions never log it again. + let again = mark(); + names.refresh_current().await.unwrap(); + rename( + &names, + &pending("evt-uncertain-read"), + "Release plan", + NameIntent::User, + ) + .await + .unwrap(); + assert_no_change(again, &target, "the change was already logged"); +} + +/// The next transaction is a commit, and another process committed on top +/// of the uncertain document first: the store's own bind keeps its cause +/// and its `from_ref`, the other process's rename is `adopted_external`, +/// and the new commit logs its own change — each exactly once. +#[tokio::test] +async fn an_uncertain_commit_keeps_its_cause_when_a_commit_lands_over_it() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let mine = claude("evt-uncertain-mine"); + ensure(&names, "evt-uncertain-mine-h", "/w/evt-uncertain-mine").await; + let theirs = claude("evt-uncertain-theirs"); + let theirs_before = hydrate(&names, "evt-uncertain-theirs", None, None).await; + set_test_hooks(dir.path(), vec![TestHook::PostReplace]); + let since_uncertain = mark(); + + let uncertain = names + .bind_pending(BindNameInput { + pending: pending("evt-uncertain-mine-h"), + target: mine.clone(), + acquisition: verified_claude(), + }) + .await; + + assert!( + matches!(uncertain, Err(NameError::CommitUncertain(_))), + "{uncertain:?}" + ); + assert_no_change(since_uncertain, &mine, "an unconfirmed bind is not logged"); + + // Another process reconciles and adopts the document at open (which logs + // nothing), then commits its own rename on top of it. + clear_test_hooks(dir.path()); + let other = open_store(dir.path()); + let theirs_renamed = rename(&other, &theirs, "Renamed elsewhere", NameIntent::User) + .await + .unwrap(); + assert_no_change(since_uncertain, &mine, "the other process never logs it"); + let since = mark(); + + // This store's next commit lands over both documents. + let next = pending("evt-uncertain-next"); + let created = ensure(&names, "evt-uncertain-next", "/w/evt-uncertain-next").await; + + let bound = current(&names, &mine).await; + assert_eq!( + one_change(since, &mine), + NameChanged { + cause: "bind".into(), + old: None, + new: new_of(&bound), + self_heal: false, + extra: extra(&[("from_ref", &name_ref_key(&pending("evt-uncertain-mine-h")))]), + } + ); + assert_eq!( + one_change(since, &theirs), + NameChanged { + cause: "adopted_external".into(), + old: old_of(&theirs_before), + new: new_of(&theirs_renamed), + self_heal: false, + extra: no_extra(), + } + ); + assert_eq!( + one_change(since, &next), + NameChanged { + cause: "ensure_pending".into(), + old: None, + new: new_of(&created), + self_heal: false, + extra: no_extra(), + } + ); +} diff --git a/crates/freshell-server/src/session_name_first_message.rs b/crates/freshell-server/src/session_name_first_message.rs new file mode 100644 index 000000000..ef410f55f --- /dev/null +++ b/crates/freshell-server/src/session_name_first_message.rs @@ -0,0 +1,437 @@ +//! The first-message rules of the name store (name-reliability plan, +//! Task 5; decision D4, root cause RC12, validation V2). +//! +//! - **The fallback.** A session's first user message is its free +//! first-message name, offered for EVERY activity that carries one — a +//! newly accepted or indexed message, and an open or resumed session (the +//! sweep reports a session open in a live terminal as `Opened`, so before +//! this an open terminal pane kept its folder name whenever AI naming +//! failed). Text the shared classifier recognizes as a default or derived +//! label — tool wrapper text above all, "171 saved names are +//! ``" — is never a fallback. An accepted send +//! carries the message just sent, so its fallback heals a junk name only +//! while the store has no input for the session +//! ([`fallback_may_self_heal`]); later sends only raise the rank. +//! - **Re-absorb at rest.** A real first message never changes; an indexed +//! one changes only when the extraction rules change (Task 5 re-derives +//! the cached rows whose message was wrapper text). So a changed message +//! fed by the index to an Idle, never-attempted series refreshes the +//! series' input and leaves it Idle: it arms on an explicit open, exactly +//! like the boot snapshot, and re-deriving history never starts background +//! AI naming. +//! - **The prompt-input guard.** Wrapper or derived text is never AI-naming +//! input: an activity without a real first message drops a stored one +//! (the series rests Idle with no input), the explicit-open hook of a bind +//! leaves such a series Idle and drops its input instead of arming it, and +//! the claim refuses one without consuming a start. (Otherwise Gemini +//! would name a session from wrapper text, and the answer would be a +//! protected `freshell_ai` name the self-heal rule can never replace.) +//! - **Who still needs a first message.** The sweep's targeted OpenCode +//! lookup also serves a record that is below `first_message` or is junk +//! under the row's folder (C5), not only generation input. +//! +//! Pure functions over the document; the store's decisions and the sweep's +//! pre-checks call the same ones, so the pre-check and the apply never +//! drift. + +use std::cell::RefCell; + +use freshell_freshagent::naming::NameActivityReason; +use freshell_protocol::session_names::{NameSource, SessionNameRecord}; +use freshell_sessions::{is_default_or_derived_name, NameEvidenceContext}; + +use super::session_name_policy::is_self_heal_record; +use super::{ + excerpt_of, fingerprint_message, source_is_protected, source_rank, GenerationState, + GenerationStatus, StoredDocument, +}; + +/// Whether an activity's first-message fallback may heal a junk name — +/// replace a self-heal record at equal or lower rank — or may only raise +/// the rank. The sweep's activities (indexed, opened) carry the session +/// index's first user message. An accepted send carries the message the +/// user just sent, which is the session's first only while the store holds +/// no AI-naming input for the session (no series, or one without input): +/// every real user message the store learns for an unprotected record is +/// stored as that input. So a later send only raises a name below +/// `first_message`, and the index heals a junk name with the true first +/// message. Otherwise "continue", sent to a resumed session the sweep had +/// not reached yet, would replace its junk name for good: the true first +/// message no longer outranks it, and it is no longer junk. +pub(super) fn fallback_may_self_heal( + document: &StoredDocument, + key: &str, + reason: NameActivityReason, +) -> bool { + reason != NameActivityReason::AcceptedUserMessage + || document + .generation + .get(key) + .is_none_or(|series| series.input_fingerprint.is_none()) +} + +/// The first-message fallback title (the existing 50-character extraction +/// of the message's first line), or `None` when it is empty or a default or +/// derived label — wrapper text, a provider label, "Tab 3" (folder-free: +/// the folder rule needs the offer's own folder, which the rank rule in +/// `session_name_policy` applies). +pub(super) fn first_message_fallback(message: &str) -> Option { + let title = freshell_sessions::text::extract_title_from_message(message, 50); + if title.is_empty() || is_default_or_derived_name(&title, &NameEvidenceContext::default()) { + None + } else { + Some(title) + } +} + +/// Whether `text` is usable AI-naming input: neither empty nor a default or +/// derived label (wrapper text above all). Folder-free. +pub(super) fn is_generation_input(text: &str) -> bool { + !is_default_or_derived_name(text, &NameEvidenceContext::default()) +} + +/// The activity's first user message, trimmed, when it is usable AI-naming +/// input. +fn real_first_message(message: Option<&str>) -> Option<&str> { + message + .map(str::trim) + .filter(|message| is_generation_input(message)) +} + +/// Whether the series stores input the guard refuses (wrapper or derived +/// text). +pub(super) fn holds_refused_input(series: &GenerationState) -> bool { + series + .excerpt + .as_deref() + .is_some_and(|excerpt| !is_generation_input(excerpt)) +} + +/// What one activity does to the session's generation series. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SeriesPlan { + /// (Re)arm with this input: the series becomes Eligible (one with a + /// start in flight takes the input and stays in flight). + Arm { + fingerprint: String, + excerpt: String, + }, + /// Re-absorb at rest: take this input and stay Idle. + Rest { + fingerprint: String, + excerpt: String, + }, + /// The prompt-input guard: drop the refused stored input; stay Idle. + DropInput, +} + +impl SeriesPlan { + pub(super) fn arms(&self) -> bool { + matches!(self, Self::Arm { .. }) + } +} + +/// Plan the series side of one activity on an UNPROTECTED record (the +/// caller skips protected ones: a protected winner keeps generation +/// stopped). The arming rules are the existing ones — an accepted or +/// indexed message (re)arms when its fingerprint differs; accepted sends +/// keep an already captured real first message; an open or +/// resume arms only an unattempted series, including the boot-absorbed Idle +/// series an explicit open may arm with the same message; an exhausted +/// series never re-arms — plus re-absorb at rest and the prompt-input guard +/// above. +pub(super) fn plan_series( + document: &StoredDocument, + key: &str, + reason: NameActivityReason, + first_user_message: Option<&str>, +) -> Option { + let series = document.generation.get(key); + let Some(message) = real_first_message(first_user_message) else { + return series + .filter(|series| droppable(series) && holds_refused_input(series)) + .map(|_| SeriesPlan::DropInput); + }; + let attempt_allowed = match reason { + NameActivityReason::AcceptedUserMessage | NameActivityReason::IndexUserMessage => true, + NameActivityReason::Opened | NameActivityReason::Resumed => { + series.is_none_or(|series| series.consumed == 0) + } + }; + if !attempt_allowed { + return None; + } + let fingerprint = fingerprint_message(message); + let excerpt = || excerpt_of(message); + let Some(series) = series else { + return Some(SeriesPlan::Arm { + fingerprint, + excerpt: excerpt(), + }); + }; + // AcceptedUserMessage carries the current send, not a corrected first + // message from the index. Preserve the original input across later + // turns, pauses and in-flight requests: otherwise "continue" becomes + // the input for a protected AI name. Refused old wrapper input may + // still be replaced by the first real message. + if reason == NameActivityReason::AcceptedUserMessage { + if let Some(first) = series + .excerpt + .as_deref() + .filter(|text| is_generation_input(text)) + { + // A historical session's boot-absorbed input rests until an + // explicit open or send. Resumed fresh panes have no pending + // handle whose bind could activate it, so the send does that + // here using the stored first message, never the current turn. + return (series.status == GenerationStatus::Idle && series.consumed == 0).then(|| { + SeriesPlan::Arm { + fingerprint: series + .input_fingerprint + .clone() + .unwrap_or_else(|| fingerprint_message(first)), + excerpt: first.to_string(), + } + }); + } + } + if series.status == GenerationStatus::Exhausted { + return None; + } + let resting = series.status == GenerationStatus::Idle && series.consumed == 0; + if series.input_fingerprint.as_deref() != Some(fingerprint.as_str()) { + return Some( + if reason == NameActivityReason::IndexUserMessage && resting { + SeriesPlan::Rest { + fingerprint, + excerpt: excerpt(), + } + } else { + SeriesPlan::Arm { + fingerprint, + excerpt: excerpt(), + } + }, + ); + } + // The same message ("explicit open/resume may arm an unattempted + // series"): the boot snapshot's absorbed series (Idle, never attempted) + // rests until the user actually opens the session, and the same message + // then arms it. An attempted series (Eligible retry, InFlight) never + // resets. + (reason == NameActivityReason::Opened && resting).then(|| SeriesPlan::Arm { + fingerprint, + excerpt: excerpt(), + }) +} + +/// A series whose input may be dropped: not mid-attempt and not exhausted. +fn droppable(series: &GenerationState) -> bool { + matches!( + series.status, + GenerationStatus::Idle | GenerationStatus::Eligible + ) +} + +/// Apply a planned series change; `true` when the document changed. A +/// guard drop is recorded in `refusals`. +pub(super) fn apply_series_plan( + document: &mut StoredDocument, + key: &str, + plan: SeriesPlan, + refusals: &InputRefusals, +) -> bool { + match plan { + SeriesPlan::Arm { + fingerprint, + excerpt, + } => { + let series = document + .generation + .entry(key.to_string()) + .or_insert_with(new_series); + if series.status == GenerationStatus::Exhausted { + return false; + } + // A claimed start stays in flight until its fold settles it + // (refund, retry, or exhaustion at the cap) and boot recovery + // can still find it; the newer input waits for that settle. + if series.status != GenerationStatus::InFlight { + series.status = GenerationStatus::Eligible; + } + series.input_fingerprint = Some(fingerprint); + series.excerpt = Some(excerpt); + series.next_due = None; + true + } + SeriesPlan::Rest { + fingerprint, + excerpt, + } => { + let Some(series) = document.generation.get_mut(key) else { + return false; + }; + series.input_fingerprint = Some(fingerprint); + series.excerpt = Some(excerpt); + series.next_due = None; + true + } + SeriesPlan::DropInput => document + .generation + .get_mut(key) + .is_some_and(|series| drop_refused_input(series, RefusalSite::Activity, refusals)), + } +} + +fn new_series() -> GenerationState { + GenerationState { + status: GenerationStatus::Eligible, + series_id: uuid::Uuid::new_v4().to_string(), + input_fingerprint: None, + excerpt: None, + attempt_ids: Vec::new(), + attempt_starts: Vec::new(), + consumed: 0, + next_due: None, + exhausted_at: None, + } +} + +/// Which guard refused stored input (the `site` of the +/// `session_names.generation_input_refused` event). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RefusalSite { + /// An activity (sweep pass or live edge) without a real first message. + Activity, + /// A bind's explicit-open hook. + Bind, + /// The generation claim, the last line before the model. + Claim, +} + +impl RefusalSite { + fn as_str(self) -> &'static str { + match self { + Self::Activity => "activity", + Self::Bind => "bind", + Self::Claim => "claim", + } + } +} + +/// The prompt-input guard's drop: no input, resting Idle, nothing consumed +/// or refunded. `true` when the series changed; the drop and the guard +/// that fired are recorded in `refusals`, which the transaction logs once +/// it commits. +pub(super) fn drop_refused_input( + series: &mut GenerationState, + site: RefusalSite, + refusals: &InputRefusals, +) -> bool { + if !droppable(series) || !holds_refused_input(series) { + return false; + } + series.input_fingerprint = None; + series.excerpt = None; + series.status = GenerationStatus::Idle; + series.next_due = None; + refusals.record(site, series); + true +} + +/// One transaction's prompt-input guard drops. The +/// `session_names.generation_input_refused` line says the input "was +/// dropped", so it is written only after the drop committed +/// ([`InputRefusals::log_committed`]); a transaction whose commit fails +/// writes none (the input is still stored, and a later transaction drops +/// it again). +#[derive(Debug, Default)] +pub(super) struct InputRefusals(RefCell>); + +#[derive(Debug)] +struct InputRefusal { + site: RefusalSite, + series: String, + consumed: u32, +} + +impl InputRefusals { + fn record(&self, site: RefusalSite, series: &GenerationState) { + self.0.borrow_mut().push(InputRefusal { + site, + series: series.series_id.clone(), + consumed: series.consumed, + }); + } + + /// The transaction committed: log each drop once. + pub(super) fn log_committed(&self) { + for refusal in self.0.take() { + tracing::info!( + target: "freshell_server::session_names", + op = "generation_input_refused", + site = refusal.site.as_str(), + series = %refusal.series, + consumed = refusal.consumed, + "session_names.generation_input_refused: stored AI-naming input is wrapper or derived text; it was dropped and the series rests" + ); + } + } +} + +/// The bind's explicit-open hook for one series: an Idle, never-attempted +/// series with usable input becomes Eligible (the user opened the session); +/// one holding refused input stays Idle and drops it. `true` when changed. +pub(super) fn explicit_open(series: &mut GenerationState, refusals: &InputRefusals) -> bool { + if series.status != GenerationStatus::Idle || series.consumed != 0 { + return false; + } + if holds_refused_input(series) { + return drop_refused_input(series, RefusalSite::Bind, refusals); + } + if series.input_fingerprint.is_none() { + return false; + } + series.status = GenerationStatus::Eligible; + series.next_due = None; + true +} + +/// Whether a record still needs the session's first user message (the +/// sweep's targeted lookup serves it): below `first_message`, or a self-heal +/// record under the row's folder (C5). Protected names never do. +pub(super) fn record_needs_first_message(record: &SessionNameRecord, cwd: Option<&str>) -> bool { + !source_is_protected(record.source) + && (source_rank(record.source) < source_rank(NameSource::FirstMessage) + || is_self_heal_record(record, cwd)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_fallback_is_never_a_default_label() { + assert_eq!( + first_message_fallback("Fix the sardine importer").as_deref(), + Some("Fix the sardine importer") + ); + assert_eq!( + first_message_fallback("\n- github: review pull requests"), + None + ); + assert_eq!( + first_message_fallback( + "Caveat: The messages below were generated" + ), + None + ); + assert_eq!(first_message_fallback("Claude CLI"), None); + assert_eq!(first_message_fallback(" "), None); + // A real sentence that merely mentions a tag stays a name. + assert_eq!( + first_message_fallback("Fix bold rendering").as_deref(), + Some("Fix bold rendering") + ); + } +} diff --git a/crates/freshell-server/src/session_name_generation.rs b/crates/freshell-server/src/session_name_generation.rs index b5908a89a..72a403f44 100644 --- a/crates/freshell-server/src/session_name_generation.rs +++ b/crates/freshell-server/src/session_name_generation.rs @@ -19,6 +19,14 @@ //! messages, opens, reconnects, title changes and restarts — there is no //! automatic rearm and no daily-budget subsystem. //! +//! Name-reliability Task 6: a credential, permission, proxy or quota failure +//! is not a consumed attempt. The fold REFUNDS it (the series keeps its +//! allowance and its naming input, and waits behind the series already +//! due, so the probe after the pause tries another session first) and the +//! in-memory pause ([`crate::session_name_capability::CapabilityLatch`]) +//! stops claims until a re-probe time or a key change; exhaustion moved +//! from the claim to the fold, so a refunded third start keeps the series. +//! //! The [`SessionNameGenerator`] supplies generation EXECUTION to the Task 3 //! native worker's ONE owned serial loop ([`crate::session_name_native:: //! SessionNameWorker::start`]) under the shared `.session-names-worker.lock` @@ -26,24 +34,26 @@ //! document (short current-document transactions; the Gemini call itself //! happens OUTSIDE them); clock injection for the document's retry //! arithmetic is the store's data-dir-keyed test hook. A disabled naming -//! toggle or a missing OneCLI/direct credential route pauses the work without -//! consumption — the -//! worker's ≤500ms poll re-evaluates capability, so a pause is discovered -//! and resumed within one poll of the capability arriving. The ONE -//! explicit wake caller today is the scoped compatibility route -//! ([`Self::notify_capability_change`], `sessions.rs`'s generate-title -//! arm); the settings PATCH path does NOT wake (the key cell is a bare -//! RwLock with no notify), and `Notify::notify_waiters` is lossy (no +//! toggle, no credential route, or the naming pause stops the work without +//! consumption — the worker's ≤500ms poll (shortened to the pause's +//! re-probe time) re-evaluates capability. A key saved in Settings calls +//! [`SessionNameGenerator::notify_capability_change`] (clears the pause and +//! wakes the worker); the scoped generate-title compatibility route calls +//! [`SessionNameGenerator::wake`]. `Notify::notify_waiters` is lossy (no //! permit) — the wake is a latency hint, never a correctness mechanism. //! //! Jobs capture the target, series id and input fingerprint; before folding //! a completion the store compares the captured fingerprint against the //! persisted series (a re-armed series keeps its series id, so a late answer -//! generated from older input must not be accepted). Only the committed +//! generated from older input must not be accepted, though the start it was +//! charged still settles — refunded, or counted toward exhaustion). A key +//! change starts a new pause epoch, so a refusal of the old key that lands +//! afterwards is refunded without pausing naming. Only the committed //! winner publishes; the structured start/retry/exhausted/stale/paused/ //! accepted decisions log severity, operation, name reference, series, //! attempt and failure class — never prompts, credentials, or payloads. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -51,7 +61,11 @@ use uuid::Uuid; use freshell_protocol::session_names::SessionNameRef; -use crate::ai_title::{self, GeminiSessionNameAuth, GeminiTransport}; +use crate::ai_title::{self, GeminiSessionNameAuth}; +use crate::gemini_failure::{GeminiFailure, GeminiFailureClass}; +use crate::session_name_capability::{ + CapabilityLatch, LatchEpoch, LatchTransition, LatchWindows, NamingRoute, SessionNameTransport, +}; use crate::session_names::{SessionNames, MAX_GENERATION_STARTS}; use crate::settings_store::SettingsStore; @@ -59,6 +73,14 @@ use crate::settings_store::SettingsStore; #[path = "session_name_generation_tests.rs"] mod tests; +#[cfg(test)] +#[path = "session_name_generation_capability_tests.rs"] +mod capability_tests; + +#[cfg(test)] +#[path = "session_name_generation_pause_tests.rs"] +mod pause_tests; + /// The per-request bound for one generation attempt ("each request bounded /// to 20 seconds"). A timeout consumes the attempt exactly once (the start /// was already persisted by the claim) and schedules the remaining retry @@ -66,6 +88,12 @@ mod tests; /// a late answer folds through the same series/fingerprint guards. pub const GENERATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +// A dropped connect must fail as a connect error (a pause that uses no +// attempt) before this bound charges the attempt as a timeout. +const _: () = assert!( + ai_title::SESSION_NAME_CONNECT_TIMEOUT.as_millis() < GENERATION_REQUEST_TIMEOUT.as_millis() +); + // --------------------------------------------------------------------------- // Store-facing work shapes (produced/consumed by the session_names seams) // --------------------------------------------------------------------------- @@ -101,12 +129,17 @@ pub(crate) struct GenerationClaim { /// One classified generation outcome folded back into the durable series. /// `Answer` carries a non-empty candidate (validation and acceptance are /// the store's); `Empty` is an empty/invalid answer (it consumes the -/// attempt); `Failed` carries the request's failure class. +/// attempt); `Failed` carries the request's failure class (it consumes). +/// `CapabilityUnavailable` and `Throttled` (name-reliability Task 6) are +/// refunded: the claimed start identified by `attempt_id` is given back and +/// the series is Eligible again with its input. #[derive(Clone, Debug, PartialEq)] pub(crate) enum GenerationOutcome { Answer(String), Empty, Failed(String), + CapabilityUnavailable { attempt_id: String }, + Throttled { attempt_id: String }, } /// Hydration input for one index-observed scoped CLI session @@ -128,56 +161,131 @@ pub(crate) struct IndexedNameInput { /// Generation execution for the shared serial worker. Constructed once in /// `main` and handed to [`crate::session_name_native::SessionNameWorker::start`]; /// [`Self::run_due_attempt`] performs one claimed attempt under the worker's -/// already-held background guard. Capability (the naming toggle and the -/// OneCLI/direct credential route) is checked by the worker BEFORE selection, -/// so disabled naming or missing credentials pauses the work without consuming anything and -/// without monopolizing the worker; the worker's ≤500ms poll re-evaluates -/// capability, so a pause is discovered and resumed within one poll of the -/// capability arriving. The ONE explicit wake caller today is the scoped -/// compatibility route (`sessions.rs`'s generate-title arm calls -/// [`Self::notify_capability_change`]); the settings PATCH path does NOT -/// wake, and the wake itself is a lossy latency hint, never a correctness -/// mechanism. +/// already-held background guard. Capability (the naming toggle, the +/// credential route, and the naming pause) is checked by the worker BEFORE +/// selection, so disabled naming, missing credentials or a refused +/// credential pause the work without consuming anything and without +/// monopolizing the worker. A key saved in Settings calls +/// [`Self::notify_capability_change`]; the scoped generate-title +/// compatibility route calls [`Self::wake`]; both wakes are lossy latency +/// hints, never correctness mechanisms. pub struct SessionNameGenerator { settings: SettingsStore, auth: GeminiSessionNameAuth, - transport: Arc, + transport: Arc, + latch: CapabilityLatch, + /// The inherited-identity WARN is said once per process. + inherited_route_warned: AtomicBool, wake: Arc, } impl SessionNameGenerator { + /// The pause windows come from the Rust-only test seams + /// (`FRESHELL_NAMING_CAPABILITY_REPROBE_MS`, + /// `FRESHELL_NAMING_THROTTLE_REPROBE_MS`), else the defaults. pub fn new( settings: SettingsStore, auth: GeminiSessionNameAuth, - transport: Arc, + transport: Arc, ) -> Self { Self { settings, auth, transport, + latch: CapabilityLatch::new(LatchWindows::from_environment()), + inherited_route_warned: AtomicBool::new(false), wake: Arc::new(tokio::sync::Notify::new()), } } - /// Whether generation may run right now: session naming has an applicable - /// OneCLI route or a direct key AND the user's `sidebar.autoGenerateTitles` - /// setting is on. A `false` - /// answer is a capability pause — the armed series keeps its whole - /// allowance and stays discoverable; nothing is consumed. + /// Replace the pause windows (tests). + #[cfg(test)] + pub(crate) fn with_latch_windows(mut self, windows: LatchWindows) -> Self { + self.latch = CapabilityLatch::new(windows); + self + } + + #[cfg(test)] + pub(crate) fn latch(&self) -> &CapabilityLatch { + &self.latch + } + + /// Whether generation may run right now: a credential route exists, + /// naming is not paused (or its re-probe time came), AND the user's + /// `sidebar.autoGenerateTitles` setting is on. A `false` answer pauses + /// without consuming anything: armed series keep their whole allowance + /// and stay discoverable. pub async fn capability_ready(&self) -> bool { - if !self.auth.enabled() { + if !self.auth.route().is_available() { + return false; + } + if !self.latch.ready(tokio::time::Instant::now()) { return false; } self.settings.get().await.sidebar.auto_generate_titles } - /// Wake capability-paused remaining work: a settings/key capability - /// change makes the next poll re-evaluate immediately. Never replenishes - /// exhausted attempts (the claim enforces the cap regardless). + /// How long the idle worker may sleep: its poll, shortened so a paused + /// series is re-probed when its window ends (an already-due re-probe + /// keeps the plain poll, so an idle worker never spins). + pub(crate) fn idle_poll(&self, poll: Duration) -> Duration { + match self.latch.reprobe_in(tokio::time::Instant::now()) { + Some(left) if !left.is_zero() => left.min(poll), + _ => poll, + } + } + + /// The saved Gemini key changed: clear both pauses (the route is chosen + /// per attempt from the key cell, so the next attempt already uses the + /// new key), say which route naming now uses, and wake the worker. + /// Never replenishes exhausted attempts (the claim enforces the cap + /// regardless). pub fn notify_capability_change(&self) { + let cleared = self.latch.clear(); + self.log_route_selected("key_change", cleared); + self.wake.notify_waiters(); + } + + /// Wake the worker so already-eligible work runs at once (a latency + /// hint: it clears no pause and replenishes nothing). + pub fn wake(&self) { self.wake.notify_waiters(); } + /// The boot line: which route AI naming uses, and (only on the + /// inherited OneCLI route) the WARN that the server borrows the + /// launching shell's OneCLI identity. + pub fn log_boot_route(&self) { + self.log_route_selected("boot", false); + } + + fn log_route_selected(&self, reason: &'static str, pause_cleared: bool) { + let route = self.auth.route(); + let facts = self.auth.facts(); + tracing::info!( + target: "freshell_server::session_name_generation", + op = "generation_route", + reason, + route = route.as_str(), + onecli_marker = facts.onecli_marker, + key_configured = self.auth.key_configured(), + key_route_transport = facts.key_route_transport().as_str(), + pause_cleared, + "session_names.generation_route_selected: AI naming uses the {} route", + route.as_str() + ); + if route == NamingRoute::InheritedOneCli + && !self.inherited_route_warned.swap(true, Ordering::SeqCst) + { + tracing::warn!( + target: "freshell_server::session_name_generation", + op = "generation_route", + route = route.as_str(), + "session_names.generation_route_inherited_identity: no Gemini key is configured, so AI naming borrows the OneCLI identity of the shell that launched the server; enter a key in Settings to stop depending on it" + ); + } + } + /// The capability-change listener the worker selects alongside its poll. pub(crate) fn wake_notify(&self) -> &tokio::sync::Notify { &self.wake @@ -272,32 +380,20 @@ impl SessionNameGenerator { GenerationOutcome::Empty } else { let custom_prompt = self.settings.get().await.ai.title_prompt; - let call = ai_title::generate_ai_session_title( - self.transport.as_ref(), - &excerpt, - custom_prompt.as_deref(), - ); - match tokio::time::timeout(GENERATION_REQUEST_TIMEOUT, call).await { - Ok(Ok(Some(title))) => GenerationOutcome::Answer(title), - Ok(Ok(None)) => GenerationOutcome::Empty, - Ok(Err(reason)) => GenerationOutcome::Failed(reason), - Err(_) => GenerationOutcome::Failed( - "the generation request exceeded the 20-second bound".to_string(), - ), - } + // Read before the transport picks the key: a key saved while + // this request is in flight makes its outcome stale. + let began = self.latch.epoch(); + let call = + generate_session_name(self.transport.as_ref(), &excerpt, custom_prompt.as_deref()); + let result = tokio::time::timeout(GENERATION_REQUEST_TIMEOUT, call) + .await + .unwrap_or_else(|_| Err(GeminiFailure::request_timed_out())); + self.attempt_outcome(claim, began, result) }; - if let GenerationOutcome::Failed(reason) = &outcome { - tracing::warn!( - target: "freshell_server::session_name_generation", - op = "generation_attempt", - name_ref = %freshell_freshagent::naming::name_ref_debug_key(&claim.target), - series = %claim.series_id, - attempt = %claim.attempt_id, - consumed = claim.consumed, - class = "failed", - "session_names.generation_failed: {reason}" - ); - } + let refunded = matches!( + outcome, + GenerationOutcome::CapabilityUnavailable { .. } | GenerationOutcome::Throttled { .. } + ); match names .fold_generation_outcome( claim.target.clone(), @@ -320,7 +416,9 @@ impl SessionNameGenerator { ); } Ok(update) => { - let class = if claim.consumed >= MAX_GENERATION_STARTS { + let class = if refunded { + "refunded" + } else if claim.consumed >= MAX_GENERATION_STARTS { "exhausted" } else { "retry_scheduled" @@ -349,4 +447,118 @@ impl SessionNameGenerator { } } } + + /// Map one finished request to the outcome the store folds, recording it + /// on the naming pause and logging what a reader needs once: a pause + /// transition (WARN), a restore (INFO), an outcome the pause ignores + /// because the key changed after the request began (INFO), or a + /// consumed failure (WARN with its status and provider code). `began` is + /// the latch epoch read before the request was built. Never a key, URL + /// or prompt. + fn attempt_outcome( + &self, + claim: &GenerationClaim, + began: LatchEpoch, + result: Result, GeminiFailure>, + ) -> GenerationOutcome { + let failure = match result { + Ok(answer) => { + self.log_latch_transition(&self.latch.record_reachable(began), None); + return match answer { + Some(title) => GenerationOutcome::Answer(title), + None => GenerationOutcome::Empty, + }; + } + Err(failure) => failure, + }; + let transition = self + .latch + .record_failure(&failure, tokio::time::Instant::now(), began); + self.log_latch_transition(&transition, Some(&failure)); + let attempt_id = claim.attempt_id.clone(); + if failure.pauses_naming() { + tracing::debug!( + target: "freshell_server::session_name_generation", + op = "generation_attempt", + name_ref = %freshell_freshagent::naming::name_ref_debug_key(&claim.target), + series = %claim.series_id, + attempt = %claim.attempt_id, + code = %failure.code(), + status = failure.status, + class = if transition == LatchTransition::Superseded { + "superseded" + } else { + "paused" + }, + "session_names.generation_paused: {failure}; the attempt is refunded" + ); + return if failure.class == GeminiFailureClass::Throttled { + GenerationOutcome::Throttled { attempt_id } + } else { + GenerationOutcome::CapabilityUnavailable { attempt_id } + }; + } + tracing::warn!( + target: "freshell_server::session_name_generation", + op = "generation_attempt", + name_ref = %freshell_freshagent::naming::name_ref_debug_key(&claim.target), + series = %claim.series_id, + attempt = %claim.attempt_id, + consumed = claim.consumed, + status = failure.status, + provider_code = failure.provider_code.as_deref(), + class = "failed", + "session_names.generation_failed: {failure}" + ); + GenerationOutcome::Failed(failure.to_string()) + } + + fn log_latch_transition(&self, transition: &LatchTransition, failure: Option<&GeminiFailure>) { + match transition { + LatchTransition::Unchanged => {} + LatchTransition::Superseded => { + tracing::info!( + target: "freshell_server::session_name_generation", + op = "generation_capability", + code = failure.map(GeminiFailure::code).as_deref(), + status = failure.and_then(|failure| failure.status), + route = self.auth.route().as_str(), + "session_names.generation_outcome_superseded: this attempt began before the Gemini key changed, so its result does not pause or restore AI naming" + ); + } + LatchTransition::Paused { kind, code } => { + tracing::warn!( + target: "freshell_server::session_name_generation", + op = "generation_capability", + code = %code, + kind = ?kind, + status = failure.and_then(|failure| failure.status), + route = self.auth.route().as_str(), + "session_names.generation_capability_unavailable: AI naming is paused ({code}); nothing is consumed, and it tries again later or when a Gemini key is saved" + ); + } + LatchTransition::Restored => { + tracing::info!( + target: "freshell_server::session_name_generation", + op = "generation_capability", + route = self.auth.route().as_str(), + "session_names.generation_capability_restored: AI naming reached the provider again" + ); + } + } + } +} + +/// One session-name request: the title prompt for `excerpt`, the answer +/// trimmed and capped, an empty answer `None`. +async fn generate_session_name( + transport: &dyn SessionNameTransport, + excerpt: &str, + custom_prompt: Option<&str>, +) -> Result, GeminiFailure> { + let prompt = ai_title::build_session_title_prompt(excerpt, custom_prompt); + let text = transport + .generate_content(prompt, ai_title::SESSION_TITLE_MAX_OUTPUT_TOKENS) + .await?; + Ok(ai_title::finalize_session_title(&text)) } diff --git a/crates/freshell-server/src/session_name_generation_capability_tests.rs b/crates/freshell-server/src/session_name_generation_capability_tests.rs new file mode 100644 index 000000000..108b09526 --- /dev/null +++ b/crates/freshell-server/src/session_name_generation_capability_tests.rs @@ -0,0 +1,1230 @@ +//! Name-reliability Task 6: capability and quota failures pause AI naming +//! without using up a session's attempts or losing its naming input; real +//! failures still exhaust, now in the fold; the route and pause logs say +//! what happened once, without secrets or URLs; a key saved in Settings +//! takes over at once. Everything drives the REAL temp-file store and the +//! real worker; transports are scripted fakes or the real HTTP transport +//! against loopback fixtures. + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use freshell_protocol::session_names::{NameSource, NamedProvider, SessionNameRef}; +use serde_json::Value; +use tower::ServiceExt; + +use super::tests::{ + arm_with_message, document_json, ensure_pending, generation_consumed, generation_entry, + generation_status, get_one, open_store, pending, settings_for, temp_data_dir, NoRouteNative, +}; +use super::{GenerationOutcome, SessionNameGenerator}; +use crate::ai_title::{AiKeyCell, GeminiSessionNameAuth}; +use crate::gemini_failure::{classify_gemini_failure, GeminiFailure}; +use crate::session_name_capability::tests::{ + session_name_http, start_fake_gemini, start_fake_proxy, ProxyMode, ScriptedTransport, +}; +use crate::session_name_capability::{LatchWindows, PauseKind, SessionNameTransport}; +use crate::session_name_native::SessionNameWorker; +use crate::session_names::{ + clear_test_hooks, set_test_hooks, SessionNames, TestHook, GENERATION_RETRY_1_MS, + GENERATION_RETRY_2_MS, MAX_GENERATION_STARTS, +}; +use crate::test_trace_capture::{scoped_trace_capture, CapturedTraceEvent}; + +pub(super) const GENERATION_TARGET: &str = "freshell_server::session_name_generation"; +const GEMINI_URL: &str = "http://generativelanguage.googleapis.com/v1beta"; + +fn capability(code: &str) -> GeminiFailure { + classify_gemini_failure(403, format!(r#"{{"error":"{code}"}}"#).as_bytes()) +} + +fn throttled() -> GeminiFailure { + classify_gemini_failure(429, b"") +} + +pub(super) fn windows(capability_ms: u64, throttle_ms: u64) -> LatchWindows { + LatchWindows { + capability: Duration::from_millis(capability_ms), + throttle_base: Duration::from_millis(throttle_ms), + } +} + +pub(super) fn keyed_auth(key: &str) -> GeminiSessionNameAuth { + GeminiSessionNameAuth::direct_for_test(AiKeyCell::init(Some(key.to_string()), None)) +} + +pub(super) fn generator( + dir: &std::path::Path, + auth: GeminiSessionNameAuth, + transport: Arc, + latch: LatchWindows, +) -> Arc { + Arc::new( + SessionNameGenerator::new(settings_for(dir), auth, transport).with_latch_windows(latch), + ) +} + +fn excerpt_of(doc: &Value, target: &SessionNameRef) -> Value { + generation_entry(doc, target) + .and_then(|entry| entry.get("excerpt")) + .cloned() + .unwrap_or(Value::Null) +} + +fn attempt_ids_of(doc: &Value, target: &SessionNameRef) -> Vec { + generation_entry(doc, target) + .and_then(|entry| entry.get("attemptIds")) + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(|id| id.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn attempt_starts_len(doc: &Value, target: &SessionNameRef) -> usize { + generation_entry(doc, target) + .and_then(|entry| entry.get("attemptStarts")) + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +pub(super) async fn armed_target( + store: &Arc, + handle: &str, + message: &str, +) -> SessionNameRef { + let target = pending(handle); + ensure_pending(store, handle, NamedProvider::Claude, Some("/w/capability")) + .await + .expect("ensure"); + arm_with_message(store, target.clone(), "freshclaude", message).await; + target +} + +/// Claim and fail `count` starts (each fold consumes), advancing the store's +/// decision clock past each retry delay. +async fn consume_failed_starts( + store: &Arc, + dir: &std::path::Path, + target: &SessionNameRef, + count: u32, +) -> Option { + let mut offset = 0; + let mut excerpt = None; + set_test_hooks(dir, vec![TestHook::ClockOffsetMs(0)]); + for attempt in 1..=count { + let claim = store + .claim_generation_start(target.clone(), format!("attempt-failed-{attempt}")) + .await + .expect("claim runs") + .expect("the series is claimable"); + excerpt = claim.excerpt.clone(); + store + .fold_generation_outcome( + target.clone(), + claim.series_id, + claim.input_fingerprint, + GenerationOutcome::Failed(format!("scripted failure {attempt}")), + ) + .await + .expect("the failure folds"); + offset += match attempt { + 1 => GENERATION_RETRY_1_MS, + _ => GENERATION_RETRY_2_MS, + }; + set_test_hooks(dir, vec![TestHook::ClockOffsetMs(offset)]); + } + excerpt +} + +// --------------------------------------------------------------------------- +// The fold: refunds and fold-side exhaustion +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn capability_failure_refunds_the_attempt() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-refund", "Refund the sardine attempt").await; + let armed = document_json(dir.path()); + let armed_excerpt = excerpt_of(&armed, &target); + assert!(!armed_excerpt.is_null(), "the armed series holds its input"); + + let claim = store + .claim_generation_start(target.clone(), "attempt-refund".to_string()) + .await + .expect("claim runs") + .expect("claimable"); + assert_eq!(claim.consumed, 1); + store + .fold_generation_outcome( + target.clone(), + claim.series_id.clone(), + claim.input_fingerprint.clone(), + GenerationOutcome::CapabilityUnavailable { + attempt_id: "attempt-refund".to_string(), + }, + ) + .await + .expect("the refund folds"); + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 0, + "the start is refunded" + ); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + assert_eq!( + excerpt_of(&doc, &target), + armed_excerpt, + "the input is kept" + ); + assert!( + attempt_ids_of(&doc, &target).is_empty(), + "the attempt id is removed" + ); + assert_eq!( + attempt_starts_len(&doc, &target), + 0, + "the start time is removed" + ); + + // A quota answer refunds the same way. + let claim = store + .claim_generation_start(target.clone(), "attempt-throttled".to_string()) + .await + .expect("claim runs") + .expect("a refunded series is claimable again"); + assert_eq!(claim.consumed, 1); + assert_eq!(claim.excerpt.as_deref(), armed_excerpt.as_str()); + store + .fold_generation_outcome( + target.clone(), + claim.series_id, + claim.input_fingerprint, + GenerationOutcome::Throttled { + attempt_id: "attempt-throttled".to_string(), + }, + ) + .await + .expect("the refund folds"); + let doc = document_json(dir.path()); + assert_eq!(generation_consumed(&doc, &target), 0); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + assert_eq!(excerpt_of(&doc, &target), armed_excerpt); +} + +#[tokio::test] +async fn third_attempt_capability_failure_keeps_the_series_and_its_excerpt() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-third-capability", "Keep the third attempt").await; + let armed_excerpt = consume_failed_starts(&store, dir.path(), &target, 2).await; + assert!(armed_excerpt.is_some()); + + let claim = store + .claim_generation_start(target.clone(), "attempt-third".to_string()) + .await + .expect("claim runs") + .expect("the third start is claimable"); + assert_eq!(claim.consumed, MAX_GENERATION_STARTS); + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("inFlight"), + "the claim never exhausts: the fold decides" + ); + assert_eq!( + excerpt_of(&doc, &target).as_str(), + armed_excerpt.as_deref(), + "the claim keeps the input" + ); + + store + .fold_generation_outcome( + target.clone(), + claim.series_id, + claim.input_fingerprint, + GenerationOutcome::CapabilityUnavailable { + attempt_id: "attempt-third".to_string(), + }, + ) + .await + .expect("the refund folds"); + let doc = document_json(dir.path()); + assert_eq!(generation_consumed(&doc, &target), 2); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + assert_eq!(excerpt_of(&doc, &target).as_str(), armed_excerpt.as_deref()); + + let next = store + .claim_generation_start(target.clone(), "attempt-third-again".to_string()) + .await + .expect("claim runs") + .expect("the refunded third start is claimable again"); + assert_eq!( + next.excerpt, armed_excerpt, + "the next claim sends the same input" + ); + assert_eq!(next.consumed, MAX_GENERATION_STARTS); + clear_test_hooks(dir.path()); +} + +#[tokio::test] +async fn third_attempt_real_failure_exhausts_in_the_fold() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-third-failed", "Exhaust in the fold").await; + consume_failed_starts(&store, dir.path(), &target, 2).await; + let claim = store + .claim_generation_start(target.clone(), "attempt-third".to_string()) + .await + .expect("claim runs") + .expect("the third start is claimable"); + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("inFlight") + ); + assert!(!excerpt_of(&doc, &target).is_null()); + + store + .fold_generation_outcome( + target.clone(), + claim.series_id, + claim.input_fingerprint, + GenerationOutcome::Failed("gemini http 500 Internal Server Error".to_string()), + ) + .await + .expect("the failure folds"); + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("exhausted") + ); + assert_eq!( + generation_consumed(&doc, &target), + u64::from(MAX_GENERATION_STARTS) + ); + assert!( + excerpt_of(&doc, &target).is_null(), + "exhaustion clears the input" + ); + assert!( + generation_entry(&doc, &target) + .and_then(|entry| entry.get("exhaustedAt")) + .is_some_and(Value::is_i64), + "the exhaustion time is recorded" + ); + let beyond = store + .claim_generation_start(target.clone(), "attempt-beyond".to_string()) + .await + .expect("claim runs"); + assert!(beyond.is_none()); + clear_test_hooks(dir.path()); +} + +/// Pinning test: a crash mid-attempt at the cap still ends exhausted. +#[tokio::test] +async fn interrupted_start_at_the_cap_still_exhausts_at_recovery() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-third-interrupted", "Interrupted at the cap").await; + consume_failed_starts(&store, dir.path(), &target, 2).await; + store + .claim_generation_start(target.clone(), "attempt-interrupted".to_string()) + .await + .expect("claim runs") + .expect("the third start is claimable"); + // Restart: the start was persisted, never folded. + let store = open_store(dir.path()); + store + .recover_interrupted_generation() + .await + .expect("recovery runs"); + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("exhausted") + ); + assert!(excerpt_of(&doc, &target).is_null()); + assert_eq!(store.interrupted_generation_count(), 0); + let beyond = store + .claim_generation_start(target.clone(), "attempt-beyond".to_string()) + .await + .expect("claim runs"); + assert!(beyond.is_none(), "an exhausted series is never claimable"); + clear_test_hooks(dir.path()); +} + +// --------------------------------------------------------------------------- +// A newer first message arrives while a claimed request is in flight +// --------------------------------------------------------------------------- + +async fn claim_named( + store: &Arc, + target: &SessionNameRef, + attempt_id: &str, +) -> super::GenerationClaim { + store + .claim_generation_start(target.clone(), attempt_id.to_string()) + .await + .expect("claim runs") + .expect("the series is claimable") +} + +async fn fold( + store: &Arc, + claim: &super::GenerationClaim, + outcome: GenerationOutcome, +) { + store + .fold_generation_outcome( + claim.target.clone(), + claim.series_id.clone(), + claim.input_fingerprint.clone(), + outcome, + ) + .await + .expect("the outcome folds"); +} + +fn refused(attempt_id: &str) -> GenerationOutcome { + GenerationOutcome::CapabilityUnavailable { + attempt_id: attempt_id.to_string(), + } +} + +/// Only the index can correct an already captured first message. A later +/// accepted send carries the current turn and must keep the original. +async fn correct_first_message(store: &Arc, target: SessionNameRef, message: &str) { + use freshell_freshagent::naming::{NameActivity, NameActivityReason, SessionNaming}; + store + .activity(NameActivity { + target, + mode: "freshclaude".to_string(), + event_id: "corrected-first-message".to_string(), + reason: NameActivityReason::IndexUserMessage, + first_user_message: Some(message.to_string()), + cwd: None, + }) + .await + .expect("the index corrects naming input"); +} + +/// The re-arm makes the in-flight request's input stale, but its start was +/// still charged: a refusal gives it back. +#[tokio::test] +async fn a_refused_start_is_refunded_after_a_rearm_during_the_request() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-rearm-refund", "The message the request carried").await; + let claim = claim_named(&store, &target, "attempt-rearm-refund").await; + assert_eq!(claim.consumed, 1); + correct_first_message(&store, target.clone(), "A newer first message").await; + + fold(&store, &claim, refused("attempt-rearm-refund")).await; + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 0, + "the refused start is refunded" + ); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + assert!(attempt_ids_of(&doc, &target).is_empty()); + assert_eq!(attempt_starts_len(&doc, &target), 0); + + let next = claim_named(&store, &target, "attempt-after-refund").await; + assert_eq!(next.consumed, 1); + assert_eq!( + next.excerpt.as_deref(), + Some("A newer first message"), + "the next claim sends the newer input" + ); +} + +/// At the cap, a counted outcome exhausts the series even when a newer +/// first message re-armed it during the last request (the fold's input +/// guard must not leave it Eligible at three consumed with its input kept); +/// a late refund for that start and later messages never revive it. +#[tokio::test] +async fn a_rearm_during_the_last_start_still_exhausts_on_failure() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-rearm-last-failed", "The original first message").await; + consume_failed_starts(&store, dir.path(), &target, 2).await; + let claim = claim_named(&store, &target, "attempt-last").await; + assert_eq!(claim.consumed, MAX_GENERATION_STARTS); + correct_first_message( + &store, + target.clone(), + "A newer first message during the last start", + ) + .await; + + fold( + &store, + &claim, + GenerationOutcome::Failed("gemini http 500 Internal Server Error".to_string()), + ) + .await; + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("exhausted") + ); + assert_eq!( + generation_consumed(&doc, &target), + u64::from(MAX_GENERATION_STARTS) + ); + assert!( + excerpt_of(&doc, &target).is_null(), + "exhaustion drops the newer input too" + ); + assert!(generation_entry(&doc, &target) + .and_then(|entry| entry.get("exhaustedAt")) + .is_some_and(Value::is_i64)); + + // A second fold for the same start, then another message: still spent. + fold(&store, &claim, refused("attempt-last")).await; + correct_first_message(&store, target.clone(), "Yet another first message").await; + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("exhausted") + ); + assert_eq!( + generation_consumed(&doc, &target), + u64::from(MAX_GENERATION_STARTS) + ); + assert!(excerpt_of(&doc, &target).is_null()); + let beyond = store + .claim_generation_start(target.clone(), "attempt-beyond".to_string()) + .await + .expect("claim runs"); + assert!(beyond.is_none()); + clear_test_hooks(dir.path()); +} + +/// At the cap, a refused last start is given back after a re-arm during the +/// request, and the series then tries the newer input. +#[tokio::test] +async fn a_rearm_during_the_last_start_keeps_the_refund_and_the_newer_input() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-rearm-last-refused", "The original first message").await; + consume_failed_starts(&store, dir.path(), &target, 2).await; + let claim = claim_named(&store, &target, "attempt-last-refused").await; + correct_first_message(&store, target.clone(), "The newer first message").await; + + fold(&store, &claim, refused("attempt-last-refused")).await; + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 2, + "the refused last start is refunded" + ); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + + let next = claim_named(&store, &target, "attempt-last-again").await; + assert_eq!(next.consumed, MAX_GENERATION_STARTS); + assert_eq!(next.excerpt.as_deref(), Some("The newer first message")); + clear_test_hooks(dir.path()); +} + +/// A crash after a re-arm during the last start: boot recovery still sees +/// the interrupted start and exhausts the series. +#[tokio::test] +async fn a_rearm_during_the_last_start_still_exhausts_at_recovery() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-rearm-last-crash", "The original first message").await; + consume_failed_starts(&store, dir.path(), &target, 2).await; + claim_named(&store, &target, "attempt-last-interrupted").await; + correct_first_message(&store, target.clone(), "A newer first message").await; + + // Restart: the start was persisted, never folded. + let store = open_store(dir.path()); + store + .recover_interrupted_generation() + .await + .expect("recovery runs"); + let doc = document_json(dir.path()); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("exhausted") + ); + assert!(excerpt_of(&doc, &target).is_null()); + let beyond = store + .claim_generation_start(target.clone(), "attempt-beyond".to_string()) + .await + .expect("claim runs"); + assert!(beyond.is_none()); + clear_test_hooks(dir.path()); +} + +// --------------------------------------------------------------------------- +// The worker under a pause +// --------------------------------------------------------------------------- + +pub(super) async fn wait_for(deadline: Duration, what: &str, mut done: impl FnMut() -> bool) { + let until = tokio::time::Instant::now() + deadline; + while !done() { + assert!(tokio::time::Instant::now() < until, "timed out: {what}"); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +fn gaps(transport: &ScriptedTransport) -> Vec { + let times = transport.call_times.lock().unwrap().clone(); + times.windows(2).map(|pair| pair[1] - pair[0]).collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn latched_capability_pauses_without_claims() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-latched", "Paused by the gateway").await; + let transport = ScriptedTransport::new(vec![Err(capability("access_restricted"))]); + let generator = generator( + dir.path(), + keyed_auth("latched-key-value"), + transport.clone(), + windows(50, 20), + ); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + wait_for(Duration::from_secs(5), "three probes", || { + transport.calls() >= 3 + }) + .await; + // Stop the worker between probes (never mid-attempt): once the last + // refund is on disk and the next re-probe is still well ahead, nothing + // can claim before the abort. + wait_for( + Duration::from_secs(5), + "a folded refund inside a window", + || { + let doc = document_json(dir.path()); + generation_status(&doc, &target).as_deref() == Some("eligible") + && generator + .latch() + .reprobe_in(tokio::time::Instant::now()) + .is_some_and(|left| left >= Duration::from_millis(25)) + }, + ) + .await; + worker.abort(); + for gap in gaps(&transport) { + assert!( + gap >= Duration::from_millis(50), + "no claim before the re-probe time (gap {gap:?})" + ); + } + assert_eq!(generator.latch().pause_kind(), Some(PauseKind::Capability)); + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 0, + "a paused series keeps its whole allowance" + ); + assert!(!excerpt_of(&doc, &target).is_null(), "and its input"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn throttled_latches_with_growing_backoff() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-throttled", "Throttled by the quota").await; + let transport = ScriptedTransport::new(vec![ + Err(throttled()), + Err(throttled()), + Err(throttled()), + Err(throttled()), + Ok("Quota recovered name".to_string()), + ]); + let generator = generator( + dir.path(), + keyed_auth("throttled-key-value"), + transport.clone(), + windows(60_000, 20), + ); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + let until = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if let Some(update) = get_one(&store, target.clone()).await { + if update.record.source == NameSource::FreshellAi { + assert_eq!(update.record.name, "Quota recovered name"); + break; + } + } + assert!( + tokio::time::Instant::now() < until, + "the fifth probe names the session" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + worker.abort(); + assert_eq!( + transport.calls(), + 5, + "never back-to-back: one probe per window" + ); + let expected = [20u64, 40, 80, 160]; + for (gap, minimum) in gaps(&transport).iter().zip(expected) { + assert!( + *gap >= Duration::from_millis(minimum), + "the throttle window doubles (gap {gap:?}, expected at least {minimum} ms)" + ); + } + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 1, + "only the successful attempt consumed" + ); + assert_eq!( + generator.latch().pause_kind(), + None, + "the success resets the pause" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_credential_means_no_claims() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-no-credential", "Nothing to name with").await; + let transport = ScriptedTransport::new(vec![Ok("Never requested".to_string())]); + let generator = generator( + dir.path(), + GeminiSessionNameAuth::direct_for_test(AiKeyCell::default()), + transport.clone(), + LatchWindows::default(), + ); + assert!(!generator.capability_ready().await); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + tokio::time::sleep(Duration::from_millis(1_200)).await; + worker.abort(); + assert_eq!(transport.calls(), 0); + let doc = document_json(dir.path()); + assert_eq!(generation_consumed(&doc, &target), 0); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); +} + +// --------------------------------------------------------------------------- +// Logs +// --------------------------------------------------------------------------- + +pub(super) fn named<'a>( + events: &'a [CapturedTraceEvent], + name: &str, +) -> Vec<&'a CapturedTraceEvent> { + events + .iter() + .filter(|event| event.message.split(':').next() == Some(name)) + .collect() +} + +fn assert_no_leak(events: &[CapturedTraceEvent], secrets: &[&str]) { + for event in events { + let rendered = format!("{} {:?}", event.message, event.fields); + for secret in secrets { + assert!( + !rendered.contains(secret), + "a log line leaked {secret:?}: {rendered}" + ); + } + } +} + +#[tokio::test] +async fn capability_logs_once_per_transition() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let store = open_store(dir.path()); + armed_target(&store, "h-logs", "Log the pause once").await; + // The key route through an ordinary proxy that refuses the key (the + // gateway's 400 answer): a capability failure on every attempt. + let proxy = start_fake_proxy("Unused", ProxyMode::AccessRestricted).await; + let key = "never-log-this-key-value-123"; + let (auth, http) = session_name_http( + AiKeyCell::init(Some(key.to_string()), None), + GEMINI_URL, + Some(&proxy.ordinary_url()), + ); + let generator = generator(dir.path(), auth, http, LatchWindows::default()); + for _ in 0..3 { + let items = store.generation_work_snapshot(); + assert_eq!(items.len(), 1, "a refunded series is claimable again"); + generator.run_due_attempt(&store, &items[0]).await; + } + assert_eq!(proxy.recorded().len(), 3, "three failures"); + let events = events.snapshot(); + let warnings = named(&events, "session_names.generation_capability_unavailable"); + assert_eq!(warnings.len(), 1, "one WARN per transition: {events:#?}"); + assert_eq!(warnings[0].level, tracing::Level::WARN); + assert_eq!( + warnings[0].fields.get("code").map(String::as_str), + Some("access_restricted") + ); + assert_eq!( + warnings[0].fields.get("route").map(String::as_str), + Some("key") + ); + assert_eq!( + warnings[0].fields.get("status").map(String::as_str), + Some("400") + ); + assert!( + named(&events, "session_names.generation_failed").is_empty(), + "a refunded failure is not a consumed failure" + ); + assert_no_leak( + &events, + &[ + key, + &format!("127.0.0.1:{}", proxy.port), + "generativelanguage", + ], + ); + let doc = document_json(dir.path()); + assert_eq!(generation_consumed(&doc, &pending("h-logs")), 0); +} + +#[tokio::test] +async fn a_consumed_failure_logs_its_status_and_provider_code() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let store = open_store(dir.path()); + armed_target(&store, "h-consumed", "Log the consumed failure").await; + let transport = ScriptedTransport::new(vec![Err(classify_gemini_failure( + 500, + br#"{"error":{"code":500,"status":"INTERNAL"}}"#, + ))]); + let generator = generator( + dir.path(), + keyed_auth("consumed-key-value"), + transport, + LatchWindows::default(), + ); + let items = store.generation_work_snapshot(); + generator.run_due_attempt(&store, &items[0]).await; + let events = events.snapshot(); + let failed = named(&events, "session_names.generation_failed"); + assert_eq!(failed.len(), 1, "{events:#?}"); + assert_eq!( + failed[0].fields.get("status").map(String::as_str), + Some("500") + ); + assert_eq!( + failed[0].fields.get("provider_code").map(String::as_str), + Some("INTERNAL") + ); + assert_eq!( + generation_consumed(&document_json(dir.path()), &pending("h-consumed")), + 1 + ); +} + +#[tokio::test] +async fn a_proxy_error_never_reaches_logs() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let store = open_store(dir.path()); + armed_target(&store, "h-proxy-leak", "Keep the proxy out of logs").await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + // A OneCLI-marked proxy URL with credentials, nothing listening: reqwest's + // own error text names the proxy. + let proxy_url = format!("http://aoc_leak_marker:leak-password@127.0.0.1:{port}"); + let (auth, http) = session_name_http(AiKeyCell::default(), GEMINI_URL, Some(&proxy_url)); + let failure = SessionNameTransport::generate_content(http.as_ref(), "p".to_string(), 30) + .await + .expect_err("nothing listens on the proxy"); + assert_eq!(failure.code(), "proxy_unreachable"); + assert!(!failure.to_string().contains("leak")); + let generator = generator(dir.path(), auth, http, LatchWindows::default()); + let items = store.generation_work_snapshot(); + generator.run_due_attempt(&store, &items[0]).await; + let events = events.snapshot(); + assert_eq!( + named(&events, "session_names.generation_capability_unavailable").len(), + 1, + "{events:#?}" + ); + assert_no_leak( + &events, + &["aoc_leak_marker", "leak-password", &format!(":{port}")], + ); +} + +#[tokio::test] +async fn boot_log_reports_key_route_transport() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let key = "boot-log-key-value-456"; + let transport = ScriptedTransport::new(vec![Ok(String::new())]); + for (proxy, transport_name, marker) in [ + ("http://%61oc_boot_marker@127.0.0.1:9", "direct", "true"), + ("http://127.0.0.1:9", "ambient_proxy", "false"), + ] { + let (auth, _http) = session_name_http( + AiKeyCell::init(Some(key.to_string()), None), + GEMINI_URL, + Some(proxy), + ); + generator(dir.path(), auth, transport.clone(), LatchWindows::default()).log_boot_route(); + let snapshot = events.snapshot(); + let selected = named(&snapshot, "session_names.generation_route_selected"); + let last = selected.last().expect("the route is logged at boot"); + assert_eq!(last.level, tracing::Level::INFO); + assert_eq!(last.fields.get("route").map(String::as_str), Some("key")); + assert_eq!( + last.fields.get("key_route_transport").map(String::as_str), + Some(transport_name) + ); + assert_eq!( + last.fields.get("onecli_marker").map(String::as_str), + Some(marker) + ); + assert_eq!( + last.fields.get("key_configured").map(String::as_str), + Some("true") + ); + } + let snapshot = events.snapshot(); + assert!(named( + &snapshot, + "session_names.generation_route_inherited_identity" + ) + .is_empty()); + assert_no_leak(&snapshot, &[key, "boot_marker", "127.0.0.1"]); +} + +#[tokio::test] +async fn inherited_proxy_route_logs_one_warning() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let transport = ScriptedTransport::new(vec![Ok(String::new())]); + let (auth, _http) = session_name_http( + AiKeyCell::default(), + GEMINI_URL, + Some("http://%61oc_boot_marker@127.0.0.1:9"), + ); + let inherited = generator(dir.path(), auth, transport.clone(), LatchWindows::default()); + inherited.log_boot_route(); + inherited.notify_capability_change(); + let snapshot = events.snapshot(); + let warnings = named( + &snapshot, + "session_names.generation_route_inherited_identity", + ); + assert_eq!(warnings.len(), 1, "{snapshot:#?}"); + assert_eq!(warnings[0].level, tracing::Level::WARN); + let selected = named(&snapshot, "session_names.generation_route_selected"); + assert_eq!(selected.len(), 2, "boot, then the capability change"); + assert_eq!( + selected[0].fields.get("route").map(String::as_str), + Some("inherited_onecli") + ); + assert_eq!( + selected[0].fields.get("key_configured").map(String::as_str), + Some("false") + ); + assert_no_leak(&snapshot, &["boot_marker", "127.0.0.1"]); +} + +// --------------------------------------------------------------------------- +// A key saved in Settings +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn saved_key_clears_the_latch() { + let dir = temp_data_dir(); + std::fs::create_dir_all(dir.path().join(".freshell")).unwrap(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-saved-key", "Take over from the gateway").await; + let gemini = start_fake_gemini("Saved key name", Some("saved-valid-key-value")).await; + let proxy = start_fake_proxy("Unused", ProxyMode::AccessRestricted).await; + let key_cell = AiKeyCell::default(); + let (auth, http) = session_name_http( + key_cell.clone(), + &gemini.base_url, + Some(&proxy.onecli_url()), + ); + let settings = settings_for(dir.path()); + let generator = Arc::new( + SessionNameGenerator::new(settings.clone(), auth, http) + .with_latch_windows(windows(600_000, 600_000)), + ); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + wait_for( + Duration::from_secs(5), + "the inherited route is refused", + || generator.latch().pause_kind() == Some(PauseKind::Capability), + ) + .await; + assert_eq!(proxy.recorded().len(), 1); + + // Save the key through the real Settings route. + let auth_token = Arc::new("settings-token".to_string()); + let (broadcast_tx, _rx) = tokio::sync::broadcast::channel::(16); + let broadcast_tx = Arc::new(broadcast_tx); + let log_secrets = freshell_runtime_observability::LiveSecrets::default(); + let state = crate::settings_store::SettingsRouterState { + store: settings, + auth_token: Arc::clone(&auth_token), + broadcast_tx: Arc::clone(&broadcast_tx), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({}), + ), + registry: freshell_terminal::TerminalRegistry::new(), + ai_key: key_cell.clone(), + log_secrets: log_secrets.clone(), + naming_generation: Some(Arc::clone(&generator)), + }; + let response = crate::settings_store::router(state) + .oneshot( + axum::http::Request::builder() + .method("PATCH") + .uri("/api/settings") + .header("x-auth-token", "settings-token") + .header("content-type", "application/json") + .body(Body::from( + r#"{"ai":{"geminiApiKey":"saved-valid-key-value"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + assert!( + log_secrets.redact("x saved-valid-key-value y".to_string()) == "x ***REDACTED*** y", + "the saved key is a live log secret" + ); + + let until = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(update) = get_one(&store, target.clone()).await { + if update.record.source == NameSource::FreshellAi { + assert_eq!(update.record.name, "Saved key name"); + break; + } + } + assert!( + tokio::time::Instant::now() < until, + "the next poll claims on the key route" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + worker.abort(); + assert_eq!( + *gemini.keys.lock().unwrap(), + vec![Some("saved-valid-key-value".to_string())], + "one keyed request, direct" + ); + assert!( + proxy.recorded().iter().all(|request| !request.has_api_key), + "the OneCLI proxy never sees the key" + ); + assert_eq!(proxy.recorded().len(), 1); + assert_eq!(generator.latch().pause_kind(), None); +} + +/// A scripted transport whose FIRST request waits until the test releases +/// it (a request still in flight when the key changes). +struct HoldFirstRequest { + script: Arc, + release: Arc, +} + +impl SessionNameTransport for HoldFirstRequest { + fn generate_content( + &self, + prompt: String, + max_output_tokens: u32, + ) -> crate::ai_title::BoxFuture> { + let first = self.script.calls() == 0; + let answer = self.script.generate_content(prompt, max_output_tokens); + let release = Arc::clone(&self.release); + Box::pin(async move { + if first { + release + .acquire() + .await + .expect("the release gate stays open") + .forget(); + } + answer.await + }) + } +} + +/// A refusal of the OLD key that lands after a new key was saved (a partial +/// key saved while the user typed, or the refused OneCLI route) must not +/// pause naming: the new key is tried at the next poll, and the refused +/// attempt is still refunded. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refusal_from_before_a_key_change_does_not_pause_naming() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-stale-refusal", "Outlive the old key").await; + let script = ScriptedTransport::new(vec![ + Err(capability("API_KEY_INVALID")), + Ok("Name from the saved key".to_string()), + ]); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let transport = Arc::new(HoldFirstRequest { + script: script.clone(), + release: Arc::clone(&release), + }); + let key_cell = AiKeyCell::init(Some("partial-key-typed".to_string()), None); + let generator = generator( + dir.path(), + GeminiSessionNameAuth::direct_for_test(key_cell.clone()), + transport, + windows(600_000, 600_000), + ); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + wait_for( + Duration::from_secs(5), + "the old key's request is in flight", + || script.calls() == 1, + ) + .await; + + // The full key is saved while that request is still out. + let log_secrets = freshell_runtime_observability::LiveSecrets::default(); + assert!(crate::session_name_capability::apply_saved_gemini_key( + &key_cell, + &log_secrets, + Some(generator.as_ref()), + Some("full-saved-key-value"), + )); + release.add_permits(1); + + let until = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(update) = get_one(&store, target.clone()).await { + if update.record.source == NameSource::FreshellAi { + assert_eq!(update.record.name, "Name from the saved key"); + break; + } + } + assert!( + tokio::time::Instant::now() < until, + "the old key's refusal paused naming: the saved key was never tried (pause {:?})", + generator.latch().pause_kind() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + worker.abort(); + assert_eq!(script.calls(), 2); + assert_eq!(generator.latch().pause_kind(), None); + assert_eq!( + generation_consumed(&document_json(dir.path()), &target), + 1, + "the old key's refused attempt was refunded; only the answered one counts" + ); +} + +/// The superseded refusal says once, at INFO and without the keys, why +/// naming did not pause; no pause WARN is logged. +#[tokio::test] +async fn a_superseded_refusal_logs_why_naming_is_not_paused() { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, "h-stale-log", "Explain the ignored refusal").await; + let script = ScriptedTransport::new(vec![Err(capability("API_KEY_INVALID"))]); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let transport = Arc::new(HoldFirstRequest { + script: script.clone(), + release: Arc::clone(&release), + }); + let key_cell = AiKeyCell::init(Some("stale-log-old-key".to_string()), None); + let generator = generator( + dir.path(), + GeminiSessionNameAuth::direct_for_test(key_cell.clone()), + transport, + windows(600_000, 600_000), + ); + let items = store.generation_work_snapshot(); + let log_secrets = freshell_runtime_observability::LiveSecrets::default(); + let save_during_the_request = async { + wait_for(Duration::from_secs(5), "the request is in flight", || { + script.calls() == 1 + }) + .await; + crate::session_name_capability::apply_saved_gemini_key( + &key_cell, + &log_secrets, + Some(generator.as_ref()), + Some("stale-log-new-key"), + ); + release.add_permits(1); + }; + tokio::join!( + generator.run_due_attempt(&store, &items[0]), + save_during_the_request + ); + + let events = events.snapshot(); + let superseded = named(&events, "session_names.generation_outcome_superseded"); + assert_eq!(superseded.len(), 1, "{events:#?}"); + assert_eq!(superseded[0].level, tracing::Level::INFO); + assert_eq!( + superseded[0].fields.get("code").map(String::as_str), + Some("API_KEY_INVALID") + ); + assert!( + named(&events, "session_names.generation_capability_unavailable").is_empty(), + "no pause is announced: {events:#?}" + ); + assert_no_leak(&events, &["stale-log-old-key", "stale-log-new-key"]); + assert_eq!(generator.latch().pause_kind(), None); + assert_eq!(generation_consumed(&document_json(dir.path()), &target), 0); +} diff --git a/crates/freshell-server/src/session_name_generation_pause_tests.rs b/crates/freshell-server/src/session_name_generation_pause_tests.rs new file mode 100644 index 000000000..4567eef44 --- /dev/null +++ b/crates/freshell-server/src/session_name_generation_pause_tests.rs @@ -0,0 +1,262 @@ +//! The naming pause in conditions the scripted capability tests do not +//! reach: a route whose packets are silently dropped (a firewall that +//! answers nothing, so the connect never completes), and a refusal that +//! depends on one session's request rather than on the credential or the +//! route. The real worker and the real temp-file store run throughout; the +//! dropped-route tests use the real HTTP transport against a loopback port +//! that drops every connect, in tokio virtual time (no wall-clock wait). + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use freshell_protocol::session_names::NameSource; + +use super::capability_tests::{ + armed_target, generator, keyed_auth, named, wait_for, windows, GENERATION_TARGET, +}; +use super::tests::{ + document_json, generation_consumed, generation_status, get_one, open_store, temp_data_dir, + NoRouteNative, +}; +use super::GENERATION_REQUEST_TIMEOUT; +use crate::ai_title::{AiKeyCell, BoxFuture}; +use crate::gemini_failure::{classify_gemini_failure, GeminiFailure}; +use crate::session_name_capability::tests::session_name_http; +use crate::session_name_capability::{ + KeyRouteTransport, LatchWindows, NamingRoute, PauseKind, SessionNameTransport, +}; +use crate::session_name_native::{select_generation, SessionNameWorker}; +use crate::test_trace_capture::scoped_trace_capture; + +/// A loopback port whose connects never complete, as behind a firewall that +/// silently drops packets: a listener that never accepts, with its accept +/// queue already full, so the kernel drops every further SYN instead of +/// answering it. (A refused connect fails at once and would not show what a +/// dropped one does.) +struct DroppedConnects { + addr: std::net::SocketAddr, + _listener: tokio::net::TcpListener, + _queued: Vec, +} + +impl DroppedConnects { + fn start() -> Self { + let socket = tokio::net::TcpSocket::new_v4().expect("a socket"); + socket + .bind("127.0.0.1:0".parse().unwrap()) + .expect("a loopback port"); + let listener = socket.listen(0).expect("listening"); + let addr = listener.local_addr().unwrap(); + let mut queued = Vec::new(); + // Fill the accept queue until a connect is dropped: that proves + // every later connect to this port hangs too. + for _ in 0..64 { + match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(250)) { + Ok(stream) => queued.push(stream), + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => { + return Self { + addr, + _listener: listener, + _queued: queued, + }; + } + Err(error) => panic!("filling the accept queue failed: {error}"), + } + } + panic!("the accept queue never filled, so connects to it are not dropped"); + } + + fn base_url(&self) -> String { + format!("http://{}/v1beta", self.addr) + } + + fn proxy_url(&self) -> String { + format!("http://{}", self.addr) + } +} + +/// Run one due attempt and check it ended as a naming pause that used no +/// attempt: refunded, the series still eligible, the pause latched with +/// `code`, one WARN and no consumed-failure line — and all of it before the +/// worker's 20-second request bound would have charged the attempt. +async fn assert_dropped_connect_pauses( + handle: &str, + key: &str, + base_url: &str, + proxy: &str, + route: NamingRoute, + code: &str, +) { + let (_guard, events) = scoped_trace_capture(GENERATION_TARGET); + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let target = armed_target(&store, handle, "Name this session behind a firewall").await; + let (auth, http) = session_name_http( + AiKeyCell::init(Some(key.to_string()), None), + base_url, + Some(proxy), + ); + assert_eq!(auth.route(), route); + let generator = generator(dir.path(), auth, http, LatchWindows::default()); + let items = store.generation_work_snapshot(); + assert_eq!(items.len(), 1); + + let began = tokio::time::Instant::now(); + generator.run_due_attempt(&store, &items[0]).await; + assert!( + began.elapsed() < GENERATION_REQUEST_TIMEOUT, + "the connect failed before the request bound ({:?})", + began.elapsed() + ); + + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &target), + 0, + "a dropped connect uses no attempt" + ); + assert_eq!( + generation_status(&doc, &target).as_deref(), + Some("eligible") + ); + assert_eq!(generator.latch().pause_kind(), Some(PauseKind::Capability)); + let events = events.snapshot(); + let warnings = named(&events, "session_names.generation_capability_unavailable"); + assert_eq!(warnings.len(), 1, "one pause WARN: {events:#?}"); + assert_eq!( + warnings[0].fields.get("code").map(String::as_str), + Some(code) + ); + assert!( + named(&events, "session_names.generation_failed").is_empty(), + "not a consumed failure: {events:#?}" + ); +} + +/// Residual 19: a keyed request goes direct past a OneCLI-marked proxy; a +/// host whose firewall drops that direct route pauses naming with a +/// `direct_unreachable` WARN and uses no attempt. +#[tokio::test(start_paused = true)] +async fn a_dropped_direct_connect_pauses_naming_without_using_the_attempt() { + let dropped = DroppedConnects::start(); + assert_dropped_connect_pauses( + "h-dropped-direct", + "dropped-direct-key", + &dropped.base_url(), + "http://aoc_unused_marker@127.0.0.1:9", + NamingRoute::Key(KeyRouteTransport::Direct), + "direct_unreachable", + ) + .await; +} + +/// The same through an ordinary ambient proxy that drops the connect. +#[tokio::test(start_paused = true)] +async fn a_dropped_proxy_connect_pauses_naming_without_using_the_attempt() { + let dropped = DroppedConnects::start(); + assert_dropped_connect_pauses( + "h-dropped-proxy", + "dropped-proxy-key", + "http://generativelanguage.googleapis.com/v1beta", + &dropped.proxy_url(), + NamingRoute::Key(KeyRouteTransport::AmbientProxy), + "proxy_unreachable", + ) + .await; +} + +/// A transport whose refusal depends on the request, as a proxy that +/// inspects traffic refuses content it blocks (a bare 403, which pauses +/// naming like any permission refusal): a prompt holding `blocked` is +/// refused, any other prompt is named. +struct ContentRefusingTransport { + blocked: &'static str, + refused: Mutex, +} + +impl SessionNameTransport for ContentRefusingTransport { + fn generate_content( + &self, + prompt: String, + _max_output_tokens: u32, + ) -> BoxFuture> { + let answer = if prompt.contains(self.blocked) { + *self.refused.lock().unwrap() += 1; + Err(classify_gemini_failure(403, b"")) + } else { + Ok("Parser bug fix".to_string()) + }; + Box::pin(async move { answer }) + } +} + +/// The probe after a pause rotates: a session whose own request is +/// refused is not the one probed after every window, so the other armed +/// sessions still get named. The refused one keeps its whole allowance. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_session_does_not_hold_the_probe_after_a_pause() { + let dir = temp_data_dir(); + let store = open_store(dir.path()); + let refused = armed_target(&store, "h-a-refused", "Deploy with the BLOCKED-WORD token").await; + let clean = armed_target(&store, "h-b-clean", "Fix the parser bug").await; + let first = select_generation(&store.generation_work_snapshot()).expect("two armed series"); + assert_eq!(first.target, refused, "the refused session is probed first"); + + let transport = Arc::new(ContentRefusingTransport { + blocked: "BLOCKED-WORD", + refused: Mutex::new(0), + }); + let generator = generator( + dir.path(), + keyed_auth("rotation-key-value"), + transport.clone(), + windows(50, 20), + ); + let worker = SessionNameWorker::start( + Arc::clone(&store), + Arc::new(NoRouteNative), + Arc::clone(&generator), + ); + let until = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(update) = get_one(&store, clean.clone()).await { + if update.record.source == NameSource::FreshellAi { + assert_eq!(update.record.name, "Parser bug fix"); + break; + } + } + assert!( + tokio::time::Instant::now() < until, + "the clean session is named after the first pause (refused {} times)", + *transport.refused.lock().unwrap() + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // The success ends the pause, so the refused session is probed (and + // refused) again at once. Stop the worker between probes: its refund is + // on disk and the next re-probe is still well ahead. + wait_for( + Duration::from_secs(5), + "a folded refund inside a window", + || { + let doc = document_json(dir.path()); + generation_status(&doc, &refused).as_deref() == Some("eligible") + && generator + .latch() + .reprobe_in(tokio::time::Instant::now()) + .is_some_and(|left| left >= Duration::from_millis(25)) + }, + ) + .await; + worker.abort(); + let doc = document_json(dir.path()); + assert_eq!( + generation_consumed(&doc, &refused), + 0, + "the refused session keeps its allowance" + ); + assert_eq!( + generation_status(&doc, &refused).as_deref(), + Some("eligible") + ); +} diff --git a/crates/freshell-server/src/session_name_generation_series.rs b/crates/freshell-server/src/session_name_generation_series.rs new file mode 100644 index 000000000..a8a09b853 --- /dev/null +++ b/crates/freshell-server/src/session_name_generation_series.rs @@ -0,0 +1,114 @@ +//! Name-reliability Task 6: the generation series' attempt accounting. +//! +//! A claim charges one of the bounded starts and marks the series in flight; +//! it never exhausts and never drops the naming input, because only the +//! fold knows whether the attempt really counted. The fold either refunds +//! the start (a capability or quota failure: the request never had a fair +//! chance) or settles it as consumed (an empty, invalid or failed answer): +//! at the cap that exhausts the series and drops its input, below the cap it +//! schedules the bounded retry. An interrupted start (a crash between claim +//! and fold) is still settled as consumed by boot recovery. +//! +//! A newer first message can re-arm the series while its request is in +//! flight: the series takes the new input but stays in flight, and the fold +//! of the old request ([`settle_superseded_start`]) still settles the +//! charged start — refunded, or counted (exhausting at the cap) — before the +//! newer input is tried. +//! +//! A child module of the store so it reads the series internals without +//! widening their visibility. + +use super::{generation_retry_delay_ms, GenerationState, GenerationStatus, MAX_GENERATION_STARTS}; +use crate::session_name_generation::GenerationOutcome; + +/// Charge one start for `attempt_id` at `now`: consumed +1, the attempt +/// recorded, the series in flight. Returns the consumed count. +pub(super) fn charge_start(series: &mut GenerationState, attempt_id: &str, now: i64) -> u32 { + series.consumed += 1; + series.attempt_ids.push(attempt_id.to_string()); + series.attempt_starts.push(now); + series.status = GenerationStatus::InFlight; + series.consumed +} + +/// Give back the in-flight start of `attempt_id`, refused at `now`: +/// consumed −1, its attempt id and start time removed, the series Eligible +/// again with its input. Its due time becomes `now`: still due (the naming +/// pause, not the series, decides when naming resumes), but behind every +/// series that was already waiting, so the probe after the pause tries +/// another session first and a refusal caused by this one request cannot +/// hold naming for the rest. `false` (nothing changed) when the series is +/// not in flight or never recorded that attempt. +pub(super) fn refund_start(series: &mut GenerationState, attempt_id: &str, now: i64) -> bool { + if series.status != GenerationStatus::InFlight { + return false; + } + let Some(position) = series.attempt_ids.iter().rposition(|id| id == attempt_id) else { + return false; + }; + series.attempt_ids.remove(position); + // Only the guard-holding worker claims, one attempt at a time, so the + // refunded claim's start is the latest one recorded. + if let Some(latest) = series + .attempt_starts + .iter() + .enumerate() + .max_by_key(|(_, start)| **start) + .map(|(index, _)| index) + { + series.attempt_starts.remove(latest); + } + series.consumed = series.consumed.saturating_sub(1); + series.status = GenerationStatus::Eligible; + series.next_due = Some(now); + true +} + +/// Settle a start that counted: at the cap the series is exhausted (the +/// exhaustion time recorded once) and its input dropped; below it the retry +/// is scheduled from `now`. +pub(super) fn settle_consumed_start(series: &mut GenerationState, now: i64) { + if series.status == GenerationStatus::Exhausted || series.consumed >= MAX_GENERATION_STARTS { + exhaust(series, now); + } else { + series.status = GenerationStatus::Eligible; + series.next_due = Some(now + generation_retry_delay_ms(series.consumed).unwrap_or(0)); + } +} + +/// Settle the in-flight start of a request built from input that a newer +/// first message has since replaced. The old answer is never accepted, but +/// the start it was charged still resolves: a refusal (`CapabilityUnavailable` +/// / `Throttled`) gives it back by its attempt id; any other outcome counted +/// it, which exhausts the series at the cap (dropping the newer input too) +/// and otherwise leaves the series Eligible with the newer input due at +/// once (it has never been tried). `false` (nothing changed) when the series +/// has no start in flight. +pub(super) fn settle_superseded_start( + series: &mut GenerationState, + outcome: &GenerationOutcome, + now: i64, +) -> bool { + match outcome { + GenerationOutcome::CapabilityUnavailable { attempt_id } + | GenerationOutcome::Throttled { attempt_id } => refund_start(series, attempt_id, now), + GenerationOutcome::Answer(_) | GenerationOutcome::Empty | GenerationOutcome::Failed(_) => { + if series.status != GenerationStatus::InFlight { + return false; + } + if series.consumed >= MAX_GENERATION_STARTS { + exhaust(series, now); + } else { + series.status = GenerationStatus::Eligible; + } + true + } + } +} + +fn exhaust(series: &mut GenerationState, now: i64) { + series.status = GenerationStatus::Exhausted; + series.exhausted_at.get_or_insert(now); + series.next_due = None; + series.excerpt = None; +} diff --git a/crates/freshell-server/src/session_name_generation_tests.rs b/crates/freshell-server/src/session_name_generation_tests.rs index 37071c1aa..fc0573c8d 100644 --- a/crates/freshell-server/src/session_name_generation_tests.rs +++ b/crates/freshell-server/src/session_name_generation_tests.rs @@ -26,7 +26,9 @@ use freshell_protocol::session_names::{ use serde_json::Value; use super::{GenerationOutcome, IndexedNameInput, SessionNameGenerator}; -use crate::ai_title::{AiKeyCell, BoxFuture, GeminiHttp, GeminiSessionNameHttp, GeminiTransport}; +use crate::ai_title::{AiKeyCell, BoxFuture, GeminiSessionNameAuth, GeminiSessionNameHttp}; +use crate::gemini_failure::{classify_gemini_failure, GeminiFailure}; +use crate::session_name_capability::SessionNameTransport; use crate::session_name_native::{ NativeCallResult, NativeFuture, NativeNameAttempt, NativeNameBackend, NativeNameReadback, NativeNameTarget, NativeProbeFuture, SessionNameWorker, @@ -40,15 +42,15 @@ use crate::session_names::{ // Helpers // --------------------------------------------------------------------------- -fn temp_data_dir() -> tempfile::TempDir { +pub(super) fn temp_data_dir() -> tempfile::TempDir { tempfile::tempdir().expect("tempdir") } -fn open_store(dir: &Path) -> Arc { +pub(super) fn open_store(dir: &Path) -> Arc { SessionNames::open(dir.to_path_buf()).expect("open store") } -fn pending(id: &str) -> SessionNameRef { +pub(super) fn pending(id: &str) -> SessionNameRef { SessionNameRef::Pending { id: id.to_string() } } @@ -77,7 +79,7 @@ fn verified_acquisition(location: NativeLocation) -> NativeAcquisition { } } -async fn ensure_pending( +pub(super) async fn ensure_pending( store: &Arc, handle: &str, provider: NamedProvider, @@ -103,6 +105,7 @@ async fn rename_user( name: name.to_string(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await } @@ -143,13 +146,16 @@ async fn observe_provider_title( .await } -async fn get_one(store: &Arc, target: SessionNameRef) -> Option { +pub(super) async fn get_one( + store: &Arc, + target: SessionNameRef, +) -> Option { let updates = store.get(vec![target]).await.expect("get succeeds"); updates.into_iter().next() } /// Arm one record's generation series through the accepted-input path. -async fn arm_with_message( +pub(super) async fn arm_with_message( store: &Arc, target: SessionNameRef, mode: &str, @@ -169,23 +175,23 @@ async fn arm_with_message( /// The persisted document JSON (black-box: the series/retry state lives in /// the internal sections; the file is the contract). -fn document_json(dir: &Path) -> Value { +pub(super) fn document_json(dir: &Path) -> Value { let bytes = std::fs::read(dir.join("session-names.json")).expect("document exists"); serde_json::from_slice(&bytes).expect("document parses") } /// The generation series entry for one target (black-box). -fn generation_entry<'a>(doc: &'a Value, target: &SessionNameRef) -> Option<&'a Value> { +pub(super) fn generation_entry<'a>(doc: &'a Value, target: &SessionNameRef) -> Option<&'a Value> { doc["generation"].as_object()?.get(&name_ref_key(target)) } -fn generation_status(doc: &Value, target: &SessionNameRef) -> Option { +pub(super) fn generation_status(doc: &Value, target: &SessionNameRef) -> Option { generation_entry(doc, target)?["status"] .as_str() .map(str::to_string) } -fn generation_consumed(doc: &Value, target: &SessionNameRef) -> u64 { +pub(super) fn generation_consumed(doc: &Value, target: &SessionNameRef) -> u64 { generation_entry(doc, target) .and_then(|entry| entry["consumed"].as_u64()) .unwrap_or(0) @@ -223,8 +229,12 @@ impl CountingTransport { } } -impl GeminiTransport for CountingTransport { - fn generate_content(&self, _prompt: String, _max: u32) -> BoxFuture> { +impl SessionNameTransport for CountingTransport { + fn generate_content( + &self, + _prompt: String, + _max: u32, + ) -> BoxFuture> { self.calls.fetch_add(1, Ordering::SeqCst); let answer = self.answer.lock().unwrap().clone(); Box::pin(async move { Ok(answer) }) @@ -241,8 +251,12 @@ struct FileGatedTransport { held_sentinel: PathBuf, } -impl GeminiTransport for FileGatedTransport { - fn generate_content(&self, _prompt: String, _max: u32) -> BoxFuture> { +impl SessionNameTransport for FileGatedTransport { + fn generate_content( + &self, + _prompt: String, + _max: u32, + ) -> BoxFuture> { self.calls.fetch_add(1, Ordering::SeqCst); let answer = self.answer.clone(); let gate = self.gate_path.clone(); @@ -262,7 +276,8 @@ impl GeminiTransport for FileGatedTransport { } } -/// A transport that always fails (the bounded-retry driver). +/// A transport that always fails with a consuming failure (the bounded-retry +/// driver). struct FailingTransport { calls: AtomicU32, } @@ -275,15 +290,20 @@ impl FailingTransport { } } -impl GeminiTransport for FailingTransport { - fn generate_content(&self, _prompt: String, _max: u32) -> BoxFuture> { +impl SessionNameTransport for FailingTransport { + fn generate_content( + &self, + _prompt: String, + _max: u32, + ) -> BoxFuture> { self.calls.fetch_add(1, Ordering::SeqCst); - Box::pin(async move { Err("scripted provider failure".to_string()) }) + // A consuming failure (a provider 500), never a capability pause. + Box::pin(async move { Err(classify_gemini_failure(500, b"scripted provider failure")) }) } } /// A native backend that never has a route: native work stays paused. -struct NoRouteNative; +pub(super) struct NoRouteNative; impl NativeNameBackend for NoRouteNative { fn read(&self, _target: NativeNameTarget) -> NativeFuture { @@ -303,14 +323,14 @@ impl NativeNameBackend for NoRouteNative { } } -fn settings_for(dir: &Path) -> crate::settings_store::SettingsStore { +pub(super) fn settings_for(dir: &Path) -> crate::settings_store::SettingsStore { crate::settings_store::SettingsStore::load(Some(dir), vec![]) } fn generator_with( dir: &Path, key: Option<&str>, - transport: Arc, + transport: Arc, ) -> Arc { Arc::new(SessionNameGenerator::new( settings_for(dir), @@ -323,7 +343,7 @@ fn generator_with( } /// A local HTTP Gemini fixture whose responses are HELD until released — -/// the real `GeminiHttp` transport rides it, so the request bound, the wire +/// the real session-name HTTP transport rides it, so the request bound, the wire /// contract, and the gated release are all real. struct GatedHttpGemini { base_url: String, @@ -372,10 +392,10 @@ async fn start_gated_http_gemini(reply: &str) -> GatedHttpGemini { } impl GatedHttpGemini { - fn transport(&self, key: &str) -> Arc { - Arc::new(GeminiHttp::new( - reqwest::Client::new(), - AiKeyCell::init(Some(key.to_string()), None), + fn transport(&self, key: &str) -> Arc { + Arc::new(GeminiSessionNameHttp::new( + reqwest::Client::builder().no_proxy(), + GeminiSessionNameAuth::direct_for_test(AiKeyCell::init(Some(key.to_string()), None)), self.base_url.clone(), )) } @@ -385,59 +405,6 @@ impl GatedHttpGemini { } } -struct OneCliFallbackGemini { - base_url: String, - observed_fallback_headers: Arc>>, -} - -async fn start_onecli_fallback_gemini() -> OneCliFallbackGemini { - use axum::body::Body; - use axum::routing::post; - use axum::{http::Response, Router}; - - let observed_fallback_headers = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&observed_fallback_headers); - let requests = Arc::new(AtomicU32::new(0)); - let request_count = Arc::clone(&requests); - let app = Router::new().route( - "/v1beta/models/gemini-3.5-flash-lite:generateContent", - post(move |headers: axum::http::HeaderMap| { - let observed = Arc::clone(&observed); - let request_count = Arc::clone(&request_count); - async move { - observed.lock().unwrap().push( - headers - .get("x-goog-api-key") - .and_then(|value| value.to_str().ok()) - == Some("worker-fallback-sentinel"), - ); - let response_number = request_count.fetch_add(1, Ordering::SeqCst); - let (status, body) = if response_number == 0 { - ( - axum::http::StatusCode::UNAUTHORIZED, - r#"{"error":"credential_not_found"}"#, - ) - } else { - ( - axum::http::StatusCode::OK, - r#"{"candidates":[{"content":{"parts":[{"text":"Sardine crash investigation"}]}}]}"#, - ) - }; - let mut response = Response::new(Body::from(body)); - *response.status_mut() = status; - response - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - OneCliFallbackGemini { - base_url: format!("http://{addr}/v1beta"), - observed_fallback_headers, - } -} - // --------------------------------------------------------------------------- // Activity admission: the six modes, fallback, excluded modes // --------------------------------------------------------------------------- @@ -711,35 +678,35 @@ async fn releasing_the_gated_http_fixture_saves_a_short_ai_name() { assert_eq!(sync.desired_revision, interim.record.revision + 1); } -/// The real worker uses the session-name transport's explicit OneCLI -/// missing-credential fallback and persists the generated title through the -/// normal durable name-store path. +/// Name-reliability Task 6 (rewrites the removed OneCLI-then-key retry pin, +/// G43): with a configured key and a OneCLI-marked ambient proxy, the real +/// worker sends ONE keyed request straight to Gemini (never through the +/// proxy) and persists the answer through the normal durable path. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn onecli_missing_credential_worker_fallback_saves_ai_name() { +async fn configured_key_worker_goes_direct_past_a_onecli_proxy() { + use crate::session_name_capability::tests::{ + session_name_http, start_fake_gemini, start_fake_proxy, ProxyMode, + }; let dir = temp_data_dir(); let store = open_store(dir.path()); - let fixture = start_onecli_fallback_gemini().await; - let auth = crate::ai_title::GeminiSessionNameAuth::onecli_for_test(AiKeyCell::init( - Some("worker-fallback-sentinel".to_string()), - None, - )); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); - let transport = Arc::new(GeminiSessionNameHttp::new( - client, - auth.clone(), - fixture.base_url.clone(), - )); + let gemini = start_fake_gemini("Sardine crash investigation", Some("worker-key-value")).await; + let proxy = start_fake_proxy("Proxy answer", ProxyMode::Ok).await; + let (auth, transport) = session_name_http( + AiKeyCell::init(Some("worker-key-value".to_string()), None), + &gemini.base_url, + Some(&proxy.onecli_url()), + ); let generator = Arc::new(SessionNameGenerator::new( settings_for(dir.path()), auth, transport, )); - let target = pending("h-onecli-fallback"); + let target = pending("h-key-direct"); ensure_pending( &store, - "h-onecli-fallback", + "h-key-direct", NamedProvider::Claude, - Some("/w/onecli-fallback"), + Some("/w/key-direct"), ) .await .expect("ensure"); @@ -766,16 +733,20 @@ async fn onecli_missing_credential_worker_fallback_saves_ai_name() { } assert!( tokio::time::Instant::now() < deadline, - "the worker must save the fallback answer" + "the worker must save the keyed answer" ); tokio::time::sleep(Duration::from_millis(20)).await; } worker.abort(); assert_eq!( - *fixture.observed_fallback_headers.lock().unwrap(), - vec![false, true], - "the worker makes one keyless OneCLI request and one direct-key fallback" + *gemini.keys.lock().unwrap(), + vec![Some("worker-key-value".to_string())], + "the worker makes one keyed request, direct" + ); + assert!( + proxy.recorded().is_empty(), + "the OneCLI proxy is never used" ); } @@ -1119,8 +1090,12 @@ async fn the_retry_schedule_is_immediate_then_thirty_seconds_then_five_minutes() #[tokio::test(start_paused = true)] async fn the_twenty_second_request_bound_charges_the_attempt_once() { struct NeverTransport; - impl GeminiTransport for NeverTransport { - fn generate_content(&self, _p: String, _m: u32) -> BoxFuture> { + impl SessionNameTransport for NeverTransport { + fn generate_content( + &self, + _p: String, + _m: u32, + ) -> BoxFuture> { Box::pin(std::future::pending()) } } @@ -1129,7 +1104,7 @@ async fn the_twenty_second_request_bound_charges_the_attempt_once() { let generator = generator_with( dir.path(), Some("gen-key"), - Arc::new(NeverTransport) as Arc, + Arc::new(NeverTransport) as Arc, ); let target = pending("h-bound"); ensure_pending(&store, "h-bound", NamedProvider::Claude, Some("/w/bound")) @@ -1700,14 +1675,18 @@ async fn a_rearmed_series_rejects_answers_from_older_input() { .expect("claim runs") .expect("claimable"); - // A genuinely new message re-arms the series (new fingerprint). - arm_with_message( + // The index re-derived the actual first message (new fingerprint). + // A later accepted send must never replace the first naming input. + activity( &store, target.clone(), "freshclaude", - "Second conversation message", + "corrected-first-message", + NameActivityReason::IndexUserMessage, + Some("Corrected first conversation message"), ) - .await; + .await + .expect("the index corrects naming input"); // The OLD input's late answer is rejected. store @@ -1746,7 +1725,8 @@ async fn a_rearmed_series_rejects_answers_from_older_input() { /// Hydration installs the free fallbacks (provider title > first message > /// directory basename) and NEVER schedules paid titles across history: the /// absorbed series rests Idle, out of the selector, and stays that way -/// across repeated hydration; a genuinely new message arms it. +/// across repeated hydration and a re-derived message; a genuinely newly +/// observed message on a session without an absorbed series arms. #[tokio::test] async fn hydration_installs_free_fallbacks_and_never_schedules_paid_titles() { let dir = temp_data_dir(); @@ -1826,14 +1806,39 @@ async fn hydration_installs_free_fallbacks_and_never_schedules_paid_titles() { "an existing record is returned unchanged" ); - // A genuinely newly observed message (a later pass) arms generation. + // Name-reliability Task 5 (V2 decision B, re-absorb at rest): a changed + // message the index feeds to the absorbed series is only ever a + // RE-DERIVED first message (a real first message never changes; the + // extraction rules did), so it refreshes the input at rest and is still + // never scheduled in the background — an explicit open arms it. activity( &store, titled.clone(), "opencode", + "e-rederived-message", + NameActivityReason::IndexUserMessage, + Some("The re-derived first message"), + ) + .await + .expect("the re-derived message rests"); + assert!( + store.generation_work_snapshot().is_empty(), + "a re-derived message never arms an absorbed series in the background" + ); + assert_eq!( + generation_status(&document_json(dir.path()), &titled).as_deref(), + Some("idle") + ); + + // A genuinely newly observed message (a later pass) on a session with + // no absorbed series arms generation. + activity( + &store, + plain.clone(), + "claude", "e-new-message", NameActivityReason::IndexUserMessage, - Some("A brand new second turn message"), + Some("A brand new first message"), ) .await .expect("the new message arms"); @@ -1841,7 +1846,7 @@ async fn hydration_installs_free_fallbacks_and_never_schedules_paid_titles() { store .generation_work_snapshot() .iter() - .any(|item| item.target == titled), + .any(|item| item.target == plain), "a newly observed message makes the session eligible" ); } @@ -2186,14 +2191,19 @@ async fn the_targeted_opencode_lookup_serves_eligibility_and_is_bounded() { Duration::from_millis(1_000), None, ); - let first = index.opencode_first_user_message("ses_named"); + let first = index + .opencode_first_user_message("ses_named") + .expect("the targeted read succeeds"); assert_eq!( first.as_deref(), Some("The hidden first message"), "an already-named opencode session's first message is served on demand" ); assert!( - index.opencode_first_user_message("ses_missing").is_none(), + index + .opencode_first_user_message("ses_missing") + .expect("a missing session is an answer, not a failed read") + .is_none(), "a missing session answers None" ); @@ -2446,8 +2456,12 @@ async fn the_worker_alternates_native_and_generation_under_sustained_native_requ struct LoggingGeneration { order: Arc>>, } - impl GeminiTransport for LoggingGeneration { - fn generate_content(&self, _p: String, _m: u32) -> BoxFuture> { + impl SessionNameTransport for LoggingGeneration { + fn generate_content( + &self, + _p: String, + _m: u32, + ) -> BoxFuture> { self.order.lock().unwrap().push("generation"); Box::pin(async move { Ok("Alternating AI".to_string()) }) } @@ -2575,23 +2589,24 @@ async fn run_worker_child_role(role: &str, dir: &Path) { } let cell = AiKeyCell::init(Some("child-key".to_string()), None); let settings = settings_for(dir); - let (transport, native): (Arc, Arc) = match role { - "worker" => ( - CountingTransport::new("Child AI name"), - Arc::new(NoRouteNative), - ), - "gated_worker" => ( - Arc::new(FileGatedTransport { - calls: AtomicU32::new(0), - answer: "Gated AI name".to_string(), - gate_path: dir.join("gemini-release"), - held_sentinel: dir.join("request-held"), - }), - Arc::new(NoRouteNative), - ), - "failing_worker" => (FailingTransport::new(), Arc::new(NoRouteNative)), - other => panic!("unknown child role {other}"), - }; + let (transport, native): (Arc, Arc) = + match role { + "worker" => ( + CountingTransport::new("Child AI name"), + Arc::new(NoRouteNative), + ), + "gated_worker" => ( + Arc::new(FileGatedTransport { + calls: AtomicU32::new(0), + answer: "Gated AI name".to_string(), + gate_path: dir.join("gemini-release"), + held_sentinel: dir.join("request-held"), + }), + Arc::new(NoRouteNative), + ), + "failing_worker" => (FailingTransport::new(), Arc::new(NoRouteNative)), + other => panic!("unknown child role {other}"), + }; let generator = Arc::new(SessionNameGenerator::new( settings, crate::ai_title::GeminiSessionNameAuth::direct_for_test(cell), diff --git a/crates/freshell-server/src/session_name_import_policy.rs b/crates/freshell-server/src/session_name_import_policy.rs new file mode 100644 index 000000000..f792f664f --- /dev/null +++ b/crates/freshell-server/src/session_name_import_policy.rs @@ -0,0 +1,144 @@ +//! Legacy-name import policy once the one-time consolidation is complete +//! (name-reliability plan, Task 3; decision D1, server side). +//! +//! Two rules live here, plus the envelope checks every import passes: +//! +//! 1. **Post-completion browser imports are acknowledged and change +//! nothing.** Once `migration.completed` is committed, the browser has no +//! genuine pre-migration evidence left to offer: what an old client still +//! sends is its live layout (current tab titles, "Tab N", provider labels +//! from other windows), and applying it is how saved names reverted. Every +//! candidate id is acknowledged so the client stops resending, and nothing +//! is written — no record is created or changed, no candidate id is +//! stored, no backup file is written, and the document generation does +//! not move. One INFO line per import id per process records it. The +//! receipt never reverts, so a store whose adopted view shows it answers +//! without the document lock or a re-read; a stale view goes through the +//! transaction, whose decision applies the same rule. +//! +//! 2. **An import-owned default label loses a bind collision.** A record the +//! migration installed (its winning evidence is still at the installed +//! revision) whose source is automatic (`directory`, `first_message`, +//! `provider_ai`) and whose name the shared classifier recognizes as a +//! default or derived label ("Tab 4", "OpenCode agent") gives way to the +//! live pending record being bound. Every other import-owned record — a +//! label that looks typed by a person, a real first message, anything +//! protected — keeps the rank rule. Bind inputs carry no folder, so only +//! folder-free patterns are recognized here. + +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; + +use freshell_protocol::session_names::{LegacyImportResult, LegacyNameImport}; + +use super::{NameError, StoredDocument, NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT}; + +/// The per-process set of import ids already logged as ignored is cleared +/// when it reaches this size, which bounds its memory; an id seen again after +/// a clear logs once more. +const LOGGED_IMPORT_IDS_CAPACITY: usize = 4096; + +/// The envelope checks every import passes before anything else, before or +/// after completion: a supported version, a retry-stable import id, and at +/// most [`NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT`] candidates. +pub(super) fn validate_import_envelope(input: &LegacyNameImport) -> Result<(), NameError> { + if input.version != 1 { + return Err(NameError::InvalidName( + "unsupported legacy-name import version".into(), + )); + } + if input.import_id.trim().is_empty() { + return Err(NameError::InvalidName( + "a legacy-name import must carry its retry-stable importId".into(), + )); + } + if input.candidates.len() > NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT { + return Err(NameError::InvalidName(format!( + "a legacy-name import batches at most {NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT} candidates" + ))); + } + Ok(()) +} + +/// Rule 1: the whole answer to a browser import that arrives after the +/// consolidation completed. The envelope is validated and every candidate +/// id is acknowledged (an empty id is still refused, exactly as before +/// completion); `names` is empty — no record was touched. Logs +/// `session_names.import_ignored_after_completion` once per import id per +/// process. It reads only the import itself, which is what lets the store +/// answer from its adopted view without a transaction. +pub(super) fn post_completion_acknowledge( + input: &LegacyNameImport, +) -> Result { + validate_import_envelope(input)?; + let mut acknowledged = Vec::with_capacity(input.candidates.len()); + for candidate in &input.candidates { + if candidate.id.trim().is_empty() { + return Err(NameError::InvalidName( + "a legacy-name candidate must carry its stable id".into(), + )); + } + acknowledged.push(candidate.id.clone()); + } + if first_time_logged(&input.import_id) { + tracing::info!( + target: "freshell_server::session_names", + op = "import_legacy", + import_id = %input.import_id, + candidates = acknowledged.len(), + "session_names.import_ignored_after_completion" + ); + } + Ok(LegacyImportResult { + acknowledged, + names: Vec::new(), + }) +} + +/// Whether the record at `key` is still the one the migration installed: +/// winning evidence is retained for it and the record has not moved past the +/// revision that evidence installed. +pub(super) fn is_import_owned(document: &StoredDocument, key: &str) -> bool { + match ( + document.record_at(key), + document.migration.winning_evidence.get(key), + ) { + (Some(record), Some(evidence)) => record.revision == evidence.installed_revision, + _ => false, + } +} + +/// Rule 2: whether the durable record at `key` loses a pending→durable bind +/// collision because it is an import-owned, unprotected default label — +/// exactly a self-heal record (`session_name_policy`, the one server-side +/// meaning of "default label": the record's provider, so a provider +/// placeholder counts only for its own provider) under no folder, since +/// bind inputs carry none. +pub(super) fn import_owned_label_loses_bind(document: &StoredDocument, key: &str) -> bool { + let Some(record) = document.record_at(key) else { + return false; + }; + super::session_name_policy::is_self_heal_record(record, None) && is_import_owned(document, key) +} + +/// True the first time this process sees `import_id` (until the bounded set +/// is cleared at capacity). +fn first_time_logged(import_id: &str) -> bool { + static LOGGED: OnceLock>> = OnceLock::new(); + let mut logged = LOGGED + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if logged.contains(import_id) { + return false; + } + if logged.len() >= LOGGED_IMPORT_IDS_CAPACITY { + logged.clear(); + } + logged.insert(import_id.to_string()); + true +} + +#[cfg(test)] +#[path = "session_name_import_policy_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_import_policy_tests.rs b/crates/freshell-server/src/session_name_import_policy_tests.rs new file mode 100644 index 000000000..53163c59a --- /dev/null +++ b/crates/freshell-server/src/session_name_import_policy_tests.rs @@ -0,0 +1,814 @@ +//! Name-reliability Task 3: behavior tests for legacy-name imports after the +//! one-time consolidation, and for import-owned labels in bind collisions. +//! +//! Every test drives the REAL `SessionNames` store on a temp data dir through +//! its public transaction methods; the migration is completed through +//! `complete_legacy_migration`, exactly as the boot consolidation runner +//! completes it. The on-disk document is read back to prove what was (not) +//! written. + +use std::path::Path; +use std::sync::Arc; + +use freshell_freshagent::naming::{BindNameInput, PendingNameInput, SessionNaming}; +use freshell_protocol::native_location::{ + NativeAcquisition, NativeEvidenceKind, NativeLocation, NativePersistence, +}; +use freshell_protocol::session_names::{ + LegacyCandidateScope, LegacyEvidenceEnvelope, LegacyNameCandidate, LegacyNameImport, + LegacyNameTarget, LegacyProtectionEvidence, NameSource, NamedProvider, SessionNameRef, + SessionNameUpdate, +}; +use serde_json::json; + +use crate::session_name_generation::IndexedNameInput; +use crate::session_names::{ + name_ref_key, test_store_transaction_count, SessionNames, StoredDocument, DOCUMENT_FILE_NAME, + NAME_MIGRATION_BOOT_IMPORT_ID, NAME_MIGRATION_DIR_NAME, NAME_MIGRATION_IMPORTS_DIR_NAME, +}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn open_store(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("store opens") +} + +fn claude_session(id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: id.to_string(), + } +} + +fn read_document(dir: &Path) -> StoredDocument { + let bytes = std::fs::read(dir.join(DOCUMENT_FILE_NAME)).expect("document exists"); + serde_json::from_slice(&bytes).expect("document parses") +} + +fn generation(dir: &Path) -> u64 { + read_document(dir).document_generation +} + +fn acknowledged_ids(dir: &Path) -> Vec { + read_document(dir).migration.acknowledged_candidate_ids +} + +/// The number of per-import backup files the import path wrote. +fn import_backup_count(dir: &Path) -> usize { + let imports = dir + .join(NAME_MIGRATION_DIR_NAME) + .join(NAME_MIGRATION_IMPORTS_DIR_NAME); + std::fs::read_dir(imports) + .map(|entries| entries.filter_map(Result::ok).count()) + .unwrap_or(0) +} + +async fn record_of(names: &SessionNames, target: SessionNameRef) -> Option { + names + .get(vec![target]) + .await + .expect("get succeeds") + .into_iter() + .next() +} + +/// A session record created by the normal flows: the sweep's index +/// hydration. `cwd` only → a `directory` record; a first message → a +/// `first_message` record. +async fn hydrate( + names: &SessionNames, + session_id: &str, + cwd: Option<&str>, + first_user_message: Option<&str>, +) -> SessionNameUpdate { + names + .hydrate_indexed( + IndexedNameInput { + provider: NamedProvider::Claude, + session_id: session_id.to_string(), + cwd: cwd.map(str::to_string), + first_user_message: first_user_message.map(str::to_string), + provider_title: None, + }, + false, + ) + .await + .expect("hydrate succeeds") +} + +fn candidate( + id: &str, + target: LegacyNameTarget, + name: &str, + source: NameSource, + scope: LegacyCandidateScope, + evidence_key: &str, + protection: LegacyProtectionEvidence, +) -> LegacyNameCandidate { + LegacyNameCandidate { + id: id.to_string(), + target, + name: name.to_string(), + source, + scope, + explicit_rename_at: None, + evidence_key: evidence_key.to_string(), + protection_evidence: protection, + } +} + +/// An automatic `source_tab` label from one browser window's layout. +fn window_tab_label(id: &str, session_id: &str, name: &str, window: &str) -> LegacyNameCandidate { + candidate( + id, + LegacyNameTarget::Canonical(claude_session(session_id)), + name, + NameSource::ProviderAi, + LegacyCandidateScope::SourceTab, + &format!("freshell.layout.v3.{window}#tab-1"), + LegacyProtectionEvidence::None, + ) +} + +/// A browser-shaped import (the untrusted HTTP lane) from one window. +fn browser_import(import_id: &str, candidates: Vec) -> LegacyNameImport { + LegacyNameImport { + version: 1, + import_id: import_id.to_string(), + evidence: vec![LegacyEvidenceEnvelope { + storage_key: format!("freshell.layout.v3.window-{import_id}"), + raw: json!({ "version": 4, "tabs": { "tabs": [] }, "panes": {} }).to_string(), + }], + candidates, + } +} + +fn verified_claude_acquisition() -> NativeAcquisition { + NativeAcquisition { + location: NativeLocation::Claude { + config_root: "/w/homes/claude".to_string(), + transcript_path: Some("/w/homes/claude/projects/R/x.jsonl".to_string()), + project_directory_key: Some("R".to_string()), + transcript_cwd: Some("/w/work".to_string()), + effective_project_key_override: None, + }, + evidence: NativeEvidenceKind::SelectedTranscript, + persistence: NativePersistence::Verified, + } +} + +/// One import through the trusted boot lane (the consolidation runner's +/// reserved import id), before completion. +async fn boot_import(names: &SessionNames, candidates: Vec) { + names + .import_legacy(LegacyNameImport { + version: 1, + import_id: NAME_MIGRATION_BOOT_IMPORT_ID.to_string(), + evidence: vec![], + candidates, + }) + .await + .expect("boot import succeeds"); +} + +/// A boot-lane source-tab label for `session:claude:`. +fn boot_session_label( + id: &str, + session_id: &str, + name: &str, + source: NameSource, + protection: LegacyProtectionEvidence, +) -> LegacyNameCandidate { + candidate( + id, + LegacyNameTarget::Canonical(claude_session(session_id)), + name, + source, + LegacyCandidateScope::SourceTab, + &format!("tabs-registry:device-1:claude:{session_id}:source-tab"), + protection, + ) +} + +/// Before completion, the trusted boot lane installs `name` on +/// `session:claude:` (an import-owned record) with the source +/// the boot lane classifies `source` + `protection` as (`installed_as`), +/// then the consolidation completes. +async fn boot_install_as_then_complete( + names: &SessionNames, + session_id: &str, + name: &str, + (source, protection): (NameSource, LegacyProtectionEvidence), + installed_as: NameSource, +) { + boot_import( + names, + vec![boot_session_label( + "boot-1", session_id, name, source, protection, + )], + ) + .await; + let installed = record_of(names, claude_session(session_id)) + .await + .expect("the boot import installed the record"); + assert_eq!(installed.record.name, name); + assert_eq!(installed.record.source, installed_as); + names + .complete_legacy_migration() + .await + .expect("completion commits"); +} + +/// [`boot_install_as_then_complete`] for an automatic `provider_ai` label. +async fn boot_install_then_complete(names: &SessionNames, session_id: &str, name: &str) { + boot_install_as_then_complete( + names, + session_id, + name, + (NameSource::ProviderAi, LegacyProtectionEvidence::None), + NameSource::ProviderAi, + ) + .await; +} + +/// A live pending handle whose create-time name is the folder `work` +/// (`directory` rank), bound to `session:claude:`. +async fn bind_work_pending_to(names: &SessionNames, handle: &str, session_id: &str) { + let pending = names + .ensure_pending(PendingNameInput { + handle: handle.to_string(), + provider: NamedProvider::Claude, + cwd: Some("/w/work".to_string()), + }) + .await + .expect("ensure pending"); + assert_eq!(pending.record.name, "work"); + assert_eq!(pending.record.source, NameSource::Directory); + names + .bind_pending(BindNameInput { + pending: SessionNameRef::Pending { + id: handle.to_string(), + }, + target: claude_session(session_id), + acquisition: verified_claude_acquisition(), + }) + .await + .expect("bind succeeds"); +} + +fn collision_losers(dir: &Path) -> Vec<(String, String)> { + read_document(dir) + .recovery + .values() + .filter(|entry| entry.reason == "bind_collision_loser") + .map(|entry| (entry.record.name.clone(), entry.reason.clone())) + .collect() +} + +// --------------------------------------------------------------------------- +// Post-completion imports: acknowledge, write nothing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn post_completion_import_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let existing = hydrate(&names, "existing", Some("/w/work"), None).await; + assert_eq!(existing.record.name, "work"); + assert_eq!(existing.record.source, NameSource::Directory); + names.complete_legacy_migration().await.unwrap(); + let generation_before = generation(dir.path()); + + let derived_handle = json!([ + "legacy-pending", + "freshell.layout.v3.layout-window-a", + "layout-window-a", + "pane-1", + "req-1" + ]) + .to_string(); + let candidates = vec![ + window_tab_label("c-existing", "existing", "Tab 4", "layout-window-a"), + window_tab_label("c-new", "no-record", "Billing bug", "layout-window-a"), + candidate( + "c-pending", + LegacyNameTarget::Canonical(SessionNameRef::Pending { + id: "pending-canonical".into(), + }), + "Pending Label", + NameSource::ProviderAi, + LegacyCandidateScope::Pane, + "freshell.layout.v3.layout-window-a#tab-1#pane-2", + LegacyProtectionEvidence::None, + ), + candidate( + "c-derived", + LegacyNameTarget::Canonical(SessionNameRef::Pending { + id: derived_handle.clone(), + }), + "Derived Pending Label", + NameSource::ProviderAi, + LegacyCandidateScope::Pane, + "freshell.layout.v3.layout-window-a#tab-1#pane-1", + LegacyProtectionEvidence::None, + ), + ]; + let result = names + .import_legacy(browser_import("after-completion", candidates)) + .await + .expect("a post-completion import is acknowledged"); + + assert_eq!( + result.acknowledged, + vec!["c-existing", "c-new", "c-pending", "c-derived"], + "every candidate id is acknowledged" + ); + assert!(result.names.is_empty(), "no record was touched"); + let after = record_of(&names, claude_session("existing")).await.unwrap(); + assert_eq!(after.record.name, "work"); + assert_eq!(after.record.source, NameSource::Directory); + assert_eq!(after.record.revision, existing.record.revision); + assert!( + record_of(&names, claude_session("no-record")) + .await + .is_none(), + "no record is created for a session without one" + ); + for id in ["pending-canonical".to_string(), derived_handle] { + assert!( + record_of(&names, SessionNameRef::Pending { id: id.clone() }) + .await + .is_none(), + "no orphan pending record is minted for {id}" + ); + } + assert_eq!( + generation(dir.path()), + generation_before, + "the document generation does not move" + ); + assert_eq!(import_backup_count(dir.path()), 0, "no backup file"); +} + +#[tokio::test] +async fn post_completion_reimport_stores_no_candidate_ids() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + // A pre-completion import leaves bookkeeping behind (older binaries' + // idempotency gate), which must stay exactly as it is. + names + .import_legacy(browser_import( + "before-completion", + vec![window_tab_label("c-early", "early", "Early Label", "w0")], + )) + .await + .unwrap(); + names.complete_legacy_migration().await.unwrap(); + let generation_before = generation(dir.path()); + let acknowledged_before = acknowledged_ids(dir.path()); + assert_eq!(acknowledged_before, vec!["c-early".to_string()]); + + let late = browser_import( + "old-client-window", + vec![ + window_tab_label("c-late-1", "early", "Tab 23", "w1"), + window_tab_label("c-late-2", "other", "ms-people-summit", "w1"), + ], + ); + for round in 0..3 { + let result = names.import_legacy(late.clone()).await.unwrap(); + assert_eq!(result.acknowledged, vec!["c-late-1", "c-late-2"]); + assert_eq!( + generation(dir.path()), + generation_before, + "round {round}: no document write" + ); + assert_eq!( + acknowledged_ids(dir.path()), + acknowledged_before, + "round {round}: no candidate id is stored" + ); + } + assert_eq!( + import_backup_count(dir.path()), + 1, + "only the pre-completion import wrote a backup" + ); +} + +#[tokio::test] +async fn post_completion_import_never_raises_rank() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let existing = hydrate( + &names, + "s1", + Some("/w/work"), + Some("fix the login redirect"), + ) + .await; + assert_eq!(existing.record.source, NameSource::FirstMessage); + names.complete_legacy_migration().await.unwrap(); + + let result = names + .import_legacy(browser_import( + "flagged-late", + vec![candidate( + "c-flag", + LegacyNameTarget::Canonical(claude_session("s1")), + "Old Flagged Pane Label", + NameSource::LegacyProtected, + LegacyCandidateScope::Pane, + "freshell.layout.v3.w1#tab-1#pane-1", + LegacyProtectionEvidence::LegacyFlag, + )], + )) + .await + .unwrap(); + assert_eq!(result.acknowledged, vec!["c-flag"]); + let after = record_of(&names, claude_session("s1")).await.unwrap(); + assert_eq!(after.record.name, existing.record.name); + assert_eq!(after.record.source, NameSource::FirstMessage); + assert_eq!(after.record.revision, existing.record.revision); +} + +#[tokio::test] +async fn post_completion_import_logs_once_per_import_id() { + let events = crate::test_trace_capture::captured_trace_events(); + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + names.complete_legacy_migration().await.unwrap(); + let import_id = format!("log-once-{}", uuid::Uuid::new_v4()); + let other_id = format!("log-other-{}", uuid::Uuid::new_v4()); + + let import = browser_import( + &import_id, + vec![ + window_tab_label("c-a", "s1", "Tab 4", "w1"), + window_tab_label("c-b", "s2", "Tab 5", "w1"), + ], + ); + names.import_legacy(import.clone()).await.unwrap(); + names.import_legacy(import).await.unwrap(); + names + .import_legacy(browser_import( + &other_id, + vec![window_tab_label("c-c", "s3", "Tab 6", "w2")], + )) + .await + .unwrap(); + + let ignored_for = |id: &str| { + events + .snapshot() + .into_iter() + .filter(|event| { + event.level == tracing::Level::INFO + && event.message == "session_names.import_ignored_after_completion" + && event.fields.get("import_id").map(String::as_str) == Some(id) + }) + .collect::>() + }; + let mine = ignored_for(&import_id); + assert_eq!(mine.len(), 1, "one INFO line per import id: {mine:?}"); + assert_eq!( + mine[0].fields.get("candidates").map(String::as_str), + Some("2") + ); + assert_eq!( + ignored_for(&other_id).len(), + 1, + "a different import id gets its own line" + ); +} + +/// Old clients keep sending imports until they reload, each one against a +/// multi-megabyte document: once this store knows the consolidation is +/// complete (`completed` never reverts), it answers without taking the +/// document lock or re-reading the document. Envelope validation still +/// applies. +#[tokio::test] +async fn post_completion_import_takes_no_store_transaction() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + names.complete_legacy_migration().await.unwrap(); + let before = test_store_transaction_count(dir.path()); + + let result = names + .import_legacy(browser_import( + "no-transaction", + vec![window_tab_label("c-1", "s1", "Tab 4", "w1")], + )) + .await + .unwrap(); + assert_eq!(result.acknowledged, vec!["c-1"]); + let mut unsupported = browser_import("bad-version", vec![]); + unsupported.version = 2; + assert!( + names.import_legacy(unsupported).await.is_err(), + "an unsupported envelope is still refused" + ); + let unnamed = browser_import( + "blank-candidate-id", + vec![window_tab_label(" ", "s1", "Tab 4", "w1")], + ); + assert!( + names.import_legacy(unnamed).await.is_err(), + "a candidate without its stable id is still refused" + ); + assert_eq!( + test_store_transaction_count(dir.path()), + before, + "no store transaction ran" + ); +} + +/// Pinning test: a store whose view predates the completion (another +/// process committed it) still writes nothing — the transaction re-reads +/// the document and the decision sees the receipt. +#[tokio::test] +async fn a_stale_view_still_writes_nothing_after_another_process_completed() { + let dir = tempfile::tempdir().unwrap(); + let stale = open_store(dir.path()); + let completer = open_store(dir.path()); + completer.complete_legacy_migration().await.unwrap(); + assert!( + !stale.migration_completed(), + "the first store has not adopted the completion yet" + ); + let generation_before = generation(dir.path()); + + let result = stale + .import_legacy(browser_import( + "stale-view", + vec![window_tab_label("c-1", "s1", "Tab 4", "w1")], + )) + .await + .unwrap(); + assert_eq!(result.acknowledged, vec!["c-1"]); + assert!(result.names.is_empty()); + assert!(record_of(&stale, claude_session("s1")).await.is_none()); + assert_eq!(generation(dir.path()), generation_before); + assert!(acknowledged_ids(dir.path()).is_empty()); + assert_eq!(import_backup_count(dir.path()), 0); +} + +// --------------------------------------------------------------------------- +// Bind collisions with import-owned records +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn import_owned_default_label_loses_a_bind_collision() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + boot_install_then_complete(&names, "X", "Tab 4").await; + + bind_work_pending_to(&names, "P", "X").await; + + let bound = record_of(&names, claude_session("X")).await.unwrap(); + assert_eq!(bound.record.name, "work", "the live pending name wins"); + assert_eq!(bound.record.source, NameSource::Directory); + assert_eq!( + collision_losers(dir.path()), + vec![("Tab 4".to_string(), "bind_collision_loser".to_string())], + "the import-owned default label is retained in recovery" + ); +} + +/// Pinning test: an import-owned name that is not a recognizable default +/// label keeps today's rank rule (`provider_ai` outranks the pending +/// `directory` record). +#[tokio::test] +async fn import_owned_real_label_keeps_the_rank_rule() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + boot_install_then_complete(&names, "X", "ms-people-summit").await; + + bind_work_pending_to(&names, "P", "X").await; + + let bound = record_of(&names, claude_session("X")).await.unwrap(); + assert_eq!(bound.record.name, "ms-people-summit"); + assert_eq!(bound.record.source, NameSource::ProviderAi); + assert_eq!( + collision_losers(dir.path()), + vec![("work".to_string(), "bind_collision_loser".to_string())], + ); +} + +/// Pinning test: only an automatic import-owned label gives way. A +/// protected import-owned "Tab 4" — a legacy-flagged label, a proven +/// explicit rename, an accepted Freshell AI name — keeps the rank rule, so +/// a bind never demotes a name a person may have typed. +#[tokio::test] +async fn import_owned_protected_default_label_keeps_the_rank_rule() { + for (evidence, installed_as) in [ + ( + ( + NameSource::LegacyProtected, + LegacyProtectionEvidence::LegacyFlag, + ), + NameSource::LegacyProtected, + ), + ( + (NameSource::Manual, LegacyProtectionEvidence::ExplicitRename), + NameSource::Manual, + ), + ( + (NameSource::FreshellAi, LegacyProtectionEvidence::None), + NameSource::FreshellAi, + ), + ] { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + boot_install_as_then_complete(&names, "X", "Tab 4", evidence, installed_as).await; + + bind_work_pending_to(&names, "P", "X").await; + + let bound = record_of(&names, claude_session("X")).await.unwrap(); + assert_eq!( + bound.record.name, "Tab 4", + "{installed_as:?}: the protected label stays" + ); + assert_eq!(bound.record.source, installed_as); + assert_eq!( + collision_losers(dir.path()), + vec![("work".to_string(), "bind_collision_loser".to_string())], + "{installed_as:?}: the pending name is the retained loser" + ); + } +} + +/// Pinning test: the rule covers only a record still at the revision the +/// migration installed. The boot consolidation labels both a pane's pending +/// handle and its session "Tab 4"; the pane binds, so the session record +/// carries the pane's "Tab 4" at a revision past its install. A later +/// pending "work" bound to the same session meets the rank rule: +/// `provider_ai` outranks `directory`. +#[tokio::test] +async fn a_default_label_moved_past_its_install_keeps_the_rank_rule() { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + boot_import( + &names, + vec![ + boot_session_label( + "boot-session", + "X", + "Tab 4", + NameSource::ProviderAi, + LegacyProtectionEvidence::None, + ), + candidate( + "boot-pane", + LegacyNameTarget::Canonical(SessionNameRef::Pending { id: "P1".into() }), + "Tab 4", + NameSource::ProviderAi, + LegacyCandidateScope::Pane, + "tabs-registry:device-1:tab-1:pane-1", + LegacyProtectionEvidence::None, + ), + ], + ) + .await; + names.complete_legacy_migration().await.unwrap(); + names + .bind_pending(BindNameInput { + pending: SessionNameRef::Pending { id: "P1".into() }, + target: claude_session("X"), + acquisition: verified_claude_acquisition(), + }) + .await + .expect("the pane binds"); + + let key = name_ref_key(&claude_session("X")); + let document = read_document(dir.path()); + let moved = document.record_at(&key).expect("the session record exists"); + assert_eq!(moved.name, "Tab 4"); + assert_eq!(moved.source, NameSource::ProviderAi); + let installed = document + .migration + .winning_evidence + .get(&key) + .expect("the migration's winning evidence is retained") + .installed_revision; + assert!( + moved.revision > installed, + "the record moved past its installed revision ({} vs {installed})", + moved.revision + ); + let work_lost = |dir: &Path| { + collision_losers(dir) + .iter() + .filter(|(name, _)| name == "work") + .count() + }; + assert_eq!(work_lost(dir.path()), 0); + + bind_work_pending_to(&names, "P2", "X").await; + + let bound = record_of(&names, claude_session("X")).await.unwrap(); + assert_eq!(bound.record.name, "Tab 4", "the rank rule keeps the label"); + assert_eq!(bound.record.source, NameSource::ProviderAi); + assert_eq!( + work_lost(dir.path()), + 1, + "the pending name is the retained loser" + ); +} + +/// The import-owned label's loss is logged once the bind commits, as the +/// name-change event of the session's record (name-reliability Task 12): a +/// bind collision that healed a junk label, with the sources spelled the way +/// the stored document, the wire and the other naming logs do +/// (`provider_ai`, `directory`), so a log search for a source finds it. +#[tokio::test] +async fn bind_collision_log_spells_sources_as_the_wire_does() { + let events = crate::test_trace_capture::captured_trace_events(); + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + let session_id = format!("log-{}", uuid::Uuid::new_v4()); + boot_install_then_complete(&names, &session_id, "Tab 4").await; + + bind_work_pending_to(&names, "P", &session_id).await; + + let key = name_ref_key(&claude_session(&session_id)); + let lines: Vec<_> = events + .snapshot() + .into_iter() + .filter(|event| { + event.message == "session_names.name_changed" + && event.fields.get("name_ref") == Some(&key) + && event.fields.get("cause").map(String::as_str) == Some("bind_collision") + }) + .collect(); + assert_eq!(lines.len(), 1, "one bind-collision change: {lines:?}"); + let line = &lines[0]; + assert_eq!(line.level, tracing::Level::INFO); + let field = |name: &str| line.fields.get(name).map(String::as_str); + assert_eq!(field("old_name"), Some("Tab 4")); + assert_eq!(field("old_source"), Some("provider_ai")); + assert_eq!(field("new_name"), Some("work")); + assert_eq!(field("new_source"), Some("directory")); + assert_eq!(field("self_heal"), Some("true")); + assert_eq!( + field("from_ref"), + Some( + name_ref_key(&SessionNameRef::Pending { + id: "P".to_string() + }) + .as_str() + ) + ); +} + +// --------------------------------------------------------------------------- +// Pre-completion tie-break: window ids never decide a name +// --------------------------------------------------------------------------- + +/// Pinning test for the pre-completion path: an equal-rank, equal-scope +/// candidate from another window never replaces the installed record, +/// whichever window's spelling sorts first. +#[tokio::test] +async fn equal_rank_boot_candidates_keep_the_incumbent() { + for (first, second) in [("pii4k2", "mn0x7"), ("mn0x7", "pii4k2")] { + let dir = tempfile::tempdir().unwrap(); + let names = open_store(dir.path()); + names + .import_legacy(browser_import( + &format!("import-{first}"), + vec![window_tab_label( + &format!("c-{first}"), + "s1", + &format!("Label from {first}"), + first, + )], + )) + .await + .unwrap(); + let incumbent = record_of(&names, claude_session("s1")).await.unwrap(); + assert_eq!(incumbent.record.name, format!("Label from {first}")); + + let result = names + .import_legacy(browser_import( + &format!("import-{second}"), + vec![window_tab_label( + &format!("c-{second}"), + "s1", + &format!("Label from {second}"), + second, + )], + )) + .await + .unwrap(); + assert_eq!(result.acknowledged, vec![format!("c-{second}")]); + let after = record_of(&names, claude_session("s1")).await.unwrap(); + assert_eq!( + after.record.name, + format!("Label from {first}"), + "{first} installed first; {second} must not replace it" + ); + assert_eq!(after.record.revision, incumbent.record.revision); + } +} diff --git a/crates/freshell-server/src/session_name_known_target_tests.rs b/crates/freshell-server/src/session_name_known_target_tests.rs new file mode 100644 index 000000000..78f318d1d --- /dev/null +++ b/crates/freshell-server/src/session_name_known_target_tests.rs @@ -0,0 +1,225 @@ +//! Real routes over a switched live session before naming/index hydration. + +use super::*; +use freshell_sessions::directory_index::{ClaudeSource, SessionIndex}; +use freshell_ws::identity::TerminalIdentityRegistry; + +const SESSION: &str = "88888888-9999-4aaa-8bbb-cccccccc0042"; + +#[derive(Clone, Copy, Debug)] +enum Surface { + Canonical, + Session, + Terminal, +} + +fn target() -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: SESSION.into(), + } +} + +fn live_identity(names: &Arc) -> TerminalIdentityRegistry { + let identity = TerminalIdentityRegistry::new(); + identity.set_session_naming(names.clone()); + identity.upsert( + "live", + Some("claude"), + Some(SESSION), + Some("/work/billing"), + 1_000, + ); + identity.set_name_binding("live", Some(target()), None); + identity +} + +fn route( + surface: Surface, + home: &std::path::Path, + names: &Arc, + identity: TerminalIdentityRegistry, + index: Option>, +) -> (Router, String) { + match surface { + Surface::Canonical => ( + super::super::router(SessionNamesState { + auth_token: Arc::new("tok".into()), + names: names.clone(), + identity, + index, + }), + "/api/session-names".into(), + ), + Surface::Session => { + let metadata = + crate::session_metadata::SessionMetadataStore::new(home.join(".freshell")); + let mut state = sessions_router_state(home, names, metadata); + state.identity = identity; + state.index = index; + ( + crate::sessions::router(state), + format!("/api/sessions/claude:{SESSION}"), + ) + } + Surface::Terminal => { + let registry = freshell_terminal::TerminalRegistry::new(); + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "live".into(), + stream_id: "stream".into(), + mode: "claude".into(), + resume_session_id: Some(SESSION.into()), + create_request_id: None, + created_at: None, + }); + registry.set_naming("live", Some(target()), None); + let (tx, _rx) = tokio::sync::broadcast::channel(16); + ( + crate::terminals::router(crate::terminals::TerminalsState { + auth_token: Arc::new("tok".into()), + settings: crate::settings_store::SettingsStore::load( + Some(home), + vec!["claude".into()], + ), + registry, + broadcast_tx: Arc::new(tx), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + identity, + }), + "/api/terminals/live".into(), + ) + } + } +} + +fn rename_body(name: &str, revision: Option) -> Value { + let mut body = + json!({"target": target(), "name": name, "titleOverride": name, "nameIntent": "user"}); + if let Some(revision) = revision { + body["ifRevision"] = json!(revision); + } + body +} + +#[tokio::test] +async fn known_live_session_can_be_named_from_every_session_surface() { + for surface in [Surface::Canonical, Surface::Session, Surface::Terminal] { + let home = temp_home(); + let names = names_state(&home).names; + assert!(names.get(vec![target()]).await.unwrap().is_empty()); + let (router, uri) = route(surface, &home, &names, live_identity(&names), None); + let (status, body) = patch( + router, + &uri, + rename_body("Named before first message", None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{surface:?}: {body}"); + let record = &names.get(vec![target()]).await.unwrap()[0].record; + assert_eq!(record.name, "Named before first message"); + assert_eq!(record.source, NameSource::Manual); + std::fs::remove_dir_all(home).ok(); + } +} + +#[tokio::test] +async fn known_live_admission_preserves_revision_conflicts_on_every_surface() { + for surface in [Surface::Canonical, Surface::Session, Surface::Terminal] { + let home = temp_home(); + let names = names_state(&home).names; + let (router, uri) = route(surface, &home, &names, live_identity(&names), None); + let (status, body) = + patch(router.clone(), &uri, rename_body("Stale capture", Some(0))).await; + assert_eq!(status, StatusCode::CONFLICT, "{surface:?}: {body}"); + assert_eq!(body["error"], "NAME_REVISION_CONFLICT"); + let current = names.get(vec![target()]).await.unwrap()[0].record.clone(); + assert_eq!( + current.source, + NameSource::Directory, + "the stale rename never applies" + ); + let (status, body) = patch( + router, + &uri, + rename_body("Confirmed retry", Some(current.revision)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{surface:?}: {body}"); + assert_eq!( + names.get(vec![target()]).await.unwrap()[0].record.name, + "Confirmed retry" + ); + std::fs::remove_dir_all(home).ok(); + } +} + +#[tokio::test] +async fn unknown_or_only_prospective_session_targets_stay_not_found() { + for surface in [Surface::Canonical, Surface::Session] { + for kind in ["unknown", "pending", "retired"] { + let home = temp_home(); + let names = names_state(&home).names; + let identity = if kind == "unknown" { + let identity = TerminalIdentityRegistry::new(); + identity.set_session_naming(names.clone()); + identity + } else { + live_identity(&names) + }; + match kind { + "unknown" => {} + "pending" => identity.set_name_binding( + "live", + Some(SessionNameRef::Pending { + id: "still-pending".into(), + }), + None, + ), + "retired" => { + identity.retire("live"); + } + _ => unreachable!(), + } + let (router, uri) = route(surface, &home, &names, identity, None); + let (status, body) = + patch(router, &uri, rename_body("Must not invent a session", None)).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{surface:?}/{kind}: {body}"); + assert!(names.get(vec![target()]).await.unwrap().is_empty()); + std::fs::remove_dir_all(home).ok(); + } + } +} + +#[tokio::test] +async fn published_index_target_can_be_named_before_naming_hydrates_it() { + for surface in [Surface::Canonical, Surface::Session] { + let home = temp_home(); + let names = names_state(&home).names; + let project = home.join(".claude/projects/-work-billing"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join(format!("{SESSION}.jsonl")), format!( + "{{\"type\":\"user\",\"sessionId\":\"{SESSION}\",\"cwd\":\"/work/billing\",\"timestamp\":\"2026-10-23T10:00:00Z\",\"message\":{{\"role\":\"user\",\"content\":\"Fix billing invoices\"}}}}\n" + )).unwrap(); + let index = Arc::new(SessionIndex::new(vec![Arc::new(ClaudeSource::new( + home.join(".claude"), + ))])); + assert!( + index + .snapshot() + .await + .iter() + .any(|row| row.session_id == SESSION), + "the real parser published the session" + ); + let identity = TerminalIdentityRegistry::new(); + identity.set_session_naming(names.clone()); + let (router, uri) = route(surface, &home, &names, identity, Some(index)); + let (status, body) = patch(router, &uri, rename_body("Named from index", None)).await; + assert_eq!(status, StatusCode::OK, "{surface:?}: {body}"); + assert_eq!( + names.get(vec![target()]).await.unwrap()[0].record.name, + "Named from index" + ); + std::fs::remove_dir_all(home).ok(); + } +} diff --git a/crates/freshell-server/src/session_name_migration.rs b/crates/freshell-server/src/session_name_migration.rs index 4ccf11d92..749767010 100644 --- a/crates/freshell-server/src/session_name_migration.rs +++ b/crates/freshell-server/src/session_name_migration.rs @@ -35,12 +35,14 @@ //! step, before any request is served); if the physical cleanup cannot //! flush, the next boot retries it idempotently. //! -//! Late previously-offline browser imports keep arriving through -//! `POST /api/session-names/import` with per-candidate acknowledgment and -//! their own immutable per-import backups; they compare against the -//! retained winning evidence with the same deterministic total order and -//! can never replace a post-migration manual or accepted-Freshell-AI -//! name. The Amplifier-only `run_ai_title_shadow_cleanup` stays separate. +//! Browser imports through `POST /api/session-names/import` apply only +//! while the receipt is still open (with per-candidate acknowledgment and +//! their own immutable per-import backups), and can never replace a +//! post-migration manual or accepted-Freshell-AI name. Once the receipt +//! has committed, every browser import is acknowledged without any effect +//! — no record, stored candidate id or backup (name-reliability Task 3, +//! `session_name_import_policy`). The Amplifier-only +//! `run_ai_title_shadow_cleanup` stays separate. //! //! Recovery policy: the immutable `server.json` / `imports/*.json` //! backups plus their evidence indexes ARE the retained recovery copies. @@ -141,13 +143,12 @@ pub(crate) async fn run_session_name_consolidation(inputs: SessionNameConsolidat let receipt_was_complete = names.migration_completed(); if !receipt_was_complete { // The plan's batching rule: at most 100 candidates per - // envelope/importId. Order the gathered candidates best-first by - // the deterministic total order first, THEN chunk: the - // retained-evidence comparison already makes application - // permutation-invariant, and the ordering additionally keeps every - // chunk's membership stable across boots (the create-once - // per-import backups and the per-candidate acknowledgment replay - // both key off the chunk ids). + // envelope/importId. Order the gathered candidates best-first + // (rank, rename recency, scope; then candidate id) first, THEN + // chunk: the ordering keeps every chunk's membership — and which of + // several equal candidates installs first and stays — stable across + // boots (the create-once per-import backups and the per-candidate + // acknowledgment replay both key off the chunk ids). let mut candidates = std::mem::take(&mut evidence.candidates); order_boot_legacy_candidates(&mut candidates); let envelopes = boot_evidence_envelopes(&evidence); diff --git a/crates/freshell-server/src/session_name_migration_tests.rs b/crates/freshell-server/src/session_name_migration_tests.rs index 5040b7d14..150f9584a 100644 --- a/crates/freshell-server/src/session_name_migration_tests.rs +++ b/crates/freshell-server/src/session_name_migration_tests.rs @@ -5,9 +5,9 @@ //! transaction, the store, or the settings ladder. The behaviors protected //! here are the plan's consolidation contract: //! -//! * deterministic conflict resolution — import permutations (both orders, -//! both devices) choose the same winner by the total order, never arrival -//! order; +//! * deterministic conflict resolution — rank, proven rename recency and +//! scope pick the same winner in every import order; among equal +//! candidates the incumbent stays (window/device ids never decide); //! * classification — a proven explicit session rename is manual; an old //! protected flag (automation→manual-flag path included) is //! `legacy_protected` with `legacyOrigin:"unknown"`, never manual, never @@ -23,7 +23,8 @@ //! * scope-only cleanup never deletes summary/archive/delete/description //! rows and never touches unsupported providers; //! * late/repeated/browser-only imports cannot beat a post-migration -//! manual or accepted-Freshell-AI name; +//! manual or accepted-Freshell-AI name; once the consolidation completed +//! they are acknowledged without any effect (name-reliability Task 3); //! * unresolved/ambiguous evidence stays recovery-only; //! * an initial fallback installation never blocks a later explicit legacy //! import. @@ -651,9 +652,8 @@ async fn post_migration_kilroy_titles_survive_the_late_evidence_pass() { drop(tmp); } -/// Scope + evidence-key tie-breaks: session > pane > source_tab > terminal > -/// derived, then ascending evidenceKey — the same winner from either import -/// order. +/// Scope tie-break: session > pane > source_tab > terminal > derived — the +/// same winner from either import order. #[tokio::test] async fn same_rank_conflicts_resolve_deterministically_across_orders() { let (tmp, home) = fresh_home(); @@ -741,8 +741,8 @@ async fn explicit_rename_times_order_manual_candidates_and_generic_times_do_not( // Both are untrusted imports: the store must not honor the explicit- // rename claims from the HTTP lane at all — both become legacy - // protected, and the tie-break falls to evidence key/id, not the - // claimed times. + // protected, and the claimed times never order them (the equal + // candidates keep whichever installed first). let result = names .import_legacy(browser_import( "times-1", @@ -1216,6 +1216,7 @@ async fn late_legacy_imports_cannot_beat_post_migration_names() { name: "Fresh User Rename".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -1291,11 +1292,70 @@ async fn late_legacy_imports_cannot_beat_post_migration_names() { drop(tmp); } -/// An initial fallback installation never blocks a later explicit legacy -/// import: a boot that installed only a low-rank fallback still accepts a -/// higher-rank legacy candidate from a late device. +/// An initial fallback installation never blocks a higher-rank legacy +/// candidate that arrives BEFORE the consolidation completes: a boot chunk +/// that installed only a low-rank fallback (the boot was interrupted before +/// its receipt committed) still accepts a late device's flagged label. #[tokio::test] async fn initial_fallback_installation_does_not_block_later_legacy_import() { + let (tmp, home) = fresh_home(); + let names = open_store(&home).await; + + // The interrupted boot's chunk: the gathered `titleSource:"dir"` + // fallback for claude:s1, imported under the trusted boot lane without + // the completion receipt. + names + .import_legacy(LegacyNameImport { + version: 1, + import_id: NAME_MIGRATION_BOOT_IMPORT_ID.to_string(), + evidence: vec![], + candidates: vec![candidate( + "boot-dir-1", + session_target(NamedProvider::Claude, "s1"), + "dir-name", + NameSource::Directory, + LegacyCandidateScope::Session, + "session-override:claude:s1", + LegacyProtectionEvidence::None, + )], + }) + .await + .unwrap(); + assert!(!names.migration_completed()); + let update = record_of(&names, NamedProvider::Claude, "s1") + .await + .unwrap(); + assert_eq!(update.record.source, NameSource::Directory); + + // A late device's flagged pane label upgrades the fallback. + names + .import_legacy(browser_import( + "late-upgrade", + vec![candidate( + "up-1", + session_target(NamedProvider::Claude, "s1"), + "User Flagged Pane", + NameSource::Manual, + LegacyCandidateScope::Pane, + "envelope-up#tab#pane", + LegacyProtectionEvidence::LegacyFlag, + )], + )) + .await + .unwrap(); + let update = record_of(&names, NamedProvider::Claude, "s1") + .await + .unwrap(); + assert_eq!(update.record.name, "User Flagged Pane"); + assert_eq!(update.record.source, NameSource::LegacyProtected); + drop(tmp); +} + +/// Name-reliability Task 3: once the real consolidation runner has +/// completed, a late device's import is acknowledged and changes nothing — +/// the installed fallback stays for the normal naming flows to replace. +#[tokio::test] +async fn a_late_device_import_after_consolidation_leaves_the_fallback_unchanged() { let (tmp, home) = fresh_home(); seed_config( &home, @@ -1316,15 +1376,16 @@ async fn initial_fallback_installation_does_not_block_later_legacy_import() { TerminalIdentityRegistry::new(), )) .await; - let update = record_of(&names, NamedProvider::Claude, "s1") + assert!(names.migration_completed()); + let installed = record_of(&names, NamedProvider::Claude, "s1") .await .unwrap(); - assert_eq!(update.record.source, NameSource::Directory); + assert_eq!(installed.record.name, "dir-name"); + assert_eq!(installed.record.source, NameSource::Directory); - // A late device's flagged pane label upgrades the fallback. - names + let result = names .import_legacy(browser_import( - "late-upgrade", + "late-after-completion", vec![candidate( "up-1", session_target(NamedProvider::Claude, "s1"), @@ -1337,17 +1398,22 @@ async fn initial_fallback_installation_does_not_block_later_legacy_import() { )) .await .unwrap(); - let update = record_of(&names, NamedProvider::Claude, "s1") + assert_eq!(result.acknowledged, vec!["up-1".to_string()]); + assert!(result.names.is_empty()); + let after = record_of(&names, NamedProvider::Claude, "s1") .await .unwrap(); - assert_eq!(update.record.name, "User Flagged Pane"); - assert_eq!(update.record.source, NameSource::LegacyProtected); + assert_eq!(after.record.name, "dir-name"); + assert_eq!(after.record.source, NameSource::Directory); + assert_eq!(after.record.revision, installed.record.revision); drop(tmp); } /// Simultaneous imports serialize under the strict transaction: racing two -/// imports (different devices, same target) both acknowledge and leave a -/// single coherent winner, whichever transaction lands first. +/// imports (different devices, same target, equal rank and scope) both +/// acknowledge and leave a single coherent winner — whichever transaction +/// landed first. The other device's equal candidate never replaces it +/// (window/device ids never decide a name, Task 3). #[tokio::test] async fn simultaneous_imports_serialize_and_converge() { let (tmp, home) = fresh_home(); @@ -1372,8 +1438,8 @@ async fn simultaneous_imports_serialize_and_converge() { LegacyProtectionEvidence::LegacyFlag, ); - let import_a = names.import_legacy(browser_import("race-import-a", vec![device_a])); - let import_b = names.import_legacy(browser_import("race-import-b", vec![device_b])); + let import_a = names.import_legacy(browser_import("race-import-a", vec![device_a.clone()])); + let import_b = names.import_legacy(browser_import("race-import-b", vec![device_b.clone()])); let (a, b) = tokio::join!(import_a, import_b); let a = a.unwrap(); let b = b.unwrap(); @@ -1381,12 +1447,34 @@ async fn simultaneous_imports_serialize_and_converge() { assert_eq!(b.acknowledged, vec!["race-b".to_string()]); // Both are legacy_protected pane candidates for the same target: the - // deterministic total order picks ONE winner by evidence key - // ("envelope-a…" < "envelope-b…" — the earlier evidence key wins). + // first install allocated the store's first revision, and the second + // (equal) candidate was acknowledged without a replacement. let update = record_of(&names, NamedProvider::Claude, "s1") .await .unwrap(); - assert_eq!(update.record.name, "Device A Label"); + assert!( + ["Device A Label", "Device B Label"].contains(&update.record.name.as_str()), + "one device's label won: {:?}", + update.record.name + ); + assert_eq!(update.record.source, NameSource::LegacyProtected); + assert_eq!( + update.record.revision, 1, + "exactly one install: the equal candidate never replaced it" + ); + + // Re-delivering both imports (either order) keeps the same winner. + for import in [ + browser_import("race-import-b", vec![device_b]), + browser_import("race-import-a", vec![device_a]), + ] { + names.import_legacy(import).await.unwrap(); + let again = record_of(&names, NamedProvider::Claude, "s1") + .await + .unwrap(); + assert_eq!(again.record.name, update.record.name); + assert_eq!(again.record.revision, update.record.revision); + } drop(tmp); } diff --git a/crates/freshell-server/src/session_name_native.rs b/crates/freshell-server/src/session_name_native.rs index 25af72245..35b9134b2 100644 --- a/crates/freshell-server/src/session_name_native.rs +++ b/crates/freshell-server/src/session_name_native.rs @@ -515,7 +515,9 @@ async fn run( } } tokio::select! { - _ = tokio::time::sleep(WORKER_POLL_INTERVAL) => {} + // Name-reliability Task 6: a paused generation series is + // re-probed when its window ends, not up to a poll later. + _ = tokio::time::sleep(generator.idle_poll(WORKER_POLL_INTERVAL)) => {} // A settings/key capability change wakes the poll // immediately; the next pass re-evaluates capability. _ = generator.wake_notify().notified() => {} diff --git a/crates/freshell-server/src/session_name_native_tests.rs b/crates/freshell-server/src/session_name_native_tests.rs index 045de64a8..948e6fafe 100644 --- a/crates/freshell-server/src/session_name_native_tests.rs +++ b/crates/freshell-server/src/session_name_native_tests.rs @@ -96,6 +96,7 @@ async fn rename_user( name: name.to_string(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await } @@ -111,6 +112,7 @@ async fn rename_automatic( name: name.to_string(), intent: NameIntent::Automatic, if_revision: None, + cwd: None, }) .await } @@ -1136,12 +1138,13 @@ fn inert_generation( dir: &std::path::Path, ) -> Arc { struct NeverTransport; - impl crate::ai_title::GeminiTransport for NeverTransport { + impl crate::session_name_capability::SessionNameTransport for NeverTransport { fn generate_content( &self, _p: String, _m: u32, - ) -> crate::ai_title::BoxFuture> { + ) -> crate::ai_title::BoxFuture> + { Box::pin(std::future::pending()) } } @@ -2011,12 +2014,13 @@ async fn started_shared_titled_manager( #[tokio::test] async fn an_opencode_native_series_resumes_when_the_shared_serve_arrives_after_boot() { struct NeverGemini; - impl crate::ai_title::GeminiTransport for NeverGemini { + impl crate::session_name_capability::SessionNameTransport for NeverGemini { fn generate_content( &self, _prompt: String, _max_output_tokens: u32, - ) -> crate::ai_title::BoxFuture> { + ) -> crate::ai_title::BoxFuture> + { Box::pin(std::future::pending()) } } @@ -2070,7 +2074,7 @@ async fn an_opencode_native_series_resumes_when_the_shared_serve_arrives_after_b crate::ai_title::GeminiSessionNameAuth::direct_for_test(crate::ai_title::AiKeyCell::init( None, None, )), - Arc::new(NeverGemini) as Arc, + Arc::new(NeverGemini) as Arc, )); let worker = super::SessionNameWorker::start(Arc::clone(&store), dispatch, generator); diff --git a/crates/freshell-server/src/session_name_plan_activity_sweep_tests.rs b/crates/freshell-server/src/session_name_plan_activity_sweep_tests.rs new file mode 100644 index 000000000..aff1e2c82 --- /dev/null +++ b/crates/freshell-server/src/session_name_plan_activity_sweep_tests.rs @@ -0,0 +1,314 @@ +//! Name-reliability Task 5 (D4, C5): the auto-title sweep, one pass at a +//! time against the REAL store — the first-message fallback for a session +//! open in a live terminal, and the targeted OpenCode first-message lookup +//! (who gets it, what is remembered across passes, and that a failed read +//! is retried rather than remembered). A child of `plan_activity_tests`, +//! whose helpers it shares. + +use std::time::Duration; + +use super::*; + +/// An index source whose only job is the targeted OpenCode first-message +/// lookup the sweep makes for rows whose listing carries no first message: +/// scripted answers (or failed reads), every read counted. +#[derive(Default)] +struct ScriptedFirstMessages { + messages: std::sync::Mutex>, + failing: std::sync::Mutex>, + lookups: std::sync::atomic::AtomicUsize, +} + +impl ScriptedFirstMessages { + fn answer(&self, session_id: &str, message: &str) { + self.failing.lock().unwrap().remove(session_id); + self.messages + .lock() + .unwrap() + .insert(session_id.to_string(), message.to_string()); + } + + /// Every read of `session_id` fails (the database stays busy). + fn fail(&self, session_id: &str) { + self.failing.lock().unwrap().insert(session_id.to_string()); + } + + fn lookups(&self) -> usize { + self.lookups.load(std::sync::atomic::Ordering::SeqCst) + } +} + +/// Forwarding wrapper so the test keeps a handle on the counters while the +/// index owns the source. +struct SharedScript(Arc); + +impl freshell_sessions::directory_index::SessionSource for SharedScript { + fn discover(&self) -> Vec { + Vec::new() + } + + fn parse(&self, _path: &Path) -> Option { + None + } + + fn targeted_first_user_message( + &self, + session_id: &str, + ) -> Option { + self.0 + .lookups + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.0.failing.lock().unwrap().contains(session_id) { + return Some(Err(freshell_sessions::parse::OpencodeByIdError { + code: Some("SQLITE_BUSY".to_string()), + message: "database is locked".to_string(), + })); + } + Some(Ok(self.0.messages.lock().unwrap().get(session_id).cloned())) + } +} + +fn scripted_index( + script: &Arc, +) -> Arc { + Arc::new( + freshell_sessions::directory_index::SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(SharedScript(Arc::clone(script)))], + std::time::Duration::from_millis(1_000), + None, + ), + ) +} + +struct NoGemini; +impl crate::ai_title::GeminiTransport for NoGemini { + fn generate_content( + &self, + _p: String, + _m: u32, + ) -> crate::ai_title::BoxFuture> { + Box::pin(async { Err("no live Gemini in tests".to_string()) }) + } +} + +/// A LATER (non-boot) sweep pass's state over `names`. +fn sweep_state( + home: &Path, + names: Arc, + index: Option>, +) -> crate::auto_title_sweep::AutoTitleSweepState { + let (tx, _rx) = tokio::sync::broadcast::channel::(256); + crate::auto_title_sweep::AutoTitleSweepState { + settings: crate::settings_store::SettingsStore::load(Some(home), vec![]), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + registry: freshell_terminal::TerminalRegistry::new(), + broadcast_tx: Arc::new(tx), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + auth: crate::ai_title::GeminiSessionNameAuth::direct_for_test( + crate::ai_title::AiKeyCell::init(None, None), + ), + gemini: Arc::new(NoGemini), + pending_ai_titles: Default::default(), + terminal_meta: Default::default(), + git_meta_cache: Default::default(), + names: Some(names), + metadata: crate::session_metadata::SessionMetadataStore::new(home), + index, + index_hydrated: Arc::new(std::sync::atomic::AtomicBool::new(true)), + first_message_lookups: Default::default(), + } +} + +fn row( + provider: &str, + id: &str, + cwd: &str, + first: Option<&str>, + last_activity_at: i64, +) -> crate::auto_title_sweep::SweepSession { + crate::auto_title_sweep::SweepSession { + provider: provider.into(), + session_id: id.into(), + cwd: Some(cwd.into()), + title: None, + first_user_message: first.map(str::to_string), + title_source: None, + git_branch: None, + last_activity_at, + } +} + +/// D4 end to end in the sweep: a session open in a live terminal (the +/// sweep's `Opened` edge) whose index row carries a first user message gets +/// the first-message name in ONE pass, with AI naming unavailable. The +/// pane's folder name ("work", from the folder the pane was created in) is +/// not the transcript's folder ("/x/proj": the session moved, or the +/// provider recorded another spelling of the path), so only the activity's +/// fallback — not the hydration heal of a folder name — can name it. +#[tokio::test] +async fn sweep_names_open_terminal_session_from_first_message() { + let dir = temp_dir(); + let names = open(dir.path()); + let target = directory_record(&names, NamedProvider::Claude, "s-live").await; + let state = sweep_state(dir.path(), names.clone(), None); + state.identity.upsert( + "term-live", + Some("claude"), + Some("s-live"), + Some("/x/proj"), + 1, + ); + + crate::auto_title_sweep::run_auto_title_pass( + &state, + &[row("claude", "s-live", "/x/proj", Some(SARDINE), 1_000)], + ) + .await; + + let record = get_one(&names, &target).await; + assert_eq!(record.name, SARDINE); + assert_eq!(record.source, NameSource::FirstMessage); +} + +/// An OpenCode session whose series already captured an input fingerprint +/// (so generation needs no more input) but whose record is still the +/// folder name gets the targeted lookup anyway: its first message names it +/// in one pass, and once named a later pass never reads again. +#[tokio::test] +async fn sweep_offers_fallback_for_opencode_with_existing_fingerprint() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Opencode, "ses_fingerprinted").await; + rewrite(dir.path(), |document| { + document.generation.insert( + name_ref_key(&target), + series_with_input(SARDINE, GenerationStatus::Eligible, 1), + ); + }); + let names = open(dir.path()); + assert!( + !names.needs_generation_input(&target), + "sanity: generation has its input" + ); + let script = Arc::new(ScriptedFirstMessages::default()); + script.answer("ses_fingerprinted", SARDINE); + let state = sweep_state(dir.path(), names.clone(), Some(scripted_index(&script))); + + crate::auto_title_sweep::run_auto_title_pass( + &state, + &[row("opencode", "ses_fingerprinted", "/w/work", None, 1_000)], + ) + .await; + let record = get_one(&names, &target).await; + assert_eq!(record.name, SARDINE); + assert_eq!(record.source, NameSource::FirstMessage); + assert_eq!(script.lookups(), 1); + + // Named now: even with newer index activity, nothing is read again. + crate::auto_title_sweep::run_auto_title_pass( + &state, + &[row("opencode", "ses_fingerprinted", "/w/work", None, 2_000)], + ) + .await; + assert_eq!(script.lookups(), 1, "a named session is never read again"); +} + +/// C5: an OpenCode record stuck on its folder name ("freshell" in +/// /home/dan/code/freshell) with an exhausted series — generation needs no +/// input — still gets the targeted lookup, and the record heals to the +/// first message. +#[tokio::test] +async fn opencode_folder_named_record_gets_the_targeted_lookup() { + let dir = temp_dir(); + let folder = "/home/dan/code/freshell"; + let store = open(dir.path()); + let target = session(NamedProvider::Opencode, "ses_folder"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_folder", + Some(folder), + Some("freshell"), + None, + ), + false, + ) + .await; + rewrite(dir.path(), |document| { + let mut series = + series_with_input(SARDINE, GenerationStatus::Exhausted, MAX_GENERATION_STARTS); + series.excerpt = None; + series.exhausted_at = Some(1); + document.generation.insert(name_ref_key(&target), series); + }); + let names = open(dir.path()); + assert!(!names.needs_generation_input(&target), "sanity: exhausted"); + let script = Arc::new(ScriptedFirstMessages::default()); + script.answer("ses_folder", SARDINE); + let state = sweep_state(dir.path(), names.clone(), Some(scripted_index(&script))); + + crate::auto_title_sweep::run_auto_title_pass( + &state, + &[row("opencode", "ses_folder", folder, None, 1_000)], + ) + .await; + assert_eq!( + script.lookups(), + 1, + "the folder-named record got the lookup" + ); + let record = get_one(&names, &target).await; + assert_eq!(record.name, SARDINE); + assert_eq!(record.source, NameSource::FirstMessage); +} + +/// C5: a session with no user message is read once over three passes (the +/// "no user message" answer is remembered across passes), and newer index +/// activity for it reads again. +#[tokio::test] +async fn no_user_message_memo_survives_later_passes() { + let dir = temp_dir(); + let names = open(dir.path()); + directory_record(&names, NamedProvider::Opencode, "ses_silent").await; + let script = Arc::new(ScriptedFirstMessages::default()); + let state = sweep_state(dir.path(), names.clone(), Some(scripted_index(&script))); + let pass = |at: i64| [row("opencode", "ses_silent", "/w/work", None, at)]; + + for _ in 0..3 { + crate::auto_title_sweep::run_auto_title_pass(&state, &pass(1_000)).await; + } + assert_eq!(script.lookups(), 1, "read once over three passes"); + + crate::auto_title_sweep::run_auto_title_pass(&state, &pass(2_000)).await; + assert_eq!(script.lookups(), 2, "newer index activity reads again"); +} + +/// Review of Task 5: a FAILED targeted read is not the "no user message" +/// answer. An OpenCode session still on its folder name whose read fails +/// (the database stayed busy) is read again on a pass after the retry +/// window — same index activity — and heals to its first message. +#[tokio::test] +async fn a_failed_opencode_lookup_is_retried_and_the_record_heals() { + let dir = temp_dir(); + let names = open(dir.path()); + let target = directory_record(&names, NamedProvider::Opencode, "ses_flaky").await; + let script = Arc::new(ScriptedFirstMessages::default()); + script.fail("ses_flaky"); + let mut state = sweep_state(dir.path(), names.clone(), Some(scripted_index(&script))); + state.first_message_lookups = + crate::auto_title_sweep::FirstMessageLookupMemo::with_failure_retry(Duration::ZERO); + let pass = [row("opencode", "ses_flaky", "/w/work", None, 1_000)]; + + crate::auto_title_sweep::run_auto_title_pass(&state, &pass).await; + assert_eq!(script.lookups(), 1); + assert_eq!(get_one(&names, &target).await.source, NameSource::Directory); + + // The database answers now; the row's activity has not moved. + script.answer("ses_flaky", SARDINE); + crate::auto_title_sweep::run_auto_title_pass(&state, &pass).await; + assert_eq!(script.lookups(), 2, "the failed read is retried"); + let record = get_one(&names, &target).await; + assert_eq!(record.name, SARDINE); + assert_eq!(record.source, NameSource::FirstMessage); +} diff --git a/crates/freshell-server/src/session_name_plan_activity_tests.rs b/crates/freshell-server/src/session_name_plan_activity_tests.rs new file mode 100644 index 000000000..11be5d727 --- /dev/null +++ b/crates/freshell-server/src/session_name_plan_activity_tests.rs @@ -0,0 +1,977 @@ +//! Name-reliability Task 5 (D4, RC12): the first-message fallback is offered +//! on every activity that carries a first user message (an open or resumed +//! session, not only a newly accepted or indexed message), tool wrapper text +//! is never a first-message name and never generation input, a re-derived +//! first message rests instead of arming background naming, and the OpenCode +//! first-message lookup sees the session's folder. Driven through the REAL +//! store on a temp data dir; nothing here is mocked. + +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use freshell_freshagent::naming::NameActivityReason; +use freshell_protocol::native_location::{NativeEvidenceKind, NativePersistence}; +use freshell_protocol::session_names::{NameIntent, NameSource, NamedProvider}; + +use super::*; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SARDINE: &str = "Fix the sardine importer"; +const RECOMMENDED_PLUGINS: &str = + "\n- github: review pull requests\n"; + +fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open store") +} + +fn session(provider: NamedProvider, id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider, + session_id: id.to_string(), + } +} + +fn raw_document(dir: &Path) -> StoredDocument { + let bytes = std::fs::read(dir.join(DOCUMENT_FILE_NAME)).expect("raw document exists"); + parse_document(&bytes).expect("raw document parses") +} + +/// Rewrite the document on disk (under the sidecar lock, one generation +/// later — what another current-build process committing would leave) to set +/// up a state no public path produces directly (a series the old extraction +/// armed with wrapper text, an exhausted series). Callers reopen the store. +fn rewrite(dir: &Path, change: impl FnOnce(&mut StoredDocument)) { + let mut document = raw_document(dir); + change(&mut document); + document.document_generation += 1; + let bytes = serialize_document(&document).expect("serialize rewritten document"); + let lock = open_lock_file(&dir.join(LOCK_FILE_NAME)).expect("open lock"); + let deadline = Instant::now() + LOCK_RETRY_BUDGET; + loop { + match lock.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) => { + assert!(Instant::now() < deadline, "lock stayed busy"); + std::thread::sleep(LOCK_POLL_INTERVAL); + } + Err(error) => panic!("lock for rewrite: {error}"), + } + } + std::fs::write(dir.join(DOCUMENT_FILE_NAME), bytes).expect("write rewritten document"); +} + +fn indexed( + provider: NamedProvider, + id: &str, + cwd: Option<&str>, + provider_title: Option<&str>, + first_message: Option<&str>, +) -> IndexedNameInput { + IndexedNameInput { + provider, + session_id: id.to_string(), + cwd: cwd.map(str::to_string), + first_user_message: first_message.map(str::to_string), + provider_title: provider_title.map(str::to_string), + } +} + +async fn seed( + store: &Arc, + input: IndexedNameInput, + absorb: bool, +) -> SessionNameUpdate { + store + .hydrate_indexed(input, absorb) + .await + .expect("seed through hydration") +} + +/// A directory record named after `/w/work` ("work"), no series. +async fn directory_record( + store: &Arc, + provider: NamedProvider, + id: &str, +) -> SessionNameRef { + let seeded = seed( + store, + indexed(provider, id, Some("/w/work"), None, None), + false, + ) + .await; + assert_eq!(seeded.record.name, "work"); + assert_eq!(seeded.record.source, NameSource::Directory); + session(provider, id) +} + +async fn get_one(store: &Arc, target: &SessionNameRef) -> SessionNameRecord { + store + .get(vec![target.clone()]) + .await + .expect("get") + .into_iter() + .next() + .expect("the record exists") + .record +} + +fn activity_input( + target: &SessionNameRef, + reason: NameActivityReason, + message: Option<&str>, +) -> NameActivity { + NameActivity { + target: target.clone(), + mode: match target { + SessionNameRef::Session { provider, .. } => provider.as_str().to_string(), + SessionNameRef::Pending { .. } => "claude".to_string(), + }, + event_id: "event-1".to_string(), + reason, + first_user_message: message.map(str::to_string), + cwd: Some("/w/work".to_string()), + } +} + +async fn activity( + store: &Arc, + target: &SessionNameRef, + reason: NameActivityReason, + message: Option<&str>, +) -> SessionNameUpdate { + store + .activity(activity_input(target, reason, message)) + .await + .expect("activity folds") +} + +fn series_of(dir: &Path, target: &SessionNameRef) -> Option { + raw_document(dir) + .generation + .get(&name_ref_key(target)) + .cloned() +} + +fn armed(store: &Arc, target: &SessionNameRef) -> bool { + store + .generation_work_snapshot() + .iter() + .any(|item| &item.target == target) +} + +/// A series that stores `message` as its generation input, at `status`. +fn series_with_input(message: &str, status: GenerationStatus, consumed: u32) -> GenerationState { + GenerationState { + status, + series_id: "series-wrapper".to_string(), + input_fingerprint: Some(fingerprint_message(message)), + excerpt: Some(excerpt_of(message)), + attempt_ids: Vec::new(), + attempt_starts: Vec::new(), + consumed, + next_due: None, + exhausted_at: None, + } +} + +fn codex_acquisition() -> NativeAcquisition { + NativeAcquisition { + location: NativeLocation::Codex { + codex_home: "/w/homes/codex".to_string(), + native_thread_id: Some("thread_1".to_string()), + rollout_path: Some("/w/homes/codex/rollout-1.jsonl".to_string()), + persistence_evidence: Some("thread_started_notification".to_string()), + }, + evidence: NativeEvidenceKind::InitializedRuntime, + persistence: NativePersistence::Verified, + } +} + +// --------------------------------------------------------------------------- +// The fallback on every activity that carries a first message (D4) +// --------------------------------------------------------------------------- + +/// An open session (the sweep's `Opened` edge for a live terminal) gets its +/// first-message name; before, only accepted/indexed messages offered it, +/// so a terminal pane kept its folder name whenever AI naming failed. +#[tokio::test] +async fn opened_activity_offers_first_message_fallback() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Claude, "s-opened").await; + let message = "SMS isn't updating. Root cause why."; + let update = activity(&store, &target, NameActivityReason::Opened, Some(message)).await; + assert_eq!(update.record.name, message); + assert_eq!(update.record.source, NameSource::FirstMessage); + assert_eq!(get_one(&store, &target).await.name, message); +} + +#[tokio::test] +async fn resumed_activity_offers_first_message_fallback() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Codex, "s-resumed").await; + let message = "SMS isn't updating. Root cause why."; + let update = activity(&store, &target, NameActivityReason::Resumed, Some(message)).await; + assert_eq!(update.record.name, message); + assert_eq!(update.record.source, NameSource::FirstMessage); +} + +/// Pinning test: the fallback never lowers a real name's rank, whatever +/// the activity reason. +#[tokio::test] +async fn fallback_never_lowers_rank() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-ranked"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-ranked", + Some("/w/work"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + let before = get_one(&store, &target).await; + assert_eq!(before.source, NameSource::ProviderAi); + for reason in [ + NameActivityReason::Opened, + NameActivityReason::Resumed, + NameActivityReason::AcceptedUserMessage, + NameActivityReason::IndexUserMessage, + ] { + activity(&store, &target, reason, Some(SARDINE)).await; + let after = get_one(&store, &target).await; + assert_eq!(after.name, "Billing bug", "{reason:?} kept the real name"); + assert_eq!(after.source, NameSource::ProviderAi); + assert_eq!( + after.revision, before.revision, + "{reason:?} changed nothing visible" + ); + } +} + +/// The free fallback does not depend on AI naming: an exhausted series (AI +/// naming gave up) still lets an open session take its first message, and +/// the series stays exhausted. +#[tokio::test] +async fn fallback_is_independent_of_generation_exhaustion() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Claude, "s-exhausted").await; + rewrite(dir.path(), |document| { + let mut series = + series_with_input(SARDINE, GenerationStatus::Exhausted, MAX_GENERATION_STARTS); + series.excerpt = None; + series.exhausted_at = Some(1); + document.generation.insert(name_ref_key(&target), series); + }); + let store = open(dir.path()); + let update = activity(&store, &target, NameActivityReason::Opened, Some(SARDINE)).await; + assert_eq!(update.record.name, SARDINE); + assert_eq!(update.record.source, NameSource::FirstMessage); + let series = series_of(dir.path(), &target).expect("series kept"); + assert_eq!(series.status, GenerationStatus::Exhausted); + assert_eq!(series.consumed, MAX_GENERATION_STARTS); + assert!(!armed(&store, &target)); +} + +// --------------------------------------------------------------------------- +// Wrapper text is never a name (RC12) +// --------------------------------------------------------------------------- + +/// A first message that is tool wrapper text is never offered as a name, +/// and never arms AI naming from it. +#[tokio::test] +async fn wrapper_first_message_is_never_a_fallback() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Codex, "s-wrapper").await; + for reason in [ + NameActivityReason::AcceptedUserMessage, + NameActivityReason::IndexUserMessage, + NameActivityReason::Opened, + ] { + let update = activity(&store, &target, reason, Some(RECOMMENDED_PLUGINS)).await; + assert_eq!(update.record.name, "work", "{reason:?}: no wrapper name"); + assert_eq!(update.record.source, NameSource::Directory); + } + assert!( + !armed(&store, &target), + "wrapper text is never generation input" + ); +} + +/// Hydration (the sweep's insert of a new record) never installs wrapper +/// text as a first-message name, nor absorbs it as generation input: the +/// ladder falls through to the folder name. +#[tokio::test] +async fn hydrate_never_installs_or_absorbs_wrapper_text() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Codex, "s-hydrate-wrapper"); + let seeded = seed( + &store, + indexed( + NamedProvider::Codex, + "s-hydrate-wrapper", + Some("/w/work"), + None, + Some(RECOMMENDED_PLUGINS), + ), + true, + ) + .await; + assert_eq!(seeded.record.name, "work"); + assert_eq!(seeded.record.source, NameSource::Directory); + assert!( + series_of(dir.path(), &target).is_none(), + "no series stores wrapper text" + ); +} + +/// A saved name that IS wrapper text (171 on the live copy) heals to the +/// real first message the re-derived index now offers — the same rank, so +/// only the self-heal rule lets it through. Like the live records, the +/// session's series absorbed the wrapper text at rest (Idle, never +/// attempted); the heal re-absorbs the real message without arming. +#[tokio::test] +async fn wrapper_first_message_record_heals_to_the_real_first_message() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Codex, "s-wrapper-name"); + seed( + &store, + indexed( + NamedProvider::Codex, + "s-wrapper-name", + Some("/w/work"), + None, + Some("placeholder text"), + ), + false, + ) + .await; + let wrapper_name = first_message_title_of_old_rules(); + rewrite(dir.path(), |document| { + let record = document + .records + .get_mut(&name_ref_key(&target)) + .expect("seeded record"); + record.name = wrapper_name.clone(); + record.source = NameSource::FirstMessage; + document.generation.insert( + name_ref_key(&target), + series_with_input(RECOMMENDED_PLUGINS, GenerationStatus::Idle, 0), + ); + }); + let store = open(dir.path()); + let update = activity( + &store, + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + ) + .await; + assert_eq!(update.record.name, SARDINE); + assert_eq!(update.record.source, NameSource::FirstMessage); + assert!( + !armed(&store, &target), + "healing a name arms no background naming" + ); + let series = series_of(dir.path(), &target).expect("series kept"); + assert_eq!(series.status, GenerationStatus::Idle); + assert_eq!( + series.excerpt.as_deref(), + Some(SARDINE), + "the real message rests as input" + ); +} + +/// The name the old extraction saved for a `` first +/// record: the 50-character title of the raw wrapper text. +fn first_message_title_of_old_rules() -> String { + freshell_sessions::text::extract_title_from_message(RECOMMENDED_PLUGINS, 50) +} + +// --------------------------------------------------------------------------- +// Re-absorb at rest (V2 decision B) +// --------------------------------------------------------------------------- + +/// A re-derived first message (the extraction rules changed; a real first +/// message never changes) refreshes the input of an Idle, never-attempted +/// series without arming it in the background; an explicit open then arms +/// it with the new excerpt. +#[tokio::test] +async fn changed_first_message_on_an_idle_series_reabsorbs_without_arming() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Codex, "s-reabsorb"); + seed( + &store, + indexed( + NamedProvider::Codex, + "s-reabsorb", + Some("/w/work"), + None, + Some("An older first message"), + ), + true, + ) + .await; + let before = series_of(dir.path(), &target).expect("absorbed series"); + assert_eq!(before.status, GenerationStatus::Idle); + + activity( + &store, + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + ) + .await; + let rested = series_of(dir.path(), &target).expect("series kept"); + assert_eq!( + rested.status, + GenerationStatus::Idle, + "rests, never armed in the background" + ); + assert_eq!(rested.consumed, 0); + assert_eq!(rested.series_id, before.series_id); + assert_eq!(rested.input_fingerprint, Some(fingerprint_message(SARDINE))); + assert_eq!(rested.excerpt.as_deref(), Some(SARDINE)); + assert!(!armed(&store, &target)); + let claim = store + .claim_generation_start(target.clone(), "attempt-rest".to_string()) + .await + .expect("claim runs"); + assert!(claim.is_none(), "a resting series is not claimable"); + assert_eq!(series_of(dir.path(), &target).expect("series").consumed, 0); + + activity(&store, &target, NameActivityReason::Opened, Some(SARDINE)).await; + let opened = series_of(dir.path(), &target).expect("series"); + assert_eq!( + opened.status, + GenerationStatus::Eligible, + "an explicit open arms it" + ); + assert_eq!(opened.excerpt.as_deref(), Some(SARDINE)); + assert!(armed(&store, &target)); +} + +// --------------------------------------------------------------------------- +// The prompt-input guard (V2) +// --------------------------------------------------------------------------- + +/// A series whose stored input is wrapper text (stored by the old +/// extraction) is never armed from it: an activity without a real first +/// message drops the stored input, a pending bind (the explicit-open hook) +/// leaves it Idle, and a claim forced on it consumes nothing. Each refusal +/// logs `session_names.generation_input_refused` once, naming the guard +/// that fired (`site`: activity / bind / claim). +#[tokio::test] +async fn wrapper_excerpt_is_never_armed() { + let events = crate::test_trace_capture::captured_trace_events(); + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Codex, "s-wrapper-input").await; + let key = name_ref_key(&target); + // Each plant gets its own series id, so its refusal event is told apart + // from every other test's. + let plant = |dir: &Path, status: GenerationStatus| { + let series_id = format!("series-wrapper-{}", Uuid::new_v4()); + rewrite(dir, |document| { + let mut series = series_with_input(RECOMMENDED_PLUGINS, status, 0); + series.series_id = series_id.clone(); + document.generation.insert(key.clone(), series); + }); + series_id + }; + let refusal_sites = |series_id: &str| -> Vec { + events + .snapshot() + .into_iter() + .filter(|event| { + event.level == tracing::Level::INFO + && event + .message + .starts_with("session_names.generation_input_refused") + && event.fields.get("series").map(String::as_str) == Some(series_id) + }) + .map(|event| event.fields.get("site").cloned().unwrap_or_default()) + .collect() + }; + + // An Opened activity with no first message drops the stored input. + let activity_series = plant(dir.path(), GenerationStatus::Idle); + let store = open(dir.path()); + activity(&store, &target, NameActivityReason::Opened, None).await; + let dropped = series_of(dir.path(), &target).expect("series kept"); + assert_eq!(dropped.status, GenerationStatus::Idle); + assert_eq!( + dropped.input_fingerprint, None, + "the wrapper input is dropped" + ); + assert_eq!(dropped.excerpt, None); + assert_eq!(dropped.consumed, 0); + assert!(!armed(&store, &target)); + assert_eq!(refusal_sites(&activity_series), vec!["activity"]); + + // A pending bind to the session (an explicit open) leaves it Idle. + let bind_series = plant(dir.path(), GenerationStatus::Idle); + let store = open(dir.path()); + store + .ensure_pending(PendingNameInput { + handle: "h-wrapper-input".to_string(), + provider: NamedProvider::Codex, + cwd: Some("/w/work".to_string()), + }) + .await + .expect("ensure pending"); + store + .bind_pending(BindNameInput { + pending: SessionNameRef::Pending { + id: "h-wrapper-input".to_string(), + }, + target: target.clone(), + acquisition: codex_acquisition(), + }) + .await + .expect("bind"); + let bound = series_of(dir.path(), &target).expect("series kept"); + assert_eq!( + bound.status, + GenerationStatus::Idle, + "the bind never arms wrapper input" + ); + assert_eq!(bound.excerpt, None, "the bind drops the wrapper input"); + assert!(!armed(&store, &target)); + assert_eq!(refusal_sites(&bind_series), vec!["bind"]); + + // A claim forced on an Eligible wrapper series consumes nothing, and + // the series stops being offered to the worker. + let claim_series = plant(dir.path(), GenerationStatus::Eligible); + let store = open(dir.path()); + assert!( + armed(&store, &target), + "sanity: the planted series is offered" + ); + let claim = store + .claim_generation_start(target.clone(), "attempt-wrapper".to_string()) + .await + .expect("claim runs"); + assert!(claim.is_none(), "wrapper text is never sent to the model"); + let refused = series_of(dir.path(), &target).expect("series kept"); + assert_eq!(refused.consumed, 0, "the refusal consumed nothing"); + assert!(refused.attempt_ids.is_empty()); + assert!( + !armed(&store, &target), + "the refused series is no longer offered" + ); + assert_eq!(refusal_sites(&claim_series), vec!["claim"]); +} + +/// `session_names.generation_input_refused` says the input "was dropped", +/// so it is written only once the drop committed: a transaction whose +/// commit fails writes none (the input is still stored), and its retry +/// writes one. +#[tokio::test] +async fn the_input_refusal_is_logged_only_after_the_commit() { + let events = crate::test_trace_capture::captured_trace_events(); + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Codex, "s-wrapper-uncommitted").await; + let series_id = format!("series-wrapper-{}", Uuid::new_v4()); + rewrite(dir.path(), |document| { + let mut series = series_with_input(RECOMMENDED_PLUGINS, GenerationStatus::Idle, 0); + series.series_id = series_id.clone(); + document.generation.insert(name_ref_key(&target), series); + }); + let store = open(dir.path()); + let refusals = || { + events + .snapshot() + .into_iter() + .filter(|event| { + event + .message + .starts_with("session_names.generation_input_refused") + && event.fields.get("series") == Some(&series_id) + }) + .count() + }; + set_test_hooks(dir.path(), vec![TestHook::PreReplace]); + + let failed = store + .activity(activity_input(&target, NameActivityReason::Opened, None)) + .await; + + assert!( + matches!(failed, Err(NameError::Persistence(_))), + "{failed:?}" + ); + assert_eq!( + series_of(dir.path(), &target) + .expect("series kept") + .excerpt + .as_deref(), + Some(excerpt_of(RECOMMENDED_PLUGINS).as_str()), + "the failed commit dropped nothing" + ); + assert_eq!(refusals(), 0, "no drop was saved, so none is logged"); + + clear_test_hooks(dir.path()); + activity(&store, &target, NameActivityReason::Opened, None).await; + assert_eq!( + series_of(dir.path(), &target).expect("series").excerpt, + None + ); + assert_eq!(refusals(), 1, "the committed drop is logged once"); +} + +/// Pinning the other side of the guard: an activity carrying a real first +/// message replaces the wrapper input and arms with it. +#[tokio::test] +async fn wrapper_excerpt_is_replaced_by_a_real_message() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Codex, "s-wrapper-replace").await; + rewrite(dir.path(), |document| { + document.generation.insert( + name_ref_key(&target), + series_with_input(RECOMMENDED_PLUGINS, GenerationStatus::Idle, 0), + ); + }); + let store = open(dir.path()); + activity(&store, &target, NameActivityReason::Opened, Some(SARDINE)).await; + let series = series_of(dir.path(), &target).expect("series"); + assert_eq!(series.status, GenerationStatus::Eligible); + assert_eq!(series.excerpt.as_deref(), Some(SARDINE)); + assert_eq!(series.input_fingerprint, Some(fingerprint_message(SARDINE))); + assert!(armed(&store, &target)); +} + +// --------------------------------------------------------------------------- +// The targeted first-message lookup sees the folder (C5) +// --------------------------------------------------------------------------- + +/// Whether a session still needs its first message: no record, a record +/// below `first_message`, or a junk record under the row's folder. A real +/// first-message name, a provider name, a protected name, and a folder name +/// whose folder is unknown do not. +#[tokio::test] +async fn needs_first_message_input_follows_the_folder() { + let dir = temp_dir(); + let store = open(dir.path()); + let folder = "/home/dan/code/freshell"; + + assert!(store + .needs_first_message_input(&session(NamedProvider::Opencode, "ses_none"), Some(folder))); + + let directory = directory_record(&store, NamedProvider::Opencode, "ses_dir").await; + assert!(store.needs_first_message_input(&directory, Some("/w/work"))); + + let named = session(NamedProvider::Opencode, "ses_first"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_first", + Some(folder), + None, + Some(SARDINE), + ), + false, + ) + .await; + assert!(!store.needs_first_message_input(&named, Some(folder))); + + let provider = session(NamedProvider::Opencode, "ses_provider"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_provider", + Some(folder), + Some("Billing bug"), + None, + ), + false, + ) + .await; + assert!(!store.needs_first_message_input(&provider, Some(folder))); + + let folder_named = session(NamedProvider::Opencode, "ses_folder_named"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_folder_named", + Some(folder), + Some("freshell"), + None, + ), + false, + ) + .await; + assert_eq!( + get_one(&store, &folder_named).await.source, + NameSource::ProviderAi + ); + assert!(store.needs_first_message_input(&folder_named, Some(folder))); + assert!( + !store.needs_first_message_input(&folder_named, None), + "without the folder only folder-free labels are recognized" + ); + + let manual = session(NamedProvider::Opencode, "ses_manual"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_manual", + Some(folder), + None, + None, + ), + false, + ) + .await; + store + .rename(RenameNameInput { + target: manual.clone(), + name: "freshell".to_string(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .expect("user rename"); + assert!( + !store.needs_first_message_input(&manual, Some(folder)), + "a name a person chose stays" + ); +} + +// --------------------------------------------------------------------------- +// An accepted send heals a junk name only as the first message +// --------------------------------------------------------------------------- + +/// An accepted send carries the message the user just sent, not the +/// session's first message. Once the store holds the session's AI-naming +/// input — the post-deploy shapes: an exhausted series, or one the boot +/// snapshot absorbed at rest — a later send such as "continue" must not +/// replace a junk "Tab 4" at equal or lower rank: it would stay the name, +/// because the real first message no longer outranks it and the record no +/// longer counts as junk. The session index's first message heals it. +#[tokio::test] +async fn an_accepted_send_heals_a_junk_name_only_as_the_first_message() { + for (id, status, consumed) in [ + ( + "s-continue-exhausted", + GenerationStatus::Exhausted, + MAX_GENERATION_STARTS, + ), + ("s-continue-resting", GenerationStatus::Idle, 0), + ] { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, id); + let seeded = seed( + &store, + indexed( + NamedProvider::Claude, + id, + Some("/w/work"), + Some("Tab 4"), + None, + ), + false, + ) + .await; + assert_eq!(seeded.record.name, "Tab 4"); + assert_eq!(seeded.record.source, NameSource::ProviderAi); + rewrite(dir.path(), |document| { + document.generation.insert( + name_ref_key(&target), + series_with_input(SARDINE, status, consumed), + ); + }); + let store = open(dir.path()); + + let send = activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some("continue"), + ) + .await; + assert_eq!( + send.record.name, "Tab 4", + "{id}: a later send is not the session's first message" + ); + assert_eq!(send.record.source, NameSource::ProviderAi); + + let healed = activity( + &store, + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + ) + .await; + assert_eq!( + healed.record.name, SARDINE, + "{id}: the index's first message heals it" + ); + assert_eq!(healed.record.source, NameSource::FirstMessage); + } +} + +/// The other side: while the store holds no input for the session, the +/// accepted send is the first user message the store learns (a pane's +/// first send before the session is durable), so it heals the junk name. +#[tokio::test] +async fn an_accepted_send_heals_a_junk_name_while_no_input_is_stored() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-first-send"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-first-send", + Some("/w/work"), + Some("Tab 4"), + None, + ), + false, + ) + .await; + assert!(series_of(dir.path(), &target).is_none(), "no input stored"); + let healed = activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + ) + .await; + assert_eq!(healed.record.name, SARDINE); + assert_eq!(healed.record.source, NameSource::FirstMessage); +} + +/// A later send is not a corrected first message. In particular, a +/// "continue" sent while naming is paused or in flight must not become +/// the input for a permanent AI name. +#[tokio::test] +async fn later_accepted_messages_preserve_the_first_generation_input() { + for status in [ + GenerationStatus::Idle, + GenerationStatus::Eligible, + GenerationStatus::InFlight, + ] { + let dir = temp_dir(); + let store = open(dir.path()); + let target = directory_record(&store, NamedProvider::Claude, "first-input").await; + activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + ) + .await; + rewrite(dir.path(), |document| { + document + .generation + .get_mut(&name_ref_key(&target)) + .unwrap() + .status = status; + }); + let store = open(dir.path()); + let before = series_of(dir.path(), &target).unwrap(); + + activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some("continue"), + ) + .await; + + let after = series_of(dir.path(), &target).unwrap(); + assert_eq!(after.excerpt.as_deref(), Some(SARDINE), "{status:?}"); + assert_eq!( + after.input_fingerprint, before.input_fingerprint, + "{status:?}" + ); + assert_eq!( + after.status, + if status == GenerationStatus::Idle { + GenerationStatus::Eligible + } else { + before.status + }, + "{status:?}: an explicit send activates resting input" + ); + assert_eq!(after.consumed, before.consumed, "{status:?}"); + assert_eq!(get_one(&store, &target).await.name, SARDINE); + } +} + +/// A resumed fresh conversation has no new pending handle to arm the +/// boot-absorbed series. Its next accepted turn activates naming using the +/// historical first message, never that new turn's text. +#[tokio::test] +async fn sending_to_a_historical_session_arms_its_stored_first_message() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "historical-first-input"); + seed( + &store, + indexed( + NamedProvider::Claude, + "historical-first-input", + Some("/w/work"), + None, + Some(SARDINE), + ), + true, + ) + .await; + assert!( + !armed(&store, &target), + "boot absorption never starts paid naming" + ); + + activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some("continue"), + ) + .await; + + let work = store.generation_work_snapshot(); + let pending = work + .iter() + .find(|item| item.target == target) + .expect("an explicit send activates naming"); + assert_eq!(pending.excerpt.as_deref(), Some(SARDINE)); + assert_eq!(get_one(&store, &target).await.name, SARDINE); +} + +// The sweep (one pass at a time, against the real store) lives in a child +// module so this file stays under the 1000-line cap; its tests are still +// selected by `plan_activity`. +#[path = "session_name_plan_activity_sweep_tests.rs"] +mod sweep; diff --git a/crates/freshell-server/src/session_name_policy.rs b/crates/freshell-server/src/session_name_policy.rs new file mode 100644 index 000000000..7f9a5e5f6 --- /dev/null +++ b/crates/freshell-server/src/session_name_policy.rs @@ -0,0 +1,307 @@ +//! The automatic-naming rules of the name store (name-reliability plan, +//! Task 4; decision D3): which automatic offer may replace which saved name. +//! Pure functions over records and the document — the store's decisions +//! call them, and the sweep pre-checks call the SAME functions, so the +//! pre-check and the apply can never drift. +//! +//! Three rules live here. +//! +//! 1. **The self-heal rule.** A *self-heal record* is an unprotected record +//! (`directory`, `first_message`, `provider_ai`) whose current name the +//! shared classifier recognizes as a default or derived label ("Tab 23", +//! "OpenCode agent", tool wrapper text, the session's own folder name +//! when the folder is known). It ranks below `directory` for every +//! automatic offer: an offer whose own text is a real name replaces it +//! whatever the offer's rank; a default or derived offer never replaces a +//! name, except hydration's last rung (the folder name, else the provider +//! label) stepping a junk record above `directory` down to `directory` +//! once — after which it is an ordinary `directory` record, so two default +//! labels can never alternate; the same text is always a no-op. Names a +//! person chose (`manual`), migration-protected labels and accepted +//! Freshell AI names are never self-heal records. The folder comes from +//! the offer's own input (the activity's or the index row's cwd); offers +//! that carry no folder (native observations, binds) recognize only +//! folder-free labels. +//! +//! 2. **Native observations.** A provider-native title that is a placeholder +//! or another default label is provenance only. Each record keeps the +//! last valid native title it saw (`last_native_title`, the baseline): an +//! observation equal to it is an echo and never changes the name, even +//! when it would raise rank. A title that differs from the baseline +//! replaces an unprotected name below `provider_ai`, a self-heal record, +//! or an older `provider_ai` name once a baseline exists. The first +//! observation of a record (no baseline yet) only records the baseline: +//! pre-deploy automation renames were never written back to the provider, +//! which still reports its older title. +//! +//! 3. **Automation renames.** An automatic rename (omitted or `automatic` +//! intent) is recorded at `provider_ai` and may replace any unprotected +//! name, equal rank included, unless its text is a default or derived +//! label for the target — classified with the best-known folder of the +//! target, so a rename to the session's own folder name is refused rather +//! than applied and then healed away. The caller learns which happened. + +use freshell_protocol::session_names::{ + NameSource, RenameOutcome, RenameOutcomeReason, SessionNameRecord, SessionNameRef, +}; +use freshell_sessions::{is_default_or_derived_name, NameEvidenceContext}; + +use super::{ + directory_fallback_name, first_message_fallback, name_ref_key, source_is_protected, + source_rank, validate_name, StoredDocument, +}; +use crate::session_name_generation::IndexedNameInput; + +/// The classifier context for a record: its provider (a provider placeholder +/// counts only for its own provider) and the folder the caller knows. +fn evidence_context<'a>( + record: &'a SessionNameRecord, + cwd: Option<&'a str>, +) -> NameEvidenceContext<'a> { + let provider = match &record.name_ref { + SessionNameRef::Session { provider, .. } => Some(provider.as_str()), + SessionNameRef::Pending { .. } => None, + }; + NameEvidenceContext { + cwd, + provider, + mode: None, + } +} + +/// Whether `name` is a default or derived label for `record`'s target — +/// the ONE server-side meaning of "default label" (the import bind rule, +/// native observations, automation renames and the self-heal rule all ask +/// this). +pub(super) fn is_default_label_for( + record: &SessionNameRecord, + name: &str, + cwd: Option<&str>, +) -> bool { + is_default_or_derived_name(name, &evidence_context(record, cwd)) +} + +/// Rule 1: an unprotected record whose current name is a default or +/// derived label under the known folder. +pub(super) fn is_self_heal_record(record: &SessionNameRecord, cwd: Option<&str>) -> bool { + !source_is_protected(record.source) && is_default_label_for(record, &record.name, cwd) +} + +/// Rule 1 as one predicate: whether an automatic offer replaces a self-heal +/// record. False for every record that is not a self-heal record (the rank +/// rules decide those) and for the same text. +pub(super) fn self_heal_offer_applies( + record: &SessionNameRecord, + offer_name: &str, + offer_source: NameSource, + cwd: Option<&str>, +) -> bool { + if offer_name == record.name || !is_self_heal_record(record, cwd) { + return false; + } + if !is_default_label_for(record, offer_name, cwd) { + return true; + } + // Only hydration's last rung offers at `directory`: it may step a junk + // record that sits above `directory` down to `directory`, once. + offer_source == NameSource::Directory + && source_rank(record.source) > source_rank(NameSource::Directory) +} + +/// The acceptance rule for the automatic offers that carry their own +/// evidence (the first-message fallback, hydration): a self-heal +/// replacement, else a strict rank raise whose text is a real name. A +/// default or derived offer never replaces a name outside the self-heal +/// exception, and nothing automatic replaces a protected name. +pub(super) fn automatic_offer_applies( + record: &SessionNameRecord, + offer_name: &str, + offer_source: NameSource, + cwd: Option<&str>, +) -> bool { + self_heal_offer_applies(record, offer_name, offer_source, cwd) + || rank_raise_applies(record, offer_name, offer_source, cwd) +} + +/// The strict half of [`automatic_offer_applies`]: a real name (not a +/// default or derived label under the known folder) that outranks the saved +/// one. Never the same text, never over a protected name. The fallback of +/// an accepted send that is not the session's first message gets only this +/// half (`session_name_first_message::fallback_may_self_heal`). +pub(super) fn rank_raise_applies( + record: &SessionNameRecord, + offer_name: &str, + offer_source: NameSource, + cwd: Option<&str>, +) -> bool { + offer_name != record.name + && !source_is_protected(record.source) + && !is_default_label_for(record, offer_name, cwd) + && source_rank(offer_source) > source_rank(record.source) +} + +/// Rule 2's verdict for one valid, current-location, non-own-write native +/// title. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeOfferPlan { + /// The title replaces the saved name (at `provider_ai`). + pub replace: bool, + /// The baseline to record (`None` = it stays as it is). + pub new_baseline: Option, +} + +/// Rule 2. `baseline` is the record's current baseline (staged or +/// persisted). Native observations carry no folder. +pub(super) fn native_offer_decision( + record: &SessionNameRecord, + baseline: Option<&str>, + title: &str, +) -> NativeOfferPlan { + let nothing = NativeOfferPlan { + replace: false, + new_baseline: None, + }; + // A placeholder or another default label is never a name and never a + // baseline (a baseline of "Tab 3" would let the provider's real title, + // re-reported afterwards, look new and undo a rename). + if is_default_label_for(record, title, None) { + return nothing; + } + // An echo: provenance only, even when it would raise rank. + if baseline == Some(title) { + return nothing; + } + let new_baseline = Some(title.to_string()); + if title == record.name || source_is_protected(record.source) { + return NativeOfferPlan { + replace: false, + new_baseline, + }; + } + let replace = source_rank(NameSource::ProviderAi) > source_rank(record.source) + || is_self_heal_record(record, None) + || (record.source == NameSource::ProviderAi && baseline.is_some()); + NativeOfferPlan { + replace, + new_baseline, + } +} + +/// Rule 3's verdict for one automatic rename. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AutomationRenameVerdict { + Apply, + Unchanged, + Refused(RenameOutcomeReason), +} + +impl AutomationRenameVerdict { + pub(super) fn outcome(self) -> RenameOutcome { + match self { + Self::Apply => RenameOutcome { + applied: true, + reason: None, + }, + Self::Unchanged => RenameOutcome { + applied: true, + reason: Some(RenameOutcomeReason::Unchanged), + }, + Self::Refused(reason) => RenameOutcome { + applied: false, + reason: Some(reason), + }, + } + } +} + +/// Rule 3. `name` is already validated; `cwd` is the target's best-known +/// folder. +pub(super) fn automation_rename_decision( + record: &SessionNameRecord, + name: &str, + cwd: Option<&str>, +) -> AutomationRenameVerdict { + if name == record.name { + AutomationRenameVerdict::Unchanged + } else if source_is_protected(record.source) { + AutomationRenameVerdict::Refused(RenameOutcomeReason::ProtectedName) + } else if is_default_label_for(record, name, cwd) { + AutomationRenameVerdict::Refused(RenameOutcomeReason::DefaultLabel) + } else { + AutomationRenameVerdict::Apply + } +} + +/// What hydrating one index row would do to the document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum HydratePlan { + /// No record yet: install the ladder's first rung (today's insert). + Insert { name: String, source: NameSource }, + /// A self-heal record: replace it with the first rung rule 1 allows. No + /// generation series is created or absorbed on this path. + Heal { name: String, source: NameSource }, +} + +/// The ONE hydration plan, shared by the transaction and the sweep's +/// pre-check: no record → the free-fallback ladder (provider title, then the +/// first-message extraction, then the folder name or provider label); a +/// self-heal record → the first ladder rung rule 1 allows; anything else +/// (an established real name, the same text) → `None`, a Read. +pub(super) fn plan_hydrate( + document: &StoredDocument, + input: &IndexedNameInput, +) -> Option { + let key = name_ref_key(&SessionNameRef::Session { + provider: input.provider, + session_id: input.session_id.clone(), + }); + let cwd = input.cwd.as_deref(); + match document.record_at(&key) { + None => hydrate_rungs(input) + .into_iter() + .next() + .map(|(name, source)| HydratePlan::Insert { name, source }), + // The common case — an established real name — answers before the + // ladder is computed: the sweep asks this for every indexed session + // on every pass. + Some(record) if !is_self_heal_record(record, cwd) => None, + Some(record) => hydrate_rungs(input) + .into_iter() + .find(|(name, source)| self_heal_offer_applies(record, name, *source, cwd)) + .map(|(name, source)| HydratePlan::Heal { name, source }), + } +} + +/// The free-fallback ladder in order. A provider title or first-message +/// extraction that fails accepted-name validation is an unusable rung (the +/// ladder falls through exactly like an absent one); the last rung — the +/// folder name, else the provider label — is always valid. +fn hydrate_rungs(input: &IndexedNameInput) -> Vec<(String, NameSource)> { + let mut rungs = Vec::with_capacity(3); + if let Some(title) = input + .provider_title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .filter(|title| validate_name(title).is_ok()) + { + rungs.push((title.to_string(), NameSource::ProviderAi)); + } + if let Some(message) = input + .first_user_message + .as_deref() + .and_then(first_message_fallback) + .filter(|fallback| validate_name(fallback).is_ok()) + { + rungs.push((message, NameSource::FirstMessage)); + } + rungs.push(( + directory_fallback_name(input.cwd.as_deref(), &input.provider), + NameSource::Directory, + )); + rungs +} + +#[cfg(test)] +#[path = "session_name_policy_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_policy_tests.rs b/crates/freshell-server/src/session_name_policy_tests.rs new file mode 100644 index 000000000..cd858817e --- /dev/null +++ b/crates/freshell-server/src/session_name_policy_tests.rs @@ -0,0 +1,1255 @@ +//! Name-reliability Task 4: the native-observation, automation-rename and +//! self-heal rules, driven through the REAL store on a temp data dir (every +//! decision runs the strict transaction path; nothing here is mocked). + +use std::path::Path; +use std::sync::Arc; + +use freshell_freshagent::naming::{NameActivityReason, NativeNameOrigin}; +use freshell_protocol::native_location::{NativeEvidenceKind, NativePersistence}; +use freshell_protocol::session_names::{ + NameIntent, NameSource, NamedProvider, RenameOutcome, RenameOutcomeReason, +}; + +use super::super::*; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open store") +} + +fn session(provider: NamedProvider, id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider, + session_id: id.to_string(), + } +} + +fn raw_document(dir: &Path) -> StoredDocument { + let bytes = std::fs::read(dir.join(DOCUMENT_FILE_NAME)).expect("raw document exists"); + parse_document(&bytes).expect("raw document parses") +} + +fn generation(dir: &Path) -> u64 { + raw_document(dir).document_generation +} + +fn indexed( + provider: NamedProvider, + id: &str, + cwd: Option<&str>, + provider_title: Option<&str>, + first_message: Option<&str>, +) -> IndexedNameInput { + IndexedNameInput { + provider, + session_id: id.to_string(), + cwd: cwd.map(str::to_string), + first_user_message: first_message.map(str::to_string), + provider_title: provider_title.map(str::to_string), + } +} + +/// Install a session record through the index hydration ladder (the same +/// path the sweep takes): a provider title installs at `provider_ai`, a +/// first message at `first_message`, otherwise the folder at `directory`. +/// `absorb` stores the first message's fingerprint at rest (Idle). +async fn seed( + store: &Arc, + input: IndexedNameInput, + absorb: bool, +) -> SessionNameUpdate { + store + .hydrate_indexed(input, absorb) + .await + .expect("seed through hydration") +} + +async fn get_one(store: &Arc, target: &SessionNameRef) -> SessionNameRecord { + store + .get(vec![target.clone()]) + .await + .expect("get") + .into_iter() + .next() + .expect("the record exists") + .record +} + +async fn observe( + store: &Arc, + target: &SessionNameRef, + title: &str, + location_revision: NameRevision, +) -> SessionNameUpdate { + store + .observe_native(NativeNameObservation { + target: target.clone(), + title: title.to_string(), + origin: NativeNameOrigin::Snapshot, + location_revision, + event_id: None, + }) + .await + .expect("observation folds") +} + +async fn rename( + store: &Arc, + target: &SessionNameRef, + name: &str, + intent: NameIntent, + cwd: Option<&str>, +) -> SessionNameUpdate { + store + .rename(RenameNameInput { + target: target.clone(), + name: name.to_string(), + intent, + if_revision: None, + cwd: cwd.map(str::to_string), + }) + .await + .expect("rename runs") +} + +fn activity_input( + target: &SessionNameRef, + reason: NameActivityReason, + message: Option<&str>, + cwd: Option<&str>, +) -> NameActivity { + NameActivity { + target: target.clone(), + mode: "claude".to_string(), + event_id: "event-1".to_string(), + reason, + first_user_message: message.map(str::to_string), + cwd: cwd.map(str::to_string), + } +} + +async fn activity( + store: &Arc, + target: &SessionNameRef, + reason: NameActivityReason, + message: Option<&str>, + cwd: Option<&str>, +) -> SessionNameUpdate { + store + .activity(activity_input(target, reason, message, cwd)) + .await + .expect("activity folds") +} + +/// A record protected at `source`, named `name`, on a fresh session. +async fn protected_record( + store: &Arc, + id: &str, + source: NameSource, + name: &str, +) -> SessionNameRef { + let target = session(NamedProvider::Claude, id); + seed( + store, + indexed(NamedProvider::Claude, id, Some("/w/proj"), None, None), + false, + ) + .await; + match source { + NameSource::Manual => { + rename(store, &target, name, NameIntent::User, None).await; + } + other => { + store + .offer(target.clone(), name.to_string(), other) + .await + .expect("seed the protected record"); + } + } + let record = get_one(store, &target).await; + assert_eq!(record.source, source, "seeded {id} at {source:?}"); + assert_eq!(record.name, name); + target +} + +const SARDINE: &str = "Fix the sardine importer"; + +// --------------------------------------------------------------------------- +// Native observations +// --------------------------------------------------------------------------- + +/// OpenCode's temporary "New session - " title is never saved, and is +/// not a baseline either; the real title that follows raises the record. +#[tokio::test] +async fn opencode_placeholder_observation_is_never_stored() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Opencode, "ses_placeholder"); + let seeded = seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_placeholder", + Some("/w/proj"), + None, + None, + ), + false, + ) + .await; + assert_eq!(seeded.record.source, NameSource::Directory); + let generation_before = generation(dir.path()); + let mut published = store.subscribe(); + + let placeholder = observe(&store, &target, "New session - 2026-10-20T00:09:25.350Z", 0).await; + assert!(!placeholder.changed, "a placeholder is never a name"); + assert_eq!(placeholder.record.name, "proj"); + assert_eq!(placeholder.record.revision, seeded.record.revision); + assert_eq!( + generation(dir.path()), + generation_before, + "no document write" + ); + assert!(published.try_recv().is_err(), "nothing published"); + assert_eq!( + store.staged_native_baseline(&target), + None, + "a placeholder is never a baseline either" + ); + + let real = observe(&store, &target, "Publishing blocked by token error", 0).await; + assert!(real.changed); + assert_eq!(real.record.name, "Publishing blocked by token error"); + assert_eq!(real.record.source, NameSource::ProviderAi); +} + +/// Every existing record starts without a baseline: the first observation +/// only records it (staged in memory, no document write, no publish), one +/// sweep maintenance batch persists it, and the next CHANGED title replaces +/// the older provider-level name. +#[tokio::test] +async fn first_native_observation_sets_the_baseline_without_replacing() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-baseline"); + let seeded = seed( + &store, + indexed( + NamedProvider::Claude, + "s-baseline", + Some("/w/proj"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + assert_eq!(seeded.record.source, NameSource::ProviderAi); + let generation_before = generation(dir.path()); + let mut published = store.subscribe(); + + let first = observe(&store, &target, "Older provider title", 0).await; + assert!( + !first.changed, + "the first observation only records the baseline" + ); + assert_eq!(first.record.name, "Billing bug"); + assert_eq!(first.record.revision, seeded.record.revision); + assert_eq!( + generation(dir.path()), + generation_before, + "no document write" + ); + assert!(published.try_recv().is_err(), "nothing published"); + assert_eq!( + store.staged_native_baseline(&target).as_deref(), + Some("Older provider title") + ); + + // Decisions read the staged baseline as if it were persisted: the same + // title again is an echo (still no write). + let echo = observe(&store, &target, "Older provider title", 0).await; + assert!(!echo.changed); + assert_eq!(generation(dir.path()), generation_before); + + // One maintenance batch persists it. + store + .sweep_maintenance(Vec::new()) + .await + .expect("the maintenance batch runs"); + let document = raw_document(dir.path()); + assert_eq!(document.document_generation, generation_before + 1); + let key = name_ref_key(&target); + assert_eq!( + document + .native_write + .get(&key) + .and_then(|state| state.last_native_title.as_deref()), + Some("Older provider title"), + "the batch persisted the staged baseline" + ); + assert_eq!( + store.staged_native_baseline(&target), + None, + "staging cleared" + ); + assert!(!store.has_staged_native_baselines()); + + let newer = observe(&store, &target, "Newer provider title", 0).await; + assert!( + newer.changed, + "a changed native title replaces the older provider name" + ); + assert_eq!(newer.record.name, "Newer provider title"); + assert_eq!(newer.record.source, NameSource::ProviderAi); + assert!(newer.record.revision > seeded.record.revision); +} + +/// With a baseline, a provider title that actually changed replaces the +/// older provider-level name; the same title again is an echo. +#[tokio::test] +async fn changed_native_title_replaces_older_provider_name() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-changed"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-changed", + Some("/w/proj"), + None, + None, + ), + false, + ) + .await; + // The raise from `directory` records the baseline with its own commit. + let raised = observe(&store, &target, "Billing bug", 0).await; + assert!(raised.changed); + assert_eq!(raised.record.source, NameSource::ProviderAi); + let key = name_ref_key(&target); + assert_eq!( + raw_document(dir.path()) + .native_write + .get(&key) + .and_then(|state| state.last_native_title.as_deref()), + Some("Billing bug"), + "the committing raise persisted its baseline" + ); + + let replaced = observe(&store, &target, "Real title", 0).await; + assert!(replaced.changed); + assert_eq!(replaced.record.name, "Real title"); + assert!(replaced.record.revision > raised.record.revision); + + let again = observe(&store, &target, "Real title", 0).await; + assert!(!again.changed); + assert_eq!( + again.record.revision, replaced.record.revision, + "no new revision" + ); +} + +/// A junk saved name is replaced by the very first real native title (the +/// self-heal exception to the baseline rule). +#[tokio::test] +async fn first_native_observation_heals_a_default_label() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-tab23"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-tab23", + Some("/w/proj"), + Some("Tab 23"), + None, + ), + false, + ) + .await; + let healed = observe(&store, &target, "Real title", 0).await; + assert!( + healed.changed, + "a default label gives way to the first real title" + ); + assert_eq!(healed.record.name, "Real title"); + assert_eq!(healed.record.source, NameSource::ProviderAi); +} + +/// An echo of the native title the provider already reported never undoes +/// an automation rename; a genuinely new native title still applies. +#[tokio::test] +async fn native_echo_never_undoes_an_automation_rename() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-echo"); + seed( + &store, + indexed(NamedProvider::Claude, "s-echo", Some("/w/proj"), None, None), + false, + ) + .await; + assert!(observe(&store, &target, "A", 0).await.changed); + let renamed = rename(&store, &target, "B", NameIntent::Automatic, Some("/w/proj")).await; + assert_eq!(renamed.record.name, "B"); + assert_eq!( + renamed.outcome, + Some(RenameOutcome { + applied: true, + reason: None + }) + ); + let echo = observe(&store, &target, "A", 0).await; + assert!(!echo.changed); + assert_eq!(echo.record.name, "B", "an echo never undoes the rename"); + let fresh = observe(&store, &target, "C", 0).await; + assert!(fresh.changed); + assert_eq!(fresh.record.name, "C"); +} + +/// Protected names (manual, migration-protected, Freshell AI — including a +/// manual "Tab 23") ignore every native title, changed or not. +#[tokio::test] +async fn native_observation_never_replaces_protected() { + let dir = temp_dir(); + let store = open(dir.path()); + let cases = [ + ("s-manual", NameSource::Manual, "Chosen name"), + ("s-manual-tab", NameSource::Manual, "Tab 23"), + ("s-legacy", NameSource::LegacyProtected, "Legacy label"), + ("s-ai", NameSource::FreshellAi, "Freshell AI title"), + ]; + for (id, source, name) in cases { + let target = protected_record(&store, id, source, name).await; + let before = get_one(&store, &target).await; + observe(&store, &target, "Changed native one", 0).await; + observe(&store, &target, "Changed native two", 0).await; + let after = get_one(&store, &target).await; + assert_eq!(after.name, name, "{id}: a protected name stays"); + assert_eq!(after.source, source, "{id}"); + assert_eq!(after.revision, before.revision, "{id}: no new revision"); + } +} + +/// Pinning: an old-location observation stays provenance only (no name, no +/// baseline). +#[tokio::test] +async fn stale_location_observation_stays_provenance_only() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-stale"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-stale", + Some("/w/proj"), + None, + None, + ), + false, + ) + .await; + store + .record_acquisition( + target.clone(), + NativeAcquisition { + location: NativeLocation::Claude { + config_root: "/h/.claude".into(), + transcript_path: Some("/h/.claude/projects/-p/s-stale.jsonl".into()), + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: NativeEvidenceKind::SelectedTranscript, + persistence: NativePersistence::Verified, + }, + ) + .await + .expect("verified location"); + let before = get_one(&store, &target).await; + let stale = observe(&store, &target, "Stale location title", 0).await; + assert!(!stale.changed); + assert_eq!(stale.record.name, before.name); + assert_eq!(store.staged_native_baseline(&target), None); + let current = observe(&store, &target, "Current location title", 1).await; + assert!(current.changed, "the current location still folds"); +} + +// --------------------------------------------------------------------------- +// Automation renames +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn automation_rename_reports_protected() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = protected_record(&store, "s-protected", NameSource::Manual, "LotL").await; + let before = get_one(&store, &target).await; + let update = rename(&store, &target, "Bot name", NameIntent::Automatic, None).await; + assert!(!update.changed); + assert_eq!( + update.outcome, + Some(RenameOutcome { + applied: false, + reason: Some(RenameOutcomeReason::ProtectedName) + }) + ); + assert_eq!(update.record, before, "the protected record is unchanged"); +} + +#[tokio::test] +async fn automation_rename_replaces_equal_rank() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-equal"); + let seeded = seed( + &store, + indexed( + NamedProvider::Claude, + "s-equal", + Some("/w/proj"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + let update = rename( + &store, + &target, + "Better name", + NameIntent::Automatic, + Some("/w/proj"), + ) + .await; + assert!(update.changed); + assert_eq!(update.record.name, "Better name"); + assert_eq!(update.record.source, NameSource::ProviderAi); + assert!(update.record.revision > seeded.record.revision); + assert_eq!( + update.outcome, + Some(RenameOutcome { + applied: true, + reason: None + }) + ); + + // The same text again: applied (the name is what was asked for), no change. + let same = rename(&store, &target, "Better name", NameIntent::Automatic, None).await; + assert!(!same.changed); + assert_eq!(same.record.revision, update.record.revision); + assert_eq!( + same.outcome, + Some(RenameOutcome { + applied: true, + reason: Some(RenameOutcomeReason::Unchanged) + }) + ); +} + +#[tokio::test] +async fn automation_rename_refuses_default_label() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-default"); + let seeded = seed( + &store, + indexed( + NamedProvider::Claude, + "s-default", + Some("/w/proj"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + for label in ["Tab 3", "Claude CLI", "OpenCode agent"] { + let update = rename(&store, &target, label, NameIntent::Automatic, None).await; + assert_eq!( + update.outcome, + Some(RenameOutcome { + applied: false, + reason: Some(RenameOutcomeReason::DefaultLabel) + }), + "{label}" + ); + assert_eq!(update.record, seeded.record, "{label}: record unchanged"); + } +} + +/// LB-B8: an automation rename to the session's own folder name is refused +/// at the terminal route (classified with the terminal row's cwd), and the +/// next sweep pass changes nothing. +#[tokio::test] +async fn automation_rename_to_the_folder_name_is_refused_and_stays_refused() { + let home = temp_dir(); + let data_dir = home.path().join(".freshell"); + let store = open(&data_dir); + let cwd = "/home/dan/code/freshell"; + let target = session(NamedProvider::Claude, "s-folder"); + let seeded = seed( + &store, + indexed( + NamedProvider::Claude, + "s-folder", + Some(cwd), + Some("Billing bug"), + Some(SARDINE), + ), + true, + ) + .await; + + let (tx, _rx) = tokio::sync::broadcast::channel::(16); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + identity.set_session_naming(store.clone()); + identity.upsert( + "term-folder", + Some("claude"), + Some("s-folder"), + Some(cwd), + 1_000, + ); + identity.set_name_binding("term-folder", Some(target.clone()), None); + let router = crate::terminals::router(crate::terminals::TerminalsState { + auth_token: Arc::new("tok".to_string()), + settings: crate::settings_store::SettingsStore::load( + Some(home.path()), + vec!["claude".into()], + ), + registry: freshell_terminal::TerminalRegistry::new(), + broadcast_tx: Arc::new(tx), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + identity: identity.clone(), + }); + let response = tower::util::ServiceExt::oneshot( + router, + axum::http::Request::builder() + .method("PATCH") + .uri("/api/terminals/term-folder") + .header("x-auth-token", "tok") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "titleOverride": "freshell" }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + body["sessionName"]["outcome"], + serde_json::json!({ "applied": false, "reason": "default_label" }), + "{body}" + ); + assert_eq!( + get_one(&store, &target).await, + seeded.record, + "record unchanged" + ); + + // The next sweep pass sees a settled, real-named record: no transaction. + let (sweep, _sweep_rx) = sweep_state(home.path(), store.clone()); + sweep + .index_hydrated + .store(true, std::sync::atomic::Ordering::SeqCst); + let before = crate::session_names::test_store_transaction_count(&data_dir); + crate::auto_title_sweep::run_auto_title_pass( + &sweep, + &[crate::auto_title_sweep::SweepSession { + provider: "claude".into(), + session_id: "s-folder".into(), + cwd: Some(cwd.into()), + title: None, + first_user_message: Some(SARDINE.into()), + title_source: None, + git_branch: None, + last_activity_at: 0, + }], + ) + .await; + assert_eq!( + crate::session_names::test_store_transaction_count(&data_dir), + before, + "the sweep has nothing to heal" + ); + assert_eq!(get_one(&store, &target).await.name, "Billing bug"); +} + +/// Pinning (Residual 11): a rename to SOME OTHER folder's name applies — the +/// server only knows the target's own folder. +#[tokio::test] +async fn automation_rename_to_another_folders_name_applies() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-machines"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-machines", + Some("/home/dan/code/freshell"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + let update = rename( + &store, + &target, + "machines", + NameIntent::Automatic, + Some("/home/dan/code/freshell"), + ) + .await; + assert_eq!(update.outcome.map(|o| o.applied), Some(true)); + assert_eq!(update.record.name, "machines"); +} + +#[tokio::test] +async fn user_rename_is_never_filtered() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-user"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-user", + Some("/w/proj"), + Some("Billing bug"), + None, + ), + false, + ) + .await; + let update = rename(&store, &target, "Tab 3", NameIntent::User, Some("/w/proj")).await; + assert_eq!(update.record.name, "Tab 3"); + assert_eq!(update.record.source, NameSource::Manual); + assert_eq!(update.outcome.map(|o| o.applied), Some(true)); +} + +// --------------------------------------------------------------------------- +// Self-heal +// --------------------------------------------------------------------------- + +/// The user-visible case: a provider-level "Tab 23" takes the first message +/// (today it is refused, because `provider_ai` outranks `first_message`). +#[tokio::test] +async fn self_heal_tab_n_provider_ai_takes_the_first_message() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-heal"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-heal", + Some("/w/proj"), + Some("Tab 23"), + None, + ), + false, + ) + .await; + let healed = activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + Some("/w/proj"), + ) + .await; + assert!(healed.changed); + assert_eq!(healed.record.name, SARDINE); + assert_eq!(healed.record.source, NameSource::FirstMessage); +} + +/// Names a person chose (and other protected names) are never self-heal +/// records, even when they read "Tab 23"; neither pre-check reports work. +#[tokio::test] +async fn self_heal_never_touches_a_manual_tab_n() { + let dir = temp_dir(); + let store = open(dir.path()); + for (id, source) in [ + ("s-m", NameSource::Manual), + ("s-l", NameSource::LegacyProtected), + ("s-f", NameSource::FreshellAi), + ] { + let target = protected_record(&store, id, source, "Tab 23").await; + let before = get_one(&store, &target).await; + let input = activity_input( + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + Some("/w/proj"), + ); + assert!(!store.activity_pending(&input), "{id}: activity pre-check"); + let hydrate = indexed( + NamedProvider::Claude, + id, + Some("/w/proj"), + Some("Provider title"), + Some(SARDINE), + ); + assert!( + !store.hydrate_indexed_pending(&hydrate), + "{id}: hydrate pre-check" + ); + store.activity(input).await.expect("activity"); + observe(&store, &target, "Real title", 0).await; + store + .hydrate_indexed(hydrate, false) + .await + .expect("hydrate"); + let after = get_one(&store, &target).await; + assert_eq!(after, before, "{id}: unchanged, no new revision"); + } +} + +/// The folder rule needs the folder: with the activity's cwd a folder-named +/// record heals; without one it is left alone (folder-free patterns only). +#[tokio::test] +async fn self_heal_uses_the_known_folder() { + let dir = temp_dir(); + let store = open(dir.path()); + let with_cwd = session(NamedProvider::Claude, "s-cwd"); + let without_cwd = session(NamedProvider::Claude, "s-nocwd"); + for id in ["s-cwd", "s-nocwd"] { + seed( + &store, + indexed( + NamedProvider::Claude, + id, + Some("/home/dan/code/freshell"), + Some("freshell"), + None, + ), + false, + ) + .await; + } + let healed = activity( + &store, + &with_cwd, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + Some("/home/dan/code/freshell"), + ) + .await; + assert_eq!(healed.record.name, SARDINE); + assert_eq!(healed.record.source, NameSource::FirstMessage); + + let kept = activity( + &store, + &without_cwd, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + None, + ) + .await; + assert!(!kept.changed); + assert_eq!(kept.record.name, "freshell", "no folder, no folder rule"); + assert_eq!(kept.record.source, NameSource::ProviderAi); +} + +/// Hydration re-offers to a self-heal record (it used to return early for +/// any existing record): the provider title replaces "OpenCode agent", no +/// series is created, and the pre-check settles afterwards. +#[tokio::test] +async fn hydrate_reoffers_to_a_self_heal_record() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Opencode, "ses_agent"); + seed( + &store, + indexed( + NamedProvider::Opencode, + "ses_agent", + Some("/w/proj"), + Some("OpenCode agent"), + None, + ), + false, + ) + .await; + let input = indexed( + NamedProvider::Opencode, + "ses_agent", + Some("/w/proj"), + Some("Publishing blocked by token error"), + None, + ); + assert!(store.hydrate_indexed_pending(&input)); + let healed = store + .hydrate_indexed(input.clone(), true) + .await + .expect("hydrate"); + assert!(healed.changed); + assert_eq!(healed.record.name, "Publishing blocked by token error"); + assert_eq!(healed.record.source, NameSource::ProviderAi); + assert!( + !raw_document(dir.path()) + .generation + .contains_key(&name_ref_key(&target)), + "the heal creates no generation series" + ); + assert!(!store.hydrate_indexed_pending(&input), "settled afterwards"); +} + +/// C1: the planned fallback is applied as planned — not re-offered through +/// the strict raise, which refuses equal rank — and the pre-check settles. +#[tokio::test] +async fn planned_fallback_is_applied_without_the_strict_raise() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-c1"); + seed( + &store, + indexed(NamedProvider::Claude, "s-c1", Some("/w/proj"), None, None), + false, + ) + .await; + // The junk first-message record an old build saved (Task 5 keeps the + // hydration ladder itself from installing a default label as a first + // message, so it is planted through the store's raw offer seam). + let seeded = store + .offer( + target.clone(), + "Claude CLI".to_string(), + NameSource::FirstMessage, + ) + .await + .expect("plant the junk first-message record"); + assert_eq!(seeded.record.source, NameSource::FirstMessage); + assert_eq!(seeded.record.name, "Claude CLI"); + let input = activity_input( + &target, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + Some("/w/proj"), + ); + assert!(store.activity_pending(&input)); + let healed = store.activity(input.clone()).await.expect("activity"); + assert!(healed.changed); + assert_eq!(healed.record.name, SARDINE); + assert_eq!(healed.record.source, NameSource::FirstMessage); + + assert!( + !store.activity_pending(&input), + "the same activity is settled" + ); + let before = test_store_transaction_count(dir.path()); + assert!(!store.activity_pending(&input)); + assert_eq!(test_store_transaction_count(dir.path()), before); +} + +/// C2: hydration's last rung (the folder name) steps a junk provider-level +/// record down to `directory` ONCE; after that two default labels can never +/// alternate. +#[tokio::test] +async fn hydrate_default_rung_steps_down_once() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-c2"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-c2", + Some("/w/work"), + Some("Tab 23"), + None, + ), + false, + ) + .await; + let input = indexed(NamedProvider::Claude, "s-c2", Some("/w/work"), None, None); + assert!(store.hydrate_indexed_pending(&input)); + let stepped = store + .hydrate_indexed(input.clone(), false) + .await + .expect("hydrate"); + assert_eq!(stepped.record.name, "work"); + assert_eq!(stepped.record.source, NameSource::Directory); + + assert!( + !store.hydrate_indexed_pending(&input), + "the pre-check settles" + ); + let generation_before = generation(dir.path()); + let again = store.hydrate_indexed(input, false).await.expect("hydrate"); + assert!(!again.changed); + assert_eq!( + again.record.revision, stepped.record.revision, + "no new revision" + ); + assert_eq!(generation(dir.path()), generation_before, "a Read"); + + // Without a folder its rung would be the provider label: unchanged. + let no_cwd = indexed(NamedProvider::Claude, "s-c2", None, None, None); + assert!(!store.hydrate_indexed_pending(&no_cwd)); + let unchanged = store.hydrate_indexed(no_cwd, false).await.expect("hydrate"); + assert_eq!(unchanged.record.name, "work"); + assert_eq!( + get_one(&store, &target).await.revision, + stepped.record.revision + ); +} + +#[tokio::test] +async fn activity_pre_check_reports_pending_for_a_self_heal_record() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-pre"); + // The message is absorbed at rest, so only the heal is pending work. + seed( + &store, + indexed( + NamedProvider::Claude, + "s-pre", + Some("/w/proj"), + Some("Tab 23"), + Some(SARDINE), + ), + true, + ) + .await; + let input = activity_input( + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + Some("/w/proj"), + ); + assert!( + store.activity_pending(&input), + "the junk name is pending work" + ); + store.activity(input.clone()).await.expect("activity"); + assert_eq!(get_one(&store, &target).await.name, SARDINE); + assert!(!store.activity_pending(&input), "settled after the heal"); +} + +/// C3: a stale adopted view only delays a heal. Another process (in +/// practice an older binary) writes "Tab 9" over a settled record; this +/// store's pre-check, on its old view, reports no work; after the next +/// adopted-view refresh it does, and the heal applies. +#[tokio::test] +async fn stale_view_delays_but_never_loses_a_heal() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-c3"); + seed( + &store, + indexed( + NamedProvider::Claude, + "s-c3", + Some("/w/proj"), + Some("Settled name"), + Some(SARDINE), + ), + true, + ) + .await; + let input = activity_input( + &target, + NameActivityReason::IndexUserMessage, + Some(SARDINE), + Some("/w/proj"), + ); + assert!(!store.activity_pending(&input), "settled"); + + // The foreign writer: the next generation, with the record relabelled. + let mut foreign = raw_document(dir.path()); + let key = name_ref_key(&target); + let mut record = foreign.records.get(&key).cloned().expect("record"); + record.name = "Tab 9".to_string(); + record.revision = foreign.next_revision; + foreign.next_revision += 1; + foreign.records.insert(key.clone(), record); + foreign.document_generation += 1; + let bytes = serialize_document(&foreign).expect("serialize"); + std::fs::write(dir.path().join(DOCUMENT_FILE_NAME), bytes).expect("foreign write"); + + assert!( + !store.activity_pending(&input), + "the old view cannot see the foreign junk name yet (the safe direction: a delay)" + ); + store + .refresh_current() + .await + .expect("refresh adopts the foreign generation"); + assert!( + store.activity_pending(&input), + "after the refresh the heal is visible" + ); + let healed = store.activity(input).await.expect("activity"); + assert_eq!(healed.record.name, SARDINE); +} + +/// C4: a native echo never re-raises. "freshell" raised a record to +/// `provider_ai` (baseline "freshell"); the activity with the folder healed +/// it; the same native title again is provenance only, a new one applies. +#[tokio::test] +async fn native_echo_never_raises_over_a_healed_name() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = session(NamedProvider::Claude, "s-c4"); + seed( + &store, + indexed(NamedProvider::Claude, "s-c4", Some("/w/other"), None, None), + false, + ) + .await; + let raised = observe(&store, &target, "freshell", 0).await; + assert_eq!(raised.record.source, NameSource::ProviderAi); + assert_eq!(raised.record.name, "freshell"); + + let healed = activity( + &store, + &target, + NameActivityReason::AcceptedUserMessage, + Some(SARDINE), + Some("/home/dan/code/freshell"), + ) + .await; + assert_eq!(healed.record.name, SARDINE); + assert_eq!(healed.record.source, NameSource::FirstMessage); + + let echo = observe(&store, &target, "freshell", 0).await; + assert!( + !echo.changed, + "an echo is provenance only, even when it would raise rank" + ); + assert_eq!(echo.record.revision, healed.record.revision); + + let real = observe(&store, &target, "Real title", 0).await; + assert!(real.changed); + assert_eq!(real.record.name, "Real title"); + assert_eq!(real.record.source, NameSource::ProviderAi); +} + +/// A healing activity arms (or does not arm) generation exactly as it would +/// for a real-named record with the same source and series state. +#[tokio::test] +async fn self_heal_changes_no_arming() { + let dir = temp_dir(); + let store = open(dir.path()); + let junk = session(NamedProvider::Claude, "s-arm-junk"); + let real = session(NamedProvider::Claude, "s-arm-real"); + for (id, title) in [("s-arm-junk", "Tab 23"), ("s-arm-real", "Billing bug")] { + seed( + &store, + indexed( + NamedProvider::Claude, + id, + Some("/w/proj"), + Some(title), + Some("An older first message"), + ), + true, + ) + .await; + } + for reason in [ + NameActivityReason::IndexUserMessage, + NameActivityReason::AcceptedUserMessage, + ] { + for target in [&junk, &real] { + activity(&store, target, reason, Some(SARDINE), Some("/w/proj")).await; + } + let document = raw_document(dir.path()); + let series = |target: &SessionNameRef| { + let state = document + .generation + .get(&name_ref_key(target)) + .cloned() + .expect("series"); + ( + state.status, + state.input_fingerprint, + state.excerpt, + state.consumed, + state.next_due, + ) + }; + assert_eq!( + series(&junk), + series(&real), + "{reason:?}: arming is the same" + ); + } + assert_eq!( + get_one(&store, &junk).await.name, + SARDINE, + "the junk name healed" + ); + assert_eq!( + get_one(&store, &real).await.name, + "Billing bug", + "the real name stayed" + ); +} + +// --------------------------------------------------------------------------- +// Sweep fixture (the route test above runs one pass) +// --------------------------------------------------------------------------- + +fn sweep_state( + home: &Path, + names: Arc, +) -> ( + crate::auto_title_sweep::AutoTitleSweepState, + tokio::sync::broadcast::Receiver, +) { + let (tx, rx) = tokio::sync::broadcast::channel::(64); + let state = crate::auto_title_sweep::AutoTitleSweepState { + settings: crate::settings_store::SettingsStore::load(Some(home), vec![]), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + registry: freshell_terminal::TerminalRegistry::new(), + broadcast_tx: Arc::new(tx), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + auth: crate::ai_title::GeminiSessionNameAuth::direct_for_test( + crate::ai_title::AiKeyCell::init(None, None), + ), + gemini: Arc::new(NoGemini), + pending_ai_titles: Default::default(), + terminal_meta: Default::default(), + git_meta_cache: Default::default(), + names: Some(names), + metadata: crate::session_metadata::SessionMetadataStore::new(home), + index: None, + index_hydrated: Arc::new(std::sync::atomic::AtomicBool::new(false)), + first_message_lookups: Default::default(), + }; + (state, rx) +} + +struct NoGemini; +impl crate::ai_title::GeminiTransport for NoGemini { + fn generate_content( + &self, + _p: String, + _m: u32, + ) -> crate::ai_title::BoxFuture> { + Box::pin(async { Err("no live Gemini in tests".to_string()) }) + } +} diff --git a/crates/freshell-server/src/session_name_prebind_route_tests.rs b/crates/freshell-server/src/session_name_prebind_route_tests.rs new file mode 100644 index 000000000..d40f640e0 --- /dev/null +++ b/crates/freshell-server/src/session_name_prebind_route_tests.rs @@ -0,0 +1,169 @@ +//! Real-store twins of the legacy pre-bind rename compatibility cases. + +use super::*; + +const PROSPECTIVE: &str = "88888888-9999-4aaa-8bbb-cccccccc0071"; + +fn prebind_router(names: &Arc, pending: Option<&str>) -> Router { + let registry = freshell_terminal::TerminalRegistry::new(); + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "prebind".into(), + stream_id: "prebind-stream".into(), + mode: "claude".into(), + resume_session_id: Some(PROSPECTIVE.into()), + create_request_id: None, + created_at: None, + }); + if let Some(handle) = pending { + registry.set_naming( + "prebind", + Some(SessionNameRef::Pending { id: handle.into() }), + Some(handle.into()), + ); + } + fresh_router_with_registry( + names, + json!({ + "kind": "terminal", "mode": "claude", "terminalId": "prebind", + "sessionRef": { "provider": "claude", "sessionId": PROSPECTIVE }, + }), + Some(registry), + ) +} + +fn prospective_ref() -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: PROSPECTIVE.into(), + } +} + +#[tokio::test] +async fn legacy_session_capture_renames_its_unspent_pending_record_before_materialization() { + let home = temp_home(); + let names = names_state(&home).names; + let pending = admit_pending( + &names, + "legacy-prebind", + NamedProvider::Claude, + Some("/work/prebind"), + ) + .await; + let (status, body) = patch( + prebind_router(&names, Some("legacy-prebind")), + "/api/panes/p1", + json!({ + "name": "Keep before materialization", "nameIntent": "user", + "expectedNameRef": prospective_ref(), "ifRevision": pending.record.revision, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let update: SessionNameUpdate = + serde_json::from_value(body["data"]["sessionName"].clone()).unwrap(); + assert_eq!(update.record.name_ref, pending.record.name_ref); + assert_eq!(update.record.name, "Keep before materialization"); + assert_eq!(update.record.source, NameSource::Manual); + assert!( + names.get(vec![prospective_ref()]).await.unwrap().is_empty(), + "a prospective id must not be admitted durably" + ); + let bound = bind_verified(&names, "legacy-prebind", NamedProvider::Claude, PROSPECTIVE).await; + assert_eq!(bound.record.name, update.record.name); + assert_eq!(bound.record.source, NameSource::Manual); + std::fs::remove_dir_all(home).ok(); +} + +#[tokio::test] +async fn an_unbound_prospective_pane_never_admits_a_durable_name_on_rename() { + let home = temp_home(); + let names = names_state(&home).names; + for uri in ["/api/panes/p1", "/api/tabs/t1"] { + let (status, body) = patch( + prebind_router(&names, None), + uri, + json!({"name": "Nothing to name", "nameIntent": "user"}), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert_eq!(body["error"], "NAME_NOT_FOUND"); + assert!(names.get(vec![prospective_ref()]).await.unwrap().is_empty()); + } + std::fs::remove_dir_all(home).ok(); +} + +#[tokio::test] +async fn a_legacy_prebind_shape_never_renames_a_handle_spent_on_another_session() { + let home = temp_home(); + let names = names_state(&home).names; + admit_pending( + &names, + "spent-legacy", + NamedProvider::Claude, + Some("/work/prebind"), + ) + .await; + let old = bind_verified( + &names, + "spent-legacy", + NamedProvider::Claude, + "previous-conversation", + ) + .await; + let (status, body) = patch( + prebind_router(&names, Some("spent-legacy")), + "/api/panes/p1", + json!({ + "name": "Must not name the ancestor", "nameIntent": "user", + }), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert_eq!( + names.get(vec![old.record.name_ref.clone()]).await.unwrap()[0].record, + old.record + ); + assert!(names.get(vec![prospective_ref()]).await.unwrap().is_empty()); + std::fs::remove_dir_all(home).ok(); +} + +#[tokio::test] +async fn modern_pending_capture_keeps_registry_authority_while_legacy_layout_lags() { + let home = temp_home(); + let names = names_state(&home).names; + let pending = admit_pending( + &names, + "modern-prebind", + NamedProvider::Claude, + Some("/work/prebind"), + ) + .await; + // The transcript can be indexed before its pending bind. The modern + // editor still names its own pending record, preserving the CAS target. + let indexed = names + .ensure_session(prospective_ref(), Some("/work/indexed".into())) + .await + .unwrap(); + let (status, body) = patch( + prebind_router(&names, Some("modern-prebind")), + "/api/panes/p1", + json!({ + "name": "Modern prebind name", "nameIntent": "user", + "expectedNameRef": pending.record.name_ref, "ifRevision": pending.record.revision, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let actual_pending = names + .get(vec![pending.record.name_ref.clone()]) + .await + .unwrap(); + assert_eq!(actual_pending[0].record.name, "Modern prebind name"); + assert_eq!(actual_pending[0].record.source, NameSource::Manual); + assert_eq!( + names.get(vec![prospective_ref()]).await.unwrap()[0].record, + indexed.record, + "a modern pending capture must not rename the indexed session before the bind" + ); + std::fs::remove_dir_all(home).ok(); +} diff --git a/crates/freshell-server/src/session_name_publish.rs b/crates/freshell-server/src/session_name_publish.rs new file mode 100644 index 000000000..89abb7a74 --- /dev/null +++ b/crates/freshell-server/src/session_name_publish.rs @@ -0,0 +1,36 @@ +//! Subscriber publication for a transaction that also adopts a newer +//! same-home document. The whole committed delta belongs to this store's +//! subscribers, even when the transaction itself changed a different name. + +use std::collections::BTreeMap; + +use freshell_protocol::session_names::SessionNameUpdate; + +use super::{delta_updates, materialize_publish, name_ref_key, PublishSpec, StoredDocument}; + +/// One final frame per naming ref, from the last published view to the +/// committed document. Decision-owned frames also carry status-only or +/// redirect changes that do not alter a name revision. Merging by ref keeps +/// those frames while avoiding an intermediate or duplicate name when this +/// transaction changes a record it just adopted. +pub(super) fn committed_updates( + prior: &StoredDocument, + committed: &StoredDocument, + own: &[PublishSpec], +) -> Vec { + let mut updates = BTreeMap::::new(); + for update in delta_updates(prior, committed) + .into_iter() + .chain(materialize_publish(committed, own)) + { + updates + .entry(name_ref_key(&update.record.name_ref)) + .and_modify(|current| current.changed |= update.changed) + .or_insert(update); + } + updates.into_values().collect() +} + +#[cfg(test)] +#[path = "session_name_publish_adoption_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_publish_adoption_tests.rs b/crates/freshell-server/src/session_name_publish_adoption_tests.rs new file mode 100644 index 000000000..f4e60a842 --- /dev/null +++ b/crates/freshell-server/src/session_name_publish_adoption_tests.rs @@ -0,0 +1,286 @@ +//! Real-store commits must publish every newly adopted name, including a +//! previous uncertain commit, exactly once at the final committed version. + +use std::path::Path; +use std::sync::Arc; + +use freshell_protocol::native_location::{ + NativeAcquisition, NativeEvidenceKind, NativeLocation, NativePersistence, +}; +use tokio::sync::broadcast::{error::TryRecvError, Receiver}; + +use super::super::*; +use crate::session_name_native::NativeOutcomeFold; + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open real store") +} + +fn pending(id: &str) -> SessionNameRef { + SessionNameRef::Pending { id: id.into() } +} + +async fn ensure(names: &SessionNames, id: &str) -> SessionNameRef { + names + .ensure_pending(PendingNameInput { + handle: id.into(), + provider: NamedProvider::Claude, + cwd: Some("/work/naming".into()), + }) + .await + .expect("ensure pending record"); + pending(id) +} + +async fn rename( + names: &SessionNames, + target: &SessionNameRef, + name: &str, +) -> Result { + names + .rename(RenameNameInput { + target: target.clone(), + name: name.into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await +} + +async fn bind(names: &SessionNames, from: &SessionNameRef, id: &str) -> SessionNameUpdate { + names + .bind_pending(BindNameInput { + pending: from.clone(), + target: SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: id.into(), + }, + acquisition: NativeAcquisition { + location: NativeLocation::Claude { + config_root: "/isolated/claude".into(), + transcript_path: Some(format!("/isolated/claude/{id}.jsonl")), + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: NativeEvidenceKind::SelectedTranscript, + persistence: NativePersistence::Verified, + }, + }) + .await + .expect("verified bind") +} + +fn drain(subscriber: &mut Receiver) -> Vec { + let mut updates = Vec::new(); + loop { + match subscriber.try_recv() { + Ok(update) => updates.push(update), + Err(TryRecvError::Empty) => return updates, + Err(error) => panic!("the subscribed store stays live and never lags: {error}"), + } + } +} + +fn update_for<'a>( + updates: &'a [SessionNameUpdate], + target: &SessionNameRef, +) -> &'a SessionNameUpdate { + let matching: Vec<_> = updates + .iter() + .filter(|update| &update.record.name_ref == target) + .collect(); + assert_eq!(matching.len(), 1, "one final frame per ref: {updates:#?}"); + matching[0] +} + +#[tokio::test] +async fn a_write_publishes_another_stores_rename_and_its_own_change_once() { + let home = tempfile::tempdir().unwrap(); + let local = open(home.path()); + let remote_target = ensure(&local, "external").await; + let own_target = ensure(&local, "local").await; + let remote = open(home.path()); + let mut subscriber = local.subscribe(); + let remote_name = rename(&remote, &remote_target, "Renamed on another server") + .await + .unwrap(); + + let own_name = rename(&local, &own_target, "Renamed here").await.unwrap(); + + let updates = drain(&mut subscriber); + assert_eq!(updates.len(), 2, "both committed changes reach this client"); + let adopted = update_for(&updates, &remote_target); + assert_eq!(adopted.record, remote_name.record); + assert!(adopted.changed); + assert_eq!(update_for(&updates, &own_target).record, own_name.record); + assert!(updates + .iter() + .all(|update| update.document_generation == own_name.document_generation)); + assert!(!local.adopt_external_if_changed().await.unwrap()); + assert!( + drain(&mut subscriber).is_empty(), + "the tick never duplicates it" + ); +} + +#[tokio::test] +async fn a_write_publishes_its_previous_uncertain_rename_only_after_confirmation() { + let home = tempfile::tempdir().unwrap(); + let names = open(home.path()); + let uncertain_target = ensure(&names, "uncertain").await; + let own_target = ensure(&names, "confirmed").await; + let mut subscriber = names.subscribe(); + set_test_hooks(home.path(), vec![TestHook::PostReplace]); + let uncertain = rename(&names, &uncertain_target, "Durability confirmed later").await; + assert!(matches!(uncertain, Err(NameError::CommitUncertain(_)))); + assert!( + drain(&mut subscriber).is_empty(), + "uncertainty publishes nothing" + ); + clear_test_hooks(home.path()); + + let confirmed = rename(&names, &own_target, "The confirming write") + .await + .unwrap(); + + let updates = drain(&mut subscriber); + assert_eq!(updates.len(), 2); + assert_eq!( + update_for(&updates, &uncertain_target).record.name, + "Durability confirmed later" + ); + assert!(updates + .iter() + .all(|update| update.document_generation == confirmed.document_generation)); + assert!(!names.adopt_external_if_changed().await.unwrap()); + assert!(drain(&mut subscriber).is_empty()); +} + +#[tokio::test] +async fn an_adopted_name_superseded_by_the_same_write_publishes_only_the_final_winner() { + let home = tempfile::tempdir().unwrap(); + let local = open(home.path()); + let target = ensure(&local, "same-ref").await; + let remote = open(home.path()); + let mut subscriber = local.subscribe(); + rename(&remote, &target, "Intermediate remote name") + .await + .unwrap(); + + let winner = rename(&local, &target, "Final local name").await.unwrap(); + + let updates = drain(&mut subscriber); + assert_eq!( + updates.len(), + 1, + "a client never receives the intermediate name" + ); + assert_eq!(updates[0].record, winner.record); + assert!(updates[0].changed); +} + +#[tokio::test] +async fn an_adopted_native_status_keeps_its_status_only_projection() { + let home = tempfile::tempdir().unwrap(); + let local = open(home.path()); + let pending_target = ensure(&local, "native").await; + let target = bind(&local, &pending_target, "native-session") + .await + .record + .name_ref; + let before = rename(&local, &target, "Native display name") + .await + .unwrap(); + let own_target = ensure(&local, "other").await; + let remote = open(home.path()); + let mut subscriber = local.subscribe(); + let claim = remote + .claim_native_cycle(target.clone(), "remote-native-receipt".into()) + .await + .unwrap() + .expect("manual name has an armed native series"); + let synced = remote + .fold_native_outcome( + target.clone(), + claim.location_revision, + claim.series_epoch, + NativeOutcomeFold::Read { + observed: Some(before.record.name.clone()), + receipt: None, + }, + ) + .await + .unwrap(); + assert_eq!(synced.record, before.record); + assert_eq!( + synced.native_sync.as_ref().unwrap().status, + freshell_protocol::session_names::NativeSyncStatus::Synced + ); + + rename(&local, &own_target, "Unrelated name").await.unwrap(); + + let updates = drain(&mut subscriber); + assert_eq!(updates.len(), 2); + let adopted = update_for(&updates, &target); + assert_eq!(adopted.record, before.record); + assert_eq!(adopted.native_sync, synced.native_sync); + assert!( + !adopted.changed, + "native status never claims a new saved name" + ); + assert_eq!(adopted.redirects, synced.redirects); +} + +#[tokio::test] +async fn an_adopted_bind_keeps_its_redirect_in_the_final_committed_frame() { + let home = tempfile::tempdir().unwrap(); + let local = open(home.path()); + let source = ensure(&local, "redirected").await; + let own_target = ensure(&local, "unrelated").await; + let remote = open(home.path()); + let mut subscriber = local.subscribe(); + let bound = bind(&remote, &source, "adopted-session").await; + + rename(&local, &own_target, "Own write").await.unwrap(); + + let updates = drain(&mut subscriber); + assert_eq!(updates.len(), 2); + let adopted = update_for(&updates, &bound.record.name_ref); + assert_eq!(adopted.record, bound.record); + assert_eq!(adopted.redirects, bound.redirects); + assert_eq!(adopted.redirects[0].from, source); + assert!(adopted.changed); +} + +#[tokio::test] +async fn a_failed_write_publishes_neither_adopted_nor_uncommitted_changes() { + let home = tempfile::tempdir().unwrap(); + let local = open(home.path()); + let remote_target = ensure(&local, "external-before-failure").await; + let own_target = ensure(&local, "failed-local").await; + let remote = open(home.path()); + let mut subscriber = local.subscribe(); + let remote_name = rename(&remote, &remote_target, "Remote accepted name") + .await + .unwrap(); + set_test_hooks(home.path(), vec![TestHook::PreReplace]); + + let failed = rename(&local, &own_target, "Never accepted").await; + + assert!(matches!(failed, Err(NameError::Persistence(_)))); + assert!( + drain(&mut subscriber).is_empty(), + "failure publishes nothing" + ); + clear_test_hooks(home.path()); + assert!(local.adopt_external_if_changed().await.unwrap()); + let updates = drain(&mut subscriber); + assert_eq!( + updates.len(), + 1, + "the later adoption only publishes the remote commit" + ); + assert_eq!(updates[0].record, remote_name.record); +} diff --git a/crates/freshell-server/src/session_name_route_admission.rs b/crates/freshell-server/src/session_name_route_admission.rs new file mode 100644 index 000000000..8c4fec950 --- /dev/null +++ b/crates/freshell-server/src/session_name_route_admission.rs @@ -0,0 +1,50 @@ +//! A visible live/indexed session may precede its saved-name record. Only +//! that known durable target can be admitted by a rename; arbitrary ids and +//! a runtime's prospective id remain truthful missing-name responses. + +use freshell_freshagent::naming::{NameError, RenameNameInput, SessionNaming}; +use freshell_protocol::session_names::{SessionNameRef, SessionNameUpdate}; +use freshell_sessions::directory_index::SessionIndex; +use freshell_ws::identity::TerminalIdentityRegistry; + +fn known_target( + target: &SessionNameRef, + identity: &TerminalIdentityRegistry, + index: Option<&SessionIndex>, +) -> bool { + let SessionNameRef::Session { + provider, + session_id, + } = target + else { + return false; + }; + let live = identity.list().into_iter().any(|row| { + row.name_ref.as_ref() == Some(target) + && row.provider.as_deref() == Some(provider.as_str()) + && row.session_id.as_deref() == Some(session_id.as_str()) + }); + live || index.and_then(SessionIndex::peek).is_some_and(|rows| { + rows.iter() + .any(|row| row.provider == provider.as_str() && row.session_id == *session_id) + }) +} + +pub(super) async fn rename( + sink: &dyn SessionNaming, + input: RenameNameInput, + identity: &TerminalIdentityRegistry, + index: Option<&SessionIndex>, +) -> Result { + match sink.rename(input.clone()).await { + Err(NameError::NotFound(_)) if known_target(&input.target, identity, index) => { + // Idempotent admission cannot replace a name committed while + // the first rename was in flight. The original CAS and intent + // still apply to the actual record, including a new fallback. + sink.ensure_session(input.target.clone(), input.cwd.clone()) + .await?; + sink.rename(input).await + } + result => result, + } +} diff --git a/crates/freshell-server/src/session_name_routes.rs b/crates/freshell-server/src/session_name_routes.rs index 9343ae7a4..5264402db 100644 --- a/crates/freshell-server/src/session_name_routes.rs +++ b/crates/freshell-server/src/session_name_routes.rs @@ -41,6 +41,9 @@ use crate::session_names::{ is_boot_import_id, SessionNames, NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT, }; +#[path = "session_name_route_admission.rs"] +mod admission; + /// Client batch reads are chunked to this many references per request (plan /// rule 9 / the route contract). pub(crate) const READ_CHUNK_LIMIT: usize = 100; @@ -56,6 +59,9 @@ pub struct SessionNamesState { /// (live and retired entries) before the store sees them; unresolved /// ones stay recovery-only. pub identity: TerminalIdentityRegistry, + /// Name-reliability Task 4: the session index, the best-known folder of + /// a session-key rename target. `None` when no provider home resolves. + pub index: Option>, } /// The session-names sub-router (`POST /api/session-names/read` + @@ -96,24 +102,19 @@ pub(crate) fn name_error_response(error: &NameError, target: &SessionNameRef) -> /// The shared scoped-rename drive every convenience route funnels into — /// the ONE `rename` call with the intent default (`automatic`: an agent /// suggestion never acquires a user rename's permanence) and the optional -/// `ifRevision` compare-and-set. Returns the accepted update, or the mapped -/// error response (with the structured error log already emitted). +/// `ifRevision` compare-and-set. `cwd` is the target's best-known folder +/// (name-reliability Task 4: an automation rename to it is refused as a +/// derived label). Returns the accepted update — carrying the rename's +/// `outcome` — or the mapped error response (with the structured error log +/// already emitted). pub(crate) async fn rename_through_authority( sink: &dyn SessionNaming, - target: SessionNameRef, - name: String, - intent: NameIntent, - if_revision: Option, + input: RenameNameInput, + identity: &TerminalIdentityRegistry, + index: Option<&freshell_sessions::directory_index::SessionIndex>, ) -> Result { - match sink - .rename(RenameNameInput { - target: target.clone(), - name, - intent, - if_revision, - }) - .await - { + let target = input.target.clone(); + match admission::rename(sink, input, identity, index).await { Ok(update) => Ok(update), Err(error) => { // Structured failure log under this server's own target @@ -131,6 +132,65 @@ pub(crate) async fn rename_through_authority( } } +/// Name-reliability Task 4 (LB-B8): the best-known folder of a rename +/// target. A session target reads its session index row (the published +/// snapshot only — a rename never waits on an index sweep); otherwise, or +/// when the index has no row, a terminal row bound to the target (the +/// identity table keeps the cwd of live and exited terminals). `None` when +/// nothing knows the folder: the rename then recognizes folder-free labels +/// only. +pub(crate) fn rename_target_cwd( + target: &SessionNameRef, + identity: &TerminalIdentityRegistry, + index: Option<&freshell_sessions::directory_index::SessionIndex>, +) -> Option { + if let SessionNameRef::Session { + provider, + session_id, + } = target + { + let indexed = index + .and_then(|index| index.peek()) + .and_then(|items| { + items + .iter() + .find(|row| row.provider == provider.as_str() && &row.session_id == session_id) + .and_then(|row| row.cwd.clone()) + }) + .filter(|cwd| !cwd.trim().is_empty()); + if indexed.is_some() { + return indexed; + } + if let Some(cwd) = identity + .find_all_by_session(provider.as_str(), session_id, None) + .into_iter() + .find_map(|row| row.cwd.filter(|cwd| !cwd.trim().is_empty())) + { + return Some(cwd); + } + } + identity + .list() + .into_iter() + .filter(|row| row.name_ref.as_ref() == Some(target)) + .find_map(|row| row.cwd.filter(|cwd| !cwd.trim().is_empty())) +} + +/// Name-reliability Task 4 (LB-B8): a terminal's best-known folder — the +/// live registry row, else the identity row (which survives the terminal's +/// exit). +pub(crate) fn terminal_row_cwd( + registry: &freshell_terminal::TerminalRegistry, + identity: &TerminalIdentityRegistry, + terminal_id: &str, +) -> Option { + registry + .probe(terminal_id) + .and_then(|row| row.cwd) + .or_else(|| identity.get(terminal_id).and_then(|row| row.cwd)) + .filter(|cwd| !cwd.trim().is_empty()) +} + /// Parse the rename-intent fields a convenience route may carry alongside /// its legacy body: `nameIntent` (`user` | `automatic`, default automatic /// when omitted) and `ifRevision` (compare-and-set, JS-safe ceiling). @@ -186,9 +246,11 @@ pub(crate) fn parse_rename_intents(body: &Value) -> Result<(NameIntent, Option, @@ -231,21 +293,6 @@ async fn read_session_names( } } } - // The common full-document adoption path before the name-dependent read: - // a same-home cooperating process's commits (the 2s tick's own refresh) - // are adopted here too, so a CLI/MCP read reflects current state without - // polling. Adoption failures degrade to the last established snapshot - // (the read still answers from it), loudly logged. - if let Err(error) = state.names.refresh_current().await { - tracing::warn!( - target: "freshell_server::session_names", - op = "refresh_current", - name_ref = "-", - revision = 0, - class = %error.code(), - "session_names.operation_failed: {error}" - ); - } match state.names.get(targets).await { Ok(updates) => Json(json!({ "names": updates })).into_response(), Err(error) => { @@ -307,7 +354,21 @@ async fn rename_session_name( } let target = request.target.clone(); let intent = request.name_intent.unwrap_or(NameIntent::Automatic); - match rename_through_authority(&*state.names, target, name, intent, request.if_revision).await { + let cwd = rename_target_cwd(&target, &state.identity, state.index.as_deref()); + match rename_through_authority( + &*state.names, + RenameNameInput { + target, + name, + intent, + if_revision: request.if_revision, + cwd, + }, + &state.identity, + state.index.as_deref(), + ) + .await + { Ok(update) => Json(serde_json::to_value(&update).unwrap_or(Value::Null)).into_response(), Err(response) => response, } diff --git a/crates/freshell-server/src/session_name_routes_tests.rs b/crates/freshell-server/src/session_name_routes_tests.rs index accac2247..6c09b50a3 100644 --- a/crates/freshell-server/src/session_name_routes_tests.rs +++ b/crates/freshell-server/src/session_name_routes_tests.rs @@ -32,6 +32,11 @@ use freshell_protocol::session_names::{ use super::SessionNamesState; use crate::session_names::SessionNames; +#[path = "session_name_known_target_tests.rs"] +mod known_target_tests; +#[path = "session_name_prebind_route_tests.rs"] +mod prebind_route_tests; + /// Same fake transport as `sessions_tests`'s: the wired-in result IS the /// Gemini reply. NO live Gemini calls in tests, ever. struct FakeGemini(Result); @@ -64,6 +69,7 @@ fn names_state(home: &std::path::Path) -> SessionNamesState { auth_token: Arc::new("tok".to_string()), names, identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + index: None, } } @@ -115,8 +121,19 @@ fn sessions_router_state( /// and a seeded single-pane layout whose pane content is the given scoped /// content (carrying `sessionRef` so the route resolves the durable record). fn fresh_router(names: &Arc, pane_content: Value) -> Router { + fresh_router_with_registry(names, pane_content, None) +} + +fn fresh_router_with_registry( + names: &Arc, + pane_content: Value, + registry: Option, +) -> Router { let (tx, _rx) = tokio::sync::broadcast::channel::(64); - let state = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + let mut state = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + if let Some(registry) = registry { + state = state.with_terminal_registry(registry); + } state.set_session_naming(names.clone()); let sync: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ "tabs": [{ "id": "t1", "title": "First" }], @@ -456,6 +473,438 @@ async fn every_surface_renames_the_same_durable_record_across_all_six_modes() { } } +/// Name-reliability Task 4: an automation rename reports whether it +/// applied, at each route's own nesting — the canonical route answers the +/// update itself (`outcome`), the session and terminal routes nest it under +/// `sessionName`, and the fresh-agent pane/tab routes under +/// `data.sessionName`. A protected name answers `protected_name` on every +/// surface; a rename to the target's own folder name answers +/// `default_label`, classified with the best-known folder each surface has +/// (an identity row's cwd for the canonical route, the pane content's +/// `initialCwd` for the pane route). +#[tokio::test] +async fn automation_rename_outcome_reaches_every_route_response() { + let home = temp_home(); + let state = names_state(&home); + let names = state.names.clone(); + let protected_id = "eeee5555-0000-4000-8000-000000000005"; + let folder_id = "ffff6666-0000-4000-8000-000000000006"; + admit_pending( + &names, + "handle-protected", + NamedProvider::Claude, + Some("/w"), + ) + .await; + bind_verified( + &names, + "handle-protected", + NamedProvider::Claude, + protected_id, + ) + .await; + let protected = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: protected_id.to_string(), + }; + names + .rename(RenameNameInput { + target: protected.clone(), + name: "LotL".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .expect("the user names it"); + let refused = json!({ "applied": false, "reason": "protected_name" }); + + // 1. The canonical route: the update itself. + let (status, body) = patch( + super::router(state.clone()), + "/api/session-names", + json!({ + "target": { "kind": "session", "provider": "claude", "sessionId": protected_id }, + "name": "Agent suggestion", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["outcome"], refused, "canonical: {body}"); + assert_eq!(body["record"]["name"], "LotL"); + + // 2. The session route: `sessionName.outcome`. + let (status, body) = patch( + sessions_router(&home, &names), + &format!("/api/sessions/{protected_id}?provider=claude"), + json!({ "titleOverride": "Agent suggestion" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["sessionName"]["outcome"], refused, "session: {body}"); + + // 3. The terminal route: `sessionName.outcome`. + let (tx, _rx) = tokio::sync::broadcast::channel::(16); + let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); + identity.set_session_naming(names.clone()); + identity.upsert( + "term-out", + Some("claude"), + Some(protected_id), + Some("/w"), + 1_000, + ); + identity.set_name_binding("term-out", Some(protected.clone()), None); + let terminals = crate::terminals::router(crate::terminals::TerminalsState { + auth_token: Arc::new("tok".to_string()), + settings: crate::settings_store::SettingsStore::load(Some(&home), vec!["claude".into()]), + registry: freshell_terminal::TerminalRegistry::new(), + broadcast_tx: Arc::new(tx), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + identity, + }); + let (status, body) = patch( + terminals, + "/api/terminals/term-out", + json!({ "titleOverride": "Agent suggestion" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["sessionName"]["outcome"], refused, "terminal: {body}"); + + // 4 + 5. The fresh-agent pane and tab routes: `data.sessionName.outcome`. + let content = scoped_pane_content("claude", "claude", protected_id); + for (uri, label) in [("/api/panes/p1", "pane"), ("/api/tabs/t1", "tab")] { + let (status, body) = patch( + fresh_router(&names, content.clone()), + uri, + json!({ "name": "Agent suggestion" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{label}: {body}"); + assert_eq!( + body["data"]["sessionName"]["outcome"], refused, + "{label}: {body}" + ); + assert_eq!( + body["message"], "session name not applied: protected_name", + "{label}: the message never claims a refused rename happened: {body}" + ); + } + let still = names.get(vec![protected.clone()]).await.unwrap(); + assert_eq!(still[0].record.name, "LotL", "no surface changed the name"); + + // The folder rule, through the canonical route (an identity row bound to + // the session knows its cwd) and the pane route (its `initialCwd`). + admit_pending(&names, "handle-folder", NamedProvider::Claude, Some("/w")).await; + bind_verified(&names, "handle-folder", NamedProvider::Claude, folder_id).await; + let folder_target = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: folder_id.to_string(), + }; + let canonical_state = names_state_sharing(&state); + canonical_state.identity.upsert( + "term-folder", + Some("claude"), + Some(folder_id), + Some("/home/dan/code/freshell"), + 1_000, + ); + canonical_state + .identity + .set_name_binding("term-folder", Some(folder_target.clone()), None); + let (status, body) = patch( + super::router(canonical_state), + "/api/session-names", + json!({ + "target": { "kind": "session", "provider": "claude", "sessionId": folder_id }, + "name": "freshell", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["outcome"], + json!({ "applied": false, "reason": "default_label" }), + "canonical folder rule: {body}" + ); + + let mut folder_content = scoped_pane_content("claude", "claude", folder_id); + folder_content["initialCwd"] = json!("/home/dan/code/freshell"); + let (status, body) = patch( + fresh_router(&names, folder_content), + "/api/panes/p1", + json!({ "name": "freshell" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["sessionName"]["outcome"], + json!({ "applied": false, "reason": "default_label" }), + "pane folder rule: {body}" + ); + assert_eq!( + body["message"], "session name not applied: default_label", + "pane folder rule: {body}" + ); + let folder_record = names.get(vec![folder_target]).await.unwrap(); + assert_eq!( + folder_record[0].record.name, "w", + "the folder name never applied" + ); + std::fs::remove_dir_all(&home).ok(); +} + +/// The pane route's pre-bind retry (a pane whose prospective sessionRef has +/// no record yet renames through its pending handle) answers the same +/// outcome-derived message: a refused automation rename says it was not +/// applied, an applied one that the session was renamed. +#[tokio::test] +async fn a_pending_retry_rename_message_follows_its_outcome() { + let home = temp_home(); + let state = names_state(&home); + let names = state.names.clone(); + admit_pending( + &names, + "handle-zero-turn", + NamedProvider::Claude, + Some("/w"), + ) + .await; + let pending = SessionNameRef::Pending { + id: "handle-zero-turn".to_string(), + }; + names + .rename(RenameNameInput { + target: pending.clone(), + name: "Billing".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .expect("the user names the zero-turn pane"); + admit_pending(&names, "handle-fresh", NamedProvider::Claude, Some("/w")).await; + let zero_turn_pane = |handle: &str, session_id: &str| { + let mut content = scoped_pane_content("claude", "claude", session_id); + content["namingHandle"] = json!(handle); + content + }; + + let (status, body) = patch( + fresh_router( + &names, + zero_turn_pane("handle-zero-turn", "aaaa1111-0000-4000-8000-00000000000a"), + ), + "/api/panes/p1", + json!({ "name": "Agent suggestion" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["nameRef"], + serde_json::to_value(&pending).unwrap(), + "the rename went through the pending handle: {body}" + ); + assert_eq!( + body["data"]["sessionName"]["outcome"], + json!({ "applied": false, "reason": "protected_name" }), + "{body}" + ); + assert_eq!( + body["message"], "session name not applied: protected_name", + "{body}" + ); + + let (status, body) = patch( + fresh_router( + &names, + zero_turn_pane("handle-fresh", "bbbb2222-0000-4000-8000-00000000000b"), + ), + "/api/panes/p1", + json!({ "name": "Agent suggestion" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["sessionName"]["outcome"]["applied"], + json!(true), + "{body}" + ); + assert_eq!(body["message"], "session renamed", "{body}"); + std::fs::remove_dir_all(&home).ok(); +} + +/// A switched conversation may be visible before the session index names it. +/// A retained pre-create handle belongs to the old conversation and cannot +/// authorize redirecting a rename back to it. +#[tokio::test] +async fn rename_after_switch_admits_the_new_session_and_never_renames_a_spent_handle() { + for uri in ["/api/panes/p1", "/api/tabs/t1"] { + for retained_handle in [false, true] { + let home = temp_home(); + let names = names_state(&home).names; + admit_pending(&names, "spent", NamedProvider::Claude, Some("/w")).await; + let old = + bind_verified(&names, "spent", NamedProvider::Claude, "old-conversation").await; + let mut content = scoped_pane_content("claude", "claude", "new-conversation"); + content["nameRef"] = + json!({"kind": "session", "provider": "claude", "sessionId": "new-conversation"}); + if retained_handle { + content["namingHandle"] = json!("spent"); + } + let (status, body) = patch( + fresh_router(&names, content), uri, + json!({"name": "New billing conversation", "nameIntent": "user", "expectedNameRef": { + "kind": "session", "provider": "claude", "sessionId": "new-conversation" + }}), + ).await; + assert_eq!( + status, + StatusCode::OK, + "{uri}, handle={retained_handle}: {body}" + ); + assert_eq!( + body["data"]["sessionName"]["record"]["name"], "New billing conversation", + "{body}" + ); + assert_eq!( + body["data"]["nameRef"]["sessionId"], "new-conversation", + "{body}" + ); + let old_after = names.get(vec![old.record.name_ref.clone()]).await.unwrap(); + assert_eq!( + old_after[0].record, old.record, + "the old name must stay untouched" + ); + std::fs::remove_dir_all(&home).ok(); + } + } +} + +#[tokio::test] +async fn captureless_rename_after_switch_uses_registry_before_a_stale_layout() { + for uri in ["/api/panes/p1", "/api/tabs/t1"] { + for mixed_pending in [false, true] { + let home = temp_home(); + let names = names_state(&home).names; + admit_pending(&names, "old-handle", NamedProvider::Claude, Some("/w")).await; + let old = bind_verified( + &names, + "old-handle", + NamedProvider::Claude, + "old-conversation", + ) + .await; + let mut content = scoped_pane_content( + "claude", + "claude", + if mixed_pending { + "new-conversation" + } else { + "old-conversation" + }, + ); + content["nameRef"] = if mixed_pending { + json!({"kind": "pending", "id": "old-handle"}) + } else { + serde_json::to_value(&old.record.name_ref).unwrap() + }; + content["namingHandle"] = json!("old-handle"); + content["terminalId"] = json!("T-switched"); + let registry = freshell_terminal::TerminalRegistry::new(); + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "T-switched".into(), + stream_id: "stream".into(), + mode: "claude".into(), + resume_session_id: Some("new-conversation".into()), + create_request_id: None, + created_at: None, + }); + let new_ref = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: "new-conversation".into(), + }; + registry.set_naming("T-switched", Some(new_ref.clone()), None); + let (status, body) = patch( + fresh_router_with_registry(&names, content, Some(registry)), + uri, + json!({"name": "Name after switch", "nameIntent": "user"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["data"]["nameRef"], + serde_json::to_value(new_ref).unwrap(), + "captureless rename must follow the terminal's new session" + ); + assert_eq!( + names.get(vec![old.record.name_ref.clone()]).await.unwrap()[0].record, + old.record + ); + std::fs::remove_dir_all(home).ok(); + } + } +} + +#[tokio::test] +async fn a_materialized_registry_accepts_its_own_pending_capture_and_keeps_revision_guards() { + let home = temp_home(); + let names = names_state(&home).names; + let admitted = admit_pending(&names, "materialized", NamedProvider::Claude, Some("/w")).await; + let bound = bind_verified( + &names, + "materialized", + NamedProvider::Claude, + "same-conversation", + ) + .await; + let mut content = scoped_pane_content("claude", "claude", "same-conversation"); + content["nameRef"] = json!({"kind": "pending", "id": "materialized"}); + content["namingHandle"] = json!("materialized"); + content["terminalId"] = json!("T-materialized"); + let registry = freshell_terminal::TerminalRegistry::new(); + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "T-materialized".into(), + stream_id: "stream".into(), + mode: "claude".into(), + resume_session_id: Some("same-conversation".into()), + create_request_id: None, + created_at: None, + }); + registry.set_naming("T-materialized", Some(bound.record.name_ref.clone()), None); + let router = fresh_router_with_registry(&names, content, Some(registry)); + let body = |revision| { + json!({"name": "Accepted same conversation", "nameIntent": "user", + "expectedNameRef": {"kind": "pending", "id": "materialized"}, "ifRevision": revision}) + }; + let (status, conflict) = patch( + router.clone(), + "/api/panes/p1", + body(admitted.record.revision), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{conflict}"); + assert_eq!(conflict["error"], "NAME_REVISION_CONFLICT"); + let (status, accepted) = patch(router, "/api/panes/p1", body(bound.record.revision)).await; + assert_eq!(status, StatusCode::OK, "{accepted}"); + assert_eq!( + accepted["data"]["nameRef"], + serde_json::to_value(bound.record.name_ref).unwrap() + ); + std::fs::remove_dir_all(home).ok(); +} + +/// A second state over the SAME store with its own (empty) identity table. +fn names_state_sharing(state: &SessionNamesState) -> SessionNamesState { + SessionNamesState { + auth_token: Arc::clone(&state.auth_token), + names: state.names.clone(), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + index: None, + } +} + /// A pending pane renamed BEFORE its durable identity exists keeps the name /// through the verified bind: the pre-durable rename targets the pending /// handle, and the bind transfers it onto the durable record atomically (the @@ -609,6 +1058,7 @@ async fn scoped_reset_is_refused_on_every_surface() { name: "Protected".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -1132,6 +1582,7 @@ async fn two_store_participants_converge_through_unconditional_refresh() { name: "Renamed By A".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -1185,6 +1636,7 @@ async fn a_foreign_status_only_native_sync_transition_republishes_on_adoption() name: "Manually Named".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -1306,6 +1758,42 @@ async fn read_route_chunks_and_omits_unknown_refs() { std::fs::remove_dir_all(&home).ok(); } +/// Name-reliability Task 11 (RC9): the read route answers from the adopted +/// view — it runs no `refresh_current` full-document rebuild and no locked +/// read, so a page reload burst never queues on the document lock. +#[tokio::test] +async fn read_route_does_not_call_refresh_current() { + let home = temp_home(); + let state = names_state(&home); + admit_pending( + &state.names, + "handle-tally", + NamedProvider::Claude, + Some("/w"), + ) + .await; + let data_dir = std::fs::canonicalize(home.join(".freshell")).unwrap(); + let before = crate::session_names::test_store_transaction_count(&data_dir); + + let (status, body) = post_read( + super::router(state.clone()), + json!([{ "kind": "pending", "id": "handle-tally" }]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["names"].as_array().unwrap().len(), + 1, + "the read resolves the record: {body}" + ); + assert_eq!( + crate::session_names::test_store_transaction_count(&data_dir), + before, + "the read route took no store transaction" + ); + std::fs::remove_dir_all(&home).ok(); +} + /// A session whose identity was verified under one native location and is /// then verified under a NEW location (same provider/session — a supported /// relocation) RETAINS its name; a non-target session's record is untouched. @@ -1324,6 +1812,7 @@ async fn verified_relocation_retains_the_name_and_leaves_non_targets_untouched() name: "Kept Through Move".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -1553,6 +2042,106 @@ async fn import_endpoint_acknowledges_per_candidate_and_resolves_terminals() { std::fs::remove_dir_all(&home).ok(); } +/// Name-reliability Task 3: once the one-time consolidation is complete, an +/// old browser's import is answered 200 with every candidate acknowledged, +/// and nothing is written — no record, no stored candidate id, no backup +/// file, no document generation. +#[tokio::test] +async fn import_route_after_completion_acknowledges_without_writing() { + let home = temp_home(); + let state = names_state(&home); + state + .names + .hydrate_indexed( + crate::session_name_generation::IndexedNameInput { + provider: NamedProvider::Claude, + session_id: "sess-1".into(), + cwd: Some("/w/work".into()), + first_user_message: None, + provider_title: None, + }, + false, + ) + .await + .unwrap(); + state.names.complete_legacy_migration().await.unwrap(); + let data_dir = home.join(".freshell"); + let document = |dir: &std::path::Path| -> Value { + serde_json::from_slice(&std::fs::read(dir.join("session-names.json")).unwrap()).unwrap() + }; + let before = document(&data_dir); + + let router = super::router(state.clone()); + let body = json!({ + "version": 1, + "importId": "old-client-window-import", + "evidence": [ + { "storageKey": "freshell.layout.v3.layout-window-a", "raw": "{\"version\":4}" } + ], + "candidates": [ + { + "id": "c-1", + "target": { "kind": "session", "provider": "claude", "sessionId": "sess-1" }, + "name": "Tab 23", + "source": "provider_ai", + "scope": "source_tab", + "evidenceKey": "freshell.layout.v3.layout-window-a#tab-1", + "protectionEvidence": "none" + }, + { + "id": "c-2", + "target": { "kind": "session", "provider": "claude", "sessionId": "sess-2" }, + "name": "ms-people-summit", + "source": "legacy_protected", + "scope": "source_tab", + "evidenceKey": "freshell.layout.v3.layout-window-a#tab-2", + "protectionEvidence": "legacy_flag" + } + ] + }); + let (status, response) = post_import(router, body).await; + assert_eq!(status, StatusCode::OK, "{response}"); + assert_eq!( + response["acknowledged"], + json!(["c-1", "c-2"]), + "{response}" + ); + assert_eq!(response["names"], json!([]), "{response}"); + + let after = document(&data_dir); + assert_eq!( + after["documentGeneration"], before["documentGeneration"], + "the import wrote no document generation" + ); + assert_eq!( + after, before, + "the document is byte-for-byte the same value" + ); + let record = state + .names + .get(vec![SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: "sess-1".into(), + }]) + .await + .unwrap(); + assert_eq!(record[0].record.name, "work"); + assert!(state + .names + .get(vec![SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: "sess-2".into(), + }]) + .await + .unwrap() + .is_empty()); + assert!( + !data_dir.join("name-migration-v1").join("imports").exists(), + "no per-import backup file" + ); + std::fs::remove_dir_all(&home).ok(); +} + #[tokio::test] async fn import_endpoint_requires_auth() { let home = temp_home(); diff --git a/crates/freshell-server/src/session_name_store_view.rs b/crates/freshell-server/src/session_name_store_view.rs new file mode 100644 index 000000000..76664f30f --- /dev/null +++ b/crates/freshell-server/src/session_name_store_view.rs @@ -0,0 +1,514 @@ +//! Name-reliability Task 11 (D9, RC9): lock-free name reads and a cheap +//! cross-process peek. +//! +//! The adopted view (`NamesCore::view`) already holds the whole document +//! this store last committed or adopted, so a name read never needs the +//! document lock or a re-parse. The one question a read must answer is "did +//! another participant replace the document since?", and a stat answers it: +//! the view remembers the [`DocumentStamp`] of the exact file it was adopted +//! from, and the peek compares the document's current stamp with it. +//! +//! * Unix: device + inode + length + nanosecond mtime. Every cooperating +//! writer replaces the document atomically (sibling temp + rename), and a +//! replacement always has a different inode from the file it replaces. +//! The filesystem can hand a freed inode number out again a commit or more +//! later, so an equal stamp is still possible in rare cases (the same +//! reused inode, the same length and the same mtime tick); the periodic +//! full check below bounds that case. +//! * Windows: length + mtime (100 ns). The std file-index APIs are unstable +//! and no new crate is taken for them; the periodic full check below backs +//! the weaker stamp. +//! +//! An equal stamp is trusted for at most [`FULL_CHECK_INTERVAL`] after the +//! last full read; the next peek after that is a full read again. That +//! bounds staleness for the rare change a stamp cannot see (an in-place +//! rewrite that keeps the length within one coarse mtime tick, or a reused +//! inode as above). A changed stamp means the full read too — the same +//! strict locked adoption every transaction runs: durability +//! reconciliation, generation fencing, delta publication. The full read +//! checks the stamp again once it holds the lock, because the unlocked stat +//! can catch a commit halfway (the old view stamp, then the new file); when +//! the file then turns out to be the adopted one, nothing is parsed. +//! +//! A read never queues behind a commit. When the stamp changed it takes +//! this store's local turn only if it is free — a turn held by one of this +//! process's own transactions means that transaction is about to adopt or +//! replace the document itself and publish the result into the view — and +//! then tries the document lock for at most [`READ_LOCK_WAIT`]. If either +//! is busy (normally a transaction mid-commit, here or in another +//! participant) it answers from the current view; a later read or the 2 s +//! tick adopts the change. The tick's full read is not a read a caller +//! waits on: it queues for the local turn like every transaction and then +//! takes the strict lock budget. Every lock this module takes is a +//! [`HeldSidecarLock`](super::HeldSidecarLock), so a child process forked +//! while a read held it cannot keep it held. Once a read holds the lock, +//! fences fail it exactly as before (an unreconciled replacement, a +//! vanished document, a decreasing or rewritten generation); a read +//! answered under a busy lock gets the established view, never the fenced +//! document, and a later read reports the fence. + +use std::fs::TryLockError; +use std::io::Read as _; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use freshell_freshagent::naming::{NameError, NameFuture}; +use freshell_protocol::session_names::{SessionNameRef, SessionNameUpdate}; + +use super::{ + acquire_document_lock, await_local_turn, name_ref_key, open_lock_file, run_txn_locked, + spawn_txn, update_for_key, Adopted, Decision, HeldSidecarLock, LocalTurn, NamesCore, + RedirectScope, SessionNames, StoredDocument, +}; + +/// How long an equal stamp is trusted before the peek re-reads the whole +/// document anyway. +const FULL_CHECK_INTERVAL: Duration = Duration::from_secs(30); + +/// How long a read tries a busy document lock before answering from the +/// adopted view. It rides out a momentary hold by another participant and +/// stays far below a real commit of a live-size document, which the read +/// does not wait for. +const READ_LOCK_WAIT: Duration = Duration::from_millis(10); + +/// How often a read retries a busy lock within [`READ_LOCK_WAIT`]. +const READ_LOCK_POLL: Duration = Duration::from_micros(500); + +/// What a stat of the name document says about the file: its length, its +/// modification time and (Unix) its device and inode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DocumentStamp { + pub(crate) len: u64, + pub(crate) mtime_ns: i128, + pub(crate) file_id: Option<(u64, u64)>, +} + +impl DocumentStamp { + fn of(metadata: &std::fs::Metadata) -> Self { + let mtime_ns = metadata + .modified() + .ok() + .map( + |modified| match modified.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => after.as_nanos() as i128, + Err(before) => -(before.duration().as_nanos() as i128), + }, + ) + .unwrap_or(0); + #[cfg(unix)] + let file_id = { + use std::os::unix::fs::MetadataExt; + Some((metadata.dev(), metadata.ino())) + }; + #[cfg(not(unix))] + let file_id = None; + Self { + len: metadata.len(), + mtime_ns, + file_id, + } + } +} + +/// Stat the document at `path`. +pub(crate) fn stat_stamp(path: &Path) -> std::io::Result { + std::fs::metadata(path).map(|metadata| DocumentStamp::of(&metadata)) +} + +/// Read the whole document together with the stamp of the exact file the +/// bytes came from (taken from the open handle BEFORE reading, so a stamp +/// can only be older than its bytes — a later peek then re-reads, it never +/// misses a change). +pub(super) fn read_with_stamp(path: &Path) -> std::io::Result<(Vec, DocumentStamp)> { + let mut file = std::fs::File::open(path)?; + let stamp = DocumentStamp::of(&file.metadata()?); + let mut bytes = Vec::with_capacity(usize::try_from(stamp.len).unwrap_or(0)); + file.read_to_end(&mut bytes)?; + Ok((bytes, stamp)) +} + +/// When the adopted view was last confirmed against the whole document (a +/// commit, an adoption or an unchanged full read). +#[derive(Debug, Default)] +pub(super) struct FullCheck(Mutex>); + +impl FullCheck { + pub(super) fn mark(&self) { + *self.0.lock().unwrap_or_else(|p| p.into_inner()) = Some(Instant::now()); + } + + fn within(&self, interval: Duration) -> bool { + self.0 + .lock() + .unwrap_or_else(|p| p.into_inner()) + .is_some_and(|checked| checked.elapsed() < interval) + } +} + +/// A full read confirmed the file still holds the adopted generation (the +/// equal-generation, equal-digest outcome): keep the view, record the +/// file's current stamp, and restart the full-check window. +pub(super) fn confirm_view(core: &NamesCore, stamp: Option) { + let view = core.current_view(); + if view.stamp == stamp { + core.full_check.mark(); + } else { + core.set_view(view.document.clone(), view.digest, stamp); + } +} + +/// Why a peek has to read the whole document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FullReadReason { + /// The view was never stamped (a failed stat after a commit). + NoStamp, + /// The document's stamp differs from the adopted one. + StampChanged, + /// The document cannot be stat'ed (vanished, permissions); the full + /// read reports the fence. + StatFailed, + /// The stamp is equal but the full-check window elapsed. + PeriodicCheck, +} + +impl FullReadReason { + fn as_str(self) -> &'static str { + match self { + Self::NoStamp => "no_stamp", + Self::StampChanged => "stamp_changed", + Self::StatFailed => "stat_failed", + Self::PeriodicCheck => "periodic_full_check", + } + } +} + +/// How long a full read may wait for the local turn and the document lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LockWait { + /// The local turn (FIFO), then the strict transaction budget (the 2 s + /// tick). + Bounded, + /// The local turn only if it is free, then at most [`READ_LOCK_WAIT`] + /// for the document lock (reads): either still busy means a commit is + /// in flight, and the read answers from the current view. + Brief, +} + +/// What a peek-and-adopt did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Adoption { + /// The stamp matched inside the full-check window (checked again under + /// the lock when the unlocked stat said otherwise): nothing was read. + Current, + /// A full read confirmed the adopted generation. + Confirmed, + /// A full read adopted a newer generation. + Adopted, + /// The stamp changed but the local turn was taken or the lock stayed + /// busy for the read's short wait (reads only). + Busy, +} + +/// The stat-only decision: `None` when the view may be served as is. +fn peek(core: &NamesCore) -> Option { + let Some(adopted) = core.current_view().stamp else { + return Some(FullReadReason::NoStamp); + }; + match stat_stamp(&core.doc_path) { + Err(_) => Some(FullReadReason::StatFailed), + Ok(current) if current != adopted => Some(FullReadReason::StampChanged), + Ok(_) if !core.full_check.within(full_check_interval(&core.data_dir)) => { + Some(FullReadReason::PeriodicCheck) + } + Ok(_) => None, + } +} + +/// Try the document lock for at most `wait`: `Ok(None)` when it is still +/// held by someone else at the end. +fn try_document_lock_within( + path: &Path, + wait: Duration, +) -> Result, NameError> { + let file = open_lock_file(path)?; + let deadline = Instant::now() + wait; + loop { + match file.try_lock() { + Ok(()) => { + return Ok(Some(HeldSidecarLock { + file, + path: path.to_path_buf(), + })) + } + Err(TryLockError::WouldBlock) if Instant::now() < deadline => { + std::thread::sleep(READ_LOCK_POLL); + } + Err(TryLockError::WouldBlock) => return Ok(None), + Err(TryLockError::Error(e)) => { + return Err(NameError::LockUnavailable(format!( + "session-names document lock error: {e}" + ))) + } + } + } +} + +/// The full read: the strict locked adoption (a pure-read transaction). +/// +/// `peeked` is what the unlocked peek decided. That stat ran without the +/// lock, so it can have caught a commit halfway (the old view stamp, then +/// the new file): once the lock is held no cooperating writer is mid-replace, +/// so the peek runs again and its answer is final. When it now finds the file +/// is the adopted one, nothing is read. +fn full_read( + core: &NamesCore, + turn: &LocalTurn, + wait: LockWait, + peeked: FullReadReason, +) -> Result { + let guard = match wait { + LockWait::Bounded => acquire_document_lock(&core.lock_path)?, + LockWait::Brief => { + match try_document_lock_within(&core.lock_path, read_lock_wait(&core.data_dir))? { + Some(guard) => guard, + None => return Ok(served_busy(core, peeked, "lock_busy")), + } + } + }; + let Some(reason) = peek(core) else { + tracing::debug!( + target: "freshell_server::session_names", + op = "adopt_external", + reason = peeked.as_str(), + "session_names.peek_settled_under_lock: the document is the adopted one; \ + skipped the full read" + ); + return Ok(Adoption::Current); + }; + let before = core.current_view(); + run_txn_locked(core, turn, guard, |_doc, _meta| Ok(Decision::Read(())))?; + let after = core.current_view(); + if after.document.document_generation <= before.document.document_generation { + return Ok(Adoption::Confirmed); + } + report_adoption(reason, &before, &after); + Ok(Adoption::Adopted) +} + +/// Log an adoption of a newer generation. The adopted view carries the +/// stamp of the exact file just read: when it equals the stamp adopted +/// before, the stamp missed this change (the case the periodic full check +/// exists for), which is worth an INFO line; otherwise the stamp did its job. +fn report_adoption(reason: FullReadReason, before: &Adopted, after: &Adopted) { + let from_generation = before.document.document_generation; + let to_generation = after.document.document_generation; + if before.stamp.is_some() && after.stamp == before.stamp { + tracing::info!( + target: "freshell_server::session_names", + op = "adopt_external", + reason = reason.as_str(), + from_generation, + to_generation, + "session_names.stamp_missed_change: adopted a newer document whose stamp was \ + unchanged" + ); + } else { + tracing::debug!( + target: "freshell_server::session_names", + op = "adopt_external", + reason = reason.as_str(), + from_generation, + to_generation, + "session_names.adopted_external: adopted a newer document generation" + ); + } +} + +/// Peek, and run the full read only when the peek says so. The stat runs +/// inline (one metadata call, the settings store's freshness-check +/// precedent); the full read runs on a detached blocking task like every +/// transaction, so its lock guard lives until it completes. +async fn adopt_if_changed(core: Arc, wait: LockWait) -> Result { + let Some(reason) = peek(&core) else { + return Ok(Adoption::Current); + }; + let turn = match wait { + LockWait::Bounded => await_local_turn(&core).await, + LockWait::Brief => match Arc::clone(&core.local_turn).try_lock_owned() { + Ok(turn) => turn, + Err(_) => return Ok(served_busy(&core, reason, "local_turn_busy")), + }, + }; + tokio::task::spawn_blocking(move || full_read(&core, &turn, wait, reason)) + .await + .map_err(|join_err| { + NameError::Persistence(format!("naming adoption task failed: {join_err}")) + })? +} + +/// A read whose full read could not start without waiting: it answers from +/// the adopted view (`class`: `local_turn_busy` when one of this process's +/// own transactions holds the turn, `lock_busy` when the document lock +/// stayed busy for the short wait). +fn served_busy(core: &NamesCore, peeked: FullReadReason, class: &'static str) -> Adoption { + count_busy_read_for_test(&core.data_dir); + tracing::debug!( + target: "freshell_server::session_names", + op = "get", + reason = peeked.as_str(), + class, + "session_names.read_served_from_view: a commit is in flight; answering from the \ + adopted view" + ); + Adoption::Busy +} + +/// Resolve `refs` against `document` — the read model's answer shape: +/// each ref through its pending→durable redirect, unknown refs omitted. +fn get_from_view(document: &StoredDocument, refs: &[SessionNameRef]) -> Vec { + let mut updates = Vec::new(); + for target in refs { + let (resolved, traversed) = document.resolve_ref(target); + let key = name_ref_key(&resolved); + if let Some(mut update) = update_for_key(document, &key, false, RedirectScope::None) { + if let Some(redirect) = traversed { + update.redirects.push(redirect); + } + updates.push(update); + } + } + updates +} + +/// The name read: adopt a changed document if the lock is free, then answer +/// from the adopted view. Only an unestablished store (generation 0, never +/// the case after `open`) takes the strict locked path. +pub(super) async fn read_names( + core: Arc, + refs: Vec, +) -> Result, NameError> { + adopt_if_changed(Arc::clone(&core), LockWait::Brief).await?; + let view = core.current_view(); + if view.document.document_generation == 0 { + return spawn_txn(core, move |doc, _meta| { + Ok(Decision::Read(get_from_view(doc, &refs))) + }) + .await; + } + Ok(get_from_view(&view.document, &refs)) +} + +impl SessionNames { + /// The 2 s tick's cross-process check: stat the document and, only when + /// its stamp differs from the adopted one or the full-check window + /// elapsed, run the strict locked full-read adoption (which publishes + /// the delta to subscribers). `true` when a newer generation was + /// adopted. + pub(crate) fn adopt_external_if_changed(&self) -> NameFuture { + let core = Arc::clone(&self.core); + Box::pin(async move { + let adoption = adopt_if_changed(core, LockWait::Bounded).await?; + Ok(adoption == Adoption::Adopted) + }) + } +} + +/// Test tally of reads that answered from the view because the lock stayed +/// busy past the read's short wait (data-dir keyed like the other test +/// seams, so parallel tests never see each other's counts). A no-op in +/// non-test builds. +fn count_busy_read_for_test(data_dir: &Path) { + #[cfg(test)] + { + *busy_read_tallies() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .entry(data_dir.to_path_buf()) + .or_default() += 1; + } + #[cfg(not(test))] + { + let _ = data_dir; + } +} + +#[cfg(test)] +fn busy_read_tallies() -> &'static Mutex> { + static TALLIES: std::sync::OnceLock< + Mutex>, + > = std::sync::OnceLock::new(); + TALLIES.get_or_init(Default::default) +} + +/// The current test tally of busy reads against `data_dir`. +#[cfg(test)] +pub(super) fn test_busy_read_count(data_dir: &Path) -> u64 { + busy_read_tallies() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(data_dir) + .copied() + .unwrap_or(0) +} + +/// Test helper: `get`, repeated only while the read answered from the view +/// because a commit was in flight (the local turn was taken or the lock +/// stayed busy past its short wait; the busy tally proves it). The read +/// then answers from the adopted view (safe, but not yet the new document) +/// and a later read settles. Every other outcome (an adoption, a fence +/// error, an answer from an unchanged document) is returned as is. +#[cfg(test)] +pub(super) async fn get_settled_for_test( + names: &SessionNames, + refs: Vec, +) -> Result, NameError> { + use freshell_freshagent::naming::SessionNaming as _; + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let busy_before = test_busy_read_count(&names.core.data_dir); + let result = names.get(refs.clone()).await; + let was_busy = test_busy_read_count(&names.core.data_dir) != busy_before; + if !was_busy || Instant::now() >= deadline { + return result; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// The read's lock wait for `data_dir` (a test seam lengthens it). +fn read_lock_wait(data_dir: &Path) -> Duration { + #[cfg(test)] + { + if let Some(super::TestHook::ReadLockWaitMs(ms)) = + super::take_matching_hook(data_dir, |h| { + matches!(h, super::TestHook::ReadLockWaitMs(_)) + }) + { + return Duration::from_millis(ms); + } + } + let _ = data_dir; + READ_LOCK_WAIT +} + +/// The full-check interval for `data_dir` (a test seam shortens it). +fn full_check_interval(data_dir: &Path) -> Duration { + #[cfg(test)] + { + if let Some(super::TestHook::FullCheckIntervalMs(ms)) = + super::take_matching_hook(data_dir, |h| { + matches!(h, super::TestHook::FullCheckIntervalMs(_)) + }) + { + return Duration::from_millis(ms); + } + } + let _ = data_dir; + FULL_CHECK_INTERVAL +} + +#[cfg(test)] +#[path = "session_name_store_view_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_store_view_tests.rs b/crates/freshell-server/src/session_name_store_view_tests.rs new file mode 100644 index 000000000..e0407e024 --- /dev/null +++ b/crates/freshell-server/src/session_name_store_view_tests.rs @@ -0,0 +1,818 @@ +//! Name-reliability Task 11 (D9, RC9): name reads are served from the +//! adopted view without the document lock, and the cross-process check is a +//! cheap stamp peek backed by a periodic full read. +//! +//! Every test drives the REAL store on a temp dir; a "second process" is a +//! second `SessionNames` opened on the same directory — the file lock and +//! the document file are the only things two participants share, exactly as +//! between two real processes. + +use std::io::Write as _; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use freshell_freshagent::naming::{NameError, PendingNameInput, RenameNameInput, SessionNaming}; +use freshell_protocol::session_names::{ + LegacyCandidateScope, NameIntent, NameSource, NamedProvider, SessionNameRecord, SessionNameRef, + SessionNameUpdate, +}; + +use super::super::*; +use super::*; +use crate::test_trace_capture::{scoped_trace_capture, CapturedTraceEvent}; + +const STORE_TARGET: &str = "freshell_server::session_names"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +/// This store's local turn, for driving `full_read` directly (nothing else +/// in these tests holds it at that point). +fn free_turn(store: &SessionNames) -> LocalTurn { + Arc::clone(&store.core.local_turn) + .try_lock_owned() + .expect("no transaction of this store is in flight") +} + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open store") +} + +fn pending(id: &str) -> SessionNameRef { + SessionNameRef::Pending { id: id.to_string() } +} + +async fn ensure(store: &Arc, handle: &str) { + store + .ensure_pending(PendingNameInput { + handle: handle.to_string(), + provider: NamedProvider::Claude, + cwd: Some("/w/store-view".to_string()), + }) + .await + .expect("ensure pending"); +} + +async fn rename_user( + store: &Arc, + target: SessionNameRef, + name: &str, +) -> Result { + store + .rename(RenameNameInput { + target, + name: name.to_string(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await +} + +async fn name_of(store: &Arc, target: &SessionNameRef) -> String { + store + .get(vec![target.clone()]) + .await + .expect("get succeeds") + .into_iter() + .next() + .expect("the record resolves") + .record + .name +} + +/// `name_of` through `get_settled_for_test`: a read that answered from the +/// view only because the lock was still busy (a forked child's inherited +/// copy of a just-finished transaction's lock can outlast the read's short +/// wait on a loaded machine) is repeated; any other answer is final. +async fn settled_name_of(store: &Arc, target: &SessionNameRef) -> String { + get_settled_for_test(store, vec![target.clone()]) + .await + .expect("get succeeds") + .into_iter() + .next() + .expect("the record resolves") + .record + .name +} + +fn raw_document(dir: &Path) -> StoredDocument { + let bytes = std::fs::read(dir.join(DOCUMENT_FILE_NAME)).expect("raw document exists"); + parse_document(&bytes).expect("raw document parses") +} + +/// The captured events whose event name (the message up to its first +/// colon) is `name`. +fn named<'a>(events: &'a [CapturedTraceEvent], name: &str) -> Vec<&'a CapturedTraceEvent> { + events + .iter() + .filter(|event| event.message.split(':').next() == Some(name)) + .collect() +} + +/// The change a stamp cannot see: rewrite the document IN PLACE (same +/// inode) as a newer generation that names `target` `name`, at the same +/// length and with the mtime put back (possible on coarse-timestamp +/// filesystems). `name` must be as long as the record's current name. +fn rewrite_in_place_keeping_the_stamp(data_dir: &Path, target: &SessionNameRef, name: &str) { + let doc_path = data_dir.join(DOCUMENT_FILE_NAME); + let stamp_before = stat_stamp(&doc_path).expect("stat"); + let mtime_before = std::fs::metadata(&doc_path) + .and_then(|metadata| metadata.modified()) + .expect("mtime"); + let mut newer = raw_document(data_dir); + let record = newer + .records + .get_mut(&name_ref_key(target)) + .expect("record"); + record.name = name.to_string(); + newer.document_generation += 1; + let bytes = serialize_document(&newer).expect("serialize"); + assert_eq!( + bytes.len() as u64, + stamp_before.len, + "the seam keeps the length" + ); + { + let _lock = hold_document_lock(data_dir); + let mut file = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&doc_path) + .expect("open in place"); + file.write_all(&bytes).expect("rewrite in place"); + file.sync_all().expect("sync"); + file.set_times(std::fs::FileTimes::new().set_modified(mtime_before)) + .expect("put the mtime back"); + } + assert_eq!( + stat_stamp(&doc_path).expect("stat"), + stamp_before, + "the seam forces an equal stamp" + ); +} + +/// Take the sidecar lock the way a cooperating participant does (bounded +/// retry), returning the held handle. +fn hold_document_lock(dir: &Path) -> std::fs::File { + let lock = open_lock_file(&dir.join(LOCK_FILE_NAME)).expect("open the sidecar lock"); + let deadline = Instant::now() + LOCK_RETRY_BUDGET; + loop { + match lock.try_lock() { + Ok(()) => return lock, + Err(std::fs::TryLockError::WouldBlock) => { + assert!(Instant::now() < deadline, "the sidecar lock stayed busy"); + std::thread::sleep(LOCK_POLL_INTERVAL); + } + Err(error) => panic!("sidecar lock: {error}"), + } + } +} + +async fn wait_for_lock_held_sentinel(dir: &Path) { + let sentinel = dir.join(LOCK_HELD_SENTINEL_NAME); + let deadline = Instant::now() + Duration::from_secs(15); + while !sentinel.exists() { + assert!( + Instant::now() < deadline, + "the holding participant never signaled that it took the document lock" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// The live-machine shape that made reads slow (RC9): a version-1 document +/// of at least 4.5 MB — 5,000 records, 1,200 idle generation series with +/// 1.5 KB excerpts, 1,600 acknowledged candidate ids and the winning +/// migration evidence that goes with them. Returns the document and the refs +/// of its records in key order. +fn synthetic_load_document() -> (StoredDocument, Vec) { + const RECORDS: usize = 5_000; + const IDLE_SERIES: usize = 1_200; + const EXCERPT_BYTES: usize = 1_536; + const ACKNOWLEDGED: usize = 1_600; + let mut document = StoredDocument::initial(); + document.document_generation = 1; + document.migration.completed = true; + let mut refs = Vec::with_capacity(RECORDS); + for i in 0..RECORDS { + let target = SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: format!("5e55{i:04}-0000-4000-8000-00000000{i:04}"), + }; + let key = name_ref_key(&target); + let revision = document.allocate_revision().expect("revision"); + document.records.insert( + key.clone(), + SessionNameRecord { + name_ref: target.clone(), + name: format!( + "Investigate the importer regression number {i:04} in the build pipeline" + ), + source: NameSource::FirstMessage, + revision, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }, + ); + if i < IDLE_SERIES { + let excerpt: String = format!("First message {i:04}: ") + .chars() + .chain(std::iter::repeat('x')) + .take(EXCERPT_BYTES) + .collect(); + document.generation.insert( + key.clone(), + GenerationState { + status: GenerationStatus::Idle, + series_id: format!("series-{i:04}-0000-4000-8000-000000000000"), + input_fingerprint: Some(format!("{:064x}", i)), + excerpt: Some(excerpt), + attempt_ids: Vec::new(), + attempt_starts: Vec::new(), + consumed: 0, + next_due: None, + exhausted_at: None, + }, + ); + } + if i < ACKNOWLEDGED { + let candidate_id = format!( + "import-{i:04}-0000-4000-8000-000000000000:freshell.layout.v3.window-{i:04}:tab-{i:04}-0000-4000-8000-000000000000:pane-{i:04}-0000-4000-8000-000000000000:title" + ); + document + .migration + .acknowledged_candidate_ids + .push(candidate_id.clone()); + document.migration.winning_evidence.insert( + key, + StoredMigrationEvidence { + candidate_id, + name: format!( + "Investigate the importer regression number {i:04} in the build pipeline" + ), + source: NameSource::FirstMessage, + scope: LegacyCandidateScope::Session, + explicit_rename_at: None, + evidence_key: format!( + "freshell.layout.v3.window-{i:04}-0000-4000-8000-000000000000" + ), + installed_revision: revision, + }, + ); + } + refs.push(target); + } + (document, refs) +} + +fn write_document_before_open(dir: &Path, document: &StoredDocument) -> usize { + let bytes = serialize_document(document).expect("serialize the synthetic document"); + std::fs::write(dir.join(DOCUMENT_FILE_NAME), &bytes).expect("write the synthetic document"); + bytes.len() +} + +// --------------------------------------------------------------------------- +// Lock-free reads +// --------------------------------------------------------------------------- + +/// RC9's deterministic red: another participant is mid-transaction (it +/// holds the document lock), and a read still answers at once from the +/// adopted view instead of polling the lock for a second and failing. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reads_do_not_take_the_document_lock() { + let dir = temp_dir(); + let reader = open(dir.path()); + let target = pending("h-lock-free"); + ensure(&reader, "h-lock-free").await; + rename_user(&reader, target.clone(), "Readable while locked") + .await + .expect("rename"); + + // The second participant's transaction takes the lock and holds it. + let writer = open(dir.path()); + let data_dir = writer.core.data_dir.clone(); + set_test_hooks(&data_dir, vec![TestHook::HoldLock(1_500)]); + let held = tokio::spawn({ + let writer = Arc::clone(&writer); + let target = target.clone(); + async move { rename_user(&writer, target, "Committed after the hold").await } + }); + wait_for_lock_held_sentinel(&data_dir).await; + + let started = Instant::now(); + let read = reader.get(vec![target.clone()]).await; + let elapsed = started.elapsed(); + let updates = read.expect("a read never fails because another participant holds the lock"); + assert_eq!(updates.len(), 1, "the adopted record answers"); + assert_eq!(updates[0].record.name, "Readable while locked"); + assert!( + elapsed < Duration::from_millis(50), + "the read answered without waiting on the lock (took {elapsed:?})" + ); + + held.await + .expect("the holder task completes") + .expect("the holder's rename commits after its hold"); + clear_test_hooks(&data_dir); + assert_eq!( + settled_name_of(&reader, &target).await, + "Committed after the hold", + "once the holder committed, the reader's peek adopts the new document" + ); +} + +/// The document changed AND the lock is busy (another participant is +/// mid-commit): the read does not queue behind the commit — after its short +/// wait it answers from the adopted view, and the next read after the lock +/// frees adopts the change. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_changed_document_under_a_busy_lock_is_read_from_the_view() { + let dir = temp_dir(); + let reader = open(dir.path()); + let target = pending("h-busy"); + ensure(&reader, "h-busy").await; + rename_user(&reader, target.clone(), "Adopted name") + .await + .expect("rename"); + let writer = open(dir.path()); + rename_user(&writer, target.clone(), "Committed elsewhere") + .await + .expect("the other participant commits"); + + let held = hold_document_lock(&writer.core.data_dir); + let busy_before = test_busy_read_count(&reader.core.data_dir); + let started = Instant::now(); + let read = reader.get(vec![target.clone()]).await; + let elapsed = started.elapsed(); + let updates = read.expect("a busy lock never fails a read"); + assert_eq!( + test_busy_read_count(&reader.core.data_dir), + busy_before + 1, + "the read is counted as answered under a busy lock" + ); + assert_eq!( + updates[0].record.name, "Adopted name", + "the read answered from the adopted view" + ); + assert!( + elapsed < Duration::from_millis(50), + "the read did not queue behind the commit (took {elapsed:?})" + ); + drop(held); + + assert_eq!( + settled_name_of(&reader, &target).await, + "Committed elsewhere", + "with the lock free the next read adopts the change" + ); +} + +/// The store's local turn (FIFO) serializes this store's own transactions, +/// and a read is not one of them: while one of this store's own +/// transactions holds the turn far longer than a read may wait, a read +/// whose peek sees a changed document does not queue for the turn — it +/// answers at once from the adopted view (`class = local_turn_busy`, before +/// it even tries the document lock), and the next read after the commit +/// answers the transaction's result. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_read_never_queues_behind_this_stores_own_transaction() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = pending("h-own-turn"); + ensure(&store, "h-own-turn").await; + rename_user(&store, target.clone(), "Adopted name") + .await + .expect("rename"); + // Another participant commits, so the read's peek sees a changed stamp + // and needs a full read. + let other = open(dir.path()); + rename_user(&other, target.clone(), "Committed elsewhere") + .await + .expect("the other participant commits"); + + let data_dir = store.core.data_dir.clone(); + set_test_hooks(&data_dir, vec![TestHook::HoldLock(1_500)]); + let slow = tokio::spawn({ + let store = Arc::clone(&store); + let target = target.clone(); + async move { rename_user(&store, target, "Own slow commit").await } + }); + wait_for_lock_held_sentinel(&data_dir).await; + + let busy_before = test_busy_read_count(&data_dir); + let (guard, events) = scoped_trace_capture(STORE_TARGET); + let started = Instant::now(); + let read = store.get(vec![target.clone()]).await; + let elapsed = started.elapsed(); + drop(guard); + let updates = read.expect("a read never fails behind this store's own transaction"); + assert_eq!( + updates[0].record.name, "Adopted name", + "the read answered from the adopted view" + ); + assert!( + elapsed < Duration::from_millis(50), + "the read did not queue for the local turn (took {elapsed:?})" + ); + assert_eq!( + test_busy_read_count(&data_dir), + busy_before + 1, + "the read is counted as answered while a commit was in flight" + ); + let events = events.snapshot(); + let served = named(&events, "session_names.read_served_from_view"); + assert_eq!(served.len(), 1, "one view answer: {events:#?}"); + assert_eq!( + served[0].fields.get("class").map(String::as_str), + Some("local_turn_busy"), + "the read saw this store's own turn taken, not a busy lock: {events:#?}" + ); + + slow.await + .expect("the slow transaction joins") + .expect("the slow rename commits after its hold"); + clear_test_hooks(&data_dir); + assert_eq!( + settled_name_of(&store, &target).await, + "Own slow commit", + "after the commit the next read answers its result" + ); +} + +/// A lock held only briefly does not make a read stale: the read waits the +/// hold out and adopts the change (another participant's momentary hold; +/// a forked child no longer keeps an inherited copy, `HeldSidecarLock`). +/// The test widens the read's wait so the 30 ms hold below is +/// deterministic. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_read_waits_out_a_brief_lock_hold_and_adopts_the_change() { + let dir = temp_dir(); + let reader = open(dir.path()); + let target = pending("h-brief"); + ensure(&reader, "h-brief").await; + rename_user(&reader, target.clone(), "Adopted name") + .await + .expect("rename"); + let writer = open(dir.path()); + rename_user(&writer, target.clone(), "Committed elsewhere") + .await + .expect("the other participant commits"); + let data_dir = reader.core.data_dir.clone(); + set_test_hooks(&data_dir, vec![TestHook::ReadLockWaitMs(5_000)]); + + let held = hold_document_lock(&data_dir); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + drop(held); + }); + let updates = reader + .get(vec![target.clone()]) + .await + .expect("the read succeeds"); + release.join().expect("the releasing thread completes"); + clear_test_hooks(&data_dir); + assert_eq!( + updates[0].record.name, "Committed elsewhere", + "the read waited out the brief hold and adopted the commit" + ); +} + +/// A second participant's commit is visible to the next read: the stamp +/// peek sees the replaced document, the read adopts it (publishing the +/// delta to this participant's subscribers) and answers the new name. +#[tokio::test] +async fn read_sees_cross_process_commit_via_stamp_peek() { + let dir = temp_dir(); + let a = open(dir.path()); + let target = pending("h-peek"); + ensure(&a, "h-peek").await; + rename_user(&a, target.clone(), "Named by A") + .await + .expect("A renames"); + assert_eq!(name_of(&a, &target).await, "Named by A"); + let mut subscriber = a.subscribe(); + + let b = open(dir.path()); + rename_user(&b, target.clone(), "Committed by B") + .await + .expect("B renames"); + + assert_eq!( + settled_name_of(&a, &target).await, + "Committed by B", + "A's read adopted B's commit" + ); + assert_eq!( + a.core.current_view().document.document_generation, + raw_document(dir.path()).document_generation, + "A's view is the document on disk" + ); + let published = subscriber + .try_recv() + .expect("the adoption published the changed record to A's subscribers"); + assert_eq!(published.record.name, "Committed by B"); +} + +/// An unchanged document costs a stat: neither the peek nor a read takes a +/// store transaction (no lock, no parse) while the stamp still matches. +#[tokio::test] +async fn unchanged_stamp_peek_takes_no_lock_and_parses_nothing() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = pending("h-quiet"); + ensure(&store, "h-quiet").await; + rename_user(&store, target.clone(), "Quiet name") + .await + .expect("rename"); + let data_dir = store.core.data_dir.clone(); + + let before = test_store_transaction_count(&data_dir); + for _ in 0..100 { + assert!( + !store + .adopt_external_if_changed() + .await + .expect("the peek succeeds"), + "nothing changed on disk" + ); + } + for _ in 0..100 { + assert_eq!(name_of(&store, &target).await, "Quiet name"); + } + assert_eq!( + test_store_transaction_count(&data_dir), + before, + "100 peeks and 100 reads of an unchanged document took no store transaction" + ); +} + +/// Where the stamp cannot see a change (an in-place rewrite that keeps the +/// length, inode and mtime — possible on coarse-timestamp filesystems), +/// staleness is bounded by the periodic full check: inside the window the +/// peek trusts the stamp, and once the window elapses the next peek reads +/// the whole document and adopts it. +#[tokio::test] +async fn stale_stamp_window_is_bounded_by_the_periodic_full_check() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = pending("h-window"); + ensure(&store, "h-window").await; + rename_user(&store, target.clone(), "Name AAAA") + .await + .expect("rename"); + let data_dir = store.core.data_dir.clone(); + set_test_hooks(&data_dir, vec![TestHook::FullCheckIntervalMs(60_000)]); + // A peek now starts the window from a confirmed full read. + assert!(!store.adopt_external_if_changed().await.expect("peek")); + + rewrite_in_place_keeping_the_stamp(&data_dir, &target, "Name BBBB"); + + // Inside the window the peek trusts the stamp. + assert!( + !store.adopt_external_if_changed().await.expect("peek"), + "inside the full-check window an equal stamp is trusted" + ); + + // The window elapses: the next peek is a full read and adopts it. + set_test_hooks(&data_dir, vec![TestHook::FullCheckIntervalMs(50)]); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + store.adopt_external_if_changed().await.expect("peek"), + "the periodic full check adopts what the stamp missed" + ); + assert_eq!(name_of(&store, &target).await, "Name BBBB"); + clear_test_hooks(&data_dir); +} + +/// A read's stat can catch this process's own commit halfway: it read the +/// view's stamp before the commit installed its view, and stat'ed the file +/// after the commit's rename, so it saw a changed stamp. Once the full read +/// holds the lock it asks again, finds that the file is the adopted one, and +/// reads nothing: no parse, and the lock is free again at once for the next +/// writer. +#[tokio::test] +async fn a_peek_that_raced_a_commit_settles_under_the_lock_without_a_parse() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = pending("h-race"); + ensure(&store, "h-race").await; + let stale = store.core.current_view().stamp; + rename_user(&store, target.clone(), "Own commit") + .await + .expect("rename"); + assert_ne!( + stat_stamp(&store.core.doc_path).ok(), + stale, + "a stat compared with the pre-commit view stamp sees a change" + ); + let data_dir = store.core.data_dir.clone(); + + let before = test_store_transaction_count(&data_dir); + for wait in [LockWait::Brief, LockWait::Bounded] { + // The rename released its lock explicitly (`HeldSidecarLock`), so no + // forked child can still hold it: the short wait finds it free. + let turn = free_turn(&store); + let adoption = full_read(&store.core, &turn, wait, FullReadReason::StampChanged) + .expect("the full read succeeds"); + drop(turn); + assert_eq!( + adoption, + Adoption::Current, + "under the lock the file is the adopted one ({wait:?})" + ); + } + assert_eq!( + test_store_transaction_count(&data_dir), + before, + "no store transaction ran: the document was not parsed again" + ); + assert_eq!(name_of(&store, &target).await, "Own commit"); +} + +/// The periodic check's peek saw an equal stamp, then another participant +/// committed the ordinary way (a new file, so a new stamp) before the full +/// read got the lock. The full read adopts the newer generation and reports +/// an ordinary adoption: the stamp did not miss this change. +#[tokio::test] +async fn an_ordinary_commit_after_a_periodic_peek_is_not_logged_as_a_missed_change() { + let dir = temp_dir(); + let a = open(dir.path()); + let target = pending("h-ordinary"); + ensure(&a, "h-ordinary").await; + rename_user(&a, target.clone(), "Named by A") + .await + .expect("A renames"); + let data_dir = a.core.data_dir.clone(); + set_test_hooks(&data_dir, vec![TestHook::FullCheckIntervalMs(0)]); + assert_eq!( + peek(&a.core), + Some(FullReadReason::PeriodicCheck), + "the peek saw an equal stamp after the full-check window" + ); + let b = open(dir.path()); + rename_user(&b, target.clone(), "Committed by B") + .await + .expect("B commits the ordinary way"); + + let (guard, events) = scoped_trace_capture(STORE_TARGET); + let turn = free_turn(&a); + let adoption = full_read( + &a.core, + &turn, + LockWait::Bounded, + FullReadReason::PeriodicCheck, + ) + .expect("the full read succeeds"); + drop(turn); + drop(guard); + clear_test_hooks(&data_dir); + + assert_eq!(adoption, Adoption::Adopted, "B's generation was adopted"); + let events = events.snapshot(); + assert!( + named(&events, "session_names.stamp_missed_change").is_empty(), + "the stamp changed with B's commit, so nothing was missed: {events:#?}" + ); + let adopted = named(&events, "session_names.adopted_external"); + assert_eq!(adopted.len(), 1, "one ordinary adoption: {events:#?}"); + assert_eq!(adopted[0].level, tracing::Level::DEBUG); + assert_eq!(name_of(&a, &target).await, "Committed by B"); +} + +/// The control for the case above: when the stamp really could not see the +/// change (the in-place seam), the periodic full check's adoption is +/// reported at INFO as a missed change. +#[tokio::test] +async fn a_change_the_stamp_could_not_see_is_logged_as_missed() { + let dir = temp_dir(); + let store = open(dir.path()); + let target = pending("h-missed"); + ensure(&store, "h-missed").await; + rename_user(&store, target.clone(), "Name AAAA") + .await + .expect("rename"); + let data_dir = store.core.data_dir.clone(); + let from_generation = store.core.current_view().document.document_generation; + rewrite_in_place_keeping_the_stamp(&data_dir, &target, "Name BBBB"); + set_test_hooks(&data_dir, vec![TestHook::FullCheckIntervalMs(0)]); + + let (guard, events) = scoped_trace_capture(STORE_TARGET); + let turn = free_turn(&store); + let adoption = full_read( + &store.core, + &turn, + LockWait::Bounded, + FullReadReason::PeriodicCheck, + ) + .expect("the full read succeeds"); + drop(turn); + drop(guard); + clear_test_hooks(&data_dir); + + assert_eq!(adoption, Adoption::Adopted); + let events = events.snapshot(); + let missed = named(&events, "session_names.stamp_missed_change"); + assert_eq!(missed.len(), 1, "one missed-change report: {events:#?}"); + assert_eq!(missed[0].level, tracing::Level::INFO); + assert_eq!( + missed[0].fields.get("from_generation").map(String::as_str), + Some(from_generation.to_string().as_str()) + ); + assert_eq!( + missed[0].fields.get("to_generation").map(String::as_str), + Some((from_generation + 1).to_string().as_str()) + ); + assert!( + named(&events, "session_names.adopted_external").is_empty(), + "the missed change is not also reported as an ordinary adoption: {events:#?}" + ); + assert_eq!(name_of(&store, &target).await, "Name BBBB"); +} + +/// RC9 under load: on the live-size document, 30 readers keep reading while +/// 30 renames commit one after another — every read and every rename +/// succeeds (the readers never compete for the lock). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_reads_during_write_burst_all_succeed() { + let dir = temp_dir(); + let (document, refs) = synthetic_load_document(); + let size = write_document_before_open(dir.path(), &document); + assert!( + size >= 4_500_000, + "the fixture is the live document's size (got {size} bytes)" + ); + let store = open(dir.path()); + let targets: Vec = refs.into_iter().take(30).collect(); + + let writing = Arc::new(AtomicBool::new(true)); + let writer = tokio::spawn({ + let store = Arc::clone(&store); + let targets = targets.clone(); + let writing = Arc::clone(&writing); + async move { + let mut results = Vec::new(); + for (i, target) in targets.iter().enumerate() { + results + .push(rename_user(&store, target.clone(), &format!("Burst rename {i}")).await); + } + writing.store(false, Ordering::SeqCst); + results + } + }); + let readers: Vec<_> = (0..30) + .map(|reader| { + let store = Arc::clone(&store); + let target = targets[reader].clone(); + let writing = Arc::clone(&writing); + tokio::spawn(async move { + let mut outcomes = Vec::new(); + loop { + outcomes.push( + store + .get(vec![target.clone()]) + .await + .map(|updates| updates.len()), + ); + if !writing.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + outcomes + }) + }) + .collect(); + + let renames = writer.await.expect("the writer task completes"); + let rename_errors: Vec<_> = renames.iter().filter_map(|r| r.as_ref().err()).collect(); + assert!( + rename_errors.is_empty(), + "every rename in the burst commits: {rename_errors:?}" + ); + let mut reads = 0usize; + for reader in readers { + for outcome in reader.await.expect("the reader task completes") { + reads += 1; + match outcome { + Ok(found) => assert_eq!(found, 1, "every read resolves its record"), + Err(error) => panic!("a read failed during the write burst: {error:?}"), + } + } + } + assert!( + reads >= 30, + "every reader read at least once ({reads} reads)" + ); + for (i, target) in targets.iter().enumerate() { + assert_eq!(name_of(&store, target).await, format!("Burst rename {i}")); + } +} diff --git a/crates/freshell-server/src/session_name_sweep_batch.rs b/crates/freshell-server/src/session_name_sweep_batch.rs new file mode 100644 index 000000000..18e88b76a --- /dev/null +++ b/crates/freshell-server/src/session_name_sweep_batch.rs @@ -0,0 +1,330 @@ +//! Batched sweep maintenance (name-reliability plan, Task 4; LB-B1). +//! +//! Every store transaction re-reads and rewrites the whole name document +//! under a lock that waiters poll for at most one second. Right after the +//! self-heal rule deploys, about 190 junk names heal in the first sweep +//! passes; as one transaction each they could starve user renames and binds. +//! So the sweep collects its MAINTENANCE work — self-heal replacements found +//! by the activity or hydration pre-checks, and the native baselines the +//! store staged in memory — and applies it in ONE transaction per pass that +//! covers at most [`SWEEP_MAINTENANCE_BATCH`] records. Work beyond that waits +//! for the next pass (at least 5 s later), so renames and binds take the lock +//! between passes. Inserts of new records and activities that arm +//! generation keep their own per-row transactions: they are new work, not +//! maintenance. +//! +//! The batch re-plans every item on the freshly read document with the same +//! plan functions the per-row transactions use, so an item whose record +//! moved since the pre-check (a user rename, a foreign process) applies +//! exactly what the current document allows. +//! +//! Once the batch commits, one INFO `session_names.sweep_maintenance` line +//! summarizes it: the records healed, the baselines persisted, and the rows +//! the pass found but could not fit into the batch (`deferred`, the heals +//! still waiting). A batch whose commit fails writes no summary; the +//! caller's `session_names.operation_failed` WARN reports it. + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Mutex; + +use freshell_freshagent::naming::{NameActivity, NameError}; + +use super::session_name_events::{NameChangeCause, NameChangeEvent}; +use super::session_name_policy::{plan_hydrate, HydratePlan}; +use super::{Decision, PublishSpec, StoredDocument, TxnMeta}; +use crate::session_name_generation::IndexedNameInput; + +/// One maintenance transaction covers at most this many records. +pub(crate) const SWEEP_MAINTENANCE_BATCH: usize = 25; + +/// What a sweep pre-check says about one row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SweepWork { + /// The view proves the transaction would be a no-op Read. + None, + /// New work (an insert, an arm, a first raise): its own transaction now. + Immediate, + /// A self-heal replacement only: it rides the pass's maintenance batch. + Maintenance, +} + +/// One record's maintenance work, as the sweep row reported it. Both parts +/// are optional; the batch applies the hydration heal first, then the +/// activity (re-planned on the document the heal left). +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SweepMaintenanceItem { + pub hydrate: Option, + pub activity: Option, +} + +/// What one maintenance transaction did. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct SweepMaintenanceReport { + /// Records whose saved name the batch replaced. + pub healed: usize, + /// Staged native baselines the batch persisted. + pub baselines: usize, + /// Rows beyond the batch size, left for a later pass. + pub deferred: usize, +} + +impl SweepMaintenanceReport { + /// The summary line of a batch that committed (see the module docs). + pub(super) fn log_committed(&self, data_dir: &Path) { + if self.healed + self.baselines > 0 || self.deferred > 0 { + tracing::info!( + target: "freshell_server::session_names", + op = "sweep_maintenance", + healed = self.healed, + baselines = self.baselines, + deferred = self.deferred, + data_dir = %data_dir.display(), + "session_names.sweep_maintenance" + ); + } + } +} + +/// One staged baseline: the title, and the persisted baseline the +/// observation saw when it staged it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct StagedBaseline { + title: String, + over: Option, +} + +/// The native baselines observed but not yet persisted: an observation +/// whose only effect is a new baseline never makes its own commit. Keyed by +/// record key; decisions read a staged value as if it were persisted. Lost +/// on restart, which only means the next observation records it again. +/// +/// A staged value is this process's newest observation, but only relative +/// to the persisted baseline it was staged over: once another process +/// persists a different baseline, that one is newer, and the staged value +/// is dropped — never read, never written back over it (writing it back +/// would make the provider's next re-report of the newer title look +/// changed and undo a rename made since). +#[derive(Debug, Default)] +pub(super) struct StagedNativeBaselines(Mutex>); + +/// The persisted baseline of `key` in `document`. +fn persisted<'a>(document: &'a StoredDocument, key: &str) -> Option<&'a str> { + document + .native_write + .get(key) + .and_then(|state| state.last_native_title.as_deref()) +} + +impl StagedNativeBaselines { + fn map(&self) -> std::sync::MutexGuard<'_, BTreeMap> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// The staged title, whatever the document holds (a test seam). + #[cfg(test)] + pub(super) fn title(&self, key: &str) -> Option { + self.map().get(key).map(|staged| staged.title.clone()) + } + + /// The baseline decisions use for `key`: the staged title while the + /// document still holds the baseline it was staged over, else the + /// persisted one. + pub(super) fn effective(&self, document: &StoredDocument, key: &str) -> Option { + let persisted = persisted(document, key); + self.map() + .get(key) + .filter(|staged| staged.over.as_deref() == persisted) + .map(|staged| staged.title.clone()) + .or_else(|| persisted.map(str::to_string)) + } + + /// Stage `title` over the document's persisted baseline for `key`. + pub(super) fn stage(&self, document: &StoredDocument, key: &str, title: String) { + let over = persisted(document, key).map(str::to_string); + let mut map = self.map(); + if over.as_deref() == Some(title.as_str()) { + // The persisted baseline already is this title. + map.remove(key); + } else { + map.insert(key.to_string(), StagedBaseline { title, over }); + } + } + + pub(super) fn remove(&self, key: &str) { + self.map().remove(key); + } + + pub(super) fn is_empty(&self) -> bool { + self.map().is_empty() + } + + fn first(&self, count: usize) -> Vec<(String, StagedBaseline)> { + self.map() + .iter() + .take(count) + .map(|(key, staged)| (key.clone(), staged.clone())) + .collect() + } + + /// Drop every staged baseline the document already carries, every one + /// another participant superseded (the persisted baseline is no longer + /// the one it was staged over), and every one whose record no longer + /// exists (a bound pending handle). Called with each freshly committed + /// or adopted document. + pub(super) fn prune_persisted(&self, document: &StoredDocument) { + let mut map = self.map(); + if map.is_empty() { + return; + } + map.retain(|key, staged| { + let persisted = persisted(document, key); + document.records.contains_key(key) + && persisted == staged.over.as_deref() + && persisted != Some(staged.title.as_str()) + }); + } +} + +/// The batch transaction. Applies the queued items (at most +/// [`SWEEP_MAINTENANCE_BATCH`]), then fills the remaining room with staged +/// baselines; publishes every record whose name changed. A Read when +/// nothing changed. +pub(super) fn sweep_maintenance_decision( + document: &mut StoredDocument, + meta: &TxnMeta, + batch: SweepMaintenanceQueue, +) -> Result, NameError> { + let SweepMaintenanceQueue { items, deferred } = batch; + let mut report = SweepMaintenanceReport { + deferred, + ..SweepMaintenanceReport::default() + }; + let mut publish: Vec = Vec::new(); + let mut wrote = false; + let mut used = 0usize; + for item in items { + used += 1; + let mut healed: Option<(String, NameChangeEvent)> = None; + if let Some(input) = item.hydrate { + // Only the heal half of hydration is maintenance; an insert + // (the record vanished?) is left to the per-row path. + if let Some(HydratePlan::Heal { name, source }) = plan_hydrate(document, &input) { + let key = super::indexed_key(&input); + let change = NameChangeEvent::new( + NameChangeCause::Hydrate, + document.record_at(&key), + input.cwd.as_deref(), + ); + super::replace_automatic(document, &key, name, source)?; + healed = Some((key, change)); + } + } + if let Some(activity) = item.activity { + match super::apply_activity(document, meta, activity) { + Ok(applied) => { + wrote |= applied.bookkeeping; + if let Some(change) = applied.change { + // The same record healed by hydration first: log one + // change from where the hydration started. + let earlier = healed + .take() + .and_then(|(key, earlier)| (key == applied.key).then_some(earlier)); + healed = Some((applied.key, change.after(earlier))); + } + } + // A row whose record is gone has nothing to heal. + Err(NameError::NotFound(_)) => {} + Err(error) => return Err(error), + } + } + if let Some((key, change)) = healed { + report.healed += 1; + wrote = true; + // One publish (and one logged change) per record: a record an + // earlier item already changed keeps that item's starting point. + match publish.iter_mut().find(|spec| spec.key == key) { + Some(spec) => spec.change = Some(change.after(spec.change.take())), + None => publish.push(PublishSpec::changed(key, change)), + } + } + } + let room = SWEEP_MAINTENANCE_BATCH.saturating_sub(used); + for (key, staged) in meta.staged_baselines.first(room) { + if !document.records.contains_key(&key) { + meta.staged_baselines.remove(&key); + continue; + } + // Another participant persisted a different baseline since this + // one was staged: theirs is newer, so the staged one is dropped. + if persisted(document, &key) != staged.over.as_deref() { + tracing::debug!( + target: "freshell_server::session_names", + op = "sweep_maintenance", + name_ref = %key, + "session_names.native_baseline_superseded" + ); + meta.staged_baselines.remove(&key); + continue; + } + let entry = document.native_write.entry(key).or_default(); + if entry.last_native_title.as_deref() != Some(staged.title.as_str()) { + entry.last_native_title = Some(staged.title); + wrote = true; + } + report.baselines += 1; + } + if wrote { + Ok(Decision::Write { + value: report, + publish, + }) + } else { + Ok(Decision::Read(report)) + } +} + +/// The sweep pass's collector: at most one batch of maintenance items per +/// pass (the rest is found again by the next pass's pre-checks, and counted +/// here as `deferred`). +#[derive(Debug, Default)] +pub(crate) struct SweepMaintenanceQueue { + items: Vec, + deferred: usize, +} + +impl SweepMaintenanceQueue { + /// Queue one record's maintenance work; `false` (and counted as + /// deferred) when the batch is full. + pub(crate) fn push(&mut self, item: SweepMaintenanceItem) -> bool { + if self.items.len() >= SWEEP_MAINTENANCE_BATCH { + self.deferred += 1; + return false; + } + self.items.push(item); + true + } + + /// No item queued (nothing is deferred either: only a full batch + /// defers). + pub(crate) fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +/// Items queued in order: the first batch-full, the rest deferred. +impl From> for SweepMaintenanceQueue { + fn from(items: Vec) -> Self { + let mut queue = Self::default(); + for item in items { + queue.push(item); + } + queue + } +} + +#[cfg(test)] +#[path = "session_name_sweep_batch_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_sweep_batch_tests.rs b/crates/freshell-server/src/session_name_sweep_batch_tests.rs new file mode 100644 index 000000000..e9d810d9e --- /dev/null +++ b/crates/freshell-server/src/session_name_sweep_batch_tests.rs @@ -0,0 +1,604 @@ +//! Name-reliability Task 4 (LB-B1): sweep maintenance — self-heal +//! replacements and staged native baselines — lands in ONE store transaction +//! per sweep pass covering at most [`SWEEP_MAINTENANCE_BATCH`] records, so a +//! post-deploy burst of heals never starves renames and binds. + +use std::path::Path; +use std::sync::Arc; + +use freshell_freshagent::naming::{NameActivityReason, NativeNameOrigin}; +use freshell_protocol::session_names::{NameIntent, NameSource, NamedProvider}; + +use super::super::*; +use super::{SweepMaintenanceItem, SWEEP_MAINTENANCE_BATCH}; + +fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") +} + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open store") +} + +fn session(id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: id.to_string(), + } +} + +fn indexed( + id: &str, + provider_title: Option<&str>, + first_message: Option<&str>, +) -> IndexedNameInput { + IndexedNameInput { + provider: NamedProvider::Claude, + session_id: id.to_string(), + cwd: Some("/w/proj".to_string()), + first_user_message: first_message.map(str::to_string), + provider_title: provider_title.map(str::to_string), + } +} + +fn heal_message(i: usize) -> String { + format!("Fix the importer number {i}") +} + +/// `count` claude sessions whose saved name is a junk provider-level +/// "Tab N" and whose first message is already absorbed at rest — exactly +/// the post-deploy shape: the only work each one has is the heal. +async fn seed_junk(store: &Arc, count: usize) { + for i in 0..count { + store + .hydrate_indexed( + indexed( + &format!("s-junk-{i}"), + Some(&format!("Tab {i}")), + Some(&heal_message(i)), + ), + true, + ) + .await + .expect("seed junk record"); + } +} + +async fn name_of(store: &Arc, id: &str) -> Option { + store + .get(vec![session(id)]) + .await + .expect("get") + .into_iter() + .next() + .map(|update| update.record) +} + +async fn healed_count(store: &Arc, count: usize) -> usize { + let mut healed = 0; + for i in 0..count { + let record = name_of(store, &format!("s-junk-{i}")) + .await + .expect("record"); + if record.name == heal_message(i) && record.source == NameSource::FirstMessage { + healed += 1; + } + } + healed +} + +fn sweep_rows(count: usize) -> Vec { + (0..count) + .map(|i| crate::auto_title_sweep::SweepSession { + provider: "claude".into(), + session_id: format!("s-junk-{i}"), + cwd: Some("/w/proj".into()), + title: None, + first_user_message: Some(heal_message(i)), + title_source: None, + git_branch: None, + last_activity_at: 0, + }) + .collect() +} + +fn sweep_state( + home: &Path, + names: Arc, +) -> crate::auto_title_sweep::AutoTitleSweepState { + let (tx, _rx) = tokio::sync::broadcast::channel::(256); + crate::auto_title_sweep::AutoTitleSweepState { + settings: crate::settings_store::SettingsStore::load(Some(home), vec![]), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + registry: freshell_terminal::TerminalRegistry::new(), + broadcast_tx: Arc::new(tx), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + auth: crate::ai_title::GeminiSessionNameAuth::direct_for_test( + crate::ai_title::AiKeyCell::init(None, None), + ), + gemini: Arc::new(NoGemini), + pending_ai_titles: Default::default(), + terminal_meta: Default::default(), + git_meta_cache: Default::default(), + names: Some(names), + metadata: crate::session_metadata::SessionMetadataStore::new(home), + index: None, + // A later (non-boot) pass: every indexed session feeds activity. + index_hydrated: Arc::new(std::sync::atomic::AtomicBool::new(true)), + first_message_lookups: Default::default(), + } +} + +struct NoGemini; +impl crate::ai_title::GeminiTransport for NoGemini { + fn generate_content( + &self, + _p: String, + _m: u32, + ) -> crate::ai_title::BoxFuture> { + Box::pin(async { Err("no live Gemini in tests".to_string()) }) + } +} + +/// 60 junk names heal in exactly three sweep passes with ONE maintenance +/// commit each (≤ ceil(60/25) = 3 in total), a user rename issued while +/// each batch runs succeeds, a settled fourth pass transacts zero times, +/// and a row whose work is an insert (and then an arm) still transacts on +/// its own. +#[tokio::test] +async fn sweep_maintenance_is_batched_and_leaves_room_for_renames() { + let home = temp_dir(); + let data_dir = home.path().join(".freshell"); + let store = open(&data_dir); + const JUNK: usize = 60; + seed_junk(&store, JUNK).await; + store + .hydrate_indexed(indexed("s-user", None, None), false) + .await + .expect("seed the rename target"); + let sweep = sweep_state(home.path(), store.clone()); + let rows = sweep_rows(JUNK); + + let mut pass_transactions = Vec::new(); + for pass in 0..3 { + let before = test_store_transaction_count(&data_dir); + let rename = store.rename(RenameNameInput { + target: session("s-user"), + name: format!("Renamed during pass {pass}"), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }); + let (_, renamed) = tokio::join!( + crate::auto_title_sweep::run_auto_title_pass(&sweep, &rows), + rename + ); + let renamed = renamed.unwrap_or_else(|error| { + panic!("pass {pass}: a rename during the batch must succeed: {error}") + }); + assert_eq!(renamed.record.name, format!("Renamed during pass {pass}")); + let after = test_store_transaction_count(&data_dir); + // The rename is one transaction; the pass made the rest. + pass_transactions.push(after - before - 1); + let expected_healed = ((pass + 1) * SWEEP_MAINTENANCE_BATCH).min(JUNK); + assert_eq!( + healed_count(&store, JUNK).await, + expected_healed, + "pass {pass} healed at most one batch" + ); + } + assert_eq!( + pass_transactions, + vec![1, 1, 1], + "each pass made exactly one maintenance commit" + ); + + // Settled: the fourth pass transacts zero times. + let before = test_store_transaction_count(&data_dir); + crate::auto_title_sweep::run_auto_title_pass(&sweep, &rows).await; + assert_eq!(test_store_transaction_count(&data_dir), before, "settled"); + + // A genuinely new session is an insert (and its first message an arm): + // per-row transactions, never deferred to a batch. + let mut with_new = sweep_rows(JUNK); + with_new.push(crate::auto_title_sweep::SweepSession { + provider: "claude".into(), + session_id: "s-new".into(), + cwd: Some("/w/proj".into()), + title: None, + first_user_message: Some("A brand new session".into()), + title_source: None, + git_branch: None, + last_activity_at: 0, + }); + let before = test_store_transaction_count(&data_dir); + crate::auto_title_sweep::run_auto_title_pass(&sweep, &with_new).await; + assert_eq!( + test_store_transaction_count(&data_dir) - before, + 2, + "the insert and the arm each transact on their own" + ); + let inserted = name_of(&store, "s-new") + .await + .expect("the new record installed"); + assert_eq!(inserted.name, "A brand new session"); + assert!( + store + .generation_work_snapshot() + .iter() + .any(|item| item.target == session("s-new")), + "the new session's message armed generation" + ); +} + +/// The store caps one batch at the batch size: the rest waits. +#[tokio::test] +async fn maintenance_batch_caps_at_the_batch_size() { + let dir = temp_dir(); + let store = open(dir.path()); + seed_junk(&store, 30).await; + let items: Vec = (0..30) + .map(|i| SweepMaintenanceItem { + hydrate: Some(indexed( + &format!("s-junk-{i}"), + None, + Some(&heal_message(i)), + )), + activity: None, + }) + .collect(); + let before = test_store_transaction_count(dir.path()); + let report = store.sweep_maintenance(items).await.expect("batch"); + assert_eq!(test_store_transaction_count(dir.path()) - before, 1); + assert_eq!(report.healed, SWEEP_MAINTENANCE_BATCH); + assert_eq!(report.deferred, 30 - SWEEP_MAINTENANCE_BATCH); + assert_eq!(healed_count(&store, 30).await, SWEEP_MAINTENANCE_BATCH); +} + +/// Staged native baselines fill whatever room the heals leave, and an +/// empty batch with nothing staged is not a transaction at all. +#[tokio::test] +async fn staged_baselines_fill_the_rest_of_the_batch() { + let dir = temp_dir(); + let store = open(dir.path()); + // Three real provider-level names observed for the first time: each + // observation stages its baseline without a write. + for id in ["s-base-a", "s-base-b", "s-base-c"] { + store + .hydrate_indexed(indexed(id, Some("Billing bug"), None), false) + .await + .expect("seed"); + let generation = store.core.current_view().document.document_generation; + store + .observe_native(NativeNameObservation { + target: session(id), + title: format!("Provider title for {id}"), + origin: NativeNameOrigin::Snapshot, + location_revision: 0, + event_id: None, + }) + .await + .expect("observe"); + assert_eq!( + store.core.current_view().document.document_generation, + generation, + "{id}: staging is not a write" + ); + } + assert!(store.has_staged_native_baselines()); + + // 24 heals + 3 baselines: one batch takes the 24 heals and ONE baseline. + seed_junk(&store, 24).await; + let items: Vec = (0..24) + .map(|i| SweepMaintenanceItem { + hydrate: Some(indexed( + &format!("s-junk-{i}"), + None, + Some(&heal_message(i)), + )), + activity: None, + }) + .collect(); + let report = store.sweep_maintenance(items).await.expect("batch 1"); + assert_eq!(report.healed, 24); + assert_eq!(report.baselines, 1); + assert!(store.has_staged_native_baselines(), "two baselines wait"); + + let report = store.sweep_maintenance(Vec::new()).await.expect("batch 2"); + assert_eq!(report.baselines, 2); + assert!(!store.has_staged_native_baselines()); + let document = store.core.current_view().document.clone(); + for id in ["s-base-a", "s-base-b", "s-base-c"] { + assert_eq!( + document + .native_write + .get(&name_ref_key(&session(id))) + .and_then(|state| state.last_native_title.clone()), + Some(format!("Provider title for {id}")), + "{id}: persisted" + ); + } + + let before = test_store_transaction_count(dir.path()); + let report = store + .sweep_maintenance(Vec::new()) + .await + .expect("empty batch"); + assert_eq!(report.healed + report.baselines, 0); + assert_eq!( + test_store_transaction_count(dir.path()), + before, + "nothing to do is no transaction" + ); +} + +/// An activity heal queued for the batch is re-planned on the freshly read +/// document: if the record moved meanwhile (a user rename), the batch +/// applies nothing to it. +#[tokio::test] +async fn a_batch_item_is_re_planned_on_the_fresh_document() { + let dir = temp_dir(); + let store = open(dir.path()); + seed_junk(&store, 1).await; + let target = session("s-junk-0"); + store + .rename(RenameNameInput { + target: target.clone(), + name: "Chosen by a person".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .expect("user rename"); + let report = store + .sweep_maintenance(vec![SweepMaintenanceItem { + hydrate: None, + activity: Some(NameActivity { + target: target.clone(), + mode: "claude".into(), + event_id: "e".into(), + reason: NameActivityReason::IndexUserMessage, + first_user_message: Some(heal_message(0)), + cwd: Some("/w/proj".into()), + }), + }]) + .await + .expect("batch"); + assert_eq!(report.healed, 0); + let record = name_of(&store, "s-junk-0").await.expect("record"); + assert_eq!(record.name, "Chosen by a person"); + assert_eq!(record.source, NameSource::Manual); +} + +async fn observe_title(store: &Arc, id: &str, title: &str) -> SessionNameUpdate { + store + .observe_native(NativeNameObservation { + target: session(id), + title: title.to_string(), + origin: NativeNameOrigin::Snapshot, + location_revision: 0, + event_id: None, + }) + .await + .expect("observe") +} + +fn persisted_baseline(store: &Arc, id: &str) -> Option { + store + .core + .current_view() + .document + .native_write + .get(&name_ref_key(&session(id))) + .and_then(|state| state.last_native_title.clone()) +} + +/// Two current-build processes share one data dir. Process A staged a +/// baseline it saw over an empty one; process B then persisted a NEWER +/// baseline, and an automation rename landed after it. Returns both +/// stores, A's staged baseline still waiting. +async fn superseded_staging(dir: &Path, id: &str) -> (Arc, Arc) { + let a = open(dir); + a.hydrate_indexed(indexed(id, Some("Billing bug"), None), false) + .await + .expect("seed"); + observe_title(&a, id, "Older provider title").await; + assert_eq!( + a.staged_native_baseline(&session(id)).as_deref(), + Some("Older provider title"), + "A staged its first observation" + ); + let b = open(dir); + observe_title(&b, id, "Newer provider title").await; + b.sweep_maintenance(Vec::new()) + .await + .expect("B persists its baseline"); + assert_eq!( + persisted_baseline(&b, id).as_deref(), + Some("Newer provider title") + ); + let renamed = b + .rename(RenameNameInput { + target: session(id), + name: "Chosen by automation".into(), + intent: NameIntent::Automatic, + if_revision: None, + cwd: Some("/w/proj".into()), + }) + .await + .expect("automation rename"); + assert_eq!(renamed.record.name, "Chosen by automation"); + (a, b) +} + +/// A's batch never writes its older staged baseline over B's newer +/// persisted one: the provider's next re-report of B's title stays an +/// echo and never undoes the automation rename. +#[tokio::test] +async fn a_staged_baseline_never_overwrites_a_newer_persisted_one() { + let dir = temp_dir(); + let id = "s-two-writers-batch"; + let (a, b) = superseded_staging(dir.path(), id).await; + + let report = a.sweep_maintenance(Vec::new()).await.expect("A's batch"); + assert_eq!( + persisted_baseline(&a, id).as_deref(), + Some("Newer provider title"), + "the newer persisted baseline survives A's batch" + ); + assert_eq!(report.baselines, 0, "the superseded baseline is dropped"); + assert!( + !a.has_staged_native_baselines(), + "A no longer holds the superseded baseline" + ); + + let echo = observe_title(&b, id, "Newer provider title").await; + assert!(!echo.changed, "the provider's re-report is an echo"); + assert_eq!( + name_of(&a, id).await.expect("record").name, + "Chosen by automation", + "the automation rename stands" + ); +} + +/// Before any batch runs, A's own decisions never read its superseded +/// staged baseline either: the provider's re-report, observed through A, +/// is compared with B's newer persisted baseline and stays an echo. +#[tokio::test] +async fn a_superseded_staged_baseline_is_never_read_as_the_baseline() { + let dir = temp_dir(); + let id = "s-two-writers-observe"; + let (a, _b) = superseded_staging(dir.path(), id).await; + + let echo = observe_title(&a, id, "Newer provider title").await; + assert_eq!( + echo.record.name, "Chosen by automation", + "a re-report of the newer persisted baseline never undoes the rename" + ); + assert!(!echo.changed); + assert!( + !a.has_staged_native_baselines(), + "A dropped the superseded baseline" + ); +} + +// --------------------------------------------------------------------------- +// The pass summary (`session_names.sweep_maintenance`) +// --------------------------------------------------------------------------- + +const SUMMARY: &str = "session_names.sweep_maintenance"; + +/// The position after every event captured so far. +fn mark() -> usize { + crate::test_trace_capture::captured_trace_events() + .snapshot() + .len() +} + +/// Every pass summary captured since `since`, as `(data_dir, healed, +/// baselines, deferred)` (`data_dir` is empty when the line has none). +fn summaries_since(since: usize) -> Vec<(String, usize, usize, usize)> { + let count = |fields: &std::collections::BTreeMap, name: &str| { + fields + .get(name) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("a summary carries {name}")) + }; + crate::test_trace_capture::captured_trace_events() + .snapshot() + .into_iter() + .skip(since) + .filter(|event| event.message == SUMMARY) + .map(|event| { + assert_eq!(event.level, tracing::Level::INFO, "summaries log at INFO"); + ( + event.fields.get("data_dir").cloned().unwrap_or_default(), + count(&event.fields, "healed"), + count(&event.fields, "baselines"), + count(&event.fields, "deferred"), + ) + }) + .collect() +} + +/// The summaries THIS store wrote since `since`, as `(healed, baselines, +/// deferred)`; other tests' stores write their own. +fn store_summaries(since: usize, data_dir: &Path) -> Vec<(usize, usize, usize)> { + let data_dir = std::fs::canonicalize(data_dir) + .expect("the data dir exists") + .display() + .to_string(); + summaries_since(since) + .into_iter() + .filter(|(dir, ..)| *dir == data_dir) + .map(|(_, healed, baselines, deferred)| (healed, baselines, deferred)) + .collect() +} + +/// A pass that finds more heals than one batch holds reports how many are +/// still waiting: 60 junk names heal over three passes, and the summaries +/// count 35, then 10, then nothing left. +#[tokio::test] +async fn the_pass_summary_counts_the_heals_left_for_later_passes() { + let home = temp_dir(); + let data_dir = home.path().join(".freshell"); + let store = open(&data_dir); + const JUNK: usize = 60; + seed_junk(&store, JUNK).await; + let sweep = sweep_state(home.path(), store.clone()); + let rows = sweep_rows(JUNK); + let since = mark(); + + for _ in 0..3 { + crate::auto_title_sweep::run_auto_title_pass(&sweep, &rows).await; + } + + assert_eq!(healed_count(&store, JUNK).await, JUNK); + assert_eq!( + store_summaries(since, &data_dir), + vec![(25, 0, 35), (25, 0, 10), (10, 0, 0)], + "every summary seen: {:?}", + summaries_since(since) + ); +} + +/// The summary describes a committed batch: a batch whose commit fails +/// writes none (nothing was healed), and its retry writes one. +#[tokio::test] +async fn the_batch_summary_is_written_only_after_the_commit() { + let dir = temp_dir(); + let store = open(dir.path()); + seed_junk(&store, 3).await; + let items = || -> Vec { + (0..3) + .map(|i| SweepMaintenanceItem { + hydrate: Some(indexed( + &format!("s-junk-{i}"), + None, + Some(&heal_message(i)), + )), + activity: None, + }) + .collect() + }; + set_test_hooks(dir.path(), vec![TestHook::PreReplace]); + let since = mark(); + + let failed = store.sweep_maintenance(items()).await; + + assert!( + matches!(failed, Err(NameError::Persistence(_))), + "{failed:?}" + ); + assert_eq!(healed_count(&store, 3).await, 0, "nothing was saved"); + clear_test_hooks(dir.path()); + let report = store.sweep_maintenance(items()).await.expect("the retry"); + assert_eq!(report.healed, 3); + assert_eq!( + store_summaries(since, dir.path()), + vec![(3, 0, 0)], + "one summary, for the committed retry; every summary seen: {:?}", + summaries_since(since) + ); +} diff --git a/crates/freshell-server/src/session_name_tick.rs b/crates/freshell-server/src/session_name_tick.rs new file mode 100644 index 000000000..5e0a8b576 --- /dev/null +++ b/crates/freshell-server/src/session_name_tick.rs @@ -0,0 +1,81 @@ +//! The 2 s naming tick: adopt another same-home process's committed name +//! document, then bind still-pending Claude handles whose transcripts now +//! exist. +//! +//! (1) Adoption is a stat of the document against the adopted stamp; the +//! strict locked full read runs only when the stamp changed or its 30 s +//! full-check window elapsed (name-reliability Task 11, +//! `session_name_store_view`). (2) Claude binds go through the transcript +//! locator (the signal-first and zero-turn create lanes bind as soon as the +//! transcript exists). Codex and OpenCode own their verified binds on their +//! runtime edges (the rollout walk, the DB row); the tick never fabricates +//! one. + +use std::sync::Arc; +use std::time::Duration; + +use freshell_terminal::TerminalRegistry; +use freshell_ws::identity::TerminalIdentityRegistry; + +use crate::session_names::SessionNames; + +/// How often the tick runs in the server. +pub(crate) const NAMING_TICK_PERIOD: Duration = Duration::from_secs(2); + +/// Run the tick every `period` until the task is dropped. A tick that +/// overruns skips the missed ticks instead of bunching them up. +pub(crate) async fn run_naming_tick( + names: Arc, + identity: TerminalIdentityRegistry, + registry: TerminalRegistry, + period: Duration, +) { + let mut tick = tokio::time::interval(period); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + adopt_external_commits(&names).await; + bind_pending_claude_transcripts(&identity, ®istry).await; + } +} + +/// Adopt a cooperating process's newer generation when the cheap stamp +/// peek says the document changed. A failure is logged and retried on the +/// next tick. +async fn adopt_external_commits(names: &SessionNames) { + if let Err(error) = names.adopt_external_if_changed().await { + tracing::warn!( + target: "freshell_server::session_names", + op = "adopt_external_if_changed", + name_ref = "-", + revision = 0, + class = %error.code(), + "session_names.operation_failed: {error}" + ); + } +} + +/// One bounded locate + bind per still-pending Claude row. A miss or failure +/// keeps the row pending for the next tick's retry (the helper logs its own +/// failure classes). +async fn bind_pending_claude_transcripts( + identity: &TerminalIdentityRegistry, + registry: &TerminalRegistry, +) { + for (terminal_id, provider, session_id, _handle) in identity.pending_naming_binds() { + if provider != "claude" { + continue; + } + let _ = freshell_ws::identity::bind_pending_claude_transcript( + identity, + registry, + &terminal_id, + &session_id, + ) + .await; + } +} + +#[cfg(test)] +#[path = "session_name_tick_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/session_name_tick_tests.rs b/crates/freshell-server/src/session_name_tick_tests.rs new file mode 100644 index 000000000..61777ec49 --- /dev/null +++ b/crates/freshell-server/src/session_name_tick_tests.rs @@ -0,0 +1,81 @@ +//! The 2 s naming tick, driven for real on a temp naming home: while the +//! document is unchanged it costs a stat (no store transaction), and a +//! second process's commit reaches this process's subscribers through it. + +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use freshell_freshagent::naming::{PendingNameInput, RenameNameInput, SessionNaming}; +use freshell_protocol::session_names::{NameIntent, NamedProvider, SessionNameRef}; + +use super::*; +use crate::session_names::test_store_transaction_count; + +fn open(dir: &Path) -> Arc { + SessionNames::open(dir.to_path_buf()).expect("open store") +} + +async fn rename(store: &Arc, target: &SessionNameRef, name: &str) { + store + .rename(RenameNameInput { + target: target.clone(), + name: name.to_string(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .expect("rename"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_tick_reads_nothing_while_unchanged_and_adopts_another_process_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = open(dir.path()); + let target = SessionNameRef::Pending { + id: "h-tick".to_string(), + }; + store + .ensure_pending(PendingNameInput { + handle: "h-tick".to_string(), + provider: NamedProvider::Claude, + cwd: Some("/w/tick".to_string()), + }) + .await + .expect("ensure pending"); + rename(&store, &target, "Named here").await; + let mut updates = store.subscribe(); + + let before = test_store_transaction_count(dir.path()); + let tick = tokio::spawn(run_naming_tick( + Arc::clone(&store), + TerminalIdentityRegistry::new(), + TerminalRegistry::new(), + Duration::from_millis(20), + )); + // About a dozen ticks over an unchanged document. + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!( + test_store_transaction_count(dir.path()), + before, + "ticks over an unchanged document took no store transaction (no lock, no parse)" + ); + + // Another process commits; nothing in this process reads the name, so + // only the tick can carry the commit to this process's subscribers. + let other = open(dir.path()); + rename(&other, &target, "Named elsewhere").await; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let update = tokio::time::timeout(remaining, updates.recv()) + .await + .expect("the tick adopted the other process's commit within 5 s") + .expect("the update channel stays open"); + if update.record.name == "Named elsewhere" { + break; + } + } + tick.abort(); +} diff --git a/crates/freshell-server/src/session_names.rs b/crates/freshell-server/src/session_names.rs index 86bde496f..eeff1ff4e 100644 --- a/crates/freshell-server/src/session_names.rs +++ b/crates/freshell-server/src/session_names.rs @@ -41,6 +41,20 @@ //! Neither settings dirty-merging nor a process mutex is sufficient — two //! real processes serialize through the file lock alone. //! +//! Name reads take no lock (name-reliability Task 11, +//! `session_name_store_view`): they answer from the adopted view after a +//! stat of the document against the stamp the view was adopted from; only +//! a changed stamp (or an elapsed 30 s full-check window) runs the locked +//! full-read adoption above, and a read never queues behind a commit. It +//! takes this store's local turn only if the turn is free (a turn held by +//! one of this process's own transactions means that transaction is about +//! to adopt or replace the document and update the view itself), then +//! tries the document lock for at most 10 ms, which rides out a momentary +//! hold by another participant. When either is busy it answers from the +//! adopted view (`class` `local_turn_busy` or `lock_busy`), and a later +//! read or the 2 s tick adopts the change; the tick's full read queues for +//! the local turn like every transaction. +//! //! Task 2 constructs this store in `main.rs`; Tasks 3–4 consume //! [`SessionNames::try_background_guard`] for the serial worker. @@ -71,8 +85,8 @@ use freshell_protocol::native_location::{NativeAcquisition, NativeLocation, Nati use freshell_protocol::session_names::{ LegacyCandidateScope, LegacyImportResult, LegacyNameCandidate, LegacyNameImport, LegacyNameTarget, LegacyProtectionEvidence, NameIntent, NameRevision, NameSource, - NamedProvider, SessionNameRecord, SessionNameRedirect, SessionNameRef, SessionNameUpdate, - MAX_NAME_REVISION, + NamedProvider, RenameOutcome, RenameOutcomeReason, SessionNameRecord, SessionNameRedirect, + SessionNameRef, SessionNameUpdate, MAX_NAME_REVISION, }; use freshell_ws::tabs_persist::atomic_write_durable; use serde::{Deserialize, Serialize}; @@ -84,9 +98,61 @@ use crate::session_name_generation::{ GenerationClaim, GenerationOutcome, GenerationWorkItem, IndexedNameInput, }; +// Name-reliability Task 3: the post-migration import policy and the +// import-owned bind-collision rule. A child module so it reads the document +// internals without widening their visibility. +#[path = "session_name_import_policy.rs"] +mod session_name_import_policy; + +// Name-reliability Task 4: the native-observation, automation-rename and +// self-heal rules, and the batched sweep maintenance transaction. Child +// modules for the same reason as the import policy. +#[path = "session_name_apply.rs"] +mod session_name_apply; +#[path = "session_name_policy.rs"] +mod session_name_policy; +#[path = "session_name_sweep_batch.rs"] +mod session_name_sweep_batch; +// Name-reliability Task 5: the first-message fallback, re-absorb at rest +// and the prompt-input guard. +#[path = "session_name_first_message.rs"] +mod session_name_first_message; +// Name-reliability Task 6: claim/fold attempt accounting (refunds, fold-side +// exhaustion). +#[path = "session_name_generation_series.rs"] +mod session_name_generation_series; +// Name-reliability Task 11: lock-free name reads served from the adopted +// view, and the cheap cross-process stamp peek. +#[path = "session_name_store_view.rs"] +mod session_name_store_view; +// Name-reliability Task 12: the `session_names.name_changed` event log. +#[path = "session_name_continuation.rs"] +mod session_name_continuation; +#[path = "session_name_events.rs"] +mod session_name_events; +#[path = "session_name_publish.rs"] +mod session_name_publish; +use session_name_apply::{indexed_key, replace_automatic, wire_source, with_outcome}; +use session_name_events::{AdoptedChanges, NameChangeCause, NameChangeEvent, UncertainCommits}; +use session_name_first_message::{first_message_fallback, InputRefusals, RefusalSite, SeriesPlan}; +use session_name_store_view::{DocumentStamp, FullCheck}; +use session_name_sweep_batch::StagedNativeBaselines; +pub(crate) use session_name_sweep_batch::{ + SweepMaintenanceItem, SweepMaintenanceQueue, SweepMaintenanceReport, SweepWork, +}; + #[cfg(test)] #[path = "session_names_tests.rs"] mod tests; +// Name-reliability Task 5: the first-message fallback, wrapper-text and +// generation-input rules, end to end through the store and the sweep. +#[cfg(test)] +#[path = "session_name_plan_activity_tests.rs"] +mod plan_activity_tests; + +#[cfg(test)] +#[path = "session_name_continuation_tests.rs"] +mod continuation_tests; // --------------------------------------------------------------------------- // Constants @@ -282,6 +348,13 @@ struct NativeWriteState { /// revision at claim time for documents written before the stamp existed. #[serde(default, skip_serializing_if = "Option::is_none")] series_epoch: Option, + /// Name-reliability Task 4: the last valid, current-location native + /// title the provider reported for this record (never a placeholder or + /// other default label). A native observation equal to it is an echo — + /// provenance only, even when it would raise rank — so an echo can never + /// undo an automation or user rename. Internal; never on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_native_title: Option, } /// Persisted fair-scheduling cursor (Task 3/4's serial worker alternates @@ -301,9 +374,14 @@ struct SchedulingCursor { /// interrupted between chunks leaves the receipt open so the next boot /// resumes. `winning_evidence` retains, per record key, the winning /// migration candidate — while a record is migration-owned, a later -/// previously-offline import compares against this retained evidence with -/// the same deterministic total order, so import arrival order can never -/// pick a different winner. +/// pre-completion import (a browser import that arrived before the boot +/// finished, or a resumed boot chunk) compares against this retained +/// evidence and replaces it only on a strict win, and a bind +/// collision consults it to let an import-owned default label give way. +/// Once `completed` is committed, browser imports are acknowledged without +/// any effect (`session_name_import_policy`), so no later import compares +/// against it. The bookkeeping (`acknowledged_candidate_ids`, the evidence) +/// is never pruned: an older binary sharing the document still relies on it. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct MigrationReceipts { @@ -507,6 +585,26 @@ enum Decision { struct PublishSpec { key: String, changed: bool, + /// Name-reliability Task 12: why the saved name changed, logged after + /// the commit (`session_name_events`). `None` for status-only publishes. + change: Option, +} + +impl PublishSpec { + /// A record whose saved name changed for `change`'s reason. + fn changed(key: String, change: NameChangeEvent) -> Self { + Self { + key, + changed: true, + change: Some(change), + } + } + + fn change_entry(&self) -> Option<(&str, &NameChangeEvent)> { + self.change + .as_ref() + .map(|change| (self.key.as_str(), change)) + } } /// Context handed to every decision closure. @@ -516,13 +614,22 @@ struct TxnMeta { /// ([`TestHook::NativeRetryFloorMs`]); always `None` in production (the /// helper is test-only). native_retry_floor: Option, + /// Name-reliability Task 4: the store's staged native baselines (read + /// as if persisted; written by the decisions that only stage). + staged_baselines: Arc, + /// Name-reliability Task 5: the prompt-input guard's drops in this + /// transaction, logged once it commits. + input_refusals: InputRefusals, } /// The adopted read model: the parsed document plus a digest of the exact -/// bytes it was adopted from (equal-generation-different-content detection). +/// bytes it was adopted from (equal-generation-different-content detection) +/// and the stamp of the file they came from (name-reliability Task 11: the +/// lock-free reads' cross-process peek; `None` until stamped). struct Adopted { document: StoredDocument, digest: [u8; 32], + stamp: Option, } struct NamesCore { @@ -535,7 +642,17 @@ struct NamesCore { /// here (FIFO) before contending for the cross-process sidecar lock. local_turn: Arc>, view: RwLock>, + /// Name-reliability Task 11: when the view was last confirmed against + /// the whole document (bounds how long an equal stamp is trusted). + full_check: FullCheck, tx: broadcast::Sender, + /// Name-reliability Task 4: native baselines observed but not yet + /// persisted (the sweep maintenance batch persists them). + staged_baselines: Arc, + /// This store's last commit whose durable replace ended uncertain, so + /// the transaction that confirms it logs its name changes with their own + /// causes (`session_name_events`). + uncertain_commits: UncertainCommits, } /// Proof that the holder owns this store's local transaction turn; the strict @@ -547,9 +664,15 @@ impl NamesCore { self.view.read().unwrap_or_else(|p| p.into_inner()).clone() } - fn set_view(&self, document: StoredDocument, digest: [u8; 32]) { - *self.view.write().unwrap_or_else(|p| p.into_inner()) = - Arc::new(Adopted { document, digest }) + /// Install the view the whole document was just read or written as + /// (always under the document lock), which also confirms it. + fn set_view(&self, document: StoredDocument, digest: [u8; 32], stamp: Option) { + *self.view.write().unwrap_or_else(|p| p.into_inner()) = Arc::new(Adopted { + document, + digest, + stamp, + }); + self.full_check.mark(); } fn publish(&self, updates: Vec) { @@ -646,8 +769,12 @@ impl SessionNames { view: RwLock::new(Arc::new(Adopted { document: StoredDocument::initial(), digest: [0; 32], + stamp: None, })), + full_check: FullCheck::default(), tx, + staged_baselines: Arc::new(StagedNativeBaselines::default()), + uncertain_commits: UncertainCommits::default(), }); // Reconcile/initialize the current document before success — the same // strict path every transaction takes (this is a blocking boot call). @@ -709,12 +836,13 @@ impl SessionNames { })) } - /// The common full-document adoption path before all name-dependent - /// reads and reconnect snapshots: re-read the entire document under the - /// lock (never an mtime shortcut), reconcile an unfamiliar generation's - /// durability, adopt it, and publish the delta to subscribers. Returns - /// every record as updates (bootstrap/snapshot shape: each carries the - /// full redirect list). + /// The unconditional full-document adoption: re-read the entire + /// document under the lock (no stamp shortcut), reconcile an unfamiliar + /// generation's durability, adopt it, and publish the delta to + /// subscribers. Returns every record as updates (bootstrap/snapshot + /// shape: each carries the full redirect list). The publisher's lag + /// recovery uses it; name reads and the 2 s tick use the cheap stamp + /// peek instead (`session_name_store_view`, name-reliability Task 11). pub(crate) fn refresh_current(&self) -> NameFuture> { let core = Arc::clone(&self.core); Box::pin(spawn_txn(core, move |doc, _meta| { @@ -771,8 +899,19 @@ impl SessionNames { /// accepted-Freshell-AI classifications; the HTTP route rejects the /// whole family, so an arbitrary caller can never assert them. The /// import itself never completes the migration — the runner's final - /// [`SessionNames::complete_legacy_migration`] does. + /// [`SessionNames::complete_legacy_migration`] does. Once that receipt + /// has committed, a non-boot import is acknowledged in full and writes + /// nothing at all (no backup, record or stored id; name-reliability + /// Task 3). pub(crate) fn import_legacy(&self, input: LegacyNameImport) -> NameFuture { + // `completed` never reverts, so once this store's view shows it a + // browser import is answered without the document lock or a + // re-read; a stale view falls through to the transaction, whose + // decision applies the same rule. + if !is_boot_import_id(&input.import_id) && self.migration_completed() { + let result = session_name_import_policy::post_completion_acknowledge(&input); + return Box::pin(async move { result }); + } let core = Arc::clone(&self.core); let backup_dir = core .data_dir @@ -784,8 +923,9 @@ impl SessionNames { } /// Durable generation work-claim seam (Task 4's worker dispatches through - /// this): persist an attempt's start before any provider call, consuming - /// one of the bounded three starts and exhausting the series at the cap. + /// this): persist an attempt's start before any provider call, charging + /// one of the bounded three starts. The claim never exhausts the series + /// or drops its input — the fold decides (name-reliability Task 6). /// `Ok(None)` = the series is not claimable right now (absent, not yet /// due, already in flight, exhausted, or the record's accepted source /// is protected) — nothing was consumed. @@ -809,7 +949,9 @@ impl SessionNames { /// answer generated from older input must not be accepted), and the /// accepted source is below Freshell AI; empty/failed outcomes schedule /// the bounded retry (30s after failure 1, five minutes after failure - /// 2) without consuming another start. + /// 2) without consuming another start, or exhaust the series at the + /// cap; capability and quota outcomes refund the start + /// (name-reliability Task 6). // Tasks 3–4 seam: the generation worker lands next. #[allow(dead_code)] pub(crate) fn fold_generation_outcome( @@ -914,8 +1056,11 @@ impl SessionNames { /// its input fingerprint the targeted first-message lookup has no /// remaining eligibility value — an armed-unexhausted named opencode /// session never pays the transcript read again on every pass). - /// Bounds the targeted first-message lookups: an input-carrying, - /// exhausted, or protected session never pays a transcript read. + /// One of the two conditions that gate the sweep's targeted + /// first-message lookups (with [`Self::needs_first_message_input`], + /// name-reliability Task 5): an input-carrying, exhausted, or protected + /// session pays a transcript read only while its NAME can still use the + /// message, and the sweep remembers each answer across passes. pub(crate) fn needs_generation_input(&self, target: &SessionNameRef) -> bool { let view = self.core.current_view(); let document = &view.document; @@ -935,6 +1080,29 @@ impl SessionNames { } } + /// Name-reliability Task 5 (C5): whether a scoped session's record can + /// still use its first user message as a NAME — true when no record + /// exists yet, the record is below `first_message`, or the record is a + /// self-heal record (a junk default label) under the index row's folder + /// `cwd`. The sweep's targeted OpenCode first-message lookup runs when + /// this OR [`Self::needs_generation_input`] holds, so a session stuck on + /// its folder name gets the lookup even after generation stopped + /// needing input. A lock-free read of the adopted view. + pub(crate) fn needs_first_message_input( + &self, + target: &SessionNameRef, + cwd: Option<&str>, + ) -> bool { + let view = self.core.current_view(); + let document = &view.document; + let (resolved, _) = document.resolve_ref(target); + document + .record_at(&name_ref_key(&resolved)) + .is_none_or(|record| { + session_name_first_message::record_needs_first_message(record, cwd) + }) + } + /// Task 4 dispatch re-check: whether the target's CURRENT accepted /// record is a protected winner (manual / migration-protected / /// accepted Freshell AI). The generation worker consults this between @@ -957,9 +1125,16 @@ impl SessionNames { /// history. With `absorb`, a present first user message populates the /// series' input fingerprint/excerpt at rest (Idle — never eligible): /// the boot snapshot must not arm generation for every historical - /// session, while a genuinely newly observed message (a later pass, or - /// an explicit open's bind) arms it. An existing record is returned - /// unchanged — hydration never touches established names. + /// session. Wrapper or derived text is never absorbed (name-reliability + /// Task 5). The absorbed series arms only on an explicit open — the + /// bind's explicit-open hook, or the `Opened` edge of a session open in + /// a live terminal; a changed message that a later pass's index feeds + /// re-absorbs at rest instead (`session_name_first_message::plan_series`), + /// and a newly observed message arms only a session with no series yet. + /// An existing record is returned unchanged — except a self-heal record + /// (name-reliability Task 4: a junk default label), which the ladder's + /// first allowed rung replaces (no series is created or absorbed on that + /// path). pub(crate) fn hydrate_indexed( &self, input: IndexedNameInput, @@ -972,72 +1147,112 @@ impl SessionNames { } /// Delta-review round 3, finding 2 — the adopted-view pre-check for the - /// sweep's hydration feed, in the [`needs_generation_input`] pattern (a - /// lock-free read of the current in-memory view). TRUE means the strict - /// hydrate transaction is still needed; FALSE means the view PROVES it - /// would be a no-op Read, so the sweep skips transacting. + /// sweep's hydration feed, in the [`Self::needs_generation_input`] pattern (a + /// lock-free read of the current in-memory view). Any answer but + /// [`SweepWork::None`] means the strict hydrate transaction is still + /// needed; `None` means the view PROVES it would be a no-op Read, so the + /// sweep skips transacting. Runs the SAME + /// `plan_hydrate` the transaction applies (name-reliability Task 4, C2), + /// so the pre-check and the apply can never drift. /// - /// View-accuracy argument (why skipping cannot miss a foreign process's - /// change): the strict hydrate decision's ONLY work is the - /// record-absent branch — a record present in the re-read document makes - /// it return Read unchanged, before `absorb` is even consulted (the - /// absorb lives in the insert branch only). A record present in the - /// adopted view is therefore present in every LATER on-disk generation: - /// records for durable session keys are never removed (the single - /// `records.remove` is the pending→durable bind retiring the PENDING - /// key), every persisted mutation increments documentGeneration, and a - /// foreign document lacking the record would need a lower or - /// content-rewritten equal generation — both fenced as loud Persistence - /// errors by the strict path. So the only effect of a stale view is - /// transacting unnecessarily (the safe direction); a settled session - /// stays settled across foreign commits. A first-boot fsync per - /// genuinely-new record remains — that is real durable work, paid once - /// per session ever, not once per pass. - pub(crate) fn hydrate_indexed_pending(&self, input: &IndexedNameInput) -> bool { - let target = SessionNameRef::Session { - provider: input.provider, - session_id: input.session_id.clone(), - }; - let key = name_ref_key(&target); - self.core.current_view().document.record_at(&key).is_none() + /// Staleness (name-reliability Task 4, C3): a record's stored rank CAN + /// decrease — a heal moves a junk `provider_ai` name to `first_message` + /// and a junk `first_message` name to `directory` — so "settled on the + /// view" is not permanent. The remaining safety direction: a stale + /// adopted view only DELAYS work, it never loses it. A record present in + /// the view is present in every later generation (durable session keys + /// are never removed; a foreign document lacking the record would need a + /// lower or content-rewritten equal generation, both fenced as loud + /// Persistence errors), so the insert is never skipped wrongly; and a + /// foreign writer that stores a junk name (in practice only an older + /// binary sharing the data dir) is seen at the next adopted-view refresh + /// — the unconditional 2 s tick, a bootstrap read, or any local + /// transaction — after which the pre-check reports the heal. A + /// first-boot fsync per genuinely-new record remains — real durable + /// work, paid once per session ever, not once per pass. + /// + /// The answer classifies the work for the sweep: an insert of a new + /// record is per-row work ([`SweepWork::Immediate`]); a self-heal + /// replacement rides the pass's maintenance batch + /// ([`SweepWork::Maintenance`]). + pub(crate) fn hydrate_work(&self, input: &IndexedNameInput) -> SweepWork { + let view = self.core.current_view(); + match session_name_policy::plan_hydrate(&view.document, input) { + None => SweepWork::None, + Some(session_name_policy::HydratePlan::Insert { .. }) => SweepWork::Immediate, + Some(session_name_policy::HydratePlan::Heal { .. }) => SweepWork::Maintenance, + } } /// Delta-review round 3, finding 2 — the adopted-view pre-check for the - /// sweep's activity feed: TRUE means the strict activity transaction - /// would still change something (or the target has no record, so the - /// transaction must surface its NotFound exactly as before); FALSE - /// means the view PROVES a no-op Read. Runs the SAME - /// [`plan_activity`] policy the transaction applies, so the pre-check - /// and the apply can never drift. + /// sweep's activity feed: any answer but [`SweepWork::None`] means the + /// strict activity transaction would still change something (or the + /// target has no record, so the transaction must surface its NotFound + /// exactly as before); `None` means the view PROVES a no-op Read. Runs + /// the SAME + /// [`plan_activity`] policy the transaction applies — the activity's + /// folder included (name-reliability Task 4, C1) — so the pre-check and + /// the apply can never drift. /// - /// Cross-process staleness: the plan is monotone-safe in both - /// directions. A record's source rank never decreases (offers only - /// raise; manual wins; migration only raises), so a foreign commit can - /// never make a plan-noop into plan-pending through the offer arm. The - /// arming arm becomes pending only when the series' input fingerprint - /// differs from the fed message — a foreign process moving the - /// fingerprint away means ITS transaction armed the series with a newer - /// message (work exists and will run; the input changed, not lost), and - /// the next pass after any adopted-view refresh (the unconditional 2s - /// tick, bootstrap reads, or any local transaction's adoption) re-arms - /// this process's feed if the index message moved. A genuinely new - /// record is always "pending" here, so a foreign process's hydration is - /// never skipped over. - pub(crate) fn activity_pending(&self, input: &NameActivity) -> bool { + /// Cross-process staleness (name-reliability Task 4, C3): a record's + /// stored rank CAN decrease (the self-heal rule replaces a junk + /// `provider_ai` name with the first message), so a foreign commit can + /// turn a plan-noop into plan-pending through the offer arm — in + /// practice only an older binary storing a junk name. The safety + /// direction that remains: a stale view only DELAYS that work; the next + /// pass after any adopted-view refresh (the unconditional 2 s tick, + /// bootstrap reads, or any local transaction's adoption) sees the junk + /// name and heals it. The arming arm becomes pending only when the + /// series' input fingerprint differs from the fed message — a foreign + /// process moving the fingerprint away means ITS transaction armed the + /// series with a newer message (work exists and will run; the input + /// changed, not lost). A genuinely new record is always "pending" here, + /// so a foreign process's hydration is never skipped over. + /// + /// The answer classifies the work for the sweep: a plan that is only + /// maintenance — a self-heal replacement (Task 4), a re-derived first + /// message re-absorbed at rest, or refused wrapper input dropped + /// (Task 5) — rides the pass's maintenance batch + /// ([`SweepWork::Maintenance`]); anything that arms generation, raises a + /// real name's rank, or must surface NotFound transacts on its own + /// ([`SweepWork::Immediate`]). Every non-boot pass feeds each indexed + /// session's first message, and an open or resumed session's message + /// now offers the fallback too, so the plan settles (None) once the + /// name and the series' input agree with the message. + pub(crate) fn activity_work(&self, input: &NameActivity) -> SweepWork { let view = self.core.current_view(); let Ok((key, _record)) = required_record(&view.document, &input.target) else { - return true; + return SweepWork::Immediate; }; if !freshell_freshagent::naming::is_unified_agent_mode(Some(&input.mode), None) { - return false; + return SweepWork::None; } - !plan_activity( + let plan = plan_activity( &view.document, &key, input.reason, input.first_user_message.as_deref(), - ) - .is_noop() + input.cwd.as_deref(), + ); + if plan.is_noop() { + SweepWork::None + } else if plan.is_maintenance() { + SweepWork::Maintenance + } else { + SweepWork::Immediate + } + } + + /// Test seam: whether [`Self::hydrate_work`] reports any work. + #[cfg(test)] + pub(crate) fn hydrate_indexed_pending(&self, input: &IndexedNameInput) -> bool { + self.hydrate_work(input) != SweepWork::None + } + + /// Test seam: whether [`Self::activity_work`] reports any work. + #[cfg(test)] + pub(crate) fn activity_pending(&self, input: &NameActivity) -> bool { + self.activity_work(input) != SweepWork::None } /// Task 3: the store's CURRENT verified location revision for `target` @@ -1223,23 +1438,59 @@ impl SessionNames { } } +impl SessionNames { + /// Name-reliability Task 4 (test seam): the native baseline staged in + /// memory for `target`, if any. + #[cfg(test)] + pub(crate) fn staged_native_baseline(&self, target: &SessionNameRef) -> Option { + let (resolved, _) = self.core.current_view().document.resolve_ref(target); + self.core.staged_baselines.title(&name_ref_key(&resolved)) + } + + /// Name-reliability Task 4: whether any native baseline waits for the + /// sweep maintenance batch. + pub(crate) fn has_staged_native_baselines(&self) -> bool { + !self.core.staged_baselines.is_empty() + } + + /// Name-reliability Task 4 (LB-B1): the sweep pass's ONE maintenance + /// transaction — the queued self-heal items (at most + /// [`SWEEP_MAINTENANCE_BATCH`]; the queue counts the rest as deferred) + /// re-planned on the fresh document, then staged native baselines in + /// the remaining room, summarized in one INFO line once it committed. + /// Nothing queued and nothing staged answers at once, without a + /// transaction. + pub(crate) fn sweep_maintenance( + &self, + batch: impl Into, + ) -> NameFuture { + let batch = batch.into(); + if batch.is_empty() && !self.has_staged_native_baselines() { + return Box::pin(async { Ok(SweepMaintenanceReport::default()) }); + } + let core = Arc::clone(&self.core); + Box::pin(async move { + let data_dir = core.data_dir.clone(); + let report = spawn_txn(core, move |doc, meta| { + session_name_sweep_batch::sweep_maintenance_decision(doc, meta, batch) + }) + .await?; + // Summarized only once committed (or settled as a Read). + report.log_committed(&data_dir); + Ok(report) + }) + } +} + impl SessionNaming for SessionNames { + /// Name-reliability Task 11: served from the adopted view after the + /// stamp peek — no document lock, no re-parse while nothing changed + /// (`session_name_store_view`). fn get(&self, refs: Vec) -> NameFuture> { - let core = Arc::clone(&self.core); - Box::pin(spawn_txn(core, move |doc, _meta| { - let mut updates = Vec::new(); - for target in &refs { - let (resolved, traversed) = doc.resolve_ref(target); - let key = name_ref_key(&resolved); - if let Some(mut update) = update_for_key(doc, &key, false, RedirectScope::None) { - if let Some(redirect) = traversed { - update.redirects.push(redirect); - } - updates.push(update); - } - } - Ok(Decision::Read(updates)) - })) + Box::pin(session_name_store_view::read_names( + Arc::clone(&self.core), + refs, + )) } fn ensure_pending(&self, input: PendingNameInput) -> NameFuture { @@ -1256,6 +1507,26 @@ impl SessionNaming for SessionNames { })) } + fn ensure_session( + &self, + target: SessionNameRef, + cwd: Option, + ) -> NameFuture { + Box::pin(spawn_txn(Arc::clone(&self.core), move |doc, _meta| { + session_name_continuation::ensure_session(doc, target, cwd) + })) + } + + fn continue_session( + &self, + source: SessionNameRef, + target: SessionNameRef, + ) -> NameFuture> { + Box::pin(spawn_txn(Arc::clone(&self.core), move |doc, meta| { + session_name_continuation::continue_session(doc, meta.now_ms, source, target) + })) + } + fn rename(&self, input: RenameNameInput) -> NameFuture { let core = Arc::clone(&self.core); Box::pin(spawn_txn(core, move |doc, meta| { @@ -1509,19 +1780,34 @@ async fn await_local_turn(core: &NamesCore) -> LocalTurn { turn } -fn run_txn_sync(core: &NamesCore, _turn: &LocalTurn, f: F) -> Result +fn run_txn_sync(core: &NamesCore, turn: &LocalTurn, f: F) -> Result +where + F: FnOnce(&mut StoredDocument, &TxnMeta) -> Result, NameError>, +{ + let guard = acquire_document_lock(&core.lock_path)?; + run_txn_locked(core, turn, guard, f) +} + +/// The transaction body, run while the caller holds this store's local turn +/// and `_guard` holds the document lock (the reads' short-wait adoption +/// takes both its own way, `session_name_store_view`). +fn run_txn_locked( + core: &NamesCore, + _turn: &LocalTurn, + _guard: HeldSidecarLock, + f: F, +) -> Result where F: FnOnce(&mut StoredDocument, &TxnMeta) -> Result, NameError>, { - let _guard = acquire_document_lock(&core.lock_path)?; count_store_transaction_for_test(&core.data_dir); test_hold_once(&core.data_dir); - let raw = std::fs::read(&core.doc_path); - let (mut document, raw_digest, first_init) = match raw { - Ok(bytes) => { + let raw = session_name_store_view::read_with_stamp(&core.doc_path); + let (mut document, raw_digest, raw_stamp, first_init) = match raw { + Ok((bytes, stamp)) => { let digest = digest_bytes(&bytes); - (parse_document(&bytes)?, digest, false) + (parse_document(&bytes)?, digest, Some(stamp), false) } Err(e) if e.kind() == ErrorKind::NotFound => { // A missing document is initialization ONLY for a store with no @@ -1536,7 +1822,7 @@ where .into(), )); } - (StoredDocument::initial(), [0u8; 32], true) + (StoredDocument::initial(), [0u8; 32], None, true) } Err(e) => { return Err(NameError::Persistence(format!( @@ -1564,10 +1850,24 @@ where reconcile_durability(&core.doc_path, &core.data_dir)?; } } + // The name changes since the adopted view — another participant's + // commits, and this store's own uncertain one — captured before this + // decision mutates the document (name-reliability Task 12). + let adopted = if first_init { + AdoptedChanges::default() + } else { + core.uncertain_commits.adopted_changes( + &core.current_view().document, + &document, + &raw_digest, + ) + }; let meta = TxnMeta { now_ms: effective_now_ms(&core.data_dir), native_retry_floor: take_native_retry_floor(&core.data_dir), + staged_baselines: Arc::clone(&core.staged_baselines), + input_refusals: InputRefusals::default(), }; let decision = f(&mut document, &meta)?; @@ -1582,16 +1882,46 @@ where write_durable(core, &document, &bytes, digest)?; return Ok(value); } - adopt_if_newer(core, document, raw_digest, true); + // A staged baseline another participant already persisted (or + // whose record is gone) is no longer pending work. + core.staged_baselines.prune_persisted(&document); + if adopt_if_newer(core, document, raw_digest, raw_stamp, true) { + core.uncertain_commits.log_adopted(adopted); + } else { + // The file still holds the adopted generation. + session_name_store_view::confirm_view(core, raw_stamp); + } Ok(value) } Decision::Write { value, publish } => { + let prior = core.current_view(); document.document_generation += 1; let bytes = serialize_document(&document)?; let digest = digest_bytes(&bytes); - write_durable(core, &document, &bytes, digest)?; + if let Err(error) = write_durable(core, &document, &bytes, digest) { + if matches!(error, NameError::CommitUncertain(_)) { + // The replacement landed but is unconfirmed: its name + // changes are logged by the transaction that confirms it. + core.uncertain_commits.remember( + adopted, + document, + digest, + publish.iter().filter_map(PublishSpec::change_entry), + ); + } + return Err(error); + } + core.staged_baselines.prune_persisted(&document); + // Committed: log the adopted and the own name changes. + core.uncertain_commits.log_adopted(adopted); + session_name_events::log_name_changes( + &document, + publish.iter().filter_map(PublishSpec::change_entry), + ); + meta.input_refusals.log_committed(); // Publish while the lock is still held: local generation order. - let updates = materialize_publish(&document, &publish); + let updates = + session_name_publish::committed_updates(&prior.document, &document, &publish); core.publish(updates); Ok(value) } @@ -1629,7 +1959,10 @@ fn write_durable( "injected post-replace failure: the replacement may not be durable".into(), )); } - core.set_view(document.clone(), digest); + // Still under the lock, so the stamp is the file just written (a failed + // stat leaves the view unstamped: the next peek re-reads). + let stamp = session_name_store_view::stat_stamp(&core.doc_path).ok(); + core.set_view(document.clone(), digest, stamp); Ok(()) } @@ -1640,6 +1973,7 @@ fn adopt_if_newer( core: &NamesCore, document: StoredDocument, digest: [u8; 32], + stamp: Option, publish_delta: bool, ) -> bool { let prior = core.current_view(); @@ -1647,7 +1981,7 @@ fn adopt_if_newer( return false; } let delta = delta_updates(&prior.document, &document); - core.set_view(document, digest); + core.set_view(document, digest, stamp); if publish_delta { core.publish(delta); } @@ -1724,6 +2058,8 @@ fn update_for_key( redirects, changed, native_sync: native_sync_projection(document, key), + // Rename responses set their own outcome; nothing else carries one. + outcome: None, }) } @@ -1923,6 +2259,14 @@ pub(crate) enum TestHook { /// advance virtual time without sleeping. Data-dir-keyed like every /// other hook, so parallel tests never cross-contaminate. ClockOffsetMs(i64), + /// Name-reliability Task 11: shorten (or lengthen) the stamp peek's + /// periodic full-check interval for the data dir. Persists until + /// cleared, like the clock offset. + FullCheckIntervalMs(u64), + /// Name-reliability Task 11: lengthen (or shorten) how long a name read + /// waits for a busy document lock before answering from the adopted + /// view. Persists until cleared, like the clock offset. + ReadLockWaitMs(u64), } /// Readiness sentinel written by `HoldLock` hooks while the document lock is @@ -2158,23 +2502,18 @@ fn read_update(document: &StoredDocument, key: &str) -> SessionNameUpdate { } /// The commit-decision shape: the post-commit value update plus the publish -/// list (records publish only when their accepted value changed; -/// bookkeeping-only generations publish nothing). +/// list (records publish only when their accepted value changed — `change` +/// says why; bookkeeping-only generations pass `None` and publish nothing). fn commit_decision( document: &StoredDocument, key: &str, - changed: bool, + change: Option, ) -> Decision { - let value = update_after_commit(document, key, changed, RedirectScope::ToRecord) + let value = update_after_commit(document, key, change.is_some(), RedirectScope::ToRecord) .expect("the record resolves"); - let publish = if changed { - vec![PublishSpec { - key: key.to_string(), - changed: true, - }] - } else { - Vec::new() - }; + let publish = change + .map(|change| vec![PublishSpec::changed(key.to_string(), change)]) + .unwrap_or_default(); Decision::Write { value, publish } } @@ -2192,6 +2531,7 @@ fn commit_decision_publishing_status( publish: vec![PublishSpec { key: key.to_string(), changed: false, + change: None, }], } } @@ -2269,16 +2609,6 @@ fn directory_fallback_name(cwd: Option<&str>, provider: &NamedProvider) -> Strin validate_name(&candidate).unwrap_or(label) } -/// The first-message fallback title (the existing 50-char extraction). -fn first_message_fallback(message: &str) -> Option { - let title = freshell_sessions::text::extract_title_from_message(message, 50); - if title.is_empty() { - None - } else { - Some(title) - } -} - /// Bounded first-message excerpt retained for generation (existing prompt cap). fn excerpt_of(message: &str) -> String { message @@ -2588,9 +2918,10 @@ fn ensure_pending_decision( document.records.insert(key.clone(), record); let value = update_after_commit(document, &key, true, RedirectScope::None) .expect("the freshly inserted record resolves"); + let change = NameChangeEvent::new(NameChangeCause::EnsurePending, None, None); Ok(Decision::Write { value, - publish: vec![PublishSpec { key, changed: true }], + publish: vec![PublishSpec::changed(key, change)], }) } @@ -2612,24 +2943,70 @@ fn rename_decision( }); } } - // UI Rename always conveys user intent; automation defaults to an - // automatic suggestion (provider_ai rank — it can never freeze a name). - let source = if input.intent == NameIntent::User { - NameSource::Manual - } else { - NameSource::ProviderAi - }; - if offer_mut(document, &key, name, source, meta.now_ms)? { - let record = document - .record_at(&key) - .cloned() - .expect("the accepted record resolves"); - if source_is_writable(record.source) { + if input.intent == NameIntent::User { + // UI Rename always conveys user intent: it always wins (an identical + // manual rename is not a new decision and changes nothing). + let change = NameChangeEvent::new(NameChangeCause::ManualRename, Some(&record), None); + let outcome = if offer_mut(document, &key, name, NameSource::Manual, meta.now_ms)? { + let record = document + .record_at(&key) + .cloned() + .expect("the accepted record resolves"); reset_native_series(document, &key, &record); + return Ok(with_outcome( + commit_decision(document, &key, Some(change)), + RenameOutcome { + applied: true, + reason: None, + }, + )); + } else { + RenameOutcome { + applied: true, + reason: Some(RenameOutcomeReason::Unchanged), + } + }; + return Ok(with_outcome( + Decision::Read(read_update(document, &key)), + outcome, + )); + } + // Automation (omitted/`automatic` intent): recorded at `provider_ai`, + // equal rank allowed, never over a protected name and never a default + // label for the target (name-reliability Task 4). + let verdict = + session_name_policy::automation_rename_decision(&record, &name, input.cwd.as_deref()); + match verdict { + session_name_policy::AutomationRenameVerdict::Apply => { + let change = NameChangeEvent::new( + NameChangeCause::AutomationRename, + Some(&record), + input.cwd.as_deref(), + ); + replace_automatic(document, &key, name, NameSource::ProviderAi)?; + Ok(with_outcome( + commit_decision(document, &key, Some(change)), + verdict.outcome(), + )) + } + session_name_policy::AutomationRenameVerdict::Unchanged => Ok(with_outcome( + Decision::Read(read_update(document, &key)), + verdict.outcome(), + )), + session_name_policy::AutomationRenameVerdict::Refused(reason) => { + tracing::info!( + target: "freshell_server::session_names", + op = "rename", + name_ref = %key, + source = %wire_source(record.source), + reason = ?reason, + "session_names.automation_rename_refused" + ); + Ok(with_outcome( + Decision::Read(read_update(document, &key)), + verdict.outcome(), + )) } - Ok(commit_decision(document, &key, true)) - } else { - Ok(Decision::Read(read_update(document, &key))) } } @@ -2640,7 +3017,7 @@ fn offer_decision( name: &str, source: NameSource, ) -> Result, NameError> { - let (key, _record) = required_record(document, target)?; + let (key, prior) = required_record(document, target)?; let name = validate_name(name)?; if offer_mut(document, &key, name, source, meta.now_ms)? { let record = document @@ -2650,7 +3027,8 @@ fn offer_decision( if source_is_writable(record.source) { reset_native_series(document, &key, &record); } - Ok(commit_decision(document, &key, true)) + let change = NameChangeEvent::new(NameChangeCause::of_offer(source), Some(&prior), None); + Ok(commit_decision(document, &key, Some(change))) } else { Ok(Decision::Read(read_update(document, &key))) } @@ -2707,8 +3085,9 @@ fn bind_pending_decision( // Rank decides the collision: manual/manual compares actual manual // revisions; equal automatic ranks favor the durable record; migration - // protection rides the rank order. Losing evidence is retained in - // recovery, never active. + // protection rides the rank order — except that an import-owned default + // label ("Tab 4") always gives way to the live pending record (Task 3). + // Losing evidence is retained in recovery, never active. let (winner, loser) = match &existing { None => (pending_record.clone(), None), Some(current) => { @@ -2718,6 +3097,10 @@ fn bind_pending_decision( pending_record.manual_revision.unwrap_or(0) > current.manual_revision.unwrap_or(0) } else { source_rank(pending_record.source) > source_rank(current.source) + || session_name_import_policy::import_owned_label_loses_bind( + document, + &target_key, + ) }; if pending_wins { (pending_record.clone(), Some(current.clone())) @@ -2773,200 +3156,196 @@ fn bind_pending_decision( // the session. An unattempted absorbed series (hydration installed the // fingerprint at rest so history alone never schedules the paid title) // becomes eligible now; an attempted series keeps its own schedule, and - // a protected winner keeps generation stopped. + // a protected winner keeps generation stopped. Name-reliability Task 5: + // a series whose stored input is wrapper or derived text stays Idle and + // drops it (never armed from it). if !source_is_protected(record.source) { if let Some(series) = document.generation.get_mut(&target_key) { - if series.status == GenerationStatus::Idle - && series.consumed == 0 - && series.input_fingerprint.is_some() - { - series.status = GenerationStatus::Eligible; - series.next_due = None; - } + session_name_first_message::explicit_open(series, &meta.input_refusals); } } - Ok(commit_decision(document, &target_key, true)) + // The session's saved name before the bind (none: the bind creates it). + let cause = if existing.is_some() { + NameChangeCause::BindCollision + } else { + NameChangeCause::Bind + }; + let change = NameChangeEvent::new(cause, existing.as_ref(), None).with_from_ref(&pending_key); + Ok(commit_decision(document, &target_key, Some(change))) } /// The pure pre-evaluation of one activity against a document snapshot: /// exactly what the strict activity transaction would change, without /// mutating. Shared by [`activity_decision`] (the transaction's apply path) -/// and the sweep's adopted-view pre-check ([`SessionNames::activity_pending`]), +/// and the sweep's adopted-view pre-check ([`SessionNames::activity_work`]), /// so the apply and the skip can never drift apart. struct ActivityPlan { - /// The first-message fallback that would raise the record's rank. + /// The first-message fallback that would replace the record's name. offer_fallback: Option, - /// The (fingerprint, excerpt) that would (re)arm the generation series. - arm: Option<(String, String)>, + /// The fallback replaces a self-heal record WITHOUT raising its rank + /// (name-reliability Task 4): maintenance work the sweep may batch. + offer_heals: bool, + /// What the activity does to the generation series + /// (`session_name_first_message::plan_series`). + series: Option, } impl ActivityPlan { fn is_noop(&self) -> bool { - self.offer_fallback.is_none() && self.arm.is_none() + self.offer_fallback.is_none() && self.series.is_none() + } + + /// Only maintenance (name-reliability Tasks 4–5): a self-heal + /// replacement, an input re-absorbed at rest, a refused input dropped — + /// nothing that arms generation or raises a real name. The sweep may + /// batch it. + fn is_maintenance(&self) -> bool { + let series_maintenance = self.series.as_ref().is_none_or(|plan| !plan.arms()); + let offer_maintenance = self.offer_fallback.is_none() || self.offer_heals; + !self.is_noop() && series_maintenance && offer_maintenance } } /// Evaluate the activity acceptance/arming conditions (delta-review round 3, -/// finding 2, extracted verbatim from the former inline decision so the -/// pre-check and the transaction share ONE policy). Callers apply the -/// unified-mode scope admission BEFORE planning: -/// - the first-message fallback offer runs only for accepted/index user -/// messages, only when the extraction is a valid name, and only when it -/// would RAISE rank (an automatic offer never overwrites an equal rank); -/// - arming is skipped for protected sources, duplicate delivery (same input -/// fingerprint) and exhausted series; Opened/Resumed arm only an -/// unattempted series, except the boot-absorbed Idle series an explicit -/// open may still arm. +/// finding 2: the pre-check and the transaction share ONE policy). Callers +/// apply the unified-mode scope admission BEFORE planning: +/// - the first-message fallback is offered for EVERY reason that carries a +/// first user message — accepted, indexed, opened and resumed alike +/// (name-reliability Task 5, D4: an open terminal pane's session no longer +/// keeps its folder name when AI naming fails) — only when the extraction +/// is a valid name that is not a default or derived label (never wrapper +/// text, RC12), and only when the automatic-offer rule accepts it +/// (`session_name_policy`): it RAISES a real name's rank, or it replaces a +/// self-heal record (a junk default label, classified with the activity's +/// own `cwd`) — Task 4 — the latter only when the message can be the +/// session's first (`session_name_first_message::fallback_may_self_heal`: +/// an accepted send carries the current message, not the first); +/// - the series plan (`session_name_first_message::plan_series`) is skipped +/// for protected sources; it arms on new input or an explicit open of an +/// unattempted series, re-absorbs a re-derived indexed message at rest, +/// and drops refused (wrapper) input — Task 5. fn plan_activity( document: &StoredDocument, key: &str, reason: NameActivityReason, first_user_message: Option<&str>, + cwd: Option<&str>, ) -> ActivityPlan { - let Some(record) = document.record_at(key) else { - return ActivityPlan { - offer_fallback: None, - arm: None, - }; - }; let mut plan = ActivityPlan { offer_fallback: None, - arm: None, + offer_heals: false, + series: None, }; - if matches!( - reason, - NameActivityReason::AcceptedUserMessage | NameActivityReason::IndexUserMessage - ) { - if let Some(message) = first_user_message { - if let Some(fallback) = first_message_fallback(message) { - if validate_name(&fallback).is_ok() - && source_rank(NameSource::FirstMessage) > source_rank(record.source) - { - plan.offer_fallback = Some(fallback); - } - } + let Some(record) = document.record_at(key) else { + return plan; + }; + if let Some(fallback) = first_user_message.and_then(first_message_fallback) { + let applies = if session_name_first_message::fallback_may_self_heal(document, key, reason) { + session_name_policy::automatic_offer_applies + } else { + session_name_policy::rank_raise_applies + }; + if validate_name(&fallback).is_ok() + && applies(record, &fallback, NameSource::FirstMessage, cwd) + { + plan.offer_heals = source_rank(NameSource::FirstMessage) <= source_rank(record.source); + plan.offer_fallback = Some(fallback); } } if !source_is_protected(record.source) { - if let Some(message) = first_user_message.map(str::trim).filter(|m| !m.is_empty()) { - let unattempted_only = match reason { - NameActivityReason::AcceptedUserMessage | NameActivityReason::IndexUserMessage => { - true - } - NameActivityReason::Opened | NameActivityReason::Resumed => document - .generation - .get(key) - .is_none_or(|series| series.consumed == 0), - }; - if unattempted_only { - let fingerprint = fingerprint_message(message); - let needs_arm = match document.generation.get(key) { - None => true, - Some(series) => { - if series.input_fingerprint.as_deref() != Some(fingerprint.as_str()) { - true - } else { - // "Explicit open/resume may arm an unattempted - // series": the boot snapshot's absorbed series - // (Idle, consumed == 0) rests until the user - // actually opens the session — the same message - // then arms it. An attempted series (Eligible - // retry, InFlight, Exhausted) never resets. - reason == NameActivityReason::Opened - && series.status == GenerationStatus::Idle - && series.consumed == 0 - } - } - }; - if needs_arm { - // An exhausted series never re-arms (the apply path's - // own guard, mirrored so the pre-check is view-accurate). - let exhausted = document - .generation - .get(key) - .is_some_and(|series| series.status == GenerationStatus::Exhausted); - if !exhausted { - plan.arm = Some((fingerprint, excerpt_of(message))); - } - } - } - } + plan.series = + session_name_first_message::plan_series(document, key, reason, first_user_message); } plan } +/// What applying one activity changed (shared by the per-row transaction +/// and the sweep maintenance batch). +struct ActivityApplied { + key: String, + /// The saved name changed, and why (name-reliability Task 12). + change: Option, + /// Generation bookkeeping changed (a persisted, invisible advance). + bookkeeping: bool, +} + fn activity_decision( document: &mut StoredDocument, meta: &TxnMeta, input: NameActivity, ) -> Result, NameError> { + let applied = apply_activity(document, meta, input)?; + if let Some(change) = applied.change { + Ok(commit_decision(document, &applied.key, Some(change))) + } else if applied.bookkeeping { + // Bookkeeping-only commit: a generation advance with no visible text. + Ok(commit_decision(document, &applied.key, None)) + } else { + Ok(Decision::Read(read_update(document, &applied.key))) + } +} + +/// Apply one activity exactly as [`plan_activity`] planned it: the planned +/// fallback is installed as decided — never re-offered through the strict +/// raise, which refuses the equal-rank self-heal the plan allowed (C1) — +/// and the planned arm advances the series. +fn apply_activity( + document: &mut StoredDocument, + meta: &TxnMeta, + input: NameActivity, +) -> Result { let NameActivity { target, mode, event_id: _event_id, reason, first_user_message, - cwd: _cwd, + cwd, } = input; - let (key, _record) = required_record(document, &target)?; + let (key, prior) = required_record(document, &target)?; + let mut applied = ActivityApplied { + key: key.clone(), + change: None, + bookkeeping: false, + }; // Scope admission (Task 4): only the unified coding-agent modes ever // enter the generator — kilroy (despite sharing the Claude runtime), // excluded providers, and shells keep their existing naming approach. if !freshell_freshagent::naming::is_unified_agent_mode(Some(&mode), None) { - return Ok(Decision::Read(read_update(document, &key))); + return Ok(applied); } - let plan = plan_activity(document, &key, reason, first_user_message.as_deref()); + let plan = plan_activity( + document, + &key, + reason, + first_user_message.as_deref(), + cwd.as_deref(), + ); if plan.is_noop() { - return Ok(Decision::Read(read_update(document, &key))); + return Ok(applied); } - let mut changed = false; if let Some(fallback) = plan.offer_fallback { - changed = offer_mut( - document, - &key, - fallback, - NameSource::FirstMessage, - meta.now_ms, - )?; - } - - let mut armed_or_updated = false; - if let Some((fingerprint, excerpt)) = plan.arm { - let series = document - .generation - .entry(key.clone()) - .or_insert_with(|| GenerationState { - status: GenerationStatus::Eligible, - series_id: Uuid::new_v4().to_string(), - input_fingerprint: None, - excerpt: None, - attempt_ids: Vec::new(), - attempt_starts: Vec::new(), - consumed: 0, - next_due: None, - exhausted_at: None, - }); - if series.status != GenerationStatus::Exhausted { - series.status = GenerationStatus::Eligible; - series.input_fingerprint = Some(fingerprint); - series.excerpt = Some(excerpt); - series.next_due = None; - armed_or_updated = true; - } + replace_automatic(document, &key, fallback, NameSource::FirstMessage)?; + applied.change = Some(NameChangeEvent::new( + NameChangeCause::FirstMessageFallback, + Some(&prior), + cwd.as_deref(), + )); } - if changed { - Ok(commit_decision(document, &key, true)) - } else if armed_or_updated { - // Bookkeeping-only commit: a generation advance with no visible text. - Ok(commit_decision(document, &key, false)) - } else { - Ok(Decision::Read(read_update(document, &key))) + if let Some(series_plan) = plan.series { + applied.bookkeeping = session_name_first_message::apply_series_plan( + document, + &key, + series_plan, + &meta.input_refusals, + ); } + Ok(applied) } fn observe_native_decision( @@ -2981,7 +3360,7 @@ fn observe_native_decision( location_revision, event_id, } = input; - let (key, _record) = required_record(document, &target)?; + let (key, record) = required_record(document, &target)?; // An old-location observation is provenance only: it can never mark the // current location synchronized or fold a stale title into the name. @@ -2994,6 +3373,10 @@ fn observe_native_decision( let stale = location_revision < verified_revision; let mut changed = false; + // Name-reliability Task 4: the baseline this observation records, if + // any (a valid, current-location, non-placeholder title that differs + // from the record's last one). + let mut new_baseline: Option = None; // An invalid observation title (an external rename over the accepted-name // cap, or control characters) never fails the transaction: only the // offer and the divergence rearm are skipped for the invalid text. @@ -3005,7 +3388,22 @@ fn observe_native_decision( NativeNameOrigin::Snapshot | NativeNameOrigin::ProviderAi => { if !stale { if let Some(name) = valid_title.clone() { - changed = offer_mut(document, &key, name, NameSource::ProviderAi, meta.now_ms)?; + // The native rule (`session_name_policy`): placeholders + // and other default labels are provenance only, an echo + // of the baseline never changes the name, the first + // observation only records the baseline, and a changed + // title replaces an unprotected provider-level name. + let baseline = meta.staged_baselines.effective(document, &key); + let plan = session_name_policy::native_offer_decision( + &record, + baseline.as_deref(), + &name, + ); + if plan.replace { + replace_automatic(document, &key, name, NameSource::ProviderAi)?; + changed = true; + } + new_baseline = plan.new_baseline; } } } @@ -3086,12 +3484,32 @@ fn observe_native_decision( at: meta.now_ms, stale, }); + // A new baseline rides this real commit (and supersedes any staged + // one for the record). + if let Some(baseline) = new_baseline { + entry.last_native_title = Some(baseline); + meta.staged_baselines.remove(&key); + } } else { + // A baseline that changes nothing else never makes its own commit: + // it is staged in memory, and the sweep maintenance batch persists it + // (LB-B1). Losing it at a restart only means the next observation + // records it again. + if let Some(baseline) = new_baseline { + tracing::debug!( + target: "freshell_server::session_names", + op = "observe_native", + name_ref = %key, + "session_names.native_baseline_staged" + ); + meta.staged_baselines.stage(document, &key, baseline); + } return Ok(Decision::Read(read_update(document, &key))); } if changed { - Ok(commit_decision(document, &key, true)) + let change = NameChangeEvent::new(NameChangeCause::NativeObservation, Some(&record), None); + Ok(commit_decision(document, &key, Some(change))) } else { // A status-only update publishes the unchanged record with its new // nativeSync projection; clients fold it by documentGeneration. @@ -3197,22 +3615,26 @@ fn claim_generation_start_decision( return Ok(Decision::Read(None)); } } - // Persist the start before dispatch; empty/invalid answers and - // interrupted starts consume it. + // Name-reliability Task 5, the prompt-input guard's last line: wrapper + // or derived text is never sent to the model. The claim refuses it + // without consuming a start and drops the input, so the series rests + // and the worker is never offered it again. + if session_name_first_message::drop_refused_input( + series, + RefusalSite::Claim, + &meta.input_refusals, + ) { + return Ok(Decision::Write { + value: None, + publish: Vec::new(), + }); + } + // Persist the start before dispatch; the fold decides whether it + // counted (name-reliability Task 6). let series_id = series.series_id.clone(); let input_fingerprint = series.input_fingerprint.clone(); let excerpt = series.excerpt.clone(); - series.consumed += 1; - series.attempt_ids.push(attempt_id.to_string()); - series.attempt_starts.push(meta.now_ms); - let consumed = series.consumed; - if series.consumed >= MAX_GENERATION_STARTS { - series.status = GenerationStatus::Exhausted; - series.exhausted_at = Some(meta.now_ms); - series.excerpt = None; - } else { - series.status = GenerationStatus::InFlight; - } + let consumed = session_name_generation_series::charge_start(series, attempt_id, meta.now_ms); // The persisted alternating class cursor: this claim served generation. document.scheduling_cursor.last_class = Some("generation".to_string()); let _value = update_after_commit(document, &key, false, RedirectScope::ToRecord) @@ -3250,9 +3672,20 @@ fn fold_generation_outcome_decision( return Ok(Decision::Read(read_update(document, &key))); } // The input-fingerprint guard: a re-armed series keeps its series id, so - // a late answer generated from older input must not be accepted. + // a late answer generated from older input must not be accepted — but + // the start it was charged still settles (name-reliability Task 6). if series.input_fingerprint.as_deref() != Some(input_fingerprint) { - return Ok(Decision::Read(read_update(document, &key))); + return Ok( + if session_name_generation_series::settle_superseded_start( + series, + &outcome, + meta.now_ms, + ) { + commit_decision(document, &key, None) + } else { + Decision::Read(read_update(document, &key)) + }, + ); } // A protected winner (manual, migration-protected, accepted Freshell AI) // rejects late output — and stops the series for good. @@ -3260,7 +3693,7 @@ fn fold_generation_outcome_decision( series.status = GenerationStatus::Idle; series.next_due = None; series.excerpt = None; - return Ok(commit_decision(document, &key, false)); + return Ok(commit_decision(document, &key, None)); } // An INVALID answer — a control character inside the reply (the // transport trims and caps but never strips them), an oversize or @@ -3279,6 +3712,8 @@ fn fold_generation_outcome_decision( match outcome { GenerationOutcome::Answer(answer) => { let name = validate_name(&answer)?; + let change = NameChangeEvent::new(NameChangeCause::Generation, Some(&record), None) + .with_series(series_id); if offer_mut(document, &key, name, NameSource::FreshellAi, meta.now_ms)? { { let series = document @@ -3296,27 +3731,27 @@ fn fold_generation_outcome_decision( .cloned() .expect("the accepted record resolves"); reset_native_series(document, &key, &record); - Ok(commit_decision(document, &key, true)) + Ok(commit_decision(document, &key, Some(change))) } else { Ok(Decision::Read(read_update(document, &key))) } } GenerationOutcome::Empty | GenerationOutcome::Failed(_) => { - // The attempt was charged at the claim; schedule the remaining - // delay from this fold. At the cap the series stays exhausted - // (the claim already exhausted it) — nothing more to schedule. - if series.status == GenerationStatus::Exhausted - || series.consumed >= MAX_GENERATION_STARTS - { - series.status = GenerationStatus::Exhausted; - series.next_due = None; - series.excerpt = None; + // The attempt was charged at the claim and counted: exhaust at + // the cap, else schedule the remaining delay from this fold. + session_name_generation_series::settle_consumed_start(series, meta.now_ms); + Ok(commit_decision(document, &key, None)) + } + GenerationOutcome::CapabilityUnavailable { attempt_id } + | GenerationOutcome::Throttled { attempt_id } => { + // Name-reliability Task 6: the request never had a fair chance + // (refused credential, permission, proxy or quota): give the + // start back and keep the input. + if session_name_generation_series::refund_start(series, &attempt_id, meta.now_ms) { + Ok(commit_decision(document, &key, None)) } else { - series.status = GenerationStatus::Eligible; - series.next_due = - Some(meta.now_ms + generation_retry_delay_ms(series.consumed).unwrap_or(0)); + Ok(Decision::Read(read_update(document, &key))) } - Ok(commit_decision(document, &key, false)) } } } @@ -3360,51 +3795,43 @@ fn hydrate_indexed_decision( input: IndexedNameInput, absorb: bool, ) -> Result, NameError> { + let key = indexed_key(&input); + // The ONE hydration plan (name-reliability Task 4, C2), shared with the + // sweep's pre-check. The free fallbacks: a provider-authored title, then + // the first-message extraction, then the directory basename (the + // ensure_pending fallback). Round-2 carried finding F2 (adjudicated): a + // provider title that FAILS accepted-name validation (over the + // 200-scalar cap, or control characters) is an unusable rung, not a + // failed hydration — the ladder falls through exactly like an absent + // title. An abort would leave the session with NO canonical record while + // the ~5s sweep re-failed and re-logged it every pass. + let (name, source) = match session_name_policy::plan_hydrate(document, &input) { + // An established record (or a pending handle already bound to this + // durable session through a redirect) is returned unchanged. + None => return Ok(Decision::Read(read_update(document, &key))), + // A self-heal record: the allowed rung replaces the junk name; no + // generation series is created or absorbed on this path. + Some(session_name_policy::HydratePlan::Heal { name, source }) => { + let change = NameChangeEvent::new( + NameChangeCause::Hydrate, + document.record_at(&key), + input.cwd.as_deref(), + ); + replace_automatic(document, &key, name, source)?; + return Ok(commit_decision(document, &key, Some(change))); + } + Some(session_name_policy::HydratePlan::Insert { name, source }) => (name, source), + }; let IndexedNameInput { provider, session_id, - cwd, first_user_message, - provider_title, + .. } = input; let target = SessionNameRef::Session { provider, session_id, }; - let key = name_ref_key(&target); - // An established record (or a pending handle already bound to this - // durable session through a redirect) is returned unchanged — hydration - // never touches existing names. - if document.record_at(&key).is_some() { - return Ok(Decision::Read(read_update(document, &key))); - } - // The free fallbacks: a provider-authored title, then the first-message - // extraction, then the directory basename (the ensure_pending fallback). - // Round-2 carried finding F2 (adjudicated): a provider title that FAILS - // accepted-name validation (over the 200-scalar cap, or control - // characters) is an unusable rung, not a failed hydration — the ladder - // falls through exactly like an absent title. An abort would leave the - // session with NO canonical record while the ~5s sweep re-failed and - // re-logged it every pass. - let (name, source) = if let Some(title) = provider_title - .as_deref() - .map(str::trim) - .filter(|t| !t.is_empty()) - .filter(|t| validate_name(t).is_ok()) - { - (title.to_string(), NameSource::ProviderAi) - } else if let Some(message) = first_user_message - .as_deref() - .and_then(first_message_fallback) - .filter(|fallback| validate_name(fallback).is_ok()) - { - (message, NameSource::FirstMessage) - } else { - ( - directory_fallback_name(cwd.as_deref(), &provider), - NameSource::Directory, - ) - }; let revision = document.allocate_revision()?; document.records.insert( key.clone(), @@ -3425,10 +3852,12 @@ fn hydrate_indexed_decision( // genuinely new session), no series is created here: the activity feed // right after arms it with the newly observed message. if absorb { + // Name-reliability Task 5: wrapper or derived text is never absorbed + // as AI-naming input. if let Some(message) = first_user_message .as_deref() .map(str::trim) - .filter(|m| !m.is_empty()) + .filter(|m| session_name_first_message::is_generation_input(m)) { document.generation.insert( key.clone(), @@ -3449,9 +3878,10 @@ fn hydrate_indexed_decision( let _ = meta; let value = update_after_commit(document, &key, true, RedirectScope::None) .expect("the freshly inserted record resolves"); + let change = NameChangeEvent::new(NameChangeCause::Hydrate, None, None); Ok(Decision::Write { value, - publish: vec![PublishSpec { key, changed: true }], + publish: vec![PublishSpec::changed(key, change)], }) } @@ -3764,7 +4194,7 @@ fn fold_native_outcome_decision( if before != after { Ok(commit_decision_publishing_status(document, &key)) } else { - Ok(commit_decision(document, &key, false)) + Ok(commit_decision(document, &key, None)) } } @@ -3840,15 +4270,21 @@ fn classify_legacy_candidate( } } -/// The core the deterministic total order compares. `Less` means `a` wins. +/// The core the candidate order compares. `Less` means `a` wins. /// /// Order: source rank (manual > legacy_protected > freshell_ai > /// provider_ai > first_message > directory), then — within proven manual — /// trustworthy explicit-rename recency (later first, dated ahead of /// undated), then scope (session > pane > source_tab > terminal > -/// derived), then ascending evidence key and candidate id. Generic -/// updatedAt/mtime/layout timestamps never reach `explicit_rename_at`, so -/// they can never order here. +/// derived). Nothing else orders: candidates equal on all three compare +/// `Equal`, and the apply path replaces only on a strict win, so the +/// incumbent stays — a browser window or device id (the evidence key and +/// candidate id carry them) never displaces an installed name (Task 3). +/// Within one boot an id can still pick among exact equals: +/// [`order_boot_legacy_candidates`] breaks comparator ties by candidate id, +/// so the lowest id for a target installs first and stays (the same one on +/// every boot). Generic updatedAt/mtime/layout timestamps never reach +/// `explicit_rename_at`, so they can never order here. fn compare_classified_candidates( a: &ClassifiedLegacyCandidate, b: &ClassifiedLegacyCandidate, @@ -3870,15 +4306,7 @@ fn compare_classified_candidates( (None, None) => {} } } - let scope = legacy_scope_rank(b.scope).cmp(&legacy_scope_rank(a.scope)); - if scope != std::cmp::Ordering::Equal { - return scope; - } - let evidence = a.evidence_key.cmp(&b.evidence_key); - if evidence != std::cmp::Ordering::Equal { - return evidence; - } - a.candidate_id.cmp(&b.candidate_id) + legacy_scope_rank(b.scope).cmp(&legacy_scope_rank(a.scope)) } /// Scope tie-break: session > pane > source_tab > terminal > derived. @@ -3977,7 +4405,7 @@ fn resolve_legacy_target( /// Apply one classified candidate against the current document state: /// install a missing record, or compare against the existing record / -/// retained winning migration evidence with the deterministic total order. +/// retained winning migration evidence, replacing only on a strict win. /// Post-migration protection: a record whose accepted source is manual, /// legacy-protected or accepted Freshell AI (or which moved after the /// migration installed it through a protected decision) can never be @@ -4001,8 +4429,10 @@ fn apply_legacy_candidate( let candidate_wins = match document.migration.winning_evidence.get(&key) { Some(evidence) if current.revision == evidence.installed_revision => { // Still migration-owned: compare against the retained winning - // evidence with the same total order — import arrival order - // can never pick a different winner. + // evidence. Only a strict win (higher rank, a later proven + // rename, or a wider scope) replaces it; an equal candidate — + // e.g. the same label kind from another window — leaves the + // incumbent. let retained = ClassifiedLegacyCandidate { name: evidence.name.clone(), source: evidence.source, @@ -4134,15 +4564,18 @@ pub(crate) fn is_boot_import_id(import_id: &str) -> bool { } /// Order the trusted boot lane's gathered candidates best-first by the -/// deterministic total order (`compare_classified_candidates`), so the -/// consolidation's ≤100-candidate import chunks keep a stable membership -/// across boots (the create-once per-import backups and the per-candidate -/// acknowledgment replay both key off the chunk ids). Sequential -/// application against the retained winning evidence is already -/// permutation-invariant — the ordering is the belt that guarantees no -/// cross-chunk comparison is ever decided by arrival order. Comparator- -/// equal candidates are interchangeable in every compared field (the final -/// tiebreak is the candidate id), so the stable sort needs no extra key. +/// candidate order (`compare_classified_candidates`), then by candidate id, +/// so the consolidation's ≤100-candidate import chunks keep a stable +/// membership across boots (the create-once per-import backups and the +/// per-candidate acknowledgment replay both key off the chunk ids). Higher +/// rank or scope wins whatever the arrival order; among comparator-equal +/// candidates the first one applied stays (strict-win apply). The +/// candidate-id key makes that order, and so the batching, the same on +/// every boot, and it also decides which of several comparator-equal +/// candidates for one target is applied first: the lowest candidate id +/// installs and stays. Candidate ids embed storage keys and device ids, so +/// among exact equals this one choice is made by an id; it never displaces +/// a name that is already installed. pub(crate) fn order_boot_legacy_candidates(candidates: &mut [LegacyNameCandidate]) { let mut classified: Vec<(ClassifiedLegacyCandidate, LegacyNameCandidate)> = candidates .iter() @@ -4153,7 +4586,10 @@ pub(crate) fn order_boot_legacy_candidates(candidates: &mut [LegacyNameCandidate ) }) .collect(); - classified.sort_by(|a, b| compare_classified_candidates(&a.0, &b.0)); + classified.sort_by(|a, b| { + compare_classified_candidates(&a.0, &b.0) + .then_with(|| a.0.candidate_id.cmp(&b.0.candidate_id)) + }); for (slot, (_, candidate)) in candidates.iter_mut().zip(classified) { *slot = candidate; } @@ -4169,35 +4605,28 @@ pub(crate) fn order_boot_legacy_candidates(candidates: &mut [LegacyNameCandidate /// chunk acknowledged ([`SessionNames::complete_legacy_migration`]), so /// canonical readers stop consulting migrated legacy fields exactly when /// the whole consolidation finished (the physical cleanup that follows is -/// idempotently retried at boot). +/// idempotently retried at boot). After completion a non-boot import never +/// reaches the backup or the candidates: it is a `Read` that acknowledges +/// every candidate id (`session_name_import_policy::post_completion_acknowledge`). fn import_legacy_decision( document: &mut StoredDocument, meta: &TxnMeta, backup_dir: &Path, input: LegacyNameImport, ) -> Result, NameError> { - if input.version != 1 { - return Err(NameError::InvalidName( - "unsupported legacy-name import version".into(), - )); - } - if input.import_id.trim().is_empty() { - return Err(NameError::InvalidName( - "a legacy-name import must carry its retry-stable importId".into(), - )); - } - if input.candidates.len() > NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT { - return Err(NameError::InvalidName(format!( - "a legacy-name import batches at most {} candidates", - NAME_MIGRATION_IMPORT_CANDIDATE_LIMIT - ))); + session_name_import_policy::validate_import_envelope(&input)?; + + let trusted_boot = is_boot_import_id(&input.import_id); + // Name-reliability Task 3: after the consolidation completed, a browser + // import is acknowledged and writes nothing (no backup, no stored ids). + if document.migration.completed && !trusted_boot { + return session_name_import_policy::post_completion_acknowledge(&input).map(Decision::Read); } // Immutable create-once backup BEFORE any acknowledgment: a failure // here retains every original and acknowledges nothing. write_import_backup_once(backup_dir, &input, meta.now_ms)?; - let trusted_boot = is_boot_import_id(&input.import_id); let mut acknowledged: Vec = Vec::with_capacity(input.candidates.len()); let mut newly_acknowledged = false; let mut publish: Vec = Vec::new(); @@ -4236,13 +4665,16 @@ fn import_legacy_decision( continue; }; let classified = classify_legacy_candidate(trusted_boot, candidate); + let change = NameChangeEvent::new( + NameChangeCause::Import, + document.record_at(&name_ref_key(&resolved)), + None, + ) + .with_import(&input.import_id, &classified.evidence_key); let outcome = apply_legacy_candidate(document, resolved, &classified)?; if let Some(key) = outcome.key { if outcome.changed { - publish.push(PublishSpec { - key: key.clone(), - changed: true, - }); + publish.push(PublishSpec::changed(key.clone(), change)); names.push( update_after_commit(document, &key, true, RedirectScope::ToRecord) .expect("the winning record resolves"), diff --git a/crates/freshell-server/src/session_names_tests.rs b/crates/freshell-server/src/session_names_tests.rs index 5b55e0a11..55e381c65 100644 --- a/crates/freshell-server/src/session_names_tests.rs +++ b/crates/freshell-server/src/session_names_tests.rs @@ -64,6 +64,7 @@ async fn rename_user( name: name.to_string(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await } @@ -80,6 +81,7 @@ async fn rename_user_cas( name: name.to_string(), intent: NameIntent::User, if_revision: Some(if_revision), + cwd: None, }) .await } @@ -95,15 +97,45 @@ async fn rename_automatic( name: name.to_string(), intent: NameIntent::Automatic, if_revision: None, + cwd: None, }) .await } +/// A pure-read TRANSACTION for `target`: unlike `get`, which answers from +/// the adopted view without queuing (name-reliability Task 11), it takes +/// this store's local turn and then the document lock, like every other +/// transaction. The local-serialization tests use it as the queued +/// same-process caller. +async fn read_through_transaction( + store: &Arc, + target: &SessionNameRef, +) -> Result { + let updates = store.refresh_current().await?; + Ok(updates + .into_iter() + .find(|update| &update.record.name_ref == target) + .expect("the transaction answers the target's record")) +} + async fn get_one(store: &Arc, target: SessionNameRef) -> Option { let updates = store.get(vec![target]).await.expect("get succeeds"); updates.into_iter().next() } +/// `get_one` for a read that must see a change another writer just made +/// (`session_name_store_view::get_settled_for_test`: a read answered from +/// the view only because the lock was still busy is repeated). +async fn get_one_settled( + store: &Arc, + target: SessionNameRef, +) -> Option { + let updates = session_name_store_view::get_settled_for_test(store, vec![target]) + .await + .expect("get succeeds"); + updates.into_iter().next() +} + async fn activity( store: &Arc, target: SessionNameRef, @@ -178,7 +210,12 @@ fn write_raw_document(dir: &Path, document: &StoredDocument) { } /// Bypass helper writing EXACT raw bytes (restore fixtures) UNDER the -/// sidecar lock, mirroring the strict path's discipline. +/// sidecar lock, mirroring the strict path's discipline: like every real +/// writer it replaces the document (sibling temp + rename), so the file is a +/// new inode and the stamp peek of name-reliability Task 11 sees the change +/// at once. An in-place same-length rewrite inside one coarse mtime tick is +/// the case the peek's periodic full check bounds instead +/// (`stale_stamp_window_is_bounded_by_the_periodic_full_check`). fn write_raw_document_bytes(dir: &Path, bytes: &[u8]) { let lock = open_lock_file(&dir.join(LOCK_FILE_NAME)).expect("open lock for raw write"); // Mirror `acquire_document_lock`'s bounded-retry discipline — a bare @@ -200,7 +237,9 @@ fn write_raw_document_bytes(dir: &Path, bytes: &[u8]) { Err(error) => panic!("lock for raw write: {error}"), } } - std::fs::write(dir.join(DOCUMENT_FILE_NAME), bytes).expect("write raw document"); + let staged = dir.join(".session-names.raw-fixture.tmp"); + std::fs::write(&staged, bytes).expect("write raw document"); + std::fs::rename(&staged, dir.join(DOCUMENT_FILE_NAME)).expect("install raw document"); } /// The contract rank order (independent of the implementation's helper). @@ -602,7 +641,8 @@ async fn post_replace_error_fences_until_reconciled() { // The writer self-heals on its next transaction via reconciliation; a // forced reconciliation failure keeps the fence up. set_test_hooks(dir.path(), vec![TestHook::FailReconcile]); - let still_fenced = store.get(vec![target.clone()]).await; + let still_fenced = + session_name_store_view::get_settled_for_test(&store, vec![target.clone()]).await; assert!( matches!(still_fenced, Err(NameError::CommitUncertain(_))), "adoption stays fenced until durability reconciliation succeeds" @@ -614,7 +654,7 @@ async fn post_replace_error_fences_until_reconciled() { ); clear_test_hooks(dir.path()); - let adopted = get_one(&store, target.clone()) + let adopted = get_one_settled(&store, target.clone()) .await .expect("reconciliation succeeds on the next transaction"); assert_eq!(adopted.record.name, "Uncertain name"); @@ -652,7 +692,7 @@ async fn vanished_document_fences_established_store() { std::fs::remove_file(&doc_path).expect("document vanishes"); // The next operation fences instead of silently reinitializing. - let fenced = store.get(vec![target.clone()]).await; + let fenced = session_name_store_view::get_settled_for_test(&store, vec![target.clone()]).await; assert!( matches!(fenced, Err(NameError::Persistence(ref message)) if message.contains("vanished")), "a vanished document is an error, not reinitialization: {fenced:?}" @@ -1267,9 +1307,11 @@ async fn a_child_forked_while_the_worker_lock_is_held_does_not_keep_it() { /// Local serialization (plan Task 1, item 5): the bounded one-second retry /// budget is for contention with ANOTHER process. A transaction of this same /// process that holds the document lock longer than that (a durable write -/// stalled by a busy disk) makes the next same-process caller wait its turn, -/// never fail with `LockUnavailable`. A wait that long is still diagnosable: -/// it logs one structured `session_names.local_turn_slow` warning. +/// stalled by a busy disk) makes the next same-process transaction wait its +/// turn, never fail with `LockUnavailable`. A wait that long is still +/// diagnosable: it logs one structured `session_names.local_turn_slow` +/// warning. (A plain `get` never queues at all: it answers from the adopted +/// view, `session_name_store_view`.) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_slow_local_transaction_queues_same_process_callers_instead_of_failing_them() { let events = crate::test_trace_capture::captured_trace_events(); @@ -1293,13 +1335,12 @@ async fn a_slow_local_transaction_queues_same_process_callers_instead_of_failing }); wait_for_lock_held_sentinel(dir.path()).await; - let read = store - .get(vec![target.clone()]) + let read = read_through_transaction(&store, &target) .await - .expect("a same-process read waits for the local holder instead of failing"); + .expect("a same-process transaction waits for the local holder instead of failing"); assert_eq!( - read[0].record.name, "Slow writer", - "the queued read runs after the slow writer commits" + read.record.name, "Slow writer", + "the queued transaction runs after the slow writer commits" ); let committed = slow_writer .await @@ -1321,7 +1362,7 @@ async fn a_slow_local_transaction_queues_same_process_callers_instead_of_failing assert_eq!( slow_turns.len(), 1, - "exactly the queued read logs a slow local turn: {slow_turns:?}" + "exactly the queued transaction logs a slow local turn: {slow_turns:?}" ); assert!( slow_turns[0] @@ -1387,7 +1428,7 @@ async fn a_stalled_local_turn_is_reported_while_the_caller_is_still_queued() { let queued_read = tokio::spawn({ let store = Arc::clone(&store); let target = target.clone(); - async move { store.get(vec![target]).await } + async move { read_through_transaction(&store, &target).await } }); // The holder never finishes on its own: the warning must arrive while @@ -1428,7 +1469,7 @@ async fn a_stalled_local_turn_is_reported_while_the_caller_is_still_queued() { .await .expect("queued read joins") .expect("the queued read is served once the stalled turn ends"); - assert_eq!(read[0].record.name, "Stalled writer"); + assert_eq!(read.record.name, "Stalled writer"); stalled_writer .await .expect("stalled writer joins") @@ -1496,12 +1537,11 @@ async fn same_process_callers_are_served_in_arrival_order_between_back_to_back_t }); wait_for_lock_held_sentinel(dir.path()).await; - let read = store - .get(vec![target.clone()]) + let read = read_through_transaction(&store, &target) .await .expect("a caller arriving between back-to-back local transactions is served in turn"); assert_eq!( - read[0].record.name, "First", + read.record.name, "First", "served right after the transaction running when it arrived, ahead of later ones" ); back_to_back.await.expect("background loop joins"); @@ -1572,7 +1612,8 @@ async fn decreasing_and_equal_generation_corruption_are_errors() { let mut older = read_raw_document(dir.path()); older.document_generation = 1; write_raw_document(dir.path(), &older); - let decreased = store.get(vec![target.clone()]).await; + let decreased = + session_name_store_view::get_settled_for_test(&store, vec![target.clone()]).await; assert!( matches!(decreased, Err(NameError::Persistence(_))), "decreasing generations are errors" @@ -1584,7 +1625,7 @@ async fn decreasing_and_equal_generation_corruption_are_errors() { let key = name_ref_key(&target); tampered.records.get_mut(&key).expect("tamper target").name = "Tampered".to_string(); write_raw_document(dir.path(), &tampered); - let equal = store.get(vec![target.clone()]).await; + let equal = session_name_store_view::get_settled_for_test(&store, vec![target.clone()]).await; assert!( matches!(equal, Err(NameError::Persistence(_))), "equal generation with different content is an error" @@ -1594,7 +1635,7 @@ async fn decreasing_and_equal_generation_corruption_are_errors() { let mut newer = read_raw_document(dir.path()); newer.document_generation = established_generation + 1; write_raw_document(dir.path(), &newer); - let recovered = get_one(&store, target.clone()) + let recovered = get_one_settled(&store, target.clone()) .await .expect("newer generation adopted after corruption"); assert_eq!(recovered.record.name, "Tampered"); @@ -1625,7 +1666,13 @@ async fn locally_delayed_publication_cannot_revert_newer_state() { let bytes = serialize_document(&stale.document).expect("serialize stale doc"); let digest = digest_bytes(&bytes); - let adopted = adopt_if_newer(&store.core, stale.document.clone(), digest, true); + let adopted = adopt_if_newer( + &store.core, + stale.document.clone(), + digest, + stale.stamp, + true, + ); assert!(!adopted, "a stale projection cannot overwrite newer state"); assert!( subscriber.try_recv().is_err(), @@ -2232,18 +2279,26 @@ async fn observe_native_folds_provider_titles_and_own_write_echoes() { assert!(!stale.changed, "old-location observations never fold names"); assert_eq!(stale.record.name, "Native title"); - // Equal-rank automatic observations preserve the accepted value. + // Name-reliability Task 4: an ECHO of the native title the provider + // already reported (the record's baseline) preserves the accepted value + // at the current location — provenance only, no document write. + let echo_generation = read_raw_document(dir.path()).document_generation; let equal = store .observe_native(observe( target.clone(), - "Different provider title", + "Native title", NativeNameOrigin::ProviderAi, 1, )) .await - .expect("equal-rank observation"); + .expect("echo observation"); assert!(!equal.changed); assert_eq!(equal.record.name, "Native title"); + assert_eq!( + read_raw_document(dir.path()).document_generation, + echo_generation, + "an echo writes nothing" + ); // Invalid titles (an external rename over the accepted-name cap) never // fail the transaction: only the offer is skipped — no rename, no error @@ -2285,6 +2340,21 @@ async fn observe_native_folds_provider_titles_and_own_write_echoes() { observation.location_revision, 0, "the losing no-op observation did not overwrite the committed provenance" ); + + // Name-reliability Task 4: a provider title that actually CHANGED (it + // differs from the baseline) replaces the older provider-level name. + let changed = store + .observe_native(observe( + target.clone(), + "Different provider title", + NativeNameOrigin::ProviderAi, + 1, + )) + .await + .expect("changed-title observation"); + assert!(changed.changed); + assert_eq!(changed.record.name, "Different provider title"); + assert_eq!(changed.record.source, NameSource::ProviderAi); } /// Delta-review round 4, finding 2: the mutate-then-`Decision::Read` hazard. @@ -2405,13 +2475,14 @@ async fn observation_only_native_titles_do_not_rewrite_the_document() { assert_eq!(folded.record.name, "Native title"); let generation = read_raw_document(dir.path()).document_generation; - // An equal-rank automatic observation LOSES (equal-rank observations - // preserve the accepted value) and there is no series to rearm: the only - // thing this observation could move is last_observation.at. + // An echo of the same native title (the record's baseline; name- + // reliability Task 4 — a CHANGED title would now replace the name) + // changes nothing and there is no series to rearm: the only thing this + // observation could move is last_observation.at. let noop = store .observe_native(observe( target.clone(), - "A different provider title", + "Native title", NativeNameOrigin::ProviderAi, 0, )) @@ -2746,6 +2817,7 @@ async fn publisher_recovers_a_lagged_subscription_by_re_diffing_every_record() { document_generation: 1, changed: true, native_sync: None, + outcome: None, }; tx.send(dummy("buried-1")).expect("send 1"); tx.send(dummy("buried-2")).expect("send 2"); @@ -2848,6 +2920,7 @@ async fn publisher_directory_invalidation_tracks_title_overriding_changes() { document_generation: 1, changed, native_sync: None, + outcome: None, }; // An automatic first-message install (changed: true) and a status-only diff --git a/crates/freshell-server/src/sessions.rs b/crates/freshell-server/src/sessions.rs index 5695f5836..c231c4d93 100644 --- a/crates/freshell-server/src/sessions.rs +++ b/crates/freshell-server/src/sessions.rs @@ -354,12 +354,23 @@ async fn scoped_session_rename( .into_response() } }; + // Name-reliability Task 4 (LB-B8): the session index row's folder. + let cwd = crate::session_name_routes::rename_target_cwd( + &target, + &state.identity, + state.index.as_deref(), + ); let update = match crate::session_name_routes::rename_through_authority( sink.as_ref(), - target, - name.to_string(), - intent, - if_revision, + freshell_freshagent::naming::RenameNameInput { + target, + name: name.to_string(), + intent, + if_revision, + cwd, + }, + &state.identity, + state.index.as_deref(), ) .await { @@ -569,8 +580,10 @@ async fn scoped_generate_title( }; if let Some(generator) = &state.generation_wake { // Ensure already-eligible unattempted work: the wake makes the - // worker's next pass immediate. Never replenishes anything. - generator.notify_capability_change(); + // worker's next pass immediate. Never replenishes anything, and + // never clears the naming pause (name-reliability Task 6: only a + // key change does). + generator.wake(); } match record { Some(record) => Json(json!({ diff --git a/crates/freshell-server/src/settings_store.rs b/crates/freshell-server/src/settings_store.rs index 3cbe1e99e..58c818303 100644 --- a/crates/freshell-server/src/settings_store.rs +++ b/crates/freshell-server/src/settings_store.rs @@ -2104,6 +2104,13 @@ pub struct SettingsRouterState { /// `AI_CONFIG.applySettingsKey(key, { force: true })` on the settings /// save path (`ai-prompts.ts:17-23`). A blank/absent key never clears. pub ai_key: crate::ai_title::AiKeyCell, + /// Name-reliability Task 6: the log writer's live secrets; a saved key + /// joins them before anything can log it. + pub log_secrets: freshell_runtime_observability::LiveSecrets, + /// Name-reliability Task 6: the naming generator, told when the saved + /// key changes (it clears the naming pause and wakes the worker). + /// `None` when naming is not wired. + pub naming_generation: Option>, } /// `GET`/`PATCH`/`PUT` `/api/settings` (R1: PUT === PATCH, matching @@ -2139,9 +2146,14 @@ async fn patch_settings( // Task 2: force-re-apply the settings key on EVERY successful save // (Node's `AI_CONFIG.applySettingsKey(key, { force: true })`, // `ai-prompts.ts:17-23`); blank/absent never clears the cell. - state - .ai_key - .apply_settings_key_forced(merged.ai.gemini_api_key.as_deref()); + // Name-reliability Task 6: the value becomes a live log secret, + // and a changed key resets the naming pause. + crate::session_name_capability::apply_saved_gemini_key( + &state.ai_key, + &state.log_secrets, + state.naming_generation.as_deref(), + merged.ai.gemini_api_key.as_deref(), + ); if let Ok(frame) = serde_json::to_string(&json!({ "type": "settings.updated", "settings": &merged })) { @@ -2212,6 +2224,8 @@ mod tests { fresh_codex, registry: freshell_terminal::TerminalRegistry::new(), ai_key: crate::ai_title::AiKeyCell::default(), + log_secrets: freshell_runtime_observability::LiveSecrets::default(), + naming_generation: None, } } @@ -3774,6 +3788,8 @@ mod tests { json!({}), ), registry: freshell_terminal::TerminalRegistry::new(), + log_secrets: freshell_runtime_observability::LiveSecrets::default(), + naming_generation: None, }; let resp = patch_settings( diff --git a/crates/freshell-server/src/terminals.rs b/crates/freshell-server/src/terminals.rs index a033ccd9d..a5c269eaf 100644 --- a/crates/freshell-server/src/terminals.rs +++ b/crates/freshell-server/src/terminals.rs @@ -1172,12 +1172,20 @@ async fn scoped_terminal_rename( .into_response() } }; + // Name-reliability Task 4 (LB-B8): the terminal row's folder. + let cwd = + crate::session_name_routes::terminal_row_cwd(&state.registry, &state.identity, terminal_id); let update = match crate::session_name_routes::rename_through_authority( sink.as_ref(), - target, - name.to_string(), - intent, - if_revision, + freshell_freshagent::naming::RenameNameInput { + target, + name: name.to_string(), + intent, + if_revision, + cwd, + }, + &state.identity, + None, ) .await { diff --git a/crates/freshell-server/src/test_trace_capture.rs b/crates/freshell-server/src/test_trace_capture.rs index 29e284d39..8d3594770 100644 --- a/crates/freshell-server/src/test_trace_capture.rs +++ b/crates/freshell-server/src/test_trace_capture.rs @@ -55,7 +55,10 @@ unsafe fn install_process_trace_capture() { /// inspect tracing events read it and filter by values unique to their own /// fixture, because every test in the binary feeds the same buffer. pub(crate) fn captured_trace_events() -> CapturedTraceEvents { - process_capture().clone() + let events = process_capture().clone(); + // Recompute interest after the process-wide subscriber is installed. + tracing::callsite::rebuild_interest_cache(); + events } fn process_capture() -> &'static CapturedTraceEvents { @@ -129,6 +132,8 @@ fn is_captured(metadata: &tracing::Metadata<'_>) -> bool { "freshell_server::session_names" => { level == tracing::Level::WARN || level == tracing::Level::INFO } + // Only first-message lookup failures are retained in on_event. + "freshell_server::auto_title_sweep" => level == tracing::Level::WARN, _ => false, } } @@ -206,6 +211,72 @@ where return; } + if metadata.target() == "freshell_server::auto_title_sweep" + && visitor.message.split(':').next() + != Some("session_names.first_message_lookup_failed") + { + return; + } + + self.events.0.lock().unwrap().push(CapturedTraceEvent { + level: *metadata.level(), + message: visitor.message, + fields: visitor.fields, + }); + } +} + +/// A capture for the CURRENT THREAD only: a scoped default subscriber that +/// keeps every event (any level) whose target starts with `target_prefix`, +/// until the returned guard drops. Tests that count log lines use it so a +/// test running in parallel cannot add lines to their count; everything the +/// test drives must run on its own thread (a current-thread runtime). +pub(crate) fn scoped_trace_capture( + target_prefix: &'static str, +) -> (tracing::subscriber::DefaultGuard, CapturedTraceEvents) { + use tracing_subscriber::prelude::*; + + // While exactly one dispatcher is registered, tracing-core decides a + // call site's interest from the CALLING thread's default dispatcher + // (its "just one" shortcut). So when this capture is the only + // dispatcher, a call site first reached on another test's thread (which + // has no subscriber) caches "never", and this capture silently misses + // that site's events for the rest of the run. A second dispatcher that + // is never dropped (and is interested in nothing) keeps tracing-core on + // the path that asks every live dispatcher. + static ANCHOR: OnceLock = OnceLock::new(); + ANCHOR.get_or_init(|| tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default())); + + let events = CapturedTraceEvents::default(); + let subscriber = tracing_subscriber::registry().with(ScopedCaptureLayer { + target_prefix, + events: events.clone(), + }); + let guard = tracing::subscriber::set_default(subscriber); + tracing::callsite::rebuild_interest_cache(); + (guard, events) +} + +struct ScopedCaptureLayer { + target_prefix: &'static str, + events: CapturedTraceEvents, +} + +impl tracing_subscriber::Layer for ScopedCaptureLayer +where + S: tracing::Subscriber, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _context: tracing_subscriber::layer::Context<'_, S>, + ) { + let metadata = event.metadata(); + if !metadata.target().starts_with(self.target_prefix) { + return; + } + let mut visitor = TraceFieldVisitor::default(); + event.record(&mut visitor); self.events.0.lock().unwrap().push(CapturedTraceEvent { level: *metadata.level(), message: visitor.message, diff --git a/crates/freshell-server/src/tests/managed_runtime_lifecycle_tests.rs b/crates/freshell-server/src/tests/managed_runtime_lifecycle_tests.rs index 4419794f3..181c82a5f 100644 --- a/crates/freshell-server/src/tests/managed_runtime_lifecycle_tests.rs +++ b/crates/freshell-server/src/tests/managed_runtime_lifecycle_tests.rs @@ -177,7 +177,7 @@ async fn directory_fixture_with_retry( .await .unwrap() .unwrap_or_else(|| managed_descriptor_from_view(&view, None).unwrap()); - registry.register_managed(descriptor); + registry.register_managed(descriptor, "Shell".to_string()); let read = registry .refresh_managed_output("directory-retained", 65536) .await diff --git a/crates/freshell-sessions/src/directory_index.rs b/crates/freshell-sessions/src/directory_index.rs index da7b74ad3..2c2d74eb0 100644 --- a/crates/freshell-sessions/src/directory_index.rs +++ b/crates/freshell-sessions/src/directory_index.rs @@ -46,6 +46,9 @@ use crate::codex_segments::{ compose_codex_segments, scan_codex_file_bytes_evidence, CodexComposition, CodexFileEvidence, CodexSegmentEntry, CodexUnresolvedIdentity, }; +use crate::first_message_rules::{ + cached_first_message_is_stale, log_reparse, FIRST_MESSAGE_RULES_VERSION, +}; use crate::meta::ParsedSessionMeta; use crate::provider_layout::ProviderLayout; use crate::{parse_codex_session_content, parse_session_content, ParseSessionOptions}; @@ -102,7 +105,9 @@ pub struct IndexedSession { /// stamp; see `parse::is_opencode_placeholder_title`). opencode /// names parent sessions itself after the first exchange. `None` for /// placeholder-titled sessions (those instead carry - /// `first_user_message` for the first-message/AI rungs). + /// `first_user_message` for the first-message/AI rungs, and present + /// its derived title — never the placeholder — as [`Self::title`]; + /// see `opencode_row_title`). pub title_source: Option, pub last_activity_at: i64, pub created_at: Option, @@ -319,12 +324,19 @@ pub trait SessionSource: Send + Sync { /// messages only for placeholder-titled rows — the naming sweep needs /// the single named session's first real user message on demand (one /// bounded read-only query, never a full-history pass). `None` (the - /// default) = this source has no targeted accessor. - fn targeted_first_user_message(&self, _session_id: &str) -> Option { + /// default) = this source has no targeted accessor; otherwise the read's + /// answer or its failure ([`FirstUserMessageRead`]). + fn targeted_first_user_message(&self, _session_id: &str) -> Option { None } } +/// One targeted first-user-message read: `Ok(Some(_))` the message, +/// `Ok(None)` the session has no user message, `Err` the read failed — a +/// failure is never the "no user message" answer (name-reliability Task 5 +/// review: the naming sweep remembers answers across passes). +pub type FirstUserMessageRead = Result, crate::parse::OpencodeByIdError>; + /// Open a provider ROOT directory for the SINGLE-PASS checked traversals /// below: a MISSING root (`NotFound`/`NotADirectory`) is a genuine empty /// (an absent provider — matches Node's ENOENT tolerance, `Ok(None)`), while @@ -854,18 +866,21 @@ impl OpencodeSource { /// lookup for an already-named opencode session (see /// [`SessionSource::targeted_first_user_message`]) — one bounded /// read-only sqlite query through the same data home the listing uses. - pub fn first_user_message_for(&self, session_id: &str) -> Option { + pub fn first_user_message_for(&self, session_id: &str) -> FirstUserMessageRead { let database_path = self.provider.database_path(); - let data_home = database_path.parent()?; + let data_home = database_path + .parent() + .ok_or_else(|| crate::parse::OpencodeByIdError { + code: None, + message: "the opencode database path has no parent directory".to_string(), + })?; crate::parse::opencode::opencode_first_user_message_by_id(data_home, session_id) - .ok() - .flatten() } } impl SessionSource for OpencodeSource { - fn targeted_first_user_message(&self, session_id: &str) -> Option { - self.first_user_message_for(session_id) + fn targeted_first_user_message(&self, session_id: &str) -> Option { + Some(self.first_user_message_for(session_id)) } fn discover(&self) -> Vec { @@ -932,19 +947,20 @@ fn opencode_session_to_indexed( // override rows (auto_title_sweep.rs::overlay_session_title). Deliberate // mainline deviation from the retired Node reference, which never // populated titleSource or firstUserMessage for opencode. - let provider_named = s - .title - .as_deref() - .is_some_and(|t| !t.trim().is_empty() && !crate::parse::is_opencode_placeholder_title(t)); + let provider_named = crate::opencode_row_title::is_opencode_session_name(s.title.as_deref()); // Compute BEFORE the literal: the IndexedSession construction below // moves fields out of `s`, and the helper borrows the whole struct. let token_usage = opencode_token_usage(&s, resolver); + // Name-reliability Task 4: a placeholder (or blank) title is never the + // row's title — the first message's title stands in, else none. + let title = + crate::opencode_row_title::opencode_row_title(s.title, s.first_user_message.as_deref()); IndexedSession { session_id: s.session_id, legacy_session_id: None, provider: "opencode".to_string(), project_path: s.project_path, - title: s.title, + title, // bool twin of `title_source` -- same predicate, kept consistent // (see parse/claude.rs's identical convention). title_provider_generated: provider_named, @@ -1070,6 +1086,13 @@ struct FileEntry { /// partial composition. #[serde(default)] codex_evidence: Option, + /// The first-message rules version this entry was derived under + /// (name-reliability Task 5; `crate::first_message_rules`). Older cache + /// files omit it (0); `load_cache_file` drops an older entry whose + /// cached first message the newer rules would derive differently, and + /// stamps every entry it keeps. An older binary ignores the field. + #[serde(default)] + first_message_rules: u32, } /// The cached, TTL-refreshed session index composed from one or more @@ -1298,12 +1321,14 @@ impl SessionIndex { /// a targeted accessor (opencode — see /// [`SessionSource::targeted_first_user_message`]). One bounded /// read-only query per call; the naming sweep consults it only for - /// sessions whose generation can still use input, so exhausted or - /// protected sessions never pay the lookup. - pub fn opencode_first_user_message(&self, session_id: &str) -> Option { + /// sessions that can still use the message, so exhausted or protected + /// sessions never pay the lookup. No source with a targeted accessor + /// answers `Ok(None)`; a failed read is an `Err`. + pub fn opencode_first_user_message(&self, session_id: &str) -> FirstUserMessageRead { self.sources .iter() .find_map(|source| source.targeted_first_user_message(session_id)) + .unwrap_or(Ok(None)) } /// Providers whose MOST RECENT listing attempt failed (unsearchable, not @@ -2484,6 +2509,7 @@ fn refresh_snapshot( size: stat.size, item, codex_evidence, + first_message_rules: FIRST_MESSAGE_RULES_VERSION, }, ); if content_moved { @@ -2539,6 +2565,7 @@ fn refresh_snapshot( size: stat.size, item, codex_evidence, + first_message_rules: FIRST_MESSAGE_RULES_VERSION, }, ); if content_moved { @@ -2759,10 +2786,11 @@ fn load_cache_file(path: &Path) -> HashMap { ); return HashMap::new(); } - parsed + let mut stale_first_messages = 0usize; + let entries = parsed .entries .into_iter() - .filter_map(|(key, entry)| { + .filter_map(|(key, mut entry)| { let path = PathBuf::from(key); let has_legacy_claude_identity = entry.item.as_ref().is_some_and(|item| { item.provider == "claude" @@ -2773,9 +2801,30 @@ fn load_cache_file(path: &Path) -> HashMap { // discard only legacy Claude rows that cached an embedded // (usually parent) sessionId. Matching Claude entries, exclusions // (`item: None`), and every other provider remain warm. - (!has_legacy_claude_identity).then_some((path, entry)) + if has_legacy_claude_identity { + return None; + } + // Name-reliability Task 5: the same targeted invalidation for a + // first message the older rules derived from tool wrapper text + // (re-parsed once); every kept entry is stamped current, so it + // is never content-retested. + let stale_first_message = entry.item.as_ref().is_some_and(|item| { + cached_first_message_is_stale( + entry.first_message_rules, + &item.provider, + item.first_user_message.as_deref(), + ) + }); + if stale_first_message { + stale_first_messages += 1; + return None; + } + entry.first_message_rules = FIRST_MESSAGE_RULES_VERSION; + Some((path, entry)) }) - .collect() + .collect(); + log_reparse(stale_first_messages); + entries } /// Atomically persist `cache` to `path`: write the full JSON payload to a @@ -5320,6 +5369,113 @@ pub(crate) mod tests { std::fs::remove_dir_all(&data_home).ok(); } + /// Name-reliability Task 4: opencode's temporary placeholder ("New + /// session - ") is never a row's title — the sidebar and history + /// render `title` whenever no saved name is known yet, so a placeholder + /// here showed on screen until the session was named. Such a row + /// presents the title derived from its first user message (the Claude + /// and Codex convention for a transcript without a provider title), else + /// no title; a blank stored title is treated the same way, and a real + /// opencode name is kept as it is. + #[test] + fn opencode_placeholder_title_is_never_the_row_title() { + let data_home = opencode_data_home_with_sessions( + "opencodesrc-placeholder-title", + &[ + ( + "ses_with_message", + "/repo/a", + "New session - 2026-10-20T00:09:25.350Z", + 1000, + 5000, + ), + ( + "ses_without_message", + "/repo/b", + "New session - 2026-10-20T00:09:26.350Z", + 1000, + 4000, + ), + ("ses_blank", "/repo/c", " ", 1000, 3000), + ( + "ses_named", + "/repo/d", + "Publishing blocked by token error", + 1000, + 2000, + ), + ], + ); + seed_opencode_user_message( + &data_home, + "ses_with_message", + "msg_1", + 100, + "user", + &[( + "prt_1", + r#"{"type":"text","text":" Check the publishing pipeline\nthen report back "}"#, + )], + ); + seed_opencode_user_message( + &data_home, + "ses_blank", + "msg_2", + 100, + "user", + &[("prt_2", r#"{"type":"text","text":"Rename the blank one"}"#)], + ); + let items = OpencodeSource::new(data_home.clone()).scan(); + let title_of = |id: &str| { + items + .iter() + .find(|item| item.session_id == id) + .unwrap_or_else(|| panic!("{id} is listed")) + .title + .clone() + }; + assert_eq!( + title_of("ses_with_message").as_deref(), + Some("Check the publishing pipeline"), + "a placeholder row presents its first message's title" + ); + assert_eq!( + title_of("ses_without_message"), + None, + "a placeholder row with no user message presents no title" + ); + assert_eq!( + title_of("ses_blank").as_deref(), + Some("Rename the blank one"), + "a blank stored title is not a name either" + ); + assert_eq!( + title_of("ses_named").as_deref(), + Some("Publishing blocked by token error"), + "opencode's own name is the row title" + ); + for item in &items { + let title = item.title.as_deref().unwrap_or_default(); + assert!( + !crate::name_evidence::is_provider_placeholder_title("opencode", title), + "{} presents the placeholder {title:?}", + item.session_id + ); + } + // The derived title is not a provider name: the auto-title ladder + // still treats these sessions as unnamed. + let unnamed = items + .iter() + .filter(|item| item.session_id != "ses_named") + .collect::>(); + assert_eq!(unnamed.len(), 3); + for item in unnamed { + assert_eq!(item.title_source, None, "{}", item.session_id); + assert!(!item.title_provider_generated, "{}", item.session_id); + } + std::fs::remove_dir_all(&data_home).ok(); + } + #[test] fn opencode_native_title_maps_to_provider_generated_and_skips_message_fetch() { let data_home = opencode_data_home_with_sessions( @@ -6255,6 +6411,7 @@ pub(crate) mod tests { size: matching_stat.size, item: source.parse(&matching_path), codex_evidence: None, + first_message_rules: 0, }, ), ( @@ -6265,6 +6422,7 @@ pub(crate) mod tests { size: child_stat.size, item: Some(legacy_child), codex_evidence: None, + first_message_rules: 0, }, ), ]); @@ -6311,6 +6469,412 @@ pub(crate) mod tests { std::fs::remove_dir_all(&cache_dir).ok(); } + /// Name-reliability Task 5: a Codex source whose parses are counted while + /// every Codex-specific path (evidence, composition) stays the real one. + struct CountedCodexSource { + inner: CodexSource, + parse_calls: Arc, + } + + impl SessionSource for CountedCodexSource { + fn discover(&self) -> Vec { + self.inner.discover() + } + + fn parse(&self, path: &Path) -> Option { + self.parse_calls.fetch_add(1, Ordering::SeqCst); + self.inner.parse(path) + } + + fn parse_with_codex_evidence( + &self, + path: &Path, + ) -> (Option, Option) { + self.parse_calls.fetch_add(1, Ordering::SeqCst); + self.inner.parse_with_codex_evidence(path) + } + + fn compose_codex_segments(&self, entries: Vec) -> CodexComposition { + self.inner.compose_codex_segments(entries) + } + + fn provider_name(&self) -> Option<&'static str> { + self.inner.provider_name() + } + + fn discover_checked(&self) -> Result, std::io::Error> { + self.inner.discover_checked() + } + + fn stat_scoped(&self, path: &Path) -> Option { + self.inner.stat_scoped(path) + } + } + + const RECOMMENDED_PLUGINS_BLOCK: &str = + "\n- github: review pull requests\n"; + + /// One Codex rollout whose first user record is the given text, then a + /// real prompt. + fn write_codex_rollout( + sessions: &Path, + file: &str, + id: &str, + first_record: &str, + prompt: &str, + ) -> PathBuf { + let lines = [ + serde_json::json!({ + "timestamp": "2026-10-01T00:00:00.000Z", + "type": "session_meta", + "payload": {"id": id, "cwd": "/project/sardines", "model_provider": "openai"}, + }), + serde_json::json!({ + "timestamp": "2026-10-01T00:00:01.000Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": first_record}], + }, + }), + serde_json::json!({ + "timestamp": "2026-10-01T00:00:02.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": prompt}, + }), + ]; + let body = lines + .iter() + .map(|line| serde_json::to_string(line).unwrap()) + .collect::>() + .join("\n") + + "\n"; + let path = sessions.join(file); + std::fs::write(&path, body).unwrap(); + path + } + + /// The cache entry the pre-Task-5 parser wrote for `path`: the current + /// parse with the old extraction's tag-led first message (and the title + /// derived from it), unstamped. + fn pre_task5_entry(source: &CodexSource, path: &Path, stale_first: Option<&str>) -> FileEntry { + let stat = stat_file(path).expect("rollout stat"); + let (item, codex_evidence) = source.parse_with_codex_evidence(path); + let mut item = item.expect("rollout parses"); + if let Some(stale) = stale_first { + item.first_user_message = Some(stale.to_string()); + item.title = Some(crate::text::extract_title_from_message(stale, 200)); + } + FileEntry { + source_name: Some("codex".to_string()), + mtime_ms: stat.mtime_ms, + size: stat.size, + item: Some(item), + codex_evidence, + first_message_rules: 0, + } + } + + /// Name-reliability Task 5 (V2, LB-B11): an UNSTAMPED cached Codex entry + /// whose first message starts with a wrapper tag the old rules did not + /// know is dropped at load and re-parsed once, yielding the real first + /// message; an unstamped entry with a real first message and the same + /// `(mtime, size)` stays cached (no re-parse). Both persist stamped, and + /// the schema version an older binary expects is unchanged. + #[tokio::test] + async fn load_cache_file_reparses_stale_tag_led_entries_once() { + let codex_home = unique_temp_dir("persist-tag-led-codex"); + let sessions = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let stale_path = write_codex_rollout( + &sessions, + "rollout-stale.jsonl", + "11111111-0000-4000-8000-000000000001", + RECOMMENDED_PLUGINS_BLOCK, + "Fix the sardine importer", + ); + let real_path = write_codex_rollout( + &sessions, + "rollout-real.jsonl", + "22222222-0000-4000-8000-000000000002", + "Plan the offsite", + "Book the venue", + ); + let source = CodexSource::new(codex_home.clone()); + let stale_cache = HashMap::from([ + ( + stale_path.clone(), + pre_task5_entry(&source, &stale_path, Some(RECOMMENDED_PLUGINS_BLOCK)), + ), + ( + real_path.clone(), + pre_task5_entry(&source, &real_path, None), + ), + ]); + let cache_dir = unique_temp_dir("persist-tag-led-codex-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_path = cache_path_in(&cache_dir); + save_cache_file(&cache_path, &stale_cache).unwrap(); + + let parse_calls = Arc::new(AtomicUsize::new(0)); + let index = SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(CountedCodexSource { + inner: CodexSource::new(codex_home.clone()), + parse_calls: Arc::clone(&parse_calls), + })], + Duration::from_millis(10), + Some(cache_path.clone()), + ); + let snapshot = index.snapshot().await; + assert_eq!( + parse_calls.load(Ordering::SeqCst), + 1, + "only the tag-led entry is re-parsed" + ); + let first_message_of = |id: &str| { + snapshot + .iter() + .find(|row| row.session_id == id) + .and_then(|row| row.first_user_message.clone()) + }; + assert_eq!( + first_message_of("11111111-0000-4000-8000-000000000001").as_deref(), + Some("Fix the sardine importer") + ); + assert_eq!( + first_message_of("22222222-0000-4000-8000-000000000002").as_deref(), + Some("Plan the offsite") + ); + + assert!( + wait_until(Duration::from_secs(2), || { + let persisted = load_cache_file(&cache_path); + persisted.len() == 2 + && persisted + .values() + .all(|entry| entry.first_message_rules == FIRST_MESSAGE_RULES_VERSION) + && persisted.get(&stale_path).is_some_and(|entry| { + entry + .item + .as_ref() + .and_then(|item| item.first_user_message.as_deref()) + == Some("Fix the sardine importer") + }) + }) + .await, + "both entries persist stamped, the re-parsed one with its real first message" + ); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cache_path).unwrap()).unwrap(); + assert_eq!( + raw["schema_version"], + serde_json::json!(3), + "an older binary (schema 3) keeps this warm cache" + ); + + // A later boot re-parses nothing. + let later_calls = Arc::new(AtomicUsize::new(0)); + let later = SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(CountedCodexSource { + inner: CodexSource::new(codex_home.clone()), + parse_calls: Arc::clone(&later_calls), + })], + Duration::from_millis(10), + Some(cache_path.clone()), + ); + let _ = later.snapshot().await; + assert_eq!(later_calls.load(Ordering::SeqCst), 0, "re-parsed once only"); + + std::fs::remove_dir_all(&codex_home).ok(); + std::fs::remove_dir_all(&cache_dir).ok(); + } + + /// Name-reliability Task 5 (LB-B11): a STAMPED entry is never content- + /// retested. Even one whose first message still starts with a wrapper + /// tag (an unclosed `; + #[derive(Clone, Default)] + struct Capture(Arc>>); + impl tracing_subscriber::layer::Layer for Capture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _: tracing_subscriber::layer::Context<'_, S>, + ) { + struct Visit(Fields); + impl tracing::field::Visit for Visit { + fn record_debug( + &mut self, + field: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + self.0.insert(field.name().into(), format!("{value:?}")); + } + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.insert(field.name().into(), value.into()); + } + } + let mut visit = Visit(Fields::new()); + event.record(&mut visit); + if visit.0.get("event").map(String::as_str) + == Some("session_index.first_message_rules_reparse") + { + self.0 + .lock() + .unwrap() + .push((*event.metadata().level(), visit.0)); + } + } + } + let capture = Capture::default(); + let _guard = + tracing::subscriber::set_default(tracing_subscriber::registry().with(capture.clone())); + + let codex_home = unique_temp_dir("persist-reparse-log-codex"); + let sessions = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let source = CodexSource::new(codex_home.clone()); + let entry = |file: &str, id: &str, first: &str, stale: Option<&str>| { + let path = write_codex_rollout(&sessions, file, id, first, "Fix the sardine importer"); + let entry = pre_task5_entry(&source, &path, stale); + (path, entry) + }; + let stale_cache = HashMap::from([ + entry( + "rollout-a.jsonl", + "44444444-0000-4000-8000-000000000004", + RECOMMENDED_PLUGINS_BLOCK, + Some(RECOMMENDED_PLUGINS_BLOCK), + ), + entry( + "rollout-b.jsonl", + "55555555-0000-4000-8000-000000000005", + RECOMMENDED_PLUGINS_BLOCK, + Some(RECOMMENDED_PLUGINS_BLOCK), + ), + entry( + "rollout-c.jsonl", + "66666666-0000-4000-8000-000000000006", + "Plan the offsite", + None, + ), + ]); + let cache_dir = unique_temp_dir("persist-reparse-log-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_path = cache_path_in(&cache_dir); + save_cache_file(&cache_path, &stale_cache).unwrap(); + + let kept = load_cache_file(&cache_path); + assert_eq!(kept.len(), 1, "sanity: the two tag-led entries are dropped"); + let logged = capture.0.lock().unwrap().clone(); + assert_eq!(logged.len(), 1, "one event per load: {logged:?}"); + let (level, fields) = &logged[0]; + assert_eq!(*level, tracing::Level::INFO); + assert_eq!(fields.get("dropped").map(String::as_str), Some("2")); + assert_eq!( + fields.get("rules_version").map(String::as_str), + Some(FIRST_MESSAGE_RULES_VERSION.to_string().as_str()) + ); + + // Nothing stale (the kept entry, now stamped): nothing logged. + save_cache_file(&cache_path, &kept).unwrap(); + assert_eq!(load_cache_file(&cache_path).len(), 1); + assert_eq!( + capture.0.lock().unwrap().len(), + 1, + "a clean load logs nothing" + ); + + std::fs::remove_dir_all(&codex_home).ok(); + std::fs::remove_dir_all(&cache_dir).ok(); + } + /// C1. A missing cache file loads as an empty cache -- no panic. #[test] fn load_cache_file_missing_path_returns_empty_cache() { @@ -6482,6 +7046,7 @@ pub(crate) mod tests { size: canonical_stat.size, item: source.parse(&canonical_path), codex_evidence: None, + first_message_rules: 0, }, ), ( @@ -6492,6 +7057,7 @@ pub(crate) mod tests { size: nested_stat.size, item: source.parse(&nested_path), codex_evidence: None, + first_message_rules: 0, }, ), ]); @@ -6583,6 +7149,7 @@ pub(crate) mod tests { size: 2, item: None, codex_evidence: None, + first_message_rules: 0, }, ); let result = save_cache_file(&path, &cache); diff --git a/crates/freshell-sessions/src/first_message_rules.rs b/crates/freshell-sessions/src/first_message_rules.rs new file mode 100644 index 000000000..05ce760c6 --- /dev/null +++ b/crates/freshell-sessions/src/first_message_rules.rs @@ -0,0 +1,94 @@ +//! Name-reliability Task 5 (V2, LB-B11): which persisted parse-cache entries +//! must be re-derived because the first-message rules changed. +//! +//! The session index keeps every parsed transcript in a persistent cache +//! (`rust-session-cache.json`) keyed by the file's `(mtime, size)`, so an +//! unchanged file is never re-parsed. When the rules that extract a +//! session's first user message change (Task 5 taught +//! [`crate::text::extract_user_authored_text`] the tool wrapper tags), cached +//! entries keep the message the OLD rules derived. Bumping the cache's +//! schema version would re-parse every transcript at boot (17.6 GB on the +//! live host), so instead each entry carries the rules version it was +//! derived under, and only an entry that is below the current version AND +//! whose cached first message opens with a wrapper tag is dropped and +//! re-parsed — once: every entry the parser writes, and every older entry +//! that is kept, is stamped current, and a stamped entry is never +//! content-retested (even one whose re-derived message still opens with a +//! tag, so no message can cause a re-parse on every boot). + +use crate::text::starts_with_context_tag; + +/// The current first-message rules. Bump it whenever the extraction of a +/// Claude or Codex first user message changes so that cached rows must be +/// re-derived, and widen [`cached_first_message_is_stale`] to recognize what +/// the older rules got wrong. +/// +/// - `1`: the tool wrapper tags (``, +/// ``, ...) are system context. +pub(crate) const FIRST_MESSAGE_RULES_VERSION: u32 = 1; + +/// Whether a cached entry derived under rules version `stamp` must be +/// re-parsed: only Claude and Codex rows (the providers whose first message +/// the wrapper tags reach), only below the current version, and only when +/// the cached first message opens with a context tag. +pub(crate) fn cached_first_message_is_stale( + stamp: u32, + provider: &str, + first_user_message: Option<&str>, +) -> bool { + stamp < FIRST_MESSAGE_RULES_VERSION + && matches!(provider, "claude" | "codex") + && first_user_message.is_some_and(starts_with_context_tag) +} + +/// Log one load's re-parse (INFO `session_index.first_message_rules_reparse` +/// with how many entries were dropped and the rules version), so the +/// one-time cost after a rules bump shows up in the JSONL log. A load that +/// dropped nothing logs nothing. +pub(crate) fn log_reparse(dropped: usize) { + if dropped == 0 { + return; + } + tracing::info!( + event = "session_index.first_message_rules_reparse", + dropped, + rules_version = FIRST_MESSAGE_RULES_VERSION, + "session_index.first_message_rules_reparse: cached first messages derived under older rules are re-parsed once" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + const WRAPPER: &str = "\n- github: review pull requests"; + + #[test] + fn only_unstamped_tag_led_claude_and_codex_messages_are_stale() { + assert!(cached_first_message_is_stale(0, "codex", Some(WRAPPER))); + assert!(cached_first_message_is_stale( + 0, + "claude", + Some("Caveat: The messages below") + )); + // Stamped: never retested, whatever the message. + assert!(!cached_first_message_is_stale( + FIRST_MESSAGE_RULES_VERSION, + "codex", + Some(WRAPPER) + )); + // A real message, no message, or another provider stays cached. + assert!(!cached_first_message_is_stale( + 0, + "codex", + Some("Fix the sardine importer") + )); + assert!(!cached_first_message_is_stale(0, "codex", None)); + assert!(!cached_first_message_is_stale(0, "opencode", Some(WRAPPER))); + assert!(!cached_first_message_is_stale( + 0, + "amplifier", + Some(WRAPPER) + )); + } +} diff --git a/crates/freshell-sessions/src/lib.rs b/crates/freshell-sessions/src/lib.rs index ef1dba14d..b4f197eae 100644 --- a/crates/freshell-sessions/src/lib.rs +++ b/crates/freshell-sessions/src/lib.rs @@ -24,8 +24,11 @@ pub mod codex_history; pub mod codex_locator; pub mod codex_segments; pub mod directory_index; +mod first_message_rules; pub mod meta; +pub mod name_evidence; pub mod opencode_locator; +mod opencode_row_title; pub mod parse; pub mod provider_layout; pub mod resume_input; @@ -46,6 +49,9 @@ pub mod watch_plan; pub(crate) static HOME_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); pub use meta::{CodexTaskEventSnapshot, ParsedSessionMeta, TokenSummary}; +pub use name_evidence::{ + is_default_or_derived_name, is_provider_placeholder_title, NameEvidenceContext, +}; pub use parse::{ parse_codex_session_content, parse_session_content, ClaudeUsageAccumulator, ParseSessionOptions, }; diff --git a/crates/freshell-sessions/src/name_evidence.rs b/crates/freshell-sessions/src/name_evidence.rs new file mode 100644 index 000000000..0386555de --- /dev/null +++ b/crates/freshell-sessions/src/name_evidence.rs @@ -0,0 +1,180 @@ +//! The shared default/derived name classifier: one definition of "this text +//! is a default or derived label, not a name a session earned". +//! +//! Twin of `shared/name-evidence.ts`. Both implementations are pinned by the +//! same table, `test/fixtures/name-evidence/cases.json` (consumed by +//! `tests/name_evidence.rs` here and `test/unit/shared/name-evidence.test.ts` +//! on the client), so they cannot drift. Change the rules in both places and +//! add the fixture rows that pin the change. +//! +//! A name is default/derived when, after trimming, it is empty or: +//! - a generated tab label: `Tab ` (exact case, one space); +//! - the basename of the known working directory (only when a cwd is given, +//! so a caller without a folder recognizes only folder-free patterns); +//! - a provider or mode label (`Claude`, `Claude CLI`, `Codex`, `Codex CLI`, +//! `OpenCode`, `Shell`, `Freshclaude`, `Freshcodex`, `Freshopencode`, +//! `Kilroy`, `Gemini`, `Kimi`, `Amplifier`), optionally prefixed with +//! `Recovered ` and/or suffixed with ` agent`, ASCII case-insensitive; +//! - one of the bare words `Recovered`, `Untitled`, `Tab` (case-insensitive); +//! - a provider placeholder title ([`is_provider_placeholder_title`]) for the +//! context's provider (else its mode); with neither known, any provider's; +//! - text that opens with a tool wrapper tag +//! ([`crate::text::starts_with_context_tag`]). Only that leading-tag check +//! is used: the rest of `is_system_context` would also flag real names such +//! as `$ ls -la` or `1, 2, 3`. +//! +//! Pure functions, no IO; callers log their own decisions. + +use crate::parse::is_opencode_placeholder_title; +use crate::text::starts_with_context_tag; + +/// What the caller knows about the target of a name. Every field is +/// optional; an unknown field only narrows what can be recognized (no cwd → +/// no folder-name rule), it never makes a real name look like a default. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct NameEvidenceContext<'a> { + /// The target's working directory; its basename is a derived label. + pub cwd: Option<&'a str>, + /// The session provider wire string (`claude`, `codex`, `opencode`). + pub provider: Option<&'a str>, + /// The pane/terminal mode; stands in for `provider` when that is unknown. + pub mode: Option<&'a str>, +} + +/// Provider and mode labels, lowercase. Matched after removing an optional +/// `recovered ` prefix and ` agent` suffix. +const PROVIDER_OR_MODE_LABELS: &[&str] = &[ + "claude", + "claude cli", + "codex", + "codex cli", + "opencode", + "shell", + "freshclaude", + "freshcodex", + "freshopencode", + "kilroy", + "gemini", + "kimi", + "amplifier", +]; + +/// Bare default words, lowercase. +const BARE_DEFAULT_WORDS: &[&str] = &["recovered", "untitled", "tab"]; + +/// Providers that mint placeholder titles of their own. +const PLACEHOLDER_PROVIDERS: &[&str] = &["opencode"]; + +/// Whether `name` is a default or derived label rather than a real name. +/// See the module docs for the exact rules. +pub fn is_default_or_derived_name(name: &str, ctx: &NameEvidenceContext<'_>) -> bool { + let name = name.trim(); + name.is_empty() + || is_tab_number_label(name) + || is_cwd_basename(name, ctx.cwd) + || is_provider_or_mode_label(name) + || is_placeholder_for_context(name, ctx) + || starts_with_context_tag(name) +} + +/// Whether `title` is a placeholder the given provider writes for a session +/// it has not named yet. Only OpenCode has them (`New session - `, +/// `Child session - `, legacy `New Session - `); the answer for +/// OpenCode is exactly the directory index's own predicate. Not trimmed, so +/// it agrees with that predicate byte for byte. +pub fn is_provider_placeholder_title(provider: &str, title: &str) -> bool { + match provider { + "opencode" => is_opencode_placeholder_title(title), + _ => false, + } +} + +/// The client's generated tab label, `Tab `: exact case, one space, +/// ASCII digits, nothing else (the `^Tab \d+$` rule). +fn is_tab_number_label(text: &str) -> bool { + text.strip_prefix("Tab ") + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) +} + +fn is_cwd_basename(name: &str, cwd: Option<&str>) -> bool { + cwd.and_then(basename_segment) + .is_some_and(|segment| segment == name) +} + +fn is_provider_or_mode_label(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if BARE_DEFAULT_WORDS.contains(&lower.as_str()) { + return true; + } + let core = lower.strip_prefix("recovered ").unwrap_or(&lower); + let core = core.strip_suffix(" agent").unwrap_or(core); + PROVIDER_OR_MODE_LABELS.contains(&core) +} + +fn is_placeholder_for_context(name: &str, ctx: &NameEvidenceContext<'_>) -> bool { + fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|v| !v.is_empty()) + } + match non_empty(ctx.provider).or_else(|| non_empty(ctx.mode)) { + Some(provider) => is_provider_placeholder_title(provider, name), + None => PLACEHOLDER_PROVIDERS + .iter() + .any(|provider| is_provider_placeholder_title(provider, name)), + } +} + +/// `shared/path-basename.ts` `basenameSegment`: the last path segment, +/// either separator, trailing separators ignored, `/` for the Unix root and +/// `C:\` for a bare drive. +fn basename_segment(path: &str) -> Option { + let trimmed = path.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + return path.starts_with('/').then(|| "/".to_string()); + } + let b = trimmed.as_bytes(); + if b.len() == 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return Some(format!("{trimmed}\\")); + } + trimmed + .rsplit(['/', '\\']) + .next() + .filter(|last| !last.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tab_number_label_is_exact() { + assert!(is_tab_number_label("Tab 1")); + assert!(is_tab_number_label("Tab 2048")); + assert!(!is_tab_number_label("Tab")); + assert!(!is_tab_number_label("Tab ")); + assert!(!is_tab_number_label(" Tab 1")); + assert!(!is_tab_number_label("Tab 1 ")); + assert!(!is_tab_number_label("tab 1")); + assert!(!is_tab_number_label("Tab 1")); + assert!(!is_tab_number_label("Tab ١")); + } + + #[test] + fn basename_matches_the_shared_helper() { + assert_eq!( + basename_segment("/home/dan/code/freshell").as_deref(), + Some("freshell") + ); + assert_eq!( + basename_segment("/home/dan/code/freshell//").as_deref(), + Some("freshell") + ); + assert_eq!(basename_segment("/").as_deref(), Some("/")); + assert_eq!( + basename_segment("C:\\Users\\dan\\proj").as_deref(), + Some("proj") + ); + assert_eq!(basename_segment("C:").as_deref(), Some("C:\\")); + assert_eq!(basename_segment(""), None); + } +} diff --git a/crates/freshell-sessions/src/opencode_row_title.rs b/crates/freshell-sessions/src/opencode_row_title.rs new file mode 100644 index 000000000..1cba7ad0f --- /dev/null +++ b/crates/freshell-sessions/src/opencode_row_title.rs @@ -0,0 +1,44 @@ +//! The title an OpenCode session row presents (name-reliability plan, +//! Task 4). +//! +//! opencode stores a temporary placeholder title (`New session - `, +//! see [`crate::name_evidence::is_provider_placeholder_title`]) until it +//! names a session itself after the first exchange. A placeholder — or a +//! blank title — is not a name: the sidebar and the history page render a +//! row's `title` whenever no saved name is known yet, so presenting it put +//! the placeholder on screen. Such a row presents the title derived from its +//! first user message instead (the convention Claude and Codex rows already +//! follow when their transcript carries no provider title), else no title. +//! The derived title is never a provider name: the row's `title_source` +//! stays unset, so the naming ladder still treats the session as unnamed. +//! +//! Pure functions, no IO. + +use crate::name_evidence::is_provider_placeholder_title; +use crate::text::extract_title_from_message; + +/// Character cap of a title derived from a first user message (the Claude +/// and Codex parse layers use the same cap). +const DERIVED_TITLE_MAX_CHARS: usize = 200; + +/// Whether opencode's stored session title is opencode's own name for the +/// session: present, not blank and not one of its placeholders. +pub(crate) fn is_opencode_session_name(stored_title: Option<&str>) -> bool { + stored_title.is_some_and(|title| { + !title.trim().is_empty() && !is_provider_placeholder_title("opencode", title) + }) +} + +/// The title an opencode row presents: opencode's own name when it has +/// one, else the title derived from the first user message, else none. +pub(crate) fn opencode_row_title( + stored_title: Option, + first_user_message: Option<&str>, +) -> Option { + if is_opencode_session_name(stored_title.as_deref()) { + return stored_title; + } + first_user_message + .map(|message| extract_title_from_message(message, DERIVED_TITLE_MAX_CHARS)) + .filter(|title| !title.is_empty()) +} diff --git a/crates/freshell-sessions/src/parse/claude.rs b/crates/freshell-sessions/src/parse/claude.rs index ea35261d1..1dfaacdb3 100644 --- a/crates/freshell-sessions/src/parse/claude.rs +++ b/crates/freshell-sessions/src/parse/claude.rs @@ -643,3 +643,117 @@ pub fn parse_session_content(content: &str, options: &ParseSessionOptions) -> Pa fn slice_chars(s: &str, n: usize) -> String { s.chars().take(n).collect() } + +/// Name-reliability Task 5 (G52, LB-B7): Claude Code's slash-command and +/// reminder wrappers are system context, so they no longer count as user +/// messages — which decides `is_non_interactive` (at most one counted user +/// message) and with it the sidebar's default hide-non-interactive filter. +#[cfg(test)] +mod wrapper_count_tests { + use super::*; + + const SESSION_ID: &str = "6c6c6c6c-0000-4000-8000-000000000006"; + const CAVEAT: &str = "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."; + + fn user(uuid: &str, content: &str, is_meta: bool) -> String { + let mut record = serde_json::json!({ + "type": "user", + "cwd": "/home/dan/code/sardines", + "sessionId": SESSION_ID, + "timestamp": "2026-10-20T10:00:00.000Z", + "uuid": uuid, + "message": {"role": "user", "content": content}, + }); + if is_meta { + record["isMeta"] = serde_json::Value::Bool(true); + } + serde_json::to_string(&record).expect("user record serializes") + } + + fn assistant(uuid: &str) -> String { + serde_json::to_string(&serde_json::json!({ + "type": "assistant", + "cwd": "/home/dan/code/sardines", + "sessionId": SESSION_ID, + "timestamp": "2026-10-20T10:00:01.000Z", + "uuid": uuid, + "message": {"role": "assistant", "content": [{"type": "text", "text": "Done."}]}, + })) + .expect("assistant record serializes") + } + + fn command(name: &str, args: &str) -> String { + format!( + "/{name}\n {name}\n {args}" + ) + } + + fn parse(lines: &[String]) -> ParsedSessionMeta { + parse_session_content(&lines.join("\n"), &ParseSessionOptions::default()) + } + + /// Accepted change, pinned: one real prompt plus caveat, command and + /// stdout records is ONE counted user message — the session is marked + /// non-interactive (before, each wrapper record counted). + #[test] + fn command_wrapper_records_no_longer_count_as_user_messages() { + let parsed = parse(&[ + user("u-caveat", CAVEAT, true), + user("u-command", &command("model", "opus"), false), + user( + "u-stdout", + "Set model to opus", + false, + ), + user("u-prompt", "Fix the sardine importer", false), + assistant("a-1"), + ]); + assert_eq!( + parsed.first_user_message.as_deref(), + Some("Fix the sardine importer") + ); + assert_eq!( + parsed.is_non_interactive, + Some(true), + "only the one real prompt counts" + ); + } + + /// The other side (LB-B7): two real prompts interleaved with reminder, + /// caveat, command and stdout records — one of them a reminder that + /// carries the second prompt in the same record — still count two user + /// messages, so the session stays interactive. + #[test] + fn two_real_prompts_with_reminders_and_commands_stay_interactive() { + let parsed = parse(&[ + user( + "u-reminder", + "\nThe user opened src/importer.rs in the IDE.\n", + true, + ), + user("u-prompt-1", "Fix the sardine importer", false), + assistant("a-1"), + user("u-caveat", CAVEAT, true), + user("u-command", &command("compact", ""), false), + user( + "u-stdout", + "Compacted the conversation", + false, + ), + user( + "u-prompt-2", + "\nTodo list is empty.\n\nNow add a regression test for it", + false, + ), + assistant("a-2"), + ]); + assert_eq!( + parsed.first_user_message.as_deref(), + Some("Fix the sardine importer") + ); + assert_eq!( + parsed.is_non_interactive, None, + "two real prompts keep the session interactive" + ); + } +} diff --git a/crates/freshell-sessions/src/parse/opencode.rs b/crates/freshell-sessions/src/parse/opencode.rs index 4afa584ec..e225a2891 100644 --- a/crates/freshell-sessions/src/parse/opencode.rs +++ b/crates/freshell-sessions/src/parse/opencode.rs @@ -31,7 +31,8 @@ const OPENCODE_DB_BUSY_TIMEOUT_MS: u64 = 5000; /// name (opencode retitles parent sessions itself after the first /// exchange); the directory index surfaces those as provider-generated. /// Deliberate mainline deviation: the retired Node reference never -/// classified opencode titles at all. +/// classified opencode titles at all. Callers reach it through the shared +/// name classifier, `crate::name_evidence::is_provider_placeholder_title`. pub fn is_opencode_placeholder_title(title: &str) -> bool { let rest = ["New session - ", "Child session - ", "New Session - "] .iter() @@ -333,33 +334,36 @@ const FIRST_USER_MESSAGE_SQL: &str = "\ AND json_extract(p.data, '$.text') IS NOT NULL \ ORDER BY p.id LIMIT 1"; +/// The listing path's read: a failed prepare or query degrades to `None` +/// (an older schema must not break listing). fn first_user_message_for_session(conn: &Connection, session_id: &str) -> Option { - let mut stmt = match conn.prepare_cached(FIRST_USER_MESSAGE_SQL) { - Ok(stmt) => stmt, - Err(e) => { - tracing::debug!( - session_id, - error = %e, - "opencode first-user-message prepare failed; degrading to None" - ); - return None; - } - }; + try_first_user_message_for_session(conn, session_id).unwrap_or_else(|e| { + tracing::debug!( + session_id, + error = %e, + "opencode first-user-message read failed; degrading to None" + ); + None + }) +} + +/// The read itself: `Ok(None)` when the session has no (non-empty) user +/// message, `Err` when the statement cannot be prepared or the query fails +/// (busy past the timeout, schema mismatch). +fn try_first_user_message_for_session( + conn: &Connection, + session_id: &str, +) -> rusqlite::Result> { + let mut stmt = conn.prepare_cached(FIRST_USER_MESSAGE_SQL)?; let text: Option = match stmt.query_row(rusqlite::params![session_id], |row| row.get(0)) { Ok(text) => text, - Err(rusqlite::Error::QueryReturnedNoRows) => return None, - Err(e) => { - tracing::debug!( - session_id, - error = %e, - "opencode first-user-message query failed; degrading to None" - ); - return None; - } + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e), }; - text.as_deref() - .and_then(crate::text::normalize_first_user_message) + Ok(text + .as_deref() + .and_then(crate::text::normalize_first_user_message)) } /// Unified agent names (Task 3): the TARGETED first-user-message lookup for @@ -368,6 +372,12 @@ fn first_user_message_for_session(conn: &Connection, session_id: &str) -> Option /// messages for placeholder-titled rows. This reads the single named /// session's first real user message on demand (read-only, the same /// SQL/normalization the listing path uses), never a full-history scan. +/// +/// `Ok(None)` means the session has no user message; ANY failed read — the +/// database cannot be opened, the statement cannot be prepared, the query +/// fails or stays busy past the timeout — is an `Err` (name-reliability +/// Task 5 review: the naming sweep remembers answers, so a failure must +/// never read as "no user message"). pub fn opencode_first_user_message_by_id( data_home: &Path, session_id: &str, @@ -382,7 +392,7 @@ pub fn opencode_first_user_message_by_id( OPENCODE_BYID_BUSY_TIMEOUT_MS, )) .map_err(by_id_err)?; - Ok(first_user_message_for_session(&conn, session_id)) + try_first_user_message_for_session(&conn, session_id).map_err(by_id_err) } /// Assistant messages of a session, newest-first — the usage walk's cursor. @@ -1008,11 +1018,8 @@ impl OpencodeProvider { // opencode's own title (provider-generated) and never need the // lookup, so listing cost scales with the small unnamed subset, // not with DB size. - let needs_naming = row - .title - .as_deref() - .map(|t| t.trim().is_empty() || is_opencode_placeholder_title(t)) - .unwrap_or(true); + let needs_naming = + !crate::opencode_row_title::is_opencode_session_name(row.title.as_deref()); let first_user_message = if needs_naming { first_user_message_for_session(&conn, &row.session_id) } else { diff --git a/crates/freshell-sessions/src/text.rs b/crates/freshell-sessions/src/text.rs index 8f08ae5d1..9ece42dce 100644 --- a/crates/freshell-sessions/src/text.rs +++ b/crates/freshell-sessions/src/text.rs @@ -107,6 +107,21 @@ pub fn normalize_first_user_message(content: &str) -> Option { } } +// Name-reliability Task 5 (RC12): both lists below also carry the tool +// wrapper blocks coding CLIs serialize as `role:"user"` records that no +// person typed (V5 checked every one against real transcripts on this host): +// - Codex's own model-context fragments, written next to +// `environment_context`: `recommended_plugins`, `user_shell_command`, +// `user_action`; +// - Claude Code's slash-command family (an `isMeta` caveat record, then the +// command's name/message/args, then its output) and its `system-reminder` +// blocks. +// `bash-input`/`bash-stdout`/`bash-stderr` are deliberately absent: no +// transcript on this host carries them; add them only with evidence. Every +// consumer of `extract_user_authored_text` (titles, search, the AI prompt, +// first messages, Claude's user-message count) and the name classifier's +// leading-tag check (`starts_with_context_tag`, with its TypeScript copy in +// `shared/name-evidence.ts`, kept in step by the shared fixture) sees them. const USER_CONTEXT_TAGS: &[&str] = &[ "environment_context", "system_context", @@ -117,10 +132,20 @@ const USER_CONTEXT_TAGS: &[&str] = &[ "permissions", "collaboration_mode", "skills_instructions", + "recommended_plugins", + "user_shell_command", + "user_action", + "local-command-caveat", + "command-name", + "command-message", + "command-args", + "local-command-stdout", + "local-command-stderr", + "system-reminder", ]; // The subset used by the leading `\s]` system-context check (matches the alternation -// in the first `isSystemContext` regex). +// in the first `isSystemContext` regex, plus the tool wrapper tags above). const SYSTEM_CONTEXT_LEAD_TAGS: &[&str] = &[ "environment_context", "system_context", @@ -131,8 +156,41 @@ const SYSTEM_CONTEXT_LEAD_TAGS: &[&str] = &[ "permissions", "collaboration_mode", "skills_instructions", + "recommended_plugins", + "user_shell_command", + "user_action", + "local-command-caveat", + "command-name", + "command-message", + "command-args", + "local-command-stdout", + "local-command-stderr", + "system-reminder", ]; +/// Whether `text` (after trimming) opens with a known system-context tag: +/// `` or `\s]/i`, the first +/// `isSystemContext` alternation). This is ONLY the leading-tag check; the +/// rest of [`is_system_context`] also flags text a person may use as a name +/// (`$ cmd`, `1, 2, 3`), so the name classifier +/// (`crate::name_evidence`) uses this check alone. +pub fn starts_with_context_tag(text: &str) -> bool { + let Some(rest) = text.trim().strip_prefix('<') else { + return false; + }; + let lower = rest.to_ascii_lowercase(); + SYSTEM_CONTEXT_LEAD_TAGS.iter().any(|tag| { + // ASCII lowercasing keeps byte offsets, and the tag is ASCII, so + // `tag.len()` is a char boundary of `rest` whenever it matched. + lower.starts_with(tag) + && rest[tag.len()..] + .chars() + .next() + .is_some_and(|next| next == '>' || next.is_whitespace()) + }) +} + /// `server/coding-cli/utils.ts` `isSystemContext`. pub fn is_system_context(text: &str) -> bool { let trimmed = text.trim(); @@ -141,17 +199,8 @@ pub fn is_system_context(text: &str) -> bool { } // /^<(environment_context|...)[>\s]/i - if let Some(rest) = trimmed.strip_prefix('<') { - let lower = rest.to_ascii_lowercase(); - for tag in SYSTEM_CONTEXT_LEAD_TAGS { - if lower.starts_with(tag) { - if let Some(next) = rest[tag.len()..].chars().next() { - if next == '>' || next.is_whitespace() { - return true; - } - } - } - } + if starts_with_context_tag(trimmed) { + return true; } // /^#\s*(AGENTS|Instructions?|System)/i @@ -563,3 +612,210 @@ mod char_boundary_tests { } } } + +/// Name-reliability Task 5 (RC12): the tool wrapper blocks Codex and Claude +/// Code serialize as `role:"user"` records are system context, never the +/// user's words. A record holding only the block yields no user text; a +/// record whose block is followed by the user's own sentence keeps exactly +/// that sentence; and the name classifier's leading-tag check sees the tag. +#[cfg(test)] +mod wrapper_tag_tests { + use super::*; + + const USER_SENTENCE: &str = "Fix the sardine importer"; + + fn assert_wrapper_block(block: &str) { + assert_eq!( + extract_user_authored_text(block), + None, + "a record holding only {block:?} has no user-authored text" + ); + assert_eq!( + extract_user_authored_text(&format!("{block}\n{USER_SENTENCE}")), + Some(USER_SENTENCE.to_string()), + "the user's sentence after {block:?} is kept" + ); + assert!( + starts_with_context_tag(block), + "the leading-tag check recognizes {block:?}" + ); + } + + // Codex's own model-context fragments (V5: the Codex 0.163.0 binary + // writes them next to `environment_context`). + #[test] + fn extract_user_authored_text_skips_recommended_plugins() { + assert_wrapper_block( + "\n- github: review pull requests\n", + ); + } + + #[test] + fn extract_user_authored_text_skips_user_shell_command() { + assert_wrapper_block( + "\necho hi\n\nhi\nExit code: 0\n\n", + ); + } + + #[test] + fn extract_user_authored_text_skips_user_action() { + assert_wrapper_block( + "\n User initiated a review task. Here's the full review output from reviewer model.\n review\n", + ); + } + + // Claude Code's slash-command family (V5: real transcripts write the + // caveat as an `isMeta` record, then the command, then its output). + #[test] + fn extract_user_authored_text_skips_local_command_caveat() { + assert_wrapper_block( + "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.", + ); + } + + #[test] + fn extract_user_authored_text_skips_command_name() { + assert_wrapper_block("/model"); + } + + #[test] + fn extract_user_authored_text_skips_command_message() { + assert_wrapper_block("model"); + } + + #[test] + fn extract_user_authored_text_skips_command_args() { + assert_wrapper_block("opus"); + } + + #[test] + fn extract_user_authored_text_skips_local_command_stdout() { + assert_wrapper_block("Set model to opus"); + } + + #[test] + fn extract_user_authored_text_skips_local_command_stderr() { + assert_wrapper_block( + "Error: unknown model \"opux\"", + ); + } + + #[test] + fn extract_user_authored_text_skips_system_reminder() { + assert_wrapper_block( + "\nThe user opened the file src/main.rs in the IDE.\n", + ); + } + + /// The command family's full block, as one record carries it: name, + /// message and args (an empty args block included) in one part. + #[test] + fn extract_user_authored_text_skips_a_whole_command_block() { + assert_eq!( + extract_user_authored_text( + "/compact\n compact\n " + ), + None + ); + } + + /// V5's mixed record: ONE user record whose parts are the caveat, a + /// slash command, its output, then a plain sentence. Only the sentence + /// is the user's. + #[test] + fn extract_user_authored_text_mixed_wrapper_record_keeps_the_sentence() { + let sentence = + "Keep the effort high for the rest of this refactor and explain each step briefly."; + let record = [ + "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.", + "/effort\n effort\n high", + "Set effort level to high", + sentence, + ] + .join("\n"); + assert_eq!( + extract_user_authored_text(&record), + Some(sentence.to_string()) + ); + } + + /// The real Claude shape end to end through the transcript parser: an + /// `isMeta` caveat record, a `/model` command record, its stdout record, + /// then the prompt. The index's first user message is the prompt. + #[test] + fn extract_user_authored_text_real_claude_command_shape_yields_the_prompt() { + let session_id = "5a5a5a5a-0000-4000-8000-000000000005"; + let record = |uuid: &str, extra: serde_json::Value| { + let mut base = serde_json::json!({ + "cwd": "/home/dan/code/sardines", + "sessionId": session_id, + "version": "2.1.23", + "gitBranch": "main", + "timestamp": "2026-10-20T10:00:00.000Z", + "uuid": uuid, + }); + for (key, value) in extra.as_object().expect("record fields") { + base[key] = value.clone(); + } + serde_json::to_string(&base).expect("record serializes") + }; + let transcript = [ + record( + "u-caveat", + serde_json::json!({ + "type": "user", + "isMeta": true, + "message": {"role": "user", "content": "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."}, + }), + ), + record( + "u-command", + serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": "/model\n model\n "}, + }), + ), + record( + "u-stdout", + serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": "Set model to opus"}, + }), + ), + record( + "u-prompt", + serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": USER_SENTENCE}, + }), + ), + record( + "a-reply", + serde_json::json!({ + "type": "assistant", + "message": {"role": "assistant", "content": [{"type": "text", "text": "On it."}]}, + }), + ), + ] + .join("\n"); + let parsed = crate::parse::parse_session_content( + &transcript, + &crate::parse::ParseSessionOptions::default(), + ); + assert_eq!(parsed.first_user_message.as_deref(), Some(USER_SENTENCE)); + assert_eq!(parsed.title.as_deref(), Some(USER_SENTENCE)); + } + + /// Pins of what must NOT change: text that merely contains a tag, an + /// unknown tag, and the shell tags no transcript on this host carries. + #[test] + fn extract_user_authored_text_keeps_real_text_around_tags() { + assert_eq!( + extract_user_authored_text("Fix bold rendering"), + Some("Fix bold rendering".to_string()) + ); + assert!(!starts_with_context_tag(" review")); + assert!(!starts_with_context_tag(" list")); + assert!(!starts_with_context_tag("ls")); + } +} diff --git a/crates/freshell-sessions/tests/name_evidence.rs b/crates/freshell-sessions/tests/name_evidence.rs new file mode 100644 index 000000000..da15a622e --- /dev/null +++ b/crates/freshell-sessions/tests/name_evidence.rs @@ -0,0 +1,106 @@ +//! Cross-language parity for the default/derived name classifier: the SAME +//! fixture table that pins `shared/name-evidence.ts` (via +//! `test/unit/shared/name-evidence.test.ts`) pins this port. If either +//! implementation changes behavior, exactly one of the two suites goes red. + +use freshell_sessions::{ + is_default_or_derived_name, is_provider_placeholder_title, NameEvidenceContext, +}; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Fixture { + cases: Vec, + placeholder_cases: Vec, +} + +#[derive(serde::Deserialize)] +struct ClassifierCase { + name: String, + ctx: FixtureContext, + expected: bool, +} + +#[derive(serde::Deserialize, Default, Debug)] +#[serde(default, deny_unknown_fields)] +struct FixtureContext { + cwd: Option, + provider: Option, + mode: Option, +} + +#[derive(serde::Deserialize)] +struct PlaceholderCase { + provider: String, + title: String, + expected: bool, +} + +fn load_fixture() -> Fixture { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/name-evidence/cases.json" + ); + let raw = std::fs::read_to_string(path).expect("read shared name-evidence fixture"); + serde_json::from_str(&raw).expect("parse name-evidence fixture json") +} + +#[test] +fn shared_fixture_cases_match() { + let fixture = load_fixture(); + assert!( + fixture.cases.len() >= 40, + "shared fixture unexpectedly small: {}", + fixture.cases.len() + ); + let mismatches: Vec = fixture + .cases + .iter() + .enumerate() + .filter_map(|(index, row)| { + let ctx = NameEvidenceContext { + cwd: row.ctx.cwd.as_deref(), + provider: row.ctx.provider.as_deref(), + mode: row.ctx.mode.as_deref(), + }; + let actual = is_default_or_derived_name(&row.name, &ctx); + (actual != row.expected).then(|| { + format!( + "row {index}: {:?} ctx={:?} expected {}, got {actual}", + row.name, row.ctx, row.expected + ) + }) + }) + .collect(); + assert!( + mismatches.is_empty(), + "mismatches:\n{}", + mismatches.join("\n") + ); +} + +#[test] +fn opencode_placeholder_reuses_directory_index_predicate() { + let fixture = load_fixture(); + assert!(!fixture.placeholder_cases.is_empty()); + for row in &fixture.placeholder_cases { + let actual = is_provider_placeholder_title(&row.provider, &row.title); + assert_eq!( + actual, row.expected, + "placeholder mismatch for provider {} title {:?}", + row.provider, row.title + ); + // The OpenCode answer is exactly the directory index's own predicate, + // so the two can never disagree about an OpenCode title. + let index_predicate = freshell_sessions::parse::is_opencode_placeholder_title(&row.title); + assert_eq!( + is_provider_placeholder_title("opencode", &row.title), + index_predicate, + "opencode answer diverges from the directory index predicate for {:?}", + row.title + ); + // Other providers have no placeholders. + assert!(!is_provider_placeholder_title("claude", &row.title)); + assert!(!is_provider_placeholder_title("codex", &row.title)); + } +} diff --git a/crates/freshell-sessions/tests/native_titles.rs b/crates/freshell-sessions/tests/native_titles.rs index 020cc3159..40639a591 100644 --- a/crates/freshell-sessions/tests/native_titles.rs +++ b/crates/freshell-sessions/tests/native_titles.rs @@ -165,6 +165,52 @@ fn opencode_named_first_message_lookup_is_targeted() { .is_none()); } +/// Name-reliability Task 5 review: a read that FAILED is an error, never +/// the "no user message" answer — the naming sweep remembers answers across +/// passes, so a failure reported as `None` would put off the session's +/// first-message name for the rest of the process's life. A database that +/// cannot be opened and a query that cannot run (here: no `message` table) +/// are both errors; a session with no user message stays `Ok(None)`. +#[test] +fn opencode_first_message_lookup_reports_a_failed_read_as_an_error() { + let missing = TmpDir::new("no-db"); + assert!( + opencode_first_user_message_by_id(missing.path(), "ses_named").is_err(), + "no database: the read failed" + ); + + let broken = TmpDir::new("no-message-table"); + rusqlite::Connection::open(broken.path().join("opencode.db")) + .unwrap() + .execute_batch( + "CREATE TABLE session ( + id TEXT PRIMARY KEY, directory TEXT, title TEXT, + time_created INTEGER, time_updated INTEGER, time_archived INTEGER, + project_id TEXT, parent_id TEXT + );", + ) + .unwrap(); + let failure = opencode_first_user_message_by_id(broken.path(), "ses_named") + .expect_err("a query that cannot run is a failed read, not 'no user message'"); + assert!( + failure.message.contains("no such table"), + "the failure names what went wrong: {}", + failure.message + ); + + let silent = TmpDir::new("no-user-message"); + named_session_db(silent.path()); + rusqlite::Connection::open(silent.path().join("opencode.db")) + .unwrap() + .execute_batch("DELETE FROM part; DELETE FROM message;") + .unwrap(); + assert_eq!( + opencode_first_user_message_by_id(silent.path(), "ses_named") + .expect("a readable session with no user message is an answer"), + None + ); +} + /// The retained exact-ID database on the by-id hit travels through the /// resolve core WITHOUT leaking into any match output (it is internal /// routing evidence, never user-facing). diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index aae172c86..283dcd643 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -544,7 +544,6 @@ impl Registry { &installation_id, &input.soul_id, &input.project_key, - &input.provider, soul_intent_revision, input.view_intent.as_ref(), now, diff --git a/crates/freshell-supervisor/src/view_intent_title_tests.rs b/crates/freshell-supervisor/src/view_intent_title_tests.rs new file mode 100644 index 000000000..bde4f2f4a --- /dev/null +++ b/crates/freshell-supervisor/src/view_intent_title_tests.rs @@ -0,0 +1,89 @@ +//! View-intent titles are names; the supervisor never invents one. +use super::*; + +/// Name reliability (Task 8, D6): a view intent requested without a title +/// stays untitled on both durable write paths (the launch's automatic +/// primary and an upserted view), instead of the old "Recovered +/// agent" provider label that clients then wrote over real tab names. +#[tokio::test] +async fn view_intent_without_title_stays_empty() { + use crate::admission::AdmissionPolicy; + use crate::registry::LaunchPreparation; + use freshell_runtime_protocol::{RequestId, RuntimeProfile, UpsertViewIntentRequest}; + + let dir = tempfile::tempdir().unwrap(); + let registry = Registry::open(dir.path(), None).unwrap(); + let soul = SoulId::new(); + registry + .prepare_launch(LaunchPreparation { + soul_id: soul.clone(), + provider: "opencode".into(), + provider_store_id: "store".into(), + native_session_id: None, + creation_seed_ref: "seed".into(), + request_id: RequestId::new(), + payload_digest: "untitled".into(), + requested_limits: RuntimeLimits { + cpu_milli: 500, + memory_bytes: 64 * 1024 * 1024, + swap_bytes: 0, + pids_max: 32, + }, + profile: RuntimeProfile::Custom, + project_key: "test-project".into(), + fixture: None, + terminal: None, + fresh_agent: None, + view_intent: Some(ViewIntentRequest::default()), + admission: AdmissionPolicy::default(), + }) + .await + .unwrap(); + + let snapshot = registry.inventory_snapshot().await.unwrap(); + let automatic = snapshot + .view_intents + .iter() + .find(|intent| intent.soul_id == soul && intent.kind == ViewIntentKind::AutomaticPrimary) + .expect("the launch records its automatic primary view"); + assert_eq!(automatic.title, ""); + + let explicit = registry + .upsert_view_intent(UpsertViewIntentRequest { + soul_id: soul.clone(), + view_id: None, + intent: ViewIntentRequest { + kind: ViewIntentKind::Explicit, + preferred_tab_id: Some("tab-explicit".into()), + preferred_pane_id: Some("pane-explicit".into()), + title: Some(" ".into()), + ..ViewIntentRequest::default() + }, + expected_revision: None, + expected_soul_intent_revision: 1, + expected_control_epoch: None, + }) + .await + .unwrap(); + assert_eq!(explicit.title, ""); + + // A real title still passes through untouched. + let named = registry + .upsert_view_intent(UpsertViewIntentRequest { + soul_id: soul, + view_id: Some(explicit.view_id.clone()), + intent: ViewIntentRequest { + kind: ViewIntentKind::Explicit, + preferred_tab_id: Some("tab-explicit".into()), + preferred_pane_id: Some("pane-explicit".into()), + title: Some("Billing bug".into()), + ..ViewIntentRequest::default() + }, + expected_revision: Some(explicit.revision), + expected_soul_intent_revision: 1, + expected_control_epoch: None, + }) + .await + .unwrap(); + assert_eq!(named.title, "Billing bug"); +} diff --git a/crates/freshell-supervisor/src/view_intents.rs b/crates/freshell-supervisor/src/view_intents.rs index 8c257358c..81a08e60c 100644 --- a/crates/freshell-supervisor/src/view_intents.rs +++ b/crates/freshell-supervisor/src/view_intents.rs @@ -46,12 +46,10 @@ fn automatic_placement_for_owner( } } -#[allow(clippy::too_many_arguments)] pub fn automatic_primary_intent( installation_id: &InstallationId, soul_id: SoulId, workspace_id: String, - provider: &str, title_hint: Option<&str>, soul_intent_revision: u64, now: i64, @@ -64,19 +62,16 @@ pub fn automatic_primary_intent( installation_id, soul_id, workspace_id, - provider, Some(&request), soul_intent_revision, now, ) } -#[allow(clippy::too_many_arguments)] fn automatic_primary_intent_from_request( installation_id: &InstallationId, soul_id: SoulId, fallback_workspace_id: String, - provider: &str, request: Option<&ViewIntentRequest>, soul_intent_revision: u64, now: i64, @@ -103,7 +98,7 @@ fn automatic_primary_intent_from_request( let title = request .and_then(|value| nonempty(value.title.as_deref())) .map(str::to_string) - .unwrap_or_else(|| format!("Recovered {} agent", provider_label(provider))); + .unwrap_or_default(); let placement_group = request .and_then(|value| nonempty(value.placement_group.as_deref())) .unwrap_or(RECOVERED_AGENTS_GROUP) @@ -148,23 +143,11 @@ fn stable_hex(key: &str, domain: &[u8]) -> String { .collect() } -fn provider_label(provider: &str) -> &str { - match provider { - "claude" => "Claude", - "codex" => "Codex", - "opencode" => "OpenCode", - "amplifier" => "Amplifier", - other => other, - } -} - -#[allow(clippy::too_many_arguments)] pub(crate) fn ensure_automatic_primary_in_tx( tx: &Transaction<'_>, installation_id: &InstallationId, soul_id: &SoulId, workspace_id: &str, - provider: &str, soul_intent_revision: u64, request: Option<&ViewIntentRequest>, now: i64, @@ -176,7 +159,6 @@ pub(crate) fn ensure_automatic_primary_in_tx( installation_id, soul_id.clone(), workspace_id.to_string(), - provider, request, soul_intent_revision, now, @@ -206,15 +188,14 @@ pub(crate) fn backfill_automatic_view_intents( ) -> Result<(), RegistryError> { let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let mut statement = tx.prepare( - "SELECT s.soul_id,s.project_key,s.provider,s.intent_revision,s.desired_state FROM souls s WHERE EXISTS (SELECT 1 FROM incarnations i WHERE i.soul_id=s.soul_id AND i.terminal_spec IS NOT NULL) ORDER BY s.created_at,s.soul_id", + "SELECT s.soul_id,s.project_key,s.intent_revision,s.desired_state FROM souls s WHERE EXISTS (SELECT 1 FROM incarnations i WHERE i.soul_id=s.soul_id AND i.terminal_spec IS NOT NULL) ORDER BY s.created_at,s.soul_id", )?; let rows = statement.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, u64>(3)?, - row.get::<_, String>(4)?, + row.get::<_, u64>(2)?, + row.get::<_, String>(3)?, )) })?; let mut values = Vec::new(); @@ -223,7 +204,7 @@ pub(crate) fn backfill_automatic_view_intents( } drop(statement); let now = now_millis(); - for (soul, workspace, provider, revision, desired) in values { + for (soul, workspace, revision, desired) in values { let soul = SoulId::parse(soul).map_err(|_| RegistryError::Integrity("invalid soul id".into()))?; ensure_automatic_primary_in_tx( @@ -231,7 +212,6 @@ pub(crate) fn backfill_automatic_view_intents( installation_id, &soul, &workspace, - &provider, revision, None, now, @@ -600,11 +580,11 @@ impl Registry { let installation_id = self.installation_id().clone(); self.run_blocking(move |mut conn| { let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; - let (provider, workspace, soul_revision): (String, String, u64) = tx + let (workspace, soul_revision): (String, u64) = tx .query_row( - "SELECT provider,project_key,intent_revision FROM souls WHERE soul_id=?1", + "SELECT project_key,intent_revision FROM souls WHERE soul_id=?1", params![request.soul_id.as_str()], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()? .ok_or_else(|| RegistryError::UnknownSoul(request.soul_id.clone()))?; @@ -686,7 +666,7 @@ impl Registry { .as_deref() .and_then(|value| nonempty(Some(value))) .map(str::to_string) - .unwrap_or_else(|| format!("Recovered {} agent", provider_label(&provider))), + .unwrap_or_default(), placement_group: request .intent .placement_group @@ -960,7 +940,6 @@ mod tests { &installation, SoulId::parse("soul-one").unwrap(), "workspace-one".into(), - "opencode", None, 4, 10, @@ -968,10 +947,15 @@ mod tests { assert_eq!(intent.visibility, ViewVisibilityIntent::Visible); assert_eq!(intent.kind, ViewIntentKind::AutomaticPrimary); assert_eq!(intent.placement_group, RECOVERED_AGENTS_GROUP); - assert_eq!(intent.title, "Recovered OpenCode agent"); + // A view title is a name; the supervisor never invents one. + assert_eq!(intent.title, ""); assert_eq!( projection_event_id(&intent.view_id, 1), projection_event_id(&intent.view_id, 1) ); } } + +#[cfg(test)] +#[path = "view_intent_title_tests.rs"] +mod title_tests; diff --git a/crates/freshell-terminal/src/managed_output_tests.rs b/crates/freshell-terminal/src/managed_output_tests.rs index d70e2d736..73cb48b8c 100644 --- a/crates/freshell-terminal/src/managed_output_tests.rs +++ b/crates/freshell-terminal/src/managed_output_tests.rs @@ -79,16 +79,19 @@ impl ManagedTerminalController for Controller { fn fixture() -> (TerminalRegistry, Arc) { let registry = TerminalRegistry::new(); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-stable".into(), - incarnation_id: "incarnation-old".into(), - terminal_id: "terminal-stable".into(), - stream_id: "epoch-old".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: Some("native-original".into()), - create_request_id: Some("create-once".into()), - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-stable".into(), + incarnation_id: "incarnation-old".into(), + terminal_id: "terminal-stable".into(), + stream_id: "epoch-old".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some("native-original".into()), + create_request_id: Some("create-once".into()), + }, + "OpenCode".to_string(), + ); registry.ingest_managed_output("terminal-stable", 206, 206, "old conversation tail".into()); let controller = Arc::new(Controller { hold: AtomicBool::new(false), diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index 3fcd666a3..bc5fa7dd3 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -4494,6 +4494,24 @@ impl TerminalRegistry { } } + /// Name reliability (Task 10): drop the display cache when the row's + /// binding moved to a session with no record yet (a conversation switch), + /// presenting `fallback_title` instead of the previous session's name. + pub fn clear_session_name(&self, terminal_id: &str, fallback_title: &str) { + let shared = { + let inner = self.inner.lock().expect("registry lock"); + inner + .terminals + .get(terminal_id) + .map(|h| Arc::clone(&h.shared)) + }; + if let Some(shared) = shared { + let mut s = shared.lock().expect("terminal lock"); + s.session_name = None; + s.title = fallback_title.to_string(); + } + } + /// Unified agent names: the terminal's last-known canonical name record /// (display cache), if any. pub fn session_name_of( @@ -4643,10 +4661,15 @@ impl TerminalRegistry { /// Reconstruct a browser-facing facade from protected supervisor inventory /// before legacy pane reconciliation consults host-local provider stores. /// This is read/adopt only: it never launches or resumes a provider. + /// + /// `title_for_mode` labels the reconstructed row from its mode (the + /// caller owns the CLI labels); the naming bind then overwrites it with + /// the saved name. pub async fn adopt_managed_for_reconcile( &self, terminal_id: &str, create_request_id: Option, + title_for_mode: impl FnOnce(&str) -> String, ) -> Result { if self.exists(terminal_id) { return Ok(self.is_managed(terminal_id)); @@ -4671,7 +4694,8 @@ impl TerminalRegistry { descriptor.terminal_id )); } - self.register_managed(descriptor); + let title = title_for_mode(&descriptor.mode); + self.register_managed(descriptor, title); Ok(true) } @@ -4715,8 +4739,10 @@ impl TerminalRegistry { /// Register the browser-facing facade for a supervisor-owned PTY. This row /// deliberately has no `PtyTerminal`: server shutdown therefore has no OS - /// process handle it could accidentally reap. - pub fn register_managed(&self, descriptor: ManagedTerminalDescriptor) { + /// process handle it could accidentally reap. `title` is the caller's mode + /// label (never a hard-coded "Shell" for an agent soul); the naming bind + /// replaces it with the saved name. + pub fn register_managed(&self, descriptor: ManagedTerminalDescriptor, title: String) { let created_at = now_ms(); let shared = Arc::new(Mutex::new(TerminalShared { managed_identity: Some(( @@ -4749,7 +4775,7 @@ impl TerminalRegistry { geometry_epoch: 1, has_client_geometry: false, cwd: Some(descriptor.cwd.clone()), - title: "Shell".to_string(), + title, description: None, mode: descriptor.mode.clone(), resume_session_id: descriptor.resume_session_id.clone(), @@ -15513,16 +15539,19 @@ mod tests { fn managed_facade_is_never_reaped_by_legacy_idle_or_shutdown_paths() { let registry = TerminalRegistry::new(); registry.set_auto_kill_idle_minutes(1); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-managed".into(), - incarnation_id: "incarnation-managed".into(), - terminal_id: "T-managed".into(), - stream_id: "S-managed".into(), - mode: "shell".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: Some("req-managed".into()), - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-managed".into(), + incarnation_id: "incarnation-managed".into(), + terminal_id: "T-managed".into(), + stream_id: "S-managed".into(), + mode: "shell".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: Some("req-managed".into()), + }, + "Shell".to_string(), + ); registry.backdate_last_activity("T-managed", now_ms() - 48 * 60 * 60_000); assert!(registry.enforce_idle_kills().is_empty()); @@ -15534,16 +15563,19 @@ mod tests { #[test] fn managed_exit_marks_facade_exited_but_retains_replay_row() { let registry = TerminalRegistry::new(); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-exit".into(), - incarnation_id: "incarnation-exit".into(), - terminal_id: "T-exit".into(), - stream_id: "S-exit".into(), - mode: "shell".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: None, - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-exit".into(), + incarnation_id: "incarnation-exit".into(), + terminal_id: "T-exit".into(), + stream_id: "S-exit".into(), + mode: "shell".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: None, + }, + "Shell".to_string(), + ); assert!(registry.ingest_managed_output("T-exit", 1, 1, "tail\n".into())); assert!(registry.finish_managed_exit("T-exit", 17)); assert!(!registry.is_live("T-exit")); @@ -15560,16 +15592,19 @@ mod tests { fn managed_exit_waits_for_paced_replay_before_notifying_the_client() { let registry = TerminalRegistry::new(); registry.set_paced_page_max_bytes(0); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-paced-exit".into(), - incarnation_id: "incarnation-paced-exit".into(), - terminal_id: "T-paced-exit".into(), - stream_id: "S-paced-exit".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: None, - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-paced-exit".into(), + incarnation_id: "incarnation-paced-exit".into(), + terminal_id: "T-paced-exit".into(), + stream_id: "S-paced-exit".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: None, + }, + "OpenCode".to_string(), + ); for seq in 1..=3 { assert!(registry.ingest_managed_output( "T-paced-exit", @@ -15649,16 +15684,19 @@ mod tests { #[test] fn managed_output_replay_dedupes_repeated_host_cursor_reads() { let registry = TerminalRegistry::new(); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-output".into(), - incarnation_id: "incarnation-output".into(), - terminal_id: "T-output".into(), - stream_id: "S-output".into(), - mode: "shell".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: None, - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-output".into(), + incarnation_id: "incarnation-output".into(), + terminal_id: "T-output".into(), + stream_id: "S-output".into(), + mode: "shell".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: None, + }, + "Shell".to_string(), + ); assert!(registry.ingest_managed_output("T-output", 1, 1, "one\n".into())); assert!(registry.ingest_managed_output("T-output", 1, 1, "duplicate\n".into())); @@ -15677,6 +15715,10 @@ mod tests { #[path = "managed_output_tests.rs"] mod managed_output_tests; +#[cfg(test)] +#[path = "registry_naming_tests.rs"] +mod registry_naming_tests; + #[cfg(test)] mod fixture_denies_host_pty { use super::*; @@ -15733,16 +15775,19 @@ mod fixture_denies_host_pty { #[test] fn managed_facade_remains_available_without_a_host_pid() { let registry = TerminalRegistry::new().with_managed_environment_fixture_only(true); - registry.register_managed(ManagedTerminalDescriptor { - soul_id: "fixture-soul".into(), - incarnation_id: "fixture-incarnation".into(), - terminal_id: "fixture-managed".into(), - stream_id: "fixture-stream".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: Some("fixture-create".into()), - }); + registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "fixture-soul".into(), + incarnation_id: "fixture-incarnation".into(), + terminal_id: "fixture-managed".into(), + stream_id: "fixture-stream".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: Some("fixture-create".into()), + }, + "OpenCode".to_string(), + ); assert!(registry.is_managed("fixture-managed")); assert_eq!(registry.pid_of("fixture-managed"), None); assert!(registry.ingest_managed_output("fixture-managed", 1, 1, "fixture output".into())); diff --git a/crates/freshell-terminal/src/registry_naming_tests.rs b/crates/freshell-terminal/src/registry_naming_tests.rs new file mode 100644 index 000000000..75cdc53ab --- /dev/null +++ b/crates/freshell-terminal/src/registry_naming_tests.rs @@ -0,0 +1,145 @@ +//! Name reliability (Task 10): a managed facade row presents its MODE label — +//! never the hard-coded "Shell" — until the naming bind overwrites it with +//! the saved name, and a binding that moves away from its cached record can +//! drop that cache. Headless: no PTY, no async runtime. +use super::*; +use std::task::{Context, Poll, Waker}; + +fn immediate(future: F) -> F::Output { + let mut future = Box::pin(future); + match future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + { + Poll::Ready(value) => value, + Poll::Pending => panic!("headless test future unexpectedly waited"), + } +} + +fn opencode_soul(terminal_id: &str) -> ManagedTerminalDescriptor { + ManagedTerminalDescriptor { + soul_id: format!("soul-{terminal_id}"), + incarnation_id: format!("incarnation-{terminal_id}"), + terminal_id: terminal_id.to_string(), + stream_id: format!("S-{terminal_id}"), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some("ses_soul".into()), + create_request_id: Some(format!("req-{terminal_id}")), + } +} + +/// The supervisor's inventory after a web restart: lookup answers the soul. +struct InventoryController { + soul: ManagedTerminalDescriptor, +} + +impl ManagedTerminalController for InventoryController { + fn lookup_terminal<'a>( + &'a self, + terminal_id: &'a str, + _: Option, + ) -> ManagedTerminalFuture<'a, Result, String>> { + let found = (terminal_id == self.soul.terminal_id).then(|| self.soul.clone()); + Box::pin(std::future::ready(Ok(found))) + } + fn launch<'a>( + &'a self, + _: ManagedTerminalLaunch, + ) -> ManagedTerminalFuture<'a, Result> + { + panic!("adoption must never launch a provider") + } + fn input<'a>( + &'a self, + _: ManagedTerminalDescriptor, + _: String, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + panic!("adoption must never replay input") + } + fn resize<'a>( + &'a self, + _: ManagedTerminalDescriptor, + _: u16, + _: u16, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + panic!("adoption must never resize") + } + fn stop<'a>( + &'a self, + _: ManagedTerminalDescriptor, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + panic!("adoption must never stop a provider") + } + fn read_output<'a>( + &'a self, + _: ManagedTerminalDescriptor, + _: i64, + _: u64, + ) -> ManagedTerminalFuture<'a, Result> { + panic!("adoption must never read output") + } +} + +#[test] +fn register_managed_titles_an_agent_row_with_the_passed_mode_label() { + let registry = TerminalRegistry::new(); + registry.register_managed(opencode_soul("T-label"), "OpenCode".to_string()); + assert_eq!( + registry.title_of("T-label").as_deref(), + Some("OpenCode"), + "an agent facade presents its mode label, never Shell" + ); +} + +#[test] +fn register_managed_through_reconcile_adoption_titles_from_the_mode_label() { + let registry = TerminalRegistry::new(); + registry.set_managed_controller(Some(Arc::new(InventoryController { + soul: opencode_soul("T-adopted"), + }))); + let mut labelled_modes = Vec::new(); + let adopted = immediate(registry.adopt_managed_for_reconcile( + "T-adopted", + Some("req-T-adopted".into()), + |mode| { + labelled_modes.push(mode.to_string()); + "OpenCode".to_string() + }, + )) + .expect("lookup succeeds"); + assert!(adopted, "the soul is reconstructed"); + assert_eq!(labelled_modes, vec!["opencode".to_string()]); + assert_eq!(registry.title_of("T-adopted").as_deref(), Some("OpenCode")); +} + +#[test] +fn register_managed_row_drops_a_moved_binding_cache_back_to_the_fallback_title() { + let registry = TerminalRegistry::new(); + registry.register_managed(opencode_soul("T-moved"), "OpenCode".to_string()); + let record = freshell_protocol::session_names::SessionNameRecord { + name_ref: freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Opencode, + session_id: "ses_old".into(), + }, + name: "Old conversation".into(), + source: freshell_protocol::session_names::NameSource::Manual, + revision: 2, + manual_revision: Some(2), + renamed_at: None, + legacy_origin: None, + }; + registry.update_session_name("T-moved", &record); + assert_eq!( + registry.title_of("T-moved").as_deref(), + Some("Old conversation") + ); + + registry.clear_session_name("T-moved", "OpenCode"); + + assert!( + registry.session_name_of("T-moved").is_none(), + "the moved row no longer advertises the old session's record" + ); + assert_eq!(registry.title_of("T-moved").as_deref(), Some("OpenCode")); +} diff --git a/crates/freshell-ws/src/auto_resume.rs b/crates/freshell-ws/src/auto_resume.rs index fbb0ff511..f8db38c92 100644 --- a/crates/freshell-ws/src/auto_resume.rs +++ b/crates/freshell-ws/src/auto_resume.rs @@ -588,6 +588,7 @@ async fn run_hub_body( session_id: session_id.clone(), create_request_id: key.clone(), cwd, + replaces_terminal_id: ev.terminal_id.clone(), }; match driver.respawn(&spec).await { Ok(new_tid) => { @@ -760,6 +761,9 @@ pub(crate) struct RespawnSpec { pub session_id: String, pub create_request_id: String, pub cwd: Option, + /// The crashed terminal this respawn replaces (its naming handle may + /// carry over). + pub replaces_terminal_id: String, } /// The production [`AutoResumeDriver`]: delegates to the real registry / @@ -1317,6 +1321,7 @@ impl AutoResumeDriver for WsAutoResumeDriver { session_id: req.session_id.clone(), create_request_id: req.create_request_id.clone(), cwd: req.cwd.clone(), + replaces_terminal_id: Some(req.replaces_terminal_id.clone()), }; async move { crate::terminal::respawn_agent_terminal(&state, &req) @@ -2082,6 +2087,9 @@ mod tests { tokio::time::advance(std::time::Duration::from_millis(2_000)).await; tokio::task::yield_now().await; assert_eq!(fake.respawn_calls().len(), 1); + // Name reliability (Task 10): the respawn names the crashed row, so + // the replacement can inherit its still-pending naming handle. + assert_eq!(fake.respawn_calls()[0].replaces_terminal_id, "t1"); assert_eq!( fake.replaced_calls(), vec![("t1".into(), "t-new".into(), 1u32)] diff --git a/crates/freshell-ws/src/claude_signal.rs b/crates/freshell-ws/src/claude_signal.rs index 2bacd88dd..3d7856ea7 100644 --- a/crates/freshell-ws/src/claude_signal.rs +++ b/crates/freshell-ws/src/claude_signal.rs @@ -443,6 +443,10 @@ async fn apply_claude_signal(state: &WsState, sig: &ClaudeSignal) -> SignalDispo } } } + // Name reliability (Task 10, G45): a conversation switch moved the + // identity's durable ref; the terminal row's binding follows it and the + // row adopts the new session's own name. + crate::terminal_naming::follow_identity_switch(state, &sig.terminal_id).await; state.registry.set_meta( &sig.terminal_id, None, diff --git a/crates/freshell-ws/src/codex_identity.rs b/crates/freshell-ws/src/codex_identity.rs index 9f33fc556..44a254c47 100644 --- a/crates/freshell-ws/src/codex_identity.rs +++ b/crates/freshell-ws/src/codex_identity.rs @@ -381,6 +381,10 @@ async fn apply_codex_identity( ) .await; } + // Name reliability (Task 10, G45): a conversation switch moved the + // identity's durable ref; the terminal row's binding follows it and the + // row adopts the new session's own name. + crate::terminal_naming::follow_identity_switch(state, terminal_id).await; state.registry.set_meta( terminal_id, None, @@ -427,6 +431,7 @@ pub(crate) fn broadcast_terminal_session_associated( ) { let associated = ServerMessage::TerminalSessionAssociated(TerminalSessionAssociated { terminal_id: terminal_id.to_string(), + name_ref: state.registry.name_ref_of(terminal_id), session_ref: SessionLocator { provider: provider.to_string(), session_id: session_id.to_string(), diff --git a/crates/freshell-ws/src/codex_proxy_route.rs b/crates/freshell-ws/src/codex_proxy_route.rs index 5b265f17e..f65464586 100644 --- a/crates/freshell-ws/src/codex_proxy_route.rs +++ b/crates/freshell-ws/src/codex_proxy_route.rs @@ -768,6 +768,7 @@ mod tests { document_generation: 1, changed: true, native_sync: None, + outcome: None, }) }) } diff --git a/crates/freshell-ws/src/identity.rs b/crates/freshell-ws/src/identity.rs index 7e5b29375..cd1d30e1e 100644 --- a/crates/freshell-ws/src/identity.rs +++ b/crates/freshell-ws/src/identity.rs @@ -646,11 +646,13 @@ pub(crate) async fn bind_pending_naming( .await { Ok(update) => { - if update.record.name_ref == target { - identity.set_name_binding(terminal_id, Some(target.clone()), None); - registry.set_naming(terminal_id, Some(target), None); - } - registry.update_session_name(terminal_id, &update.record); + freshell_freshagent::terminal_name_binding::apply_transferred_record( + registry, + &crate::terminal_naming::IdentityNameBinder::new(identity.clone()), + terminal_id, + &target, + &update.record, + ); true } Err(error) => { @@ -985,6 +987,7 @@ mod tests { document_generation: 1, changed: false, native_sync: None, + outcome: None, }, ) .collect(); @@ -1030,6 +1033,7 @@ mod tests { document_generation: 1, changed: true, native_sync: None, + outcome: None, }) }) } diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 0e7296e75..104c3f6fd 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -57,6 +57,7 @@ pub mod invariants; pub mod opencode_association; pub mod opencode_lane; pub mod opencode_signal; +pub mod opencode_title_naming; pub mod origin; pub(crate) mod paced_replay; pub mod pane_identity_binder; @@ -74,6 +75,7 @@ pub(crate) mod tabs_store_migrate; pub mod tabs_store_model; pub mod terminal; pub mod terminal_meta; +pub mod terminal_naming; pub use codex_identity::codex_sessions_root; pub use codex_reconcile::locate_codex_rollout; diff --git a/crates/freshell-ws/src/managed_retained_socket_tests.rs b/crates/freshell-ws/src/managed_retained_socket_tests.rs index 953f814d5..e8b473e3e 100644 --- a/crates/freshell-ws/src/managed_retained_socket_tests.rs +++ b/crates/freshell-ws/src/managed_retained_socket_tests.rs @@ -14,7 +14,7 @@ async fn managed_socket_windows( cwd: "/workspace".into(), resume_session_id: None, create_request_id: None, - }); + }, "OpenCode".to_string()); state .registry .set_managed_controller(Some(Arc::new(ExitedOpenCodeRead { diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 76af599a1..5fb7d37bc 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -304,6 +304,10 @@ async fn associate_session_identity( acquisition, ) .await; + // Name reliability (Task 10, G45): a conversation switch moved the + // identity's durable ref; the terminal row's binding follows it and the + // row adopts the new session's own name. + crate::terminal_naming::follow_identity_switch(state, terminal_id).await; state.registry.set_meta( terminal_id, None, @@ -481,6 +485,7 @@ async fn broadcast_terminal_session_associated( ) { let associated = ServerMessage::TerminalSessionAssociated(TerminalSessionAssociated { terminal_id: terminal_id.to_string(), + name_ref: state.registry.name_ref_of(terminal_id), session_ref: SessionLocator { provider: "opencode".to_string(), session_id: session_id.to_string(), diff --git a/crates/freshell-ws/src/opencode_lane.rs b/crates/freshell-ws/src/opencode_lane.rs index 77e03dcbf..a54b9491c 100644 --- a/crates/freshell-ws/src/opencode_lane.rs +++ b/crates/freshell-ws/src/opencode_lane.rs @@ -1463,6 +1463,198 @@ mod tests { assert!(!version_in_tested_range("2.0.0")); } + /// Name-reliability Task 4: a naming store that refuses (lock busy) the + /// next `fail` observations, recording every attempt and every accepted + /// title. Only `observe_native` is exercised. + #[derive(Default)] + struct TitleSink { + fail: AtomicUsize, + attempts: Mutex>, + accepted: Mutex>, + } + + impl freshell_freshagent::naming::SessionNaming for TitleSink { + fn get( + &self, + _refs: Vec, + ) -> freshell_freshagent::naming::NameFuture> + { + Box::pin(async { Ok(Vec::new()) }) + } + fn ensure_pending( + &self, + _input: freshell_freshagent::naming::PendingNameInput, + ) -> freshell_freshagent::naming::NameFuture { + Box::pin(async { Err(freshell_freshagent::naming::naming_unavailable("test")) }) + } + fn bind_pending( + &self, + _input: freshell_freshagent::naming::BindNameInput, + ) -> freshell_freshagent::naming::NameFuture { + Box::pin(async { Err(freshell_freshagent::naming::naming_unavailable("test")) }) + } + fn rename( + &self, + _input: freshell_freshagent::naming::RenameNameInput, + ) -> freshell_freshagent::naming::NameFuture { + Box::pin(async { Err(freshell_freshagent::naming::naming_unavailable("test")) }) + } + fn activity( + &self, + _input: freshell_freshagent::naming::NameActivity, + ) -> freshell_freshagent::naming::NameFuture { + Box::pin(async { Err(freshell_freshagent::naming::naming_unavailable("test")) }) + } + fn observe_native( + &self, + input: freshell_freshagent::naming::NativeNameObservation, + ) -> freshell_freshagent::naming::NameFuture { + self.attempts.lock().unwrap().push(input.title.clone()); + let refuse = self + .fail + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok(); + if refuse { + return Box::pin(async { + Err(freshell_freshagent::naming::NameError::LockUnavailable( + "injected: the document lock stayed busy".into(), + )) + }); + } + self.accepted.lock().unwrap().push(input.title.clone()); + let record = freshell_protocol::session_names::SessionNameRecord { + name_ref: input.target, + name: input.title, + source: freshell_protocol::session_names::NameSource::ProviderAi, + revision: 2, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }; + Box::pin(async move { + Ok(freshell_protocol::SessionNameUpdate { + record, + document_generation: 2, + redirects: Vec::new(), + changed: true, + native_sync: None, + outcome: None, + }) + }) + } + fn record_acquisition( + &self, + _target: freshell_protocol::SessionNameRef, + _acquisition: freshell_protocol::native_location::NativeAcquisition, + ) -> freshell_freshagent::naming::NameFuture { + Box::pin(async { Err(freshell_freshagent::naming::naming_unavailable("test")) }) + } + } + + /// Name-reliability Task 4 (the CLI lane's `NativeTitleObserver` seam + /// that `main.rs` wires): `session.updated` titles fold through the + /// naming store without ever stalling the lane. The placeholder never + /// reaches the store, the real title's locked inline attempt hands off + /// to the background retry, and the event after the titles reaches the + /// hub at once. + #[tokio::test(flavor = "multi_thread")] + async fn naming_title_observer_folds_titles_without_stalling_the_lane() { + let sink = Arc::new(TitleSink::default()); + sink.fail.store(1, Ordering::SeqCst); + let observer = crate::opencode_title_naming::naming_title_observer( + crate::identity::TerminalIdentityRegistry::new(), + Some(sink.clone() as Arc), + ); + let log: CallLog = Arc::new(Mutex::new(Vec::new())); + let http = Arc::new(FakeLaneHttp { + log: log.clone(), + respond: Box::new(|url| { + if url.ends_with("/global/health") { + return Ok((200, json!({ "healthy": true, "version": "1.18.11" }))); + } + if url.ends_with("/session/status") { + return Ok((200, json!({}))); + } + Ok((404, json!({}))) + }), + }); + let stream = Arc::new(FakeLaneStream { + log, + scripts: Mutex::new(VecDeque::from([StreamScript { + buffered: vec![], + live: vec![ + parsed(json!({ + "type": "session.updated", + "properties": { "info": { + "id": "ses-title", + "title": "New session - 2026-10-20T00:09:25.350Z" + } } + })), + parsed(json!({ + "type": "session.updated", + "properties": { "info": { + "id": "ses-title", + "title": "Publishing blocked by token error" + } } + })), + parsed(json!({ + "type": "session.idle", + "properties": { "sessionID": "ses-title" } + })), + ], + finish: false, + }])), + }); + let deps = Arc::new(OpencodeLaneDeps { + http, + events: stream, + native_title_observer: Some(observer), + }); + let (hub, _rx) = hub(); + (hub.registry_observer())(freshell_terminal::ActivityEvent::Created { + terminal_id: "t-title".into(), + mode: "opencode".into(), + resume_session_id: Some("ses-title".into()), + at: 1_000, + }); + hub.register_opencode_lane_for_tests("t-title", 1); + let (_lane, _verify_tx) = + spawn_opencode_lane(deps, hub.clone(), "t-title".into(), "http://fake".into(), 1); + + // Snapshot, then the idle that followed both titles — promptly. + let ingress = wait_for_ingress(&hub, 2, 1_000).await; + assert!( + matches!( + ingress[1].3, + OpencodeLaneEvent::SessionIdle { ref session_id } if session_id == "ses-title" + ), + "{ingress:?}" + ); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while sink.accepted.lock().unwrap().is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "the background retry never folded the real title: {:?}", + sink.attempts.lock().unwrap() + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!( + sink.accepted.lock().unwrap().clone(), + vec!["Publishing blocked by token error".to_string()] + ); + assert!( + !sink + .attempts + .lock() + .unwrap() + .iter() + .any(|title| title.starts_with("New session - ")), + "the placeholder never reached the store" + ); + } + /// The cycle shape: health gate (two 500s, then healthy) → two-phase /// connect → `/session/status` snapshot → buffered-frame flush. The /// snapshot is noted BEFORE the buffered `session.idle`, connect diff --git a/crates/freshell-ws/src/opencode_signal.rs b/crates/freshell-ws/src/opencode_signal.rs index 6dbcc9f41..cecbb4b75 100644 --- a/crates/freshell-ws/src/opencode_signal.rs +++ b/crates/freshell-ws/src/opencode_signal.rs @@ -486,6 +486,10 @@ async fn rebind_fanout( cwd, now_ms(), ); + // Name reliability (Task 10, G45): a conversation switch moved the + // identity's durable ref; the terminal row's binding follows it and the + // row adopts the new session's own name. + crate::terminal_naming::follow_identity_switch(state, &sig.terminal_id).await; state.registry.set_meta( &sig.terminal_id, None, diff --git a/crates/freshell-ws/src/opencode_title_naming.rs b/crates/freshell-ws/src/opencode_title_naming.rs new file mode 100644 index 000000000..80fb1e238 --- /dev/null +++ b/crates/freshell-ws/src/opencode_title_naming.rs @@ -0,0 +1,50 @@ +//! The CLI opencode lane's native-title fold (unified-agent-names Task 3; +//! name-reliability Task 4): the `NativeTitleObserver` `main.rs` installs on +//! the lane, built here so it is testable against the real lane. +//! +//! The lane calls the observer synchronously for every `session.updated` +//! title, in stream order. The observer resolves the naming target (the +//! durable opencode session's record, else the terminal's own pending ref), +//! drops provider placeholders, RESERVES the observation's sequence right +//! away — so the folds keep stream order even though each runs on its own +//! task — and spawns the fold. The lane never waits on the naming store. + +use std::sync::Arc; + +use freshell_freshagent::naming::{is_placeholder_for_target, NativeNameOrigin, SessionNaming}; + +use crate::identity::TerminalIdentityRegistry; +use crate::opencode_lane::NativeTitleObserver; + +/// The observer for a lane: folds through `names` (`None` — a degraded boot +/// without a store — drops every observation). +pub fn naming_title_observer( + identity: TerminalIdentityRegistry, + names: Option>, +) -> NativeTitleObserver { + Arc::new(move |terminal_id: &str, session_id: &str, title: &str| { + let Some(names) = names.as_ref() else { + return; + }; + let Some(target) = identity + .named_session_ref_of("opencode", session_id) + .or_else(|| identity.name_ref_for(terminal_id)) + else { + return; + }; + if is_placeholder_for_target(&target, title) { + return; + } + let ticket = freshell_freshagent::native_observation_retry::reserve(names, &target); + let title = title.to_string(); + tokio::spawn(async move { + let _ = freshell_freshagent::native_observation_retry::submit( + ticket, + title, + NativeNameOrigin::Snapshot, + None, + ) + .await; + }); + }) +} diff --git a/crates/freshell-ws/src/reconcile.rs b/crates/freshell-ws/src/reconcile.rs index c379b6e7d..e506e159f 100644 --- a/crates/freshell-ws/src/reconcile.rs +++ b/crates/freshell-ws/src/reconcile.rs @@ -674,6 +674,7 @@ mod tests { session_ref: None, resume_session_id: None, status: None, + naming_handle: None, } } @@ -784,8 +785,8 @@ mod tests { #[test] fn managed_ownership_dominates_a_stale_disk_index_absence_verdict() { let f = Fixture::new(); - f.registry - .register_managed(freshell_terminal::registry::ManagedTerminalDescriptor { + f.registry.register_managed( + freshell_terminal::registry::ManagedTerminalDescriptor { soul_id: "soul-managed-1".to_string(), incarnation_id: "incarnation-managed-1".to_string(), terminal_id: "T-managed".to_string(), @@ -794,7 +795,9 @@ mod tests { cwd: "/workspace".to_string(), resume_session_id: Some("ses-managed".to_string()), create_request_id: Some("cr-managed-original".to_string()), - }); + }, + "OpenCode".to_string(), + ); // The host-local index has seen this id before and no longer finds it: // the exact shape that produces `session_not_on_disk`. f.probe.mark_observed("opencode", "ses-managed"); diff --git a/crates/freshell-ws/src/reconcile_freshagent.rs b/crates/freshell-ws/src/reconcile_freshagent.rs index cadf0d305..62bc48600 100644 --- a/crates/freshell-ws/src/reconcile_freshagent.rs +++ b/crates/freshell-ws/src/reconcile_freshagent.rs @@ -409,6 +409,7 @@ mod tests { }), resume_session_id: None, status: None, + naming_handle: None, } } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 1410d3243..9b57f96e6 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -3469,6 +3469,7 @@ fn stable_managed_uuid(create_request_id: &str, domain: &[u8]) -> Uuid { async fn adopt_existing_managed_for_compat( registry: &freshell_terminal::TerminalRegistry, + cli_commands: &[freshell_platform::CliCommandSpec], mode: &str, create_request_id: &str, negotiated_managed_runtime: bool, @@ -3483,7 +3484,9 @@ async fn adopt_existing_managed_for_compat( .simple() .to_string(); if !registry - .adopt_managed_for_reconcile(&terminal_id, Some(create_request_id.to_string())) + .adopt_managed_for_reconcile(&terminal_id, Some(create_request_id.to_string()), |mode| { + crate::terminal_naming::mode_title(cli_commands, mode) + }) .await? { return Ok(None); @@ -3813,19 +3816,23 @@ mod managed_runtime_id_tests { }))); let adopted = - adopt_existing_managed_for_compat(®istry, "opencode", create_request_id, false) + adopt_existing_managed_for_compat(®istry, &[], "opencode", create_request_id, false) .await .unwrap() .expect("old-capability replay adopts the managed row"); assert_eq!(adopted, descriptor); assert!(registry.is_managed(&terminal_id)); - assert!( - adopt_existing_managed_for_compat(®istry, "opencode", create_request_id, true,) - .await - .unwrap() - .is_none() - ); + assert!(adopt_existing_managed_for_compat( + ®istry, + &[], + "opencode", + create_request_id, + true, + ) + .await + .unwrap() + .is_none()); } async fn assert_compat_create_retains_ownership(wire_ref: bool, already_registered: bool) { @@ -3861,7 +3868,9 @@ mod managed_runtime_id_tests { final_tail: false, }))); if already_registered { - state.registry.register_managed(descriptor); + state + .registry + .register_managed(descriptor, "OpenCode".to_string()); } let frames = Arc::new(std::sync::Mutex::new(Vec::::new())); @@ -3967,7 +3976,7 @@ mod managed_runtime_id_tests { resume_session_id: Some("ses_match".into()), create_request_id: Some("create-match".into()), }; - registry.register_managed(descriptor.clone()); + registry.register_managed(descriptor.clone(), "OpenCode".to_string()); let check = |descriptor: &ManagedTerminalDescriptor| { super::settle_managed_compat_adoption( ®istry, &None, &create, 17, descriptor, &mut None, &mut None, None, @@ -4018,7 +4027,9 @@ mod managed_runtime_id_tests { resume_session_id: Some("ses_retained_ended".into()), create_request_id: Some(request.into()), }; - state.registry.register_managed(descriptor.clone()); + state + .registry + .register_managed(descriptor.clone(), "OpenCode".to_string()); state .registry .ingest_managed_output(&terminal, 1, 1, "final retained tail\n".into()); @@ -4100,7 +4111,7 @@ mod managed_runtime_id_tests { resume_session_id: Some("ses_ended_input".into()), create_request_id: Some("ended-input-create".into()), }; - registry.register_managed(descriptor.clone()); + registry.register_managed(descriptor.clone(), "OpenCode".to_string()); registry.finish_managed_exit(&descriptor.terminal_id, 17); registry.set_managed_controller(Some(Arc::new(LookupOnlyController { descriptor: descriptor.clone(), @@ -4773,7 +4784,15 @@ pub(crate) async fn prepare_launch( /// Unified agent names (Task 2): the scoped-create naming admission. See /// [`handle_create`]'s call site for the ordering contract. Answers the /// frame projection (`nameRef` + last-known `sessionName`) when the create -/// is in scope and a naming authority is wired. +/// is in scope, a naming authority is wired, and a record is known. +/// +/// Name reliability (Task 10): the admission runs through the shared +/// terminal naming bind, so a transient store error is retried and then +/// deferred to the reconcile tick with the binding kept — a read failure on +/// a resume never falls into the fresh pending path, and a failed +/// `ensure_pending` still leaves the row bound to its pending handle. +/// `preallocated_session_id` is this create's own server-preallocated +/// `--session-id` (a fresh Claude create), which is prospective. #[allow(clippy::too_many_arguments)] async fn admit_create_naming( state: &WsState, @@ -4782,117 +4801,44 @@ async fn admit_create_naming( mode: &str, cwd: Option<&str>, session_locator: Option<&SessionLocator>, + fallback_title: String, + preallocated_session_id: Option<&str>, ) -> Option<( freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, )> { - let provider = freshell_freshagent::naming::named_provider_for(Some(mode), None)?; - let sink = state.identity.naming()?; // A resume/restore create (a client-sent sessionRef) targets the DURABLE - // session's own record — established identities are never re-pended. - // The server-side PREALLOCATED identity is deliberately NOT durable - // input: a fresh `--session-id` spawn is prospective (the transcript - // does not exist yet), so a fresh create always admits its pre-durable - // handle and the verified-bind lanes transfer it at materialization. + // session's own record — established identities are never re-pended. So + // does a managed create the supervisor answered with an existing soul's + // native session (a retried create after a web restart). The server-side + // PREALLOCATED identity is deliberately NOT durable input — not even when + // the supervisor echoes it back in the managed descriptor: a fresh + // `--session-id` spawn is prospective (the transcript does not exist + // yet), so a fresh create always admits its pre-durable handle and the + // verified-bind lanes transfer it at materialization. let durable_session = session_locator - .filter(|locator| locator.provider == provider.as_str()) - .map(|locator| locator.session_id.clone()); - if let Some(session_id) = durable_session { - let target = freshell_protocol::session_names::SessionNameRef::Session { - provider, - session_id: session_id.clone(), - }; - state - .identity - .set_name_binding(terminal_id, Some(target.clone()), None); - state - .registry - .set_naming(terminal_id, Some(target.clone()), None); - let updates = sink - .get(vec![target.clone()]) - .await - .map_err(|error| { - // Structured failure log under THIS crate's target. - tracing::warn!( - target: "freshell_ws::naming", - op = "get", - name_ref = %freshell_freshagent::naming::name_ref_debug_key(&target), - revision = error.log_revision(), - class = %error.code(), - "session_names.operation_failed: {error}" - ); - }) - .ok(); - match updates.and_then(|found| found.into_iter().next()) { - Some(update) => { - state - .registry - .update_session_name(terminal_id, &update.record); - return Some((update.record.name_ref.clone(), update.record)); - } - None => { - // A recovery create against a PROSPECTIVE (record-less) - // sessionRef — a zero-turn pane whose server restarted — - // must re-stamp the PERSISTED pre-durable handle (the pane - // content's own namingHandle) instead of leaving the - // restored row bound to a dead durable ref: only a pending - // binding puts the row on the sweep's reconcile list, so - // the verified bind can transfer the record once the - // transcript materializes. Without this fallthrough the - // pre-durable rename is orphaned behind a spent handle - // forever. A record-less durable create WITHOUT a handle - // keeps the previous behavior (durable binding, unnamed). - let has_persisted_handle = create - .naming_handle - .as_deref() - .map(|handle| !handle.trim().is_empty()) - .unwrap_or(false); - if !has_persisted_handle { - return None; - } - } - } - } - // Fresh scoped create: admit the pre-durable handle (client-sent, else - // server-minted — the nanoid-style uuid facility). - let handle = create - .naming_handle - .clone() - .filter(|h| !h.trim().is_empty()) - .unwrap_or_else(|| format!("nh-{}", uuid::Uuid::new_v4())); - let pending = freshell_protocol::session_names::SessionNameRef::Pending { id: handle.clone() }; - let update = sink - .ensure_pending(freshell_freshagent::naming::PendingNameInput { - handle: handle.clone(), - provider, + .filter(|locator| locator.provider == mode) + .map(|locator| locator.session_id.clone()) + .or_else(|| { + freshell_freshagent::terminal_name_binding::managed_reused_session( + &state.registry, + terminal_id, + preallocated_session_id, + ) + }); + let outcome = crate::terminal_naming::bind_ws_terminal_naming( + state, + terminal_id, + freshell_freshagent::terminal_name_binding::NamingBindHint { + mode: mode.to_string(), cwd: cwd.map(str::to_string), - }) - .await - .map_err(|error| { - // Structured failure log under THIS crate's target. - tracing::warn!( - target: "freshell_ws::naming", - op = "ensure_pending", - name_ref = %freshell_freshagent::naming::name_ref_debug_key(&pending), - revision = error.log_revision(), - class = %error.code(), - "session_names.operation_failed: {error}" - ); - }) - .ok()?; - let target = update.record.name_ref.clone(); - // Retain the binding BEFORE the frame acknowledges creation (the - // registry row + identity entry are the rename routes' resolvers). - state - .identity - .set_name_binding(terminal_id, Some(target.clone()), Some(handle.clone())); - state - .registry - .set_naming(terminal_id, Some(target.clone()), Some(handle)); - state - .registry - .update_session_name(terminal_id, &update.record); - Some((update.record.name_ref.clone(), update.record)) + resume_session_id: durable_session, + naming_handle: create.naming_handle.clone(), + fallback_title, + }, + ) + .await; + outcome.projection() } /// `terminal.create` — spawn + register the PTY in the shared registry (owned by no @@ -5704,6 +5650,7 @@ pub(crate) async fn handle_create( // terminal protocol without requiring the old client to understand souls. match adopt_existing_managed_for_compat( &state.registry, + &state.cli_commands, &mode, &create.request_id, use_managed_runtime, @@ -5733,6 +5680,14 @@ pub(crate) async fn handle_create( .await; } }; + // Name reliability (Task 10): the reconstructed facade binds the + // soul's saved name before the frame acknowledges it. + crate::terminal_naming::bind_adopted_managed_row( + state, + &descriptor, + create.naming_handle.clone(), + ) + .await; tracing::info!( terminal_id = %managed_terminal_id, create_request_id = %create.request_id, @@ -6901,7 +6856,9 @@ pub(crate) async fn handle_create( }; match state.registry.launch_managed(managed).await { Ok(descriptor) => { - state.registry.register_managed(descriptor); + state + .registry + .register_managed(descriptor, mode_label(&mode, cli.as_ref())); tracing::info!( terminal_id = %terminal_id, mode = %mode, @@ -7578,6 +7535,10 @@ pub(crate) async fn handle_create( &mode, spec.cwd.as_deref(), create_session_locator(&create).as_ref(), + mode_label(&mode, cli.as_ref()), + resume_session_id + .as_deref() + .filter(|_| claude_fresh_prealloc), ) .await; let name_ref = naming_projection.as_ref().map(|(r, _)| r.clone()); @@ -7677,6 +7638,11 @@ pub struct AgentRespawnRequest { /// SAME as the dead generation (cap continuity) pub create_request_id: String, pub cwd: Option, + /// The crashed generation's terminal id. Name reliability (Task 10): + /// its still-pending naming handle (one not yet transferred onto a + /// durable session) carries over, so a pre-durable name survives the + /// respawn. `None` for a respawn with no predecessor row. + pub replaces_terminal_id: Option, } /// Why a respawn could not produce a terminal. The orchestrator (Task 5) @@ -8248,6 +8214,32 @@ pub async fn respawn_agent_terminal( } } + // Name reliability (Task 10): the replacement row resumes the SAME + // durable session, so it binds that session's saved name (its title was + // " (auto-resumed)" with no binding at all). With no record yet the + // auto-resumed label stays. A gate-fired fresh respawn (the cached session + // is gone) is prospective, so it admits a pending handle instead. The + // crashed row's STILL-PENDING handle carries over (a rename before the + // first message), so its pre-durable name survives and the bind lane + // can still transfer it. + crate::terminal_naming::bind_ws_terminal_naming( + state, + &terminal_id, + freshell_freshagent::terminal_name_binding::NamingBindHint { + mode: mode.clone(), + cwd: spec.cwd.clone(), + resume_session_id: resume_session_id + .clone() + .filter(|_| launch_intent == LaunchIntent::Resume), + naming_handle: req + .replaces_terminal_id + .as_deref() + .and_then(|crashed| crate::terminal_naming::still_pending_handle(state, crashed)), + fallback_title: format!("{} (auto-resumed)", mode_label(&mode, cli.as_ref())), + }, + ) + .await; + // "Notify all clients that list changed" so sidebars refresh, then the // meta slice — the same closing pair as `handle_create`. broadcast_terminals_changed(state); @@ -8504,10 +8496,13 @@ async fn handle_pane_reconcile( if state.registry.exists(terminal_id) { continue; } - match state - .registry - .adopt_managed_for_reconcile(terminal_id, pane.create_request_id.clone()) - .await + match crate::terminal_naming::adopt_managed_row_for_reconcile( + state, + terminal_id, + pane.create_request_id.clone(), + pane.naming_handle.clone(), + ) + .await { Ok(true) => tracing::info!( terminal_id, @@ -12858,6 +12853,12 @@ mod connection_span_filter_tests { } } +/// Name reliability (Task 10): every WebSocket-side terminal row path binds +/// its saved name (a child of this module, so it drives the real entries). +#[cfg(test)] +#[path = "terminal_naming_tests.rs"] +mod terminal_naming_tests; + /// Task 2 (reconnect-revive): the `pane.reconcile.request` capability gate. /// A request arriving on a connection that did NOT negotiate `paneReconcileV1` /// must get an explicit terminal refusal carrying the reconcileId (the client @@ -13420,16 +13421,19 @@ mod managed_output_order_tests { read_error: Option<&str>, ) -> String { let terminal_id = format!("T-managed-{suffix}"); - state.registry.register_managed(ManagedTerminalDescriptor { - soul_id: format!("soul-{suffix}"), - incarnation_id: format!("incarnation-{suffix}"), - terminal_id: terminal_id.clone(), - stream_id: format!("S-managed-{suffix}"), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: Some(format!("ses-{suffix}")), - create_request_id: Some(format!("req-{suffix}")), - }); + state.registry.register_managed( + ManagedTerminalDescriptor { + soul_id: format!("soul-{suffix}"), + incarnation_id: format!("incarnation-{suffix}"), + terminal_id: terminal_id.clone(), + stream_id: format!("S-managed-{suffix}"), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some(format!("ses-{suffix}")), + create_request_id: Some(format!("req-{suffix}")), + }, + "OpenCode".to_string(), + ); let blocked_lifecycle = ManagedLifecycle { state: ManagedLifecycleState::Blocked, recovery_summary: Some(freshell_protocol::ManagedRecoverySummary { @@ -13460,16 +13464,19 @@ mod managed_output_order_tests { async fn native_identity_and_exit_in_one_read_bind_before_exit() { let state = super::pane_reconcile_gate_tests::state(); let terminal_id = "T-managed-short-lived"; - state.registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-short-lived".into(), - incarnation_id: "incarnation-short-lived".into(), - terminal_id: terminal_id.into(), - stream_id: "S-managed-short-lived".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: Some("req-short-lived".into()), - }); + state.registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-short-lived".into(), + incarnation_id: "incarnation-short-lived".into(), + terminal_id: terminal_id.into(), + stream_id: "S-managed-short-lived".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: Some("req-short-lived".into()), + }, + "OpenCode".to_string(), + ); state .registry .set_managed_controller(Some(Arc::new(ExitedOpenCodeRead { @@ -13509,16 +13516,19 @@ mod managed_output_order_tests { async fn managed_final_exit_waits_for_all_retained_pages() { let state = super::pane_reconcile_gate_tests::state(); let terminal = "T-managed-short-lived"; - state.registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-short-lived".into(), - incarnation_id: "incarnation-short-lived".into(), - terminal_id: terminal.into(), - stream_id: "S-managed-short-lived".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: Some("req-short-lived".into()), - }); + state.registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-short-lived".into(), + incarnation_id: "incarnation-short-lived".into(), + terminal_id: terminal.into(), + stream_id: "S-managed-short-lived".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: Some("req-short-lived".into()), + }, + "OpenCode".to_string(), + ); state .registry .set_managed_controller(Some(Arc::new(ExitedOpenCodeRead { @@ -13556,16 +13566,19 @@ mod managed_output_order_tests { let state = super::pane_reconcile_gate_tests::state(); let terminal_id = "T-managed-blocked"; let mut events = state.broadcast_tx.subscribe(); - state.registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-blocked".into(), - incarnation_id: "incarnation-blocked".into(), - terminal_id: terminal_id.into(), - stream_id: "S-managed-blocked".into(), - mode: "opencode".into(), - cwd: "/workspace".into(), - resume_session_id: Some("ses-blocked".into()), - create_request_id: Some("req-blocked".into()), - }); + state.registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-blocked".into(), + incarnation_id: "incarnation-blocked".into(), + terminal_id: terminal_id.into(), + stream_id: "S-managed-blocked".into(), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some("ses-blocked".into()), + create_request_id: Some("req-blocked".into()), + }, + "OpenCode".to_string(), + ); state .registry .set_managed_controller(Some(Arc::new(ExitedOpenCodeRead { @@ -13791,16 +13804,19 @@ mod managed_output_order_tests { async { let state = super::pane_reconcile_gate_tests::state(); let terminal = "T-managed-failures"; - state.registry.register_managed(ManagedTerminalDescriptor { - soul_id: "soul-failure".into(), - incarnation_id: "incarnation-failure".into(), - terminal_id: terminal.into(), - stream_id: "epoch-failure".into(), - mode: "shell".into(), - cwd: "/workspace".into(), - resume_session_id: None, - create_request_id: None, - }); + state.registry.register_managed( + ManagedTerminalDescriptor { + soul_id: "soul-failure".into(), + incarnation_id: "incarnation-failure".into(), + terminal_id: terminal.into(), + stream_id: "epoch-failure".into(), + mode: "shell".into(), + cwd: "/workspace".into(), + resume_session_id: None, + create_request_id: None, + }, + "Shell".to_string(), + ); let controller = Arc::new(FailedRead(StdMutex::new(Some( "transport unavailable secret=synthetic-sensitive".into(), )))); diff --git a/crates/freshell-ws/src/terminal_naming.rs b/crates/freshell-ws/src/terminal_naming.rs new file mode 100644 index 000000000..237ceca88 --- /dev/null +++ b/crates/freshell-ws/src/terminal_naming.rs @@ -0,0 +1,221 @@ +//! Name reliability (Task 10): the WebSocket door's side of the shared +//! terminal naming bind (`freshell_freshagent::terminal_name_binding`). +//! +//! [`IdentityNameBinder`] implements the bind's [`TerminalNameBinder`] over +//! this crate's identity registry — the table every rename route and name +//! projection resolves through. The thin wrappers below call the shared +//! helper from each WebSocket-side terminal row path (create, managed +//! adoption, auto-resume respawn), from the identity upsert's conversation +//! switch callers, and from the 2 s reconcile tick. `freshell-server` also +//! hands an [`IdentityNameBinder`] to the REST door, so both doors write the +//! SAME identity table through the same helper. + +use std::sync::Arc; +use std::time::Duration; + +use freshell_freshagent::naming::SessionNaming; +use freshell_freshagent::terminal_name_binding::{ + bind_terminal_naming_from_identity, follow_identity_name_ref, reconcile_terminal_names, + NamingBindHint, NamingBindOutcome, TerminalNameBinder, TerminalNamesReconcile, + TerminalNamesTick, +}; +use freshell_protocol::session_names::SessionNameRef; + +use crate::identity::TerminalIdentityRegistry; +use crate::WsState; + +/// The reconcile tick's cadence (the naming tick's 2 s, `freshell-server`). +const RECONCILE_INTERVAL: Duration = Duration::from_secs(2); + +/// The identity registry as the naming bind's door table. +#[derive(Clone, Debug)] +pub struct IdentityNameBinder { + identity: TerminalIdentityRegistry, +} + +impl IdentityNameBinder { + pub fn new(identity: TerminalIdentityRegistry) -> Self { + Self { identity } + } +} + +impl TerminalNameBinder for IdentityNameBinder { + fn set_name_binding( + &self, + terminal_id: &str, + name_ref: &SessionNameRef, + naming_handle: Option<&str>, + ) { + self.identity.set_name_binding( + terminal_id, + Some(name_ref.clone()), + naming_handle.map(str::to_string), + ); + } + + fn name_ref_of(&self, terminal_id: &str) -> Option { + self.identity.name_ref_for(terminal_id) + } + + fn naming_handle_of(&self, terminal_id: &str) -> Option { + self.identity.naming_handle_of(terminal_id) + } + + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)> { + self.identity + .session_ref_for(terminal_id) + .map(|locator| (locator.provider, locator.session_id)) + } +} + +/// `getModeLabel` from a mode and the registered CLI specs (the same label +/// `terminal.rs`'s `mode_label` reads off a resolved launch): `"Shell"` for +/// shell, the spec label otherwise, else the capitalized mode. +pub fn mode_title(cli_commands: &[freshell_platform::CliCommandSpec], mode: &str) -> String { + if mode == "shell" { + return "Shell".to_string(); + } + match cli_commands.iter().find(|spec| spec.name == mode) { + Some(spec) if !spec.label.is_empty() => spec.label.clone(), + _ => { + let mut chars = mode.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } + } +} + +/// The WebSocket door's naming authority and identity table. +fn door(state: &WsState) -> (Option>, IdentityNameBinder) { + ( + state.identity.naming(), + IdentityNameBinder::new(state.identity.clone()), + ) +} + +/// Bind one WebSocket-side terminal row through the shared helper. +pub(crate) async fn bind_ws_terminal_naming( + state: &WsState, + terminal_id: &str, + hint: NamingBindHint, +) -> NamingBindOutcome { + let (sink, binder) = door(state); + bind_terminal_naming_from_identity(sink.as_ref(), &state.registry, &binder, terminal_id, hint) + .await +} + +/// Bind a managed facade row the supervisor just handed back (reconcile or +/// compatibility adoption after a web restart): the soul's native session +/// is the durable target when it has one. A zero-turn soul's native session +/// may be prospective (Claude's preallocated `--session-id`) with no record +/// yet; `naming_handle`, the pane's persisted pre-durable handle, then lets +/// the row rejoin the pending record that holds the pane's name. +pub(crate) async fn bind_adopted_managed_row( + state: &WsState, + descriptor: &freshell_terminal::registry::ManagedTerminalDescriptor, + naming_handle: Option, +) -> NamingBindOutcome { + let hint = NamingBindHint { + mode: descriptor.mode.clone(), + cwd: Some(descriptor.cwd.clone()), + resume_session_id: descriptor.resume_session_id.clone(), + naming_handle, + fallback_title: mode_title(&state.cli_commands, &descriptor.mode), + }; + bind_ws_terminal_naming(state, &descriptor.terminal_id, hint).await +} + +/// A terminal's naming handle while its binding is STILL that pending +/// handle (not yet transferred onto a durable session). A transferred +/// handle is spent — it resolves to the old session's record — so it is +/// never answered. Retired identities still answer (a crashed row's). +pub(crate) fn still_pending_handle(state: &WsState, terminal_id: &str) -> Option { + match state.identity.name_ref_for(terminal_id) { + Some(SessionNameRef::Pending { id }) => Some(id), + _ => None, + } +} + +/// The reconcile pre-pass's managed adoption: reconstruct the supervisor's +/// facade for `terminal_id` (titled by its mode, never "Shell") and bind its +/// saved name. `naming_handle` is the pane's persisted pre-durable handle +/// (the reconcile entry's): a zero-turn soul's native session has no record +/// yet, and its name still lives under that handle. Answers whether a +/// managed row now exists for the id. +pub(crate) async fn adopt_managed_row_for_reconcile( + state: &WsState, + terminal_id: &str, + create_request_id: Option, + naming_handle: Option, +) -> Result { + let adopted = state + .registry + .adopt_managed_for_reconcile(terminal_id, create_request_id, |mode| { + mode_title(&state.cli_commands, mode) + }) + .await?; + if adopted { + if let Some(descriptor) = state.registry.managed_descriptor(terminal_id) { + bind_adopted_managed_row(state, &descriptor, naming_handle).await; + } + } + Ok(adopted) +} + +/// The conversation-switch follow-up: call right after an identity upsert +/// that may have retargeted this terminal onto a new durable session. The +/// registry binding follows the identity ref and the row adopts the new +/// session's record (or its mode label when none exists yet). +pub async fn follow_identity_switch(state: &WsState, terminal_id: &str) { + let Some(mode) = state.registry.mode_of(terminal_id) else { + return; + }; + let (sink, binder) = door(state); + let fallback = mode_title(&state.cli_commands, &mode); + follow_identity_name_ref( + sink.as_ref(), + &state.registry, + &binder, + terminal_id, + &fallback, + ) + .await; +} + +/// One reconcile pass over every terminal row (the tick's body; `pub` so +/// tests drive it deterministically). `tick` is the memory the passes share +/// (the empty-answer back-off). +pub async fn reconcile_terminal_naming( + state: &WsState, + tick: &TerminalNamesTick, +) -> TerminalNamesReconcile { + let (sink, binder) = door(state); + let cli_commands = Arc::clone(&state.cli_commands); + let label = move |mode: &str| mode_title(&cli_commands, mode); + reconcile_terminal_names(sink.as_ref(), &state.registry, &binder, &label, tick).await +} + +/// Spawned once by `freshell-server` boot: the 2 s terminal-naming reconcile +/// tick (moved bindings, unbound scoped rows, empty name caches). +pub fn spawn_terminal_naming_reconcile(state: WsState) { + tokio::spawn(async move { + let memory = TerminalNamesTick::default(); + let mut tick = tokio::time::interval(RECONCILE_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + let report = reconcile_terminal_naming(&state, &memory).await; + if report != TerminalNamesReconcile::default() { + tracing::info!( + target: "freshell_ws::naming", + moved = report.moved, + bound = report.bound, + filled = report.filled, + "session_names.terminal_naming_reconciled" + ); + } + } + }); +} diff --git a/crates/freshell-ws/src/terminal_naming_tests.rs b/crates/freshell-ws/src/terminal_naming_tests.rs new file mode 100644 index 000000000..7f0dd291e --- /dev/null +++ b/crates/freshell-ws/src/terminal_naming_tests.rs @@ -0,0 +1,736 @@ +//! Name reliability (Task 10): every WebSocket-side terminal row path binds +//! its saved name — managed reconcile adoption, compatibility adoption, the +//! auto-resume respawn — and a conversation switch moves the row's binding +//! to the new session (through the follow-up the upsert callers run, and +//! through the reconcile tick when that follow-up is skipped). +//! +//! A child of `terminal` so the cases drive the real `handle_create` / +//! `handle_pane_reconcile` / `respawn_agent_terminal` entries over the +//! module's own test state. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use freshell_freshagent::naming::{ + BindNameInput, NameActivity, NameError, NameFuture, NativeNameObservation, PendingNameInput, + RenameNameInput, SessionNaming, +}; +use freshell_protocol::native_location::NativeAcquisition; +use freshell_protocol::session_names::{ + NameSource, NamedProvider, SessionNameRecord, SessionNameRef, SessionNameUpdate, +}; +use freshell_terminal::registry::{ + ManagedOutputRead, ManagedTerminalController, ManagedTerminalDescriptor, ManagedTerminalFuture, + ManagedTerminalLaunch, ManagedTerminalLaunchError, +}; + +use super::ServerMessage; + +// ── fakes ──────────────────────────────────────────────────────────────── + +/// A record map behind the naming surface: `get` answers seeded or admitted +/// records, `ensure_pending` admits a directory-fallback record. Every +/// admission is counted so a test can prove no record was created. +#[derive(Default)] +struct MapSink { + records: Mutex>, + ensures: Mutex>, +} + +fn key(target: &SessionNameRef) -> String { + freshell_freshagent::naming::name_ref_debug_key(target) +} + +impl MapSink { + fn seed(&self, target: SessionNameRef, name: &str) { + self.records.lock().unwrap().insert( + key(&target), + SessionNameRecord { + name_ref: target, + name: name.to_string(), + source: NameSource::Manual, + revision: 3, + manual_revision: Some(3), + renamed_at: None, + legacy_origin: None, + }, + ); + } + + fn name_of(&self, target: &SessionNameRef) -> Option { + self.records + .lock() + .unwrap() + .get(&key(target)) + .map(|record| record.name.clone()) + } + + fn update(record: SessionNameRecord) -> SessionNameUpdate { + SessionNameUpdate { + redirects: Vec::new(), + record, + document_generation: 1, + changed: false, + native_sync: None, + outcome: None, + } + } +} + +fn unsupported() -> NameFuture { + Box::pin(async { Err(NameError::NotFound("not needed here".into())) }) +} + +impl SessionNaming for MapSink { + fn get(&self, refs: Vec) -> NameFuture> { + let records = self.records.lock().unwrap(); + let updates = refs + .iter() + .filter_map(|target| records.get(&key(target)).cloned()) + .map(MapSink::update) + .collect(); + Box::pin(async move { Ok(updates) }) + } + + fn ensure_pending(&self, input: PendingNameInput) -> NameFuture { + self.ensures.lock().unwrap().push(input.handle.clone()); + let target = SessionNameRef::Pending { + id: input.handle.clone(), + }; + let mut records = self.records.lock().unwrap(); + let record = records + .entry(key(&target)) + .or_insert_with(|| SessionNameRecord { + name_ref: target, + name: input.provider.display_label().to_string(), + source: NameSource::Directory, + revision: 1, + manual_revision: None, + renamed_at: None, + legacy_origin: None, + }) + .clone(); + Box::pin(async move { Ok(MapSink::update(record)) }) + } + + fn bind_pending(&self, _input: BindNameInput) -> NameFuture { + unsupported() + } + + fn rename(&self, _input: RenameNameInput) -> NameFuture { + unsupported() + } + + fn activity(&self, _input: NameActivity) -> NameFuture { + unsupported() + } + + fn observe_native(&self, _input: NativeNameObservation) -> NameFuture { + unsupported() + } + + fn record_acquisition( + &self, + _target: SessionNameRef, + _acquisition: NativeAcquisition, + ) -> NameFuture { + unsupported() + } +} + +/// The supervisor's inventory after a web restart: lookup answers the soul +/// for its own terminal id; nothing else is ever called. +struct SoulInventory { + soul: ManagedTerminalDescriptor, +} + +impl ManagedTerminalController for SoulInventory { + fn lookup_terminal<'a>( + &'a self, + terminal_id: &'a str, + _create_request_id: Option, + ) -> ManagedTerminalFuture<'a, Result, String>> { + let found = (terminal_id == self.soul.terminal_id).then(|| self.soul.clone()); + Box::pin(async move { Ok(found) }) + } + + fn launch<'a>( + &'a self, + _request: ManagedTerminalLaunch, + ) -> ManagedTerminalFuture<'a, Result> + { + Box::pin(async { Err("adoption must never launch".into()) }) + } + + fn input<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _data: String, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn resize<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _cols: u16, + _rows: u16, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn stop<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn read_output<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _after_seq: i64, + _max_bytes: u64, + ) -> ManagedTerminalFuture<'a, Result> { + Box::pin(async { Err("adoption must never read output".into()) }) + } +} + +// ── helpers ────────────────────────────────────────────────────────────── + +fn opencode(session_id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Opencode, + session_id: session_id.to_string(), + } +} + +fn claude(session_id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Claude, + session_id: session_id.to_string(), + } +} + +fn opencode_spec() -> freshell_platform::CliCommandSpec { + freshell_platform::CliCommandSpec { + name: "opencode".into(), + label: "OpenCode".into(), + default_cmd: "opencode".into(), + ..Default::default() + } +} + +/// The module's test state with naming wired and the given CLI specs. +fn named_state(specs: Vec) -> (crate::WsState, Arc) { + let mut state = super::pane_reconcile_gate_tests::state(); + let sink = Arc::new(MapSink::default()); + state.identity.set_session_naming(sink.clone()); + state.cli_commands = Arc::new(specs); + (state, sink) +} + +fn soul(terminal_id: &str, create_request_id: &str, session_id: &str) -> ManagedTerminalDescriptor { + ManagedTerminalDescriptor { + soul_id: format!("soul-{terminal_id}"), + incarnation_id: format!("incarnation-{terminal_id}"), + terminal_id: terminal_id.to_string(), + stream_id: format!("stream-{terminal_id}"), + mode: "opencode".into(), + cwd: "/workspace".into(), + resume_session_id: Some(session_id.to_string()), + create_request_id: Some(create_request_id.to_string()), + } +} + +fn assert_bound_to(state: &crate::WsState, terminal_id: &str, target: &SessionNameRef, name: &str) { + assert_eq!( + state.registry.name_ref_of(terminal_id).as_ref(), + Some(target), + "the registry row carries the expected binding" + ); + assert_eq!( + state.identity.name_ref_for(terminal_id).as_ref(), + Some(target), + "the identity table carries the same binding" + ); + assert_eq!( + state + .registry + .session_name_of(terminal_id) + .map(|record| record.name), + Some(name.to_string()), + "the row caches the saved name" + ); + assert_eq!( + state.registry.title_of(terminal_id).as_deref(), + Some(name), + "the row presents the saved name, never Shell" + ); +} + +// ── adoption ───────────────────────────────────────────────────────────── + +/// A negotiated reconcile for a soul the restarted server has no row for +/// reconstructs the facade AND binds the soul's saved name. +#[tokio::test] +async fn adopt_managed_for_reconcile_binds_naming() { + let (state, sink) = named_state(vec![opencode_spec()]); + sink.seed(opencode("ses_reconciled"), "Survivor"); + state + .registry + .set_managed_controller(Some(Arc::new(SoulInventory { + soul: soul("T-reconciled", "crid-reconciled", "ses_reconciled"), + }))); + let (mut ws_tx, _client) = super::pane_reconcile_gate_tests::loopback_sink_and_client().await; + let request: freshell_protocol::PaneReconcileRequest = + serde_json::from_value(serde_json::json!({ + "reconcileId": "rec-adopt", + "panes": [{ + "paneKey": "pk-soul", + "kind": "terminal", + "mode": "opencode", + "createRequestId": "crid-reconciled", + "terminalId": "T-reconciled", + }], + })) + .expect("reconcile request"); + + assert!(super::handle_pane_reconcile(request, &mut ws_tx, &state, false, true).await); + + assert!(state.registry.is_managed("T-reconciled")); + assert_bound_to( + &state, + "T-reconciled", + &opencode("ses_reconciled"), + "Survivor", + ); +} + +/// A legacy client's replayed create adopts the supervisor's facade through +/// the compatibility fence AND binds the soul's saved name; the created +/// frame projects it. +#[tokio::test] +async fn compat_adoption_binds_naming() { + let (state, sink) = named_state(vec![opencode_spec()]); + sink.seed(opencode("ses_compat_named"), "Survivor"); + let create_request_id = "crid-compat-named"; + let terminal_id = super::stable_managed_uuid(create_request_id, b"terminal") + .simple() + .to_string(); + state + .registry + .set_managed_controller(Some(Arc::new(SoulInventory { + soul: soul(&terminal_id, create_request_id, "ses_compat_named"), + }))); + let frames = Arc::new(Mutex::new(Vec::::new())); + let collector = Arc::clone(&frames); + let frame_sink: freshell_terminal::FrameSink = Arc::new(move |message| { + collector.lock().expect("frames lock").push(message); + }); + let mut output = crate::create_gate::CreateOutput::Channel(&frame_sink); + let create: freshell_protocol::TerminalCreate = serde_json::from_value(serde_json::json!({ + "requestId": create_request_id, + "mode": "opencode", + "shell": "system", + "restore": true, + })) + .expect("terminal create"); + let mut limiter = crate::create_limit::CreateRateLimiter::new(100, 1000); + + assert!( + super::handle_create( + create, + None, + &mut output, + &state, + 17, + true, + &mut limiter, + &super::ConnectionIdentity::default(), + super::now_ms(), + ) + .await + ); + + assert_bound_to( + &state, + &terminal_id, + &opencode("ses_compat_named"), + "Survivor", + ); + let frames = frames.lock().expect("frames lock"); + let created = frames + .iter() + .find_map(|frame| match frame { + ServerMessage::TerminalCreated(created) if created.terminal_id == terminal_id => { + Some(created.clone()) + } + _ => None, + }) + .expect("the adoption answers terminal.created"); + assert_eq!( + created.session_name.map(|record| record.name).as_deref(), + Some("Survivor") + ); + assert_eq!(created.name_ref, Some(opencode("ses_compat_named"))); +} + +// ── create admission ───────────────────────────────────────────────────── + +fn claude_create(request_id: &str) -> freshell_protocol::TerminalCreate { + serde_json::from_value(serde_json::json!({ + "requestId": request_id, + "mode": "claude", + "shell": "system", + })) + .expect("terminal create") +} + +fn managed_claude_row(state: &crate::WsState, terminal_id: &str, descriptor_session: &str) { + let mut descriptor = soul( + terminal_id, + &format!("crid-{terminal_id}"), + descriptor_session, + ); + descriptor.mode = "claude".into(); + state + .registry + .register_managed(descriptor, "Claude CLI".to_string()); +} + +/// A handle-less fresh managed Claude create: the supervisor echoed the +/// create's own server-preallocated `--session-id` into the descriptor. That +/// id is prospective, so the admission binds a server-minted PENDING handle +/// (the only binding the verified bind lane can transfer at +/// materialization) — never the bare, record-less session. +#[tokio::test] +async fn fresh_managed_create_never_binds_its_own_preallocated_session() { + let (state, sink) = named_state(Vec::new()); + managed_claude_row(&state, "T-fresh-claude", "s-prealloc"); + + let projection = super::admit_create_naming( + &state, + &claude_create("crid-T-fresh-claude"), + "T-fresh-claude", + "claude", + Some("/workspace"), + None, + "Claude CLI".to_string(), + Some("s-prealloc"), + ) + .await; + + let bound = state + .registry + .name_ref_of("T-fresh-claude") + .expect("the row is bound"); + assert!( + matches!(bound, SessionNameRef::Pending { .. }), + "a prospective session never binds as durable: {bound:?}" + ); + assert_eq!( + state.identity.name_ref_for("T-fresh-claude"), + Some(bound.clone()) + ); + assert_eq!( + sink.ensures.lock().unwrap().len(), + 1, + "the pending record was admitted" + ); + assert_eq!(projection.map(|(name_ref, _)| name_ref), Some(bound)); +} + +/// A managed create the supervisor answered with an EXISTING soul (its +/// native session differs from this create's own preallocation) binds that +/// soul's durable session and its saved name. +#[tokio::test] +async fn managed_create_reusing_a_soul_binds_the_souls_saved_name() { + let (state, sink) = named_state(Vec::new()); + sink.seed(claude("s-soul"), "Survivor"); + managed_claude_row(&state, "T-reused-claude", "s-soul"); + + let projection = super::admit_create_naming( + &state, + &claude_create("crid-T-reused-claude"), + "T-reused-claude", + "claude", + Some("/workspace"), + None, + "Claude CLI".to_string(), + Some("s-this-create"), + ) + .await; + + assert_bound_to(&state, "T-reused-claude", &claude("s-soul"), "Survivor"); + assert_eq!( + projection.map(|(_, record)| record.name).as_deref(), + Some("Survivor") + ); + assert!(sink.ensures.lock().unwrap().is_empty()); +} + +// ── auto-resume ────────────────────────────────────────────────────────── + +/// A sleeper claude CLI the auto-resume respawn can spawn (removed on drop). +#[cfg(unix)] +struct SleeperClaude { + script: std::path::PathBuf, +} + +#[cfg(unix)] +impl SleeperClaude { + fn new(case: &str) -> Self { + let script = std::env::temp_dir().join(format!( + "freshell-terminal-naming-respawn-{case}-{}.sh", + std::process::id() + )); + std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").expect("write sleeper"); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + Self { script } + } + + fn spec(&self) -> freshell_platform::CliCommandSpec { + freshell_platform::CliCommandSpec { + name: "claude".into(), + label: "Claude CLI".into(), + default_cmd: self.script.to_string_lossy().into_owned(), + resume_args: Some(vec!["--resume".into(), "{{sessionId}}".into()]), + create_session_args: Some(vec!["--session-id".into(), "{{sessionId}}".into()]), + ..Default::default() + } + } +} + +#[cfg(unix)] +impl Drop for SleeperClaude { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.script); + } +} + +#[cfg(unix)] +fn respawn_request(session_id: &str, replaces: Option<&str>) -> super::AgentRespawnRequest { + super::AgentRespawnRequest { + mode: "claude".into(), + provider: "claude".into(), + session_id: session_id.into(), + create_request_id: format!("crid-{session_id}"), + cwd: Some(std::env::temp_dir().to_string_lossy().into_owned()), + replaces_terminal_id: replaces.map(str::to_string), + } +} + +/// The auto-resume respawn's replacement row carries the session binding +/// and the saved name (it was titled " (auto-resumed)" and unbound). +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn respawned_agent_row_carries_binding_and_saved_name() { + let cli = SleeperClaude::new("survivor"); + let (state, sink) = named_state(vec![cli.spec()]); + sink.seed(claude("s-survivor"), "Survivor"); + + let terminal_id = super::respawn_agent_terminal(&state, &respawn_request("s-survivor", None)) + .await + .expect("respawn spawns a replacement"); + + assert_bound_to(&state, &terminal_id, &claude("s-survivor"), "Survivor"); + state.registry.kill(&terminal_id); +} + +/// The CLI crashed before its row's pending handle transferred onto the +/// durable session (a rename before the first message): the replacement +/// inherits that STILL-PENDING handle, so it shows the pre-durable name and +/// the bind lane can still transfer it — it neither binds the record-less +/// session nor mints a new handle. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn respawn_inherits_the_crashed_rows_still_pending_handle() { + let cli = SleeperClaude::new("pending"); + let (state, sink) = named_state(vec![cli.spec()]); + let pending = SessionNameRef::Pending { + id: "nh-before-first-message".into(), + }; + sink.seed(pending.clone(), "Early name"); + state + .identity + .upsert("T-crashed", Some("claude"), Some("s-early"), None, 1); + state.identity.set_name_binding( + "T-crashed", + Some(pending.clone()), + Some("nh-before-first-message".into()), + ); + state.identity.retire("T-crashed"); + + let terminal_id = + super::respawn_agent_terminal(&state, &respawn_request("s-early", Some("T-crashed"))) + .await + .expect("respawn spawns a replacement"); + + assert_bound_to(&state, &terminal_id, &pending, "Early name"); + assert_eq!( + state.identity.naming_handle_of(&terminal_id).as_deref(), + Some("nh-before-first-message"), + "the replacement retains the handle the bind lane transfers" + ); + assert_eq!( + *sink.ensures.lock().unwrap(), + vec!["nh-before-first-message".to_string()], + "no new handle was minted" + ); + state.registry.kill(&terminal_id); +} + +/// A crashed row whose handle ALREADY transferred onto a durable session +/// passes nothing on: that spent handle resolves to the old session's +/// record, so inheriting it would show the wrong conversation's name. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn respawn_never_inherits_a_transferred_handle() { + let cli = SleeperClaude::new("transferred"); + let (state, sink) = named_state(vec![cli.spec()]); + state.identity.upsert( + "T-crashed-durable", + Some("claude"), + Some("s-moved-on"), + None, + 1, + ); + state.identity.set_name_binding( + "T-crashed-durable", + Some(claude("s-moved-on")), + Some("nh-spent".into()), + ); + state.identity.retire("T-crashed-durable"); + + let terminal_id = super::respawn_agent_terminal( + &state, + &respawn_request("s-moved-on", Some("T-crashed-durable")), + ) + .await + .expect("respawn spawns a replacement"); + + assert_eq!( + state.registry.name_ref_of(&terminal_id), + Some(claude("s-moved-on")) + ); + assert!(sink.ensures.lock().unwrap().is_empty()); + state.registry.kill(&terminal_id); +} + +// ── conversation switch ────────────────────────────────────────────────── + +/// A durable claude row bound to session A whose record is "LotL". +fn row_on_session_a(state: &crate::WsState, sink: &MapSink, terminal_id: &str) { + state + .registry + .register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: terminal_id.to_string(), + stream_id: format!("S-{terminal_id}"), + mode: "claude".into(), + resume_session_id: Some("s-a".into()), + create_request_id: None, + created_at: None, + }); + state + .identity + .upsert(terminal_id, Some("claude"), Some("s-a"), None, 1); + state + .identity + .set_name_binding(terminal_id, Some(claude("s-a")), None); + state + .registry + .set_naming(terminal_id, Some(claude("s-a")), None); + let record = sink + .records + .lock() + .unwrap() + .get(&key(&claude("s-a"))) + .cloned() + .expect("A's record is seeded"); + state.registry.update_session_name(terminal_id, &record); +} + +/// Switching the terminal to session B (B has its own record) moves the +/// registry binding to B and shows B's name; A's record is untouched. +/// Covered twice: through the upsert callers' follow-up, and through the +/// reconcile tick when that follow-up is skipped. +#[tokio::test] +async fn conversation_switch_adopts_the_new_sessions_record() { + let (state, sink) = named_state(Vec::new()); + sink.seed(claude("s-a"), "LotL"); + sink.seed(claude("s-b"), "Billing bug"); + row_on_session_a(&state, &sink, "T-helper"); + row_on_session_a(&state, &sink, "T-tick"); + + state + .identity + .upsert("T-helper", Some("claude"), Some("s-b"), None, 2); + crate::terminal_naming::follow_identity_switch(&state, "T-helper").await; + state + .identity + .upsert("T-tick", Some("claude"), Some("s-b"), None, 2); + crate::terminal_naming::reconcile_terminal_naming(&state, &Default::default()).await; + + for terminal_id in ["T-helper", "T-tick"] { + assert_bound_to(&state, terminal_id, &claude("s-b"), "Billing bug"); + } + assert_eq!(sink.name_of(&claude("s-a")).as_deref(), Some("LotL")); +} + +/// Switching to a session with no record: the row moves to B (never back to +/// a pending handle that redirects to A), no record is created, and the row +/// shows the mode label; the naming publisher, which refreshes every row +/// bound to a record, reaches it once B is named. +#[tokio::test] +async fn conversation_switch_to_a_session_without_a_record_shows_the_fallback() { + let (state, sink) = named_state(Vec::new()); + sink.seed(claude("s-a"), "LotL"); + row_on_session_a(&state, &sink, "T-bare"); + state + .identity + .set_name_binding("T-bare", None, Some("nh-was-bound-to-a".into())); + let records_before = sink.records.lock().unwrap().len(); + + state + .identity + .upsert("T-bare", Some("claude"), Some("s-b"), None, 2); + crate::terminal_naming::follow_identity_switch(&state, "T-bare").await; + crate::terminal_naming::reconcile_terminal_naming(&state, &Default::default()).await; + + assert_eq!(state.registry.name_ref_of("T-bare"), Some(claude("s-b"))); + assert!(state.registry.session_name_of("T-bare").is_none()); + assert_eq!(state.registry.title_of("T-bare").as_deref(), Some("Claude")); + assert!(sink.ensures.lock().unwrap().is_empty()); + assert_eq!( + sink.records.lock().unwrap().len(), + records_before, + "a switch creates no record" + ); + assert_eq!( + state.registry.terminals_bound_to(&claude("s-b")), + vec!["T-bare".to_string()], + "the publisher's refresh set for B now includes the row" + ); + // B is named by the normal paths (hydrate / first message); the publisher + // folds the committed record into every row bound to it. + sink.seed(claude("s-b"), "Fix the login flow"); + let named = sink + .records + .lock() + .unwrap() + .get(&key(&claude("s-b"))) + .cloned() + .unwrap(); + for terminal_id in state.registry.terminals_bound_to(&claude("s-b")) { + state.registry.update_session_name(&terminal_id, &named); + } + assert_eq!( + state.registry.title_of("T-bare").as_deref(), + Some("Fix the login flow") + ); + assert_eq!(sink.name_of(&claude("s-a")).as_deref(), Some("LotL")); +} diff --git a/crates/freshell-ws/tests/auto_resume_respawn.rs b/crates/freshell-ws/tests/auto_resume_respawn.rs index 568bb9b83..a9bf9f7c2 100644 --- a/crates/freshell-ws/tests/auto_resume_respawn.rs +++ b/crates/freshell-ws/tests/auto_resume_respawn.rs @@ -157,6 +157,7 @@ async fn respawn_spawns_resume_generation_with_same_create_request_id() { session_id: session_id.clone(), create_request_id: create_request_id.into(), cwd: None, + replaces_terminal_id: None, }, ) .await @@ -235,6 +236,7 @@ async fn respawn_is_rejected_loud_when_spawn_gate_queue_is_full() { session_id: "sess-gate-pin".into(), create_request_id: "req-gate-pin".into(), cwd: None, + replaces_terminal_id: None, }, ) .await @@ -463,6 +465,7 @@ async fn respawn_with_absent_session_spawns_fresh_and_retires_binding() { session_id: "stale-amp".into(), create_request_id: "req-respawn-gate-1".into(), cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + replaces_terminal_id: None, }, ) .await @@ -590,6 +593,7 @@ async fn respawn_with_unknown_existence_resumes_exactly_as_today() { session_id: "maybe-amp".into(), create_request_id: "req-respawn-gate-2".into(), cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + replaces_terminal_id: None, }, ) .await diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index 735cccf8e..fc40f7c10 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -802,6 +802,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { name: "Pre Identity Name".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .expect("pre-identity rename lands on the pending record"); @@ -822,6 +823,8 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { tokio::task::yield_now().await; let rebound8 = next_associated_frame(&mut ws, &tid8, "phase8/first-bind").await; + assert_eq!(rebound8["nameRef"], serde_json::to_value(claude_session_ref(b2)).unwrap(), + "the association carries the verified naming binding even before the client reads redirects"); assert_eq!( rebound8["sessionRef"], json!({ "provider": "claude", "sessionId": b2 }), @@ -894,6 +897,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { name: "C Own Name".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .expect("name C2"); @@ -911,6 +915,11 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { tokio::task::yield_now().await; let rebound9 = next_associated_frame(&mut ws, &tid8, "phase9/switch").await; + assert_eq!( + rebound9["nameRef"], + serde_json::to_value(state.identity.name_ref_for(&tid8)).unwrap(), + "the switch carries its own authoritative naming binding" + ); assert_eq!( rebound9["sessionRef"], json!({ "provider": "claude", "sessionId": c2 }), @@ -942,6 +951,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { name: "Renamed After Switch".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .expect("rename through the pane's current binding"); diff --git a/crates/freshell-ws/tests/codex_fork_rebind.rs b/crates/freshell-ws/tests/codex_fork_rebind.rs index 03159b150..136214e1b 100644 --- a/crates/freshell-ws/tests/codex_fork_rebind.rs +++ b/crates/freshell-ws/tests/codex_fork_rebind.rs @@ -772,6 +772,7 @@ async fn seed_named_durable_record( name: name.to_string(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -823,6 +824,7 @@ async fn cli_adoption_transfers_the_pending_naming_record() { name: "Pre Identity Codex".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -924,6 +926,7 @@ async fn cli_fork_retarget_surfaces_the_new_sessions_own_record() { name: "Old Own Name".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index 9823e3a18..4583bac8b 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -748,6 +748,7 @@ async fn codex_terminal_create_argv_default_managed_and_flag_zero_optout() { session_id: "thread-e2e-auto-resume".into(), create_request_id: "req-auto-resume".into(), cwd: Some(tmp_cwd.to_string_lossy().into_owned()), + replaces_terminal_id: None, }, ) .await diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 3daff9b7d..d2cf7073b 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -352,6 +352,16 @@ fn name_key(target: &freshell_protocol::session_names::SessionNameRef) -> String } impl NamingProbeSink { + /// Serve a pre-existing record (name reliability Task 10): the record a + /// RESTARTED server's store already holds for a session it never saw + /// create — e.g. a managed soul the supervisor kept alive. + pub fn seed_record(&self, record: freshell_protocol::session_names::SessionNameRecord) { + self.records + .lock() + .unwrap() + .insert(name_key(&record.name_ref), record); + } + fn resolve( &self, target: &freshell_protocol::session_names::SessionNameRef, @@ -386,6 +396,7 @@ impl NamingProbeSink { document_generation: 1, changed, native_sync: None, + outcome: None, } } } @@ -1573,7 +1584,10 @@ pub async fn connect_and_capture_inventory_with_identity( .await } -async fn connect_with_hello(url: &str, hello: serde_json::Value) -> (TestWs, serde_json::Value) { +pub async fn connect_with_hello( + url: &str, + hello: serde_json::Value, +) -> (TestWs, serde_json::Value) { let (mut ws, _resp) = tokio_tungstenite::connect_async(url) .await .expect("ws connect"); diff --git a/crates/freshell-ws/tests/managed_restart_naming.rs b/crates/freshell-ws/tests/managed_restart_naming.rs new file mode 100644 index 000000000..75861559a --- /dev/null +++ b/crates/freshell-ws/tests/managed_restart_naming.rs @@ -0,0 +1,647 @@ +//! Name reliability (Task 10, V3 + LB-B2): a server that RESTARTED while the +//! supervisor kept a managed soul alive must bind the soul's saved name on +//! EVERY entry a restarted process can use to reconstruct the terminal row — +//! reconcile adoption, compatibility adoption, a resume create, and a +//! negotiated managed create. Which one production takes after a real managed +//! restart is not known today (the pre-existing P2-G01 reattach bug), so each +//! entry is proven here on a FRESH server state (the "restarted" process): +//! its registry has no row, and its naming store already holds the soul's +//! session record "Survivor". Every case asserts the resulting row is bound +//! to the session ref, caches "Survivor", and is never titled "Shell". +//! +//! Raw-WS integration against an in-process axum server on an ephemeral +//! loopback port (the shared `common` harness convention). A fake +//! `ManagedTerminalController` stands in for the supervisor (the in-crate +//! `LookupOnlyController` pattern, `terminal.rs` `managed_runtime_id_tests`). +//! Only the resume create spawns a local OpenCode TUI, so only it runs under +//! `common::opencode_home`'s isolated home (whose contract asserts the TUI +//! plugin install that a local spawn performs); the managed entries spawn +//! nothing locally. +//! +//! The last case pins the other side of the managed descriptor: a FRESH +//! managed Claude create's own server-preallocated `--session-id`, which a +//! supervisor echoes back, is prospective and never binds as durable. +//! Managed Claude exists only in a `provider-qualification` build with the +//! qualification policy naming claude, so that case is feature-gated and +//! re-runs itself alone in a child process with the policy set. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use freshell_protocol::session_names::{ + NameSource, NamedProvider, SessionNameRecord, SessionNameRef, +}; +use freshell_terminal::registry::{ + ManagedOutputRead, ManagedTerminalController, ManagedTerminalDescriptor, ManagedTerminalFuture, + ManagedTerminalLaunch, ManagedTerminalLaunchError, +}; + +const SAVED_NAME: &str = "Survivor"; + +/// The supervisor after a web-server restart: it still owns the soul (lookup +/// answers it by its durable create request id) and launches idempotently +/// (a managed create for the soul's request answers the soul's descriptor). +struct RestartedSupervisor { + soul_create_request_id: String, + soul_session_id: String, + mode: String, + cwd: String, +} + +impl RestartedSupervisor { + fn descriptor(&self, terminal_id: &str, stream_id: &str) -> ManagedTerminalDescriptor { + ManagedTerminalDescriptor { + soul_id: format!("soul-{}", self.soul_create_request_id), + incarnation_id: format!("incarnation-{}", self.soul_create_request_id), + terminal_id: terminal_id.to_string(), + stream_id: stream_id.to_string(), + mode: self.mode.clone(), + cwd: self.cwd.clone(), + resume_session_id: Some(self.soul_session_id.clone()), + create_request_id: Some(self.soul_create_request_id.clone()), + } + } +} + +impl ManagedTerminalController for RestartedSupervisor { + fn lookup_terminal<'a>( + &'a self, + terminal_id: &'a str, + create_request_id: Option, + ) -> ManagedTerminalFuture<'a, Result, String>> { + Box::pin(async move { + Ok( + (create_request_id.as_deref() == Some(self.soul_create_request_id.as_str())) + .then(|| self.descriptor(terminal_id, &format!("stream-{terminal_id}"))), + ) + }) + } + + fn launch<'a>( + &'a self, + request: ManagedTerminalLaunch, + ) -> ManagedTerminalFuture<'a, Result> + { + Box::pin(async move { Ok(self.descriptor(&request.terminal_id, &request.stream_id)) }) + } + + fn input<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _data: String, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn resize<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _cols: u16, + _rows: u16, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn stop<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn read_output<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _after_seq: i64, + _max_bytes: u64, + ) -> ManagedTerminalFuture<'a, Result> { + Box::pin(async { + Ok(ManagedOutputRead { + lifecycle: None, + completion: None, + stream_epoch: None, + incarnation_id: None, + reset_required: false, + truncated: false, + retained_from_seq: 0, + head_seq: 0, + exit_code: None, + native_session_id: None, + chunks: Vec::new(), + }) + }) + } +} + +fn session_ref(session_id: &str) -> SessionNameRef { + SessionNameRef::Session { + provider: NamedProvider::Opencode, + session_id: session_id.to_string(), + } +} + +/// The restarted process: a fresh server (empty registry) whose naming store +/// already holds the soul's saved record, with the supervisor installed. The +/// soul's folder is a per-case temp dir (any create-time provider config +/// lands there, never in a shared directory). +async fn restarted_server( + case: &str, +) -> ( + String, + freshell_terminal::TerminalRegistry, + Arc, + Arc, + tempfile::TempDir, +) { + let restarted = boot_restarted(case, "opencode").await; + let (_, _, sink, supervisor, _) = &restarted; + sink.seed_record(saved_record( + session_ref(&supervisor.soul_session_id), + SAVED_NAME, + )); + restarted +} + +/// A fresh server (empty registry, empty naming store) whose supervisor +/// still owns a `mode` soul. +async fn boot_restarted( + case: &str, + mode: &str, +) -> ( + String, + freshell_terminal::TerminalRegistry, + Arc, + Arc, + tempfile::TempDir, +) { + let soul_dir = tempfile::tempdir().expect("soul folder"); + let (url, registry, sink) = + common::spawn_server_with_specs_and_naming(vec![common::sleeper_cli_spec(mode)]).await; + let supervisor = Arc::new(RestartedSupervisor { + soul_create_request_id: format!("crid-{case}"), + soul_session_id: format!("ses_{case}"), + mode: mode.to_string(), + cwd: soul_dir.path().to_string_lossy().into_owned(), + }); + registry.set_managed_controller(Some(supervisor.clone())); + assert!( + registry.directory().is_empty(), + "the restarted process starts with no terminal rows" + ); + (url, registry, sink, supervisor, soul_dir) +} + +/// A user-named saved record (the store a restarted server reopens). +fn saved_record(name_ref: SessionNameRef, name: &str) -> SessionNameRecord { + SessionNameRecord { + name_ref, + name: name.to_string(), + source: NameSource::Manual, + revision: 4, + manual_revision: Some(4), + renamed_at: None, + legacy_origin: None, + } +} + +async fn connect(url: &str, capabilities: serde_json::Value) -> common::TestWs { + let (ws, _inventory) = common::connect_with_hello( + url, + serde_json::json!({ + "type": "hello", + "token": common::AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + "capabilities": capabilities, + }), + ) + .await; + ws +} + +/// Poll the registry row until it carries the soul's binding and saved name +/// (bounded), then pin the three contract facts. +async fn assert_row_bound_to_saved_name( + registry: &freshell_terminal::TerminalRegistry, + terminal_id: &str, + session_id: &str, + entry: &str, +) { + assert_row_bound( + registry, + terminal_id, + &session_ref(session_id), + SAVED_NAME, + entry, + ) + .await; +} + +/// Poll the registry row until it caches `name` (bounded), then pin that it +/// is bound to `bound_to`, caches `name`, and is titled `name` (never +/// "Shell"). +async fn assert_row_bound( + registry: &freshell_terminal::TerminalRegistry, + terminal_id: &str, + bound_to: &SessionNameRef, + name: &str, + entry: &str, +) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + if registry.exists(terminal_id) + && registry + .session_name_of(terminal_id) + .is_some_and(|record| record.name == name) + { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!( + registry.exists(terminal_id), + "{entry}: the restarted server must reconstruct the soul's row" + ); + assert_eq!( + registry.name_ref_of(terminal_id).as_ref(), + Some(bound_to), + "{entry}: the row must be bound to {bound_to:?}" + ); + assert_eq!( + registry + .session_name_of(terminal_id) + .map(|record| record.name), + Some(name.to_string()), + "{entry}: the row must cache the saved name" + ); + let title = registry.title_of(terminal_id).unwrap_or_default(); + assert_ne!( + title, "Shell", + "{entry}: an agent row is never titled Shell" + ); + assert_eq!(title, name, "{entry}: the row presents the saved name"); +} + +/// A negotiated `pane.reconcile.request` for the soul's terminal id reaches +/// `adopt_managed_for_reconcile` (`terminal.rs` handle_pane_reconcile). +#[tokio::test(flavor = "multi_thread")] +async fn restart_reconcile_adopt_binds_the_saved_name() { + let (url, registry, _sink, supervisor, _soul_dir) = restarted_server("reconcile-adopt").await; + let mut ws = connect( + &url, + serde_json::json!({ "paneReconcileV1": true, "managedRuntimeV1": true }), + ) + .await; + let terminal_id = "t-soul-reconcile-adopt"; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "pane.reconcile.request", + "reconcileId": "rec-restart-1", + "panes": [{ + "paneKey": "pk-soul", + "kind": "terminal", + "mode": "opencode", + "createRequestId": supervisor.soul_create_request_id, + "terminalId": terminal_id, + }], + }) + .to_string(), + )) + .await + .expect("send pane.reconcile.request"); + + assert_row_bound_to_saved_name( + ®istry, + terminal_id, + &supervisor.soul_session_id, + "reconcile adopt", + ) + .await; +} + +/// A ZERO-TURN soul: the descriptor's native session is Claude's +/// preallocated `--session-id`, which has no transcript and no record yet, +/// and the pane's name ("Billing", renamed before the first message) lives +/// under the pane's pending handle. The reconcile entry carries that +/// persisted `namingHandle`, so the adopted row rejoins the pending record — +/// the binding the verified bind lane transfers at the first message — +/// instead of binding the record-less prospective session and orphaning the +/// name. +#[tokio::test(flavor = "multi_thread")] +async fn restart_reconcile_adopt_of_a_zero_turn_soul_keeps_its_pending_name() { + let (url, registry, sink, supervisor, _soul_dir) = + boot_restarted("reconcile-zero-turn", "claude").await; + let handle = "nh-zero-turn-soul"; + let pending = SessionNameRef::Pending { + id: handle.to_string(), + }; + sink.seed_record(saved_record(pending.clone(), "Billing")); + let mut ws = connect( + &url, + serde_json::json!({ "paneReconcileV1": true, "managedRuntimeV1": true }), + ) + .await; + let terminal_id = "t-soul-reconcile-zero-turn"; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "pane.reconcile.request", + "reconcileId": "rec-restart-zero-turn", + "panes": [{ + "paneKey": "pk-zero-turn", + "kind": "terminal", + "mode": "claude", + "createRequestId": supervisor.soul_create_request_id, + "terminalId": terminal_id, + "sessionRef": { "provider": "claude", "sessionId": supervisor.soul_session_id }, + "namingHandle": handle, + }], + }) + .to_string(), + )) + .await + .expect("send pane.reconcile.request"); + + assert_row_bound( + ®istry, + terminal_id, + &pending, + "Billing", + "zero-turn reconcile adopt", + ) + .await; +} + +/// A NON-negotiated `terminal.create` replaying the soul's create request id +/// reaches `adopt_existing_managed_for_compat` (`terminal.rs` handle_create's +/// compatibility fence); the created frame carries the saved name too. +#[tokio::test(flavor = "multi_thread")] +async fn restart_compat_adopt_binds_the_saved_name() { + let (url, registry, _sink, supervisor, _soul_dir) = restarted_server("compat-adopt").await; + let mut ws = connect(&url, serde_json::json!({})).await; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": supervisor.soul_create_request_id, + "mode": "opencode", + "shell": "system", + "cwd": supervisor.cwd, + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + let created = common::next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"] + .as_str() + .expect("terminal.created carries terminalId") + .to_string(); + assert!( + created["notice"] + .as_str() + .is_some_and(|notice| notice.contains("Reattached")), + "the create must take the compatibility adoption entry: {created}" + ); + assert_eq!( + created["sessionName"]["name"], + serde_json::json!(SAVED_NAME), + "the adoption's created frame carries the saved name: {created}" + ); + assert_row_bound_to_saved_name( + ®istry, + &terminal_id, + &supervisor.soul_session_id, + "compat adopt", + ) + .await; +} + +/// A `terminal.create` resuming the soul's session id reaches the durable +/// branch of `admit_create_naming`. +#[tokio::test(flavor = "multi_thread")] +async fn restart_resume_create_binds_the_saved_name() { + if common::opencode_home::run_with_opencode_home("restart_resume_create_binds_the_saved_name") { + return; + } + let (url, registry, _sink, supervisor, _soul_dir) = restarted_server("resume-create").await; + let mut ws = connect(&url, serde_json::json!({})).await; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": "req-resume-after-restart", + "mode": "opencode", + "shell": "system", + "cwd": supervisor.cwd, + "sessionRef": { "provider": "opencode", "sessionId": supervisor.soul_session_id }, + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + let created = common::next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"] + .as_str() + .expect("terminal.created carries terminalId") + .to_string(); + assert_row_bound_to_saved_name( + ®istry, + &terminal_id, + &supervisor.soul_session_id, + "resume create", + ) + .await; + registry.kill(&terminal_id); +} + +/// A negotiated managed create launches through the supervisor and reaches +/// `register_managed` (`terminal.rs` handle_create's managed door). +#[tokio::test(flavor = "multi_thread")] +async fn restart_managed_create_binds_the_saved_name() { + let (url, registry, _sink, supervisor, _soul_dir) = restarted_server("managed-create").await; + let mut ws = connect(&url, serde_json::json!({ "managedRuntimeV1": true })).await; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": supervisor.soul_create_request_id, + "mode": "opencode", + "shell": "system", + "cwd": supervisor.cwd, + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + let created = common::next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"] + .as_str() + .expect("terminal.created carries terminalId") + .to_string(); + assert!( + registry.is_managed(&terminal_id), + "the create must take the managed door" + ); + assert_row_bound_to_saved_name( + ®istry, + &terminal_id, + &supervisor.soul_session_id, + "managed create", + ) + .await; +} + +/// The qualification policy is a process-wide env var, so the case re-runs +/// itself alone in a child process with it set (and a throwaway HOME) — +/// never `set_var` in the shared test process. Answers true in the parent, +/// after the child passed. +#[cfg(feature = "provider-qualification")] +fn run_with_claude_qualification(test: &str) -> bool { + const CHILD: &str = "FRESHELL_TEST_CLAUDE_QUALIFICATION_CHILD"; + if let Ok(child) = std::env::var(CHILD) { + assert_eq!(child, test, "the qualification child ran the wrong case"); + return false; + } + let home = tempfile::tempdir().expect("child home"); + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", test, "--nocapture"]) + .env(CHILD, test) + .env("FRESHELL_MANAGED_PROVIDER_QUALIFICATION", "claude") + .env("HOME", home.path()) + .output() + .expect("run the qualification child"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed"), + "the qualification child failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + true +} + +/// A supervisor launching fresh souls the way the production controller +/// does: the descriptor echoes the create's own launch session id. +#[cfg(feature = "provider-qualification")] +struct FreshLaunchSupervisor; + +#[cfg(feature = "provider-qualification")] +impl ManagedTerminalController for FreshLaunchSupervisor { + fn lookup_terminal<'a>( + &'a self, + _terminal_id: &'a str, + _create_request_id: Option, + ) -> ManagedTerminalFuture<'a, Result, String>> { + Box::pin(async { Ok(None) }) + } + + fn launch<'a>( + &'a self, + request: ManagedTerminalLaunch, + ) -> ManagedTerminalFuture<'a, Result> + { + Box::pin(async move { + Ok(ManagedTerminalDescriptor { + soul_id: format!("soul-{}", request.terminal_id), + incarnation_id: format!("incarnation-{}", request.terminal_id), + terminal_id: request.terminal_id, + stream_id: request.stream_id, + mode: request.mode, + cwd: request.spec.cwd.unwrap_or_else(|| "/workspace".into()), + resume_session_id: request.resume_session_id, + create_request_id: request.create_request_id, + }) + }) + } + + fn input<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _data: String, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn resize<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _cols: u16, + _rows: u16, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn stop<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + ) -> ManagedTerminalFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn read_output<'a>( + &'a self, + _terminal: ManagedTerminalDescriptor, + _after_seq: i64, + _max_bytes: u64, + ) -> ManagedTerminalFuture<'a, Result> { + Box::pin(async { Err("no output in the naming test".into()) }) + } +} + +/// A negotiated, handle-less FRESH managed Claude create: the server +/// preallocates a `--session-id` and the supervisor echoes it in the +/// descriptor. That id is prospective (no transcript, no record), so the row +/// binds to a server-minted PENDING handle — the only binding the verified +/// bind lane can transfer at materialization — never to the bare session. +#[cfg(feature = "provider-qualification")] +#[tokio::test(flavor = "multi_thread")] +async fn fresh_managed_claude_create_binds_a_pending_handle_not_its_preallocated_id() { + if run_with_claude_qualification( + "fresh_managed_claude_create_binds_a_pending_handle_not_its_preallocated_id", + ) { + return; + } + let soul_dir = tempfile::tempdir().expect("soul folder"); + let (url, registry, _sink) = + common::spawn_server_with_specs_and_naming(vec![common::sleeper_cli_spec("claude")]).await; + registry.set_managed_controller(Some(Arc::new(FreshLaunchSupervisor))); + let mut ws = connect(&url, serde_json::json!({ "managedRuntimeV1": true })).await; + ws.send(WsMessage::Text( + serde_json::json!({ + "type": "terminal.create", + "requestId": "req-fresh-managed-claude", + "mode": "claude", + "shell": "system", + "cwd": soul_dir.path().to_string_lossy(), + }) + .to_string(), + )) + .await + .expect("send terminal.create"); + let created = common::next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"] + .as_str() + .expect("terminal.created carries terminalId") + .to_string(); + + assert!( + registry.is_managed(&terminal_id), + "the create must take the managed door" + ); + let preallocated = registry + .managed_descriptor(&terminal_id) + .and_then(|descriptor| descriptor.resume_session_id) + .expect("a fresh claude create preallocates its --session-id"); + let bound = registry + .name_ref_of(&terminal_id) + .expect("the row is bound before the created frame"); + assert!( + matches!(bound, SessionNameRef::Pending { .. }), + "the row must bind a pending handle, not the prospective session {preallocated}: {bound:?}" + ); + assert_eq!( + created["nameRef"]["kind"], + serde_json::json!("pending"), + "the created frame projects the pending binding: {created}" + ); +} diff --git a/crates/freshell-ws/tests/opencode_switch_rebind.rs b/crates/freshell-ws/tests/opencode_switch_rebind.rs index 80c2e1c51..b4637cc8a 100644 --- a/crates/freshell-ws/tests/opencode_switch_rebind.rs +++ b/crates/freshell-ws/tests/opencode_switch_rebind.rs @@ -730,6 +730,7 @@ async fn cli_db_association_transfers_pending_and_switch_surfaces_own_record() { name: "Pre Identity Opencode".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -827,6 +828,7 @@ async fn cli_db_association_transfers_pending_and_switch_surfaces_own_record() { name: "Switch Own Name".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .unwrap(); @@ -856,6 +858,19 @@ async fn cli_db_association_transfers_pending_and_switch_surfaces_own_record() { switch_record[0].record.name, "Switch Own Name", "the new session's OWN record must surface through the pane's binding" ); + // Name reliability (Task 10, G45): the REGISTRY row — what + // `/api/terminals`, the overview, and the publisher's refresh set read — + // follows the switch too, and presents the new session's own name. + assert_eq!( + registry.name_ref_of(&tid), + Some(opencode_session_ref(SWITCH)), + "the terminal row's naming binding must follow the conversation switch" + ); + assert_eq!( + registry.session_name_of(&tid).map(|record| record.name), + Some("Switch Own Name".to_string()), + "the terminal row must show the new session's own name" + ); let kept = sink .get(vec![opencode_session_ref(ASSOC)]) .await diff --git a/crates/freshell-ws/tests/session_identity_frames.rs b/crates/freshell-ws/tests/session_identity_frames.rs index 7520baebe..314f71451 100644 --- a/crates/freshell-ws/tests/session_identity_frames.rs +++ b/crates/freshell-ws/tests/session_identity_frames.rs @@ -429,6 +429,7 @@ async fn scoped_claude_resume_create_frames_carry_the_durable_naming_projection( name: "Preexisting Name".into(), intent: NameIntent::User, if_revision: None, + cwd: None, }) .await .expect("seed rename"); diff --git a/docs/development/rename-scope-contract.md b/docs/development/rename-scope-contract.md index 7b718e7e1..afe6e4ede 100644 --- a/docs/development/rename-scope-contract.md +++ b/docs/development/rename-scope-contract.md @@ -31,11 +31,59 @@ a migration-protected legacy label (`legacy_protected` — historic origin unknown, never claimed as human), Freshell's short AI-generated name (`freshell_ai`), the provider's own AI-generated title (`provider_ai`), the first message (`first_message`), and the working directory -(`directory`). An automatic offer can only raise rank; a manual name and -an accepted Freshell AI name are protected from late or stale automatic -answers. Automation (agent/API/CLI/MCP calls) defaults to an automatic -suggestion and can never acquire the permanence of a user's rename; -explicit `nameIntent: "user"` is the only automation path that does. +(`directory`). A manual name, a migration-protected label and an accepted +Freshell AI name are protected: nothing automatic ever replaces them, so +they are safe from late or stale automatic answers. Otherwise an automatic +offer replaces a saved name only by raising its rank, with three +exceptions (the rules live in +`crates/freshell-server/src/session_name_policy.rs`): + +- **A provider's changed native title.** Each record keeps the last valid + native title the provider reported (its baseline). A native title + replaces an unprotected name below `provider_ai` (a raise) or a junk name + (the self-heal rule below), and replaces an older `provider_ai` name at + equal rank only once a baseline exists and the title differs from it: + the first report over a `provider_ai` name only records the baseline, + because older automation renames were never written back to the + provider, which still reports its older title. A report equal to the + baseline is an echo and never changes the name, even when it would + raise rank, so an echo can never undo a rename. +- **Automation renames.** Automation (agent/API/CLI/MCP calls) defaults + to an automatic suggestion, recorded at `provider_ai`: it replaces any + unprotected name, equal rank included, and can never acquire the + permanence of a user's rename; explicit `nameIntent: "user"` is the only + automation path that does. It is refused when the saved name is + protected or when its text is a default label for the target (classified + with the target's best-known folder, so a rename to the session's own + folder name is refused rather than applied and then healed away). Every + scoped rename response carries `outcome`: `{applied: true}`; + `{applied: true, reason: "unchanged"}` when the saved name already is + that text; or `{applied: false, reason: "protected_name" | "default_label"}`. + Broadcasts never carry it. The fresh-agent pane and tab routes' `message` + follows it (`session name not applied: ` for a refusal, else + `session renamed`). The CLI reports a refusal on stderr as + `name not applied: `, and the MCP tool adds it as a `warning`. +- **The self-heal rule.** An unprotected saved name (`directory`, + `first_message`, `provider_ai`) that the shared classifier recognizes as + a default or derived label (see "What is never name evidence" below) + gives way to any real automatic name — a changed native title, the first + message, or the session index's provider title or first message — + whatever that offer's rank. A default or derived offer never replaces a + name, except that the session index's last fallback (the folder name, + else the provider label) may step such a name above `directory` down to + `directory` once, after which it is an ordinary `directory` name; the + same text is always a no-op. `manual`, `legacy_protected` and + `freshell_ai` names are never healed. Healing happens on the + session-index sweep (every pass offers each indexed session's first + message and re-checks its index row, applying at most 25 records' + changes per pass), on live native titles and on an accepted user message + that can be the session's first — one sent while Freshell has stored no + message for the session yet. A later message (a "continue" typed into a + resumed session) only replaces a name below `first_message`, so a junk + name it finds waits for the session index's real first message. Existing + junk names therefore heal without a repair step; a session the index no + longer lists heals only when it is seen again. A name the classifier + does not recognize stays until someone renames it. The user-facing control is **Rename** only. There is no "generate a new name" action, no custom-label control, and no "reset to provider title" @@ -43,17 +91,149 @@ for these sessions (a scoped null/blank rename is refused with `NAME_RESET_UNSUPPORTED`). Freshell's own short-name generation is activity-driven and server-side: it runs for UI-, CLI-, and API-created sessions alike after the first accepted user message, with an immediate -fallback name and bounded retries (three attempts, then the series stays -exhausted). A provider-generated title is a fallback and never -suppresses Freshell's generation. +fallback name and bounded retries. The fallback is the first message, +offered for every activity that carries one (a newly accepted or indexed +message, and a session that is opened or resumed). Once captured, the first +message remains the AI naming input across later sends; a session-index +extraction may correct that first message. Three attempts that +end in an empty or unusable answer or an ordinary request failure (a +timeout, a server error) are the limit; then the series stays exhausted +and keeps its fallback name. A credential, permission, proxy, certificate +or quota failure uses no attempt, and neither does an unreachable Google or +proxy (a connection that is refused, or not made within 10 seconds, as +when a firewall drops it): AI naming pauses for every session — 15 +minutes, or for a quota answer a re-probe that starts at 60 s and doubles +up to 15 minutes — and resumes after the pause or as soon as the Gemini +key changes. The first request after a pause is for a session that has +waited longer than the refused one, so a refusal of one session's request +does not stop naming for the others. While paused, no AI naming request +starts; the pause is logged +(`session_names.generation_capability_unavailable`, then +`session_names.generation_capability_restored`) and is not shown in the +UI. A configured Gemini key (`GOOGLE_GENERATIVE_AI_API_KEY` at boot, else +Settings → Naming; a nonempty Settings save replaces it at the next attempt) is +always used: it goes straight to Google when the proxy the server +inherited from its launching shell is OneCLI's, and through any other +inherited proxy. Only when no key is configured does AI naming use the +server's inherited OneCLI connection (a boot WARN, +`session_names.generation_route_inherited_identity`, says so); with +neither, no AI naming runs and the fallback names still apply. +Clearing the Settings field does not clear the running server's cached key; +it stops using that key only after a restart with no startup key configured. +Each attempt makes one request; there is no fallback between the key and +OneCLI. Re-deriving an older session's first message (when the extraction +rules change) never starts AI naming by itself: such a session, if AI +naming never ran for it, gets its AI name when it is next opened. A +provider-generated title is a fallback +and never suppresses Freshell's generation. The name is assigned before the durable provider identity exists (a pending handle), transfers to that identity atomically at materialization, and survives refreshes, reconnects, reopening, resuming, -cross-browser use, and server restarts. A multi-session tab stores no -name of its own: it follows its original pane's session while that pane +cross-browser use, and server restarts. Every terminal row the server +creates or adopts — WebSocket and REST creates, resume, crash +auto-resume, managed-runtime reattach after a restart, compatibility +adoption — binds to its session's saved name through one helper +(`crates/freshell-freshagent/src/terminal_name_binding.rs`); a transient +store failure is retried and then finished by the 2 s tick, so a row is +never left unbound, and the row's title is the saved name or a mode +label, never "Shell" for an agent. A row rebuilt for a session that has no +saved record yet (a zero-turn pane restored or adopted after a restart) +rejoins the pane's pending handle, which every recovery request carries +(the restore create, compatibility adoption, and the `pane.reconcile` +entry's `namingHandle`); a handle the store already moved onto a +different session is never used, so the row keeps its own session and +shows its mode label instead of another conversation's name. + +A multi-session tab follows its original pane's session while that pane exists, and a tab rename targets that session — not whichever pane last -had activity or focus. +had activity or focus. The tab's stored `title` is a display copy of that +session's saved name, kept by the saved-name tab title mirror +(`src/store/savedNameTabTitleMirror.ts`): it writes the saved name only +when the session's saved record is known (never a fallback label), changes +the title only (no tab recency bump, no user flag), and otherwise leaves +the last-known title in place. Every copy of the tab (the persisted +layout, `GET /api/tabs`, the tabs registry, closed-tab snapshots) +therefore carries the saved name, and a reload shows it before names +load. The copy is never name evidence, and no legacy title writer +(terminal window titles, the shell exit suffix, session reopen retitles, +the Kilroy first-message name, unscoped terminal renames) may write a +session-owned tab's title: each asks `canWriteLegacyTabTitle` +(`src/lib/tab-title-ownership.ts`) first. + +**A tab stops taking its name from a session.** When a tab's last +Claude/Codex/OpenCode pane is closed or replaced by other content (for +example `[Claude | shell]` with the Claude pane closed), the tab goes back to +the non-agent title rules. If its stored title is still the saved name the +mirror copied, it returns to the `Tab N` placeholder, so the tab shows its +own derived label (and later window titles from its shell), never the old +session's name. The mirror takes back only the name it copied: a title that +changed in the same update, or a user-renamed tab, is kept. The session +keeps its own saved name. + +**Conversation switch.** When a conversation switch (Claude's `/clear` or +`/resume`, an OpenCode session switch, a new Codex thread) moves a pane's +terminal onto another session, the pane, the terminal row and the tab +(when it takes its name from that pane) show the NEW session's saved +name, or the pane's fallback label until that session has one (the tab's +stored title returns to the `Tab N` placeholder, which every display +treats as no title). Nothing is copied +from the old session, whose name is untouched; the switch creates or +changes no record. A rename immediately after the switch can admit the +new session's directory fallback and rename it before the session index +creates its record. Canonical and session rename routes also admit a +durable target held by a live identity binding or the published session +index; the terminal route admits its live binding. Unknown unindexed ids, +prospective pending identities and retired-only unnamed targets remain +missing-name responses; admission never bypasses revision checks. +A pending handle that resolves to the old session +cannot redirect that rename back to it. The retry pins the pending record's +revision so a concurrent bind cannot change its target silently. +For terminal pane and tab renames, the current registry binding takes +precedence over a lagging browser layout. An editor's pending capture +continues to match its own verified materialization, but not a different +conversation, and revision checks still apply. +A pane switched before its first message keeps its +pending name, which transfers to the new session when that session +materializes. + +Association and materialization frames carry the server's current naming +reference. The client adopts it even before it has read pending redirects: +a zero-turn pending reference stays pending, while a switch releases the +old handle and names the new session. + +**Undo and redo.** A Claude undo or redo replaces its runtime as an +internal continuation. Before announcing the replacement, the name store +seeds its name once from the current session (or its still-pending name), +preserving manual provenance, rename time and generation history. The +child settles a copied in-flight generation start as interrupted, using +the existing retry budget; the source's actual request stays untouched. +The source record stays unchanged, no durable redirect is created, and an +existing child name is never replaced. Later renames address the child. +This applies to sidebar-resumed conversations too. Ordinary forks and new +conversations start independently and never inherit a name this way. + +**What is never name evidence.** One shared classifier +(`shared/name-evidence.ts` and `crates/freshell-sessions/src/name_evidence.rs`, +both run against `test/fixtures/name-evidence/cases.json`) recognizes +default and derived labels: `Tab N`; the session's own folder name (only +where the folder is known); provider and mode labels such as `Claude`, +`Codex CLI`, `OpenCode`, `Shell`, `Kilroy`, with an optional `Recovered ` +prefix and ` agent` suffix (`Shell agent`, `Recovered OpenCode agent`); +`Recovered`, `Untitled` and `Tab`; provider placeholders (OpenCode's +`New session - ` and `Child session - `); and text +that opens with a tool wrapper tag (``, +``, ``, ...). Such text is never +name evidence: a native title that is one is provenance only, a first +message that is one is neither a fallback name nor AI-naming input (and +the session index does not count tool wrapper records as user messages), an +automation rename to one is refused, an OpenCode session row never +presents its placeholder as its title (it shows the first-message title +instead), a managed-runtime refresh never writes an existing tab's title +and stores a reconstructed tab's view title only when it is a real name, +a stored tab title that is one heals (see "Stored tab title heal" below), +and a saved name that is one heals through the self-heal rule. User +renames are never filtered. ### Native (provider) sync status and limitations @@ -98,6 +278,37 @@ raw evidence was retained in immutable recovery copies under active. Post-migration, the old ladder fields are no longer consulted for these sessions. +After that one-time consolidation: + +- **Each device captures at most once.** A browser profile copies only + its genuine pre-migration layout keys (the bare shared + `freshell.layout.v3` family and the v2 tab/pane keys, and only while they + carry no unified-names fields) into immutable envelopes, once, then marks + itself done (`freshell.session-names.capture-done.v1`). A live + per-window layout, cross-window sync and machine-workspace restore are + never evidence. Each envelope is deleted once the server acknowledges + it, and a done device deletes any envelope an older client left. A + failed capture is logged (`legacy_name_capture_failed`) and never blocks + a layout save (`src/lib/legacy-name-capture-policy.ts`). +- **Post-completion browser imports are acknowledged without effect.** + Once the server's boot consolidation has completed, every browser import + is acknowledged and nothing is written: no record is created or changed, + no candidate id is stored, no backup file is written, and the document + generation does not move (one INFO + `session_names.import_ignored_after_completion` per import id). An old + browser tab left open after an upgrade therefore cannot revert a name. +- **An imported default label loses a bind collision.** When a pending + name binds to a durable session whose record an import installed and + that is still an unprotected default label ("Tab 4", "OpenCode agent"), + the live pending name wins; every other imported name keeps the rank + rule. +- **The migration bookkeeping is never pruned.** The acknowledged + candidate ids and the retained winning evidence stay in the name + document, because an older binary sharing it still relies on them. +- **Junk names heal on their own.** Default labels an earlier import left + behind heal through the self-heal rule above; there is no repair + command. + Recovery procedure (documented, the only supported path): read the immutable evidence in `name-migration-v1/`, then — if a name was lost — submit a deliberate rename through the normal Rename UI or API. Never @@ -106,6 +317,30 @@ override rows, and never reactivate competing aliases; a pre-feature binary rollback requires an approved stopped-scratch restore from the full config backup. +### Name-change log + +Every accepted saved-name change writes one INFO line, +`session_names.name_changed`, after the durable commit +(`crates/freshell-server/src/session_name_events.rs`): the record key +(`name_ref`); the old and new name, source and revision (the old ones are +absent for a new record); the `cause` (`import`, `native_observation`, +`first_message_fallback`, `generation`, `manual_rename`, +`automation_rename`, `bind`, `bind_collision`, `ensure_pending`, +`ensure_session`, `internal_continuation`, +`hydrate`, `adopted_external`); `self_heal` (true when the self-heal rule +replaced a junk name with one whose source ranks the same or lower); and +`from_ref`, `import_id`, `evidence_key` or `series` where they apply. A +failed commit logs nothing, and a change to bookkeeping only (a generation +advance, a native sync status) logs no line. A commit whose replacement +landed but whose durability is unproven (the operation failed with +`commit_uncertain`) is logged with its own cause, once, by the next naming +transaction that confirms the document. A change another process +committed is logged once, with cause `adopted_external`, when this server +adopts it (never for the document loaded at startup). A conversation +switch changes no record, so it logs `session_names.terminal_binding_moved` +instead. Names are logged as every naming surface shows them, and every +line passes the log writer's secret scrubber. + ## The legacy four-scope model (everything else) Each non-agent name has exactly one owner and each rename surface writes @@ -175,24 +410,72 @@ section composes the legacy scopes. **Tab display title** (enforced by `getTabDisplayTitle`, `src/lib/tab-title.ts`; a session-owned tab displays its source pane's canonical session name through -`selectTabDisplayTitles` instead): +`selectTabDisplayTitles` instead, and falls back to this composition only while +that saved record is not loaded — its stored title is then the last name the +saved-name tab title mirror copied, see the unified contract above): 1. **Explicit title** — `tab.title` with `titleSetByUser: true`: a TabBar inline rename, a `PATCH /api/tabs/:id`, or an explicit non-empty `name` on a - REST/MCP tab create (the `tab.create` fold sets the user flag for - caller-provided names). An empty explicit title falls through. + REST/MCP tab create (the `tab.create` fold sets the user flag for a + caller-provided name, except on a Claude/Codex/OpenCode tab: there the + name seeds the session's saved name as an automatic suggestion and the tab + title stays a mirror of the saved name, never frozen). An empty explicit + title falls through. 2. **Single-pane override** — the sole leaf pane's stored non-derived pane title, whatever composed it (see the pane ladder below — a registry auto-title like "Codex CLI" or a mirrored session title can show as the tab title when no explicit title outranks it). -3. **Stored non-derived `tab.title`** set without the user flag (create-time - mode labels like "Amplifier"). +3. **Stored non-derived `tab.title`** set without the user flag that is not + a `Tab N` placeholder — for example a window title a program set on a split + tab. A stored default label never stays on this rung: the store heals it + (below). 4. **cwd-leaf derived titles** — the `deriveTabName`/`derivePaneTitle` fallbacks. +**Stored tab title heal** (`src/store/storedTabTitleHeal.ts`; the rule is +`isHealableStoredTabTitle` in `src/lib/tab-title.ts`). Earlier builds' +managed-runtime refresh stored provider labels ("Shell agent", "OpenCode +agent", "Recovered Shell agent") as the title of every unrenamed tab it +matched; such a stored title is not a name. A stored title heals to the +`Tab N` placeholder (its position) when all of these hold: the tab has no +`titleSetByUser`; it has a layout; the title is not already `Tab N`; the +saved-name mirror does not own it (a session-owned tab whose saved record is +cached keeps the mirrored saved name, so the two never fight); the title +differs from the tab's derived label (a create-time label equal to it is +already no title to every display); and the shared classifier recognizes it +for the tab's mode and provider — a provider or mode label, a provider +placeholder or tool wrapper text. The classifier's folder-name rule is not +used, so a window title that is a folder name stays. Only the title changes +(no recency bump, no flag). The heal runs once when the store is created and +after every change to tabs, layouts, saved names or extensions, so every way +a tab arrives (the persisted-layout load, cross-window sync, the new-window +rebuild, a Tabs-view open, a write from an old client) passes it, and the +persisted layout, `GET /api/tabs` and the tabs registry follow the healed +title. A title's writer is not recorded, so a live window title that is +itself such a label (a program in a shell titling the window "opencode") +heals too; a single-pane tab still shows it through its pane title. A session +name, a rename and any other window title are never healed. Closed-tab and +other-device registry records written before the heal keep their stored +label until they are opened (the opened tab heals at once). + +**Rebuilt tabs keep their names.** A tab rebuilt from a recorded snapshot — +a new window or recovery rebuild (from the tabs registry's recorded +snapshots), a tab opened from a Tabs-view record, or a pane opened in its own +tab from there — gets back its recorded pane titles and pane rename flags, +remapped onto the rebuilt pane ids (a flag only together with its title; never +for a scoped agent pane, which its saved record names), and, for a tab that is +not session-owned, its tab rename flag (a pane opened in its own tab gets no +tab flag: the tab is new). One rule set, `src/lib/restored-tab-names.ts`, +serves all three; the tabs registry's pane snapshots carry the pane rename +flag (`titleSetByUser`) for it. + **Pane title** — composed per pane kind (NOT a blanket mirror-over-auto rule; enforced by the terminal-title cache plus `sessionTitleMirrorMiddleware`, -`src/store/sessionTitleMirror.ts`): +`src/store/sessionTitleMirror.ts`, which copies session-directory titles into +the titles of NON-scoped panes only — a scoped Claude/Codex/OpenCode pane +displays its saved name. Not to be confused with +`src/store/savedNameTabTitleMirror.ts`, which copies a session's saved name +into its session-owned TAB's stored title): - A **user-set pane title** is never mirrored over. - **Terminal panes**: a cached terminal-level title outranks the session-title @@ -201,8 +484,8 @@ enforced by the terminal-title cache plus `sessionTitleMirrorMiddleware`, auto-titles ("Codex CLI"), `PATCH /api/terminals/:id` renames, and the inventory/live folds own that pane; the mirror only titles terminal panes with NO cached terminal title (unattached/exited/not-yet-reattached panes). -- **Fresh-agent panes**: no registry pipeline — the session-title mirror is - their runtime title source (always eligible). +- **Non-scoped fresh-agent panes** (e.g. Kilroy): no registry pipeline — the + session-title mirror is their runtime title source (always eligible). - Fallback: the `initLayout`-derived cwd-leaf. **Title-less running session rows** (a real session whose transcript has not diff --git a/docs/plans/2026-10-21-name-reliability.md b/docs/plans/2026-10-21-name-reliability.md new file mode 100644 index 000000000..7d157639e --- /dev/null +++ b/docs/plans/2026-10-21-name-reliability.md @@ -0,0 +1,2315 @@ +# Name Reliability Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. Every task is red → green → +> refactor; a task is not done until its red receipt, green receipt and +> refactor note are recorded. A step labelled **pinning test** is expected to +> pass before the change; it guards behavior the change must keep and is not +> a red step. + +## User Request + +### Requested result +Complete the pane/tab naming-reliability work recovered from Claude session b0e858cc-c789-4cc8-aba4-46c81f21d8b8. Rebase fix/name-reliability onto current main, preserve the intended naming fixes alongside main's new behavior, finish remaining necessary repairs and validation, then land via the already-authorized PR path. + +### Explicit constraints +- At most 10 additional repair/review rounds are authorized. Stop immediately at the first passing result; no further fixes or review rounds after it passes. +- Keep all work in /home/dan/code/freshell/.worktrees/name-reliability on fix/name-reliability. Preserve unrelated work. Rebase is explicitly authorized. +- One durable saved name per coding-agent session; manual names protected; a conversation switch adopts its new session's name. No repair command or dedicated naming proxy. +- Preserve main's current Freshclaude identity, lifecycle and attention behavior. +- Use meaningful red/green/refactor tests and unit/integration/browser coverage. Do not weaken or skip tests. Fix observed test races. Use coordinated broad gates and the destructive-test sandbox. +- Known main failures require reproduction evidence before any baseline qualification. Unknown/new failures remain failures. +- One PR targeting main, already approved for creation and merge after checks; do not ask again. No production deployment or port3001 restart without the user's exact APPROVED, not given. + +### Accepted tradeoffs and residuals +Existing accepted limitations in the committed naming plan remain: one-file name storage, recognizable-junk-only healing, unindexed/exhausted histories, incomplete closed-registry cleanup, inherited OneCLI only without a configured Gemini key, and deployment-time quota confirmation. The separate pre-existing managed-shell restart and initial Freshclaude view-flavor persistence issues stay outside this change. + +**Goal:** A Claude, Codex or OpenCode session gets a sensible name (first +message, then Freshell's AI name) and keeps it across reloads, new windows, +reconnects and server restarts, and a conversation switch shows the new +conversation's name; shells, browsers and editors keep their user names in +new windows and across managed-runtime refreshes; a saved name that is +recognizable junk ("Tab 23", "OpenCode agent", tool wrapper text, a provider +placeholder) is replaced by the next real automatic name the session offers, +so existing damage heals as sessions are seen, while names a person chose are +never touched. + +**Architecture:** (1) The browser stops treating its live layout as "legacy" +name evidence: capture runs once per device, only from genuine pre-migration +keys, and once the one-time consolidation is complete the server acknowledges +browser imports without writing anything. (2) One shared default-label +classifier (TypeScript + Rust, one shared fixture) keeps "Tab N", folder +names, provider labels, OpenCode placeholders and tool wrapper text out of +every name-evidence path. (3) The naming store gets four ranking fixes: +provider-native titles that actually change may replace an older automatic +provider-level name, automation renames report applied/not-applied, the +first-message fallback is offered on every newly observed first message, and +the self-heal rule: an unprotected record whose current name the classifier +recognizes as default/derived ranks below `directory` for every automatic +offer, so the next real name replaces it. (4) A configured Gemini key always +wins and is used directly; only with no key does AI naming fall back to the +inherited OneCLI proxy; credential and quota failures pause naming without +using up attempts or losing the naming input. (5) On the client, a +session-owned tab's stored title becomes a display mirror of the saved name, +written only when a saved record is known; the managed-runtime merge never +writes names; the new-window rebuild carries pane titles and user flags. +(6) On the server, every terminal row binds its name through one helper that +both doors (WebSocket and REST) can call, and a conversation switch moves the +row's binding to the new session; the name store serves reads without the +lock; every accepted change emits one structured event. + +**Tech Stack:** TypeScript client (React 18, Redux Toolkit, Vitest jsdom), +Rust server crates (`freshell-server`, `freshell-ws`, `freshell-terminal`, +`freshell-sessions`, `freshell-protocol`, `freshell-freshagent`, +`freshell-supervisor`, `freshell-codex`), Playwright e2e with the owned +`RustServer` fixture. + +## Global Constraints + +- **Worktree:** `/home/dan/code/freshell/.worktrees/name-reliability`, branch + `fix/name-reliability` from `origin/main` (1c7e4dff7 at revision time; the + plan's line numbers were read at 302cee989 and re-checked: #851 between them + touched only `crates/freshell-codex`, its tests and one codex e2e spec, so + every naming line number below holds; `git fetch origin` and re-check + before creating the worktree). Before creating the worktree run the + green-base check through `GCLOUD_ROBOT_REQUIRE=1 scripts/base-gate.sh test`. +- **Current baseline (2026-10-24):** the resumed run is rebased onto + `b5f86817ce3a9a1907e90eda8924927a55e669d6`. All five previously failing + OpenCode cases passed on that exact clean main checkout, across + `test/integration/server/managed-provider-parity.test.ts` and + `test/integration/server/fresh-agent-parity.test.ts`. The dependent wrapper + was not rerun, so its former failure qualification is not renewed either. + The historical managed-runtime waiver from 2026-10-22 is retired for this + run: use the ordinary complete gate without the temporary companion + adapter and require zero test failures. This focused baseline proof does + not claim that the entire main suite passed. Receipt: + `.worktrees/.the-usual-logs/name-reliability/reports/main-baseline-20261024/report.md` + (relative to the main checkout). +- **Focused client tests:** + `pnpm run test:vitest run --config config/vitest/vitest.config.ts`. + Never raw `pnpm exec vitest`/`npx vitest`. +- **Focused Rust tests:** for `freshell-server`, use the coordinator's + delegated narrowed lane `pnpm run test:server ` (it runs + `cargo test -p freshell-server --locked `; see + `scripts/testing/coordinator-command-matrix.ts:177-180`). The coordinator + has no narrowed lane for other crates, so those use + `~/.local/bin/mise exec rust@1.96 -- cargo test -p --locked `. + Cargo accepts ONE name filter per run: two filters mean two commands. + Broad Rust lane: `pnpm run test:server` (coordinated). +- **E2e:** `GCLOUD_ROBOT_REQUIRE=1 FRESHELL_E2E_BACKEND=cloud pnpm run test:e2e --project=chromium ` + (no `--` separator). Specs that need the host Docker supervisor are added to + `LOCAL_ONLY_SPECS` in `test/e2e-browser/playwright.config.ts`; every + other new spec must be cloud-legal and must NOT be added to + `CLOUD_SKIP_SPECS`. A spec that matches zero tests is not coverage. +- **Local-only (Docker supervisor) runs (V3).** Every local-only command has + this exact shape, one case at a time: + `env -u FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE -u FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE -u FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE pnpm run test:e2e:local --project=chromium --grep ''` + (three separate `-u` flags: a brace expansion would hand `env` the second + and third names as the command). The shell exports those OneCLI grant + files, and with them a managed OpenCode case can spend real model turns on + Dan's credential. Run `--list` with the same arguments first and confirm + exactly the intended case is selected. Before the first run, make sure + Playwright 1.58.2's `chromium-headless-shell` revision 1208 is installed + (`~/.cache/ms-playwright/chromium_headless_shell-1208`; if it is missing, + install it into a scratch `PLAYWRIGHT_BROWSERS_PATH` with + `pnpm exec playwright install chromium-headless-shell` rather than changing + the shared cache). Managed-runtime rigs (these specs and the + `test/integration/server` managed parity files) run alone, never in + parallel with another managed rig: run alone they passed, run together + they timed out (V3; the cause is inferred, Residual 22). Never run `runtime-terminal-continuity-rust.spec.ts` for + this plan: its P2-G01 is red on `main` (Residuals, out-of-scope finding) + and its P2-G04 makes paid model calls. +- **Cloud env (non-login shells):** export `FRESHELL_VITEST_BACKEND=cloud`, + `FRESHELL_E2E_BACKEND=cloud`, `GCLOUD_ROBOT_REQUIRE=1` when unset. +- **Full gate:** `FRESHELL_TEST_SUMMARY="name-reliability task N gate" GCLOUD_ROBOT_REQUIRE=1 pnpm run check` + (typecheck then coordinated suite). Check `pnpm run test:status` first; + never kill a foreign holder. +- **Lint/format:** `pnpm run lint` (jsx-a11y), `pnpm run typecheck`, + `~/.local/bin/mise exec rust@1.96 -- cargo fmt --all --check`, + `~/.local/bin/mise exec rust@1.96 -- cargo clippy --workspace --exclude freshell-tauri --all-targets -- -D warnings`. + The native Windows `freshell-server.exe` build must keep compiling: any + platform-specific code is behind `#[cfg(unix)]`/`#[cfg(windows)]` and uses + only stable std APIs (Task 11). +- **Destructive tests:** none of the planned tests kill processes outside an + owned fixture or corrupt host config. Server restarts happen only on owned + `RustServer` instances on ephemeral ports (the existing restart-spec + pattern). If an implementer finds a test that must kill an unowned process + or storm restarts, it moves into `scripts/sandbox-test.sh ""`. +- **1000-line rule:** these files are over the cap and get wiring-only edits; + new logic goes in the new modules named in each task: + `crates/freshell-server/src/session_names.rs` (4104), + `crates/freshell-server/src/main.rs` (7812), + `crates/freshell-server/src/ai_title.rs` (1191), + `crates/freshell-server/src/auto_title_sweep.rs` (2439), + `crates/freshell-server/src/recovery_inventory.rs` (1772), + `crates/freshell-server/src/managed_runtime.rs` (1814), + `crates/freshell-server/src/fresh_agent_proxy.rs` (1431), + `crates/freshell-server/src/terminals.rs` (1957), + `crates/freshell-server/src/session_name_migration.rs` (1006), + `crates/freshell-ws/src/terminal.rs` (15408), + `crates/freshell-ws/src/identity.rs` (1619), + `crates/freshell-terminal/src/registry.rs` (15751), + `crates/freshell-freshagent/src/naming.rs` (1226), + `crates/freshell-freshagent/src/terminal_tabs.rs`, + `crates/freshell-freshagent/src/lib.rs`, + `crates/freshell-codex/src/remote_proxy.rs`, + `src/App.tsx` (2571), `src/store/tabsSlice.ts` (1944), + `src/store/panesSlice.ts` (3050), `src/components/TerminalView.tsx` (7806). +- **Logging:** every new decision path logs structured JSONL with severity + through `tracing` (server) or `createLogger` (client; errors forward to + `/api/logs/client`). Never log credentials, tokens, proxy URLs or OneCLI + manage URLs. Prompt/turn content is not logged, with one deliberate + exception: `session_names.name_changed` logs the saved display names, + at most 200 characters each (a `first_message` name is cut to 50), through + the JSONL writer's secret scrubber. Other failure diagnostics use error + codes and statuses. The JSONL writer's + scrubber recognizes only the old `AIza…` Gemini key form, and Dan's key is + the newer `AQ.` form, so Task 6 extends it (G48). +- **Rename contract:** user-intent renames always win; nothing automatic ever + replaces `manual`, `legacy_protected` or `freshell_ai`. Shell/browser/editor + names stay in the legacy four-scope model. +- **Commits:** one focused commit per task (conventional message, ends with + the attribution line from the session reminder). Push the branch at major + checkpoints. The single PR is opened only in Task 13, after the code review + and the gate (Dan approved creating and merging it then). +- **Load-bearing validation:** round 1 validated five assumptions (V1–V5, + section near the end) and every result is applied in the tasks below. + Steps that rest on one cite it; the task receipt records that the + implementation matches. + +## Verified ground truth (origin/main; lines at 302cee989, unchanged at 1c7e4dff7) + +Each fact below was re-read in the tree while writing or revising this plan. +Corrections to the investigation report are marked **(corrected)**. + +| # | Fact | Location | +|---|---|---| +| G1 | `isLegacyNameCaptureSourceKey` accepts the live per-window key `freshell.layout.v3.` and its `.bak` / fresh-agent-backup channels | `src/lib/session-name-migration.ts:111-136` | +| G2 | Capture runs at module import, before the store | `src/lib/session-name-migration.ts:889-893`, imported first by `src/main.tsx:13` | +| G3 | Capture also runs in machine-workspace restore and on every sibling-window layout write | `src/lib/machine-workspace.ts:147-155`; `src/store/crossTabSync.ts:443-453` (`captureLegacyLayoutEnvelope`, `src/lib/session-name-migration.ts:299-326`) | +| G4 | Submission fires on every connection-ready edge; nothing checks for a finished migration | `src/store/store.ts:162-196`; `submitPendingLegacyNameImports` `src/lib/session-name-migration.ts:771-821` | +| G5 | Tab titles of session-owned tabs become `source_tab` candidates ranked `provider_ai`; pane titles likewise; a legacy tab whose `titleSetByUser` is true and whose first leaf is a scoped pane becomes `legacy_protected` **(new finding)** | `extractEnvelopeCandidates` `src/lib/session-name-migration.ts:482-630` (tab at 568-593, panes at 596-627) | +| G6 | Acknowledgment only adds an `.ack.` marker; envelopes are never deleted | `markAcknowledged` `src/lib/session-name-migration.ts:169-178` | +| G7 | A failing capture defers the whole layout flush | `src/store/persistMiddleware.ts:658-671` via `ensureLegacyNameCapturesForFlush` `src/lib/session-name-migration.ts:346-352` | +| G8 | The import route never checks `migration.completed`; every import writes a create-once backup file keyed by import id BEFORE acknowledging, and every newly seen candidate id is pushed into `acknowledged_candidate_ids`, which forces a document write | route `crates/freshell-server/src/session_name_routes.rs:317-390`; decision `crates/freshell-server/src/session_names.rs:4003-4104` (backup `:4029`, ack push `:4053-4057`, write forced `:4094-4103`); backup writer `:3734-3770` | +| G9 | Rank order `directory 0 < first_message 1 < provider_ai 2 < freshell_ai 3 < legacy_protected 4 < manual 5`; untrusted manual/protected claims without evidence degrade to `provider_ai` | `source_rank` `session_names.rs:2034-2043`; `classify_legacy_candidate` `:3623-3673` | +| G10 | Equal-rank ties among migration-owned records end on evidence key (contains the window id) then candidate id | `compare_classified_candidates` `session_names.rs:3682-3712`; applied at `:3836-3851` | +| G11 | A missing record is installed by any import; pending targets (including derived `legacy-pending` handles) are installed too, which mints orphan records | `apply_legacy_candidate` `session_names.rs:3815-3881`; handle derivation `src/lib/session-name-migration.ts:461-475` | +| G12 | Bind collision: equal automatic ranks favor the durable record, so an import that landed on the session key first keeps its label | `bind_pending_decision` `session_names.rs:2489-2625` (collision at 2537-2553) | +| G13 | Automatic offers only strictly raise rank; automation renames map to `provider_ai` and return the unchanged record as success | `offer_mut` `session_names.rs:2147-2204`; `rename_decision` `:2427-2464` | +| G14 | Native observations offer at `provider_ai` with no placeholder filter; the only placeholder predicate is used by the directory index; every call site discards the result (`let _ =`) and awaits it INLINE in an event relay loop | `observe_native_decision` `session_names.rs:2802-2930`; `is_opencode_placeholder_title` `crates/freshell-sessions/src/parse/opencode.rs:35`; OpenCode event `crates/freshell-opencode/src/events.rs:198-210`; inline awaits: freshopencode SSE bridge `crates/freshell-freshagent/src/opencode_ws.rs:7787-7805`, codex proxy route `crates/freshell-ws/src/codex_proxy_route.rs:112-131`; other lanes `crates/freshell-server/src/main.rs:1955-1985`, `crates/freshell-freshagent/src/codex.rs:7622` | +| G15 | First-message fallback is offered only for `AcceptedUserMessage`/`IndexUserMessage`; the sweep sends `Opened` whenever a live terminal is bound | `plan_activity` `session_names.rs:2651-2727` (gate at 2668-2681); sweep `crates/freshell-server/src/auto_title_sweep.rs:470-523` | +| G16 | The Gemini route is `OneCliProxy` whenever the effective proxy URL carries a OneCLI marker; `enabled()` is then true without any key; on that route the first request carries NO key even when one is configured (`:332-334`), so with Dan's Settings key and the inherited proxy, naming still fails on `access_restricted` (confirmed live, G49); a 403 drops the body; only `credential_not_found`/`app_not_connected` permit a direct-key retry (through the same proxy-honoring client); 407 never does. Both naming and summary clients are `reqwest::Client::new()`, which honors the ambient proxy | `crates/freshell-server/src/ai_title.rs:256-288`, `:325-391` (error at 385), `:437-450`; clients `main.rs:974`, `:985` | +| G17 | Each failure consumes one of `MAX_GENERATION_STARTS = 3`. The CLAIM (not the fold) exhausts the series at the cap and clears the excerpt; the fold only sees `(series_id, fingerprint, outcome)`; the failure reason is never persisted; boot recovery of an interrupted start already exhausts and clears at the cap | `session_names.rs:100`; claim `:3030-3061` (exhaust + `excerpt = None` at 3038-3043); fold `:3063-3152`; recovery `recover_interrupted_generation_decision` `:3154-3185`; generator `crates/freshell-server/src/session_name_generation.rs:233-351` | +| G18 | **Open question 1 answered:** the server's Gemini key is env `GOOGLE_GENERATIVE_AI_API_KEY` (wins at boot) else `settings.ai.geminiApiKey` in `/config.json`; every successful Settings save force-applies the Settings key into the same key cell. The live config copy (taken before 2026-10-22) had `settings.ai = {}`; **Dan has since entered a key in Settings** (whether the live process also has an env key is part of V1). The probe answered `{"error":"access_restricted",…}` from the OneCLI gateway. The proxy (and so the OneCLI agent identity) is whatever `HTTPS_PROXY` the launching shell exported; the server's `.env` cannot override it because environment variables win over `.env` | `crates/freshell-server/src/main.rs:953-964`; `crates/freshell-server/src/settings_store.rs:2139-2143`; `scratchpad/live-evidence/config.json`; `scratchpad/live-evidence/gemini-probe.json`; `.env` precedence per `AGENTS.md` | +| G19 | The managed merge writes `title: view.title` for existing tabs unless `titleSetByUser`, takes tab-level fields from whichever pane matched, and bumps `updatedAt` | `buildManagedRuntimeMergePlan` `src/lib/recovery/managed-runtime-recovery.ts:291-372` (title 323, reconstructed title 359); `updateTab` `src/store/tabsSlice.ts:404-410` | +| G20 | View-intent titles are fixed provider labels | `crates/freshell-server/src/managed_runtime.rs:776`; `crates/freshell-server/src/fresh_agent_proxy.rs:723`; fallback "Recovered X agent" `crates/freshell-supervisor/src/view_intents.rs:103-106`, `:683-689` | +| G21 | **Open question 6 answered (code):** "Shell agent" IS visible on a single-pane shell tab: `getTabDisplayTitle` shows a stored non-default `tab.title` that differs from the derived name whenever no non-derived pane title exists | `src/lib/tab-title.ts:19-38`. Task 8's first test pins this. | +| G22 | Nothing writes the saved name back into a session-owned tab's `title`; the display falls back to it when no record is cached | `src/App.tsx:1472-1477`; `selectTabDisplayName` `src/store/selectors/sessionNameSelectors.ts:217-230`; the comment in `stripSessionOwnedTabFreezeFlags` `src/lib/session-name-migration.ts:870-887` is false today | +| G23 | Legacy tab-title writers: OSC title `src/components/TerminalView.tsx:3189-3192` (**corrected: no session-owned gate here**, unlike `:6160-6176`), exit suffix `:6136-6143`, `openSessionTab` existing-tab retitles `src/store/tabsSlice.ts:1821`, `:1871`, create defaults `:358`, `:1836`, `:1912`, `:1930`, `src/store/codingAgentNaming.ts:36-42`, managed merge (G19), machine restore `src/lib/machine-workspace.ts:197-205`, UI command `tab.create` (`src/lib/ui-commands.ts:181-196`, sets `titleSetByUser:true` for any caller name, including on an agent tab). UI command `tab.rename` (`:222-226`) is already harmless for session-owned tabs: `consumeScopedRenameReceipt` (`:38-62`) returns before `applyTabRename` whenever the tab has a naming source | +| G24 | The new-window rebuild drops pane titles and user flags: plan hard-codes `paneTitles: {}`; inventory forwards only `tabKey/tabName/nameSource/panes`; the registry pane snapshot carries `title` but **no pane user flag (corrected)** | `src/lib/recovery/build-recovery-plan.ts:430-437`; `crates/freshell-server/src/recovery_inventory.rs:1010-1033`; `src/lib/tab-registry-snapshot.ts:89-113`; `shared/tab-registry-types.ts:54-59`; `restoreMachineWorkspace` `src/lib/machine-workspace.ts:197-213` | +| G25 | `register_managed` titles every managed row "Shell" with no naming; adoption paths never bind naming. **Production** callers **(corrected)**: WS managed create `crates/freshell-ws/src/terminal.rs:6876`, REST managed create `crates/freshell-freshagent/src/terminal_tabs.rs:2906`, compat adopt `terminal.rs:3459`, reconcile adopt `terminal.rs:8481`. The calls at `terminal.rs:3837, 3943, 3994, 4076` are inside `mod managed_runtime_id_tests` (`:3590-4090`) and `:13408+` inside the test module starting at `:10894` | `crates/freshell-terminal/src/registry.rs:4719-4776` (title 4752); `adopt_managed_for_reconcile` `:4646-4676` | +| G26 | `admit_create_naming` turns a read error into "no record" (`.ok()` at `:4798`), so "read failed" and "no record" arrive as the same `None`; a lock error on a resume create that carries a persisted `namingHandle` falls into the fresh-pending path (`:4799-4828`); a failed `ensure_pending` returns `None` (`.ok()?` at `:4855`), leaving the row with NO naming binding. REST create does the same | `crates/freshell-ws/src/terminal.rs:4751-4870`; REST CLI naming admission `crates/freshell-freshagent/src/terminal_tabs.rs:3457-3545` | +| G27 | Conversation switch retargets only the identity table (a durable `Session` ref moves to the new session; pending refs never move); respawn titles " (auto-resumed)" without naming | `crates/freshell-ws/src/identity.rs:314-330`, `bind_pending_naming` `:613-655`; `respawn_agent_terminal` `crates/freshell-ws/src/terminal.rs:7680` (title `:8099-8107`) | +| G28 | Every name read takes the exclusive flock and re-parses the whole document; the read route runs `refresh_current` first; the 2 s tick runs `refresh_current` | `run_txn_sync` `session_names.rs:1395-1484`; `get` `:1164-1180`; `refresh_current` `:655-668`; lock `:94-96`, `:1706-1730`; route `session_name_routes.rs:233-248`; tick `main.rs:1352-1398` | +| G29 | **Corrected bloat breakdown** (current copy, 5,050 records): exhausted series already carry no excerpt; the excerpt bytes are Idle (boot-absorbed, never attempted) series, ~1.93 MB, and they are still needed by the explicit-open arming path; acknowledged candidate ids 0.38 MB; winning evidence 0.18 MB | `scratchpad/live-evidence/session-names.json`; arming dependency `bind_pending_decision` `session_names.rs:2603-2617` | +| G30 | Import-owned records (winning evidence still at its installed revision, unprotected) on the live copy: 188 `first_message`, 1 `directory`, 114 `provider_ai`. Of the `provider_ai` ones, 29 are default labels recognizable without a folder ("Tab N", "OpenCode agent"-style; 27 came from per-window layout keys), and 85 are other text — among them folder names ("machines", "shapiroserver2", "podcast") and labels that look typed by a person ("ms-people-summit", "DirectorDeck"). None has a generation excerpt, so first messages must come from the session index | same copy (count script: `/tmp/claude-1000/-home-dan-code-freshell/b0e858cc-c789-4cc8-aba4-46c81f21d8b8/scratchpad/plan/revision-1-notes.md`) | +| G31 | Log spam: 15,145 of 15,833 lines are `codex_proxy_repair_trigger` WARNs, all the generic `ProxyError { "Codex remote proxy client/upstream connection errored." }`, ~580 per minute spread across 6 terminals (~1.6/s each: a reconnect loop, not one burst). #851 (merged after the live build) added a per-proxy `EmissionGate` and teardown-pair suppression only for CLASSIFIED capacity errors; the generic errored trigger is still emitted unthrottled at `crates/freshell-codex/src/remote_proxy.rs:1118-1130` | `crates/freshell-ws/src/codex_proxy_route.rs:92-95`; `scratchpad/live-evidence/rust-server.jsonl`; `EmissionGate` `remote_proxy.rs:826-860` | +| G32 | Existing e2e helpers to reuse: `bootUnifiedNamesServer`, `startFakeGemini`, `startFakeOneCliGeminiProxy`, `modeFixture`, `sendFirstMessage`, `readRecord`; the Claude terminal fake is `test/e2e-browser/fixtures/providers/fake-claude.mjs` (wired as `CLAUDE_CMD` at `helpers/unified-agent-names.ts:236`) | `test/e2e-browser/helpers/unified-agent-names.ts`, `test/e2e-browser/helpers/unified-agent-names-modes.ts`; server log via `RustServer.capturedOutput()` `test/e2e-browser/helpers/rust-server.ts:571` | +| G33 | `selectScopedTabDisplayTitles` calls `selectPaneDisplayName`, which returns a FALLBACK label (`selectPaneFallbackTitle`) when no saved record is cached; the tabs registry already overlays these values onto session-owned tab records | `src/store/selectors/sessionNameSelectors.ts:165-177` (fallback at 176), `:317-338`; `selectLiveLocalTabRecords` `src/store/selectors/tabsRegistrySelectors.ts:163-186` | +| G34 | The client name cache is fed by `/api/session-names/read` bootstrap AND by record projections riding on other frames and responses (terminal/session-directory projections, rename responses) | `src/App.tsx:1253`, `:1465`, `:1475`, `:1488`, `:1715`; `src/store/sessionNamesSlice.ts:179-222` | +| G35 | Crate edge: `freshell-ws` depends on `freshell-freshagent` (`crates/freshell-ws/Cargo.toml:55`), never the reverse; `freshell-freshagent` depends on `freshell-terminal` and already bridges outward through traits (`identity_sink.rs` `PaneIdentitySink`) | `crates/freshell-freshagent/Cargo.toml:54`; `crates/freshell-freshagent/src/identity_sink.rs:1-5, 343` | +| G36 | No folder on the server side of imports and renames: `LegacyNameCandidate` and `RenameNameInput` carry no cwd, and the name document stores none. Folders are known only to `PendingNameInput.cwd` (at create), `IndexedNameInput.cwd`/`NameActivity.cwd` (session index), and the terminal row | `crates/freshell-protocol/src/session_names.rs:395-405`; `crates/freshell-freshagent/src/naming.rs:78-96`; `crates/freshell-server/src/session_name_generation.rs:116-122` | +| G37 | The sweep fetches an OpenCode first message only while `needs_generation_input` is true, which is false once a series carries a fingerprint or is exhausted — so an OpenCode session stuck on a folder name never gets the first-message fallback | `crates/freshell-server/src/auto_title_sweep.rs:441-465`; `session_names.rs:850-873` | +| G38 | `hydrate_indexed_decision` returns early whenever any record exists; the sweep pre-check `hydrate_indexed_pending` mirrors that; the `activity_pending` pre-check's comment relies on "a record's source rank never decreases" | `session_names.rs:3200-3212`, `:933-940`, `:951-960` | +| G39 | **RC12 (new):** 171 saved names on the live copy are tool wrapper text taken as the "first message": 153 ``, 9 ``, 4 ``, 5 `…` (172 on the later V2 copy; 161 of them have an index row, 11 do not). The three Codex tags are Codex's own model-context fragments; in real Claude transcripts the caveat record is `isMeta` and is followed by ``, `` and sometimes `` records before the real prompt (V5). The first-message extraction goes through `extract_user_authored_text`, whose system-context tag list (`USER_CONTEXT_TAGS`/`SYSTEM_CONTEXT_LEAD_TAGS`) does not list these tags | `crates/freshell-sessions/src/text.rs:110-140`, `:311-335`; fallback `session_names.rs:2103-2110` | +| G40 | Rename convenience routes nest the update differently: the freshagent pane/tab routes return `data.sessionName` (the full `SessionNameUpdate`) | `crates/freshell-freshagent/src/lib.rs:6064-6079` | +| G41 | Which naming inputs carry a folder: `NameActivity.cwd` (today unused by `activity_decision`, bound as `cwd: _cwd`) and `IndexedNameInput.cwd` do; `NativeNameObservation` does not. The activity pre-check runs the SAME `plan_activity` as the transaction; the hydrate pre-check is a bare "no record" test; the sweep's settled memo lasts one pass only, and every non-boot pass feeds `IndexUserMessage` for every indexed session with a first message | `crates/freshell-freshagent/src/naming.rs:117-124`, `:141-147`; `crates/freshell-server/src/session_name_generation.rs:116`; `session_names.rs:933-940`, `:963-976`, `:2729-2741`; `crates/freshell-server/src/auto_title_sweep.rs:432-436`, `:495-535` | +| G42 | The index keeps a persistent parse cache (`rust-session-cache.json`) keyed by file `(mtime, size)` that stores `first_user_message`; an unchanged file is never re-parsed; `CACHE_SCHEMA_VERSION = 3` gates whole-cache invalidation, and `load_cache_file` already drops individual stale entries with a targeted filter (`has_legacy_claude_identity`). So a tag-list change alone does not re-derive cached first messages (V2: 164 live rows start with a new tag, 161 of them in files that will not change by themselves; a schema bump would re-parse all 17.6 GB of transcripts at boot) | `crates/freshell-sessions/src/directory_index.rs:1128`, `:2448-2450`, `:2688`, `:2738-2779` | +| G43 | Tests that pin "the inherited OneCLI proxy wins over a configured key": `session_name_onecli_route_omits_direct_key`, `onecli_missing_credential_retries_with_direct_key` and neighbors; the worker test `onecli_missing_credential_worker_fallback_saves_ai_name`; the e2e OneCLI journey, whose `bootJourney` sets `GOOGLE_GENERATIVE_AI_API_KEY` to a sentinel or a key next to the proxy and asserts `hasApiKey:false` | `crates/freshell-server/src/ai_title.rs:1034-1200`; `crates/freshell-server/src/session_name_generation_tests.rs:718`; `test/e2e-browser/helpers/unified-agent-names-modes.ts:75-83`, `:251-287` (used by `unified-agent-names-freshcodex.spec.ts:24`) | +| G44 | The current client rewrites the per-window sidecar `freshell.layout.pre-migration-raw.v1.` during its own storage migration, so it is not genuine pre-migration evidence (on the live copy it produced 5 winning "OpenCode agent" records); the bare `freshell.layout.pre-migration-raw.v1` key is not written by the current client | `src/store/storage-migration.ts:318-331`; `src/store/window-layout-keys.ts:53`, `:75` | +| G45 | Contract: "switching conversations adopts the new session's name". The identity upsert moves a durable `Session` ref onto the new session but never touches the registry's naming binding, so the row keeps showing the old session's name | `docs/plans/2026-09-16-unified-agent-names.md:19`; `crates/freshell-ws/src/identity.rs:314-330`; registry binding set only in `bind_pending_naming` `:640-646` | +| G46 | `observe_native_live` stamps each observation with the store's CURRENT verified location revision at call time, so a retry that calls it again gets a new stamp | `crates/freshell-freshagent/src/naming.rs:608-639` | +| G47 | A Rust-only, env-read-once test seam for e2e already exists (`FRESHELL_GEMINI_BASE_URL`) | `crates/freshell-server/src/main.rs:965-972` | +| G48 | The JSONL writer's scrubber redacts the exact `AUTH_TOKEN` plus a common-secret regex whose Gemini arm is `AIza[0-9A-Za-z_-]{20,}` only; Dan's Settings key is 53 characters in the `AQ.` form (V1), which it would not redact | `crates/freshell-runtime-observability/src/lib.rs:61-69`, `:92-125`; tests `crates/freshell-server/src/logging.rs:495-535` | +| G49 | **V1 (live, read-only GETs):** with Dan's key, Gemini answers 200 both directly and through the inherited OneCLI gateway (identical bodies; the gateway decrypts TLS with its own CA, which the live process trusts through `SSL_CERT_FILE`). Through the gateway: no key → 403 `access_restricted`; a bad key → **400** with the same OneCLI `access_restricted` body (Google's `API_KEY_INVALID` is hidden); a valid key and an unknown model → 404 with Google's own body. Without `SSL_CERT_FILE` the proxied request fails `is_connect()` with an `UnknownIssuer` certificate error in its source chain. The live process has no `GOOGLE_GENERATIVE_AI_API_KEY`; since its start the live log shows 25 `generation_failed: gemini http 403` and 0 successes | `scratchpad/lb-validate/report-0.md`; `scratchpad/lb-validate/V1/` | +| G50 | The activity apply re-checks the strict raise: `activity_decision` applies `plan.offer_fallback` through `offer_mut`, which accepts an automatic offer only when `source_rank(source) > source_rank(record.source)`; so a pre-check that plans a self-heal would be refused at apply time, every pass. The bind's explicit-open hook flips an Idle, never-attempted series to Eligible with whatever excerpt it stored | `crates/freshell-server/src/session_names.rs:2756-2765`, `:2183`; bind hook `:2600-2614` | +| G51 | Reconstructed managed panes: `managedTerminalContent` sets a `sessionRef` only when the soul has `nativeSessionId`, and never sets `nameRef`/`namingHandle`; the inventory has no naming handle. Managed runtime covers shell and OpenCode only; OpenCode learns its session id later, and `terminal.session.associated` writes that `sessionRef` into every pane with the matching `terminalId`, reconstructed ones included. A tab added with no title stores `Tab N`, which `getTabDisplayTitle` treats as a default | `src/lib/recovery/managed-runtime-recovery.ts:119-145`; `crates/freshell-agent-runtime/src/lib.rs:83-175`; `crates/freshell-ws/src/opencode_association.rs:314`; `src/App.tsx:1608-1625`; `src/lib/terminal-session-association.ts:162`; `src/store/tabsSlice.ts:358`; `src/lib/tab-title.ts:19-38` | +| G52 | `extract_user_authored_text` also feeds Claude's `user_message_count`, which drives `is_non_interactive` (at most 1 counted message) and the sidebar's default hide-non-interactive filter; V5 found 1 of 32 Claude sessions on this host would flip once the command tags are skipped | `crates/freshell-sessions/src/parse/claude.rs:353-358`, `:532`; `src/store/selectors/sidebarSelectors.ts:729-732` | +| G53 | **V3:** the host's Docker supervisor lane works when a managed case runs alone (a managed OpenCode parity case passed alone; the baseline's 7 failures ran several managed rigs at once). `runtime-terminal-continuity-rust.spec.ts` is `test.describe.serial`; its P2-G01 fails on `main` (after a graceful web restart the managed shell's `terminal.attach` gets `INVALID_TERMINAL_ID` and the client starts a new terminal), so later cases are skipped; its P2-G04 needs Dan's OneCLI grant files and makes paid model calls. Playwright 1.58.2 needs `chromium_headless_shell-1208` | `test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts:164-165`, `:323-329`; `test/e2e-browser/playwright.config.ts:24-36`; `src/components/TerminalView.tsx:6695-6710`; `scratchpad/lb-validate/report-3.md`, `scratchpad/lb-validate/v3/` | +| G54 | Rust seams for a restart-level managed naming test: `TerminalRegistry::set_managed_controller`; the in-crate lookup-only fake controller pattern; the reconcile adopt path; the integration tests' `NamingProbeSink`; the in-process WebSocket harness of `pane_reconcile.rs` | `crates/freshell-terminal/src/registry.rs:4608`, `:4646-4676`; `crates/freshell-ws/src/terminal.rs:3635-3660`, `:8460-8490`; `crates/freshell-ws/tests/common/mod.rs:385`; `crates/freshell-ws/tests/pane_reconcile.rs:102-118` | + +## Decisions and exact cross-task interfaces + +### Name evidence (Task 1) +- `shared/name-evidence.ts`: + `isDefaultOrDerivedName(name: string, ctx?: { cwd?: string; provider?: string; mode?: string }): boolean` + and `isProviderPlaceholderTitle(provider: string, title: string): boolean`. +- `crates/freshell-sessions/src/name_evidence.rs` with the same two functions + (`is_default_or_derived_name(name, &NameEvidenceContext)`, + `is_provider_placeholder_title(provider, title)`), re-exported from the crate + root. It calls the existing `is_opencode_placeholder_title` + (`parse/opencode.rs:35`). +- A name is default/derived when, after trimming: it matches `^Tab \d+$`; it + equals the basename of `ctx.cwd` (only when a cwd is supplied); it is a + provider or mode label (`Claude`, `Claude CLI`, `Codex`, `Codex CLI`, + `OpenCode`, `Shell`, `Freshclaude`, `Freshcodex`, `Freshopencode`, + `Kilroy`, `Gemini`, `Kimi`, `Amplifier`, with optional `Recovered ` prefix + and/or ` agent` suffix, case-insensitive); it is `Recovered`, `Untitled` or + `Tab`; it is a provider placeholder (OpenCode `New session - `, + `Child session - `, `New Session - `); or it begins with a tool + wrapper tag (`` or `` or its + channels; never the per-window sidecar + `freshell.layout.pre-migration-raw.v1.`, which the current client + rewrites during its own storage migration (G44); never from `crossTabSync` + or machine-workspace restore. +- **Server, once `migration.completed` is true, for every non-boot import: + acknowledge every candidate id and write nothing.** No record is created or + changed, no candidate id is stored, no backup file is written, and the + document generation does not move. One INFO + `session_names.import_ignored_after_completion {import_id, candidates}` per + import id per process (an in-memory set bounds it). D1 permits this ("may *at + most* create"); the report's RC1 fix 1.b recommends it. +- **Why not create at the lowest rank (the earlier draft).** A created + `directory` record keeps nothing: the next sweep pass replaces it (the + first-message fallback, or the self-heal hydrate re-offer for a folder + name), and creating costs a 4.5 MB document write per import. Old browser + tabs that stay open after the deploy keep sending imports until reloaded; + acknowledge-only makes those sends free. +- Bind collision: an import-owned record (winning evidence still at its + installed revision) whose source is `directory`, `first_message` or + `provider_ai` loses to the pending record being bound ONLY when its name is + default/derived (`is_default_or_derived_name`, `cwd: None`: bind inputs + carry no folder, G36); the loser goes to recovery as today. Every other + import-owned record (a real-looking label such as "ms-people-summit", a + real first message) and every protected one keeps today's rank rule. +- Tie-break (pre-completion boot path only): `compare_classified_candidates` + stops at scope; equal candidates compare `Equal`, and + `apply_legacy_candidate` replaces only on a strict win, so the incumbent + stays. `order_boot_legacy_candidates` keeps a candidate-id sort only to keep + chunk membership stable (it decides batching, not the winner). + +### Ranking and self-heal (Tasks 4–5) +| Source of the offer | Applies when the current record is | Result when refused | +|---|---|---| +| User rename (`nameIntent:"user"`) | anything | — (unchanged rule) | +| Freshell AI answer | below `freshell_ai` (every unprotected record, so the self-heal rule changes nothing here) | (unchanged rule) | +| Automation rename (omitted/`automatic` intent), recorded at `provider_ai` | `directory`, `first_message`, `provider_ai` (equal rank allowed) and text differs and is not a default or derived label, classified with the best-known folder for the target (terminal row cwd for pane/tab/terminal routes, session index row cwd for session-key routes; LB-B8), so a rename to the session's own folder name is refused, never applied and then healed | `outcome:{applied:false, reason:"protected_name"|"default_label"}`; same text → `{applied:true, reason:"unchanged"}` with `changed:false` | +| Provider-native observation (current location; not a placeholder or other default/derived label, folder-free patterns) | title differs from `last_native_title` (C4) AND the record is below `provider_ai` (raise), a self-heal record, or `provider_ai` when a baseline exists (`last_native_title` set) and the observed title differs from both the current name and that baseline. The FIRST observation of a record (`last_native_title == None`) only records the baseline at equal rank | provenance only (the baseline is still recorded) | +| First-message fallback (wrapper and other default/derived text excluded, RC12) | below `first_message`, or a self-heal record, for ANY activity reason that carries a first user message | no-op | +| Hydrate from the session index (sweep) | no record (today's insert); or a self-heal record (new; which rung may apply is below) | no-op | +| Post-migration browser import | never applies | acknowledged, nothing written | + +**Self-heal rule (new; replaces the withdrawn repair tool).** +- A **self-heal record** is an unprotected record (`directory`, + `first_message`, `provider_ai`) whose current name the Task 1 classifier + recognizes: `is_default_or_derived_name(name, ctx)` with `ctx.provider` from + the record's ref and `ctx.cwd` from the offer's own input — `NameActivity.cwd` + (the sweep's index row, or the lane that reported the accepted input + through `report_accepted_input`, `crates/freshell-freshagent/src/naming.rs:422-450`) + and the hydrate input's index-row cwd; `None` (folder-free patterns only) + for native observations, imports and pending→durable binds, whose inputs + carry no folder (G36, G41). +- A self-heal record ranks below `directory` for every automatic offer + (first-message fallback, native observation, hydrate from the index, AI + generation answer). Concretely: + - an offer whose own text is a real name (not default/derived under the + same context) replaces it, whatever the offer's rank; + - a default/derived offer never replaces a name, with one exception: + hydrate's last rung (`directory_fallback_name`: the session's folder + name, else the provider label) may replace a self-heal record whose source + is above `directory`. After that the record is an ordinary `directory` + record, so two default labels can never alternate; + - the same text is always a no-op (no revision, no publish). +- `manual`, `legacy_protected` and `freshell_ai` records are never self-heal + records. Automation renames are not affected by the rule (their own row + above already allows equal rank). +- One pure predicate `self_heal_offer_applies(record, offer_name, + offer_source, ctx) -> bool` in `crates/freshell-server/src/session_name_policy.rs` + decides it. V2 confirmed the rule works with the sweep pre-checks only + under five conditions, each with its own Task 4/5 test: + - **C1 — apply what was planned.** `activity_pending` already runs + `plan_activity` (G41); `plan_activity` gains the activity's `cwd` and + routes its fallback rank check through the predicate, AND + `activity_decision` applies `plan.offer_fallback` as decided instead of + re-offering it through `offer_mut`'s strict raise (G50). Otherwise the + pre-check says "pending" while the apply refuses, the name never heals, + and every 5 s pass runs a locked full-document transaction per record. + - **C2 — one hydrate plan.** `hydrate_indexed_pending` + (`session_names.rs:933-940`, today a bare "no record" test) and the early + return in `hydrate_indexed_decision` (`:3200-3212`) both call one new pure + `plan_hydrate(document, &input) -> Option<(String, NameSource)>`: no + record → today's ladder (insert, absorb unchanged); a self-heal record → + the ladder's rung when the predicate allows it (replace; no series is + created or absorbed on this path); the same text, or anything else → + `None` (Read, and the pre-check reports no work). + - **C3 — stored rank can decrease.** Healing moves `provider_ai` to + `first_message` and `first_message` to `directory`. The two doc + comments that argue from "a record's source rank never decreases" + (`:911-932`, `:950-962`) are rewritten to say so and to state the + remaining safety direction: a stale adopted view only delays work (a + foreign writer — in practice only an older binary — storing a junk name + is seen after the next refresh, the 2 s tick) and never loses it. + - **C4 — native echoes never re-raise.** An observed native title equal + to `last_native_title` is provenance only, also when it would raise + rank. Without this, a native title equal to the folder name (not + recognizable without a folder) could raise over a healed name on every + echo while the sweep healed it back (0 live cases, but the loop is + possible). + - **C5 — the OpenCode first-message lookup sees the folder.** + `needs_first_message_input(target, cwd)` takes the index row's folder, + so a folder-named record counts as needing input, and the sweep's "no + user message" memo persists across passes (keyed by session id + index + `updated_at`), not only within one pass (Task 5). +- Where healing happens: every non-boot sweep pass feeds `IndexUserMessage` + for every indexed session with a first message and re-checks hydrate + (G41); live native observations; accepted user messages. Existing junk + names therefore heal within the first sweep passes after deploy, or when + a session is next seen, with no separate tool. Names that are not + recognizable junk stay (Residuals). V2's estimate for the live data: + about 190 one-time record rewrites in the first passes (141 Codex wrapper + names → real first message, 16 → folder name, 4 Claude wrapper names, 19 + OpenCode records → provider title, 11 `provider_ai` folder/label names → + first message), plus the re-absorb writes for the re-derived Codex + sessions and the native baselines. The wrapper names heal only because + Task 5 invalidates their stale parse-cache entries (G42), and the + re-derived first messages do not start background AI naming (Task 5, + re-absorb at rest). +- **Batched sweep writes (LB-B1).** Every transaction re-reads and rewrites + the whole 4.5 MB document under a lock that waiters poll for at most 1 s + (`session_names.rs:93-96`, `:1703-1727`), so ~190 back-to-back + single-record commits could starve user renames and binds right after + deploy. Instead, the sweep pass collects its maintenance work — self-heal + replacements (activity fallback or hydrate rung), re-absorb-at-rest + updates, and staged native baselines — and applies it in ONE store + transaction per pass covering at most `SWEEP_MAINTENANCE_BATCH = 25` + records (`sweep_maintenance_decision`, re-planning each item on the + freshly read document with the same plan functions, publishing each + changed record). Work beyond 25 waits for the next pass (≥ 5 s later), so + the post-deploy burst is about eight writes spread over a minute, and + renames and binds take the lock between passes. Inserts of new records + and activities that arm generation keep today's per-row transactions. + The pre-checks report which kind of work a row has, so the pass can sort + it without transacting. +- Logging: the Task 12 `name_changed` event carries `self_heal: true` when + an automatic native, first-message, hydrate or bind-collision offer + replaces recognizable junk with a name whose source ranks the same or + lower. A rank increase is `false`, even when it replaces junk; renames, + AI answers, imports and adopted external changes are always `false`. + +**Native observation details.** +- New internal field `NativeWriteState.last_native_title: Option` + (serde default; never on the wire). It is persisted whenever a valid + current-location, non-placeholder title differs from it, so an echo of the + same native title can never undo an automation or user rename, and never + raises rank either (C4). +- **First observation sets the baseline.** Every existing record starts with + `last_native_title == None`, and pre-deploy automation renames + (`provider_ai`) were never written back to the provider (only `manual` and + `freshell_ai` are writable), so the provider still reports its older title. + The first observation therefore records the baseline and replaces at + equal rank only when the record is a self-heal record; a raise from below + `provider_ai` still applies as today. A baseline that changes nothing + else never makes its own commit: the store stages it in memory and the + next sweep maintenance batch (or the next real commit touching that + record) persists it. Losing a staged baseline at a restart only means the + next observation records it again (LB-B1). +- **Native observations never block an event relay (G14).** `observe_native_live` + builds the `NativeNameObservation` once, stamping `location_revision` at + observation time (G46), and makes one attempt inline; on + `LockUnavailable`/`Persistence` it hands THAT observation to a per-sink + background retrier (200 ms, then 1 s) that keeps only the LATEST + observation per target, tagged with a per-target sequence. A retry + re-submits the captured observation unchanged (never re-stamped), so if + the provider location moved meanwhile it folds as stale (provenance only); + a retry applies only if its sequence is still the latest, so an older + title can never land after a newer one. The relay loop returns + immediately. + +**Automation rename outcome.** +- Wire (additive, optional): `SessionNameUpdate.outcome?: { applied: boolean; reason?: 'protected_name' | 'default_label' | 'unchanged' }` + in `crates/freshell-protocol/src/session_names.rs:191-198` and + `shared/session-names.ts:184-190`. Only rename responses set it; broadcasts + never do. Rename convenience routes pass it through unchanged; the CLI and + MCP read it at each route's actual nesting (freshagent routes: + `data.sessionName.outcome`, G40; the implementer records each route's path + in the Task 4 receipt). + +### Generation capability (Task 6) +- **Route by credential (D5, as amended by Dan on 2026-10-22),** decided per + attempt from the live key cell: + 1. **A key is configured** (`GOOGLE_GENERATIVE_AI_API_KEY`, else + `settings.ai.geminiApiKey`, through the existing `AiKeyCell`, so a + Settings save takes effect at the next attempt, G18) → **key route**: + the key is always sent (`x-goog-api-key`) and wins over any OneCLI + marker on the ambient proxy. The old "OneCLI first, key only on + `credential_not_found`/`app_not_connected`" order (G16) is removed. + 2. else the ambient proxy carries a OneCLI marker → **inherited OneCLI + route** (today's behavior: no key sent), with a boot WARN + `session_names.generation_route_inherited_identity` (once) saying the + server is borrowing the launching shell's OneCLI identity. + 3. else unavailable (no claims). + Every route makes one request per attempt; there is no fallback between + routes. +- **How the key route reaches Google (V1 + LB-B5/B6, resolved).** A + request carrying a configured key goes DIRECT (a `reqwest` client built + with `.no_proxy()`) when the ambient proxy for the Gemini URL carries the + OneCLI marker (`has_onecli_authorization`, `ai_title.rs:471`): OneCLI is + the launching agent's credential broker, its token may not outlive that + agent, and a client-supplied key through it was only ever proven for GETs, + while direct was proven on this host (G49). Otherwise the key route uses + the normal client that honors the ambient proxy environment + (`HTTPS_PROXY`/`NO_PROXY`, `reqwest::Client::new()` semantics), so hosts + whose only way out is an ordinary proxy keep working. Requests with no + key (the inherited OneCLI route) use the normal client as today. On Dan's + host the key therefore never passes through the gateway. The boot log + field `key_route_transport` (`direct` | `ambient_proxy`) says which. +- Failure classes (G49), checked in this order: + - HTTP: **429 is `Throttled` first** (LB-B10: a quota answer through the + gateway must not latch as a 15-minute `Capability`); then any non-2xx + whose body is OneCLI-style JSON (a top-level string `error`, e.g. + `access_restricted`, `credential_not_found`, `app_not_connected`) is + `Capability{code: }`, checked before the remaining status + rules because the gateway reports a bad key as **400** + `access_restricted`; then 401/403/407 and Google `API_KEY_INVALID` are + `Capability`; any other status is `Transient`/`Invalid` (unchanged + consumption). + - Transport: whether the request went through a proxy is computed for the + request URL with the existing `GeminiProxyEnvironment` + (`effective_proxy`/`bypasses_proxy`, `ai_title.rs:152-230`), and is + always false on the direct key route. A rustls invalid-certificate + error anywhere in the source chain is `Capability{proxy_tls}` through a + proxy and `Capability{tls_untrusted}` otherwise; it is found by + downcasting to `rustls::Error::InvalidCertificate` (walking `source()` + and looking inside `std::io::Error::get_ref()`), never by matching + message text, because `is_connect()` is also true for a refused + connection. `freshell-server` adds `rustls` as a direct dependency at + the version already in `Cargo.lock` (0.23.41, via reqwest) so the + downcast type is the same one. Any other `is_connect()` error is + `Capability{proxy_unreachable}` through a proxy (a CONNECT refused with + 407/403, a proxy that is down) and `Capability{direct_unreachable}` + otherwise (latched, nothing consumed: a host that reaches Google only + through its OneCLI proxy pauses naming with a WARN, Residual 19). + Timeouts and everything else are `Transient`. +- **Log scrubbing (G48, LB-B13).** The JSONL writer redacts the currently + configured key value itself (exact match) in addition to the patterns: + the writer gains a shared live-secret set next to its `AUTH_TOKEN` + secret, `main.rs` puts the key cell's value in it after `AiKeyCell::init`, + and the Settings PATCH path refreshes it right after + `apply_settings_key_forced` (`settings_store.rs:2139-2143`). The + common-secret regex in `crates/freshell-runtime-observability/src/lib.rs:61-69` + also gains the `AQ.` key form (`AQ\.[0-9A-Za-z_-]{20,}`) next to `AIza…`. + Naming code still logs only statuses and codes. +- **Exhaustion moves from the claim to the fold (G17).** The claim increments + `consumed` and sets `InFlight`, and never clears the excerpt. The fold + decides: `Capability`/`Throttled` refund the start (consumed −1, attempt + id/start removed, status Eligible, excerpt kept); `Empty`/`Failed` at the + cap exhaust and clear the excerpt (today's end state). Boot recovery of an + interrupted start already exhausts at the cap (`:3168-3171`), so a crash + mid-attempt still ends exhausted. +- **Latches (in memory, with code).** `Capability` latches naming as + unavailable until a re-probe time (default 15 min; Rust-only test seam + `FRESHELL_NAMING_CAPABILITY_REPROBE_MS`) or a key change. `Throttled` + latches the same way with a shorter, growing re-probe: 60 s, doubling per + consecutive throttled probe, capped at 15 min, reset by any success or a + key change (Rust-only test seam `FRESHELL_NAMING_THROTTLE_REPROBE_MS` sets + the base). One probe attempt runs per window. While latched the worker + claims nothing, so a refunded series cannot loop (load-bearing review + LB-A14). +- Logs: WARN `session_names.generation_capability_unavailable {code, status, route}` + once per transition (a 429 logs `code=throttled`); INFO + `session_names.generation_capability_restored`; INFO at boot + `session_names.generation_route_selected {route, onecli_marker, key_configured, key_route_transport}` + (booleans and names only; never a key or URL); `session_names.generation_failed` + gains `status` and `provider_code` fields. + +### Client tab titles (Tasks 7–9) +- **D2 choice: mirror, not "no stored title".** A session-owned tab + (`nameSource.kind === 'session'`) keeps `tab.title`, written only by a new + middleware from the canonical record, without bumping `updatedAt` and + without `titleSetByUser`. Justification: `title` is a required field in + persisted layouts, the server layout mirror, the tabs registry, closed-tab + snapshots and REST projections; removing it would touch every one of those + schemas and old-client compatibility, while a mirror fixes all of them at + once (they already copy `tab.title`) and keeps the last-known saved name + for offline display. It is never name evidence because Task 2 stops reading + live layouts. +- **The mirror writes only when a saved record is known (G33).** For each + session-owned tab it resolves the source pane's ref + (`resolvePaneNameRef(content)`) and reads `selectSessionNameRecord(state, ref)`. + No record → no write; the tab keeps its last-known title. It never uses + `selectScopedTabDisplayTitles`/`selectPaneDisplayName`, which fall back to + provider labels. New selector `selectSessionTabSavedNames(state): Record` + (records only) in `src/store/selectors/sessionNameSelectors.ts` next to the + existing one; the registry overlay switches to it too (so the registry no + longer advertises a fallback label for a session-owned tab whose record is + not loaded). +- Module name: `src/store/savedNameTabTitleMirror.ts` + (`savedNameTabTitleMirrorMiddleware`). It differs from the existing + `src/store/sessionTitleMirror.ts`, which copies session-directory titles + into NON-scoped pane titles; the contract doc's pane-title section says so. +- One gate `canWriteLegacyTabTitle(state, tabId)` in new + `src/lib/tab-title-ownership.ts`: false when the tab is session-owned. Every + legacy writer in G23 calls it; the `tab.create` UI command does not set + `titleSetByUser` when the created pane content is scoped (its title is a + provisional display until the record arrives). +- `updateTab` payload gains optional `touchUpdatedAt?: boolean` (default + true); display-only refreshes pass `false`. + +### Terminal naming bind (Task 10) +The core lives in `freshell-freshagent` so both doors can call it (G35): +```rust +// crates/freshell-freshagent/src/terminal_name_binding.rs +pub enum NamingBindOutcome { Bound(SessionNameRecord), BoundNoRecord, NotScoped, Deferred(String) } +pub trait TerminalNameBinder: Send + Sync { + /// Record the terminal's naming ref in the door's identity table. + fn set_name_ref(&self, terminal_id: &str, name_ref: &SessionNameRef); + /// The durable (provider, session id) the door knows for this terminal, if any. + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)>; +} +pub struct NamingBindHint { pub mode: String, pub cwd: Option, pub resume_session_id: Option, pub naming_handle: Option } +pub async fn bind_terminal_naming_from_identity( + sink: &NameSink, registry: &TerminalRegistry, binder: &dyn TerminalNameBinder, + terminal_id: &str, hint: NamingBindHint, +) -> NamingBindOutcome +``` +- `freshell-ws` implements `TerminalNameBinder` over its identity registry + (thin wrapper in new `crates/freshell-ws/src/terminal_naming.rs`); the REST + door implements it over whatever `terminal_tabs.rs:3491-3545` uses today to + record the binding. +- It resolves the target (durable scoped session if known, else the pending + handle), sets the identity and registry bindings together, then reads the + record. "Read failed" and "no record" are separate arms (today both are + `None`, G26): read failure retries on `LockUnavailable`/`Persistence` + (50 ms, 250 ms, 1 s) and then returns `Deferred` with the binding kept; + "no record" with a persisted handle goes to the pending path; `ensure_pending` + gets the same bounded retry, and on persistent failure the row is still + bound to the pending ref and marked for the tick (it is never left + unbound). A recovery create accepts the pending answer only if the + handle is still pending or resolves to the intended durable session. A + handle already spent on another session never moves the row to that + session: the intended durable binding stays, without a cached name. + `pane.reconcile.request` carries the pane's optional `namingHandle` + through managed-row adoption, so a zero-turn pane can rejoin its existing + pending name after a server restart. It fills + `registry.update_session_name` and sets the row title to the saved name + or `mode_label(mode)` (never "Shell" for an agent mode). +- The 2 s tick reconciles: rows whose identity ref differs from the registry + ref; bound rows whose name cache is empty; and scoped-mode rows with NO ref + (it runs `ensure_pending` for them). +- **Conversation switch adopts the new session's name (existing contract, + G45).** When the identity upsert retargets a terminal from session A to + session B, the registry binding moves to B together with the identity ref + (the helper sets both; the tick's "identity ref ≠ registry ref" reconcile + is the backstop), and the row adopts B's record: B's saved name if B has a + record; otherwise the row title is `mode_label(mode)` and the pane shows + its no-saved-name fallback label until B's record is created by the + normal paths (the sweep's hydrate ladder: provider title → first message + → folder; then the first-message fallback and AI naming). On this path + the helper never takes the "no record → pending" arm: the terminal's + pending handle was already bound to A, so falling back to it would show + A's name again. The retarget itself creates or changes no record, and A's + record is untouched. +- Association/materialization notifications carry the authoritative + `nameRef`. The client adopts it together with the session identity and + releases a spent pending handle when the conversation changes, even + immediately after a reload before redirect records have loaded. A + zero-turn pane whose authoritative ref is still pending keeps that ref + and its pre-durable saved name. + +### Store reads (Task 11) +- `Adopted` gains `stamp: DocumentStamp { len: u64, mtime_ns: i128, file_id: Option<(u64, u64)> }`. + `file_id` is `(dev, ino)` from `std::os::unix::fs::MetadataExt` under + `#[cfg(unix)]` and `None` elsewhere (the Windows file-index APIs in std are + unstable; no new crate). On Windows the stamp is `len` + 100 ns `mtime`. +- `SessionNames::adopt_external_if_changed() -> NameFuture`: stat the + document; if the stamp equals the adopted one AND the last full check is + younger than 30 s, return `false` without a lock; otherwise run the existing + locked full-read adoption. The 30 s full check bounds staleness for the + rare case a replace keeps length and mtime (coarse filesystems). +- `get` serves from the adopted view after `adopt_external_if_changed`; only + an unestablished store (generation 0) falls back to the locked path. + +### Name-change event (Task 12) +INFO `session_names.name_changed` with fields `name_ref`, `old_name`, +`old_source`, `old_revision`, `new_name`, `new_source`, `new_revision`, +`cause` (`import`|`native_observation`|`first_message_fallback`|`generation`|`manual_rename`|`automation_rename`|`bind`|`bind_collision`|`ensure_pending`|`hydrate`|`adopted_external`), +`self_heal` (true when an automatic native, first-message, hydrate or +bind-collision offer replaces recognizable junk at the same or lower +source rank; rank increases, renames, AI answers, imports and adopted +external changes are false), plus +`import_id`/`evidence_key`/`series` when applicable. Emitted once per +accepted visible change, after the durable commit. A conversation switch +changes no record, so it emits no `name_changed`; Task 10 logs the binding +move itself. + +### Task order and dependencies +| Phase | Task | Depends on | Validation results it applies | +|---|---|---|---| +| Stop new damage | 1 Name-evidence classifier | — | V5 (leading-tag check only) | +| | 2 Client import shutdown + envelope cleanup + flush never blocked (D1 client, D10) | 1 | — | +| | 3 Server: acknowledge-only imports after completion; default-label bind collisions (D1 server) | 1 | — | +| Ranking, self-heal & fallback | 4 Native placeholders, native replacement, automation outcome, self-heal rule (D3) | 1 | V2 (C1–C4); LB-B1 (batched sweep maintenance, staged baselines), LB-B8 (folder-aware automation renames) | +| | 5 First-message fallback on every first message; wrapper text excluded (D4, RC12) | 1, 4 | V2 (cache invalidation, re-absorb at rest, prompt-input guard, C5), V5 (tag set, fixture shape); LB-B7 (interactive pin), LB-B11 (per-entry rules stamp) | +| | 6 Generation routing, capability classification and pause (D5 as amended) | — | V1; LB-B5/B6 (direct key route when the proxy is OneCLI's), LB-B10 (429 first), LB-B13 (live-key scrubbing) | +| Client models | 7 Saved-name tab title mirror (D2) | 2 | — | +| | 8 Managed-runtime merge never writes names (D6) | 1, 7 (the mirror must read records only before reconstructed agent tabs rely on it) | V3 (new local-only spec), V4 (scoped-only session ownership); LB-B3 (stored-state assertions, red receipts), LB-B4/B14 (fallbacks) | +| | 9 New-window rebuild keeps pane titles and flags (D7) | 7 | — | +| | 14 Heal stored provider-label tab titles; registry opens keep pane names (added after the Stage 2 reviews; Residual 23, Task 9 receipt open concern 3) | 1, 7, 8, 9 | — | +| Server binding | 10 Bind naming on every terminal row path; a conversation switch adopts the new session's name (D8) | — | V3 (restart naming at Rust integration level); LB-B2 (every adopt/create entry) | +| Store perf | 11 Lock-free reads and a cheap cross-process peek (D9) | — | — | +| Observability | 12 Name-change events; codex proxy `RepairTrigger` log spam diagnosed and bounded (D11) | 3–6, 10 | — | +| Finish | 13 Contract doc, e2e roll-up, build, code review, gate, PR and merge | all | V3 (local-only run rules), all results recorded | + +--- + +### Task 1: Shared default/derived name classifier + +**Intent:** one definition of "this text is a default or derived label, not a +name", used everywhere name evidence is accepted and by the self-heal +rule (D1, D3). + +**Files:** +- Create: `shared/name-evidence.ts` +- Create: `crates/freshell-sessions/src/name_evidence.rs` (export from `crates/freshell-sessions/src/lib.rs`) +- Create: `test/fixtures/name-evidence/cases.json` +- Create: `test/unit/shared/name-evidence.test.ts` +- Create: `crates/freshell-sessions/tests/name_evidence.rs` +- Modify: `crates/freshell-sessions/src/text.rs:135-154` (extract the leading + `\s]` branch of `is_system_context` into + `pub fn starts_with_context_tag(text: &str) -> bool`; `is_system_context` + calls it, so its behavior is unchanged) + +- [ ] **Step 1: Write the failing tests.** + - `cases.json`: ≥40 rows `{name, ctx, expected}` covering `Tab 7`, ` Tab 12 `, + `Tab` , folder basename with `ctx.cwd:"/home/dan/code/freshell"` → + `freshell`, the same `freshell` with no `ctx.cwd` → NOT default (pins the + folder-free limit), `OpenCode agent`, `Shell agent`, `Recovered Claude agent`, + `Claude CLI`, `codex cli` (case), `New session - 2026-10-20T00:09:25.350Z`, + `Child session - …`, `New Session - …` (legacy capital S), `Untitled`, + and true names that must NOT match: `Tab bar fix`, `freshell refactor`, + `Fix the OpenCode agent crash`, `New session - planning` (not ISO), + `main cleanup`, `LotL`, `ms-people-summit`, and the two V5 guards for the + leading-tag-only rule: `$ ls -la` and `1, 2, 3` → NOT default (the rest + of `is_system_context` would flag them). `…` → + default (an existing tag). (The RC12 wrapper-tag rows are added in + Task 5, when the tags exist.) + - `test/unit/shared/name-evidence.test.ts`: + `it('classifies every shared fixture case exactly as expected')` (loops the + table, reports the failing row) and + `it('treats provider placeholders per provider only')` (`opencode` + + placeholder → true; `claude` + same text → false). + - `crates/freshell-sessions/tests/name_evidence.rs`: + `shared_fixture_cases_match` (reads + `concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/name-evidence/cases.json")`) + and `opencode_placeholder_reuses_directory_index_predicate`. +- [ ] **Step 2: Run red.** `pnpm run test:vitest run test/unit/shared/name-evidence.test.ts --config config/vitest/vitest.config.ts` + and `~/.local/bin/mise exec rust@1.96 -- cargo test -p freshell-sessions --locked --test name_evidence`. + Expected: fail to compile/import (module missing). +- [ ] **Step 3: Implement** both modules per "Name evidence" above. TS imports + use `.js` extensions where NodeNext requires them. +- [ ] **Step 4: Run green** (same commands). +- [ ] **Step 5: Refactor.** Make the directory index's placeholder use + (`crates/freshell-sessions/src/parse/opencode.rs:1014`) and the client + `tab-title.ts` `^Tab \d+$` check use the shared functions so there is one + definition per language; re-run the existing tests of those files + (`~/.local/bin/mise exec rust@1.96 -- cargo test -p freshell-sessions --locked opencode`, + and the existing `getTabDisplayTitle` cases, located with + `grep -rln getTabDisplayTitle test/unit`, through + `pnpm run test:vitest run --config config/vitest/vitest.config.ts`). +- [ ] **Step 6: Commit** `feat(names): shared default-label classifier`. + +### Task 2: Client import shutdown, envelope cleanup, flush never blocked (D1 client, D10) + +**Intent:** the browser stops sending its current tab and pane titles as name +evidence, deletes the migration copies it no longer needs, and can never +block a layout save on evidence capture. + +**Files:** +- Create: `src/lib/legacy-name-capture-policy.ts` (`isGenuinePreMigrationSource(key, raw)`, `runOneTimeLegacyCapture(storage)`, `purgeObsoleteEnvelopes(storage)`, `markCaptureDone(storage)`, `isCaptureDone(storage)`) +- Modify: `src/lib/session-name-migration.ts` (module-load call at 889-893 → `runOneTimeLegacyCapture`; delete `captureLegacyLayoutEnvelope` 299-326 and `ensureLegacyNameCapturesForFlush`/`captureWriteFailed` 77-84, 346-357; `markAcknowledged` deletes the envelope; `submitPendingLegacyNameImports` marks done when nothing is pending; `extractEnvelopeCandidates` 482-630 skips default/derived labels with `ctx.cwd` from pane content `initialCwd`, and skips session-owned tabs' `title` entirely) +- Modify: `src/store/crossTabSync.ts:443-453` (remove capture) +- Modify: `src/lib/machine-workspace.ts:147-155` (remove capture) +- Modify: `src/store/persistMiddleware.ts:658-671` (remove the deferral gate) +- Modify: `src/store/store.ts:162-196` (submission only while capture is not done) +- Tests: rewrite `test/unit/client/lib/session-name-migration.test.ts` cases at :148, :209, :243, :269 and the taxonomy case at :686 (they pin the removed behavior); rewrite `test/unit/client/store/persistFlushCaptureGate.test.ts:97`; update the capture assertion near `test/unit/client/store/crossTabSync.test.ts:3060`; create `test/unit/client/lib/legacy-name-capture-policy.test.ts`. +- E2e: create `test/e2e-browser/specs/name-import-shutdown-rust.spec.ts` (scenarios A–C below; scenario D lands in Task 5) and `test/e2e-browser/specs/name-storage-quota-rust.spec.ts` (RC10, see the e2e matrix). + +- [ ] **Step 1: Write the failing unit tests** (`legacy-name-capture-policy.test.ts` + and the rewritten cases): + - `it('never captures a live per-window layout key, its .bak or its fresh-agent backup')`: + seed `freshell.layout.v3.layout-window-a`, `….bak`, + `….backup-before-fresh-agent-centralization`; run; assert zero + `freshell.session-names.migration.v1.*` keys and no import request + (mock `api.post`). + - `it('never captures the per-window pre-migration sidecar')`: seed + `freshell.layout.pre-migration-raw.v1.layout-window-a` holding a + pre-names raw whose scoped tab is titled `OpenCode agent` (the shape the + current client writes, G44); run; zero envelopes, zero requests. The + bare `freshell.layout.pre-migration-raw.v1` key is still captured (covered + by the next case's source list). + - `it('captures a genuine bare legacy layout once, then marks the device done after acknowledgment')`: + seed `freshell.layout.v3` with a pre-names raw (no `nameSource`); run, submit + (mock 200 with all candidate ids), assert envelope deleted, done marker + present; run again with a changed bare key → no new envelope, no request. + - `it('ignores a bare legacy key whose raw already carries unified-names fields')`. + - `it('a device with prior envelopes or ack markers becomes done without resubmitting live-layout envelopes')`: + seed an acked envelope + an unacked envelope whose `storageKey` is + `freshell.layout.v3.layout-window-b`; run; assert both envelopes and the + ack marker are deleted, done marker set, zero requests. + - `it('a sibling window layout write never captures or submits')` + (crossTabSync storage event with a changed per-window raw; assert no + envelope, no `api.post`). + - `it('machine-workspace restore performs no legacy capture')` (in + `test/unit/client/lib/machine-workspace.test.ts`). + - `it('extracts no candidate from default or derived labels')`: genuine + legacy raw where a scoped tab is titled `Tab 7`, `freshell` (cwd basename), + `OpenCode agent`, `Claude CLI`; assert `prepareLegacyNameImports` yields + zero candidates; a real label `Billing bug` still yields one. + - `persistFlushCaptureGate.test.ts`: `it('a quota-failing capture never blocks the layout flush')` + (make `setItem` throw for `freshell.session-names.*` keys only; dispatch a + tab rename; assert the layout key was written and a structured + `legacy_name_capture_failed` error was logged). +- [ ] **Step 2: Write the failing e2e (scenarios A–C)** in + `name-import-shutdown-rust.spec.ts`, driven by `bootUnifiedNamesServer({ mode: 'claude', gemini: })` + (extend `startFakeGemini` with `opts.status?: number` and + `opts.body?: unknown`), real pane-picker create, `sendFirstMessage`, + `page.on('request')` counting `POST /api/session-names/import`, and + `readRecord` for the server truth: + - A `reload keeps the saved name and sends no import`: after the first message + settles, read the record (name N, revision R); `page.reload()`; assert zero + import POSTs, record still N at R, tab label N. + - B `a second window keeps the saved name`: `context.newPage()` (new + sessionStorage → new window id, shared localStorage); same assertions in + both pages. + - C `two windows with different stale tab titles never change the name`: + `addInitScript` seeds two per-window keys whose session-owned tab titles + are `Tab 23` and `ms-people-summit` for the same session; open both, + reload each three times; record name and revision never change. +- [ ] **Step 3: Run red.** `pnpm run test:vitest run test/unit/client/lib/legacy-name-capture-policy.test.ts test/unit/client/lib/session-name-migration.test.ts test/unit/client/store/persistFlushCaptureGate.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/machine-workspace.test.ts --config config/vitest/vitest.config.ts` + and `GCLOUD_ROBOT_REQUIRE=1 FRESHELL_E2E_BACKEND=cloud pnpm run test:e2e --project=chromium test/e2e-browser/specs/name-import-shutdown-rust.spec.ts test/e2e-browser/specs/name-storage-quota-rust.spec.ts`. + Expected red: envelopes captured from per-window keys, flush deferred, + e2e A sees ≥1 import POST and (as reproduced) the name becomes `Tab N`. +- [ ] **Step 4: Implement.** Policy module per "Import policy after + migration". `runOneTimeLegacyCapture`: done → purge acked/obsolete + envelopes and return; else purge envelopes whose source is not genuine + (never submit them), delete acked envelopes and markers; if a prior capture + ever happened (ack markers or envelopes existed) and nothing genuine is + pending → mark done; else capture genuine sources once (no source → mark + done). Capture write failure logs `log.error('legacy_name_capture_failed', { storageKey, reason })` + and never sets any flush gate. Submission marks done after the last + acknowledgment. Remove the three capture call sites and the flush gate. +- [ ] **Step 5: Run green** (both commands). +- [ ] **Step 6: Refactor.** Remove now-dead exports (`captureLegacyLayoutEnvelope`, + `registerLegacyNameSubmitGate`, `resetLegacyNameCaptureFailureForTests`) and + their imports; `session-name-migration.ts` must end smaller than 893 lines. + Fix the `stripSessionOwnedTabFreezeFlags` comment once Task 7 lands (note + it in Task 7). Re-run Step 3 commands plus `pnpm run typecheck`. +- [ ] **Step 7: Commit** `fix(names): capture legacy names once per device and never block layout saves`. + +### Task 3: Server acknowledges browser imports after completion without writing; default-label bind collisions (D1 server) + +**Intent:** once the one-time consolidation is complete, a browser import +changes nothing and costs nothing; an import-owned default label ("Tab 4", +"OpenCode agent") never wins a bind collision, while other imported names +keep today's rank rule; window ids never decide a name. + +**Files:** +- Create: `crates/freshell-server/src/session_name_import_policy.rs` (`post_completion_acknowledge(input) -> LegacyImportResult`, `is_import_owned(document, key) -> bool`, `import_owned_label_loses_bind(document, key) -> bool` = import-owned AND unprotected AND `is_default_or_derived_name(name, cwd: None)` from Task 1, the per-process logged-import-id set) +- Create: `crates/freshell-server/src/session_name_import_policy_tests.rs` +- Modify (wiring): `crates/freshell-server/src/session_names.rs` — `import_legacy_decision` 4003-4104: when `document.migration.completed && !trusted_boot`, return `Decision::Read(post_completion_acknowledge(&input))` BEFORE the backup write (`:4029`) and the ack push (`:4053-4057`); `compare_classified_candidates` 3682-3712 ends at scope; `apply_legacy_candidate` 3836-3851 keeps strict-win; `bind_pending_decision` 2537-2553 consults `import_owned_label_loses_bind` +- Modify: `crates/freshell-server/src/session_names_tests.rs`, `crates/freshell-server/src/session_name_routes_tests.rs` (cases that pinned tie-break order or post-completion application) + +- [ ] **Step 1: Write the failing tests** (`session_name_import_policy_tests.rs`, real `SessionNames` on a temp data dir, migration completed through `complete_legacy_migration`): + - `post_completion_import_writes_nothing`: one existing session record + (`directory` "work") and one session with no record; import candidates + `provider_ai` "Tab 4" (existing session), "Billing bug" (no-record + session), a canonical pending target and a derived `legacy-pending` + handle → every candidate id acknowledged; existing record unchanged + (name, source, revision); no record created for either session or + either pending target; document generation unchanged; the import backup + directory gained no file. + - `post_completion_reimport_stores_no_candidate_ids`: the same import sent + three times → generation unchanged every time and + `acknowledged_candidate_ids` did not grow (the 4.5 MB rewrite stops). + - `post_completion_import_never_raises_rank`: existing `first_message` + record; candidate claims `legacy_protected` (flag evidence) → unchanged. + - `post_completion_import_logs_once_per_import_id` (capture `tracing` + output with the crate's existing log-capture helper; same import id + twice → one INFO `session_names.import_ignored_after_completion`). + - `import_owned_default_label_loses_a_bind_collision`: before completion a + boot import installs "Tab 4" at `provider_ai` on `session:claude:X`; + complete; pending handle P has directory "work"; bind P→X → record + "work", loser in recovery with reason `bind_collision_loser`. + - **Pinning test** `import_owned_real_label_keeps_the_rank_rule`: same + setup with "ms-people-summit" → the durable record keeps + "ms-people-summit" (`provider_ai` outranks the pending `directory` + record), exactly as today. + - **Pinning test** (pre-completion boot path; short) + `equal_rank_boot_candidates_keep_the_incumbent`: migration-owned record + installed from window `pii…`; an equal-rank, equal-scope candidate from + window `mn0…` → incumbent kept; swapped spelling → incumbent kept. Red + today because the evidence key decides. + - Route test in `session_name_routes_tests.rs`: + `import_route_after_completion_acknowledges_without_writing` (HTTP POST → + 200, `acknowledged` lists ids, generation unchanged, no backup file). +- [ ] **Step 2: Run red.** `pnpm run test:server session_name_import_policy` + and `pnpm run test:server session_name_routes`. Expected: creates, + replacements, stored ids and backup files happen today; tie test flips + with key order. +- [ ] **Step 3: Implement** per "Import policy after migration". +- [ ] **Step 4: Run green**, then the whole naming suite: + `pnpm run test:server session_name`. +- [ ] **Step 5: Refactor.** Update the doc comments that promise "a later + previously-offline import compares against this retained evidence with the + same deterministic total order" (`session_names.rs:286-296`) and the + tie-break comments; the pre-completion path is unchanged apart from the + tie-break. +- [ ] **Step 6: E2e.** Add scenario `server ignores a forced stale import` to + `name-import-shutdown-rust.spec.ts`: seed + `freshell.session-names.migration.v1.` holding a per-window raw with + "Tab 23", clear the done marker (forcing an old-client-style send); assert + the import POST returns 200, the record and its revision are unchanged, and + the server log has one `import_ignored_after_completion` line. +- [ ] **Step 7: Commit** `fix(names): acknowledge post-migration imports without writing`. + +### Task 4: Native placeholders, native replacement, automation outcome, self-heal (D3) + +**Intent:** OpenCode's temporary "New session -