feat(#1466): add sessions table + turn_stats.session_id capture for per-session stats

This commit is contained in:
damocles 2026-06-10 19:37:48 +02:00 committed by mara
commit 17ddba0341
5 changed files with 102 additions and 3 deletions

View file

@ -46,10 +46,21 @@ CREATE TABLE IF NOT EXISTS turn_stats (
open_threads_count INTEGER,
open_reminders_count INTEGER,
result_kind TEXT NOT NULL,
note TEXT
note TEXT,
session_id INTEGER
);
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);
-- 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.
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
model TEXT NOT NULL
);
";
/// Additive column migrations. Each runs unconditionally and ignores
@ -61,6 +72,9 @@ const MIGRATIONS: &[&str] = &[
"ALTER TABLE turn_stats ADD COLUMN last_output_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE turn_stats ADD COLUMN last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE turn_stats ADD COLUMN last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0",
// 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",
];
/// One row to be inserted. `Option`-wrapped fields default to NULL
@ -92,6 +106,10 @@ pub struct TurnStatRow {
/// `"ok" | "failed" | "prompt_too_long"`.
pub result_kind: &'static str,
pub note: Option<String>,
/// FK to `sessions.id` for the fresh claude session this turn belongs
/// to. `None` on pre-capture rows (and when the stats db couldn't mint
/// a session) so the read side degrades to empty.
pub session_id: Option<i64>,
}
/// Thin sqlite wrapper. Cloning is cheap (Arc-shared connection).
@ -163,7 +181,7 @@ impl TurnStats {
last_cache_read_input_tokens, last_cache_creation_input_tokens,
tool_call_count, tool_call_breakdown_json,
open_threads_count, open_reminders_count,
result_kind, note
result_kind, note, session_id
) VALUES (
?1, ?2, ?3, ?4, ?5,
?6, ?7,
@ -172,7 +190,7 @@ impl TurnStats {
?12, ?13,
?14, ?15,
?16, ?17,
?18, ?19
?18, ?19, ?20
)",
params![
row.started_at,
@ -196,6 +214,7 @@ impl TurnStats {
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
row.result_kind,
row.note,
row.session_id,
],
);
if let Err(e) = res {
@ -203,6 +222,29 @@ impl TurnStats {
}
}
/// Mint a new session row at fresh-session start, returning its `id`
/// for stamping onto this session's `turn_stats` rows. Best-effort —
/// returns `None` (and logs) on any sqlite error, so a hiccup degrades
/// to NULL `session_id` rows rather than crashing the turn loop.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn start_session(&self, started_at: i64, model: &str) -> Option<i64> {
let conn = self.inner.lock().unwrap();
match conn.execute(
"INSERT INTO sessions (started_at, model) VALUES (?1, ?2)",
params![started_at, model],
) {
Ok(_) => Some(conn.last_insert_rowid()),
Err(e) => {
tracing::warn!(error = ?e, "turn_stats: start_session insert failed");
None
}
}
}
/// Token counts from the most recently inserted row, if any.
/// Returns `(ctx, cost)` — both backfill `Bus` on startup so the
/// per-agent web UI's ctx + cost badges paint with real numbers on