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
|
|
@ -12,6 +12,7 @@ use anyhow::Result;
|
|||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::{TurnStatRow, TurnStats};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, turn, web_ui};
|
||||
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ async fn main() -> Result<()> {
|
|||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot");
|
||||
let login_state = Arc::new(Mutex::new(initial));
|
||||
let bus = Bus::new();
|
||||
let stats = TurnStats::open_default();
|
||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
|
||||
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
plugins::install_configured(&cli.socket, None).await;
|
||||
|
|
@ -80,6 +82,7 @@ async fn main() -> Result<()> {
|
|||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
)
|
||||
|
|
@ -91,6 +94,7 @@ async fn main() -> Result<()> {
|
|||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
)
|
||||
|
|
@ -106,6 +110,7 @@ async fn serve(
|
|||
socket: &Path,
|
||||
interval: Duration,
|
||||
bus: Bus,
|
||||
stats: Option<TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
|
|
@ -152,12 +157,34 @@ async fn serve(
|
|||
});
|
||||
let prompt = format_wake_prompt(&from, &body, unread);
|
||||
bus.set_state(TurnState::Thinking);
|
||||
let started_at = now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let outcome = {
|
||||
let _guard = turn_lock.lock().await;
|
||||
turn::drive_turn(&prompt, files, &bus).await
|
||||
};
|
||||
turn::emit_turn_end(&bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
if let Some(s) = &stats {
|
||||
let ended_at = now_unix();
|
||||
let duration_ms =
|
||||
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let (open_threads, open_reminders) =
|
||||
fetch_manager_post_turn_counts(socket).await;
|
||||
let row = build_row(
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model_at_start,
|
||||
from.clone(),
|
||||
&outcome,
|
||||
&bus,
|
||||
open_threads,
|
||||
open_reminders,
|
||||
);
|
||||
s.record(&row);
|
||||
}
|
||||
// Check for messages that arrived during the turn and loop
|
||||
// immediately if any are waiting — mirrors hive-ag3nt behaviour.
|
||||
let pending = inbox_unread(socket).await;
|
||||
|
|
@ -176,7 +203,8 @@ async fn serve(
|
|||
| ManagerResponse::QuestionQueued { .. }
|
||||
| ManagerResponse::Recent { .. }
|
||||
| ManagerResponse::Logs { .. }
|
||||
| ManagerResponse::OpenThreads { .. },
|
||||
| ManagerResponse::OpenThreads { .. }
|
||||
| ManagerResponse::PendingRemindersCount { .. },
|
||||
) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
|
|
@ -211,3 +239,80 @@ async fn inbox_unread(socket: &Path) -> u64 {
|
|||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Manager-flavour equivalent of the agent helper. Mirror shape, just
|
||||
/// uses ManagerRequest/ManagerResponse instead of the agent variants.
|
||||
async fn fetch_manager_post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||
let threads = match client::request::<_, ManagerResponse>(
|
||||
socket,
|
||||
&ManagerRequest::GetOpenThreads,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ManagerResponse::OpenThreads { threads }) => u64::try_from(threads.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, ManagerResponse>(
|
||||
socket,
|
||||
&ManagerRequest::CountPendingReminders,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ManagerResponse::PendingRemindersCount { count }) => Some(count),
|
||||
_ => None,
|
||||
};
|
||||
(threads, reminders)
|
||||
}
|
||||
|
||||
/// Manager flavour of the agent's build_row helper. Duplicated rather
|
||||
/// than shared to keep each bin self-contained at this size.
|
||||
fn build_row(
|
||||
started_at: i64,
|
||||
ended_at: i64,
|
||||
duration_ms: i64,
|
||||
model: String,
|
||||
wake_from: String,
|
||||
outcome: &turn::TurnOutcome,
|
||||
bus: &Bus,
|
||||
open_threads_count: Option<u64>,
|
||||
open_reminders_count: Option<u64>,
|
||||
) -> TurnStatRow {
|
||||
let usage = bus.last_usage().unwrap_or_default();
|
||||
let tool_calls = bus.take_tool_calls();
|
||||
let tool_call_count: u64 = tool_calls.values().copied().sum();
|
||||
let tool_call_breakdown_json = if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::to_string(&tool_calls).ok()
|
||||
};
|
||||
let (result_kind, note) = match outcome {
|
||||
turn::TurnOutcome::Ok => ("ok", None),
|
||||
turn::TurnOutcome::PromptTooLong => ("prompt_too_long", None),
|
||||
turn::TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),
|
||||
};
|
||||
TurnStatRow {
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model,
|
||||
wake_from,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
||||
tool_call_count,
|
||||
tool_call_breakdown_json,
|
||||
open_threads_count,
|
||||
open_reminders_count,
|
||||
result_kind,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue