From e71bb2e8b8b62ad2c128054a3fbc1fb7d417f1e3 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Fri, 29 May 2026 11:21:37 +0530 Subject: [PATCH] fix(subconscious): suppress repeated Sentry errors when DB init fails (TAURI-RUST-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDL runs on every `with_connection` call. On Windows, if the very first call fails (directory lock, antivirus hold, network-drive WAL rejection, etc.) every subsequent `subconscious_tasks_list` poll re-runs the same failing path and fires `report_error_or_expected` — producing 1,499+ identical Sentry events from a single underlying cause (TAURI-RUST-A). Fix: add `DDL_EVER_FAILED` / `DDL_LOGGED` atomic flags in `store.rs`. - First failure: log at `error!` level (Sentry captures once) + set both flags. - Subsequent calls: return `DB_INIT_FAILED_SENTINEL` immediately, skipping the expensive re-open + DDL re-run entirely. - Read-only list handlers (`tasks_list`, `log_list`, `escalations_list`, `reflections_list`, `status`) in `schemas.rs` detect the sentinel via `is_db_init_failed()` and return empty payloads instead of propagating an error that would re-fire `report_error_or_expected` on every poll. - Write/mutate handlers (`tasks_add`, `tasks_update`, `tasks_remove`, `escalations_approve`, `escalations_dismiss`, `reflections_act`, `reflections_dismiss`) still surface the error — appropriate since those are explicit user actions, not background polls. Closes #2903. --- src/openhuman/subconscious/schemas.rs | 75 ++++++++++++++++----- src/openhuman/subconscious/store.rs | 82 +++++++++++++++++++++-- src/openhuman/subconscious/store_tests.rs | 20 ++++++ 3 files changed, 156 insertions(+), 21 deletions(-) diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index fcd95f8786..0bf8911cd1 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -277,7 +277,7 @@ fn handle_status(_params: Map) -> ControllerFuture { let hb = &config.heartbeat; let (task_count, pending_escalations, last_tick_at, total_ticks) = - store::with_connection(&config.workspace_dir, |conn| { + match store::with_connection(&config.workspace_dir, |conn| { let tc = store::task_count(conn).unwrap_or(0); let pe = store::pending_escalation_count(conn).unwrap_or(0); let (lt, tt) = conn @@ -288,8 +288,15 @@ fn handle_status(_params: Map) -> ControllerFuture { ) .unwrap_or((None, 0)); Ok((tc, pe, lt, tt)) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(counts) => counts, + Err(e) if is_db_init_failed(&e) => { + // DB unavailable — return zeroed status so the UI stays functional. + log::debug!("[subconscious] status: db unavailable — returning zeroed status"); + (0u64, 0u64, None::, 0u64) + } + Err(e) => return Err(e.to_string()), + }; let provider_unavailable_reason = if hb.enabled && hb.inference_enabled { super::engine::subconscious_provider_unavailable_reason(&config) @@ -352,10 +359,18 @@ fn handle_tasks_list(params: Map) -> ControllerFuture { .and_then(|v| v.as_bool()) .unwrap_or(false); let config = load_config().await?; - let tasks = store::with_connection(&config.workspace_dir, |conn| { + let tasks = match store::with_connection(&config.workspace_dir, |conn| { store::list_tasks(conn, enabled_only) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(t) => t, + Err(e) if is_db_init_failed(&e) => { + // DDL failed on a previous call and was already reported to Sentry + // once. Return an empty list so the UI stays functional (TAURI-RUST-A). + log::debug!("[subconscious] tasks_list: db unavailable — returning empty list"); + vec![] + } + Err(e) => return Err(e.to_string()), + }; to_json(RpcOutcome::single_log(tasks, "tasks listed")) }) } @@ -441,10 +456,16 @@ fn handle_log_list(params: Map) -> ControllerFuture { let task_id = params.get("task_id").and_then(|v| v.as_str()); let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; let config = load_config().await?; - let entries = store::with_connection(&config.workspace_dir, |conn| { + let entries = match store::with_connection(&config.workspace_dir, |conn| { store::list_log_entries(conn, task_id, limit) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(e) => e, + Err(e) if is_db_init_failed(&e) => { + log::debug!("[subconscious] log_list: db unavailable — returning empty list"); + vec![] + } + Err(e) => return Err(e.to_string()), + }; to_json(RpcOutcome::single_log(entries, "log entries listed")) }) } @@ -460,10 +481,18 @@ fn handle_escalations_list(params: Map) -> ControllerFuture { _ => EscalationStatus::Pending, }); let config = load_config().await?; - let escalations = store::with_connection(&config.workspace_dir, |conn| { + let escalations = match store::with_connection(&config.workspace_dir, |conn| { store::list_escalations(conn, status_filter.as_ref()) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(e) => e, + Err(e) if is_db_init_failed(&e) => { + log::debug!( + "[subconscious] escalations_list: db unavailable — returning empty list" + ); + vec![] + } + Err(e) => return Err(e.to_string()), + }; to_json(RpcOutcome::single_log(escalations, "escalations listed")) }) } @@ -517,10 +546,18 @@ fn handle_reflections_list(params: Map) -> ControllerFuture { let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; let since_ts = params.get("since_ts").and_then(|v| v.as_f64()); let config = load_config().await?; - let reflections = store::with_connection(&config.workspace_dir, |conn| { + let reflections = match store::with_connection(&config.workspace_dir, |conn| { reflection_store::list_recent(conn, limit, since_ts) - }) - .map_err(|e| e.to_string())?; + }) { + Ok(r) => r, + Err(e) if is_db_init_failed(&e) => { + log::debug!( + "[subconscious] reflections_list: db unavailable — returning empty list" + ); + vec![] + } + Err(e) => return Err(e.to_string()), + }; to_json(RpcOutcome::single_log(reflections, "reflections listed")) }) } @@ -713,6 +750,14 @@ fn to_json(outcome: RpcOutcome) -> Result outcome.into_cli_compatible_json() } +/// Returns `true` when `e` is the sentinel set by `store::with_connection` after +/// the first DDL init failure. Callers use this to return empty/degraded payloads +/// instead of propagating an error that would re-fire `report_error_or_expected` +/// on every polling call (TAURI-RUST-A). +fn is_db_init_failed(e: &anyhow::Error) -> bool { + e.to_string().contains(store::DB_INIT_FAILED_SENTINEL) +} + #[cfg(test)] #[path = "schemas_tests.rs"] mod tests; diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 2aed761cb3..0ffa67a787 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -2,10 +2,24 @@ //! //! Follows the cron module's `with_connection` pattern: opens the database, //! runs DDL on every connection, and provides pure functions. +//! +//! ## Init-failure noise suppression (TAURI-RUST-A) +//! +//! `with_connection` runs the schema DDL on every call. On Windows the DDL can +//! fail once (e.g. locked file, antivirus hold, network-drive WAL rejection) and +//! then every subsequent `subconscious_tasks_list` RPC re-runs the same failing +//! path — producing ~1,500 identical Sentry events from a single underlying cause. +//! +//! Guard: `DDL_EVER_FAILED` / `DDL_LOGGED` pair. +//! - First failure → log at `error!` level (captured by Sentry once) + set both flags. +//! - All subsequent calls → return a sentinel `Err` that the read-only list +//! handlers in `schemas.rs` detect and convert to empty payloads, bypassing +//! the RPC error path that would re-fire `report_error_or_expected`. use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use uuid::Uuid; use super::types::{ @@ -13,22 +27,64 @@ use super::types::{ TaskPatch, TaskRecurrence, TaskSource, }; +/// Set to `true` the first time DDL init fails. Prevents re-running the same +/// failing DDL on every subsequent RPC call (TAURI-RUST-A — 1,499 events). +static DDL_EVER_FAILED: AtomicBool = AtomicBool::new(false); + +/// Set to `true` once we have emitted the first error log. Ensures Sentry only +/// captures the failure once — subsequent calls get a silent sentinel error. +static DDL_LOGGED: AtomicBool = AtomicBool::new(false); + +/// Sentinel message returned after the first DDL failure. Read-only list +/// handlers in `schemas.rs` match on this prefix to return empty payloads +/// instead of propagating an error that would fire `report_error_or_expected`. +pub const DB_INIT_FAILED_SENTINEL: &str = "[subconscious] db unavailable (init failed)"; + /// Open the subconscious database and run schema migrations. +/// +/// After the first DDL failure the function returns [`DB_INIT_FAILED_SENTINEL`] +/// immediately, skipping both the filesystem open and the DDL re-run. Callers +/// that perform read-only list queries should treat this sentinel as "empty +/// result" rather than an RPC error (see `schemas.rs`). pub fn with_connection( workspace_dir: &Path, f: impl FnOnce(&Connection) -> Result, ) -> Result { + // Fast path: DDL already failed in this process — skip the re-run and + // return the sentinel without logging again. + if DDL_EVER_FAILED.load(Ordering::Relaxed) { + anyhow::bail!(DB_INIT_FAILED_SENTINEL); + } + let db_path = workspace_dir.join("subconscious").join("subconscious.db"); if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create subconscious dir: {}", parent.display()))?; + if let Err(e) = std::fs::create_dir_all(parent) { + let msg = format!( + "[subconscious] failed to create subconscious dir {}: {e}", + parent.display() + ); + maybe_log_ddl_error(&msg); + anyhow::bail!(DB_INIT_FAILED_SENTINEL); + } } - let conn = Connection::open(&db_path) - .with_context(|| format!("failed to open subconscious DB: {}", db_path.display()))?; + let conn = match Connection::open(&db_path) { + Ok(c) => c, + Err(e) => { + let msg = format!( + "[subconscious] failed to open subconscious DB {}: {e}", + db_path.display() + ); + maybe_log_ddl_error(&msg); + anyhow::bail!(DB_INIT_FAILED_SENTINEL); + } + }; - conn.execute_batch(SCHEMA_DDL) - .with_context(|| "failed to run subconscious schema DDL")?; + if let Err(e) = conn.execute_batch(SCHEMA_DDL) { + let msg = format!("[subconscious] failed to run subconscious schema DDL: {e}"); + maybe_log_ddl_error(&msg); + anyhow::bail!(DB_INIT_FAILED_SENTINEL); + } // Drop the legacy `disposition` / `surfaced_at` columns + their index // from previously-migrated DBs. Idempotent — fresh installs and @@ -42,6 +98,20 @@ pub fn with_connection( f(&conn) } +/// Log the DDL failure exactly once (first call) so Sentry captures a single +/// event. Sets `DDL_EVER_FAILED` so subsequent `with_connection` calls skip +/// the expensive open + DDL re-run without emitting more Sentry events. +fn maybe_log_ddl_error(msg: &str) { + DDL_EVER_FAILED.store(true, Ordering::Relaxed); + // Only log the first occurrence — CAS ensures a single Sentry capture. + if DDL_LOGGED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + log::error!("{msg}"); + } +} + const SCHEMA_DDL: &str = " PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; diff --git a/src/openhuman/subconscious/store_tests.rs b/src/openhuman/subconscious/store_tests.rs index f191baa8bf..5ae6bd0002 100644 --- a/src/openhuman/subconscious/store_tests.rs +++ b/src/openhuman/subconscious/store_tests.rs @@ -1,5 +1,25 @@ use super::*; +// ── TAURI-RUST-A regression tests ──────────────────────────────────────────── +// Verify that the sentinel string is stable. The global `DDL_EVER_FAILED` flag +// cannot be unit-tested in isolation (process-global, permanent once set), so +// we only test properties that are safe to check without mutating global state. + +#[test] +fn db_init_failed_sentinel_is_non_empty_and_prefixed() { + // Sentinel must be non-empty and carry the [subconscious] grep prefix so + // `is_db_init_failed` in schemas.rs can match it reliably. + assert!(!DB_INIT_FAILED_SENTINEL.is_empty()); + assert!( + DB_INIT_FAILED_SENTINEL.contains("[subconscious]"), + "sentinel must carry [subconscious] grep prefix, got: {DB_INIT_FAILED_SENTINEL}" + ); + assert!( + DB_INIT_FAILED_SENTINEL.contains("db unavailable"), + "sentinel must carry 'db unavailable' so UI can distinguish init failures from query failures" + ); +} + fn test_conn() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(SCHEMA_DDL).unwrap();