feat(hive-claude): parse turn telemetry in the lib, return it from run

This commit is contained in:
müde 2026-07-05 20:06:30 +02:00
commit 487e62a9ca
7 changed files with 260 additions and 227 deletions

View file

@ -4,7 +4,7 @@ use std::sync::Mutex;
use serde_json::Value;
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Usage, usage};
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Telemetry};
/// A named claude session that outlives the model's context window by
/// compacting itself. Bundles the three things a durable session needs:
@ -32,12 +32,16 @@ pub struct InfiniteSession<P: CompactionPolicy> {
}
/// What [`InfiniteSession::run`] did, beyond streaming the turn to the sink.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Progress {
/// The turn resumed no existing session — a fresh one was created.
pub created: bool,
/// A compaction ran (reactively on overflow, or proactively per policy).
pub compacted: bool,
/// Everything parsed from the answering turn's stream (usage, cost,
/// context window, resolved model). The authoritative copy — finalised at
/// the turn's `result` event.
pub telemetry: Telemetry,
}
impl<P: CompactionPolicy> InfiniteSession<P> {
@ -60,26 +64,30 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
/// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate
/// unchanged for the caller to handle.
pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> {
let meter = UsageSink::new(sink);
let meter = TelemetrySink::new(sink);
let created = match self.attempt(config, prompt, &meter).await {
Ok(created) => created,
Err(Error::PromptTooLong) => {
// The session is already past the window — no turn can run on
// it and the detail is gone (no checkpoint possible). Compact,
// then retry the same prompt once.
// then retry the same prompt once; the retry is the answering
// turn, so its telemetry is what we report.
self.compact(config, sink).await?;
let created = self.attempt(config, prompt, sink).await?;
let retry = TelemetrySink::new(sink);
let created = self.attempt(config, prompt, &retry).await?;
return Ok(Progress {
created,
compacted: true,
telemetry: retry.snapshot(),
});
}
Err(other) => return Err(other),
};
let telemetry = meter.snapshot();
// Proactive: the turn completed on a healthy session. If the policy
// says it's due, checkpoint (best-effort) then compact.
if self.policy.should_compact(meter.snapshot()) {
if self.policy.should_compact(telemetry.usage()) {
if let Some(checkpoint) = self.policy.checkpoint_prompt() {
let _ = self.attempt(config, checkpoint, sink).await;
}
@ -87,11 +95,13 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
return Ok(Progress {
created,
compacted: true,
telemetry,
});
}
Ok(Progress {
created,
compacted: false,
telemetry,
})
}
@ -133,35 +143,30 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
}
}
/// A [`Sink`] that forwards to an inner sink while accumulating the minimal
/// [`Usage`] the policy needs from the stream. Cheap; the driver calls it
/// synchronously from one reader task, so the `Mutex` only satisfies `&self`.
struct UsageSink<'a, S: Sink> {
/// A [`Sink`] that forwards to an inner sink while accumulating the turn's
/// [`Telemetry`] from the stream. Cheap; the driver calls it synchronously
/// from one reader task, so the `Mutex` only satisfies `&self`.
struct TelemetrySink<'a, S: Sink> {
inner: &'a S,
usage: Mutex<Usage>,
telemetry: Mutex<Telemetry>,
}
impl<'a, S: Sink> UsageSink<'a, S> {
impl<'a, S: Sink> TelemetrySink<'a, S> {
fn new(inner: &'a S) -> Self {
Self {
inner,
usage: Mutex::new(Usage::default()),
telemetry: Mutex::new(Telemetry::default()),
}
}
fn snapshot(&self) -> Usage {
*self.usage.lock().unwrap()
fn snapshot(&self) -> Telemetry {
self.telemetry.lock().unwrap().clone()
}
}
impl<S: Sink> Sink for UsageSink<'_, S> {
impl<S: Sink> Sink for TelemetrySink<'_, S> {
fn on_event(&self, event: &Value) {
if let Some(tokens) = usage::context_tokens(event) {
self.usage.lock().unwrap().context_tokens = tokens;
}
if let Some(window) = usage::context_window(event) {
self.usage.lock().unwrap().context_window = Some(window);
}
self.telemetry.lock().unwrap().observe(event);
self.inner.on_event(event);
}