huskies: merge 1209 story Gateway lifecycle & telemetry MCP: gateway_info, restart_gateway, gateway_logs, start_story, chat_telemetry

This commit is contained in:
Huskies Agent
2026-07-18 02:21:05 +00:00
parent 3b10b29ef5
commit 8a7bff71aa
14 changed files with 584 additions and 4 deletions
+130
View File
@@ -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"
);
}
}
+3
View File
@@ -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.
+31
View File
@@ -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 {
+3
View File
@@ -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.