From 3af3c3a6138bf6053496e22846283b20be48d87e Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 13 Jun 2026 11:26:21 +0200 Subject: [PATCH] turn_stats: create the session_id index after the column exists --- hive-ag3nt/src/turn_stats.rs | 104 ++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/hive-ag3nt/src/turn_stats.rs b/hive-ag3nt/src/turn_stats.rs index d82123c0..c239aff2 100644 --- a/hive-ag3nt/src/turn_stats.rs +++ b/hive-ag3nt/src/turn_stats.rs @@ -51,8 +51,11 @@ CREATE TABLE IF NOT EXISTS turn_stats ( ); CREATE INDEX IF NOT EXISTS idx_turn_stats_started ON turn_stats (started_at DESC); -CREATE INDEX IF NOT EXISTS idx_turn_stats_session - ON turn_stats (session_id); +-- NOTE: the index on session_id is created in MIGRATIONS, not here. On an +-- existing pre-session db the `CREATE TABLE IF NOT EXISTS` above is a no-op +-- (the old table has no session_id column), so indexing session_id in this +-- batch would fail with `no such column` and abort the whole SCHEMA apply — +-- which disables the stats sink. The column is added by MIGRATIONS first. -- One row per fresh claude session (minted when --continue is suppressed). -- turn_stats.session_id FKs here so per-session stats (first-turn tokens, -- per-session totals, turn count, duration) are one GROUP BY away. @@ -75,6 +78,9 @@ const MIGRATIONS: &[&str] = &[ // Nullable FK to sessions.id — no default; pre-migration rows stay NULL // (the surface treats NULL as "no session", inert until capture lands). "ALTER TABLE turn_stats ADD COLUMN session_id INTEGER", + // Index on session_id — must run AFTER the column is added, so it lives + // here rather than in SCHEMA (see the note there). Idempotent. + "CREATE INDEX IF NOT EXISTS idx_turn_stats_session ON turn_stats (session_id)", ]; /// One row to be inserted. `Option`-wrapped fields default to NULL @@ -306,3 +312,97 @@ impl TurnStats { fn default_path() -> PathBuf { crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite") } + +#[cfg(test)] +mod tests { + use super::*; + + /// A `turn_stats` db in the *pre-session* shape: the original table with no + /// `session_id` column, plus the `started_at` index. This is what every + /// agent created before the sessions feature has on disk. + fn seed_pre_session_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE turn_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + model TEXT NOT NULL, + wake_from TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, + last_input_tokens INTEGER NOT NULL DEFAULT 0, + last_output_tokens INTEGER NOT NULL DEFAULT 0, + last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, + last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, + tool_call_count INTEGER NOT NULL DEFAULT 0, + tool_call_breakdown_json TEXT, + open_threads_count INTEGER, + open_reminders_count INTEGER, + result_kind TEXT NOT NULL, + note TEXT + ); + CREATE INDEX idx_turn_stats_started ON turn_stats (started_at DESC);", + ) + .unwrap(); + } + + fn sample_row() -> TurnStatRow { + TurnStatRow { + started_at: 100, + ended_at: 101, + duration_ms: 1_000, + model: "opus".to_owned(), + wake_from: "recv".to_owned(), + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + last_input_tokens: 10, + last_output_tokens: 5, + last_cache_read_input_tokens: 0, + last_cache_creation_input_tokens: 0, + tool_call_count: 1, + tool_call_breakdown_json: None, + open_threads_count: None, + open_reminders_count: None, + result_kind: "ok", + note: None, + session_id: None, + } + } + + /// Regression: a pre-session db must `open()` cleanly (the `session_id` + /// index used to live in `SCHEMA` and aborted the apply with + /// `no such column`, silently disabling the stats sink), get the column + /// added by `MIGRATIONS`, and then accept writes. + #[test] + fn open_upgrades_pre_session_db_and_writes() { + let path = std::env::temp_dir().join("hyperhive-turnstats-pre-session-regression.sqlite"); + let _ = std::fs::remove_file(&path); + seed_pre_session_db(&path); + + let stats = TurnStats::open(&path).expect("open() must succeed on a pre-session db"); + stats.record(&sample_row()); + + let conn = Connection::open(&path).unwrap(); + let rows: i64 = conn + .query_row("SELECT COUNT(*) FROM turn_stats", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows, 1, "the row must be written once the column is added"); + let has_session_id: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('turn_stats') \ + WHERE name = 'session_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(has_session_id, 1, "MIGRATIONS must add session_id"); + + let _ = std::fs::remove_file(&path); + } +}