huskies: merge 1209 story Gateway lifecycle & telemetry MCP: gateway_info, restart_gateway, gateway_logs, start_story, chat_telemetry
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
//! Chat turn telemetry — bounded in-memory ring buffer of per-turn
|
||||
//! duration/ttft/cache/cost metrics for the Claude Code chat provider.
|
||||
//!
|
||||
//! Populated by `llm::chat::run::chat()` on every completed Claude Code
|
||||
//! turn and surfaced via the `chat_telemetry` MCP tool so callers don't
|
||||
//! have to scrape `[pty-debug]` log lines to answer "how slow/expensive
|
||||
//! was that turn". Only the Claude Code provider path is instrumented —
|
||||
//! the Anthropic/Ollama tool-loop path in the same `chat()` function does
|
||||
//! not currently surface a comparable usage struct from its `chat_stream`
|
||||
//! return value.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Maximum number of recent turns retained in the ring buffer.
|
||||
const CAPACITY: usize = 200;
|
||||
|
||||
/// Timing and token/cost metrics for one completed chat turn.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ChatTurnTelemetry {
|
||||
/// ISO 8601 UTC timestamp when the turn completed.
|
||||
pub timestamp: String,
|
||||
/// Persona name the turn was run against (e.g. `"timmy"`).
|
||||
pub persona: String,
|
||||
/// Wall-clock time from turn start to completion, in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
/// Wall-clock time from turn start to the first streamed token, in
|
||||
/// milliseconds. `None` if no token was ever streamed.
|
||||
pub ttft_ms: Option<u64>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
}
|
||||
|
||||
static BUFFER: OnceLock<Mutex<VecDeque<ChatTurnTelemetry>>> = OnceLock::new();
|
||||
|
||||
fn buffer() -> &'static Mutex<VecDeque<ChatTurnTelemetry>> {
|
||||
BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY)))
|
||||
}
|
||||
|
||||
/// Record a completed chat turn, evicting the oldest entry when at capacity.
|
||||
pub fn record(entry: ChatTurnTelemetry) {
|
||||
if let Ok(mut buf) = buffer().lock() {
|
||||
if buf.len() >= CAPACITY {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return up to `count` most recent turns, oldest first.
|
||||
pub fn recent(count: usize) -> Vec<ChatTurnTelemetry> {
|
||||
let buf = match buffer().lock() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let start = buf.len().saturating_sub(count);
|
||||
buf.iter().skip(start).cloned().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(persona: &str) -> ChatTurnTelemetry {
|
||||
ChatTurnTelemetry {
|
||||
timestamp: "2026-01-01T00:00:00Z".to_string(),
|
||||
persona: persona.to_string(),
|
||||
duration_ms: 100,
|
||||
ttft_ms: Some(50),
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_recent_round_trip() {
|
||||
// Use unique personas so this test is independent of the shared
|
||||
// global buffer's contents from other tests running concurrently.
|
||||
let marker = "test_round_trip_marker";
|
||||
record(sample(marker));
|
||||
let turns = recent(1000);
|
||||
assert!(turns.iter().any(|t| t.persona == marker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_returns_most_recent_last() {
|
||||
let marker_a = "test_order_marker_a";
|
||||
let marker_b = "test_order_marker_b";
|
||||
record(sample(marker_a));
|
||||
record(sample(marker_b));
|
||||
let turns = recent(1000);
|
||||
let pos_a = turns.iter().position(|t| t.persona == marker_a);
|
||||
let pos_b = turns.iter().position(|t| t.persona == marker_b);
|
||||
if let (Some(a), Some(b)) = (pos_a, pos_b) {
|
||||
assert!(a < b, "marker_a must have been recorded before marker_b");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_respects_count_limit() {
|
||||
for _ in 0..5 {
|
||||
record(sample("test_limit_marker"));
|
||||
}
|
||||
let turns = recent(2);
|
||||
assert_eq!(turns.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_evicts_oldest_past_capacity() {
|
||||
for i in 0..(CAPACITY + 10) {
|
||||
record(sample(&format!("test_evict_marker_{i}")));
|
||||
}
|
||||
let turns = recent(CAPACITY + 10);
|
||||
assert!(
|
||||
turns.len() <= CAPACITY,
|
||||
"buffer must never exceed CAPACITY entries, got {}",
|
||||
turns.len()
|
||||
);
|
||||
assert!(
|
||||
!turns.iter().any(|t| t.persona == "test_evict_marker_0"),
|
||||
"oldest entry must have been evicted"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ pub fn read_bot_config_raw(config_dir: &Path) -> BotConfigFields {
|
||||
password: s("password"),
|
||||
slack_bot_token: s("slack_bot_token"),
|
||||
slack_signing_secret: s("slack_signing_secret"),
|
||||
model: s("model"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +92,8 @@ pub struct BotConfigFields {
|
||||
pub password: Option<String>,
|
||||
pub slack_bot_token: Option<String>,
|
||||
pub slack_signing_secret: Option<String>,
|
||||
/// Claude Code model override configured for this bot (`gateway_info` MCP tool, story 1209).
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// Write a `bot.toml` from the given content string.
|
||||
|
||||
@@ -38,13 +38,32 @@ use io::Client;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub use crate::crdt_state::NodePresenceView;
|
||||
|
||||
// ── Uptime (gateway_info MCP tool, story 1209) ──────────────────────────────
|
||||
|
||||
/// Instant the gateway process started, set once by `GatewayState::new`.
|
||||
static GATEWAY_START_TIME: OnceLock<Instant> = OnceLock::new();
|
||||
|
||||
/// Seconds elapsed since the gateway process started.
|
||||
///
|
||||
/// Lazily initialises the start time on first call so this never panics, but
|
||||
/// `GatewayState::new` calls it once at startup so the timer reflects actual
|
||||
/// process start rather than first `gateway_info` call in normal operation.
|
||||
pub fn gateway_uptime_secs() -> u64 {
|
||||
GATEWAY_START_TIME
|
||||
.get_or_init(Instant::now)
|
||||
.elapsed()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
// ── Status event broadcaster ────────────────────────────────────────────────
|
||||
|
||||
/// Capacity of the gateway status event broadcast channel.
|
||||
@@ -272,6 +291,7 @@ impl GatewayState {
|
||||
config_dir: PathBuf,
|
||||
port: u16,
|
||||
) -> Result<Self, String> {
|
||||
GATEWAY_START_TIME.get_or_init(Instant::now);
|
||||
let first_from_config = config::validate_config(&gateway_config)?;
|
||||
// Restore active project from CRDT if the stored value is still valid.
|
||||
let first = crate::crdt_state::read_gateway_active_project()
|
||||
@@ -838,6 +858,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_uptime_secs_is_zero_or_positive_immediately_after_start() {
|
||||
// Just ensure it doesn't panic and returns a sane (small) value —
|
||||
// the static is process-global so we can't assert it's exactly 0.
|
||||
let uptime = gateway_uptime_secs();
|
||||
assert!(
|
||||
uptime < 3600,
|
||||
"uptime should be small in a fresh test run, got {uptime}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_state_rejects_empty_config() {
|
||||
let config = GatewayConfig {
|
||||
|
||||
@@ -11,6 +11,9 @@ pub mod agents;
|
||||
pub mod anthropic;
|
||||
/// Bot command dispatch — parses and executes slash commands.
|
||||
pub mod bot_command;
|
||||
/// Chat turn telemetry — bounded in-memory ring buffer of per-turn
|
||||
/// duration/ttft/cache/cost metrics for the Claude Code chat provider.
|
||||
pub mod chat_telemetry;
|
||||
/// Shared pure helpers used across service modules.
|
||||
pub mod common;
|
||||
/// Diagnostics — server logs, CRDT dump, and permission management.
|
||||
|
||||
Reference in New Issue
Block a user