feat(#2608): show session count in agent stats window
Adds a 'sessions' chip to the per-agent stats summary panel showing how many fresh claude sessions started within the selected time window. Backend (hive-agent/src/stats.rs): - New optional field `session_count: Option<u64>` on `Snapshot` (skip_serializing_if = None — inert-until-data, same pattern as first_turn_ctx). Counts rows in the `sessions` table whose started_at falls within the window; returns None when the table doesn't exist on an older db. - New `read_session_count(conn, from)` helper (rusqlite::Result so the caller maps Err to None). - Extracted per-row accumulation loop into `TurnAccum` struct + `push()` method to keep `snapshot()` under the too_many_lines limit. Frontend (frontend/packages/agent/src/stats.js): - New 'sessions' chip added to renderSummary, guarded by `typeof s.session_count === 'number'`, placed before the existing first-turn-ctx chip.
This commit is contained in:
parent
b25c44f7cb
commit
35611e9f0d
2 changed files with 95 additions and 53 deletions
|
|
@ -123,6 +123,12 @@ window.Chart = Chart;
|
|||
['reminders pending', fmtInt(s.reminder_stats.pending)],
|
||||
);
|
||||
}
|
||||
// Session count: fresh claude sessions started in the window (each
|
||||
// new-session or auto-compaction-fallback mints one). Omitted until
|
||||
// the sessions table exists (older db).
|
||||
if (typeof s.session_count === 'number') {
|
||||
chips.push(['sessions', fmtInt(s.session_count)]);
|
||||
}
|
||||
// First-turn ctx: input tokens of the most recent fresh session's
|
||||
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl
|
||||
// proxy. Omitted from the JSON (and so absent here) until the
|
||||
|
|
|
|||
|
|
@ -149,6 +149,14 @@ pub struct Snapshot {
|
|||
/// pre-capture `turn_stats` row has a NULL `session_id` and is excluded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_turn_ctx: Option<u64>,
|
||||
/// Number of fresh claude sessions (i.e. rows in the `sessions` table)
|
||||
/// that started within the window. A new session is minted whenever the
|
||||
/// harness runs a turn without `--continue` (manual `/new-session`,
|
||||
/// auto-compaction fallback, or first-ever turn). `None` when the
|
||||
/// `sessions` table doesn't exist yet (older db) — same inert-until-data
|
||||
/// pattern as `first_turn_ctx`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -235,6 +243,56 @@ fn empty_snapshot(window: Window) -> Snapshot {
|
|||
duration_summary: DurationSummary::default(),
|
||||
reminder_stats: None,
|
||||
first_turn_ctx: None,
|
||||
session_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulated totals across all rows fetched for one stats window.
|
||||
/// Built by iterating `turn_stats` rows; passed to `fill_buckets` /
|
||||
/// `summarize_durations` / `top_n` to produce the final `Snapshot`.
|
||||
#[derive(Default)]
|
||||
struct TurnAccum {
|
||||
by_bucket: HashMap<i64, BucketAcc>,
|
||||
tool_totals: HashMap<String, u64>,
|
||||
wake_totals: HashMap<String, u64>,
|
||||
result_totals: HashMap<String, u64>,
|
||||
model_set: HashSet<String>,
|
||||
all_durations: Vec<i64>,
|
||||
turn_count: u64,
|
||||
}
|
||||
|
||||
impl TurnAccum {
|
||||
fn push(&mut self, r: Row, bucket_secs: i64) {
|
||||
self.turn_count += 1;
|
||||
let bucket_ts = (r.started_at / bucket_secs) * bucket_secs;
|
||||
let b = self.by_bucket.entry(bucket_ts).or_default();
|
||||
b.turn_count += 1;
|
||||
b.durations.push(r.duration_ms.max(0));
|
||||
b.input_tokens = b.input_tokens.saturating_add(r.input_tokens);
|
||||
b.output_tokens = b.output_tokens.saturating_add(r.output_tokens);
|
||||
b.cache_read_input_tokens = b
|
||||
.cache_read_input_tokens
|
||||
.saturating_add(r.cache_read_input_tokens);
|
||||
b.cache_creation_input_tokens = b
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(r.cache_creation_input_tokens);
|
||||
b.ctx_sum = b.ctx_sum.saturating_add(r.last_input_tokens);
|
||||
b.ctx_max = b.ctx_max.max(r.last_input_tokens);
|
||||
*b.model_counts.entry(r.model.clone()).or_insert(0) += 1;
|
||||
*b.result_counts.entry(r.result_kind.clone()).or_insert(0) += 1;
|
||||
|
||||
self.all_durations.push(r.duration_ms.max(0));
|
||||
*self.wake_totals.entry(r.wake_from).or_insert(0) += 1;
|
||||
*self.result_totals.entry(r.result_kind).or_insert(0) += 1;
|
||||
self.model_set.insert(r.model);
|
||||
|
||||
if let Some(json) = r.tool_breakdown_json
|
||||
&& let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&json)
|
||||
{
|
||||
for (k, v) in map {
|
||||
*self.tool_totals.entry(k).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,14 +302,12 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
|||
.with_context(|| format!("open {} read-only", path.display()))?;
|
||||
// turn_stats is rollback-journal (not WAL): a read landing while the
|
||||
// harness's own sink is mid-INSERT gets SQLITE_BUSY, which propagates up
|
||||
// and blanks the whole stats page. Wait out the brief write instead —
|
||||
// matches hive-c0re's host-side reader (`hive_stats::read_agent`).
|
||||
// and blanks the whole stats page. Wait out the brief write instead.
|
||||
conn.busy_timeout(std::time::Duration::from_millis(500))
|
||||
.with_context(|| format!("set busy_timeout on {}", path.display()))?;
|
||||
let now = now_unix();
|
||||
// Fixed windows look back a constant span; `all` starts at the earliest
|
||||
// recorded turn (`MIN(started_at)`, falling back to `now` on an empty
|
||||
// table) and sizes its buckets adaptively from that span.
|
||||
// recorded turn and sizes its buckets adaptively from that span.
|
||||
let (from, bucket_secs) = match window {
|
||||
Window::All => {
|
||||
let min_ts: Option<i64> =
|
||||
|
|
@ -291,51 +347,14 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
|||
})
|
||||
})?;
|
||||
|
||||
let mut by_bucket: HashMap<i64, BucketAcc> = HashMap::new();
|
||||
let mut tool_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut wake_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut result_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut model_set: HashSet<String> = HashSet::new();
|
||||
let mut all_durations: Vec<i64> = Vec::new();
|
||||
let mut turn_count: u64 = 0;
|
||||
|
||||
let mut acc = TurnAccum::default();
|
||||
for r in rows {
|
||||
let r = r?;
|
||||
turn_count += 1;
|
||||
let bucket_ts = (r.started_at / bucket_secs) * bucket_secs;
|
||||
let acc = by_bucket.entry(bucket_ts).or_default();
|
||||
acc.turn_count += 1;
|
||||
acc.durations.push(r.duration_ms.max(0));
|
||||
acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens);
|
||||
acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens);
|
||||
acc.cache_read_input_tokens = acc
|
||||
.cache_read_input_tokens
|
||||
.saturating_add(r.cache_read_input_tokens);
|
||||
acc.cache_creation_input_tokens = acc
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(r.cache_creation_input_tokens);
|
||||
acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens);
|
||||
acc.ctx_max = acc.ctx_max.max(r.last_input_tokens);
|
||||
*acc.model_counts.entry(r.model.clone()).or_insert(0) += 1;
|
||||
*acc.result_counts.entry(r.result_kind.clone()).or_insert(0) += 1;
|
||||
|
||||
all_durations.push(r.duration_ms.max(0));
|
||||
*wake_totals.entry(r.wake_from).or_insert(0) += 1;
|
||||
*result_totals.entry(r.result_kind).or_insert(0) += 1;
|
||||
model_set.insert(r.model);
|
||||
|
||||
if let Some(json) = r.tool_breakdown_json
|
||||
&& let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&json)
|
||||
{
|
||||
for (k, v) in map {
|
||||
*tool_totals.entry(k).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
acc.push(r?, bucket_secs);
|
||||
}
|
||||
|
||||
let buckets = fill_buckets(from, now, bucket_secs, &by_bucket);
|
||||
let duration_summary = summarize_durations(&mut all_durations);
|
||||
let mut models: Vec<String> = model_set.into_iter().collect();
|
||||
let buckets = fill_buckets(from, now, bucket_secs, &acc.by_bucket);
|
||||
let duration_summary = summarize_durations(&mut acc.all_durations);
|
||||
let mut models: Vec<String> = acc.model_set.into_iter().collect();
|
||||
models.sort_unstable();
|
||||
|
||||
Ok(Snapshot {
|
||||
|
|
@ -343,19 +362,19 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
|||
bucket_seconds: bucket_secs,
|
||||
now,
|
||||
from,
|
||||
turn_count,
|
||||
turn_count: acc.turn_count,
|
||||
buckets,
|
||||
tool_breakdown: top_n(tool_totals, 10),
|
||||
tool_breakdown: top_n(acc.tool_totals, 10),
|
||||
bash_breakdown: read_bash_breakdown(&conn, from).unwrap_or_default(),
|
||||
wake_mix: top_n(wake_totals, 20),
|
||||
result_mix: top_n(result_totals, 20),
|
||||
wake_mix: top_n(acc.wake_totals, 20),
|
||||
result_mix: top_n(acc.result_totals, 20),
|
||||
models,
|
||||
duration_summary,
|
||||
reminder_stats: None, // filled in by api_stats in web_ui.rs via fetch_reminder_stats RPC
|
||||
// Inert-until-capture: `.ok()` maps both "no fresh session in the
|
||||
// window yet" (QueryReturnedNoRows) and "sessions table absent on
|
||||
// an older db" (Err) to None, same decoupling as read_bash_breakdown.
|
||||
// Inert-until-capture: `.ok()` maps both "no sessions in the window
|
||||
// yet" and "sessions table absent on an older db" to None.
|
||||
first_turn_ctx: read_first_turn_ctx(&conn, from).ok(),
|
||||
session_count: read_session_count(&conn, from).ok(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +401,23 @@ fn read_first_turn_ctx(conn: &Connection, from: i64) -> rusqlite::Result<u64> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Count of fresh claude sessions that started within `[from, now]`.
|
||||
///
|
||||
/// Each row in the `sessions` table represents one fresh `claude --print`
|
||||
/// invocation (without `--continue`): a manual `/new-session`, an
|
||||
/// auto-compaction fallback, or the very first turn after spawning.
|
||||
///
|
||||
/// Returns `Err` when the `sessions` table doesn't exist (older db) so the
|
||||
/// caller can map to `None` — same inert-until-data pattern as
|
||||
/// [`read_first_turn_ctx`].
|
||||
fn read_session_count(conn: &Connection, from: i64) -> rusqlite::Result<u64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE started_at >= ?1",
|
||||
[from],
|
||||
|row| row.get::<_, i64>(0).map(u64_from_i64),
|
||||
)
|
||||
}
|
||||
|
||||
/// Aggregate the top shell-command heads ("favorite tools") over
|
||||
/// `[from, now]` from the `bash_commands` table — one row per bash task
|
||||
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
|
||||
|
|
|
|||
Loading…
Reference in a new issue