feat(hive-claude): add InfiniteSession (name+store+compaction policy)

This commit is contained in:
müde 2026-07-05 19:38:16 +02:00
commit 80d819e444
8 changed files with 501 additions and 283 deletions

175
hive-claude/src/session.rs Normal file
View file

@ -0,0 +1,175 @@
//! A durable, self-compacting ("infinite") claude session.
use std::sync::Mutex;
use serde_json::Value;
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Usage, usage};
/// A named claude session that outlives the model's context window by
/// compacting itself. Bundles the three things a durable session needs:
///
/// - a **name** (the constant session title it resumes / creates under),
/// - a **store** ([`SessionStore`], to find the backing file so a resume vs.
/// create is decided without a wasted spawn), and
/// - a **policy** ([`CompactionPolicy`], deciding *when* to compact).
///
/// [`InfiniteSession::run`] keeps the session alive across turns:
///
/// - **resume-or-create** — resumes the titled session, creating it on first
/// use (or after its file was archived away);
/// - **reactive** — if a turn overflows ([`Error::PromptTooLong`]), it compacts
/// and retries the same prompt once;
/// - **proactive** — after a clean turn it consults the policy and, if due,
/// runs an optional checkpoint turn then compacts.
///
/// Resetting/archiving the session is intentionally *not* part of this type —
/// that stays with the caller.
pub struct InfiniteSession<P: CompactionPolicy> {
name: String,
store: SessionStore,
policy: P,
}
/// What [`InfiniteSession::run`] did, beyond streaming the turn to the sink.
#[derive(Debug, Clone, Copy, 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,
}
impl<P: CompactionPolicy> InfiniteSession<P> {
/// Build a durable session for `name`, backed by `store`, governed by
/// `policy`.
pub fn new(name: impl Into<String>, store: SessionStore, policy: P) -> Self {
Self {
name: name.into(),
store,
policy,
}
}
/// Run one turn, keeping the session infinite (see the type docs).
///
/// # Errors
///
/// Propagates any non-`SessionNotFound` [`Error`] from the underlying
/// runs. A reactive retry that *still* overflows surfaces as
/// [`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 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.
self.compact(config, sink).await?;
let created = self.attempt(config, prompt, sink).await?;
return Ok(Progress {
created,
compacted: true,
});
}
Err(other) => return Err(other),
};
// 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 let Some(checkpoint) = self.policy.checkpoint_prompt() {
let _ = self.attempt(config, checkpoint, sink).await;
}
let _ = self.compact(config, sink).await;
return Ok(Progress {
created,
compacted: true,
});
}
Ok(Progress {
created,
compacted: false,
})
}
/// Force a `/compact` on the session (e.g. operator-driven). Resume-only:
/// if the session doesn't exist there is nothing to compact, so a
/// [`Error::SessionNotFound`] is swallowed as a no-op `Ok`.
///
/// # Errors
///
/// Propagates any error other than `SessionNotFound` from the compact run.
pub async fn compact(&self, config: &Config, sink: &impl Sink) -> Result<()> {
match Claude::run(config, &Attach::Resume(self.name.clone()), "/compact", sink).await {
Err(Error::SessionNotFound) => Ok(()),
other => other,
}
}
/// One resume-or-create turn. Uses the store to pick resume vs. create up
/// front (avoiding a wasted resume-miss spawn), and still self-heals if the
/// backing file vanished between the check and the run. Returns whether a
/// fresh session was created.
async fn attempt(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<bool> {
let exists = self.store.find_by_title(&self.name).is_some();
let attach = if exists {
Attach::Resume(self.name.clone())
} else {
Attach::Create(self.name.clone())
};
match Claude::run(config, &attach, prompt, sink).await {
Ok(()) => Ok(!exists),
// We thought it existed but the resume missed (raced an archive) —
// self-heal by creating.
Err(Error::SessionNotFound) if exists => {
Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?;
Ok(true)
}
Err(other) => Err(other),
}
}
}
/// 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> {
inner: &'a S,
usage: Mutex<Usage>,
}
impl<'a, S: Sink> UsageSink<'a, S> {
fn new(inner: &'a S) -> Self {
Self {
inner,
usage: Mutex::new(Usage::default()),
}
}
fn snapshot(&self) -> Usage {
*self.usage.lock().unwrap()
}
}
impl<S: Sink> Sink for UsageSink<'_, 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.inner.on_event(event);
}
fn on_stdout_line(&self, line: &str) {
self.inner.on_stdout_line(line);
}
fn on_stderr_line(&self, line: &str) {
self.inner.on_stderr_line(line);
}
}