turn_stats: per-turn analytics sink
new sqlite table at /state/hyperhive-turn-stats.sqlite on each agent's state dir. one row per claude turn captures identity (model, wake_from, result_kind), timing (started/ended_at, duration_ms), cost (input/output/cache_read/cache_creation token counts), behaviour (tool_call_count + per-tool breakdown JSON), and post-turn snapshot metrics (open_threads_count, open_reminders_count). wire additions: - AgentRequest/ManagerRequest::CountPendingReminders + Broker::count_pending_reminders_for(agent) - Bus::observe_stream + take_tool_calls — pumps the existing stdout stream-json, picks out tool_use blocks, accumulates per turn. bin loops fold the breakdown into each row. - TurnStats::open_default + TurnStatRow + record() — best-effort inserts; failures log + don't block the harness. both ag3nt and m1nd bins capture started_at + duration via Instant::elapsed, fetch open-thread + reminder counts from hive-c0re via the existing socket (post-turn, best-effort), and record one row at turn_end. record_kind splits ok / failed / prompt_too_long; failures carry the error message in note. todo entries for host-side vacuum sweep + reading the table back into agent/dashboard badges.
This commit is contained in:
parent
dc1ce1f236
commit
8f5752980f
12 changed files with 476 additions and 3 deletions
163
hive-ag3nt/src/turn_stats.rs
Normal file
163
hive-ag3nt/src/turn_stats.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! Per-turn analytics sink. One sqlite row per claude turn captures:
|
||||
//! identity (model, wake_from, result_kind), timing (started_at,
|
||||
//! ended_at, duration_ms), cost (token counts), behaviour (tool-call
|
||||
//! count + per-tool breakdown), and post-turn snapshot metrics
|
||||
//! (open_threads_count, open_reminders_count).
|
||||
//!
|
||||
//! Lives next to `hyperhive-events.sqlite` in the agent's state dir
|
||||
//! so the host-side state vacuum sweep can reach both. Schema is
|
||||
//! intentionally append-only — every column has a default so future
|
||||
//! additions don't break old readers; new columns land via
|
||||
//! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration
|
||||
//! block.
|
||||
//!
|
||||
//! Writes are best-effort: a failed insert logs a warning and lets
|
||||
//! the turn loop continue. The next turn either succeeds or the
|
||||
//! operator sees the journal trail.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
/// SQL bootstrap. CREATE TABLE IF NOT EXISTS so first-boot agents
|
||||
/// and existing ones converge on the same shape; ALTER-style
|
||||
/// migrations land here as additional statements once we have any.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
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 IF NOT EXISTS idx_turn_stats_started
|
||||
ON turn_stats (started_at DESC);
|
||||
";
|
||||
|
||||
/// One row to be inserted. `Option`-wrapped fields default to NULL
|
||||
/// when the harness couldn't gather them (e.g. socket roundtrip for
|
||||
/// open_threads failed) so a partial row beats no row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnStatRow {
|
||||
pub started_at: i64,
|
||||
pub ended_at: i64,
|
||||
pub duration_ms: i64,
|
||||
pub model: String,
|
||||
pub wake_from: String,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
pub tool_call_count: u64,
|
||||
/// Per-tool breakdown as JSON: `{"Read":12,"Bash":3,...}`. None
|
||||
/// when no tools were called (saves a sqlite write of `"{}"`).
|
||||
pub tool_call_breakdown_json: Option<String>,
|
||||
pub open_threads_count: Option<u64>,
|
||||
pub open_reminders_count: Option<u64>,
|
||||
/// `"ok" | "failed" | "prompt_too_long"`.
|
||||
pub result_kind: &'static str,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Thin sqlite wrapper. Cloning is cheap (Arc-shared connection).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnStats {
|
||||
inner: std::sync::Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl TurnStats {
|
||||
/// Open the per-agent stats db, creating the file + schema if
|
||||
/// missing. Returns `None` when the db can't be opened (read-only
|
||||
/// fs in tests, missing state dir) — the harness logs and
|
||||
/// continues without a sink rather than failing the turn loop.
|
||||
#[must_use]
|
||||
pub fn open_default() -> Option<Self> {
|
||||
let path = default_path();
|
||||
match Self::open(&path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
path = %path.display(),
|
||||
"turn_stats: open failed; per-turn analytics disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("open turn_stats db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply turn_stats schema")?;
|
||||
Ok(Self {
|
||||
inner: std::sync::Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a row. Best-effort — logs + swallows errors so a sqlite
|
||||
/// hiccup (locked db, full disk) doesn't crash the harness.
|
||||
pub fn record(&self, row: &TurnStatRow) {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO turn_stats (
|
||||
started_at, ended_at, duration_ms, model, wake_from,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
tool_call_count, tool_call_breakdown_json,
|
||||
open_threads_count, open_reminders_count,
|
||||
result_kind, note
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5,
|
||||
?6, ?7,
|
||||
?8, ?9,
|
||||
?10, ?11,
|
||||
?12, ?13,
|
||||
?14, ?15
|
||||
)",
|
||||
params![
|
||||
row.started_at,
|
||||
row.ended_at,
|
||||
row.duration_ms,
|
||||
row.model,
|
||||
row.wake_from,
|
||||
i64::try_from(row.input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.output_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_read_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_creation_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.tool_call_count).unwrap_or(i64::MAX),
|
||||
row.tool_call_breakdown_json,
|
||||
row.open_threads_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.open_reminders_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.result_kind,
|
||||
row.note,
|
||||
],
|
||||
);
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "turn_stats: insert failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_path() -> PathBuf {
|
||||
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
|
||||
}
|
||||
Loading…
Reference in a new issue